Skip to main content
PyTorch · lesson 9 of 12
PyTorch advanced Lesson 9 of 12

Transformers in PyTorch

Understand Transformers: attention by hand, padding and causal masks, multi-head attention, positional embeddings, and the Hugging Face API.

By SkillByExample · Updated

Real-World Scenario

A company needs to classify customer support tickets into categories. Training a Transformer from scratch would take far more labeled tickets than they have, so the practical route is to fine-tune a pretrained model such as DistilBERT on the tickets they do have. Doing that well — and debugging it when predictions look wrong — is much easier once you have seen what attention, masks, and positions actually compute.

Scaled Dot-Product Attention by Hand

Each token’s query is compared with every token’s key; the softmax of those scores says how much of each token’s value to take.

import math
import torch
import torch.nn.functional as F

torch.manual_seed(0)
Q = torch.randn(1, 3, 4)            # (batch, tokens, d_k)
K = torch.randn(1, 3, 4)
V = torch.tensor([[[1., 0., 0., 0.], [0., 1., 0., 0.], [0., 0., 1., 0.]]])

scores = Q @ K.transpose(-2, -1) / math.sqrt(Q.size(-1))   # (1, 3, 3)
weights = scores.softmax(dim=-1)
out = weights @ V

print(weights.squeeze(0).round(decimals=3))
print(weights.sum(dim=-1))                                  # each row sums to 1
print(out.squeeze(0).round(decimals=3))
print(torch.allclose(out, F.scaled_dot_product_attention(Q, K, V), atol=1e-6))
$ python attention.py
tensor([[0.4880, 0.3510, 0.1610],
        [0.3950, 0.2930, 0.3120],
        [0.4540, 0.3870, 0.1580]])
tensor([[1., 1., 1.]])
tensor([[0.4880, 0.3510, 0.1610, 0.0000],
        [0.3950, 0.2930, 0.3120, 0.0000],
        [0.4540, 0.3870, 0.1580, 0.0000]])
True

Row i of the weights says how much token i attends to each of the three tokens, and every row sums to 1. The values were chosen as one-hot vectors, so the output is literally the attention weights copied into the first three columns — in general, each output row is a weighted average of the value rows. Dividing by √d_k keeps the scores from growing with the vector size, which would push the softmax towards one-hot. PyTorch’s fused F.scaled_dot_product_attention computes the same thing.

Padding Masks — and a NaN Trap

Padding tokens must not receive attention. Setting their scores to -inf before the softmax gives them exactly zero weight.

import torch
import torch.nn.functional as F

torch.manual_seed(0)
x = torch.randn(1, 4, 8)                         # 4 tokens; the last two are padding
keep = torch.tensor([[True, True, False, False]])

scores = x @ x.transpose(-2, -1)
masked = scores.masked_fill(~keep[:, None, :], float("-inf"))
print(masked.softmax(dim=-1)[0].round(decimals=3))

nothing = torch.full((1, 4), float("-inf"))      # a row where every key is masked
print(nothing.softmax(dim=-1))
$ python masking.py
tensor([[0.9990, 0.0010, 0.0000, 0.0000],
        [0.0590, 0.9410, 0.0000, 0.0000],
        [0.3000, 0.7000, 0.0000, 0.0000],
        [0.3070, 0.6930, 0.0000, 0.0000]])
tensor([[nan, nan, nan, nan]])

Every row puts all its weight on the two real tokens, including the rows for the padding positions — those outputs are ignored later anyway. The last line is the trap: if every key in a row is masked, softmax computes 0/0 and returns NaN, which then spreads through every later layer. A batch that contains an all-padding sequence is enough to trigger it.

Multi-Head Attention

Multi-head attention splits the model dimension into several smaller heads that attend independently, then recombines them.

import torch
import torch.nn as nn

class MultiHeadSelfAttention(nn.Module):
    def __init__(self, d_model, n_heads):
        super().__init__()
        self.h, self.d_k = n_heads, d_model // n_heads
        self.qkv = nn.Linear(d_model, 3 * d_model)     # Q, K, V projections in one matmul
        self.out = nn.Linear(d_model, d_model)

    def forward(self, x):
        B, L, D = x.shape
        q, k, v = self.qkv(x).view(B, L, 3, self.h, self.d_k).permute(2, 0, 3, 1, 4)
        print("per-head q:", tuple(q.shape))           # (batch, heads, tokens, d_k)
        ctx = nn.functional.scaled_dot_product_attention(q, k, v)
        return self.out(ctx.transpose(1, 2).reshape(B, L, D))

mine = MultiHeadSelfAttention(d_model=128, n_heads=4)
print("output:    ", tuple(mine(torch.randn(8, 32, 128)).shape))

