CIS6270 / lecture_5 /gumbel.py
pranamanam's picture
Upload 87 files
0ba9d09 verified
Raw History Blame Contribute Delete
12 kB
"""Gumbel-Softmax Flow Matching: the path comes from the way a token is sampled.
Sampling a categorical is a deterministic function of noise. We add a Gumbel draw to each log
probability and take the largest. If we replace the argmax by a softmax, the winner becomes a point
of the simplex, and cooling the temperature slides that point from the center toward a corner. With
the noise held fixed the trajectory is a smooth curve, and its time derivative is the replicator
equation of evolutionary dynamics. At inference we set the noise to zero, and the velocity
collapses to a multiple of the direction toward the data corner.
Chapter 5 runs t = 0 at the source and t = 1 at the data, and writes the probability velocity
u_t(y, x) for the same quantity Chapter 4 writes as the rate R_t(x, y).
Chapter 5, Sections 5.22.1 to 5.22.6. Trained on the synthetic DNA of ``cis6270.data``. Prints the
Gumbel-max frequencies, the annealed path at four times, the replicator velocity, the marginal
velocity late in a trajectory, and the measurements of a generated batch.
python lecture_5/gumbel.py # train, then 64 Euler steps on the simplex
python lecture_5/gumbel.py --steps 20 --quiet # a few seconds, for a first look
"""
# %% 01. Imports and configuration
import math
import torch
import torch.nn as nn
import torch.nn.functional as F
from cis6270.data import DNA_SIZE, decode_dna, dna_splits
from cis6270.metrics import gc_fraction, motif_fraction, token_entropy, unique_fraction
from cis6270.nets import SequenceDenoiser, count_parameters
from cis6270.runner import common_parser, print_report, save_run, set_seed, train
TAU_MAX = 10.0 # The temperature at t = 0, hot enough that every letter holds nearly the same mass.
LAMBDA = 3.0 # The cooling rate, so tau(1) = 10 exp(-3) = 0.498.
BETA = 2.0 # The noise scale; a larger beta makes the paths from one data token spread less.
# %% 02. The Gumbel-max trick
#
# A Gumbel variable is the negative logarithm of an exponential clock, so the trick is Chapter 4's
# race between clocks written with logarithms, where the winner is an argmax and the rates are
# logits. All of the randomness sits in the noise, and the logits are ours to move.
def gumbel(shape, generator=None):
"""g ~ Gumbel(0, 1), as minus the log of minus the log of a uniform draw."""
u = torch.rand(shape, generator=generator).clamp(min=1e-12, max=1 - 1e-12)
return -torch.log(-torch.log(u)) # The distribution function is exp(-exp(-x)).
def gumbel_max(probabilities, draws, generator=None):
"""Frequencies of argmax_k [log pi_k + g_k] over ``draws`` samples. Returns [K]."""
logits = probabilities.log()[None, :] + gumbel((draws, len(probabilities)), generator)
counts = torch.bincount(logits.argmax(-1), minlength=len(probabilities)).double()
return counts / counts.sum() # Approaches pi itself.
# %% 03. The softmax relaxation and a cooling temperature
#
# A softmax of the noisy logits divided by a temperature is a point of the simplex. As the
# temperature falls the point approaches the one-hot of the argmax, and as it grows the point
# approaches the center. The temperature never reaches zero, so the path stays in the interior and
# its density stays defined.
def temperature(t, tau_max=TAU_MAX, rate=LAMBDA):
"""tau(t) = tau_max exp(-lambda t), or 10 at t = 0 and 0.498 at t = 1."""
return tau_max * math.exp(-rate * t) # 2.231 at t = 0.5 and 0.672 at t = 0.9.
# %% 04. The annealed path toward a data token
#
# We put the data token into the logits and let the cooling do the rest. That gives the
# conditional path. The noise is drawn once and held for the whole trajectory, so only the
# temperature changes with time and the point moves continuously toward the data corner.
def annealed_path(logits, t):
"""psi_t = softmax((e_k + g/beta)/tau(t)), with the logits already assembled. Returns [..., K]."""
return torch.softmax(logits / temperature(t), dim=-1) # A point of the simplex at every time.
def path_logits(targets, noise, K=DNA_SIZE, beta=BETA):
"""e_k + g/beta at every position. targets: [B, L], noise: [B, L, K] -> [B, L, K]."""
return F.one_hot(targets, K) + noise / beta # The data indicator plus the scaled Gumbel draw.
# %% 05. Differentiating the path: the replicator equation
#
# The only time dependence sits in one over tau. It grows at relative rate lambda. If we compose
# with the softmax Jacobian we get the current mass times the gap between a letter's logit and the
# mass-weighted average of all of them, the replicator equation with fitness lambda ell_i/tau(t).
def replicator_velocity(x, logits, t, rate=LAMBDA):
"""u_{t,i} = (lambda/tau(t)) x_i (ell_i - sum_j x_j ell_j). x, logits: [..., K] -> [..., K]."""
average = (x * logits).sum(-1, keepdim=True) # The mass-weighted average of the logits.
return (rate / temperature(t)) * x * (logits - average) # Coordinates summing to zero.
# %% 06. The inference-time velocity
#
# The sampler has only the current point, so we set the noise to zero. The logits become the data
# indicator, the bracket becomes the gap to the data coordinate, and the velocity collapses to a
# multiple of the direction toward the corner, with the factor x_k(1 - x_k) in the k-th component.
def inference_velocity(x, k, t, rate=LAMBDA):
"""u_t(x | x_1 = e_k) = (lambda/tau(t)) x_k (e_k - x). x: [..., K] -> [..., K]."""
corner = F.one_hot(torch.tensor(k), x.shape[-1]).to(x) # The data corner.
return (rate / temperature(t)) * x[..., k, None] * (corner - x) # Zero once x_k reaches zero.
def marginal_velocity(x, p_hat, t, rate=LAMBDA):
"""The posterior average over corners. x, p_hat: [..., K] -> [..., K]."""
weight = (rate / temperature(t)) * x * p_hat # [..., K] the coefficient of each corner.
return weight - weight.sum(-1, keepdim=True) * x # sum_k w_k (e_k - x), collected.
# %% 07. Training and the marginal velocity
#
# Cross-entropy on the data token trains the denoiser, as it did for the mixture paths and for the
# Dirichlet path, and the conditional velocity appears nowhere in the gradient. At sampling time the
# marginal velocity is the posterior average, the fourth appearance of the same rule in this chapter.
def relaxed_denoiser(length, width, K=DNA_SIZE, depth=2):
"""The shared sequence denoiser with its lookup table replaced by a map on the relaxed token."""
model = SequenceDenoiser(K, length, width=width, depth=depth)
model.token_embedding = nn.Linear(K, width, bias=False) # It now takes a point of the simplex.
return model
@torch.no_grad()
def generate(model, batch, length, steps, K=DNA_SIZE):
"""Integrate the marginal velocity from the center of the simplex to t = 1. Returns [B, L]."""
x = annealed_path(gumbel((batch, length, K)) / BETA, 0.0) # The source, with no data indicator.
h = 1.0 / steps
for k in range(steps):
t = k * h
p_hat = model(x, torch.full((batch,), t)).softmax(-1) # [B, L, K] the denoiser.
x = x + h * marginal_velocity(x, p_hat, t) # One Euler step along the marginal velocity.
x = x.clamp(min=1e-8) # A large step can push a coordinate below zero.
x = x / x.sum(-1, keepdim=True) # Project back onto the simplex.
return x.argmax(-1) # Read the token off the largest coordinate.
# %% 08. The report
def main():
parser = common_parser(__doc__.split("\n")[0])
parser.add_argument("--draws", type=int, default=400_000, help="draws in the Gumbel-max check")
parser.set_defaults(steps=1000, width=64, samples=64)
args = parser.parse_args()
set_seed(args.seed)
pi = torch.tensor([0.5, 0.2, 0.2, 0.1]) # The categorical of the chapter's quick check.
report = {
"gumbel_max_frequencies": [round(float(v), 4) for v in gumbel_max(pi, args.draws)],
"target_probabilities": [round(float(v), 4) for v in pi], # The frequencies approach these.
"tau_at_0": round(temperature(0.0), 4), # 10.
"tau_at_1": round(temperature(1.0), 4), # 0.498.
}
# Section 5.22.3: one base annealed toward G with the chapter's fixed noise.
scaled = torch.tensor([0.0, -0.45, 0.40, -0.05]) # g/beta over A, C, G, T.
logits = F.one_hot(torch.tensor(2), DNA_SIZE) + scaled # The data indicator at G plus the noise.
report["path_logits"] = [round(float(v), 2) for v in logits] # (0, -0.45, 1.40, -0.05).
for t in (0.0, 0.5, 0.9, 1.0):
point = annealed_path(logits, t)
report[f"path_at_{t}"] = [round(float(v), 4) for v in point]
# Section 5.22.4: the replicator velocity at the midpoint.
x = annealed_path(logits, 0.5) # (0.2142, 0.1751, 0.4012, 0.2095).
report["tau_at_0.5"] = round(temperature(0.5), 4) # 2.231.
report["rate_over_tau"] = round(LAMBDA / temperature(0.5), 3) # 1.345.
report["average_logit"] = round(float((x * logits).sum()), 4) # 0.4724.
noisy = replicator_velocity(x, logits, 0.5)
report["replicator_velocity"] = [round(float(v), 3) for v in noisy] # (-0.136, -0.217, 0.5, -0.147).
report["replicator_sums_to"] = f"{float(noisy.sum()):.1e}" # Zero, so the step stays on the simplex.
# Section 5.22.5: the same point with the noise set to zero.
clean = inference_velocity(x, 2, 0.5)
report["inference_velocity"] = [round(float(v), 3) for v in clean] # (-0.116, -0.094, 0.323, ...).
# Section 5.22.6: one Euler step late in the trajectory.
late = torch.tensor([0.138, 0.084, 0.647, 0.131]) # The base at t = 0.8.
p_hat = torch.tensor([0.05, 0.10, 0.70, 0.15]) # The denoiser output there.
report["tau_at_0.8"] = round(temperature(0.8), 4) # 0.9072.
coefficients = (LAMBDA / temperature(0.8)) * late * p_hat
report["corner_coefficients"] = [round(float(v), 4) for v in coefficients] # (0.0228, ..., 1.4976).
v = marginal_velocity(late, p_hat, 0.8)
report["marginal_velocity"] = [round(float(c), 3) for c in v] # (-0.200, -0.108, 0.454, -0.146).
report["euler_step_h_0.01"] = [round(float(c), 4) for c in late + 0.01 * v] # (0.1360, ...).
# Training on the shared DNA, then generation.
splits = dna_splits(num_sequences=512, length=args.length, seed=args.seed)
data = splits["train_tokens"] # [N, L].
model = relaxed_denoiser(args.length, args.width)
def loss_fn(step):
rows = torch.randint(0, len(data), (args.batch_size,)) # One minibatch of data sequences.
x1 = data[rows] # [B, L] the letters the denoiser has to recover.
t = torch.rand(args.batch_size) # One time per sequence, uniform on [0, 1].
noise = gumbel((args.batch_size, args.length, DNA_SIZE)) # Held fixed along one trajectory.
cooled = path_logits(x1, noise) / torch.tensor(
[temperature(float(s)) for s in t])[:, None, None] # Each sequence at its own temperature.
xt = cooled.softmax(-1) # [B, L, K] a point on the conditional path.
return F.cross_entropy(model(xt, t).transpose(1, 2), x1) # -log <x_theta, x_1>.
if not args.quiet:
print(f"parameters={count_parameters(model):,} tau_max={TAU_MAX} lambda={LAMBDA}")
losses = train(model, loss_fn, steps=args.steps, lr=args.lr, quiet=args.quiet)
tokens = generate(model, args.samples, args.length, args.sample_steps)
report["final_loss_nats"] = sum(losses[-20:]) / len(losses[-20:])
report["gc_fraction"] = gc_fraction(tokens) # The training data sit at 0.577.
report["motif_fraction"] = motif_fraction(tokens) # Sequences carrying the planted ACGT.
report["unique_fraction"] = unique_fraction(tokens) # Catches a collapsed sampler.
report["token_entropy_nats"] = token_entropy(tokens, DNA_SIZE) # At most log 4 = 1.386.
print_report("Gumbel-Softmax flow matching", report, decode_dna(tokens))
save_run(args.out, model, vars(args), report, losses, decode_dna(tokens))
if __name__ == "__main__":
main()