Skip to main content
PyTorch advanced Lesson 8 of 11

Transformers in PyTorch

Build a Transformer from scratch, understand self-attention, and fine-tune pre-trained models with HuggingFace Transformers.

Real-World Scenario

A company needs to classify customer support tickets into 20 categories. Training a Transformer from scratch requires millions of examples. Fine-tuning DistilBERT on 2,000 labeled tickets for 3 epochs achieves 91% accuracy — in 15 minutes on a single GPU. The same task with a bag-of-words model reaches 73%.

Self-Attention From Scratch

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

class MultiHeadSelfAttention(nn.Module):
    """Multi-head self-attention — the core of every Transformer."""

    def __init__(self, d_model: int, n_heads: int, dropout: float = 0.1):
        super().__init__()
        assert d_model % n_heads == 0
        self.d_model  = d_model
        self.n_heads  = n_heads
        self.d_k      = d_model // n_heads  # dimension per head

        self.W_q = nn.Linear(d_model, d_model)
        self.W_k = nn.Linear(d_model, d_model)
        self.W_v = nn.Linear(d_model, d_model)
        self.W_o = nn.Linear(d_model, d_model)
        self.dropout = nn.Dropout(dropout)

    def split_heads(self, x: torch.Tensor) -> torch.Tensor:
        B, L, D = x.shape
        # (B, L, D) → (B, n_heads, L, d_k)
        return x.view(B, L, self.n_heads, self.d_k).transpose(1, 2)

    def forward(
        self,
        x: torch.Tensor,                          # (B, L, D)
        mask: torch.Tensor | None = None,         # (B, 1, 1, L) for padding
    ) -> torch.Tensor:
        B, L, _ = x.shape

        Q = self.split_heads(self.W_q(x))   # (B, H, L, d_k)
        K = self.split_heads(self.W_k(x))
        V = self.split_heads(self.W_v(x))

        # Scaled dot-product attention
        scores = torch.matmul(Q, K.transpose(-2, -1)) / math.sqrt(self.d_k)  # (B, H, L, L)

        if mask is not None:
            scores = scores.masked_fill(mask == 0, float("-inf"))

        attn_weights = self.dropout(F.softmax(scores, dim=-1))  # (B, H, L, L)
        context      = torch.matmul(attn_weights, V)            # (B, H, L, d_k)

        # Merge heads: (B, H, L, d_k) → (B, L, D)
        context = context.transpose(1, 2).contiguous().view(B, L, self.d_model)
        return self.W_o(context)


class TransformerEncoderBlock(nn.Module):
    def __init__(self, d_model: int, n_heads: int, d_ff: int, dropout: float = 0.1):
        super().__init__()
        self.attention  = MultiHeadSelfAttention(d_model, n_heads, dropout)
        self.norm1      = nn.LayerNorm(d_model)
        self.norm2      = nn.LayerNorm(d_model)
        self.ff         = nn.Sequential(
            nn.Linear(d_model, d_ff), nn.GELU(), nn.Dropout(dropout),
            nn.Linear(d_ff, d_model), nn.Dropout(dropout),
        )

    def forward(self, x: torch.Tensor, mask: torch.Tensor | None = None) -> torch.Tensor:
        # Pre-norm (modern variant) + residual connections
        x = x + self.attention(self.norm1(x), mask)
        x = x + self.ff(self.norm2(x))
        return x


class TransformerClassifier(nn.Module):
    def __init__(
        self, vocab_size: int, d_model: int, n_heads: int,
        n_layers: int, d_ff: int, max_len: int, n_classes: int,
    ):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, d_model, padding_idx=0)
        self.pos_embed = nn.Embedding(max_len, d_model)  # learned positional encoding
        self.dropout   = nn.Dropout(0.1)
        self.blocks    = nn.ModuleList([
            TransformerEncoderBlock(d_model, n_heads, d_ff)
            for _ in range(n_layers)
        ])
        self.norm       = nn.LayerNorm(d_model)
        self.classifier = nn.Linear(d_model, n_classes)

    def forward(self, token_ids: torch.Tensor) -> torch.Tensor:
        B, L  = token_ids.shape
        pos   = torch.arange(L, device=token_ids.device).unsqueeze(0)
        x     = self.dropout(self.embedding(token_ids) + self.pos_embed(pos))
        mask  = (token_ids != 0).unsqueeze(1).unsqueeze(2)   # padding mask

        for block in self.blocks:
            x = block(x, mask)

        x = self.norm(x)
        # Use [CLS]-equivalent: mean of non-padding tokens
        mask_expanded = (token_ids != 0).unsqueeze(-1).float()
        pooled = (x * mask_expanded).sum(1) / mask_expanded.sum(1).clamp(min=1)
        return self.classifier(pooled)


# Quick smoke test
model = TransformerClassifier(
    vocab_size=10_000, d_model=128, n_heads=4,
    n_layers=2, d_ff=256, max_len=64, n_classes=5,
)
x = torch.randint(0, 10_000, (8, 32))  # batch of 8, seq len 32
logits = model(x)
print(f"Output shape: {logits.shape}")  # (8, 5)
total_params = sum(p.numel() for p in model.parameters())
print(f"Parameters:   {total_params:,}")

Fine-Tuning with HuggingFace Transformers

from transformers import AutoTokenizer, AutoModelForSequenceClassification
from torch.utils.data import DataLoader, Dataset
import torch
import torch.nn as nn
import torch.optim as optim
from torch.amp import autocast, GradScaler

