NLP & LLMs

Encoder Models Bert And Friends

Fine-Tuning BERT for Downstream Tasks

This chapter directly applies DL Notes, Module 7's transfer learning framework to BERT specifically: take the pretrained encoder (Chapters 1-2), add a small, ta

JrCodex·6 min read

Jr Codex NLP & LLM Notes

Level: Advanced Prerequisites: Chapter 2: BERT's Pretraining — MLM + NSP Time to complete: ~25 minutes


Table of Contents

  1. The Fine-Tuning Pattern
  2. Task 1: Text Classification
  3. Task 2: Named Entity Recognition (Token Classification)
  4. Task 3: Extractive Question Answering
  5. Task 4: Sentence-Pair Tasks
  6. Fine-Tuning Hyperparameters — BERT-Specific Guidance
  7. Summary & Next Steps

1. The Fine-Tuning Pattern

This chapter directly applies DL Notes, Module 7's transfer learning framework to BERT specifically: take the pretrained encoder (Chapters 1-2), add a small, task-specific head, and fine-tune the whole thing (or just the head) on labeled data for a SPECIFIC downstream task.

The General Pattern, Across Every Task in This Chapter
─────────────────────────────────────────
  1. Load PRETRAINED BERT (Chapter 1, Section 5)
  2. ADD a small task-specific layer on top of BERT's output
       (which specific output — [CLS] vs per-token — depends
       on the task)
  3. FINE-TUNE (DL Notes, Module 7, Ch.2) — typically the
       ENTIRE model, at a low learning rate, since downstream
       datasets are usually small relative to pretraining
─────────────────────────────────────────

2. Task 1: Text Classification

Directly reusing Chapter 1, Section 3's [CLS] token — its final representation summarizes the entire input, making it a natural input to a simple classification head.

from transformers import AutoModelForSequenceClassification, AutoTokenizer, TrainingArguments, Trainer
from datasets import load_dataset
 
model_name = "bert-base-uncased"
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForSequenceClassification.from_pretrained(model_name, num_labels=2)      # e.g. binary sentiment
 
dataset = load_dataset("imdb")
 
def tokenize_function(examples):
    return tokenizer(examples["text"], padding="max_length", truncation=True, max_length=256)
 
tokenized_dataset = dataset.map(tokenize_function, batched=True)
 
training_args = TrainingArguments(
    output_dir="./bert-sentiment",
    num_train_epochs=3,
    per_device_train_batch_size=16,
    learning_rate=2e-5,      # DL Notes, Module 7, Ch.2, Sec.3's low fine-tuning rate
    weight_decay=0.01,          # DL Notes, Module 3, Ch.4
)
 
trainer = Trainer(model=model, args=training_args,
                   train_dataset=tokenized_dataset["train"], eval_dataset=tokenized_dataset["test"])
trainer.train()
What AutoModelForSequenceClassification Adds
─────────────────────────────────────────
  A single nn.Linear layer (DL Notes, Module 2, Ch.1) on TOP
  of BERT's POOLED [CLS] output (Chapter 1, Section 3) —
  mapping BERT's 768-dimensional representation down to
  num_labels output scores, exactly the classification head
  pattern from DL Notes, Module 7, Chapter 2, Section 2.
─────────────────────────────────────────

3. Task 2: Named Entity Recognition (Token Classification)

Unlike classification (one label for the WHOLE input), NER requires a label for every individual token — directly the "many-to-many, aligned" pattern from DL Notes, Module 5, Chapter 2, Section 6.

from transformers import AutoModelForTokenClassification
 
# Labels like: O (outside any entity), B-PER (beginning of a person name),
# I-PER (inside a person name), B-ORG, I-ORG, B-LOC, I-LOC, etc.
label_list = ["O", "B-PER", "I-PER", "B-ORG", "I-ORG", "B-LOC", "I-LOC"]
 
model = AutoModelForTokenClassification.from_pretrained(model_name, num_labels=len(label_list))
 
text = "Marie Curie worked at the University of Paris"
inputs = tokenizer(text, return_tensors="pt")
 
import torch
with torch.no_grad():
    outputs = model(**inputs)
 
predictions = torch.argmax(outputs.logits, dim=-1)
tokens = tokenizer.convert_ids_to_tokens(inputs["input_ids"][0])
for token, pred in zip(tokens, predictions[0]):
    print(f"{token}: {label_list[pred]}")
What Changed From Classification (Section 2)
─────────────────────────────────────────
  Instead of using ONLY [CLS]'s output, a classification head
  is applied to EVERY token position's final hidden state
  INDEPENDENTLY — each token gets its OWN prediction, using
  the CONTEXTUAL representation self-attention already computed
  for it (directly benefiting from Chapter 1, Section 6's
  bidirectional context).
