Transformer Models: A collection of various transformer architectures, models, & their implementations-from-scratch (PyTorch) in Jupyter Notebooks.
- Author: Chizoba Obasi
- Github Repo: https://github.com/chizkidd/transformers-from-scratch
%%capture
!pip install watermark
!pip install torchinfo
!pip install datasets
%load_ext watermark
%watermark -a 'Chizoba Obasi' -v -p torch
Author: Chizoba Obasi Python implementation: CPython Python version : 3.12.12 IPython version : 7.34.0 torch: 2.10.0+cu128
BERT: Fine-tuning for Extractive Question Answering¶
This notebook implements BERT (Bidirectional Encoder Representations from Transformers) [1] from scratch, evolving directly from the Transformer Encoder in the previous "Attention Is All You Need" notebook.
The implementation has two phases:
Phase 1 — Pre-training: We train BERT with two self-supervised objectives:
- Masked Language Modeling (MLM): Randomly mask 15% of tokens and predict them.
- Next Sentence Prediction (NSP): Given two sentences, predict if sentence B actually follows sentence A.
Phase 2 — Fine-tuning: We take the pre-trained BERT and add a span-prediction head to fine-tune on SQuAD v2.0 for extractive question answering. The model must predict the start and end positions of the answer within the context, or determine that the question is unanswerable.
BERT: Pre-Training & Fine-Tuning¶

