Fine-Tune a Small Text Classifier: Baselines, a Training Loop, Early Stopping, Abstention and a Shift Test
Hands-on lab · IDE in your browser

Fine-Tune a Small Text Classifier: Baselines, a Training Loop, Early Stopping, Abstention and a Shift Test

Train a small transformer encoder to label chat messages by intent, and measure what fine-tuning buys: beat a bag-of-words and a frozen-encoder baseline, write the training loop, pick the epoch with the best dev accuracy, send the model's least confident predictions to a person, and test all three models on messages in an unseen style, where the one that learned the meaning pulls ahead.

Time
55 min
Checked steps
5
Level
Intermediate
Setup
None
Read step 1

Hands-on labs require Pro · $29.99/mo · cancel anytime

The job

Deployflow wants its team-chat messages labelled by intent: blocker, status, request, praise or decision. The words overlap between intents, so the model has to learn the intent and not the vocabulary. You fine-tune a small encoder and find out where it beats the no-training baselines, and where it does not.

5 steps, each checked when you finish it

A check runs your work at the end of every step. Hints and the full solution are there if you get stuck.

  1. 1

    Two baselines to beat

    Deployflow's team chat needs each message labelled by the writer's intent: blocker, status, request, praise or decision.

  2. 2

    Fine-tune the encoder

    Now train the encoder itself.

  3. 3

    Stop at the right epoch

    In Step 2 the training accuracy raced to 1.0 while dev accuracy rose, peaked, then slipped.

  4. 4

    When the model is unsure

    A classifier can hand its least confident messages to a person instead of guessing.

  5. 5

    The shift test

    On the test set the three models all did well, because the test messages are written like the training ones.

Step 1 as it appears in the lab

The lab’s own text. The hint and the solution stay inside the lab.

Step 1: Two baselines to beat

Deployflow's team chat needs each message labelled by the writer's intent: blocker, status, request, praise or decision. The messages are all about the same work (PRs, deploys, tickets), so the words overlap between intents. You will fine-tune a small encoder for this, but first measure what you get without any training. If a baseline is good enough, fine-tuning is not worth its cost.

harness.py gives you the labelled data (H.load("train"), "dev", "test"), the frozen encoder as a feature extractor (H.encode_frozen(texts)), and LABELS.

Do this in classifier.py

1. fit_bow(train): a TfidfVectorizer (1- and 2-grams) feeding a LogisticRegression. Return an object with a .predict(texts) that gives label strings.

2. fit_frozen(train): the same, but the features are H.encode_frozen(texts) instead of word counts.

3. Run. It reports each baseline's dev accuracy.

classifier.py, the file you edit78 lines
"""Train a small text classifier for Deployflow's team chat: label each message by the writer's intent
(blocker, status, request, praise, decision). The words overlap between intents, so the model has to learn the
intent, not the vocabulary."""
import numpy as np
import torch
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression

import harness as H

LABELS = H.LABELS


def texts_labels(rows):
    return [r["text"] for r in rows], [r["label"] for r in rows]


def accuracy(pred, gold):
    """The share of predictions that match, as a float."""
    return float(np.mean([p == g for p, g in zip(pred, gold)])) if gold else 0.0


# ---------- Step 1: two baselines to beat ----------

def fit_bow(train):
    """A bag-of-words baseline: a TfidfVectorizer (1- and 2-grams) fitted on the training texts feeding a
    LogisticRegression. Return an object with a .predict(texts) that gives label strings."""
    # TODO (Step 1): TfidfVectorizer(1,2-grams) + LogisticRegression on the training texts; return an object with .predict.
    raise NotImplementedError("Step 1: write fit_bow()")


def fit_frozen(train):
    """A feature-extraction baseline: the frozen encoder's embeddings (H.encode_frozen) feeding a LogisticRegression.
    Return an object with .predict(texts)."""
    # TODO (Step 1): H.encode_frozen(texts) as features + LogisticRegression; return an object with .predict.
    raise NotImplementedError("Step 1: write fit_frozen()")


# ---------- Step 2: fine-tune the encoder ----------

def train_epoch(model, opt, rows, epoch=0, batch_size=16):
    """One pass over rows in shuffled batches (seed the shuffle with epoch so runs repeat). For each batch: zero the
    gradients, run the model on the tokenised texts with their labels, back-propagate the loss, step the optimiser.
    Return the mean loss over the batches."""
    raise NotImplementedError("train_epoch() arrives in Step 2")


def predict(model, texts, batch_size=32):
    """The predicted label string for each text."""
    raise NotImplementedError("predict() arrives in Step 2")


# ---------- Step 3: stop at the right epoch ----------

def best_epoch(history):
    """The epoch (an entry's "epoch") with the highest "dev_acc" in history (a list of {"epoch","train_acc",
    "dev_acc"}); the earliest on a tie. This is the checkpoint to keep, not the last one."""
    raise NotImplementedError("best_epoch() arrives in Step 3")


# ---------- Step 4: when the model is unsure ----------

def probabilities(model, texts, batch_size=32):
    """The softmax probability of each label for each text, as an array of shape (len(texts), len(LABELS))."""
    raise NotImplementedError("probabilities() arrives in Step 4")


def coverage_accuracy(probs, gold, tau):
    """(coverage, accuracy): the share of texts whose top probability is at least tau (the rest go to a person), and
    the accuracy on that kept share (1.0 if none are kept). gold is the list of true label strings."""
    raise NotImplementedError("coverage_accuracy() arrives in Step 4")


# ---------- Step 5: the shift test ----------

def per_class_f1(pred, gold, labels=None):
    """{label: F1} for each label. F1 is 2*precision*recall/(precision+recall), 0 when it is undefined."""
    raise NotImplementedError("per_class_f1() arrives in Step 5")
Provided for you:data.jsonharness.pyshift.jsontry_it.py

Frequently asked questions

When is fine-tuning better than using frozen embeddings?

When there is enough labelled data for the task and the frozen encoder's off-the-shelf representation does not already separate the classes. With little data or a large distribution shift, a frozen feature extractor can generalise better and costs far less, so measure both.

How do you avoid overfitting when fine-tuning a classifier?

Track dev accuracy each epoch and keep the checkpoint where it peaks, not the last one. Training past the peak lowers the loss on the training set while dev accuracy slips.

How can a classifier abstain when it is unsure?

Use the top softmax probability as a confidence score and set a threshold: predictions below it are sent to a person. Raising the threshold lowers coverage and raises accuracy on what is kept.

Fine-tuning a small text classifier

Fine-tuning a small pretrained encoder is the workhorse of text classification, but it is only worth its cost when it beats the baselines that need no training. The way to know is to measure. You build a bag-of-words and a frozen-encoder baseline, write the fine-tuning loop, read the train and dev curves to stop at the right epoch, add a confidence threshold that routes uncertain messages to a person, and compare all three models on messages in an unseen style.