Language Modelling

Week 10 of 18 · Transformers · 7 days

Full curriculum
Week 10 · Transformers

Language Modelling

Week 10 · Day 1 of 7

Predicting the Next Character

The corpus, the shifted target, and the loss you have to beat

By 1520 words

A language model does one thing: given some text, produce a probability distribution over what comes next. Everything else people do with them is built on that one operation repeated.

The corpus

import torch

def build_corpus(seed=0, sentences=500):
"""A small language with real grammar, generated from a seed.

Written into the page rather than downloaded, so the week runs
offline and everybody trains on identical characters. It is
regular enough that a model can genuinely learn it, and long
enough that counting pairs of characters cannot."""

g = torch.Generator().manual_seed(seed)
def pick(xs):
return xs[int(torch.randint(len(xs), (1,), generator=g))]
subject = ['the model', 'the network', 'the optimiser',
'the gradient', 'the loss', 'attention',
'the encoder', 'the decoder', 'each layer',
'the batch', 'the learning rate', 'dropout']
verb = ['reads', 'predicts', 'updates', 'normalises',
'averages', 'scores', 'reduces', 'controls',
'stabilises', 'copies']
obj = ['the sequence', 'the next token', 'every position',
'the weights', 'the hidden state', 'the input',
'the residual path', 'the attention weights',
'the training data', 'the validation set']
tail = ['during training', 'at every step', 'in a single pass',
'before the softmax', 'after the residual connection',
'without a mask', 'across the batch', 'once per epoch']
joiner = [', and ', ', so ', ', because ', '. ', '. ', '. ']
out = []
for _ in range(sentences):
out.append('%s %s %s %s%s'
% (pick(subject), pick(verb), pick(obj),
pick(tail), pick(joiner)))
return ''.join(out)

CORPUS = build_corpus()
import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset

text = CORPUS.strip()
chars = sorted(set(text))
stoi = {c: i for i, c in enumerate(chars)}
itos = {i: c for c, i in stoi.items()}
VOCAB = len(chars)

def encode(s):
return torch.tensor([stoi[c] for c in s], dtype=torch.long)

def decode(t):
return ''.join(itos[int(i)] for i in t)

data = encode(text)
BLOCK_SIZE = 32

def windows(seq, block):
"""Every position is a training example: x is block characters,
y is the same block shifted one to the right."""

starts = range(0, len(seq) - block - 1, 4) # stride 4
x = torch.stack([seq[i:i + block] for i in starts])
y = torch.stack([seq[i + 1:i + block + 1] for i in starts])
return x, y

split = int(len(data) * 0.9)
X_tr, Y_tr = windows(data[:split], BLOCK_SIZE)
X_va, Y_va = windows(data[split:], BLOCK_SIZE)
train_loader = DataLoader(TensorDataset(X_tr, Y_tr), batch_size=64,
shuffle=True)
print('characters %d' % len(text))
print('vocabulary %d: %s' % (VOCAB, ''.join(chars)))
print()
print(text[:180])
print()
print('encoded:', encode('the model')[:12].tolist())
print('decoded:', decode(encode('the model')))
characters 28539
vocabulary 27: ,.abcdefghiklmnopqrstuvwxy

each layer copies the weights during training, so the gradient controls the weights at every step. attention reduces the residual path once per epoch. each layer reduces the traini

encoded: [21, 10, 7, 0, 14, 16, 6, 7, 13]
decoded: the model

Character level, and why it is the right place to start

A vocabulary of a few dozen characters means no tokeniser to explain, no unknown tokens, and a model small enough to train in seconds. Everything about the training loop, the mask and the sampling is identical to a model with a fifty thousand token vocabulary. Day 5 covers what changes when you move to subword tokens, and the answer is less than you would expect.

Every position is a training example

import torch

def build_corpus(seed=0, sentences=500):
"""A small language with real grammar, generated from a seed.

Written into the page rather than downloaded, so the week runs
offline and everybody trains on identical characters. It is
regular enough that a model can genuinely learn it, and long
enough that counting pairs of characters cannot."""

g = torch.Generator().manual_seed(seed)
def pick(xs):
return xs[int(torch.randint(len(xs), (1,), generator=g))]
subject = ['the model', 'the network', 'the optimiser',
'the gradient', 'the loss', 'attention',
'the encoder', 'the decoder', 'each layer',
'the batch', 'the learning rate', 'dropout']
verb = ['reads', 'predicts', 'updates', 'normalises',
'averages', 'scores', 'reduces', 'controls',
'stabilises', 'copies']
obj = ['the sequence', 'the next token', 'every position',
'the weights', 'the hidden state', 'the input',
'the residual path', 'the attention weights',
'the training data', 'the validation set']
tail = ['during training', 'at every step', 'in a single pass',
'before the softmax', 'after the residual connection',
'without a mask', 'across the batch', 'once per epoch']
joiner = [', and ', ', so ', ', because ', '. ', '. ', '. ']
out = []
for _ in range(sentences):
out.append('%s %s %s %s%s'
% (pick(subject), pick(verb), pick(obj),
pick(tail), pick(joiner)))
return ''.join(out)

CORPUS = build_corpus()
import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset

text = CORPUS.strip()
chars = sorted(set(text))
stoi = {c: i for i, c in enumerate(chars)}
itos = {i: c for c, i in stoi.items()}
VOCAB = len(chars)

def encode(s):
return torch.tensor([stoi[c] for c in s], dtype=torch.long)

def decode(t):
return ''.join(itos[int(i)] for i in t)

data = encode(text)
BLOCK_SIZE = 32

def windows(seq, block):
"""Every position is a training example: x is block characters,
y is the same block shifted one to the right."""

starts = range(0, len(seq) - block - 1, 4) # stride 4
x = torch.stack([seq[i:i + block] for i in starts])
y = torch.stack([seq[i + 1:i + block + 1] for i in starts])
return x, y

split = int(len(data) * 0.9)
X_tr, Y_tr = windows(data[:split], BLOCK_SIZE)
X_va, Y_va = windows(data[split:], BLOCK_SIZE)
train_loader = DataLoader(TensorDataset(X_tr, Y_tr), batch_size=64,
shuffle=True)
x, y = X_tr[0], Y_tr[0]
print('input :', repr(decode(x[:32])))
print('target:', repr(decode(y[:32])))
print('\nthe target is the input shifted one place left.')
print()
for i in range(5):
print('given %-22r predict %r'
% (decode(x[:i + 1]), decode(y[i:i + 1])))
print('\n%d windows of %d characters, so %d predictions per epoch'
% (len(X_tr), BLOCK_SIZE, len(X_tr) * BLOCK_SIZE))
input : 'each layer copies the weights du'
target: 'ach layer copies the weights dur'

the target is the input shifted one place left.

given 'e' predict 'a'
given 'ea' predict 'c'
given 'eac' predict 'h'
given 'each' predict ' '
given 'each ' predict 'l'

6413 windows of 32 characters, so 205216 predictions per epoch

What a good loss looks like

import torch

def build_corpus(seed=0, sentences=500):
"""A small language with real grammar, generated from a seed.

Written into the page rather than downloaded, so the week runs
offline and everybody trains on identical characters. It is
regular enough that a model can genuinely learn it, and long
enough that counting pairs of characters cannot."""

g = torch.Generator().manual_seed(seed)
def pick(xs):
return xs[int(torch.randint(len(xs), (1,), generator=g))]
subject = ['the model', 'the network', 'the optimiser',
'the gradient', 'the loss', 'attention',
'the encoder', 'the decoder', 'each layer',
'the batch', 'the learning rate', 'dropout']
verb = ['reads', 'predicts', 'updates', 'normalises',
'averages', 'scores', 'reduces', 'controls',
'stabilises', 'copies']
obj = ['the sequence', 'the next token', 'every position',
'the weights', 'the hidden state', 'the input',
'the residual path', 'the attention weights',
'the training data', 'the validation set']
tail = ['during training', 'at every step', 'in a single pass',
'before the softmax', 'after the residual connection',
'without a mask', 'across the batch', 'once per epoch']
joiner = [', and ', ', so ', ', because ', '. ', '. ', '. ']
out = []
for _ in range(sentences):
out.append('%s %s %s %s%s'
% (pick(subject), pick(verb), pick(obj),
pick(tail), pick(joiner)))
return ''.join(out)

CORPUS = build_corpus()
import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset

text = CORPUS.strip()
chars = sorted(set(text))
stoi = {c: i for i, c in enumerate(chars)}
itos = {i: c for c, i in stoi.items()}
VOCAB = len(chars)

def encode(s):
return torch.tensor([stoi[c] for c in s], dtype=torch.long)

def decode(t):
return ''.join(itos[int(i)] for i in t)

data = encode(text)
BLOCK_SIZE = 32

def windows(seq, block):
"""Every position is a training example: x is block characters,
y is the same block shifted one to the right."""

starts = range(0, len(seq) - block - 1, 4) # stride 4
x = torch.stack([seq[i:i + block] for i in starts])
y = torch.stack([seq[i + 1:i + block + 1] for i in starts])
return x, y

split = int(len(data) * 0.9)
X_tr, Y_tr = windows(data[:split], BLOCK_SIZE)
X_va, Y_va = windows(data[split:], BLOCK_SIZE)
train_loader = DataLoader(TensorDataset(X_tr, Y_tr), batch_size=64,
shuffle=True)
import math
from collections import Counter

counts = Counter(text)
total = sum(counts.values())
uniform = math.log(VOCAB)
unigram = -sum((n / total) * math.log(n / total) for n in counts.values())

print('loss if the model guesses uniformly %.4f' % uniform)
print('loss from character frequencies alone %.4f' % unigram)
print('\nperplexity is exp(loss), the effective number of choices:')
print(' uniform %.1f' % math.exp(uniform))
print(' unigram %.1f' % math.exp(unigram))
print('\nanything above the unigram number means the model has not')
print('learned even which letters are common.')
loss if the model guesses uniformly 3.2958
loss from character frequencies alone 2.8138

perplexity is exp(loss), the effective number of choices:
uniform 27.0
unigram 16.7

anything above the unigram number means the model has not
learned even which letters are common.
Perplexity: The exponential of the cross entropy loss. If a model has a perplexity of 8 it is, on average, as uncertain as if it were choosing uniformly between 8 options. It is the standard way language model results are reported, and it is just the loss wearing a friendlier scale.

Day 1 takeaway

A language model predicts the next token, and every position in every sequence is a training example. Work out the loss you would get from guessing uniformly and from character frequencies alone before you train anything, because those two numbers are what your model has to beat.
Week 10 · Day 2 of 7

Baselines and a First Model

Bigram counts, a small transformer, and what it writes

By 2028 words

Two baselines before the transformer, so the improvement can be measured rather than assumed.

Counting bigrams

import torch

def build_corpus(seed=0, sentences=500):
"""A small language with real grammar, generated from a seed.

Written into the page rather than downloaded, so the week runs
offline and everybody trains on identical characters. It is
regular enough that a model can genuinely learn it, and long
enough that counting pairs of characters cannot."""