builtin = nn.MultiheadAttention(128, 4, batch_first=True)
count = lambda m: sum(p.numel() for p in m.parameters())
print("params:    ", count(mine), count(builtin))
$ python mha.py
per-head q: (8, 4, 32, 32)
output:     (8, 32, 128)
params:     66048 66048

With d_model=128 and 4 heads, each head works on 32-dimensional queries, keys, and values: (batch, heads, tokens, d_k). Computing Q, K, and V with one Linear(128, 384) and reshaping is equivalent to three separate projections and faster. The parameter count matches nn.MultiheadAttention exactly — 4 × 128² + 4 × 128 = 66,048 — because it is the same computation.

Attention Ignores Word Order

import torch
import torch.nn as nn

torch.manual_seed(0)
layer = nn.TransformerEncoderLayer(d_model=16, nhead=2, batch_first=True, dropout=0.0)
layer.eval()
emb = nn.Embedding(10, 16)

dog_bites_man = torch.tensor([[3, 7, 5]])
man_bites_dog = torch.tensor([[5, 7, 3]])

with torch.no_grad():
    a = layer(emb(dog_bites_man))
    b = layer(emb(man_bites_dog))
print(torch.allclose(a[0, 0], b[0, 2], atol=1e-6))       # token 3 gets the same vector
print(torch.allclose(a.mean(1), b.mean(1), atol=1e-6))   # pooled sentences are identical
$ python order_blind.py
True
True

“Dog bites man” and “man bites dog” contain the same tokens in a different order. A Transformer layer gives token 3 exactly the same output vector in both sentences, and averaging over the tokens produces the same sentence vector. Self-attention treats its input as a set; nothing in it knows which token came first.

Positions Make Order Learnable

To see what that costs, train the same small classifier on a task that depends on order — does token 2 appear before token 3? — with and without learned position embeddings. The model and data generator, saved as order_model.py:

import torch
import torch.nn as nn

class Classifier(nn.Module):
    def __init__(self, use_positions, vocab=12, d=32, max_len=8):
        super().__init__()
        self.tok = nn.Embedding(vocab, d)
        self.pos = nn.Embedding(max_len, d) if use_positions else None
        layer = nn.TransformerEncoderLayer(d, nhead=4, dim_feedforward=64, batch_first=True)
        self.encoder = nn.TransformerEncoder(layer, num_layers=2)
        self.head = nn.Linear(d, 2)

    def forward(self, ids):
        x = self.tok(ids)
        if self.pos is not None:
            x = x + self.pos(torch.arange(ids.size(1)))       # learned position vectors
        return self.head(self.encoder(x).mean(dim=1))

def make_data(n, g):
    ids = torch.stack([torch.randperm(10, generator=g)[:8] + 2 for _ in range(n)])
    first_a = (ids == 2).float().argmax(1) + 8 * (ids != 2).all(1)
    first_b = (ids == 3).float().argmax(1) + 8 * (ids != 3).all(1)
    return ids, (first_a < first_b).long()                   # 1 if token 2 precedes token 3

The training script trains one model of each kind on the same data:

import torch
import torch.nn as nn
from order_model import Classifier, make_data

g = torch.Generator().manual_seed(0)
(X, y), (Xt, yt) = make_data(4000, g), make_data(1000, g)
for use_positions in (False, True):
    torch.manual_seed(0)
    model = Classifier(use_positions)
    opt = torch.optim.AdamW(model.parameters(), lr=1e-3)
    for epoch in range(15):
        for i in range(0, 4000, 64):
            opt.zero_grad()
            nn.functional.cross_entropy(model(X[i:i + 64]), y[i:i + 64]).backward()
            opt.step()
    model.eval()
    with torch.no_grad():
        acc = (model(Xt).argmax(1) == yt).float().mean().item()
    print(f"positions={use_positions!s:5}  test accuracy {acc:.3f}")
$ python order_task.py
positions=False  test accuracy 0.695
positions=True   test accuracy 1.000

With positions, the model solves the task perfectly. Without them it reaches about 69% — and that is not random luck but the best an order-blind model can do. Each sequence draws 8 of 10 possible tokens, so in about 38% of sequences token 2 or 3 is missing, and presence alone decides the label; in the remaining 62% the model can only guess. 0.378 + 0.622 × 0.5 ≈ 0.689. Adding self.pos(...) to the token embeddings is the entire fix.

The Hugging Face API

Hugging Face Transformers wraps pretrained models and their tokenizers behind a common API. This machine could not reach the Hugging Face Hub, so the setup script below builds a small local tokenizer and a small DistilBERT configuration in the same format that from_pretrained("distilbert-base-uncased") would download. Saved as tickets.py:

TEXTS = [
    "my payment failed and i was charged twice",
    "how do i reset my password",
    "the app crashes when i open it",
    "i need to cancel my subscription",
    "when will my order arrive",
]
LABELS = [0, 1, 2, 3, 4]
LABEL_NAMES = ["billing", "account", "technical", "subscription", "shipping"]
from pathlib import Path
from transformers import BertTokenizerFast, DistilBertConfig
from tickets import TEXTS

