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()
|