g = torch.Generator().manual_seed(seed)
def pick(xs):
return xs[int(torch.randint(len(xs), (1,), generator=g))]
subject = ['the model', 'the network', 'the optimiser',
'the gradient', 'the loss', 'attention',
'the encoder', 'the decoder', 'each layer',
'the batch', 'the learning rate', 'dropout']
verb = ['reads', 'predicts', 'updates', 'normalises',
'averages', 'scores', 'reduces', 'controls',
'stabilises', 'copies']
obj = ['the sequence', 'the next token', 'every position',
'the weights', 'the hidden state', 'the input',
'the residual path', 'the attention weights',
'the training data', 'the validation set']
tail = ['during training', 'at every step', 'in a single pass',
'before the softmax', 'after the residual connection',
'without a mask', 'across the batch', 'once per epoch']
joiner = [', and ', ', so ', ', because ', '. ', '. ', '. ']
out = []
for _ in range(sentences):
out.append('%s %s %s %s%s'
% (pick(subject), pick(verb), pick(obj),
pick(tail), pick(joiner)))
return ''.join(out)

CORPUS = build_corpus()
import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset

text = CORPUS.strip()
chars = sorted(set(text))
stoi = {c: i for i, c in enumerate(chars)}
itos = {i: c for c, i in stoi.items()}
VOCAB = len(chars)

def encode(s):
return torch.tensor([stoi[c] for c in s], dtype=torch.long)

def decode(t):
return ''.join(itos[int(i)] for i in t)

data = encode(text)
BLOCK_SIZE = 32

def windows(seq, block):
"""Every position is a training example: x is block characters,
y is the same block shifted one to the right."""

starts = range(0, len(seq) - block - 1, 4) # stride 4
x = torch.stack([seq[i:i + block] for i in starts])
y = torch.stack([seq[i + 1:i + block + 1] for i in starts])
return x, y

split = int(len(data) * 0.9)
X_tr, Y_tr = windows(data[:split], BLOCK_SIZE)
X_va, Y_va = windows(data[split:], BLOCK_SIZE)
train_loader = DataLoader(TensorDataset(X_tr, Y_tr), batch_size=64,
shuffle=True)
import math

# The simplest possible language model: for each character, count what
# followed it, and predict from those counts.
counts = torch.zeros(VOCAB, VOCAB)
for a, b in zip(data[:split][:-1], data[:split][1:]):
counts[a, b] += 1
probs = (counts + 1) / (counts + 1).sum(1, keepdim=True) # smoothed

val = data[split:]
logp = probs[val[:-1], val[1:]].log().mean()
print('bigram validation loss %.4f, perplexity %.1f'
% (-logp.item(), math.exp(-logp.item())))

torch.manual_seed(0)
out, current = [], stoi['t']
for _ in range(120):
current = int(torch.multinomial(probs[current], 1))
out.append(current)
print('\ngenerated from bigram counts:')
print(repr(decode(out)))
bigram validation loss 1.9837, perplexity 7.3

generated from bigram counts:
'heske dutmaicocobesintcrathtcr thore cevatthe ethk, ioseaninthol ecos l axtain h t widddimponts t oconp. pimalie putl rn'

It has learned that q is followed by u and that spaces are common. It has learned nothing about words, because it can only see one character back. Every improvement from here is about seeing more context.

A small transformer

import torch

def build_corpus(seed=0, sentences=500):
"""A small language with real grammar, generated from a seed.

Written into the page rather than downloaded, so the week runs
offline and everybody trains on identical characters. It is
regular enough that a model can genuinely learn it, and long
enough that counting pairs of characters cannot."""

g = torch.Generator().manual_seed(seed)
def pick(xs):
return xs[int(torch.randint(len(xs), (1,), generator=g))]
subject = ['the model', 'the network', 'the optimiser',
'the gradient', 'the loss', 'attention',
'the encoder', 'the decoder', 'each layer',
'the batch', 'the learning rate', 'dropout']
verb = ['reads', 'predicts', 'updates', 'normalises',
'averages', 'scores', 'reduces', 'controls',
'stabilises', 'copies']
obj = ['the sequence', 'the next token', 'every position',
'the weights', 'the hidden state', 'the input',
'the residual path', 'the attention weights',
'the training data', 'the validation set']
tail = ['during training', 'at every step', 'in a single pass',
'before the softmax', 'after the residual connection',
'without a mask', 'across the batch', 'once per epoch']
joiner = [', and ', ', so ', ', because ', '. ', '. ', '. ']
out = []
for _ in range(sentences):
out.append('%s %s %s %s%s'
% (pick(subject), pick(verb), pick(obj),
pick(tail), pick(joiner)))
return ''.join(out)

CORPUS = build_corpus()
import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset

text = CORPUS.strip()
chars = sorted(set(text))
stoi = {c: i for i, c in enumerate(chars)}
itos = {i: c for c, i in stoi.items()}
VOCAB = len(chars)

def encode(s):
return torch.tensor([stoi[c] for c in s], dtype=torch.long)

def decode(t):
return ''.join(itos[int(i)] for i in t)

data = encode(text)
BLOCK_SIZE = 32

def windows(seq, block):
"""Every position is a training example: x is block characters,
y is the same block shifted one to the right."""

starts = range(0, len(seq) - block - 1, 4) # stride 4
x = torch.stack([seq[i:i + block] for i in starts])
y = torch.stack([seq[i + 1:i + block + 1] for i in starts])
return x, y

split = int(len(data) * 0.9)
X_tr, Y_tr = windows(data[:split], BLOCK_SIZE)
X_va, Y_va = windows(data[split:], BLOCK_SIZE)
train_loader = DataLoader(TensorDataset(X_tr, Y_tr), batch_size=64,
shuffle=True)
class GPT(nn.Module):
def __init__(self, vocab=None, width=96, heads=4, blocks=2,
block_size=BLOCK_SIZE, dropout=0.1):
super().__init__()
vocab = vocab or VOCAB
self.block_size = block_size
self.tok = nn.Embedding(vocab, width)
self.pos = nn.Embedding(block_size, width)
self.drop = nn.Dropout(dropout)
layer = nn.TransformerEncoderLayer(
d_model=width, nhead=heads, dim_feedforward=width * 4,
dropout=dropout, batch_first=True, norm_first=True,
activation='gelu')
self.blocks = nn.TransformerEncoder(layer, num_layers=blocks)
self.norm = nn.LayerNorm(width)
self.head = nn.Linear(width, vocab, bias=False)
self.head.weight = self.tok.weight

def forward(self, idx):
T = idx.shape[1]
mask = torch.triu(torch.ones(T, T, dtype=torch.bool), diagonal=1)
h = self.drop(self.tok(idx) + self.pos(torch.arange(T)))
return self.head(self.norm(self.blocks(h, mask=mask)))

@torch.no_grad()
def generate(self, idx, steps, temperature=1.0, top_k=None):
self.eval()
for _ in range(steps):
window = idx[:, -self.block_size:]
logits = self(window)[:, -1, :] / temperature
if top_k:
kth = logits.topk(min(top_k, logits.shape[-1])).values[:, -1:]
logits = logits.masked_fill(logits < kth, float('-inf'))
nxt = torch.multinomial(logits.softmax(-1), 1)
idx = torch.cat([idx, nxt], dim=1)
return idx

def train(model, epochs=5, lr=3e-3, warmup=100):
torch.manual_seed(0)
opt = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=0.01)
loss_fn = nn.CrossEntropyLoss()
step = 0
for _ in range(epochs):
model.train()
for xb, yb in train_loader:
step += 1
for group in opt.param_groups:
group['lr'] = lr * min(1.0, step / warmup)
opt.zero_grad()
loss = loss_fn(model(xb).reshape(-1, VOCAB), yb.reshape(-1))
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
opt.step()
model.eval()
with torch.no_grad():
val = loss_fn(model(X_va).reshape(-1, VOCAB),
Y_va.reshape(-1)).item()
return loss.item(), val
import time
torch.manual_seed(0)
model = GPT()
print('parameters %d' % sum(p.numel() for p in model.parameters()))
start = time.time()
train_loss, val_loss = train(model, epochs=5)
import math
print('train loss %.4f validation loss %.4f perplexity %.1f'
% (train_loss, val_loss, math.exp(val_loss)))
print('%.0f seconds' % (time.time() - start))
parameters 229536
train loss 0.4920 validation loss 0.3461 perplexity 1.4
94 seconds

What it writes

import torch

def build_corpus(seed=0, sentences=500):
"""A small language with real grammar, generated from a seed.

Written into the page rather than downloaded, so the week runs
offline and everybody trains on identical characters. It is
regular enough that a model can genuinely learn it, and long
enough that counting pairs of characters cannot."""

g = torch.Generator().manual_seed(seed)
def pick(xs):
return xs[int(torch.randint(len(xs), (1,), generator=g))]
subject = ['the model', 'the network', 'the optimiser',
'the gradient', 'the loss', 'attention',
'the encoder', 'the decoder', 'each layer',
'the batch', 'the learning rate', 'dropout']
verb = ['reads', 'predicts', 'updates', 'normalises',
'averages', 'scores', 'reduces', 'controls',
'stabilises', 'copies']
obj = ['the sequence', 'the next token', 'every position',
'the weights', 'the hidden state', 'the input',
'the residual path', 'the attention weights',
'the training data', 'the validation set']
tail = ['during training', 'at every step', 'in a single pass',
'before the softmax', 'after the residual connection',
'without a mask', 'across the batch', 'once per epoch']
joiner = [', and ', ', so ', ', because ', '. ', '. ', '. ']
out = []
for _ in range(sentences):
out.append('%s %s %s %s%s'
% (pick(subject), pick(verb), pick(obj),
pick(tail), pick(joiner)))
return ''.join(out)

CORPUS = build_corpus()
import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset

text = CORPUS.strip()
chars = sorted(set(text))
stoi = {c: i for i, c in enumerate(chars)}
itos = {i: c for c, i in stoi.items()}
VOCAB = len(chars)

def encode(s):
return torch.tensor([stoi[c] for c in s], dtype=torch.long)

def decode(t):
return ''.join(itos[int(i)] for i in t)

data = encode(text)
BLOCK_SIZE = 32

def windows(seq, block):
"""Every position is a training example: x is block characters,
y is the same block shifted one to the right."""

starts = range(0, len(seq) - block - 1, 4) # stride 4
x = torch.stack([seq[i:i + block] for i in starts])
y = torch.stack([seq[i + 1:i + block + 1] for i in starts])
return x, y