# Offline stand-in for AutoTokenizer.from_pretrained("distilbert-base-uncased")
words = sorted({w for t in TEXTS for w in t.split()})
Path("tiny").mkdir(exist_ok=True)
Path("tiny/vocab.txt").write_text("\n".join(["[PAD]", "[UNK]", "[CLS]", "[SEP]", "[MASK]"] + words))
tokenizer = BertTokenizerFast("tiny/vocab.txt")
tokenizer.save_pretrained("tiny")

config = DistilBertConfig(vocab_size=tokenizer.vocab_size, dim=64, n_layers=2, n_heads=2,
                          hidden_dim=128, num_labels=5)
config.save_pretrained("tiny")

batch = tokenizer(["how do i reset my password", "refund please"], padding=True)
print(tokenizer.convert_ids_to_tokens(batch["input_ids"][1]))
print(batch["attention_mask"])
$ python hf_setup.py
['[CLS]', '[UNK]', '[UNK]', '[SEP]', '[PAD]', '[PAD]', '[PAD]', '[PAD]']
[[1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 0, 0, 0, 0]]

The tokenizer adds [CLS] and [SEP] around every text and pads shorter texts in the batch with [PAD], while attention_mask marks which positions are real — the same padding mask as above. refund and please are not in this tiny vocabulary and become [UNK]. A real WordPiece vocabulary would split unfamiliar words into known pieces instead.

Training a sequence classifier is an ordinary PyTorch loop:

import torch
import transformers
from transformers import AutoModelForSequenceClassification, AutoConfig, AutoTokenizer
from tickets import TEXTS, LABELS

transformers.logging.disable_progress_bar()
tokenizer = AutoTokenizer.from_pretrained("tiny")
torch.manual_seed(0)
# With Hub access: AutoModelForSequenceClassification.from_pretrained(
#     "distilbert-base-uncased", num_labels=5)
model = AutoModelForSequenceClassification.from_config(AutoConfig.from_pretrained("tiny"))
print(f"{type(model).__name__}: {sum(p.numel() for p in model.parameters()):,} parameters")

batch = tokenizer(TEXTS, padding=True, return_tensors="pt")
labels = torch.tensor(LABELS)
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3)

model.train()
for step in range(1, 61):
    out = model(**batch, labels=labels)           # passing labels makes the model compute the loss
    out.loss.backward()
    optimizer.step()
    optimizer.zero_grad()
    if step % 20 == 0:
        print(f"step {step}  loss {out.loss.item():.4f}  logits {tuple(out.logits.shape)}")

model.save_pretrained("ticket_classifier")
tokenizer.save_pretrained("ticket_classifier")
$ python hf_finetune.py
DistilBertForSequenceClassification: 106,245 parameters
step 20  loss 1.5336  logits (5, 5)
step 40  loss 1.0760  logits (5, 5)
step 60  loss 0.4041  logits (5, 5)

from_config builds the architecture with random weights; from_pretrained — shown in the comment — would start from DistilBERT’s pretrained weights, and nothing else in the loop changes. Passing labels makes the model compute the cross-entropy loss itself and return it as out.loss, alongside out.logits. Pretrained fine-tuning uses a much smaller learning rate, typically 2e-5 to 5e-5, so as not to erase what pretraining learned; this randomly initialized model needs a larger one. save_pretrained writes the weights and config in the format from_pretrained reads back:

import torch
import transformers
from transformers import AutoModelForSequenceClassification, AutoTokenizer
from tickets import LABEL_NAMES

transformers.logging.disable_progress_bar()
tokenizer = AutoTokenizer.from_pretrained("ticket_classifier")
model = AutoModelForSequenceClassification.from_pretrained("ticket_classifier").eval()

for text in ["i was charged twice", "the app crashes on open", "refund please"]:
    inputs = tokenizer(text, return_tensors="pt")
    with torch.no_grad():
        probs = model(**inputs).logits.softmax(dim=-1)[0]
    unknown = tokenizer.convert_ids_to_tokens(inputs["input_ids"][0]).count("[UNK]")
    print(f"{text!r:28} -> {LABEL_NAMES[probs.argmax()]:12s} {probs.max():.2f}  ({unknown} unknown)")
$ python hf_predict.py
'i was charged twice'        -> billing      0.67  (0 unknown)
'the app crashes on open'    -> technical    0.65  (1 unknown)
'refund please'              -> billing      0.42  (2 unknown)