─────────────────────────────────────────

4. Task 3: Extractive Question Answering

Given a question and a passage, extractive QA predicts the exact span (start and end position) within the passage that answers the question.

from transformers import AutoModelForQuestionAnswering
 
qa_model = AutoModelForQuestionAnswering.from_pretrained("bert-large-uncased-whole-word-masking-finetuned-squad")
qa_tokenizer = AutoTokenizer.from_pretrained("bert-large-uncased-whole-word-masking-finetuned-squad")
 
question = "Who developed the theory of relativity?"
passage = "Albert Einstein developed the theory of relativity, revolutionizing physics."
 
inputs = qa_tokenizer(question, passage, return_tensors="pt")      # Chapter 1, Sec.4's TWO-segment input
 
with torch.no_grad():
    outputs = qa_model(**inputs)
 
start_idx = torch.argmax(outputs.start_logits)      # the MOST likely START of the answer span
end_idx = torch.argmax(outputs.end_logits)              # the MOST likely END of the answer span
 
answer_tokens = inputs["input_ids"][0][start_idx:end_idx+1]
answer = qa_tokenizer.decode(answer_tokens)
print(f"Answer: {answer}")      # "Albert Einstein"
Two Independent Classification Heads
─────────────────────────────────────────
  A "start" classifier and an "end" classifier, EACH applied
  to every token position (similar to Section 3's per-token
  approach) — predicting, for EVERY position, how likely it is
  to be the START (or END) of the answer span.
─────────────────────────────────────────

5. Task 4: Sentence-Pair Tasks

Tasks like Natural Language Inference (NLI) — given two sentences, predict whether the first entails, contradicts, or is neutral toward the second — directly reuse Chapter 1, Section 4's segment embeddings for encoding two sentences at once.

model = AutoModelForSequenceClassification.from_pretrained(model_name, num_labels=3)      # entailment/neutral/contradiction
 
premise = "A man is playing guitar on stage."
hypothesis = "A person is performing music."
 
inputs = tokenizer(premise, hypothesis, return_tensors="pt")      # TWO sentences, ONE input, via segment embeddings
outputs = model(**inputs)
prediction = torch.argmax(outputs.logits, dim=-1)
labels = ["entailment", "neutral", "contradiction"]
print(f"Prediction: {labels[prediction.item()]}")

This is structurally identical to Section 2's classification setup — the only difference is the input contains two sentences (via BERT's native segment-embedding support) instead of one.


6. Fine-Tuning Hyperparameters — BERT-Specific Guidance

Practical Defaults That Work Well for BERT (Directly Extending
DL Notes, Module 7, Chapter 4's Recipe)
─────────────────────────────────────────
  Learning rate:      2e-5 to 5e-5 — NOTABLY smaller than
                         typical CNN fine-tuning rates (DL Notes,
                         Module 7), since BERT's pretrained
                         representations are especially easy to
                         disrupt with large updates
  Epochs:                 2-4 — BERT fine-tuning typically needs
                              FAR fewer epochs than training from
                              scratch would, since most of the
                              "learning" already happened during
                              pretraining (Chapter 2)
  Batch size:                  16-32, often the LARGEST that fits
                                   in available GPU memory (DL
                                   Notes, Module 9, Ch.2's
                                   gradient accumulation is a
                                   common workaround for memory
                                   constraints)
  Warmup:                          a SHORT warmup period (DL
                                       Notes, Module 3, Ch.5) is
                                       standard practice, typically
                                       10% of total training steps
─────────────────────────────────────────

7. Summary & Next Steps

Key Takeaways

  • Fine-tuning BERT follows a consistent pattern: load pretrained weights, add a small task-specific head, and fine-tune at a low learning rate — directly applying the DL Notes' transfer learning framework.
  • Text classification uses the [CLS] token's pooled representation; NER (token classification) applies a classifier to every token position independently.
  • Extractive QA uses two independent classification heads (start and end position) applied per-token; sentence-pair tasks like NLI reuse BERT's native segment embeddings for two-sentence input.
  • BERT fine-tuning typically uses notably lower learning rates (2e-5 to 5e-5) and fewer epochs (2-4) than training from scratch, since most learning already occurred during pretraining.

Concept Check

  1. Why does text classification use the [CLS] token's representation, while NER applies a classifier to every token position?
  2. How does extractive question answering use two separate classification heads to identify an answer span?
  3. Why do BERT fine-tuning recipes typically use much lower learning rates than training a CNN from scratch would?

Next Chapter

Chapter 4: The BERT Family — RoBERTa, ALBERT, DistilBERT, ELECTRA


Jr Codex — 1-on-1 Personalized Coaching | Back to Module Index | Back to NLP & LLM Index