split = int(len(data) * 0.9)
X_tr, Y_tr = windows(data[:split], BLOCK_SIZE)
X_va, Y_va = windows(data[split:], BLOCK_SIZE)
train_loader = DataLoader(TensorDataset(X_tr, Y_tr), batch_size=64,
shuffle=True)
class GPT(nn.Module):
def __init__(self, vocab=None, width=96, heads=4, blocks=2,
block_size=BLOCK_SIZE, dropout=0.1):
super().__init__()
vocab = vocab or VOCAB
self.block_size = block_size
self.tok = nn.Embedding(vocab, width)
self.pos = nn.Embedding(block_size, width)
self.drop = nn.Dropout(dropout)
layer = nn.TransformerEncoderLayer(
d_model=width, nhead=heads, dim_feedforward=width * 4,
dropout=dropout, batch_first=True, norm_first=True,
activation='gelu')
self.blocks = nn.TransformerEncoder(layer, num_layers=blocks)
self.norm = nn.LayerNorm(width)
self.head = nn.Linear(width, vocab, bias=False)
self.head.weight = self.tok.weight

def forward(self, idx):
T = idx.shape[1]
mask = torch.triu(torch.ones(T, T, dtype=torch.bool), diagonal=1)
h = self.drop(self.tok(idx) + self.pos(torch.arange(T)))
return self.head(self.norm(self.blocks(h, mask=mask)))

@torch.no_grad()
def generate(self, idx, steps, temperature=1.0, top_k=None):
self.eval()
for _ in range(steps):
window = idx[:, -self.block_size:]
logits = self(window)[:, -1, :] / temperature
if top_k:
kth = logits.topk(min(top_k, logits.shape[-1])).values[:, -1:]
logits = logits.masked_fill(logits < kth, float('-inf'))
nxt = torch.multinomial(logits.softmax(-1), 1)
idx = torch.cat([idx, nxt], dim=1)
return idx

def train(model, epochs=5, lr=3e-3, warmup=100):
torch.manual_seed(0)
opt = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=0.01)
loss_fn = nn.CrossEntropyLoss()
step = 0
for _ in range(epochs):
model.train()
for xb, yb in train_loader:
step += 1
for group in opt.param_groups:
group['lr'] = lr * min(1.0, step / warmup)
opt.zero_grad()
loss = loss_fn(model(xb).reshape(-1, VOCAB), yb.reshape(-1))
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
opt.step()
model.eval()
with torch.no_grad():
val = loss_fn(model(X_va).reshape(-1, VOCAB),
Y_va.reshape(-1)).item()
return loss.item(), val
torch.manual_seed(0)
model = GPT()
train(model, epochs=5)

prompt = encode('the model ').unsqueeze(0)
for temperature in [0.4, 0.8, 1.2]:
torch.manual_seed(0)
out = model.generate(prompt, 140, temperature=temperature)
print('temperature %.1f:' % temperature)
print(' ' + repr(decode(out[0])))
print()
temperature 0.4:
'the model updates the validation set across the batch, because the batch layer step, and the learning rate copies the validation set at every step, so'

temperature 0.8:
'the model updates the validation set across the batch. the batch, and the learning rate copies the hidden state without a mask, because the attention '

temperature 1.2:
'the model updates the validation state bilises the hidden state state without a mask, and dropout copies the input across the learning data after the '

It is memorising, and on a corpus this size it must

Roughly thirty thousand characters against a model with a few hundred thousand parameters. The generated text will contain stretches lifted verbatim from the corpus, which is worth seeing rather than hiding: it is the same phenomenon that makes large models reproduce their training data, at a scale where you can hold both ends of it in your head.

The gap between training and validation loss above is the measurement. Everything in weeks 4 and 11 about regularisation and scale is aimed at that gap.

Day 2 takeaway

Fit a bigram counting model first: it takes four lines and gives you a real number to beat. A small transformer beats it comfortably because it can see the whole window rather than one character. On a tiny corpus it will also memorise, and the train-validation gap is where you see that.
Week 10 · Day 3 of 7

Context, Width and Depth

Scaling laws in miniature, on a corpus far too small

By 2234 words

Three things decide how good a language model is, and they are not the things people usually adjust first.

Context length

import torch

def build_corpus(seed=0, sentences=500):
"""A small language with real grammar, generated from a seed.

Written into the page rather than downloaded, so the week runs
offline and everybody trains on identical characters. It is
regular enough that a model can genuinely learn it, and long
enough that counting pairs of characters cannot."""

g = torch.Generator().manual_seed(seed)
def pick(xs):
return xs[int(torch.randint(len(xs), (1,), generator=g))]
subject = ['the model', 'the network', 'the optimiser',
'the gradient', 'the loss', 'attention',
'the encoder', 'the decoder', 'each layer',
'the batch', 'the learning rate', 'dropout']
verb = ['reads', 'predicts', 'updates', 'normalises',
'averages', 'scores', 'reduces', 'controls',
'stabilises', 'copies']
obj = ['the sequence', 'the next token', 'every position',
'the weights', 'the hidden state', 'the input',
'the residual path', 'the attention weights',
'the training data', 'the validation set']
tail = ['during training', 'at every step', 'in a single pass',
'before the softmax', 'after the residual connection',
'without a mask', 'across the batch', 'once per epoch']
joiner = [', and ', ', so ', ', because ', '. ', '. ', '. ']
out = []
for _ in range(sentences):
out.append('%s %s %s %s%s'
% (pick(subject), pick(verb), pick(obj),
pick(tail), pick(joiner)))
return ''.join(out)

CORPUS = build_corpus()
import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset

text = CORPUS.strip()
chars = sorted(set(text))
stoi = {c: i for i, c in enumerate(chars)}
itos = {i: c for c, i in stoi.items()}
VOCAB = len(chars)

def encode(s):
return torch.tensor([stoi[c] for c in s], dtype=torch.long)

def decode(t):
return ''.join(itos[int(i)] for i in t)

data = encode(text)
BLOCK_SIZE = 32

def windows(seq, block):
"""Every position is a training example: x is block characters,
y is the same block shifted one to the right."""

starts = range(0, len(seq) - block - 1, 4) # stride 4
x = torch.stack([seq[i:i + block] for i in starts])
y = torch.stack([seq[i + 1:i + block + 1] for i in starts])
return x, y

split = int(len(data) * 0.9)
X_tr, Y_tr = windows(data[:split], BLOCK_SIZE)
X_va, Y_va = windows(data[split:], BLOCK_SIZE)
train_loader = DataLoader(TensorDataset(X_tr, Y_tr), batch_size=64,
shuffle=True)
class GPT(nn.Module):
def __init__(self, vocab=None, width=96, heads=4, blocks=2,
block_size=BLOCK_SIZE, dropout=0.1):
super().__init__()
vocab = vocab or VOCAB
self.block_size = block_size
self.tok = nn.Embedding(vocab, width)
self.pos = nn.Embedding(block_size, width)
self.drop = nn.Dropout(dropout)
layer = nn.TransformerEncoderLayer(
d_model=width, nhead=heads, dim_feedforward=width * 4,
dropout=dropout, batch_first=True, norm_first=True,
activation='gelu')
self.blocks = nn.TransformerEncoder(layer, num_layers=blocks)
self.norm = nn.LayerNorm(width)
self.head = nn.Linear(width, vocab, bias=False)
self.head.weight = self.tok.weight

def forward(self, idx):
T = idx.shape[1]
mask = torch.triu(torch.ones(T, T, dtype=torch.bool), diagonal=1)
h = self.drop(self.tok(idx) + self.pos(torch.arange(T)))
return self.head(self.norm(self.blocks(h, mask=mask)))

@torch.no_grad()
def generate(self, idx, steps, temperature=1.0, top_k=None):
self.eval()
for _ in range(steps):
window = idx[:, -self.block_size:]
logits = self(window)[:, -1, :] / temperature
if top_k:
kth = logits.topk(min(top_k, logits.shape[-1])).values[:, -1:]
logits = logits.masked_fill(logits < kth, float('-inf'))
nxt = torch.multinomial(logits.softmax(-1), 1)
idx = torch.cat([idx, nxt], dim=1)
return idx

def train(model, epochs=5, lr=3e-3, warmup=100):
torch.manual_seed(0)
opt = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=0.01)
loss_fn = nn.CrossEntropyLoss()
step = 0
for _ in range(epochs):
model.train()
for xb, yb in train_loader:
step += 1
for group in opt.param_groups:
group['lr'] = lr * min(1.0, step / warmup)
opt.zero_grad()
loss = loss_fn(model(xb).reshape(-1, VOCAB), yb.reshape(-1))
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
opt.step()
model.eval()
with torch.no_grad():
val = loss_fn(model(X_va).reshape(-1, VOCAB),
Y_va.reshape(-1)).item()
return loss.item(), val
import math
results = []
for block in [8, 16, 32]:
torch.manual_seed(0)
Xa, Ya = windows(data[:split], block)
Xb, Yb = windows(data[split:], block)
loader = DataLoader(TensorDataset(Xa, Ya), batch_size=64, shuffle=True)
model = GPT(block_size=block)
opt = torch.optim.AdamW(model.parameters(), lr=3e-3, weight_decay=0.01)
loss_fn = nn.CrossEntropyLoss()
for _ in range(5):
model.train()
for xb, yb in loader:
opt.zero_grad()
loss_fn(model(xb).reshape(-1, VOCAB), yb.reshape(-1)).backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
opt.step()
model.eval()
with torch.no_grad():
val = loss_fn(model(Xb).reshape(-1, VOCAB), Yb.reshape(-1)).item()
results.append((block, val))

print('%10s %14s %12s' % ('context', 'val loss', 'perplexity'))
for block, val in results:
print('%10d %14.4f %12.1f' % (block, val, math.exp(val)))
context val loss perplexity
8 0.7046 2.0
16 0.4769 1.6
32 0.3314 1.4

Width and depth

import torch

def build_corpus(seed=0, sentences=500):
"""A small language with real grammar, generated from a seed.

Written into the page rather than downloaded, so the week runs
offline and everybody trains on identical characters. It is
regular enough that a model can genuinely learn it, and long
enough that counting pairs of characters cannot."""

g = torch.Generator().manual_seed(seed)
def pick(xs):
return xs[int(torch.randint(len(xs), (1,), generator=g))]
subject = ['the model', 'the network', 'the optimiser',
'the gradient', 'the loss', 'attention',
'the encoder', 'the decoder', 'each layer',
'the batch', 'the learning rate', 'dropout']
verb = ['reads', 'predicts', 'updates', 'normalises',
'averages', 'scores', 'reduces', 'controls',
'stabilises', 'copies']
obj = ['the sequence', 'the next token', 'every position',
'the weights', 'the hidden state', 'the input',
'the residual path', 'the attention weights',
'the training data', 'the validation set']
tail = ['during training', 'at every step', 'in a single pass',
'before the softmax', 'after the residual connection',
'without a mask', 'across the batch', 'once per epoch']
joiner = [', and ', ', so ', ', because ', '. ', '. ', '. ']
out = []
for _ in range(sentences):
out.append('%s %s %s %s%s'
% (pick(subject), pick(verb), pick(obj),
pick(tail), pick(joiner)))
return ''.join(out)

CORPUS = build_corpus()
import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset

text = CORPUS.strip()
chars = sorted(set(text))
stoi = {c: i for i, c in enumerate(chars)}
itos = {i: c for c, i in stoi.items()}
VOCAB = len(chars)

def encode(s):
return torch.tensor([stoi[c] for c in s], dtype=torch.long)