# Sample data: 5-class ticket classification
TEXTS = [
    "My payment failed and I was charged twice",
    "How do I reset my password?",
    "The app crashes when I open it on iOS 17",
    "I need to cancel my subscription",
    "When will my order arrive?",
] * 40   # 200 training examples

LABELS = [0, 1, 2, 3, 4] * 40   # 5 classes
LABEL_NAMES = ["billing", "account", "technical", "subscription", "shipping"]

# Tokenize with DistilBERT tokenizer
MODEL_NAME = "distilbert-base-uncased"
tokenizer  = AutoTokenizer.from_pretrained(MODEL_NAME)

class TicketDataset(Dataset):
    def __init__(self, texts: list[str], labels: list[int], max_len: int = 64):
        self.encodings = tokenizer(
            texts,
            truncation=True, padding="max_length",
            max_length=max_len, return_tensors="pt"
        )
        self.labels = torch.tensor(labels, dtype=torch.long)

    def __len__(self):
        return len(self.labels)

    def __getitem__(self, idx):
        return {
            "input_ids":      self.encodings["input_ids"][idx],
            "attention_mask": self.encodings["attention_mask"][idx],
            "labels":         self.labels[idx],
        }

dataset = TicketDataset(TEXTS, LABELS)
train_size = int(0.8 * len(dataset))
train_ds, val_ds = torch.utils.data.random_split(
    dataset, [train_size, len(dataset) - train_size],
    generator=torch.Generator().manual_seed(42)
)
train_loader = DataLoader(train_ds, batch_size=16, shuffle=True)
val_loader   = DataLoader(val_ds,   batch_size=32)

# Load pre-trained model with classification head
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model  = AutoModelForSequenceClassification.from_pretrained(
    MODEL_NAME, num_labels=5
).to(device)

# Fine-tuning: use a very low learning rate to not destroy pre-trained weights
optimizer = optim.AdamW(model.parameters(), lr=2e-5, weight_decay=0.01)
scheduler = optim.lr_scheduler.LinearLR(
    optimizer, start_factor=0.1, total_iters=len(train_loader)  # warmup
)
scaler = GradScaler(device=device.type)


def train_epoch(model, loader, optimizer, scaler):
    model.train()
    total_loss, correct = 0, 0
    for batch in loader:
        input_ids  = batch["input_ids"].to(device)
        attn_mask  = batch["attention_mask"].to(device)
        labels     = batch["labels"].to(device)

        optimizer.zero_grad()
        with autocast(device_type=device.type):
            outputs = model(input_ids, attention_mask=attn_mask, labels=labels)
            loss    = outputs.loss

        scaler.scale(loss).backward()
        scaler.unscale_(optimizer)
        torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
        scaler.step(optimizer)
        scaler.update()
        scheduler.step()

        total_loss += loss.item()
        correct    += (outputs.logits.argmax(-1) == labels).sum().item()

    return total_loss / len(loader), correct / len(loader.dataset)


def evaluate(model, loader):
    model.eval()
    correct = 0
    with torch.no_grad():
        for batch in loader:
            input_ids = batch["input_ids"].to(device)
            attn_mask = batch["attention_mask"].to(device)
            labels    = batch["labels"].to(device)
            outputs   = model(input_ids, attention_mask=attn_mask)
            correct  += (outputs.logits.argmax(-1) == labels).sum().item()
    return correct / len(loader.dataset)


for epoch in range(3):
    train_loss, train_acc = train_epoch(model, train_loader, optimizer, scaler)
    val_acc = evaluate(model, val_loader)
    print(f"Epoch {epoch+1}: loss={train_loss:.4f}  train_acc={train_acc:.3f}  val_acc={val_acc:.3f}")

# Save fine-tuned model
model.save_pretrained("./fine_tuned_ticket_classifier")
tokenizer.save_pretrained("./fine_tuned_ticket_classifier")

Inference with Saved Model

from transformers import AutoTokenizer, AutoModelForSequenceClassification
import torch

LABEL_NAMES = ["billing", "account", "technical", "subscription", "shipping"]

tokenizer = AutoTokenizer.from_pretrained("./fine_tuned_ticket_classifier")
model     = AutoModelForSequenceClassification.from_pretrained("./fine_tuned_ticket_classifier")
model.eval()

def classify_ticket(text: str) -> dict:
    inputs = tokenizer(text, return_tensors="pt", truncation=True, max_length=64)
    with torch.no_grad():
        logits = model(**inputs).logits
    probs   = torch.softmax(logits, dim=-1)[0]
    top_idx = probs.argmax().item()
    return {
        "category":   LABEL_NAMES[top_idx],
        "confidence": round(probs[top_idx].item(), 4),
        "all_scores": {name: round(p.item(), 4) for name, p in zip(LABEL_NAMES, probs)},
    }

tickets = [
    "I was double-charged on my credit card last night",
    "App won't load, stuck on loading screen since yesterday",
    "Please cancel my annual plan and refund the remaining months",
]
for t in tickets:
    result = classify_ticket(t)
    print(f"'{t[:50]}...'")
    print(f"  → {result['category']} ({result['confidence']:.1%})\n")

Frequently Asked Questions

Do I need to build a Transformer from scratch to use them?
No — HuggingFace Transformers gives you pre-trained models in 3 lines of code. Build from scratch to understand the internals, then use HuggingFace for production. The from-scratch implementation here is educational, not production code.
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 pre-trained weights on your task data (at a low learning rate). Fine-tuning almost always gives better accuracy. Feature extraction is faster and useful when you have very little labeled data (<100 examples).