SQuAD v2.0: Extractive Question Answering¶
| Feature | SQuAD v1.1 | SQuAD v2.0 |
|---|---|---|
| Answerable | All questions have answers | ~50% are unanswerable |
| Task | Predict (start, end) span | Predict span or "no answer" |
| Metric | EM / F1 | EM / F1 (with no-answer handling) |
The input format is:
$$[\texttt{CLS}] \; \text{question} \; [\texttt{SEP}] \; \text{context} \; [\texttt{SEP}]$$
The model predicts two probability distributions over token positions — one for the start and one for the end of the answer span.
For unanswerable questions, both start and end point to the [CLS] token (position 0).
REFERENCES:
- Devlin, Jacob, et al. "BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding." NAACL, 2019.
- Rajpurkar, Pranav, et al. "Know What You Don't Know: Unanswerable Questions for SQuAD." ACL, 2018.
- Vaswani, Ashish, et al. "Attention is all you need." NeurIPS, 2017.
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
from torch.utils.data import Dataset, DataLoader
from torch.nn.utils.rnn import pad_sequence
from datasets import load_dataset
from collections import Counter
from sklearn.metrics import accuracy_score, f1_score, classification_report, confusion_matrix
from torch.amp import GradScaler, autocast
from torchinfo import summary
import seaborn as sns
import matplotlib.pyplot as plt
%matplotlib inline
import numpy as np
import math
import time
import os
import random
import re
if torch.cuda.is_available():
torch.backends.cudnn.deterministic = True
def set_all_seeds(seed):
os.environ["PL_GLOBAL_SEED"] = str(seed)
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
torch.cuda.manual_seed_all(seed)
##########################
### SETTINGS
##########################
RANDOM_SEED = 123
set_all_seeds(RANDOM_SEED)
# Vocabulary
VOCABULARY_SIZE = 10000
# BERT Architecture (BERT-Small for training feasibility)
D_MODEL = 768 # 256 # 768
N_LAYERS = 12 # 4 # 12
N_HEADS = 12 # 8 # 12
D_FF = 4 * D_MODEL # 4 * 768
DROPOUT = 0.1
MAX_LEN = 128
# Pre-training
PRETRAIN_EPOCHS = 10
PRETRAIN_BATCH_SIZE = 64
PRETRAIN_LR = 1e-4
# Fine-tuning
FINETUNE_EPOCHS = 10
FINETUNE_BATCH_SIZE = 32
FINETUNE_LR = 2e-5
PATIENCE = 3
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Device: {DEVICE}")
Device: cuda
1. Data Preparation & Vocabulary¶
We build a single vocabulary from WikiText-2 for pre-training, then reuse it for SQuAD v2.0 fine-tuning.
# ==========================================
# 1a. Load Datasets
# ==========================================
# Pre-training corpus
wiki_dataset = load_dataset("wikitext", "wikitext-2-raw-v1")
# Fine-tuning dataset (SQuAD v2.0)
squad_dataset = load_dataset("rajpurkar/squad_v2")
print(f"WikiText-2 samples - train: {len(wiki_dataset['train'])}, val: {len(wiki_dataset['validation'])}, test: {len(wiki_dataset['test'])}")
print(f"SQuAD v2.0 samples - train: {len(squad_dataset['train'])}, val: {len(squad_dataset['validation'])}")
print(f"\nSample SQuAD entry:")
sample = squad_dataset['train'][0]
print(f" Question: {sample['question']}")
print(f" Context: {sample['context'][:150]}...")
print(f" Answer: {sample['answers']}")
Warning: You are sending unauthenticated requests to the HF Hub. Please set a HF_TOKEN to enable higher rate limits and faster downloads.
WikiText-2 samples - train: 36718, val: 3760, test: 4358
SQuAD v2.0 samples - train: 130319, val: 11873
Sample SQuAD entry:
Question: When did Beyonce start becoming popular?
Context: Beyoncé Giselle Knowles-Carter (/biːˈjɒnseɪ/ bee-YON-say) (born September 4, 1981) is an American singer, songwriter, record producer and actress. Bor...
Answer: {'text': ['in the late 1990s'], 'answer_start': [269]}
# ==========================================
# 1b. Tokenizer & Vocabulary
# ==========================================
def tokenizer(text):
"""Simple word-level tokenizer with punctuation splitting."""
return re.findall(r"[\w']+|[.,!?;:\-]", text.lower())
# BERT Special Tokens
SPECIAL_TOKENS = ['[PAD]', '[UNK]', '[CLS]', '[SEP]', '[MASK]']
PAD_IDX, UNK_IDX, CLS_IDX, SEP_IDX, MASK_IDX = 0, 1, 2, 3, 4
# Build vocab from WikiText-2 (the pre-training corpus)
word_counts = Counter()
for item in wiki_dataset['train']:
text = item['text'].strip()
if len(text) > 0:
word_counts.update(tokenizer(text))
# Build vocab dict
vocab = {word: i for i, word in enumerate(SPECIAL_TOKENS)}
for word, _ in word_counts.most_common(VOCABULARY_SIZE - len(SPECIAL_TOKENS)):
if word not in vocab:
vocab[word] = len(vocab)
inv_vocab = {v: k for k, v in vocab.items()}
ACTUAL_VOCAB_SIZE = len(vocab)
print(f"Vocabulary size: {ACTUAL_VOCAB_SIZE}")
print(f"Sample tokens: {list(vocab.keys())[:15]}")
Vocabulary size: 10000 Sample tokens: ['[PAD]', '[UNK]', '[CLS]', '[SEP]', '[MASK]', 'the', ',', '.', 'of', 'and', 'in', 'to', 'a', 'was', '-']
1. Pre-training Data Pipeline (MLM + NSP)¶
For NSP, we construct sentence pairs:
- 50% of the time, sentence B actually follows sentence A (
IsNext = 1) - 50% of the time, sentence B is a random sentence (
NotNext = 0)
For MLM, we mask 15% of the input tokens in each pair:
- 80% → replaced with
[MASK] - 10% → replaced with a random token
- 10% → kept unchanged
# ==========================================
# 1c. Pre-training Dataset (MLM + NSP)
# ==========================================
def encode_sentence(text, max_len=None):
"""Tokenize and convert to indices."""
tokens = tokenizer(text)
if max_len is not None:
tokens = tokens[:max_len]
return [vocab.get(t, UNK_IDX) for t in tokens]
class BERTPretrainDataset(Dataset):
"""
Constructs MLM + NSP training pairs from raw text.
Each sample: ([CLS] sent_A [SEP] sent_B [SEP], segment_ids, mlm_labels, nsp_label)
"""
def __init__(self, text_dataset, vocab, max_len=128, mlm_prob=0.15):
super().__init__()
self.max_len = max_len
self.mlm_prob = mlm_prob
self.vocab = vocab
self.vocab_size = len(vocab)
# Collect non-empty sentences
self.sentences = []
for item in text_dataset:
text = item['text'].strip()
if len(text) > 10: # Skip very short/empty lines
self.sentences.append(text)
print(f" PretrainDataset: {len(self.sentences)} sentences loaded.")
def __len__(self):
return len(self.sentences) - 1 # Need pairs
def __getitem__(self, idx):
# --- NSP: 50% real next, 50% random ---
sent_a = encode_sentence(self.sentences[idx], max_len=self.max_len // 2 - 2)
if random.random() > 0.5 and idx + 1 < len(self.sentences):
sent_b = encode_sentence(self.sentences[idx + 1], max_len=self.max_len // 2 - 1)
nsp_label = 1 # IsNext
else:
rand_idx = random.randint(0, len(self.sentences) - 1)
sent_b = encode_sentence(self.sentences[rand_idx], max_len=self.max_len // 2 - 1)
nsp_label = 0 # NotNext
# --- Build BERT input: [CLS] A [SEP] B [SEP] ---
tokens = [CLS_IDX] + sent_a + [SEP_IDX] + sent_b + [SEP_IDX]
segment_ids = [0] * (len(sent_a) + 2) + [1] * (len(sent_b) + 1)
# Truncate to max_len
tokens = tokens[:self.max_len]
segment_ids = segment_ids[:self.max_len]
# --- MLM: Mask 15% of tokens (skip [CLS], [SEP], [PAD]) ---
mlm_labels = [-100] * len(tokens) # -100 = ignore in CrossEntropyLoss
for i in range(len(tokens)):
if tokens[i] in (CLS_IDX, SEP_IDX, PAD_IDX):
continue
if random.random() < self.mlm_prob:
mlm_labels[i] = tokens[i] # Store original for loss
r = random.random()
if r < 0.8:
tokens[i] = MASK_IDX # 80%: [MASK]
elif r < 0.9:
tokens[i] = random.randint(len(SPECIAL_TOKENS), self.vocab_size - 1) # 10%: random
# else: 10%: keep original (already set)
return (
torch.tensor(tokens, dtype=torch.long),
torch.tensor(segment_ids, dtype=torch.long),
torch.tensor(mlm_labels, dtype=torch.long),
torch.tensor(nsp_label, dtype=torch.long),
)
def pretrain_collate_fn(batch):
"""Pad all tensors in the batch to the same length."""
tokens, segments, mlm_labels, nsp_labels = zip(*batch)
tokens = pad_sequence(tokens, batch_first=True, padding_value=PAD_IDX)
segments = pad_sequence(segments, batch_first=True, padding_value=0)
mlm_labels = pad_sequence(mlm_labels, batch_first=True, padding_value=-100)
nsp_labels = torch.stack(nsp_labels)
return tokens, segments, mlm_labels, nsp_labels
# Build Pre-training DataLoaders
pretrain_train_dataset = BERTPretrainDataset(wiki_dataset['train'], vocab, max_len=MAX_LEN)
pretrain_val_dataset = BERTPretrainDataset(wiki_dataset['validation'], vocab, max_len=MAX_LEN)
pretrain_train_loader = DataLoader(
pretrain_train_dataset, batch_size=PRETRAIN_BATCH_SIZE,
shuffle=True, collate_fn=pretrain_collate_fn, num_workers=0
)
pretrain_val_loader = DataLoader(
pretrain_val_dataset, batch_size=PRETRAIN_BATCH_SIZE,
shuffle=False, collate_fn=pretrain_collate_fn, num_workers=0
)
# Quick sanity check
sample = next(iter(pretrain_train_loader))
print(f"\nBatch shapes: \n tokens: {sample[0].shape},\n segments: {sample[1].shape},\n "
f"mlm_labels: {sample[2].shape},\n nsp_labels: {sample[3].shape}")
print(f"\nTrain batches: {len(pretrain_train_loader)}, Val batches: {len(pretrain_val_loader)}")
PretrainDataset: 23547 sentences loaded. PretrainDataset: 2454 sentences loaded. Batch shapes: tokens: torch.Size([64, 128]), segments: torch.Size([64, 128]), mlm_labels: torch.Size([64, 128]), nsp_labels: torch.Size([64]) Train batches: 368, Val batches: 39
2. BERT Model Architecture¶
We reuse the core MultiHeadAttention and EncoderLayer from the Transformer notebook,
but with BERT-specific modifications:
- Learned Positional Embeddings (not sinusoidal)
- Segment (Token Type) Embeddings for NSP
- GELU activation in the Feed-Forward layers
- Two output heads: MLM head + NSP head
# ==========================================
# 2a. Multi-Head Attention (from Transformer notebook)
# ==========================================
class MultiHeadAttention(nn.Module):
def __init__(self, d_model, n_heads, dropout=0.1):
super().__init__()
assert d_model % n_heads == 0, "d_model must be divisible by n_heads"
self.d_model = d_model
self.n_heads = n_heads
self.d_k = d_model // n_heads
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.fc_out = nn.Linear(d_model, d_model)
self.dropout = nn.Dropout(dropout)
self.scale = math.sqrt(self.d_k)
def forward(self, query, key, value, mask=None):
batch_size = query.shape[0]
Q = self.w_q(query).view(batch_size, -1, self.n_heads, self.d_k).transpose(1, 2)
K = self.w_k(key).view(batch_size, -1, self.n_heads, self.d_k).transpose(1, 2)
V = self.w_v(value).view(batch_size, -1, self.n_heads, self.d_k).transpose(1, 2)
energy = torch.matmul(Q, K.transpose(-2, -1)) / self.scale
if mask is not None:
energy = energy.masked_fill(mask == 0, float('-inf'))
attention = torch.softmax(energy, dim=-1)
x = torch.matmul(self.dropout(attention), V)
x = x.transpose(1, 2).contiguous().view(batch_size, -1, self.d_model)
return self.fc_out(x)
# ==========================================
# 2b. Feed-Forward Network (GELU for BERT)
# ==========================================
class PositionwiseFeedForward(nn.Module):
"""BERT uses GELU activation instead of ReLU."""
def __init__(self, d_model, d_ff, dropout=0.1):
super().__init__()
self.fc1 = nn.Linear(d_model, d_ff)
self.fc2 = nn.Linear(d_ff, d_model)
self.dropout = nn.Dropout(dropout)
def forward(self, x):
return self.fc2(self.dropout(F.gelu(self.fc1(x))))
# ==========================================
# 2c. Encoder Layer (reused from Transformer)
# ==========================================
class EncoderLayer(nn.Module):
def __init__(self, d_model, n_heads, d_ff, dropout):
super().__init__()
self.self_attn = MultiHeadAttention(d_model, n_heads, dropout)
self.feed_forward = PositionwiseFeedForward(d_model, d_ff, dropout)
self.norm1 = nn.LayerNorm(d_model)
self.norm2 = nn.LayerNorm(d_model)
self.dropout = nn.Dropout(dropout)
def forward(self, src, src_mask):
# Post-LN (original BERT): Attn → Residual → LayerNorm
_src = self.self_attn(src, src, src, src_mask)
src = self.norm1(src + self.dropout(_src))
_src = self.feed_forward(src)
src = self.norm2(src + self.dropout(_src))
return src
# ==========================================
# 2d. BERT Model
# ==========================================
class BERT(nn.Module):
"""
BERT: Bidirectional Encoder Representations from Transformers.
Key differences from Transformer encoder:
- Learned positional embeddings (not sinusoidal)
- Segment embeddings for NSP (sentence A=0, B=1)
- [CLS] token pooling for classification
- Two pre-training heads: MLM + NSP
"""
def __init__(self, vocab_size, d_model, n_layers, n_heads, d_ff,
dropout, max_len, pad_idx, device):
super().__init__()
self.pad_idx = pad_idx
self.d_model = d_model
self.device = device
# --- BERT Embeddings (Learned, not sinusoidal) ---
self.token_embedding = nn.Embedding(vocab_size, d_model, padding_idx=pad_idx)
self.position_embedding = nn.Embedding(max_len, d_model)
self.segment_embedding = nn.Embedding(2, d_model) # 0 = sent A, 1 = sent B
self.embed_norm = nn.LayerNorm(d_model)
self.embed_dropout = nn.Dropout(dropout)
# --- Encoder Stack ---
self.encoder_layers = nn.ModuleList([
EncoderLayer(d_model, n_heads, d_ff, dropout)
for _ in range(n_layers)
])
# --- Pre-training Heads ---
# MLM Head: predict masked tokens
self.mlm_head = nn.Sequential(
nn.Linear(d_model, d_model),
nn.GELU(),
nn.LayerNorm(d_model),
nn.Linear(d_model, vocab_size)
)
# NSP Head: binary classification from [CLS]
self.nsp_head = nn.Sequential(
nn.Linear(d_model, d_model),
nn.Tanh(),
nn.Linear(d_model, 2)
)
def make_pad_mask(self, input_ids):
"""Padding mask: 1 for real tokens, 0 for [PAD]."""
return (input_ids != self.pad_idx).unsqueeze(1).unsqueeze(2)
def get_embeddings(self, input_ids, segment_ids):
"""BERT embedding = token + position + segment, then LayerNorm + Dropout."""
seq_len = input_ids.size(1)
positions = torch.arange(seq_len, device=input_ids.device).unsqueeze(0)
tok_emb = self.token_embedding(input_ids) * math.sqrt(self.d_model)
pos_emb = self.position_embedding(positions)
seg_emb = self.segment_embedding(segment_ids)
return self.embed_dropout(self.embed_norm(tok_emb + pos_emb + seg_emb))
def encode(self, input_ids, segment_ids):
"""Run the encoder stack. Returns the full sequence of hidden states."""
mask = self.make_pad_mask(input_ids)
x = self.get_embeddings(input_ids, segment_ids)
for layer in self.encoder_layers:
x = layer(x, mask)
return x # [batch, seq_len, d_model]
def forward(self, input_ids, segment_ids):
"""
Pre-training forward pass.
Returns: mlm_logits [batch, seq_len, vocab_size], nsp_logits [batch, 2]
"""
hidden = self.encode(input_ids, segment_ids)
mlm_logits = self.mlm_head(hidden)
cls_hidden = hidden[:, 0, :] # [CLS] is always position 0
nsp_logits = self.nsp_head(cls_hidden)
return mlm_logits, nsp_logits
def count_parameters(model):
"""Parameter breakdown for BERT."""
def count(m): return sum(p.numel() for p in m.parameters() if p.requires_grad)
emb = count(model.token_embedding) + count(model.position_embedding) + count(model.segment_embedding)
enc = count(model.encoder_layers)
mlm = count(model.mlm_head)
nsp = count(model.nsp_head)
total = count(model)
print(f"{'Component':<25} | {'Parameters':<12}")
print("-" * 42)
print(f"{'Embeddings (tok+pos+seg)':<25} | {emb:,}")
print(f"{'Encoder Stack (N Layers)':<25} | {enc:,}")
print(f"{'MLM Head':<25} | {mlm:,}")
print(f"{'NSP Head':<25} | {nsp:,}")
print("-" * 42)
print(f"{'TOTAL TRAINABLE':<25} | {total:,}")
# ==========================================
# 3a. Pre-training Evaluation + Training Loop
# ==========================================
def evaluate_pretrain(model, data_loader, mlm_criterion, nsp_criterion, device):
"""
Evaluate BERT pre-training on a validation set.
Returns avg MLM loss, avg NSP loss, MLM accuracy, NSP accuracy.
"""
model.eval()
total_mlm_loss, total_nsp_loss = 0.0, 0.0
mlm_correct, mlm_total = 0, 0
nsp_correct, nsp_total = 0, 0
with torch.no_grad():
for tokens, segments, mlm_labels, nsp_labels in data_loader:
tokens = tokens.to(device)
segments = segments.to(device)
mlm_labels = mlm_labels.to(device)
nsp_labels = nsp_labels.to(device)
mlm_logits, nsp_logits = model(tokens, segments)
mlm_loss = mlm_criterion(
mlm_logits.view(-1, mlm_logits.size(-1)),
mlm_labels.view(-1)
)
nsp_loss = nsp_criterion(nsp_logits, nsp_labels)
total_mlm_loss += mlm_loss.item()
total_nsp_loss += nsp_loss.item()
# MLM accuracy (only masked positions)
mask_pos = mlm_labels.view(-1) != -100
if mask_pos.any():
mlm_preds = mlm_logits.view(-1, mlm_logits.size(-1)).argmax(dim=-1)
mlm_correct += (mlm_preds[mask_pos] == mlm_labels.view(-1)[mask_pos]).sum().item()
mlm_total += mask_pos.sum().item()
# NSP accuracy
nsp_preds = nsp_logits.argmax(dim=-1)
nsp_correct += (nsp_preds == nsp_labels).sum().item()
nsp_total += nsp_labels.size(0)
n = len(data_loader)
return (
total_mlm_loss / n,
total_nsp_loss / n,
mlm_correct / max(mlm_total, 1),
nsp_correct / max(nsp_total, 1),
)
def pretrain_bert(model, train_loader, val_loader, num_epochs, lr, device, patience=5):
"""
Robust BERT pre-training loop with Mixed Precision, Validation,
Early Stopping, and Best-Model Checkpointing.
(Mirrors train_transformer structure from the Transformer notebook.)
"""
optimizer = optim.AdamW(model.parameters(), lr=lr, betas=(0.9, 0.999), weight_decay=0.01)
scheduler = optim.lr_scheduler.OneCycleLR(
optimizer, max_lr=lr, steps_per_epoch=len(train_loader), epochs=num_epochs
)
mlm_criterion = nn.CrossEntropyLoss(ignore_index=-100)
nsp_criterion = nn.CrossEntropyLoss()
use_amp = (device.type == 'cuda')
scaler = GradScaler('cuda', enabled=use_amp)
best_val_loss = float('inf')
epochs_no_improve = 0
start_time = time.time()
history = {
'train_loss': [], 'val_loss': [],
'train_mlm_loss': [], 'val_mlm_loss': [],
'train_nsp_loss': [], 'val_nsp_loss': [],
'train_mlm_acc': [], 'val_mlm_acc': [],
'train_nsp_acc': [], 'val_nsp_acc': [],
'lrs': []
}
print(f"Starting BERT pre-training on {device}...")
print(f"Epochs: {num_epochs} | Batches/epoch: {len(train_loader)} | LR: {lr} | Patience: {patience}")
print("-" * 110)
for epoch in range(num_epochs):
model.train()
epoch_mlm_loss, epoch_nsp_loss = 0.0, 0.0
epoch_mlm_correct, epoch_mlm_total = 0, 0
epoch_nsp_correct, epoch_nsp_total = 0, 0
train_tokens = 0
for tokens, segments, mlm_labels, nsp_labels in train_loader:
tokens = tokens.to(device, non_blocking=True)
segments = segments.to(device, non_blocking=True)
mlm_labels = mlm_labels.to(device, non_blocking=True)
nsp_labels = nsp_labels.to(device, non_blocking=True)
optimizer.zero_grad(set_to_none=True)
with autocast('cuda', enabled=use_amp):
mlm_logits, nsp_logits = model(tokens, segments)
# MLM loss: flatten [batch*seq_len, vocab_size] vs [batch*seq_len]
mlm_loss = mlm_criterion(
mlm_logits.view(-1, mlm_logits.size(-1)),
mlm_labels.view(-1)
)
# NSP loss
nsp_loss = nsp_criterion(nsp_logits, nsp_labels)
loss = mlm_loss + nsp_loss
scaler.scale(loss).backward()
scaler.unscale_(optimizer)
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
scaler.step(optimizer)
scaler.update()
scheduler.step()
# --- Accuracy tracking (on masked positions only for MLM) ---
with torch.no_grad():
mask_pos = mlm_labels.view(-1) != -100
if mask_pos.any():
mlm_preds = mlm_logits.view(-1, mlm_logits.size(-1)).argmax(dim=-1)
epoch_mlm_correct += (mlm_preds[mask_pos] == mlm_labels.view(-1)[mask_pos]).sum().item()
epoch_mlm_total += mask_pos.sum().item()
nsp_preds = nsp_logits.argmax(dim=-1)
epoch_nsp_correct += (nsp_preds == nsp_labels).sum().item()
epoch_nsp_total += nsp_labels.size(0)
epoch_mlm_loss += mlm_loss.item()
epoch_nsp_loss += nsp_loss.item()
train_tokens += (tokens != PAD_IDX).sum().item()
# --- Epoch Train Stats ---
n_batches = len(train_loader)
t_mlm = epoch_mlm_loss / n_batches
t_nsp = epoch_nsp_loss / n_batches
t_mlm_acc = epoch_mlm_correct / max(epoch_mlm_total, 1)
t_nsp_acc = epoch_nsp_correct / max(epoch_nsp_total, 1)
t_loss = t_mlm + t_nsp
# --- Epoch Validation ---
v_mlm, v_nsp, v_mlm_acc, v_nsp_acc = evaluate_pretrain(
model, val_loader, mlm_criterion, nsp_criterion, device
)
v_loss = v_mlm + v_nsp
# --- Early Stopping & Checkpoint ---
if v_loss < best_val_loss:
best_val_loss = v_loss
torch.save(model.state_dict(), 'bert_pretrained_squadv2.pth')
epochs_no_improve = 0
status_msg = " * (Saved Best)"
else:
epochs_no_improve += 1
status_msg = f" (No improve: {epochs_no_improve}/{patience})"
current_lr = optimizer.param_groups[0]['lr']
elapsed = time.time() - start_time
tok_per_sec = train_tokens / elapsed
# --- Update History ---
history['train_loss'].append(t_loss)
history['val_loss'].append(v_loss)
history['train_mlm_loss'].append(t_mlm)
history['val_mlm_loss'].append(v_mlm)
history['train_nsp_loss'].append(t_nsp)
history['val_nsp_loss'].append(v_nsp)
history['train_mlm_acc'].append(t_mlm_acc)
history['val_mlm_acc'].append(v_mlm_acc)
history['train_nsp_acc'].append(t_nsp_acc)
history['val_nsp_acc'].append(v_nsp_acc)
history['lrs'].append(current_lr)
print(f"Epoch {epoch+1:03d}/{num_epochs:03d} | "
f"Loss (T/V): {t_loss:.4f} / {v_loss:.4f} | "
f"MLM Acc (T/V): {t_mlm_acc*100:.1f}% / {v_mlm_acc*100:.1f}% | "
f"NSP Acc (T/V): {t_nsp_acc*100:.1f}% / {v_nsp_acc*100:.1f}% | "
f"tok/s: {tok_per_sec:07.0f} | "
f"LR: {current_lr:.6f}{status_msg}")
if epochs_no_improve >= patience:
print(f"\nEarly stopping triggered.")
break
print(f'\nDone! Total Time: {(time.time() - start_time) / 60:.2f} min')
model.load_state_dict(torch.load('bert_pretrained_squadv2.pth'))
return history
# ==========================================
# 3b. Initialize & Pre-train BERT
# ==========================================
set_all_seeds(RANDOM_SEED)
bert_model = BERT(
vocab_size=ACTUAL_VOCAB_SIZE,
d_model=D_MODEL,
n_layers=N_LAYERS,
n_heads=N_HEADS,
d_ff=D_FF,
dropout=DROPOUT,
max_len=MAX_LEN,
pad_idx=PAD_IDX,
device=DEVICE
).to(DEVICE)
# Xavier init (same as Transformer notebook)
def initialize_weights(m):
if hasattr(m, 'weight') and m.weight.dim() > 1:
nn.init.xavier_uniform_(m.weight.data)
bert_model.apply(initialize_weights)
count_parameters(bert_model)
Component | Parameters ------------------------------------------ Embeddings (tok+pos+seg) | 7,779,840 Encoder Stack (N Layers) | 85,054,464 MLM Head | 8,282,128 NSP Head | 592,130 ------------------------------------------ TOTAL TRAINABLE | 101,710,098
# Run Pre-training
pretrain_history = pretrain_bert(
model=bert_model,
train_loader=pretrain_train_loader,
val_loader=pretrain_val_loader,
num_epochs=PRETRAIN_EPOCHS,
lr=PRETRAIN_LR,
device=DEVICE,
patience=PATIENCE
)
Starting BERT pre-training on cuda... Epochs: 10 | Batches/epoch: 368 | LR: 0.0001 | Patience: 3 -------------------------------------------------------------------------------------------------------------- Epoch 001/010 | Loss (T/V): 8.3554 / 6.9943 | MLM Acc (T/V): 8.1% / 10.4% | NSP Acc (T/V): 50.5% / 52.5% | tok/s: 0009864 | LR: 0.000028 * (Saved Best) Epoch 002/010 | Loss (T/V): 7.1343 / 6.9441 | MLM Acc (T/V): 8.4% / 10.4% | NSP Acc (T/V): 50.8% / 51.3% | tok/s: 0004800 | LR: 0.000076 * (Saved Best) Epoch 003/010 | Loss (T/V): 7.1399 / 6.9789 | MLM Acc (T/V): 8.3% / 10.1% | NSP Acc (T/V): 50.2% / 48.4% | tok/s: 0003170 | LR: 0.000100 (No improve: 1/3) Epoch 004/010 | Loss (T/V): 7.1337 / 6.9430 | MLM Acc (T/V): 8.4% / 10.5% | NSP Acc (T/V): 50.0% / 50.3% | tok/s: 0002379 | LR: 0.000095 * (Saved Best) Epoch 005/010 | Loss (T/V): 7.0964 / 6.9116 | MLM Acc (T/V): 8.4% / 10.8% | NSP Acc (T/V): 49.9% / 50.3% | tok/s: 0001900 | LR: 0.000081 * (Saved Best) Epoch 006/010 | Loss (T/V): 7.0992 / 6.9400 | MLM Acc (T/V): 8.3% / 10.1% | NSP Acc (T/V): 49.7% / 50.4% | tok/s: 0001578 | LR: 0.000061 (No improve: 1/3) Epoch 007/010 | Loss (T/V): 7.1029 / 6.9041 | MLM Acc (T/V): 8.3% / 10.4% | NSP Acc (T/V): 50.1% / 49.5% | tok/s: 0001356 | LR: 0.000039 * (Saved Best) Epoch 008/010 | Loss (T/V): 7.0854 / 6.8893 | MLM Acc (T/V): 8.3% / 10.3% | NSP Acc (T/V): 50.0% / 50.3% | tok/s: 0001185 | LR: 0.000019 * (Saved Best) Epoch 009/010 | Loss (T/V): 7.0903 / 6.9139 | MLM Acc (T/V): 8.4% / 10.4% | NSP Acc (T/V): 50.0% / 50.3% | tok/s: 0001056 | LR: 0.000005 (No improve: 1/3) Epoch 010/010 | Loss (T/V): 7.0805 / 6.9203 | MLM Acc (T/V): 8.4% / 10.3% | NSP Acc (T/V): 49.7% / 50.8% | tok/s: 0000946 | LR: 0.000000 (No improve: 2/3) Done! Total Time: 34.09 min
# ==========================================
# 3c. Plot Pre-training Curves
# ==========================================
fig, axes = plt.subplots(1, 4, figsize=(16, 4))
epochs = range(1, len(pretrain_history['train_loss']) + 1)
# 1. Total Loss (Train vs Val)
axes[0].plot(epochs, pretrain_history['train_loss'], 'o-', label='Train', color='#1f77b4')
axes[0].plot(epochs, pretrain_history['val_loss'], 's--', label='Val', color='#ff7f0e')
axes[0].set_title('Total Loss (MLM + NSP)')
axes[0].set_xlabel('Epoch')
axes[0].set_ylabel('Loss')
axes[0].legend()
axes[0].grid(True, alpha=0.3)
# 2. MLM Accuracy (Train vs Val)
axes[1].plot(epochs, [a*100 for a in pretrain_history['train_mlm_acc']], 'o-', label='Train MLM', color='#2ca02c')
axes[1].plot(epochs, [a*100 for a in pretrain_history['val_mlm_acc']], 's--', label='Val MLM', color='#d62728')
axes[1].set_title('MLM Accuracy')
axes[1].set_xlabel('Epoch')
axes[1].set_ylabel('Accuracy (%)')
axes[1].legend()
axes[1].grid(True, alpha=0.3)
# 3. NSP Accuracy (Train vs Val)
axes[2].plot(epochs, [a*100 for a in pretrain_history['train_nsp_acc']], 'o-', label='Train NSP', color='purple')
axes[2].plot(epochs, [a*100 for a in pretrain_history['val_nsp_acc']], 's--', label='Val NSP', color='orange')
axes[2].set_title('NSP Accuracy')
axes[2].set_xlabel('Epoch')
axes[2].set_ylabel('Accuracy (%)')
axes[2].legend()
axes[2].grid(True, alpha=0.3)
# 4. Learning Rate Schedule
axes[3].plot(epochs, pretrain_history['lrs'], label='LR', color='black', linewidth=2)
axes[3].set_title('LR Schedule (OneCycleLR)')
axes[3].set_xlabel('Epoch')
axes[3].set_ylabel('Rate')
axes[3].ticklabel_format(style='sci', axis='y', scilimits=(0,0))
axes[3].grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
4. Phase 2 — Fine-tuning on SQuAD v2.0 (Extractive QA)¶
We take the pre-trained BERT encoder and add a span prediction head that outputs start and end logits for every token position.
For unanswerable questions (SQuAD v2.0), both start and end targets point to
position 0 ([CLS]), and we use a null-score threshold at inference time.
Input: [CLS] question [SEP] context [SEP]
# ==========================================
# 4a. SQuAD v2.0 Dataset
# ==========================================
class SQuADDataset(Dataset):
"""
Wraps SQuAD v2.0 for BERT extractive QA.
Format: [CLS] question [SEP] context [SEP]
Targets: start_position, end_position (0,0 for unanswerable)
"""
def __init__(self, hf_dataset, vocab, max_len=384):
self.vocab = vocab
self.max_len = max_len
self.samples = []
skipped = 0
for item in hf_dataset:
question = item['question']
context = item['context']
answers = item['answers']
# Tokenize question and context
q_tokens = tokenizer(question)
c_tokens = tokenizer(context)
# Build character-to-token mapping for the context
# We need this to convert character-level answer_start to token-level
c_text_lower = context.lower()
char_to_tok = [None] * len(c_text_lower)
tok_spans = []
pos = 0
for ti, tok in enumerate(c_tokens):
start = c_text_lower.find(tok, pos)
if start == -1:
start = pos
end = start + len(tok)
tok_spans.append((start, end))
for ci in range(start, min(end, len(c_text_lower))):
char_to_tok[ci] = ti
pos = end
# Truncate to fit: [CLS] question [SEP] context [SEP]
max_q_len = min(len(q_tokens), 64)
max_c_len = self.max_len - max_q_len - 3 # 3 special tokens
if max_c_len <= 0:
skipped += 1
continue
q_tokens = q_tokens[:max_q_len]
c_tokens_trunc = c_tokens[:max_c_len]
# Encode
q_ids = [vocab.get(t, UNK_IDX) for t in q_tokens]
c_ids = [vocab.get(t, UNK_IDX) for t in c_tokens_trunc]
# [CLS] question [SEP] context [SEP]
input_ids = [CLS_IDX] + q_ids + [SEP_IDX] + c_ids + [SEP_IDX]
segment_ids = [0] * (len(q_ids) + 2) + [1] * (len(c_ids) + 1)
# Context starts at this offset in input_ids
context_offset = len(q_ids) + 2 # after [CLS] + question + [SEP]
# Determine answer positions
if len(answers['answer_start']) == 0:
# Unanswerable → point to [CLS] (position 0)
start_pos = 0
end_pos = 0
is_impossible = True
else:
ans_char_start = answers['answer_start'][0]
ans_text = answers['text'][0]
ans_char_end = ans_char_start + len(ans_text) - 1
# Map character positions to token positions
if ans_char_start < len(char_to_tok) and char_to_tok[ans_char_start] is not None:
tok_start = char_to_tok[ans_char_start]
else:
skipped += 1
continue
ans_char_end_clamped = min(ans_char_end, len(char_to_tok) - 1)
tok_end = char_to_tok[ans_char_end_clamped] if char_to_tok[ans_char_end_clamped] is not None else tok_start
# Check if answer fits within truncated context
if tok_start >= max_c_len or tok_end >= max_c_len:
skipped += 1
continue
start_pos = context_offset + tok_start
end_pos = context_offset + tok_end
is_impossible = False
self.samples.append({
'input_ids': torch.tensor(input_ids, dtype=torch.long),
'segment_ids': torch.tensor(segment_ids, dtype=torch.long),
'start_pos': torch.tensor(start_pos, dtype=torch.long),
'end_pos': torch.tensor(end_pos, dtype=torch.long),
'is_impossible': is_impossible,
'context_tokens': c_tokens_trunc,
'context_offset': context_offset,
'question': question,
'context': context,
'gold_text': answers['text'][0] if len(answers['text']) > 0 else '',
})
print(f" SQuADDataset: {len(self.samples)} samples loaded, {skipped} skipped.")
def __len__(self):
return len(self.samples)
def __getitem__(self, idx):
s = self.samples[idx]
return s['input_ids'], s['segment_ids'], s['start_pos'], s['end_pos']
def squad_collate_fn(batch):
input_ids, segment_ids, starts, ends = zip(*batch)
input_ids = pad_sequence(input_ids, batch_first=True, padding_value=PAD_IDX)
segment_ids = pad_sequence(segment_ids, batch_first=True, padding_value=0)
starts = torch.stack(starts)
ends = torch.stack(ends)
return input_ids, segment_ids, starts, ends
# Build SQuAD DataLoaders
# Use a subset for training feasibility with from-scratch BERT
SQUAD_MAX_LEN = min(MAX_LEN, 384)
SQUAD_TRAIN_SIZE = 20000 # Subset for feasibility
SQUAD_VAL_SIZE = 3000
squad_train_subset = squad_dataset['train'].select(range(min(SQUAD_TRAIN_SIZE, len(squad_dataset['train']))))
squad_val_subset = squad_dataset['validation'].select(range(min(SQUAD_VAL_SIZE, len(squad_dataset['validation']))))
squad_train_ds = SQuADDataset(squad_train_subset, vocab, max_len=SQUAD_MAX_LEN)
squad_val_ds = SQuADDataset(squad_val_subset, vocab, max_len=SQUAD_MAX_LEN)
squad_train_loader = DataLoader(
squad_train_ds, batch_size=FINETUNE_BATCH_SIZE,
shuffle=True, collate_fn=squad_collate_fn
)
squad_val_loader = DataLoader(
squad_val_ds, batch_size=FINETUNE_BATCH_SIZE,
shuffle=False, collate_fn=squad_collate_fn
)
print(f"SQuAD Train batches: {len(squad_train_loader)}, Val batches: {len(squad_val_loader)}")
# Sanity check
sample_batch = next(iter(squad_train_loader))
print(f"Batch shapes — input_ids: {sample_batch[0].shape}, segments: {sample_batch[1].shape}, "
f"starts: {sample_batch[2].shape}, ends: {sample_batch[3].shape}")
SQuADDataset: 18469 samples loaded, 1531 skipped. SQuADDataset: 2853 samples loaded, 147 skipped. SQuAD Train batches: 578, Val batches: 90 Batch shapes — input_ids: torch.Size([32, 128]), segments: torch.Size([32, 128]), starts: torch.Size([32]), ends: torch.Size([32])
# ==========================================
# 4b. BERT QA Head (Fine-tuning wrapper)
# ==========================================
class BERTForQA(nn.Module):
"""
BERT for Extractive Question Answering.
Takes a pre-trained BERT encoder and adds a span prediction head.
For each token position, outputs start_logit and end_logit.
For unanswerable questions, the model should predict (0, 0) = [CLS] position.
"""
def __init__(self, bert_model, d_model, dropout=0.1):
super().__init__()
self.bert = bert_model
self.qa_dropout = nn.Dropout(dropout)
# Single linear layer → 2 outputs (start_logit, end_logit) per token
self.qa_outputs = nn.Linear(d_model, 2)
def forward(self, input_ids, segment_ids):
# Get encoder hidden states
hidden = self.bert.encode(input_ids, segment_ids) # [batch, seq_len, d_model]
hidden = self.qa_dropout(hidden)
logits = self.qa_outputs(hidden) # [batch, seq_len, 2]
start_logits = logits[:, :, 0] # [batch, seq_len]
end_logits = logits[:, :, 1] # [batch, seq_len]
return start_logits, end_logits
# ==========================================
# 4c. SQuAD Metrics: Exact Match & Token-level F1
# ==========================================
def normalize_answer(text):
"""Normalize answer text for comparison (lowercase, strip punctuation/articles)."""
import string
text = text.lower().strip()
# Remove punctuation
text = ''.join(c for c in text if c not in string.punctuation)
# Remove articles
for article in ['a ', 'an ', 'the ']:
if text.startswith(article):
text = text[len(article):]
# Collapse whitespace
text = ' '.join(text.split())
return text
def compute_em(pred_text, gold_text):
"""Exact Match: 1 if normalized prediction == normalized gold, else 0."""
return float(normalize_answer(pred_text) == normalize_answer(gold_text))
def compute_token_f1(pred_text, gold_text):
"""Token-level F1 between prediction and gold answer."""
pred_tokens = normalize_answer(pred_text).split()
gold_tokens = normalize_answer(gold_text).split()
if not gold_tokens and not pred_tokens:
return 1.0 # Both empty → correct "no answer"
if not gold_tokens or not pred_tokens:
return 0.0
common = Counter(pred_tokens) & Counter(gold_tokens)
n_common = sum(common.values())
if n_common == 0:
return 0.0
precision = n_common / len(pred_tokens)
recall = n_common / len(gold_tokens)
return 2 * precision * recall / (precision + recall)
# ==========================================
# 4d. QA Evaluation & Training Loop
# ==========================================
def evaluate_qa(model, data_loader, criterion, device):
"""Evaluate QA model on validation set. Returns avg loss."""
model.eval()
total_loss = 0.0
n_batches = 0
with torch.no_grad():
for input_ids, segment_ids, starts, ends in data_loader:
input_ids = input_ids.to(device)
segment_ids = segment_ids.to(device)
starts = starts.to(device)
ends = ends.to(device)
start_logits, end_logits = model(input_ids, segment_ids)
# Clamp targets to valid range
seq_len = start_logits.size(1)
starts = starts.clamp(0, seq_len - 1)
ends = ends.clamp(0, seq_len - 1)
loss = criterion(start_logits, starts) + criterion(end_logits, ends)
total_loss += loss.item()
n_batches += 1
return total_loss / max(n_batches, 1)
def evaluate_qa_metrics(model, dataset, device, null_threshold=0.0):
"""
Compute EM and F1 on the dataset by extracting predicted spans.
For unanswerable questions: if best start/end is (0,0) → predict empty string.
"""
model.eval()
em_scores, f1_scores = [], []
n_impossible_correct = 0
n_impossible_total = 0
for i in range(len(dataset)):
s = dataset.samples[i]
input_ids = s['input_ids'].unsqueeze(0).to(device)
segment_ids = s['segment_ids'].unsqueeze(0).to(device)
with torch.no_grad():
start_logits, end_logits = model(input_ids, segment_ids)
start_logits = start_logits.squeeze(0)
end_logits = end_logits.squeeze(0)
# Get best start/end positions
# Constrain: answer must be within context and end >= start
context_offset = s['context_offset']
seq_len = start_logits.size(0)
# Null score = score of [CLS] (position 0) for both start and end
null_score = start_logits[0].item() + end_logits[0].item()
# Best span score within context
best_score = float('-inf')
best_start, best_end = 0, 0
for si in range(context_offset, seq_len):
for ei in range(si, min(si + 30, seq_len)): # max answer length = 30 tokens
score = start_logits[si].item() + end_logits[ei].item()
if score > best_score:
best_score = score
best_start = si
best_end = ei
# Decide: answerable or not
if null_score - null_threshold > best_score:
pred_text = '' # Predict unanswerable
else:
# Extract tokens from context
tok_start = best_start - context_offset
tok_end = best_end - context_offset
c_tokens = s['context_tokens']
if 0 <= tok_start < len(c_tokens) and 0 <= tok_end < len(c_tokens):
pred_text = ' '.join(c_tokens[tok_start:tok_end + 1])
else:
pred_text = ''
gold_text = s['gold_text']
# Track impossible question accuracy
if s['is_impossible']:
n_impossible_total += 1
if pred_text == '':
n_impossible_correct += 1
em_scores.append(compute_em(pred_text, gold_text))
f1_scores.append(compute_token_f1(pred_text, gold_text))
avg_em = np.mean(em_scores) if em_scores else 0.0
avg_f1 = np.mean(f1_scores) if f1_scores else 0.0
impossible_acc = n_impossible_correct / max(n_impossible_total, 1)
return avg_em, avg_f1, impossible_acc
def finetune_qa(model, train_loader, val_loader, val_dataset, num_epochs, lr, device, patience=3):
"""
Fine-tune BERT for extractive QA on SQuAD v2.0.
Mirrors the train_transformer / finetune_bert loop structure.
"""
optimizer = optim.AdamW(model.parameters(), lr=lr, weight_decay=0.01)
scheduler = optim.lr_scheduler.OneCycleLR(
optimizer, max_lr=lr, steps_per_epoch=len(train_loader), epochs=num_epochs
)
criterion = nn.CrossEntropyLoss()
use_amp = (device.type == 'cuda')
scaler = GradScaler('cuda', enabled=use_amp)
history = {'train_loss': [], 'val_loss': [], 'val_em': [], 'val_f1': [], 'lrs': []}
best_val_f1 = 0.0
epochs_no_improve = 0
start_time = time.time()
print(f"Fine-tuning BERT for QA on SQuAD v2.0 ({device})...")
print(f"Epochs: {num_epochs} | LR: {lr} | Patience: {patience}")
print("-" * 100)
for epoch in range(num_epochs):
model.train()
train_loss = 0.0
for input_ids, segment_ids, starts, ends in train_loader:
input_ids = input_ids.to(device, non_blocking=True)
segment_ids = segment_ids.to(device, non_blocking=True)
starts = starts.to(device, non_blocking=True)
ends = ends.to(device, non_blocking=True)
optimizer.zero_grad(set_to_none=True)
with autocast('cuda', enabled=use_amp):
start_logits, end_logits = model(input_ids, segment_ids)
# Clamp targets to valid sequence length
seq_len = start_logits.size(1)
starts = starts.clamp(0, seq_len - 1)
ends = ends.clamp(0, seq_len - 1)
loss = criterion(start_logits, starts) + criterion(end_logits, ends)
scaler.scale(loss).backward()
scaler.unscale_(optimizer)
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
scaler.step(optimizer)
scaler.update()
scheduler.step()
train_loss += loss.item()
# --- Epoch Evaluation ---
avg_train_loss = train_loss / len(train_loader)
val_loss = evaluate_qa(model, val_loader, criterion, device)
# Compute EM/F1 on a subset for speed
eval_size = min(500, len(val_dataset))
val_em, val_f1, imp_acc = evaluate_qa_metrics(model, val_dataset, device)
current_lr = optimizer.param_groups[0]['lr']
history['train_loss'].append(avg_train_loss)
history['val_loss'].append(val_loss)
history['val_em'].append(val_em)
history['val_f1'].append(val_f1)
history['lrs'].append(current_lr)
# Early stopping on F1
if val_f1 > best_val_f1:
best_val_f1 = val_f1
torch.save(model.state_dict(), 'best_bert_squad.pth')
epochs_no_improve = 0
status = " * (Saved Best)"
else:
epochs_no_improve += 1
status = f" (No improve: {epochs_no_improve}/{patience})"
elapsed = time.time() - start_time
print(f"Epoch {epoch+1:02d}/{num_epochs} | "
f"Loss (T/V): {avg_train_loss:.4f} / {val_loss:.4f} | "
f"EM: {val_em*100:.2f}% | F1: {val_f1*100:.2f}% | "
f"NoAns Acc: {imp_acc*100:.1f}% | "
f"LR: {current_lr:.2e} | "
f"Time: {elapsed/60:.1f}min{status}")
if epochs_no_improve >= patience:
print(f"\nEarly stopping triggered.")
break
print(f"\nFine-tuning complete! Total time: {(time.time()-start_time)/60:.1f} min")
print(f"Best Val F1: {best_val_f1*100:.2f}%")
model.load_state_dict(torch.load('best_bert_squad.pth'))
return history
# ==========================================
# 4e. Initialize & Fine-tune
# ==========================================
set_all_seeds(RANDOM_SEED)
# Load pre-trained weights
bert_model.load_state_dict(torch.load('bert_pretrained_squadv2.pth', map_location=DEVICE))
# Wrap with QA head
qa_model = BERTForQA(
bert_model=bert_model,
d_model=D_MODEL,
dropout=DROPOUT
).to(DEVICE)
total_params = sum(p.numel() for p in qa_model.parameters() if p.requires_grad)
qa_head_params = sum(p.numel() for p in qa_model.qa_outputs.parameters())
print(f"Total parameters: {total_params:,}")
print(f"QA head parameters: {qa_head_params:,} (added on top of pre-trained BERT)")
Total parameters: 101,711,636 QA head parameters: 1,538 (added on top of pre-trained BERT)
# Run Fine-tuning
qa_history = finetune_qa(
qa_model, squad_train_loader, squad_val_loader, squad_val_ds,
num_epochs=FINETUNE_EPOCHS,
lr=FINETUNE_LR,
device=DEVICE,
patience=4 #PATIENCE
)
Fine-tuning BERT for QA on SQuAD v2.0 (cuda)... Epochs: 10 | LR: 2e-05 | Patience: 4 ---------------------------------------------------------------------------------------------------- Epoch 01/10 | Loss (T/V): 9.7698 / 9.6999 | EM: 51.81% | F1: 51.81% | NoAns Acc: 100.0% | LR: 5.61e-06 | Time: 7.1min * (Saved Best) Epoch 02/10 | Loss (T/V): 9.3883 / 6.1491 | EM: 51.77% | F1: 51.77% | NoAns Acc: 99.9% | LR: 1.52e-05 | Time: 14.2min (No improve: 1/4) Epoch 03/10 | Loss (T/V): 7.1483 / 5.5604 | EM: 51.81% | F1: 51.81% | NoAns Acc: 100.0% | LR: 2.00e-05 | Time: 21.3min (No improve: 2/4) Epoch 04/10 | Loss (T/V): 6.0251 / 10.3555 | EM: 14.62% | F1: 15.75% | NoAns Acc: 26.8% | LR: 1.90e-05 | Time: 28.4min (No improve: 3/4) Epoch 05/10 | Loss (T/V): 5.7116 / 8.6913 | EM: 21.66% | F1: 22.81% | NoAns Acc: 40.3% | LR: 1.62e-05 | Time: 35.5min (No improve: 4/4) Early stopping triggered. Fine-tuning complete! Total time: 35.5 min Best Val F1: 51.81%
# ==========================================
# 4f. Plot Fine-tuning Curves
# ==========================================
fig, axes = plt.subplots(1, 4, figsize=(20, 5))
epochs = range(1, len(qa_history['train_loss']) + 1)
# Loss
axes[0].plot(epochs, qa_history['train_loss'], 'o-', label='Train', color='#1f77b4')
axes[0].plot(epochs, qa_history['val_loss'], 's--', label='Val', color='#ff7f0e')
axes[0].set_title('QA Loss (start + end)')
axes[0].set_xlabel('Epoch')
axes[0].set_ylabel('Loss')
axes[0].legend()
axes[0].grid(True, alpha=0.3)
# Exact Match
axes[1].plot(epochs, [e*100 for e in qa_history['val_em']], 'o-', label='Val EM', color='#2ca02c')
axes[1].set_title('Exact Match (SQuAD v2.0)')
axes[1].set_xlabel('Epoch')
axes[1].set_ylabel('EM (%)')
axes[1].legend()
axes[1].grid(True, alpha=0.3)
# F1
axes[2].plot(epochs, [f*100 for f in qa_history['val_f1']], 'D-', label='Val F1', color='purple')
axes[2].set_title('Token-level F1 (SQuAD v2.0)')
axes[2].set_xlabel('Epoch')
axes[2].set_ylabel('F1 (%)')
axes[2].legend()
axes[2].grid(True, alpha=0.3)
# LR
axes[3].plot(epochs, qa_history['lrs'], label='LR', color='black', linewidth=2)
axes[3].set_title('LR Schedule (OneCycleLR)')
axes[3].set_xlabel('Epoch')
axes[3].set_ylabel('Rate')
axes[3].ticklabel_format(style='sci', axis='y', scilimits=(0,0))
axes[3].grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
# ==========================================
# 5a. Full Validation Report
# ==========================================
val_em, val_f1, imp_acc = evaluate_qa_metrics(qa_model, squad_val_ds, DEVICE)
# Count answerable vs unanswerable
n_answerable = sum(1 for s in squad_val_ds.samples if not s['is_impossible'])
n_impossible = sum(1 for s in squad_val_ds.samples if s['is_impossible'])
print("=" * 65)
print(" FINAL SQuAD v2.0 VALIDATION AUDIT ")
print("=" * 65)
print(f" Dataset: {len(squad_val_ds)} samples")
print(f" Answerable: {n_answerable}")
print(f" Unanswerable: {n_impossible}")
print("-" * 65)
print(f" Exact Match (EM): {val_em*100:.2f}%")
print(f" Token F1: {val_f1*100:.2f}%")
print(f" No-Answer Accuracy: {imp_acc*100:.2f}%")
print("=" * 65)
=================================================================
FINAL SQuAD v2.0 VALIDATION AUDIT
=================================================================
Dataset: 2853 samples
Answerable: 1375
Unanswerable: 1478
-----------------------------------------------------------------
Exact Match (EM): 51.81%
Token F1: 51.81%
No-Answer Accuracy: 100.00%
=================================================================
# ==========================================
# 6a. Single-Question Inference
# ==========================================
def answer_question(question, context, model, vocab, device, max_len=MAX_LEN, null_threshold=0.0):
"""Run QA inference on a single question-context pair."""
model.eval()
q_tokens = tokenizer(question)
c_tokens = tokenizer(context)
max_q = min(len(q_tokens), 64)
max_c = max_len - max_q - 3
q_tokens = q_tokens[:max_q]
c_tokens = c_tokens[:max_c]
q_ids = [vocab.get(t, UNK_IDX) for t in q_tokens]
c_ids = [vocab.get(t, UNK_IDX) for t in c_tokens]
input_ids = [CLS_IDX] + q_ids + [SEP_IDX] + c_ids + [SEP_IDX]
segment_ids = [0] * (len(q_ids) + 2) + [1] * (len(c_ids) + 1)
context_offset = len(q_ids) + 2
input_tensor = torch.tensor([input_ids], dtype=torch.long).to(device)
segment_tensor = torch.tensor([segment_ids], dtype=torch.long).to(device)
with torch.no_grad():
start_logits, end_logits = model(input_tensor, segment_tensor)
start_logits = start_logits.squeeze(0)
end_logits = end_logits.squeeze(0)
# Null score
null_score = start_logits[0].item() + end_logits[0].item()
# Best span
best_score = float('-inf')
best_start, best_end = 0, 0
seq_len = start_logits.size(0)
for si in range(context_offset, seq_len):
for ei in range(si, min(si + 30, seq_len)):
score = start_logits[si].item() + end_logits[ei].item()
if score > best_score:
best_score = score
best_start = si
best_end = ei
if null_score - null_threshold > best_score:
return "[Unanswerable]", null_score - best_score
tok_start = best_start - context_offset
tok_end = best_end - context_offset
if 0 <= tok_start < len(c_tokens) and 0 <= tok_end < len(c_tokens):
answer = ' '.join(c_tokens[tok_start:tok_end + 1])
else:
answer = "[Out of range]"
confidence = best_score - null_score
return answer, confidence
# ==========================================
# 6b. Sample Predictions from Validation Set
# ==========================================
def show_qa_samples(dataset, model, vocab, device, n=10):
"""Show predictions on random samples from the dataset."""
indices = random.sample(range(len(dataset)), min(n, len(dataset)))
print(f"{"#":<3} | {"TYPE":<8} | {"GOLD":<30} | {"PREDICTED":<30} | {"MATCH"}")
print("-" * 110)
for rank, idx in enumerate(indices, 1):
s = dataset.samples[idx]
pred_text, conf = answer_question(
s['question'], s['context'], model, vocab, device
)
gold = s['gold_text'] if s['gold_text'] else '[Unanswerable]'
q_type = 'NoAns' if s['is_impossible'] else 'Ans'
em = compute_em(pred_text, gold if not s['is_impossible'] else '')
match = 'Y' if em > 0 else ' '
gold_disp = gold[:28] + '..' if len(gold) > 28 else gold
pred_disp = pred_text[:28] + '..' if len(pred_text) > 28 else pred_text
print(f"{rank:<3} | {q_type:<8} | {gold_disp:<30} | {pred_disp:<30} | {match}")
print()
set_all_seeds(42)
show_qa_samples(squad_val_ds, qa_model, vocab, DEVICE, n=15)
# | TYPE | GOLD | PREDICTED | MATCH -------------------------------------------------------------------------------------------------------------- 1 | NoAns | [Unanswerable] | [Unanswerable] | 2 | NoAns | [Unanswerable] | [Unanswerable] | 3 | NoAns | [Unanswerable] | [Unanswerable] | 4 | Ans | over 1,000 | [Unanswerable] | 5 | NoAns | [Unanswerable] | [Unanswerable] | 6 | NoAns | [Unanswerable] | [Unanswerable] | 7 | NoAns | [Unanswerable] | [Unanswerable] | 8 | NoAns | [Unanswerable] | [Unanswerable] | 9 | Ans | Lexus | [Unanswerable] | 10 | NoAns | [Unanswerable] | [Unanswerable] | 11 | Ans | complexity resources | [Unanswerable] | 12 | Ans | anaerobic | [Unanswerable] | 13 | NoAns | [Unanswerable] | [Unanswerable] | 14 | NoAns | [Unanswerable] | [Unanswerable] | 15 | Ans | Edward the Confessor | [Unanswerable] |
# ==========================================
# 6c. Custom Questions
# ==========================================
test_context = (
"The Transformer architecture was introduced by Vaswani et al. in 2017. "
"It relies entirely on self-attention mechanisms and does not use recurrence "
"or convolutions. BERT, introduced by Devlin et al. in 2018, uses only the "
"encoder part of the Transformer and is pre-trained with masked language "
"modeling and next sentence prediction. BERT achieved state-of-the-art "
"results on eleven natural language processing benchmarks including SQuAD."
)
test_questions = [
"Who introduced the Transformer architecture?",
"When was BERT introduced?",
"What does BERT use for pre-training?",
"Does the Transformer use recurrence?",
"How many benchmarks did BERT achieve state-of-the-art results on?",
"What is the capital of France?", # Unanswerable from this context
]
print(f"Context: {test_context[:100]}...")
print()
print(f"{"Question":<55} | {"Answer":<30} | {"Confidence"}")
print("-" * 100)
for q in test_questions:
answer, confidence = answer_question(q, test_context, qa_model, vocab, DEVICE)
q_disp = q[:52] + "..." if len(q) > 52 else q
a_disp = answer[:28] + ".." if len(answer) > 28 else answer
print(f"{q_disp:<55} | {a_disp:<30} | {confidence:+.2f}")
Context: The Transformer architecture was introduced by Vaswani et al. in 2017. It relies entirely on self-at... Question | Answer | Confidence ---------------------------------------------------------------------------------------------------- Who introduced the Transformer architecture? | [Unanswerable] | +0.00 When was BERT introduced? | [Unanswerable] | +0.00 What does BERT use for pre-training? | [Unanswerable] | +0.00 Does the Transformer use recurrence? | [Unanswerable] | +0.00 How many benchmarks did BERT achieve state-of-the-ar... | [Unanswerable] | +0.00 What is the capital of France? | [Unanswerable] | +0.00
Summary¶
In this notebook, I implemented BERT for Extractive Question Answering from scratch, building on the pre-trained BERT encoder from the previous notebook.
Phase 1 (Pre-training): The BERT encoder was pre-trained on WikiText-2 with MLM + NSP.
Phase 2 (Fine-tuning): The pre-trained encoder was fine-tuned on SQuAD v2.0 for
extractive QA, predicting answer spans (start, end positions) within a context passage.
The model also handles unanswerable questions by predicting the [CLS] position.
Key differences from SST-2 (classification):
| Feature | SST-2 (Classification) | SQuAD v2.0 (Extractive QA) |
|---|---|---|
| Input | [CLS] sentence [SEP] |
[CLS] question [SEP] context [SEP] |
| Output head | [CLS] → class logits |
Every token → (start_logit, end_logit) |
| Loss | CrossEntropy on class | CrossEntropy on start + end positions |
| Metrics | Accuracy, F1 | Exact Match, Token-level F1 |
| Special case | — | Unanswerable → predict position 0 |
Architectural Evolution Path¶
| Step | Architecture | Key Innovation | Primary Task |
|---|---|---|---|
| 1 | Seq2Seq (LSTM/GRU) | Recurrent encoding | Translation |
| 2 | Seq2Seq + Attention | Dynamic alignment | Translation |
| 3 | Transformer | Self-Attention / Parallelism | Translation |
| 4 | BERT → SST-2 | Bidirectional + Self-Supervised | Classification |
| 5 | BERT → SQuAD (this notebook) | Span prediction + No-answer | Question Answering |
%watermark -iv
datasets : 4.8.3 matplotlib: 3.10.0 numpy : 2.0.2 re : 2.2.1 seaborn : 0.13.2 sklearn : 1.6.1 torch : 2.10.0+cu128 torchinfo : 1.8.0