def decode(t):
return ''.join(itos[int(i)] for i in t)

data = encode(text)
BLOCK_SIZE = 32

def windows(seq, block):
"""Every position is a training example: x is block characters,
y is the same block shifted one to the right."""

starts = range(0, len(seq) - block - 1, 4) # stride 4
x = torch.stack([seq[i:i + block] for i in starts])
y = torch.stack([seq[i + 1:i + block + 1] for i in starts])
return x, y

split = int(len(data) * 0.9)
X_tr, Y_tr = windows(data[:split], BLOCK_SIZE)
X_va, Y_va = windows(data[split:], BLOCK_SIZE)
train_loader = DataLoader(TensorDataset(X_tr, Y_tr), batch_size=64,
shuffle=True)
class GPT(nn.Module):
def __init__(self, vocab=None, width=96, heads=4, blocks=2,
block_size=BLOCK_SIZE, dropout=0.1):
super().__init__()
vocab = vocab or VOCAB
self.block_size = block_size
self.tok = nn.Embedding(vocab, width)
self.pos = nn.Embedding(block_size, width)
self.drop = nn.Dropout(dropout)
layer = nn.TransformerEncoderLayer(
d_model=width, nhead=heads, dim_feedforward=width * 4,
dropout=dropout, batch_first=True, norm_first=True,
activation='gelu')
self.blocks = nn.TransformerEncoder(layer, num_layers=blocks)
self.norm = nn.LayerNorm(width)
self.head = nn.Linear(width, vocab, bias=False)
self.head.weight = self.tok.weight

def forward(self, idx):
T = idx.shape[1]
mask = torch.triu(torch.ones(T, T, dtype=torch.bool), diagonal=1)
h = self.drop(self.tok(idx) + self.pos(torch.arange(T)))
return self.head(self.norm(self.blocks(h, mask=mask)))

@torch.no_grad()
def generate(self, idx, steps, temperature=1.0, top_k=None):
self.eval()
for _ in range(steps):
window = idx[:, -self.block_size:]
logits = self(window)[:, -1, :] / temperature
if top_k:
kth = logits.topk(min(top_k, logits.shape[-1])).values[:, -1:]
logits = logits.masked_fill(logits < kth, float('-inf'))
nxt = torch.multinomial(logits.softmax(-1), 1)
idx = torch.cat([idx, nxt], dim=1)
return idx

def train(model, epochs=5, lr=3e-3, warmup=100):
torch.manual_seed(0)
opt = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=0.01)
loss_fn = nn.CrossEntropyLoss()
step = 0
for _ in range(epochs):
model.train()
for xb, yb in train_loader:
step += 1
for group in opt.param_groups:
group['lr'] = lr * min(1.0, step / warmup)
opt.zero_grad()
loss = loss_fn(model(xb).reshape(-1, VOCAB), yb.reshape(-1))
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
opt.step()
model.eval()
with torch.no_grad():
val = loss_fn(model(X_va).reshape(-1, VOCAB),
Y_va.reshape(-1)).item()
return loss.item(), val
import math
print('%8s %8s %10s %12s %12s'
% ('width', 'blocks', 'params', 'train loss', 'val loss'))
for width, blocks in [(48, 2), (96, 2), (96, 4)]:
torch.manual_seed(0)
model = GPT(width=width, blocks=blocks)
tr, va = train(model, epochs=5)
print('%8d %8d %10d %12.4f %12.4f'
% (width, blocks, sum(p.numel() for p in model.parameters()),
tr, va))
width blocks params train loss val loss
48 2 59472 0.8091 0.5077
96 2 229536 0.4920 0.3461
96 4 453216 0.3993 0.3218

This is a scaling law, in miniature

Bigger models reach a lower training loss and, past a point, a worse validation loss, because the corpus is far too small for them. The published scaling laws say the same thing with the sign the other way round: given enough data, loss falls predictably as a power of model size and compute. Both statements are the same relationship read from different ends, and the practical version is that model size and dataset size have to grow together.

What the model has actually learned

import torch

def build_corpus(seed=0, sentences=500):
"""A small language with real grammar, generated from a seed.

Written into the page rather than downloaded, so the week runs
offline and everybody trains on identical characters. It is
regular enough that a model can genuinely learn it, and long
enough that counting pairs of characters cannot."""

g = torch.Generator().manual_seed(seed)
def pick(xs):
return xs[int(torch.randint(len(xs), (1,), generator=g))]
subject = ['the model', 'the network', 'the optimiser',
'the gradient', 'the loss', 'attention',
'the encoder', 'the decoder', 'each layer',
'the batch', 'the learning rate', 'dropout']
verb = ['reads', 'predicts', 'updates', 'normalises',
'averages', 'scores', 'reduces', 'controls',
'stabilises', 'copies']
obj = ['the sequence', 'the next token', 'every position',
'the weights', 'the hidden state', 'the input',
'the residual path', 'the attention weights',
'the training data', 'the validation set']
tail = ['during training', 'at every step', 'in a single pass',
'before the softmax', 'after the residual connection',
'without a mask', 'across the batch', 'once per epoch']
joiner = [', and ', ', so ', ', because ', '. ', '. ', '. ']
out = []
for _ in range(sentences):
out.append('%s %s %s %s%s'
% (pick(subject), pick(verb), pick(obj),
pick(tail), pick(joiner)))
return ''.join(out)

CORPUS = build_corpus()
import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset

text = CORPUS.strip()
chars = sorted(set(text))
stoi = {c: i for i, c in enumerate(chars)}
itos = {i: c for c, i in stoi.items()}
VOCAB = len(chars)

def encode(s):
return torch.tensor([stoi[c] for c in s], dtype=torch.long)

def decode(t):
return ''.join(itos[int(i)] for i in t)

data = encode(text)
BLOCK_SIZE = 32

def windows(seq, block):
"""Every position is a training example: x is block characters,
y is the same block shifted one to the right."""

starts = range(0, len(seq) - block - 1, 4) # stride 4
x = torch.stack([seq[i:i + block] for i in starts])
y = torch.stack([seq[i + 1:i + block + 1] for i in starts])
return x, y

split = int(len(data) * 0.9)
X_tr, Y_tr = windows(data[:split], BLOCK_SIZE)
X_va, Y_va = windows(data[split:], BLOCK_SIZE)
train_loader = DataLoader(TensorDataset(X_tr, Y_tr), batch_size=64,
shuffle=True)
class GPT(nn.Module):
def __init__(self, vocab=None, width=96, heads=4, blocks=2,
block_size=BLOCK_SIZE, dropout=0.1):
super().__init__()
vocab = vocab or VOCAB
self.block_size = block_size
self.tok = nn.Embedding(vocab, width)
self.pos = nn.Embedding(block_size, width)
self.drop = nn.Dropout(dropout)
layer = nn.TransformerEncoderLayer(
d_model=width, nhead=heads, dim_feedforward=width * 4,
dropout=dropout, batch_first=True, norm_first=True,
activation='gelu')
self.blocks = nn.TransformerEncoder(layer, num_layers=blocks)
self.norm = nn.LayerNorm(width)
self.head = nn.Linear(width, vocab, bias=False)
self.head.weight = self.tok.weight

def forward(self, idx):
T = idx.shape[1]
mask = torch.triu(torch.ones(T, T, dtype=torch.bool), diagonal=1)
h = self.drop(self.tok(idx) + self.pos(torch.arange(T)))
return self.head(self.norm(self.blocks(h, mask=mask)))

@torch.no_grad()
def generate(self, idx, steps, temperature=1.0, top_k=None):
self.eval()
for _ in range(steps):
window = idx[:, -self.block_size:]
logits = self(window)[:, -1, :] / temperature
if top_k:
kth = logits.topk(min(top_k, logits.shape[-1])).values[:, -1:]
logits = logits.masked_fill(logits < kth, float('-inf'))
nxt = torch.multinomial(logits.softmax(-1), 1)
idx = torch.cat([idx, nxt], dim=1)
return idx

def train(model, epochs=5, lr=3e-3, warmup=100):
torch.manual_seed(0)
opt = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=0.01)
loss_fn = nn.CrossEntropyLoss()
step = 0
for _ in range(epochs):
model.train()
for xb, yb in train_loader:
step += 1
for group in opt.param_groups:
group['lr'] = lr * min(1.0, step / warmup)
opt.zero_grad()
loss = loss_fn(model(xb).reshape(-1, VOCAB), yb.reshape(-1))
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
opt.step()
model.eval()
with torch.no_grad():
val = loss_fn(model(X_va).reshape(-1, VOCAB),
Y_va.reshape(-1)).item()
return loss.item(), val
torch.manual_seed(0)
model = GPT()
train(model, epochs=5)
model.eval()

for prompt in ['the gradient ', 'attention ', 'if the loss ']:
idx = encode(prompt).unsqueeze(0)
with torch.no_grad():
probs = model(idx)[0, -1].softmax(-1)
top = probs.topk(4)
guesses = ', '.join('%r %.2f' % (itos[int(i)], v)
for v, i in zip(top.values, top.indices))
print('%-16r -> %s' % (prompt, guesses))
'the gradient ' -> 's' 0.46, 'c' 0.24, 'n' 0.08, 'r' 0.07
'attention ' -> 'w' 0.65, 's' 0.08, 'a' 0.05, 'r' 0.05
'if the loss ' -> 'c' 0.77, 't' 0.10, 'u' 0.05, 'r' 0.03

Day 3 takeaway

Longer context helps until the model runs out of anything useful to look back at. Larger models reach lower training loss and, on a small corpus, worse validation loss. Model size and data size go together, which is the whole content of the scaling laws.
Week 10 · Day 4 of 7

Evaluating a Language Model

Perplexity, why greedy decoding loops, and measuring memorisation

By 2372 words

Evaluating a language model properly is harder than training one, and loss is only the first of three things to look at.

Loss, perplexity and bits per character

import torch

def build_corpus(seed=0, sentences=500):
"""A small language with real grammar, generated from a seed.

Written into the page rather than downloaded, so the week runs
offline and everybody trains on identical characters. It is
regular enough that a model can genuinely learn it, and long
enough that counting pairs of characters cannot."""

g = torch.Generator().manual_seed(seed)
def pick(xs):
return xs[int(torch.randint(len(xs), (1,), generator=g))]
subject = ['the model', 'the network', 'the optimiser',
'the gradient', 'the loss', 'attention',
'the encoder', 'the decoder', 'each layer',
'the batch', 'the learning rate', 'dropout']
verb = ['reads', 'predicts', 'updates', 'normalises',
'averages', 'scores', 'reduces', 'controls',
'stabilises', 'copies']
obj = ['the sequence', 'the next token', 'every position',
'the weights', 'the hidden state', 'the input',
'the residual path', 'the attention weights',
'the training data', 'the validation set']
tail = ['during training', 'at every step', 'in a single pass',
'before the softmax', 'after the residual connection',
'without a mask', 'across the batch', 'once per epoch']
joiner = [', and ', ', so ', ', because ', '. ', '. ', '. ']
out = []
for _ in range(sentences):
out.append('%s %s %s %s%s'
% (pick(subject), pick(verb), pick(obj),
pick(tail), pick(joiner)))
return ''.join(out)