The model reloads from the directory without any class definitions. Its predictions follow the words it memorized: “charged” and “twice” point to billing, “app” and “crashes” to technical. “refund please” becomes [CLS], two [UNK]s the model never saw during training, and [SEP] — and it still picks “billing” (0.42, about twice the 0.2 of a uniform guess), an answer with no evidence behind it. A pretrained model with a full vocabulary, fine-tuned on hundreds of real tickets, is what fixes both problems.

Practice

1. Build a causal mask so each token can attend only to itself and earlier tokens, and check it against is_causal=True.
import math
import torch
import torch.nn.functional as F

torch.manual_seed(0)
q, k, v = torch.randn(3, 1, 4, 8).unbind(0)          # 4 tokens

scores = q @ k.transpose(-2, -1) / math.sqrt(8)
future = torch.triu(torch.ones(4, 4, dtype=torch.bool), diagonal=1)
weights = scores.masked_fill(future, float("-inf")).softmax(-1)
print(weights[0].round(decimals=3))

print(torch.allclose(weights @ v, F.scaled_dot_product_attention(q, k, v, is_causal=True),
                     atol=1e-6))
$ python p1_causal.py
tensor([[1.0000, 0.0000, 0.0000, 0.0000],
        [0.4930, 0.5070, 0.0000, 0.0000],
        [0.6220, 0.0320, 0.3460, 0.0000],
        [0.2030, 0.6230, 0.1020, 0.0710]])
True

torch.triu(..., diagonal=1) marks every position above the diagonal — the future — for masking, so the weight matrix is lower-triangular and the first token can only attend to itself. is_causal=True applies the same mask inside the fused kernel. This is how GPT-style decoders are trained to predict the next token.

2. Make a fully masked row produce a finite result instead of NaN.
import torch

row = torch.full((1, 4), float("-inf"))
print(row.softmax(-1))

safe = torch.full((1, 4), torch.finfo(torch.float32).min)   # very negative, but finite
print(safe.softmax(-1))
$ python p2_safe_mask.py
tensor([[nan, nan, nan, nan]])
tensor([[0.2500, 0.2500, 0.2500, 0.2500]])

The most negative finite float still underflows to zero weight in any row that has a real key, but a row with none becomes a uniform average instead of NaN. Since those outputs belong to padding and are ignored downstream, finite garbage is harmless where NaN is not.

3. Show that src_key_padding_mask stops padding tokens from influencing the real tokens.
import torch
import torch.nn as nn

torch.manual_seed(0)
emb = nn.Embedding(20, 16, padding_idx=0)
layer = nn.TransformerEncoderLayer(16, nhead=2, batch_first=True, dropout=0.0).eval()

a = torch.tensor([[4, 9, 7, 0, 0]])
b = torch.tensor([[4, 9, 7, 13, 18]])            # same real tokens, junk where padding was
pad = torch.tensor([[False, False, False, True, True]])

with torch.no_grad():
    no_mask = torch.allclose(layer(emb(a))[0, :3], layer(emb(b))[0, :3], atol=1e-5)
    masked = torch.allclose(layer(emb(a), src_key_padding_mask=pad)[0, :3],
                            layer(emb(b), src_key_padding_mask=pad)[0, :3], atol=1e-5)
print("without mask, real tokens unaffected by padding:", no_mask)
print("with mask,    real tokens unaffected by padding:", masked)
$ python p3_padding_mask.py
without mask, real tokens unaffected by padding: False
with mask,    real tokens unaffected by padding: True

Without the mask, real tokens attend to whatever sits in the padding positions, so changing those tokens changes the real tokens’ outputs. With src_key_padding_mask (True = ignore), the first three outputs are identical no matter what is in the padding. The Hugging Face models do the same thing with attention_mask, where 1 means keep.

Frequently Asked Questions

Do I need to build a Transformer from scratch to use them?
No — Hugging Face Transformers loads pretrained models in a few lines of code. Building attention from scratch is for understanding the internals, which makes shape errors, masking bugs, and odd predictions much easier to debug. For production, start from a pretrained model and fine-tune it.
What is the difference between fine-tuning and feature extraction for pre-trained models?
Feature extraction freezes all model weights and uses the last hidden states as embeddings for a downstream classifier. Fine-tuning updates the pretrained weights on your task data at a low learning rate. Fine-tuning usually gives better accuracy; feature extraction is faster and a reasonable choice when you have very little labeled data.
Why does a Transformer need positional encodings?
Self-attention treats its input as a set: shuffling the tokens shuffles the outputs in exactly the same way, so without extra information 'dog bites man' and 'man bites dog' produce the same pooled representation. Adding a position vector to each token embedding, learned or sinusoidal, is what lets the model use word order.
Why does my attention output contain NaN?
The usual cause is a query whose every key is masked with -inf, such as a sequence made entirely of padding: softmax over a row of -inf is 0/0, which is NaN, and it spreads through every later layer. Make sure every row keeps at least one unmasked key, or mask with a large finite negative number such as torch.finfo(dtype).min.