Chapter 6 · Main chapter
Fine-tuning GPT for Spam
A pretrained language model already understands English. To make it a spam detector we barely touch it — swap the output head, unfreeze one block, and train on 1,494 text messages until it hits ~96%.
Adapted from the original notebook by Sebastian Raschka · the Python shown is the book's; the demos run a faithful JavaScript port in your browser
Act 1 · From inbox to batch
Classification · this chapter
“Win a $1000 prize…”
↓
one of a fixed set of labels
Instruction · chapter 7
“Rewrite this politely…”
↓
free-form generated text
any sequence of words
Two flavors of fine-tuning. Classification maps each input to one of a fixed set of labels — here, spam or not spam. Instruction tuning (chapter 7) lets the model write free-form replies. This chapter builds the classifier.
We have batches of padded token IDs and their labels. Now the model that reads them.
Act 2 · Model surgery
next-token logits
↑token IDs
Start from the pretrained GPT of chapters 4–5 — 124 million weights that already model English. Its job was predicting the next token out of 50,257. We only need it to answer one question: spam or not?
Freezing all but the top keeps the pretrained knowledge intact and makes training fast — only a few million weights move. Time to move them.
Act 3 · Watch it learn
random head · loss 2.58 · basically a coin flip
Before training, the fresh classification head is random — it gets about 49% of the test set right, no better than guessing.
The lab
You against the model. Read a real held-out message, call it — then flip the card to see the fine-tuned GPT's verdict, its confidence, and the true label.
Spam inbox
Guess, then reveal the model's precomputed verdict on real test-set messages.
Loading test messages…
The real code
The demos run the numbers from the executed notebook. Here is the Python they
mirror. First, the dataset — tokenize every message, then pad to the longest one
with the <|endoftext|> token so a batch is a clean rectangle:
class SpamDataset(Dataset):
def __init__(self, csv_file, tokenizer, max_length=None, pad_token_id=50256):
self.data = pd.read_csv(csv_file)
self.encoded_texts = [tokenizer.encode(t) for t in self.data["Text"]]
if max_length is None:
max_length = max(len(e) for e in self.encoded_texts)
self.max_length = max_length
# truncate, then right-pad every sequence to max_length
self.encoded_texts = [
e[:max_length] + [pad_token_id] * (max_length - len(e))
for e in self.encoded_texts
]The surgery is four steps: freeze everything, replace the head, unfreeze the last block and the final norm:
for param in model.parameters(): # 1. freeze all 124M weights
param.requires_grad = False
torch.manual_seed(123) # 2. swap 768→50257 head for 768→2
model.out_head = torch.nn.Linear(in_features=768, out_features=2)
for param in model.trf_blocks[-1].parameters(): # 3. unfreeze last block
param.requires_grad = True
for param in model.final_norm.parameters(): # 4. + final norm
param.requires_grad = TrueClassifying reads the last token's logits — the only position that has attended to the whole message:
def classify_review(text, model, tokenizer, device, max_length, pad_token_id=50256):
ids = tokenizer.encode(text)[:max_length]
ids += [pad_token_id] * (max_length - len(ids))
x = torch.tensor(ids, device=device).unsqueeze(0)
with torch.no_grad():
logits = model(x)[:, -1, :] # <-- last token only
return "spam" if torch.argmax(logits, dim=-1).item() == 1 else "not spam"Show the training loop
def train_classifier_simple(model, train_loader, val_loader, optimizer, device,
num_epochs, eval_freq, eval_iter):
train_losses, val_losses, train_accs, val_accs = [], [], [], []
examples_seen, global_step = 0, -1
for epoch in range(num_epochs):
model.train()
for input_batch, target_batch in train_loader:
optimizer.zero_grad()
loss = calc_loss_batch(input_batch, target_batch, model, device)
loss.backward()
optimizer.step()
examples_seen += input_batch.shape[0]
global_step += 1
if global_step % eval_freq == 0:
train_loss, val_loss = evaluate_model(
model, train_loader, val_loader, device, eval_iter)
train_losses.append(train_loss)
val_losses.append(val_loss)
train_accs.append(calc_accuracy_loader(train_loader, model, device, num_batches=eval_iter))
val_accs.append(calc_accuracy_loader(val_loader, model, device, num_batches=eval_iter))
return train_losses, val_losses, train_accs, val_accs, examples_seen
# optimizer = AdamW(model.parameters(), lr=5e-5, weight_decay=0.1); num_epochs = 5Four things to remember
- Classification fine-tuning is surgery, not retraining. Freeze the pretrained body, replace the output head, and let only the top few layers adapt — a handful of the 124M weights actually move.
- Balance beats size. A 6.5× ham/spam imbalance is undersampled to 747-per-class; a tiny, even dataset trains a strong classifier in minutes.
- Classify from the last token. Causal attention means only the final position has read the whole message, so its output vector carries the verdict.
- A little goes a long way. Five epochs on 1,045 training messages lifts a coin-flip head to ~96% held-out accuracy.
Adapted from Build a Large Language Model (From Scratch)
by Sebastian Raschka — original notebook
(Apache 2.0). The curves, accuracies, and inbox verdicts are torch's real outputs
from fine-tuning GPT-2 (124M), reproduced with npm run build:spam and gated by
npm run verify:spam.