CORPUS = build_corpus()
import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset

text = CORPUS.strip()
chars = sorted(set(text))
stoi = {c: i for i, c in enumerate(chars)}
itos = {i: c for c, i in stoi.items()}
VOCAB = len(chars)

def encode(s):
return torch.tensor([stoi[c] for c in s], dtype=torch.long)

def decode(t):
return ''.join(itos[int(i)] for i in t)

data = encode(text)
BLOCK_SIZE = 32

def windows(seq, block):
"""Every position is a training example: x is block characters,
y is the same block shifted one to the right."""

starts = range(0, len(seq) - block - 1, 4) # stride 4
x = torch.stack([seq[i:i + block] for i in starts])
y = torch.stack([seq[i + 1:i + block + 1] for i in starts])
return x, y

split = int(len(data) * 0.9)
X_tr, Y_tr = windows(data[:split], BLOCK_SIZE)
X_va, Y_va = windows(data[split:], BLOCK_SIZE)
train_loader = DataLoader(TensorDataset(X_tr, Y_tr), batch_size=64,
shuffle=True)
class GPT(nn.Module):
def __init__(self, vocab=None, width=96, heads=4, blocks=2,
block_size=BLOCK_SIZE, dropout=0.1):
super().__init__()
vocab = vocab or VOCAB
self.block_size = block_size
self.tok = nn.Embedding(vocab, width)
self.pos = nn.Embedding(block_size, width)
self.drop = nn.Dropout(dropout)
layer = nn.TransformerEncoderLayer(
d_model=width, nhead=heads, dim_feedforward=width * 4,
dropout=dropout, batch_first=True, norm_first=True,
activation='gelu')
self.blocks = nn.TransformerEncoder(layer, num_layers=blocks)
self.norm = nn.LayerNorm(width)
self.head = nn.Linear(width, vocab, bias=False)
self.head.weight = self.tok.weight

def forward(self, idx):
T = idx.shape[1]
mask = torch.triu(torch.ones(T, T, dtype=torch.bool), diagonal=1)
h = self.drop(self.tok(idx) + self.pos(torch.arange(T)))
return self.head(self.norm(self.blocks(h, mask=mask)))

@torch.no_grad()
def generate(self, idx, steps, temperature=1.0, top_k=None):
self.eval()
for _ in range(steps):
window = idx[:, -self.block_size:]
logits = self(window)[:, -1, :] / temperature
if top_k:
kth = logits.topk(min(top_k, logits.shape[-1])).values[:, -1:]
logits = logits.masked_fill(logits < kth, float('-inf'))
nxt = torch.multinomial(logits.softmax(-1), 1)
idx = torch.cat([idx, nxt], dim=1)
return idx

def train(model, epochs=5, lr=3e-3, warmup=100):
torch.manual_seed(0)
opt = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=0.01)
loss_fn = nn.CrossEntropyLoss()
step = 0
for _ in range(epochs):
model.train()
for xb, yb in train_loader:
step += 1
for group in opt.param_groups:
group['lr'] = lr * min(1.0, step / warmup)
opt.zero_grad()
loss = loss_fn(model(xb).reshape(-1, VOCAB), yb.reshape(-1))
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
opt.step()
model.eval()
with torch.no_grad():
val = loss_fn(model(X_va).reshape(-1, VOCAB),
Y_va.reshape(-1)).item()
return loss.item(), val
import math
torch.manual_seed(0)
model = GPT()
tr, va = train(model, epochs=5)

print('cross entropy loss %.4f' % va)
print('perplexity %.2f' % math.exp(va))
print('bits per character %.3f' % (va / math.log(2)))
print()
print('for comparison:')
print(' uniform over %d characters is %.3f bits'
% (VOCAB, math.log(VOCAB) / math.log(2)))
print(' good character models of English reach roughly 1.1 to 1.3 bits')
print(' on large corpora, which is the number to have in mind.')
cross entropy loss 0.3461
perplexity 1.41
bits per character 0.499

for comparison:
uniform over 27 characters is 4.755 bits
good character models of English reach roughly 1.1 to 1.3 bits
on large corpora, which is the number to have in mind.

Loss does not tell you whether it can generate

import torch

def build_corpus(seed=0, sentences=500):
"""A small language with real grammar, generated from a seed.

Written into the page rather than downloaded, so the week runs
offline and everybody trains on identical characters. It is
regular enough that a model can genuinely learn it, and long
enough that counting pairs of characters cannot."""

g = torch.Generator().manual_seed(seed)
def pick(xs):
return xs[int(torch.randint(len(xs), (1,), generator=g))]
subject = ['the model', 'the network', 'the optimiser',
'the gradient', 'the loss', 'attention',
'the encoder', 'the decoder', 'each layer',
'the batch', 'the learning rate', 'dropout']
verb = ['reads', 'predicts', 'updates', 'normalises',
'averages', 'scores', 'reduces', 'controls',
'stabilises', 'copies']
obj = ['the sequence', 'the next token', 'every position',
'the weights', 'the hidden state', 'the input',
'the residual path', 'the attention weights',
'the training data', 'the validation set']
tail = ['during training', 'at every step', 'in a single pass',
'before the softmax', 'after the residual connection',
'without a mask', 'across the batch', 'once per epoch']
joiner = [', and ', ', so ', ', because ', '. ', '. ', '. ']
out = []
for _ in range(sentences):
out.append('%s %s %s %s%s'
% (pick(subject), pick(verb), pick(obj),
pick(tail), pick(joiner)))
return ''.join(out)

CORPUS = build_corpus()
import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset

text = CORPUS.strip()
chars = sorted(set(text))
stoi = {c: i for i, c in enumerate(chars)}
itos = {i: c for c, i in stoi.items()}
VOCAB = len(chars)

def encode(s):
return torch.tensor([stoi[c] for c in s], dtype=torch.long)

def decode(t):
return ''.join(itos[int(i)] for i in t)

data = encode(text)
BLOCK_SIZE = 32

def windows(seq, block):
"""Every position is a training example: x is block characters,
y is the same block shifted one to the right."""

starts = range(0, len(seq) - block - 1, 4) # stride 4
x = torch.stack([seq[i:i + block] for i in starts])
y = torch.stack([seq[i + 1:i + block + 1] for i in starts])
return x, y

split = int(len(data) * 0.9)
X_tr, Y_tr = windows(data[:split], BLOCK_SIZE)
X_va, Y_va = windows(data[split:], BLOCK_SIZE)
train_loader = DataLoader(TensorDataset(X_tr, Y_tr), batch_size=64,
shuffle=True)
class GPT(nn.Module):
def __init__(self, vocab=None, width=96, heads=4, blocks=2,
block_size=BLOCK_SIZE, dropout=0.1):
super().__init__()
vocab = vocab or VOCAB
self.block_size = block_size
self.tok = nn.Embedding(vocab, width)
self.pos = nn.Embedding(block_size, width)
self.drop = nn.Dropout(dropout)
layer = nn.TransformerEncoderLayer(
d_model=width, nhead=heads, dim_feedforward=width * 4,
dropout=dropout, batch_first=True, norm_first=True,
activation='gelu')
self.blocks = nn.TransformerEncoder(layer, num_layers=blocks)
self.norm = nn.LayerNorm(width)
self.head = nn.Linear(width, vocab, bias=False)
self.head.weight = self.tok.weight

def forward(self, idx):
T = idx.shape[1]
mask = torch.triu(torch.ones(T, T, dtype=torch.bool), diagonal=1)
h = self.drop(self.tok(idx) + self.pos(torch.arange(T)))
return self.head(self.norm(self.blocks(h, mask=mask)))

@torch.no_grad()
def generate(self, idx, steps, temperature=1.0, top_k=None):
self.eval()
for _ in range(steps):
window = idx[:, -self.block_size:]
logits = self(window)[:, -1, :] / temperature
if top_k:
kth = logits.topk(min(top_k, logits.shape[-1])).values[:, -1:]
logits = logits.masked_fill(logits < kth, float('-inf'))
nxt = torch.multinomial(logits.softmax(-1), 1)
idx = torch.cat([idx, nxt], dim=1)
return idx

def train(model, epochs=5, lr=3e-3, warmup=100):
torch.manual_seed(0)
opt = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=0.01)
loss_fn = nn.CrossEntropyLoss()
step = 0
for _ in range(epochs):
model.train()
for xb, yb in train_loader:
step += 1
for group in opt.param_groups:
group['lr'] = lr * min(1.0, step / warmup)
opt.zero_grad()
loss = loss_fn(model(xb).reshape(-1, VOCAB), yb.reshape(-1))
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
opt.step()
model.eval()
with torch.no_grad():
val = loss_fn(model(X_va).reshape(-1, VOCAB),
Y_va.reshape(-1)).item()
return loss.item(), val
torch.manual_seed(0)
model = GPT()
train(model, epochs=5)

# A model can have a fine loss and still produce degenerate text if the
# sampling is wrong. Same weights, three settings.
prompt = encode('the loss ').unsqueeze(0)
for name, kwargs in [('greedy (temperature 0.01)', dict(temperature=0.01)),
('temperature 0.8', dict(temperature=0.8)),
('temperature 0.8, top-k 5',
dict(temperature=0.8, top_k=5)),
('temperature 2.0', dict(temperature=2.0))]:
torch.manual_seed(0)
out = model.generate(prompt, 90, **kwargs)
print('%-26s %r' % (name, decode(out[0])))
print()
greedy (temperature 0.01) 'the loss the residual path once per epoch, so the learning rate copies the validation set across th'

temperature 0.8 'the loss the sequence after the residual connection. the learning rate predicts the attention weigh'

temperature 0.8, top-k 5 'the loss the sequence after the residual connection. the learning rate predicts the weights without'

temperature 2.0 'the loss updates the input aining data once per hidden state state without a mask, and dl path du'

Greedy decoding loops, and it is not a bug in the model

Take the most likely character every time and a language model will eventually enter a cycle and repeat it forever. The reason is that the most likely continuation of a repeated phrase is more of the same phrase, and nothing in greedy decoding can escape. This is why every text generation interface samples rather than maximising, and why repetition penalties exist.

Measuring memorisation

import torch

def build_corpus(seed=0, sentences=500):
"""A small language with real grammar, generated from a seed.

Written into the page rather than downloaded, so the week runs
offline and everybody trains on identical characters. It is
regular enough that a model can genuinely learn it, and long
enough that counting pairs of characters cannot."""

