A complete attention block, and the comparison against week 7's recurrent models on identical data.
The block
import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset
def make_sequences(n, length=8, seed=0):
"""Week 7's task: is the digit at position 0 repeated later?
Balanced by construction, and unanswerable from a bag of counts."""
g = torch.Generator().manual_seed(seed)
first = torch.randint(1, 10, (n, 1), generator=g)
rest = torch.randint(1, 9, (n, length - 1), generator=g)
rest = rest + (rest >= first).long()
y = torch.randint(0, 2, (n,), generator=g)
where = torch.randint(0, length - 1, (n, 1), generator=g)
idx = torch.arange(length - 1).unsqueeze(0)
plant = (y.unsqueeze(1) == 1) & (idx == where)
rest = torch.where(plant, first.expand_as(rest), rest)
return torch.cat([first, rest], dim=1), y
X_tr, y_tr = make_sequences(8000, seed=0)
X_va, y_va = make_sequences(2000, seed=1)
train_loader = DataLoader(TensorDataset(X_tr, y_tr), batch_size=64,
shuffle=True)
val_loader = DataLoader(TensorDataset(X_va, y_va), batch_size=512)
def fit(model, epochs=10, lr=3e-3, loader=None, val=None):
torch.manual_seed(0)
opt = torch.optim.AdamW(model.parameters(), lr=lr)
loss_fn = nn.CrossEntropyLoss()
for _ in range(epochs):
model.train()
for xb, yb in (loader or train_loader):
opt.zero_grad()
loss_fn(model(xb), yb).backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
opt.step()
model.eval()
right = seen = 0
with torch.no_grad():
for xb, yb in (val or val_loader):
right += (model(xb).argmax(1) == yb).sum().item()
seen += yb.numel()
return right / seen
class AttentionBlock(nn.Module):
"""Attention, then a small feed forward network, each wrapped in a
residual connection with layer normalisation. This is the transformer
block, and week 9 assembles a stack of them."""
def __init__(self, width, heads, expansion=4, dropout=0.1):
super().__init__()
self.norm1 = nn.LayerNorm(width)
self.attn = nn.MultiheadAttention(width, heads, dropout=dropout,
batch_first=True)
self.norm2 = nn.LayerNorm(width)
self.ff = nn.Sequential(
nn.Linear(width, width * expansion), nn.GELU(),
nn.Linear(width * expansion, width), nn.Dropout(dropout))
def forward(self, x, mask=None):
h = self.norm1(x)
a, _ = self.attn(h, h, h, attn_mask=mask, need_weights=False)
x = x + a
x = x + self.ff(self.norm2(x))
return x
torch.manual_seed(0)
block = AttentionBlock(32, 4)
x = torch.randn(2, 8, 32)
print('in ', tuple(x.shape), ' out', tuple(block(x).shape))
print('parameters %d' % sum(p.numel() for p in block.parameters()))
in (2, 8, 32) out (2, 8, 32)
parameters 12704
Normalise before, not after
The original transformer applied layer normalisation after the residual addition. Every model since about 2019 does it before, exactly as week 6 argued for pre-activation residual blocks: it keeps the shortcut path completely clear, so the gradient reaches the earliest block undiminished. Post-normalisation transformers need a careful warmup to train at all, and pre-normalisation ones largely do not.
Against the recurrent models
import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset
def make_sequences(n, length=8, seed=0):
"""Week 7's task: is the digit at position 0 repeated later?
Balanced by construction, and unanswerable from a bag of counts."""
g = torch.Generator().manual_seed(seed)
first = torch.randint(1, 10, (n, 1), generator=g)
rest = torch.randint(1, 9, (n, length - 1), generator=g)
rest = rest + (rest >= first).long()
y = torch.randint(0, 2, (n,), generator=g)
where = torch.randint(0, length - 1, (n, 1), generator=g)
idx = torch.arange(length - 1).unsqueeze(0)
plant = (y.unsqueeze(1) == 1) & (idx == where)
rest = torch.where(plant, first.expand_as(rest), rest)
return torch.cat([first, rest], dim=1), y
X_tr, y_tr = make_sequences(8000, seed=0)
X_va, y_va = make_sequences(2000, seed=1)
train_loader = DataLoader(TensorDataset(X_tr, y_tr), batch_size=64,
shuffle=True)
val_loader = DataLoader(TensorDataset(X_va, y_va), batch_size=512)
def fit(model, epochs=10, lr=3e-3, loader=None, val=None):
torch.manual_seed(0)
opt = torch.optim.AdamW(model.parameters(), lr=lr)
loss_fn = nn.CrossEntropyLoss()
for _ in range(epochs):
model.train()
for xb, yb in (loader or train_loader):
opt.zero_grad()
loss_fn(model(xb), yb).backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
opt.step()
model.eval()
right = seen = 0
with torch.no_grad():
for xb, yb in (val or val_loader):
right += (model(xb).argmax(1) == yb).sum().item()
seen += yb.numel()
return right / seen
import time
class AttentionBlock(nn.Module):
def __init__(self, width, heads, expansion=4, dropout=0.1):
super().__init__()
self.norm1 = nn.LayerNorm(width)
self.attn = nn.MultiheadAttention(width, heads, dropout=dropout,
batch_first=True)
self.norm2 = nn.LayerNorm(width)
self.ff = nn.Sequential(
nn.Linear(width, width * expansion), nn.GELU(),
nn.Linear(width * expansion, width), nn.Dropout(dropout))
def forward(self, x):
h = self.norm1(x)
a, _ = self.attn(h, h, h, need_weights=False)
x = x + a
return x + self.ff(self.norm2(x))
class AttentionClassifier(nn.Module):
def __init__(self, width=32, heads=4, blocks=2):
super().__init__()
self.tok = nn.Embedding(10, width)
self.pos = nn.Embedding(16, width)
self.blocks = nn.ModuleList([AttentionBlock(width, heads)
for _ in range(blocks)])
self.norm = nn.LayerNorm(width)
self.head = nn.Linear(width, 2)
def forward(self, x):
h = self.tok(x) + self.pos(torch.arange(x.shape[1]))
for block in self.blocks:
h = block(h)
return self.head(self.norm(h).mean(dim=1))
class Recurrent(nn.Module):
def __init__(self, cell):
super().__init__()
self.emb = nn.Embedding(10, 32)
self.rnn = {'rnn': nn.RNN, 'gru': nn.GRU,
'lstm': nn.LSTM}[cell](32, 64, batch_first=True)
self.head = nn.Linear(64, 2)
def forward(self, x):
out, _ = self.rnn(self.emb(x))
return self.head(out[:, -1, :])
print('%-26s %10s %10s %9s' % ('', 'params', 'accuracy', 'time'))
for name, maker in [('plain RNN', lambda: Recurrent('rnn')),
('LSTM', lambda: Recurrent('lstm')),
('attention, 1 block',
lambda: AttentionClassifier(blocks=1)),
('attention, 2 blocks',
lambda: AttentionClassifier(blocks=2))]:
torch.manual_seed(0)
model = maker()
start = time.time()
acc = fit(model)
print('%-26s %10d %10.4f %8.0fs'
% (name, sum(p.numel() for p in model.parameters()), acc,
time.time() - start))
params accuracy time
plain RNN 6722 0.6380 5s
LSTM 25538 0.9860 6s
attention, 1 block 13666 1.0000 10s
attention, 2 blocks 26370 1.0000 12s
Reading what it attended to
import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset
def make_sequences(n, length=8, seed=0):
"""Week 7's task: is the digit at position 0 repeated later?
Balanced by construction, and unanswerable from a bag of counts."""
g = torch.Generator().manual_seed(seed)
first = torch.randint(1, 10, (n, 1), generator=g)
rest = torch.randint(1, 9, (n, length - 1), generator=g)
rest = rest + (rest >= first).long()
y = torch.randint(0, 2, (n,), generator=g)
where = torch.randint(0, length - 1, (n, 1), generator=g)
idx = torch.arange(length - 1).unsqueeze(0)
plant = (y.unsqueeze(1) == 1) & (idx == where)
rest = torch.where(plant, first.expand_as(rest), rest)
return torch.cat([first, rest], dim=1), y
X_tr, y_tr = make_sequences(8000, seed=0)
X_va, y_va = make_sequences(2000, seed=1)
train_loader = DataLoader(TensorDataset(X_tr, y_tr), batch_size=64,
shuffle=True)
val_loader = DataLoader(TensorDataset(X_va, y_va), batch_size=512)
def fit(model, epochs=10, lr=3e-3, loader=None, val=None):
torch.manual_seed(0)
opt = torch.optim.AdamW(model.parameters(), lr=lr)
loss_fn = nn.CrossEntropyLoss()
for _ in range(epochs):
model.train()
for xb, yb in (loader or train_loader):
opt.zero_grad()
loss_fn(model(xb), yb).backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
opt.step()
model.eval()
right = seen = 0
with torch.no_grad():
for xb, yb in (val or val_loader):
right += (model(xb).argmax(1) == yb).sum().item()
seen += yb.numel()
return right / seen
class Simple(nn.Module):
def __init__(self, width=32, heads=1):
super().__init__()
self.tok = nn.Embedding(10, width)
self.pos = nn.Embedding(16, width)
self.attn = nn.MultiheadAttention(width, heads, batch_first=True)
self.norm = nn.LayerNorm(width)
self.head = nn.Linear(width, 2)
def forward(self, x, want_weights=False):
h = self.tok(x) + self.pos(torch.arange(x.shape[1]))
a, w = self.attn(h, h, h)
out = self.head(self.norm(h + a).mean(dim=1))
return (out, w) if want_weights else out
torch.manual_seed(0)
model = Simple()
print('accuracy %.4f\n' % fit(model))
model.eval()
with torch.no_grad():
_, w = model(X_va[:1], want_weights=True)
seq = X_va[0].tolist()
print('sequence %s, label %d' % (''.join(str(d) for d in seq), y_va[0]))
print('\nattention weights from each position:')
print(' ' + ' '.join('%5d' % d for d in seq))
for i, row in enumerate(w[0]):
print('%2d %s %s' % (i, str(seq[i]).rjust(2),
' '.join('%5.2f' % v for v in row.tolist())))
accuracy 0.8725
sequence 57959964, label 1
attention weights from each position:
5 7 9 5 9 9 6 4
0 5 0.00 0.00 0.00 1.00 0.00 0.00 0.00 0.00
1 7 0.00 0.00 0.10 0.28 0.19 0.35 0.02 0.06
2 9 0.00 0.00 0.36 0.00 0.35 0.29 0.00 0.00
3 5 0.93 0.00 0.00 0.07 0.00 0.00 0.00 0.00
4 9 0.00 0.00 0.13 0.00 0.30 0.57 0.00 0.00
5 9 0.00 0.00 0.43 0.00 0.33 0.24 0.00 0.00
6 6 0.00 0.00 0.00 0.00 0.00 0.00 1.00 0.00
7 4 0.00 0.00 0.00 0.00 0.00 0.00 0.00 1.00
Read the first row. Position 0 holds a 5, and it has put essentially all of its weight on the one later position that also holds a 5. The model has discovered the structure of the task and is using attention to do exactly the lookup the task requires, which is the clearest picture of attention working that this course can offer.
Do not over-read an attention map
It is tempting to treat these weights as an explanation. They are not: they show what the model looked at, not why it decided. A well-documented result is that attention weights can be changed substantially while leaving the prediction almost unaltered, which means they are not a faithful account of the reasoning. Read them as a debugging aid, in the same spirit as week 5's occlusion test, and not as evidence.
Day 7 takeaway
A transformer block is attention plus a feed forward network, each in a residual wrapper with layer normalisation applied first. It matches or beats the recurrent models on this task, computes every position in parallel, and its attention maps are a debugging tool rather than an explanation. Week 9 stacks these into a full transformer.