File size: 16,973 Bytes
4c9fae2 | 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 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 | """Step 2a: the transformer, written by hand. Symbols in, next-symbol probabilities out.
Run on its own (`python model.py`) to print every weight and check that an untrained
model's loss is near ln 28 = 3.33: with random weights it knows nothing yet, so it
should guess every symbol about evenly.
How to read this file: start at `TinyTowns.forward` near the bottom. It is the whole
model in ~10 lines. Then go up into `Layer`, `Attention` and `Knowledge` for the parts.
A note on shapes. Comments like [B, T, C] give the size of a grid of numbers:
B = batch how many names we process at once (e.g. 64)
T = positions how many symbols per name (at most 21)
C = note size how many numbers in each note (16 nano, 64 small, 128 tinytowns)
So [B, T, C] means: for every name, for every position, a note of C numbers.
"""
import json
import math
import random
from dataclasses import dataclass
import torch
import torch.nn as nn
import torch.nn.functional as F
# ---------------------------------------------------------------------------
# Letters <-> numbers (same as step 1)
# ---------------------------------------------------------------------------
# The model only understands numbers, so every symbol gets an index 0..27.
# Index 0 '.' means "start of name" when it goes in, and "end of name" when it comes out.
ALPHABET = ". abcdefghijklmnopqrstuvwxyz"
stoi = {ch: i for i, ch in enumerate(ALPHABET)} # "string to int": 'a' -> 2
itos = {i: ch for i, ch in enumerate(ALPHABET)} # "int to string": 2 -> 'a'
CONTEXT = 21 # longest input the model sees: one '.' plus at most 20 letters
# ---------------------------------------------------------------------------
# Config: the only thing that differs between the two sizes
# ---------------------------------------------------------------------------
# The code below is identical for both models; only these numbers change.
@dataclass
class Config:
note: int # numbers per note (C). Bigger = each symbol can carry more information.
layers: int # how many (attention + knowledge step) layers are stacked
heads: int # attention heads per layer; each head looks for its own pattern
mlp: int # width of the knowledge step's middle (16 -> 64 -> 16 for nano)
vocab: int = len(ALPHABET) # 28 symbols
context: int = CONTEXT # 21 positions
CONFIGS = {
"tinytowns-nano": Config(note=16, layers=1, heads=1, mlp=64), # 4,572 weights
"tinytowns-small": Config(note=64, layers=2, heads=4, mlp=256), # 105,052 weights
"tinytowns": Config(note=128, layers=5, heads=4, mlp=512), # 1,001,500 weights
}
# ---------------------------------------------------------------------------
# Turning names into model input
# ---------------------------------------------------------------------------
def encode(names):
"""Names -> (inputs x, targets y), each a [batch, 21] grid of numbers.
Example, 'paris':
position: 0 1 2 3 4 5 6 ...
x (input): . p a r i s . . . <- '.' + name, padded with '.'
y (answer):p a r i s . -1 -1 -1 <- name + '.', padded with -1
Read it column by column: at position t, the model sees x[0..t] and must predict y[t].
So one name gives one prediction per letter, plus one for "end of name".
Why padding: a batch must be a neat rectangle, but names have different lengths.
The padded inputs are harmless: the mask (in Attention) stops real positions from
looking ahead at them. The padded targets are -1, which the loss is told to skip.
"""
x = torch.zeros((len(names), CONTEXT), dtype=torch.long) # all '.' to start
y = torch.full((len(names), CONTEXT), -1, dtype=torch.long) # all "skip" to start
for row, name in enumerate(names):
ids = [stoi[ch] for ch in name]
x[row, 1 : len(ids) + 1] = torch.tensor(ids, dtype=torch.long) # after the first '.'
y[row, : len(ids) + 1] = torch.tensor(ids + [0], dtype=torch.long) # name, then '.'
return x, y
# ---------------------------------------------------------------------------
# Attention: each note gathers information from the notes before it
# ---------------------------------------------------------------------------
class Attention(nn.Module):
def __init__(self, cfg):
super().__init__()
self.heads = cfg.heads
# The Q, K, V grids. nn.Linear(16, 16) is a 16x16 grid of weights plus 16 biases
# (one number added to each output). note x grid + bias = q (or k, or v).
# With several heads, all heads' grids sit side by side in one big grid;
# we split the result into per-head slices in forward().
self.q = nn.Linear(cfg.note, cfg.note) # Q grid: "what am I looking for?"
self.k = nn.Linear(cfg.note, cfg.note) # K grid: "what do I contain?"
self.v = nn.Linear(cfg.note, cfg.note) # V grid: "what do I hand over if chosen?"
# After mixing, one more grid turns the heads' results back into one note-shaped
# update. With one head it is still useful: it decides how the mix is added in.
self.out = nn.Linear(cfg.note, cfg.note)
# The mask: future[i, j] is True when position j comes after position i.
# torch.triu(..., diagonal=1) keeps only the part above the diagonal, e.g. for 4:
# . T T T row = "me", column = "the one I might look at"
# . . T T True = in my future = forbidden
# . . . T
# . . . .
future = torch.triu(torch.ones(cfg.context, cfg.context, dtype=torch.bool), diagonal=1)
# register_buffer: keep it with the model (moves to GPU with it) but it is a fixed
# table, not a weight, so training never changes it. persistent=False: don't write it
# into the weights file; it is rebuilt from the config, so the file holds only weights.
self.register_buffer("future", future, persistent=False)
def forward(self, notes):
B, T, C = notes.shape # batch, positions, numbers per note
hs = C // self.heads # numbers per head ("head size"): 16 nano, 64/4 = 16 small, 128/4 = 32 tinytowns
# 1) q, k, v for every position at once: [B, T, C] x grid -> [B, T, C].
# .view splits the last C numbers into (heads, hs); .transpose moves "heads" forward
# so each head becomes its own little [T, hs] problem: shape [B, heads, T, hs].
# With 1 head these two calls change nothing but the shape.
q = self.q(notes).view(B, T, self.heads, hs).transpose(1, 2)
k = self.k(notes).view(B, T, self.heads, hs).transpose(1, 2)
v = self.v(notes).view(B, T, self.heads, hs).transpose(1, 2)
# 2) Score: every q against every k, all in one matrix multiply.
# score[i, j] = q_i . k_j = "how relevant is position j to position i?" -> [B, heads, T, T]
# Divide by sqrt(hs): a dot product of hs numbers tends to grow with hs, and big
# scores make softmax put ~100% on one position, which makes learning slow.
score = q @ k.transpose(-2, -1) / math.sqrt(hs)
# 3) Mask: set every "future" score to minus infinity. After softmax, e^-inf = 0,
# so no position can take anything from a later one. This is what lets the model
# train on all positions at once without cheating by looking at the answer.
score = score.masked_fill(self.future[:T, :T], float("-inf"))
# 4) Shares: softmax along each row turns scores into percentages that sum to 1.
shares = F.softmax(score, dim=-1)
# 5) Mix: each position's new information = its shares x everyone's v.
# [B, heads, T, T] @ [B, heads, T, hs] -> [B, heads, T, hs]
mix = shares @ v
# 6) Put the heads back side by side ([B, T, C]) and pass through the out grid.
mix = mix.transpose(1, 2).reshape(B, T, C)
return self.out(mix)
# ---------------------------------------------------------------------------
# Knowledge step: each note is processed on its own, no looking at neighbours
# ---------------------------------------------------------------------------
class Knowledge(nn.Module):
def __init__(self, cfg):
super().__init__()
self.up = nn.Linear(cfg.note, cfg.mlp) # 16 -> 64: spread out into more room
self.down = nn.Linear(cfg.mlp, cfg.note) # 64 -> 16: squeeze back to note size
def forward(self, notes):
# GELU is a bend: roughly keeps positive numbers and squashes negative ones to ~0.
# Without it, "x grid, then x grid" is the same as one combined grid, so the extra
# 64-wide middle would add no new ability. The bend is what lets it learn rules like
# "IF the note says X AND Y, THEN push towards letter Z".
return self.down(F.gelu(self.up(notes)))
# ---------------------------------------------------------------------------
# One layer = attention, then knowledge step, each added onto the note
# ---------------------------------------------------------------------------
class Layer(nn.Module):
def __init__(self, cfg):
super().__init__()
# LayerNorm rescales each note to average 0 and spread 1, then applies a learned
# scale and shift (16 + 16 weights). It keeps numbers in a steady range so that
# training doesn't blow up or fade out as notes pass through the layers.
self.norm1 = nn.LayerNorm(cfg.note)
self.attention = Attention(cfg)
self.norm2 = nn.LayerNorm(cfg.note)
self.knowledge = Knowledge(cfg)
def forward(self, notes):
# "notes + ..." not "notes = ...": each part ADDS an update to the note instead of
# replacing it. The original information always flows through untouched, and each
# part only has to learn a correction. (Standard name: residual connection.)
notes = notes + self.attention(self.norm1(notes))
notes = notes + self.knowledge(self.norm2(notes))
return notes
# ---------------------------------------------------------------------------
# The whole model
# ---------------------------------------------------------------------------
class TinyTowns(nn.Module):
def __init__(self, cfg):
super().__init__()
self.cfg = cfg
# nn.Embedding(28, 16) is a 28-row table: give it symbol 4, get row 4 (16 numbers).
self.lookup = nn.Embedding(cfg.vocab, cfg.note) # lookup table: symbol -> note
self.position = nn.Embedding(cfg.context, cfg.note) # position table: position -> note
self.layers = nn.ModuleList(Layer(cfg) for _ in range(cfg.layers))
self.norm = nn.LayerNorm(cfg.note) # one last tidy-up before output
self.output = nn.Linear(cfg.note, cfg.vocab) # output table: note -> 28 scores
self.apply(self._small_start) # run _small_start on every part above
@staticmethod
def _small_start(m):
# Every weight starts as a small random number (around +-0.02), every bias at 0.
# Small => all 28 output scores start near 0 => softmax gives ~1/28 each =>
# the untrained loss starts near ln 28 = 3.33. Random (not all equal) => each weight
# starts slightly different, so each one gets a different nudge and they can learn
# different jobs. If they all started equal they would all stay equal forever.
if isinstance(m, (nn.Linear, nn.Embedding)):
nn.init.normal_(m.weight, mean=0.0, std=0.02)
if isinstance(m, nn.Linear):
nn.init.zeros_(m.bias)
def forward(self, x, y=None):
"""x: [B, T] symbol numbers. y: [B, T] answers, or None when we only want predictions.
Returns (scores, loss). scores is [B, T, 28]: for every name and every position,
28 raw scores for the next symbol (softmax of them = the probabilities).
"""
B, T = x.shape
# 1) Lookup + position: each symbol becomes a note, plus "where am I" information.
# torch.arange(T) = [0, 1, ..., T-1], the position numbers. -> [B, T, C]
notes = self.lookup(x) + self.position(torch.arange(T, device=x.device))
# 2) The layers: attention (look back) and knowledge step (think), repeated.
for layer in self.layers:
notes = layer(notes)
# 3) Output table: note -> 28 scores, one per possible next symbol. -> [B, T, 28]
# These raw scores are called "logits"; softmax turns them into probabilities.
scores = self.output(self.norm(notes))
if y is None:
return scores, None
# 4) Loss, exactly as in step 1: average of -ln(probability of the real next symbol).
# F.cross_entropy does softmax + -ln p + average in one go. .view(-1, 28) flattens
# [B, T, 28] into a plain list of predictions [B*T, 28], and y into [B*T] answers.
# ignore_index=-1 skips the padding positions.
loss = F.cross_entropy(scores.view(-1, self.cfg.vocab), y.view(-1), ignore_index=-1)
return scores, loss
# ---------------------------------------------------------------------------
# Weights file: safetensors (step 6)
# ---------------------------------------------------------------------------
# A .safetensors file is: 8 bytes (header length) + a JSON header (each grid's name, number type,
# shape and byte position) + all the numbers back to back. Nothing in it can run as code.
# The header may also carry "__metadata__": text only, so we store the config as JSON text.
# read_weights.py opens this format by hand, without the library.
def save_weights(model, path, **notes):
from safetensors.torch import save_file
meta = {"config": json.dumps(model.cfg.__dict__)}
meta.update({k: json.dumps(v) for k, v in notes.items()}) # e.g. best={"step": 5500, ...}
save_file(model.state_dict(), path, metadata=meta)
def load_weights(path, device="cpu"):
"""Rebuild a model from a .safetensors file. Returns (model, notes)."""
from safetensors import safe_open
from safetensors.torch import load_file
with safe_open(path, framework="pt") as f:
meta = {k: json.loads(v) for k, v in f.metadata().items()}
model = TinyTowns(Config(**meta.pop("config")))
model.load_state_dict(load_file(path)) # every grid, matched by name; errors if any is missing
return model.to(device).eval(), meta
# ---------------------------------------------------------------------------
# Inventing names: predict, pick, feed back in, repeat (as in step 1)
# ---------------------------------------------------------------------------
@torch.no_grad() # we're only predicting, not learning, so skip the gradient bookkeeping
def sample(model, n, temperature=1.0, generator=None):
"""Invent n names. temperature < 1 = safer, more typical; > 1 = wilder."""
device = next(model.parameters()).device
names = []
for _ in range(n):
ids = [0] # start with '.'
while len(ids) < CONTEXT:
scores, _ = model(torch.tensor([ids], device=device))
# Only the last position matters: it's the prediction for the next symbol.
probs = F.softmax(scores[0, -1] / temperature, dim=-1)
nxt = torch.multinomial(probs.cpu(), 1, generator=generator).item()
if nxt == 0: # '.' = end of name
break
ids.append(nxt)
names.append("".join(itos[i] for i in ids[1:]))
return names
# ---------------------------------------------------------------------------
# Self-check: `python model.py`
# ---------------------------------------------------------------------------
if __name__ == "__main__":
torch.manual_seed(42) # same random starting weights every run
with open("data/cities.txt", encoding="utf-8") as f:
names = [line.strip() for line in f if line.strip()]
batch = random.Random(42).sample(names, 256)
x, y = encode(batch)
print(f"example: '{batch[0]}'\n input {x[0].tolist()}\n target {y[0].tolist()}")
for size, cfg in CONFIGS.items():
model = TinyTowns(cfg)
print(f"\n{size}: {cfg}")
if size == "tinytowns-nano":
# named_parameters() lists every weight grid the model owns, by name.
for name, w in model.named_parameters():
print(f" {name:<32} {str(list(w.shape)):<10} {w.numel():>6,}")
total = sum(w.numel() for w in model.parameters())
_, loss = model(x, y)
print(f" total weights: {total:,}")
print(f" untrained loss on 256 names: {loss.item():.4f} (even guess: {math.log(28):.4f})")
|