g = torch.Generator().manual_seed(seed)
def pick(xs):
return xs[int(torch.randint(len(xs), (1,), generator=g))]
subject = ['the model', 'the network', 'the optimiser',
'the gradient', 'the loss', 'attention',
'the encoder', 'the decoder', 'each layer',
'the batch', 'the learning rate', 'dropout']
verb = ['reads', 'predicts', 'updates', 'normalises',
'averages', 'scores', 'reduces', 'controls',
'stabilises', 'copies']
obj = ['the sequence', 'the next token', 'every position',
'the weights', 'the hidden state', 'the input',
'the residual path', 'the attention weights',
'the training data', 'the validation set']
tail = ['during training', 'at every step', 'in a single pass',
'before the softmax', 'after the residual connection',
'without a mask', 'across the batch', 'once per epoch']
joiner = [', and ', ', so ', ', because ', '. ', '. ', '. ']
out = []
for _ in range(sentences):
out.append('%s %s %s %s%s'
% (pick(subject), pick(verb), pick(obj),
pick(tail), pick(joiner)))
return ''.join(out)

CORPUS = build_corpus()
import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset

text = CORPUS.strip()
chars = sorted(set(text))
stoi = {c: i for i, c in enumerate(chars)}
itos = {i: c for c, i in stoi.items()}
VOCAB = len(chars)

def encode(s):
return torch.tensor([stoi[c] for c in s], dtype=torch.long)

def decode(t):
return ''.join(itos[int(i)] for i in t)

data = encode(text)
BLOCK_SIZE = 32

def windows(seq, block):
"""Every position is a training example: x is block characters,
y is the same block shifted one to the right."""

starts = range(0, len(seq) - block - 1, 4) # stride 4
x = torch.stack([seq[i:i + block] for i in starts])
y = torch.stack([seq[i + 1:i + block + 1] for i in starts])
return x, y

split = int(len(data) * 0.9)
X_tr, Y_tr = windows(data[:split], BLOCK_SIZE)
X_va, Y_va = windows(data[split:], BLOCK_SIZE)
train_loader = DataLoader(TensorDataset(X_tr, Y_tr), batch_size=64,
shuffle=True)
class GPT(nn.Module):
def __init__(self, vocab=None, width=96, heads=4, blocks=2,
block_size=BLOCK_SIZE, dropout=0.1):
super().__init__()
vocab = vocab or VOCAB
self.block_size = block_size
self.tok = nn.Embedding(vocab, width)
self.pos = nn.Embedding(block_size, width)
self.drop = nn.Dropout(dropout)
layer = nn.TransformerEncoderLayer(
d_model=width, nhead=heads, dim_feedforward=width * 4,
dropout=dropout, batch_first=True, norm_first=True,
activation='gelu')
self.blocks = nn.TransformerEncoder(layer, num_layers=blocks)
self.norm = nn.LayerNorm(width)
self.head = nn.Linear(width, vocab, bias=False)
self.head.weight = self.tok.weight

def forward(self, idx):
T = idx.shape[1]
mask = torch.triu(torch.ones(T, T, dtype=torch.bool), diagonal=1)
h = self.drop(self.tok(idx) + self.pos(torch.arange(T)))
return self.head(self.norm(self.blocks(h, mask=mask)))

@torch.no_grad()
def generate(self, idx, steps, temperature=1.0, top_k=None):
self.eval()
for _ in range(steps):
window = idx[:, -self.block_size:]
logits = self(window)[:, -1, :] / temperature
if top_k:
kth = logits.topk(min(top_k, logits.shape[-1])).values[:, -1:]
logits = logits.masked_fill(logits < kth, float('-inf'))
nxt = torch.multinomial(logits.softmax(-1), 1)
idx = torch.cat([idx, nxt], dim=1)
return idx

def train(model, epochs=5, lr=3e-3, warmup=100):
torch.manual_seed(0)
opt = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=0.01)
loss_fn = nn.CrossEntropyLoss()
step = 0
for _ in range(epochs):
model.train()
for xb, yb in train_loader:
step += 1
for group in opt.param_groups:
group['lr'] = lr * min(1.0, step / warmup)
opt.zero_grad()
loss = loss_fn(model(xb).reshape(-1, VOCAB), yb.reshape(-1))
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
opt.step()
model.eval()
with torch.no_grad():
val = loss_fn(model(X_va).reshape(-1, VOCAB),
Y_va.reshape(-1)).item()
return loss.item(), val
torch.manual_seed(0)
model = GPT()
train(model, epochs=5)

def longest_copied(generated, source, minimum=8):
best = ''
for i in range(len(generated)):
for j in range(i + minimum, len(generated) + 1):
piece = generated[i:j]
if piece in source and len(piece) > len(best):
best = piece
elif piece not in source:
break
return best

torch.manual_seed(0)
out = decode(model.generate(encode('the ').unsqueeze(0), 200,
temperature=0.8)[0])
copied = longest_copied(out, text)
print('generated %d characters' % len(out))
print('longest stretch also in the training text: %d characters'
% len(copied))
print(repr(copied))
generated 204 characters
longest stretch also in the training text: 46 characters
' at every step, and the gradient controls the '

On a corpus this small the answer is most of it. On a real corpus the same measurement is how you find out whether your model is reproducing training data, which matters for licensing, for privacy, and for whether your evaluation set has leaked into training.

Day 4 takeaway

Report perplexity or bits per character, not raw loss, so the number is comparable. Evaluate by generating as well as by loss. Greedy decoding loops by construction. And measure the longest stretch your model reproduces verbatim, because that number is the one somebody will eventually ask about.
Week 10 · Day 5 of 7

Tokenisation

Byte pair encoding built by hand, and why your API bill counts tokens

By 1279 words

Characters are the simplest choice and nobody uses them at scale. What real models use is subword tokens, and the reasons are practical rather than theoretical.

The problem with words and with characters

import torch

def build_corpus(seed=0, sentences=500):
"""A small language with real grammar, generated from a seed.

Written into the page rather than downloaded, so the week runs
offline and everybody trains on identical characters. It is
regular enough that a model can genuinely learn it, and long
enough that counting pairs of characters cannot."""

g = torch.Generator().manual_seed(seed)
def pick(xs):
return xs[int(torch.randint(len(xs), (1,), generator=g))]
subject = ['the model', 'the network', 'the optimiser',
'the gradient', 'the loss', 'attention',
'the encoder', 'the decoder', 'each layer',
'the batch', 'the learning rate', 'dropout']
verb = ['reads', 'predicts', 'updates', 'normalises',
'averages', 'scores', 'reduces', 'controls',
'stabilises', 'copies']
obj = ['the sequence', 'the next token', 'every position',
'the weights', 'the hidden state', 'the input',
'the residual path', 'the attention weights',
'the training data', 'the validation set']
tail = ['during training', 'at every step', 'in a single pass',
'before the softmax', 'after the residual connection',
'without a mask', 'across the batch', 'once per epoch']
joiner = [', and ', ', so ', ', because ', '. ', '. ', '. ']
out = []
for _ in range(sentences):
out.append('%s %s %s %s%s'
% (pick(subject), pick(verb), pick(obj),
pick(tail), pick(joiner)))
return ''.join(out)

CORPUS = build_corpus()
import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset

text = CORPUS.strip()
chars = sorted(set(text))
stoi = {c: i for i, c in enumerate(chars)}
itos = {i: c for c, i in stoi.items()}
VOCAB = len(chars)

def encode(s):
return torch.tensor([stoi[c] for c in s], dtype=torch.long)

def decode(t):
return ''.join(itos[int(i)] for i in t)

data = encode(text)
BLOCK_SIZE = 32

def windows(seq, block):
"""Every position is a training example: x is block characters,
y is the same block shifted one to the right."""

starts = range(0, len(seq) - block - 1, 4) # stride 4
x = torch.stack([seq[i:i + block] for i in starts])
y = torch.stack([seq[i + 1:i + block + 1] for i in starts])
return x, y

split = int(len(data) * 0.9)
X_tr, Y_tr = windows(data[:split], BLOCK_SIZE)
X_va, Y_va = windows(data[split:], BLOCK_SIZE)
train_loader = DataLoader(TensorDataset(X_tr, Y_tr), batch_size=64,
shuffle=True)
words = text.split()
vocab_words = sorted(set(words))
print('characters: vocabulary %d, sequence length %d'
% (VOCAB, len(text)))
print('words: vocabulary %d, sequence length %d'
% (len(vocab_words), len(words)))
print()
print('a word vocabulary is short sequences and a huge embedding table,')
print('and it has no answer at all for a word it has never seen.')
print('characters are the opposite: tiny table, very long sequences,')
print('and attention costs the square of the length.')
characters: vocabulary 27, sequence length 28539
words: vocabulary 70, sequence length 4545

a word vocabulary is short sequences and a huge embedding table,
and it has no answer at all for a word it has never seen.
characters are the opposite: tiny table, very long sequences,
and attention costs the square of the length.

Byte pair encoding, built by hand

import torch

def build_corpus(seed=0, sentences=500):
"""A small language with real grammar, generated from a seed.

Written into the page rather than downloaded, so the week runs
offline and everybody trains on identical characters. It is
regular enough that a model can genuinely learn it, and long
enough that counting pairs of characters cannot."""

g = torch.Generator().manual_seed(seed)
def pick(xs):
return xs[int(torch.randint(len(xs), (1,), generator=g))]
subject = ['the model', 'the network', 'the optimiser',
'the gradient', 'the loss', 'attention',
'the encoder', 'the decoder', 'each layer',
'the batch', 'the learning rate', 'dropout']
verb = ['reads', 'predicts', 'updates', 'normalises',
'averages', 'scores', 'reduces', 'controls',
'stabilises', 'copies']
obj = ['the sequence', 'the next token', 'every position',
'the weights', 'the hidden state', 'the input',
'the residual path', 'the attention weights',
'the training data', 'the validation set']
tail = ['during training', 'at every step', 'in a single pass',
'before the softmax', 'after the residual connection',
'without a mask', 'across the batch', 'once per epoch']
joiner = [', and ', ', so ', ', because ', '. ', '. ', '. ']
out = []
for _ in range(sentences):
out.append('%s %s %s %s%s'
% (pick(subject), pick(verb), pick(obj),
pick(tail), pick(joiner)))
return ''.join(out)

CORPUS = build_corpus()
import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset

text = CORPUS.strip()
chars = sorted(set(text))
stoi = {c: i for i, c in enumerate(chars)}
itos = {i: c for c, i in stoi.items()}
VOCAB = len(chars)

def encode(s):
return torch.tensor([stoi[c] for c in s], dtype=torch.long)

def decode(t):
return ''.join(itos[int(i)] for i in t)

data = encode(text)
BLOCK_SIZE = 32

def windows(seq, block):
"""Every position is a training example: x is block characters,
y is the same block shifted one to the right."""

starts = range(0, len(seq) - block - 1, 4) # stride 4
x = torch.stack([seq[i:i + block] for i in starts])
y = torch.stack([seq[i + 1:i + block + 1] for i in starts])
return x, y

split = int(len(data) * 0.9)
X_tr, Y_tr = windows(data[:split], BLOCK_SIZE)
X_va, Y_va = windows(data[split:], BLOCK_SIZE)
train_loader = DataLoader(TensorDataset(X_tr, Y_tr), batch_size=64,
shuffle=True)
from collections import Counter

