Fine-Tuning DistilBERT for IMDB Sentiment

A PyTorch Training Loop with Validation and Test Isolation

Fine-tuning continues training a pretrained model on labeled data for a specific task. This example adds a binary classification head to DistilBERT and trains it on IMDB reviews. A validation split is used during development, while the official test set remains untouched until training is complete.

Configuration

DistilBERT[1] is a six-layer encoder. The settings below fix the data-split and initialization seed. A maximum length of 256 reduces memory use by truncating longer reviews.

import time
import torch

from tqdm.auto import tqdm
from torch.optim import AdamW
from torch.utils.data import DataLoader
from datasets import DatasetDict, load_dataset
from transformers import (
    AutoModelForSequenceClassification,
    AutoTokenizer,
    DataCollatorWithPadding,
    set_seed,
)

MODEL_NAME = "distilbert-base-uncased"
BATCH_SIZE = 16
EPOCHS = 3
MAX_LEN = 256
LEARNING_RATE = 2e-5
SEED = 42

set_seed(SEED)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

Data

IMDB contains 25,000 labeled training reviews and 25,000 labeled test reviews[2]. Ten percent of the training split becomes validation data. Padding is deferred until batching so each batch is padded only to its longest sequence[3].

raw = load_dataset("stanfordnlp/imdb")
split = raw["train"].train_test_split(
    test_size=0.1,
    seed=SEED,
    stratify_by_column="label",
)

data = DatasetDict({
    "train": split["train"],
    "validation": split["test"],
    "test": raw["test"],
})

tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)

def tokenize(batch):
    return tokenizer(
        batch["text"],
        truncation=True,
        max_length=MAX_LEN,
    )

data = data.map(tokenize, batched=True, remove_columns=["text"])
collator = DataCollatorWithPadding(tokenizer=tokenizer)

loaders = {
    name: DataLoader(
        dataset_split,
        batch_size=BATCH_SIZE,
        shuffle=(name == "train"),
        collate_fn=collator,
    )
    for name, dataset_split in data.items()
}

Model and evaluation

The sequence-classification model adds a randomly initialized two-class head to the pretrained encoder. AdamW updates the head and all encoder parameters. Accuracy is easy to interpret here because the dataset is balanced.

model = AutoModelForSequenceClassification.from_pretrained(
    MODEL_NAME,
    num_labels=2,
).to(device)

optimizer = AdamW(model.parameters(), lr=LEARNING_RATE)

@torch.inference_mode()
def accuracy(model, loader):
    model.eval()
    correct = 0
    total = 0

    for batch in loader:
        batch = {key: value.to(device) for key, value in batch.items()}
        labels = batch.pop("labels")
        predictions = model(**batch).logits.argmax(dim=-1)
        correct += (predictions == labels).sum().item()
        total += labels.numel()

    return correct / total

Training

Validation accuracy is reported after each epoch. The test loader is evaluated once after all updates are complete. Runtime and accuracy come from the execution rather than a hard-coded estimate.

started_at = time.perf_counter()

for epoch in range(1, EPOCHS + 1):
    model.train()
    total_loss = 0.0
    examples_seen = 0

    progress = tqdm(loaders["train"], desc=f"Epoch {epoch}/{EPOCHS}")
    for batch in progress:
        batch = {key: value.to(device) for key, value in batch.items()}

        optimizer.zero_grad(set_to_none=True)
        output = model(**batch)
        output.loss.backward()
        optimizer.step()

        batch_size = batch["labels"].size(0)
        total_loss += output.loss.item() * batch_size
        examples_seen += batch_size
        progress.set_postfix(loss=f"{output.loss.item():.4f}")

    val_accuracy = accuracy(model, loaders["validation"])
    print(
        f"epoch={epoch} "
        f"train_loss={total_loss / examples_seen:.4f} "
        f"val_accuracy={val_accuracy:.4f}"
    )

test_accuracy = accuracy(model, loaders["test"])
elapsed_minutes = (time.perf_counter() - started_at) / 60

print(f"test_accuracy={test_accuracy:.4f}")
print(f"elapsed_minutes={elapsed_minutes:.1f}")

Interpreting the output

The validation values describe performance during development. The final test value is the only test measurement. Accuracy and runtime depend on the seed, package versions, hardware, and truncation length, so any published result should include those details alongside the output.