# Start from characters and repeatedly merge the commonest adjacent pair.
tokens = list(text[:900])
merges = []
for step in range(12):
pairs = Counter(zip(tokens, tokens[1:]))
if not pairs:
break
best, count = pairs.most_common(1)[0]
merged = best[0] + best[1]
merges.append((best, count))
out, i = [], 0
while i < len(tokens):
if i < len(tokens) - 1 and (tokens[i], tokens[i + 1]) == best:
out.append(merged)
i += 2
else:
out.append(tokens[i])
i += 1
tokens = out

print('%-6s %-14s %8s' % ('merge', 'pair joined', 'count'))
for i, (pair, count) in enumerate(merges, 1):
print('%-6d %-14r %8d' % (i, pair[0] + pair[1], count))
print('\n900 characters became %d tokens' % len(tokens))
print('first 20:', tokens[:20])
merge pair joined count
1 'e ' 34
2 'th' 32
3 'the ' 27
4 ' the ' 26
5 'es' 21
6 'in' 19
7 'at' 17
8 'er' 16
9 'es the ' 15
10 ' s' 15
11 'on' 13
12 'du' 12

900 characters became 653 tokens
first 20: ['e', 'a', 'c', 'h', ' ', 'l', 'a', 'y', 'er', ' ', 'c', 'o', 'p', 'i', 'es the ', 'w', 'e', 'i', 'g', 'h']

Common sequences become single tokens and rare ones stay as pieces, so the vocabulary is finite and nothing is ever completely unknown. That is the whole algorithm, and the tokenisers shipped with real models are this with a larger corpus and thirty thousand merges instead of twelve.

A real tokeniser

from tokenizers import Tokenizer, models, trainers, pre_tokenizers

text = ('the model reads the sequence and predicts the next token. '
'training is a loop: forward, loss, backward, step. the gradient '
'tells each weight which way to move and the optimiser decides '
'how far. attention scores every position against every other '
'position, and the scores become weights.') * 8

tok = Tokenizer(models.BPE(unk_token='[UNK]'))
tok.pre_tokenizer = pre_tokenizers.Whitespace()
trainer = trainers.BpeTrainer(vocab_size=120,
special_tokens=['[UNK]', '[PAD]'])
tok.train_from_iterator([text], trainer)

print('vocabulary size %d' % tok.get_vocab_size())
for s in ['the gradient', 'backpropagation', 'zqx']:
enc = tok.encode(s)
print('%-18r -> %s' % (s, enc.tokens))
vocabulary size 120
'the gradient' -> ['the', 'gr', 'adien', 't']
'backpropagation' -> ['bac', 'k', 'p', 'r', 'op', 'ag', 'a', 'tion']
'zqx' -> ['[UNK]', 'q', 'x']

Why the token count on your API bill is not the word count

A common word is one token. An unusual one is three or four. A misspelling, a product code or a language the tokeniser saw little of can be one token per character. That is why the same paragraph costs different amounts in different languages, and why counting words to estimate cost is unreliable.

Day 5 takeaway

Byte pair encoding starts from characters and repeatedly merges the commonest adjacent pair, which gives a finite vocabulary with no unknown tokens. It is the reason token counts do not match word counts, and the reason models cope with typos and product codes at all.
Week 10 · Day 6 of 7

Packing, Compute and Budgets

Where the arithmetic actually goes in a training run

By 884 words

Everything about a language model that is not the architecture: how the data is packed, how long the context is, and what the compute is spent on.

Packing

import torch

def build_corpus(seed=0, sentences=500):
"""A small language with real grammar, generated from a seed.

Written into the page rather than downloaded, so the week runs
offline and everybody trains on identical characters. It is
regular enough that a model can genuinely learn it, and long
enough that counting pairs of characters cannot."""

g = torch.Generator().manual_seed(seed)
def pick(xs):
return xs[int(torch.randint(len(xs), (1,), generator=g))]
subject = ['the model', 'the network', 'the optimiser',
'the gradient', 'the loss', 'attention',
'the encoder', 'the decoder', 'each layer',
'the batch', 'the learning rate', 'dropout']
verb = ['reads', 'predicts', 'updates', 'normalises',
'averages', 'scores', 'reduces', 'controls',
'stabilises', 'copies']
obj = ['the sequence', 'the next token', 'every position',
'the weights', 'the hidden state', 'the input',
'the residual path', 'the attention weights',
'the training data', 'the validation set']
tail = ['during training', 'at every step', 'in a single pass',
'before the softmax', 'after the residual connection',
'without a mask', 'across the batch', 'once per epoch']
joiner = [', and ', ', so ', ', because ', '. ', '. ', '. ']
out = []
for _ in range(sentences):
out.append('%s %s %s %s%s'
% (pick(subject), pick(verb), pick(obj),
pick(tail), pick(joiner)))
return ''.join(out)

CORPUS = build_corpus()
import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset

text = CORPUS.strip()
chars = sorted(set(text))
stoi = {c: i for i, c in enumerate(chars)}
itos = {i: c for c, i in stoi.items()}
VOCAB = len(chars)

def encode(s):
return torch.tensor([stoi[c] for c in s], dtype=torch.long)

def decode(t):
return ''.join(itos[int(i)] for i in t)

data = encode(text)
BLOCK_SIZE = 32

def windows(seq, block):
"""Every position is a training example: x is block characters,
y is the same block shifted one to the right."""

starts = range(0, len(seq) - block - 1, 4) # stride 4
x = torch.stack([seq[i:i + block] for i in starts])
y = torch.stack([seq[i + 1:i + block + 1] for i in starts])
return x, y

split = int(len(data) * 0.9)
X_tr, Y_tr = windows(data[:split], BLOCK_SIZE)
X_va, Y_va = windows(data[split:], BLOCK_SIZE)
train_loader = DataLoader(TensorDataset(X_tr, Y_tr), batch_size=64,
shuffle=True)
# Training on padded documents wastes compute on padding. Real training
# concatenates everything and cuts fixed-length windows out of the stream.
documents = [text[:120], text[200:260], text[400:700]]
lengths = [len(d) for d in documents]
print('document lengths', lengths)

padded = max(lengths) * len(documents)
packed = sum(lengths)
print('padded to a rectangle: %d positions, %d of them real (%.0f%%)'
% (padded, packed, 100 * packed / padded))

stream = encode(''.join(documents))
block = 48
n_windows = len(stream) // block
print('packed into a stream: %d windows of %d, no padding at all'
% (n_windows, block))
document lengths [120, 60, 300]
padded to a rectangle: 900 positions, 480 of them real (53%)
packed into a stream: 10 windows of 48, no padding at all

Packing lets one document see the end of another

A window cut from the concatenated stream can straddle a document boundary, so the model attends across it. Most implementations accept that, because the cost is small and the compute saving is large. If it matters for your data, insert a separator token and mask attention across it, which is what document-aware packing does.

Where the compute goes

def flops_per_token(width, blocks, context, vocab):
attn_proj = 4 * width * width
attn_scores = 2 * context * width
ff = 8 * width * width
per_block = attn_proj + attn_scores + ff
return 2 * (blocks * per_block + width * vocab)

print('%10s %10s %14s %14s' % ('context', 'width', 'per token', 'share attn'))
for context in [128, 1024, 8192]:
for width in [768]:
total = flops_per_token(width, 12, context, 50257)
attn = 2 * 12 * 2 * context * width
print('%10d %10d %14s %13.1f%%'
% (context, width, '{:,}'.format(total),
100 * attn / total))
context width per token share attn
128 768 251,782,656 1.9%
1024 768 284,812,800 13.3%
8192 768 549,053,952 55.0%

At a short context the attention scores are a rounding error and almost all the work is in the matrix multiplications, which is why transformers are efficient. As the context grows the quadratic term takes over, and that crossover is what all the work on efficient attention is chasing.

The three numbers that describe a training run

NumberWhat it isRule of thumb
ParametersWeights in the modelMemory during training is roughly 16 bytes per parameter with Adam
TokensHow much text it seesAround 20 tokens per parameter is compute-optimal
ComputeFloating point operationsRoughly 6 times parameters times tokens for a full training run
def budget(params, tokens):
flops = 6 * params * tokens
memory = params * 16 / 1e9
return flops, memory

print('%-16s %14s %14s %12s'
% ('model', 'parameters', 'tokens', 'memory (GB)'))
for name, params in [('small', 124e6), ('medium', 355e6), ('large', 7e9)]:
tokens = params * 20
flops, memory = budget(params, tokens)
print('%-16s %14s %14s %12.1f'
% (name, '{:,.0f}'.format(params), '{:,.0f}'.format(tokens),
memory))
print('%-16s total compute about %.2e operations' % ('', flops))
model parameters tokens memory (GB)
small 124,000,000 2,480,000,000 2.0
total compute about 1.85e+18 operations
medium 355,000,000 7,100,000,000 5.7
total compute about 1.51e+19 operations
large 7,000,000,000 140,000,000,000 112.0
total compute about 5.88e+21 operations

Day 6 takeaway

Pack documents into a continuous stream rather than padding them. Attention is a small share of the compute at short contexts and dominates at long ones. Budget roughly 16 bytes per parameter of memory, twenty tokens per parameter of data, and six times parameters times tokens of compute.
Week 10 · Day 7 of 7

The Complete Model

Measured against every baseline, and an honest account of the distance

By 1758 words

The complete model, trained with everything this week established, and measured against the baselines it has to beat.

The comparison

import torch

def build_corpus(seed=0, sentences=500):
"""A small language with real grammar, generated from a seed.

Written into the page rather than downloaded, so the week runs
offline and everybody trains on identical characters. It is
regular enough that a model can genuinely learn it, and long
enough that counting pairs of characters cannot."""

g = torch.Generator().manual_seed(seed)
def pick(xs):
return xs[int(torch.randint(len(xs), (1,), generator=g))]
subject = ['the model', 'the network', 'the optimiser',
'the gradient', 'the loss', 'attention',
'the encoder', 'the decoder', 'each layer',
'the batch', 'the learning rate', 'dropout']
verb = ['reads', 'predicts', 'updates', 'normalises',
'averages', 'scores', 'reduces', 'controls',
'stabilises', 'copies']
obj = ['the sequence', 'the next token', 'every position',
'the weights', 'the hidden state', 'the input',
'the residual path', 'the attention weights',
'the training data', 'the validation set']
tail = ['during training', 'at every step', 'in a single pass',
'before the softmax', 'after the residual connection',
'without a mask', 'across the batch', 'once per epoch']
joiner = [', and ', ', so ', ', because ', '. ', '. ', '. ']
out = []
for _ in range(sentences):
out.append('%s %s %s %s%s'
% (pick(subject), pick(verb), pick(obj),
pick(tail), pick(joiner)))
return ''.join(out)

CORPUS = build_corpus()
import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset

text = CORPUS.strip()
chars = sorted(set(text))
stoi = {c: i for i, c in enumerate(chars)}
itos = {i: c for c, i in stoi.items()}
VOCAB = len(chars)

def encode(s):
return torch.tensor([stoi[c] for c in s], dtype=torch.long)

def decode(t):
return ''.join(itos[int(i)] for i in t)

data = encode(text)
BLOCK_SIZE = 32

def windows(seq, block):
"""Every position is a training example: x is block characters,
y is the same block shifted one to the right."""

starts = range(0, len(seq) - block - 1, 4) # stride 4
x = torch.stack([seq[i:i + block] for i in starts])
y = torch.stack([seq[i + 1:i + block + 1] for i in starts])
return x, y

split = int(len(data) * 0.9)
X_tr, Y_tr = windows(data[:split], BLOCK_SIZE)
X_va, Y_va = windows(data[split:], BLOCK_SIZE)
train_loader = DataLoader(TensorDataset(X_tr, Y_tr), batch_size=64,
shuffle=True)
class GPT(nn.Module):
def __init__(self, vocab=None, width=96, heads=4, blocks=2,
block_size=BLOCK_SIZE, dropout=0.1):
super().__init__()
vocab = vocab or VOCAB
self.block_size = block_size
self.tok = nn.Embedding(vocab, width)
self.pos = nn.Embedding(block_size, width)
self.drop = nn.Dropout(dropout)
layer = nn.TransformerEncoderLayer(
d_model=width, nhead=heads, dim_feedforward=width * 4,
dropout=dropout, batch_first=True, norm_first=True,
activation='gelu')
self.blocks = nn.TransformerEncoder(layer, num_layers=blocks)
self.norm = nn.LayerNorm(width)
self.head = nn.Linear(width, vocab, bias=False)
self.head.weight = self.tok.weight

def forward(self, idx):
T = idx.shape[1]
mask = torch.triu(torch.ones(T, T, dtype=torch.bool), diagonal=1)
h = self.drop(self.tok(idx) + self.pos(torch.arange(T)))
return self.head(self.norm(self.blocks(h, mask=mask)))

@torch.no_grad()
def generate(self, idx, steps, temperature=1.0, top_k=None):
self.eval()
for _ in range(steps):
window = idx[:, -self.block_size:]
logits = self(window)[:, -1, :] / temperature
if top_k:
kth = logits.topk(min(top_k, logits.shape[-1])).values[:, -1:]
logits = logits.masked_fill(logits < kth, float('-inf'))
nxt = torch.multinomial(logits.softmax(-1), 1)
idx = torch.cat([idx, nxt], dim=1)
return idx

def train(model, epochs=5, lr=3e-3, warmup=100):
torch.manual_seed(0)
opt = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=0.01)
loss_fn = nn.CrossEntropyLoss()
step = 0
for _ in range(epochs):
model.train()
for xb, yb in train_loader:
step += 1
for group in opt.param_groups:
group['lr'] = lr * min(1.0, step / warmup)
opt.zero_grad()
loss = loss_fn(model(xb).reshape(-1, VOCAB), yb.reshape(-1))
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
opt.step()
model.eval()
with torch.no_grad():
val = loss_fn(model(X_va).reshape(-1, VOCAB),
Y_va.reshape(-1)).item()
return loss.item(), val
import math, time

# baseline 1: character frequencies
from collections import Counter
counts = Counter(text)
total = sum(counts.values())
unigram = -sum((n / total) * math.log(n / total) for n in counts.values())

# baseline 2: bigram counts
bi = torch.zeros(VOCAB, VOCAB)
for a, b in zip(data[:split][:-1], data[:split][1:]):
bi[a, b] += 1
probs = (bi + 1) / (bi + 1).sum(1, keepdim=True)
val = data[split:]
bigram = -probs[val[:-1], val[1:]].log().mean().item()

print('%-28s %12s %12s' % ('', 'val loss', 'perplexity'))
print('%-28s %12.4f %12.1f'
% ('uniform guessing', math.log(VOCAB), VOCAB))
print('%-28s %12.4f %12.1f' % ('character frequencies', unigram,
math.exp(unigram)))
print('%-28s %12.4f %12.1f' % ('bigram counts', bigram,
math.exp(bigram)))

for blocks in [1, 2]:
torch.manual_seed(0)
model = GPT(blocks=blocks)
start = time.time()
tr, va = train(model, epochs=5)
print('%-28s %12.4f %12.1f'
% ('transformer, %d blocks' % blocks, va, math.exp(va)))
val loss perplexity
uniform guessing 3.2958 27.0
character frequencies 2.8138 16.7
bigram counts 1.9837 7.3
transformer, 1 blocks 0.4943 1.6
transformer, 2 blocks 0.3461 1.4

Generating from the finished model

import torch

def build_corpus(seed=0, sentences=500):
"""A small language with real grammar, generated from a seed.

Written into the page rather than downloaded, so the week runs
offline and everybody trains on identical characters. It is
regular enough that a model can genuinely learn it, and long
enough that counting pairs of characters cannot."""

g = torch.Generator().manual_seed(seed)
def pick(xs):
return xs[int(torch.randint(len(xs), (1,), generator=g))]
subject = ['the model', 'the network', 'the optimiser',
'the gradient', 'the loss', 'attention',
'the encoder', 'the decoder', 'each layer',
'the batch', 'the learning rate', 'dropout']
verb = ['reads', 'predicts', 'updates', 'normalises',
'averages', 'scores', 'reduces', 'controls',
'stabilises', 'copies']
obj = ['the sequence', 'the next token', 'every position',
'the weights', 'the hidden state', 'the input',
'the residual path', 'the attention weights',
'the training data', 'the validation set']
tail = ['during training', 'at every step', 'in a single pass',
'before the softmax', 'after the residual connection',
'without a mask', 'across the batch', 'once per epoch']
joiner = [', and ', ', so ', ', because ', '. ', '. ', '. ']
out = []
for _ in range(sentences):
out.append('%s %s %s %s%s'
% (pick(subject), pick(verb), pick(obj),
pick(tail), pick(joiner)))
return ''.join(out)

CORPUS = build_corpus()
import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset

text = CORPUS.strip()
chars = sorted(set(text))
stoi = {c: i for i, c in enumerate(chars)}
itos = {i: c for c, i in stoi.items()}
VOCAB = len(chars)

def encode(s):
return torch.tensor([stoi[c] for c in s], dtype=torch.long)

def decode(t):
return ''.join(itos[int(i)] for i in t)

data = encode(text)
BLOCK_SIZE = 32

def windows(seq, block):
"""Every position is a training example: x is block characters,
y is the same block shifted one to the right."""

starts = range(0, len(seq) - block - 1, 4) # stride 4
x = torch.stack([seq[i:i + block] for i in starts])
y = torch.stack([seq[i + 1:i + block + 1] for i in starts])
return x, y

split = int(len(data) * 0.9)
X_tr, Y_tr = windows(data[:split], BLOCK_SIZE)
X_va, Y_va = windows(data[split:], BLOCK_SIZE)
train_loader = DataLoader(TensorDataset(X_tr, Y_tr), batch_size=64,
shuffle=True)
class GPT(nn.Module):
def __init__(self, vocab=None, width=96, heads=4, blocks=2,
block_size=BLOCK_SIZE, dropout=0.1):
super().__init__()
vocab = vocab or VOCAB
self.block_size = block_size
self.tok = nn.Embedding(vocab, width)
self.pos = nn.Embedding(block_size, width)
self.drop = nn.Dropout(dropout)
layer = nn.TransformerEncoderLayer(
d_model=width, nhead=heads, dim_feedforward=width * 4,
dropout=dropout, batch_first=True, norm_first=True,
activation='gelu')
self.blocks = nn.TransformerEncoder(layer, num_layers=blocks)
self.norm = nn.LayerNorm(width)
self.head = nn.Linear(width, vocab, bias=False)
self.head.weight = self.tok.weight

def forward(self, idx):
T = idx.shape[1]
mask = torch.triu(torch.ones(T, T, dtype=torch.bool), diagonal=1)
h = self.drop(self.tok(idx) + self.pos(torch.arange(T)))
return self.head(self.norm(self.blocks(h, mask=mask)))

@torch.no_grad()
def generate(self, idx, steps, temperature=1.0, top_k=None):
self.eval()
for _ in range(steps):
window = idx[:, -self.block_size:]
logits = self(window)[:, -1, :] / temperature
if top_k:
kth = logits.topk(min(top_k, logits.shape[-1])).values[:, -1:]
logits = logits.masked_fill(logits < kth, float('-inf'))
nxt = torch.multinomial(logits.softmax(-1), 1)
idx = torch.cat([idx, nxt], dim=1)
return idx

def train(model, epochs=5, lr=3e-3, warmup=100):
torch.manual_seed(0)
opt = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=0.01)
loss_fn = nn.CrossEntropyLoss()
step = 0
for _ in range(epochs):
model.train()
for xb, yb in train_loader:
step += 1
for group in opt.param_groups:
group['lr'] = lr * min(1.0, step / warmup)
opt.zero_grad()
loss = loss_fn(model(xb).reshape(-1, VOCAB), yb.reshape(-1))
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
opt.step()
model.eval()
with torch.no_grad():
val = loss_fn(model(X_va).reshape(-1, VOCAB),
Y_va.reshape(-1)).item()
return loss.item(), val
torch.manual_seed(0)
model = GPT(blocks=2)
tr, va = train(model, epochs=5)
print('validation loss %.4f\n' % va)

for prompt in ['the model ', 'attention ', 'if the loss ']:
torch.manual_seed(0)
out = model.generate(encode(prompt).unsqueeze(0), 110,
temperature=0.7, top_k=8)
print('%r ->' % prompt)
print(' ' + repr(decode(out[0])))
print()
validation loss 0.3461

'the model ' ->
'the model updates the validation set across the batch. the batch, and the learning rate copies the hidden state without '

'attention ' ->
'attention weights once per epoch. the optimiser predicts the input at every step, and the learning rate updates the vali'

'if the loss ' ->
'if the loss copies the input without a mask. the model controls the batch. the loss across the batch. the batch, so the ba'

What this is and is not

A few hundred thousand parameters trained on thirty thousand characters of a deliberately regular invented language, for about a minute. It has learned that language well, which is why the perplexity is so low. It has not learned English, and the gap between those two statements is the whole of the rest of the field.

The distance from here to a useful model is entirely scale: more parameters, vastly more text, subword tokens, longer context and weeks of compute. Not a single architectural idea in this week changes. That is worth sitting with, because it is the actual reason the field looks the way it does, and it is why week 11 is about using somebody else's trained model rather than building your own.

The checklist

  1. Compute the uniform and unigram losses first, so you know what beating nothing looks like.
  2. Fit a bigram counting model as a real baseline.
  3. Report perplexity or bits per character rather than raw loss.
  4. Causal mask, and check the loss against the floor the task permits.
  5. Warmup, gradient clipping, AdamW, tied embeddings.
  6. Pack documents into a stream rather than padding.
  7. Evaluate by generating, with the decoding settings recorded.
  8. Measure the longest verbatim stretch your model reproduces.

Day 7 takeaway

A character-level transformer beats bigram counts comfortably and memorises a small corpus completely. Everything in the training recipe is the same as for a model a million times larger, which is the point: the architecture is not what separates this from a useful model, and week 11 covers the alternative to trying.