Skip to content

Fine-tuning: adapting a pretrained model

Chapter 39 15 code blocks 4277 words

The code in this chapter uses these filesrun it on your own machine

The situation: 499 messages in Uzbek

You work at a rental agency. Customers write in over Telegram, hundreds of messages a day. Sorting them by hand is somebody's full working day.

The task: work out each message's intent. There are four kinds: asking about the price, booking a viewing, a complaint, a cancellation.

This is text classification. In Chapter 36 we saw how a transformer works. Now the question is different: how do we adapt a pretrained model to our own data?

python19 lines
import os
os.environ["HF_HUB_OFFLINE"] = "1"
os.environ["TRANSFORMERS_OFFLINE"] = "1"
os.environ["CUDA_VISIBLE_DEVICES"] = "-1"
os.environ["TOKENIZERS_PARALLELISM"] = "false"

import time
import numpy as np
import pandas as pd

messages = pd.read_csv(DATA / "mijoz_xabarlari.csv")
print(f"Messages: {len(messages)}")
print()
print(messages["maqsad"].value_counts().to_string())
print()
print("Samples:")
for _, q in messages.head(6).iterrows():
    label = "HARD" if q["qiyin"] else "easy"
    print(f"  [{q['maqsad']:<8}] [{label:<5}] {q['xabar']}")
Output
Messages: 499

maqsad
shikoyat    141
korish      137
bekor       113
narx        108

Samples:
  [narx    ] [HARD ] indinga ko'rishdan oldin to'lov shartlarini bilsam
  [korish  ] [HARD ] Narx muhim emas, indinga ko'rishga chiqaman
  [shikoyat] [easy ] Kalit berilmadi, besh kun kutdim
  [korish  ] [easy ] Salom. Shu hafta soat 18:00 da ko'rishga kelsam bo'ladi?
  [bekor   ] [HARD ] Depozit qaytmasa ham bekor qilaman
  [bekor   ] [easy ] Endi kerak emas, rahmat

Column names

The messages are in Uzbek, because that's the point of this chapter: how a model copes with Uzbek text. The columns: xabar message, maqsad intent, qiyin hard (0/1). The four intents: narx price question, korish viewing, shikoyat complaint, bekor cancellation.

This dataset is synthetic, and the qiyin column is there on purpose

datasets/xabarlar_qur.py builds this file with a fixed seed. These aren't real customer messages, so that no personal data is used.

The qiyin column matters. In messages with qiyin=1 a surface keyword points to a different class:

  • "Narx juda baland, bu aldov" ("The price is way too high, this is a scam") contains "narx" (price), but it's a complaint
  • "Konditsioner buzuq ekan, shuning uchun bekor qilaman" ("The air conditioner turned out to be broken, so I'm cancelling") mentions broken equipment, but it's a cancellation
  • "Bekor qilsam depozit qaytadimi?" ("If I cancel, do I get the deposit back?") contains "bekor" (cancel), but it's a price question

Why I did this: a model that works off keywords should fall over here. A transformer, on the other hand, sees the context. That was the hypothesis; we measure it below.

This chapter needs a ONE-TIME download

The code in this chapter uses pretrained models. If they aren't on your computer, the code gives the error OSError: We couldn't connect to huggingface.co, because the code deliberately runs in offline mode (for reproducible results).

So before you start the chapter, run the following once (needs internet):

python7 lines
# download.py: run once, ~600 MB
from transformers import AutoTokenizer, AutoModelForSequenceClassification

for name in ["distilbert-base-multilingual-cased", "distilroberta-base"]:
    AutoTokenizer.from_pretrained(name)
    AutoModelForSequenceClassification.from_pretrained(name, num_labels=4)
    print(name, "ready")

The libraries you need:

bash1 lines
pip install transformers peft torch

Once downloaded, the models stay cached on your computer and you don't need the internet again.

If you have no internet or no disk space: read this chapter through without running the code. Its main conclusion (measure the baseline first; TF-IDF won on this task) makes sense without running anything, and that part needs only sklearn.

Step one: the baseline (always)

The rule from Chapter 7 doesn't change here either. Before loading a transformer, we need to know what a simple model shows.

python24 lines
from sklearn.dummy import DummyClassifier
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline
from sklearn.model_selection import train_test_split

classes = sorted(messages["maqsad"].unique())
class_index = {s: i for i, s in enumerate(classes)}
y = messages["maqsad"].map(class_index).values
X = messages["xabar"].values

tr, te = train_test_split(np.arange(len(messages)), test_size=0.25,
                          random_state=42, stratify=y)
print(f"train {len(tr)}, test {len(te)}, {len(classes)} classes: {classes}")
print()

baseline = DummyClassifier(strategy="most_frequent").fit(X[tr], y[tr])
tfidf = make_pipeline(TfidfVectorizer(ngram_range=(1, 2), min_df=1),
                      LogisticRegression(max_iter=2000)).fit(X[tr], y[tr])

t0 = time.perf_counter()
tfidf_time = time.perf_counter() - t0
print(f"Baseline (most frequent): {baseline.score(X[te], y[te]):.4f}")
print(f"TF-IDF + LogReg         : {tfidf.score(X[te], y[te]):.4f}")
Output
train 374, test 125, 4 classes: ['bekor', 'korish', 'narx', 'shikoyat']

Baseline (most frequent): 0.2880
TF-IDF + LogReg         : 0.9920
python7 lines
hard_mask = messages["qiyin"].values[te].astype(bool)
print("We check TF-IDF part by part:")
print(f"  on EASY examples: {tfidf.score(X[te][~hard_mask], y[te][~hard_mask]):.4f}  ({(~hard_mask).sum()} examples)")
print(f"  on HARD examples: {tfidf.score(X[te][hard_mask], y[te][hard_mask]):.4f}  ({hard_mask.sum()} examples)")
print()
print("My hypothesis: TF-IDF falls over on the hard examples.")
print("The measurement: it didn't.")
Output
We check TF-IDF part by part:
  on EASY examples: 1.0000  (94 examples)
  on HARD examples: 0.9677  (31 examples)

My hypothesis: TF-IDF falls over on the hard examples.
The measurement: it didn't.

I started this chapter assuming "we need a transformer"

Look at the numbers above. TF-IDF + logistic regression classified almost every message correctly, even the examples I deliberately built to be hard.

Why: xabarlar_qur.py uses templates. Every template is its own particular combination of words. I put the word "narx" (price) into the complaint class, but the rest of the sentence ("bu aldov", "this is a scam"; "aldadingiz", "you cheated me") is still typical of a complaint. The model found that.

This is the limit of synthetic data, and it shouldn't be hidden. Any text dataset built from templates will be easy for a bag-of-words model.

So the fine-tuning in this chapter is an exercise, not a victory. You learn the API because you'll need it at work. But using a transformer on this task is a waste of resources, and we'll measure that.

In a real project the order is this: measure the baseline, then, if the baseline isn't enough, consider a transformer. The reverse order is the most common and the most expensive mistake.

What fine-tuning is (and isn't)

python12 lines
show(pd.DataFrame([
    {"Approach": "Training from scratch", "Data": "millions", "Cost": "thousands of GPU-hours",
     "When": "Almost never: not your job"},
    {"Approach": "Fine-tuning (full)", "Data": "thousands", "Cost": "hours",
     "When": "Lots of data, task far from pretraining"},
    {"Approach": "LoRA / QLoRA", "Data": "hundreds to thousands", "Cost": "minutes",
     "When": "The usual choice: cheap and almost as good"},
    {"Approach": "Head layer only", "Data": "hundreds", "Cost": "minutes",
     "When": "Very little data"},
    {"Approach": "Prompt / RAG", "Data": "0", "Cost": "none",
     "When": "You need knowledge, not style: no training needed"},
]))
ApproachDataCostWhen
Training from scratchmillionsthousands of GPU-hoursAlmost never: not your job
Fine-tuning (full)thousandshoursLots of data, task far from pretraining
LoRA / QLoRAhundreds to thousandsminutesThe usual choice: cheap and almost as good
Head layer onlyhundredsminutesVery little data
Prompt / RAG0noneYou need knowledge, not style: no training needed

Tokenization: does the model see Uzbek?

Before fine-tuning you have to check: how does the model you picked cut Uzbek text into pieces?

python19 lines
from transformers import AutoTokenizer

samples = ["Bugun to'lasam narx o'zgaradimi?",
           "Konditsioner buzilgan, tuzatib bering",
           "Bronni bekor qilmoqchiman"]
tok_table = []
for name in ["distilroberta-base", "distilbert-base-multilingual-cased"]:
    tok = AutoTokenizer.from_pretrained(name)
    tokens = [len(tok.tokenize(s)) for s in samples]
    chars = [len(s) for s in samples]
    tok_table.append({"model": name, "vocab size": f"{tok.vocab_size:,}",
                      "tokens": str(tokens),
                      "tokens/char": round(sum(tokens) / sum(chars), 3)})
show(pd.DataFrame(tok_table))

for name in ["distilroberta-base", "distilbert-base-multilingual-cased"]:
    tok = AutoTokenizer.from_pretrained(name)
    print(f"{name}:")
    print(f"  {tok.tokenize(samples[0])}")
Output
distilroberta-base:
  ['Bug', 'un', 'Ġto', "'", 'las', 'am', 'Ġnar', 'x', 'Ġo', "'", 'z', 'gar', 'ad', 'imi', '?']
distilbert-base-multilingual-cased:
  ['Bug', '##un', 'to', "'", 'las', '##am', 'nar', '##x', 'o', "'", 'z', '##gara', '##dim', '##i', '?']
modelvocab sizetokenstokens/char
distilroberta-base50,265[15, 16, 11]0.447
distilbert-base-multilingual-cased119,547[15, 12, 9]0.383

Both models chop Uzbek words into pieces

"Bugun" ("today") is one word. Both models split it into two pieces. "o'zgaradimi" ("does it change?") goes into several.

What that means: the model doesn't know Uzbek morphology. It sees a sequence of letters, not word stems.

The tokens/char column measures this: the smaller the number, the fewer pieces the model cuts the text into, meaning the better it "knows" the language. The multilingual model is better on this measure, but neither is perfect.

The practical consequences:

  1. Pick a multilingual model (with multilingual in its name). A model trained on English is weaker on Uzbek text.
  2. Because of the chopping, the same text takes more tokens: the context limit fills up faster, and with an LLM the cost goes up.
  3. If your project is entirely in Uzbek, a model pretrained on an Uzbek corpus (or pretraining that you continue yourself) makes a noticeable difference.

Full fine-tuning and LoRA

Now we measure the two methods under the same conditions.

create this fileadapt/train.py

python61 lines
"""Fine-tuning for text classification: full and LoRA."""
import time

import numpy as np
import torch
import torch.nn as nn
from torch.utils.data import DataLoader, TensorDataset
from transformers import AutoModelForSequenceClassification, AutoTokenizer

MODEL = "distilbert-base-multilingual-cased"
MAX_LENGTH = 48


def prepare(texts, model_name=MODEL, max_len=MAX_LENGTH):
    tok = AutoTokenizer.from_pretrained(model_name)
    return tok(list(texts), truncation=True, max_length=max_len,
               padding="max_length", return_tensors="pt")


def train(enc, y, train_idx, test_idx, n_classes, lora=False, epochs=3,
          lr=None, seed=0, model_name=MODEL):
    """Trains the model and returns (accuracy, trainable, total, time, model).

    With lora=True the base weights are FROZEN and only small
    adapter matrices are trained.
    """
    torch.manual_seed(seed)
    model = AutoModelForSequenceClassification.from_pretrained(model_name, num_labels=n_classes)
    if lora:
        from peft import LoraConfig, get_peft_model
        model = get_peft_model(model, LoraConfig(
            task_type="SEQ_CLS", r=8, lora_alpha=16, lora_dropout=0.05,
            target_modules=["q_lin", "v_lin"]))      # attention Q and V only

    trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
    total = sum(p.numel() for p in model.parameters())

    def subset(idx):
        return TensorDataset(enc["input_ids"][idx], enc["attention_mask"][idx],
                             torch.tensor(y[idx]))

    opt = torch.optim.AdamW([p for p in model.parameters() if p.requires_grad],
                            lr=lr or (5e-4 if lora else 3e-5))
    loader = DataLoader(subset(train_idx), batch_size=16, shuffle=True,
                        generator=torch.Generator().manual_seed(seed))
    t0 = time.perf_counter()
    model.train()
    for _ in range(epochs):
        for ids, mask, target in loader:
            opt.zero_grad()
            model(input_ids=ids, attention_mask=mask, labels=target).loss.backward()
            opt.step()
    elapsed = time.perf_counter() - t0

    model.eval()
    correct = 0
    with torch.no_grad():
        for ids, mask, target in DataLoader(subset(test_idx), batch_size=32):
            prediction = model(input_ids=ids, attention_mask=mask).logits.argmax(-1)
            correct += int((prediction == target).sum())
    return correct / len(test_idx), trainable, total, elapsed, model

The LoRA idea: correct a big matrix with two small ones

In full fine-tuning every weight W changes. In LoRA, W is frozen, and a small correction is added next to it:

W_new = W + B @ A

Here A and B are very "thin" matrices (r=8 is the inner dimension). Only they are trained.

Why it works: the change the weights need during fine-tuning is usually low-rank, meaning it can be expressed as a product of small matrices. That's where the name comes from: Low-Rank Adaptation.

The practical result is in the table below.

python17 lines
import sys
sys.path.insert(0, str(ISH))
from adapt.train import prepare, train

enc = prepare(X)
print(f"Tokenization ready: {tuple(enc['input_ids'].shape)}")
print()
results = []
models = {}
for name, lora in [("full fine-tuning", False), ("LoRA (r=8)", True)]:
    accuracy, trainable, total, elapsed, model = train(enc, y, tr, te, len(classes), lora=lora)
    models[name] = model
    results.append({"method": name, "accuracy": round(accuracy, 4),
                    "trainable": trainable, "total params": total,
                    "share": f"{trainable / total:.2%}",
                    "time (s)": round(elapsed, 1)})
show(pd.DataFrame(results))
Output
Tokenization ready: (499, 48)
methodaccuracytrainabletotal paramssharetime (s)
full fine-tuning0.96135 327 748135 327 748100.00%49.9
LoRA (r=8)0.904741 124136 068 8720.54%22.5
python13 lines
import shutil
from pathlib import Path

print(f"{'method':<22} {'on disk':>12}")
print("-" * 36)
for name, model in models.items():
    folder = ISH / ("saved_" + ("lora" if "LoRA" in name else "full"))
    shutil.rmtree(folder, ignore_errors=True)
    model.save_pretrained(folder)
    size = sum(p.stat().st_size for p in Path(folder).rglob("*") if p.is_file())
    print(f"{name:<22} {size / 1e6:>9.2f} MB")
print()
print("With LoRA only the adapter is saved: the base model is unchanged and doesn't need saving again.")
Output
method                      on disk
------------------------------------
full fine-tuning          541.32 MB
LoRA (r=8)                  2.97 MB

With LoRA only the adapter is saved: the base model is unchanged and doesn't need saving again.

The three methods side by side

python12 lines
KEY = "trainable"
comparison = pd.DataFrame([
    {"method": "baseline (most frequent)", "accuracy": round(baseline.score(X[te], y[te]), 4),
     "trained params": "0", "note": "learns nothing"},
    {"method": "TF-IDF + LogReg", "accuracy": round(tfidf.score(X[te], y[te]), 4),
     "trained params": "thousands", "note": "trains in a second"},
    {"method": results[0]["method"], "accuracy": results[0]["accuracy"],
     "trained params": f"{results[0][KEY]:,}", "note": "every weight changes"},
    {"method": results[1]["method"], "accuracy": results[1]["accuracy"],
     "trained params": f"{results[1][KEY]:,}", "note": "adapter: a few MB"},
])
show(comparison)
methodaccuracytrained paramsnote
baseline (most frequent)0.2880learns nothing
TF-IDF + LogReg0.992thousandstrains in a second
full fine-tuning0.96135,327,748every weight changes
LoRA (r=8)0.904741,124adapter: a few MB

Two conclusions, and both are measured.

First: on this task TF-IDF scored higher than both transformers. With thousands of times fewer resources.

Second: LoRA gave slightly lower accuracy than full fine-tuning, but it trained a very small share of the parameters, finished faster, and took a few MB on disk.

LoRA's advantage isn't accuracy

Look at the table: LoRA didn't beat full fine-tuning on accuracy. The literature often says "LoRA is almost as good", and that's true, but the word "almost" matters.

LoRA's real advantages are elsewhere:

  1. Memory. Gradients and optimizer state are kept only for the small adapter. That's what makes it possible to adapt a big model on a small GPU; QLoRA (4-bit quantization + LoRA) continues the same idea.
  2. Storage. Look at the measurement above: the adapter is tiny compared with the full model. If you need 50 custom models for 50 clients, you store 50 adapters, not 50 full models.
  3. Swapping. One base model sits in memory, and adapters are attached and detached at run time.
  4. Speed. The measured time column: fewer parameters, less computation.

If you have one task and unlimited resources, full fine-tuning may be a little better. In practice, resources aren't unlimited, and there's more than one task.

Fine-tuning pitfalls

python14 lines
show(pd.DataFrame([
    {"Pitfall": "lr too large", "Symptom": "loss is NaN or accuracy at chance level",
     "Fix": "2e-5..5e-5 for full FT; 1e-4..1e-3 for LoRA"},
    {"Pitfall": "Catastrophic forgetting", "Symptom": "the model is good at the new task, bad at everything else",
     "Fix": "fewer epochs, a small lr, or use LoRA"},
    {"Pitfall": "Many epochs on little data", "Symptom": "train accuracy 1.0, test stays low",
     "Fix": "early stopping, watch the validation set"},
    {"Pitfall": "Tuning on the test set", "Symptom": "good on paper, bad in practice",
     "Fix": "tune on validation: Chapters 6 and 24"},
    {"Pitfall": "Imbalanced classes", "Symptom": "the small class is never found",
     "Fix": "class weights or the F1 metric: Chapters 8 and 9"},
    {"Pitfall": "Random seed", "Symptom": "a different result on every run",
     "Fix": "3 seeds, and report the worst one too: Chapter 35"},
]))
PitfallSymptomFix
lr too largeloss is NaN or accuracy at chance level2e-5..5e-5 for full FT; 1e-4..1e-3 for LoRA
Catastrophic forgettingthe model is good at the new task, bad at everything elsefewer epochs, a small lr, or use LoRA
Many epochs on little datatrain accuracy 1.0, test stays lowearly stopping, watch the validation set
Tuning on the test setgood on paper, bad in practicetune on validation: Chapters 6 and 24
Imbalanced classesthe small class is never foundclass weights or the F1 metric: Chapters 8 and 9
Random seeda different result on every run3 seeds, and report the worst one too: Chapter 35

This chapter used one seed, and that should be said out loud

In Chapter 35 we measured it: a neural network's result depends noticeably on the seed.

The fine-tuning numbers in this chapter come from one seed, because every extra seed adds minutes to the book build. So you can't draw the precise conclusion "LoRA is lower by exactly this much" from them, only "at a comparable level".

In your own project you use at least 3 seeds. The book's limitation is build time, not a scientific reason, and hiding that would be wrong.

When NOT to fine-tune

python14 lines
show(pd.DataFrame([
    {"Situation": "Keywords determine the class", "Right solution": "TF-IDF + logistic regression",
     "Reason": "measured in this chapter: cheaper and higher"},
    {"Situation": "You need knowledge, not style", "Right solution": "RAG (retrieval + LLM)",
     "Reason": "adding new facts doesn't require training"},
    {"Situation": "Fewer than 100 examples", "Right solution": "prompt or few-shot",
     "Reason": "fine-tuning fits the noise"},
    {"Situation": "Requirements change often", "Right solution": "prompt",
     "Reason": "retraining on every change is expensive"},
    {"Situation": "You need an output format", "Right solution": "fine-tuning OR enforced structure",
     "Reason": "style and format are fine-tuning's strong side"},
    {"Situation": "Specialized domain language", "Right solution": "fine-tuning",
     "Reason": "medical, legal text: far from pretraining"},
]))
SituationRight solutionReason
Keywords determine the classTF-IDF + logistic regressionmeasured in this chapter: cheaper and higher
You need knowledge, not styleRAG (retrieval + LLM)adding new facts doesn't require training
Fewer than 100 examplesprompt or few-shotfine-tuning fits the noise
Requirements change oftenpromptretraining on every change is expensive
You need an output formatfine-tuning OR enforced structurestyle and format are fine-tuning's strong side
Specialized domain languagefine-tuningmedical, legal text: far from pretraining

Connecting to the protocol

python8 lines
show(pd.DataFrame([
    {"Step": "37. Data collected", "Status": "done"},
    {"Step": "38. Dataset built", "Status": "done: split by group"},
    {"Step": "39. Baseline measured", "Status": "FOUND that TF-IDF is enough"},
    {"Step": "39. Fine-tuning learned", "Status": "full and LoRA: the mechanism is known"},
    {"Step": "40. Saving the model", "Status": "next chapter"},
    {"Step": "41. Serving", "Status": "Django API"},
]))
StepStatus
37. Data collecteddone
38. Dataset builtdone: split by group
39. Baseline measuredFOUND that TF-IDF is enough
39. Fine-tuning learnedfull and LoRA: the mechanism is known
40. Saving the modelnext chapter
41. ServingDjango API

What we learned today

python10 lines
show(pd.DataFrame([
    {"Concept": "Baseline first", "In one sentence": "Measured: TF-IDF came out above the transformer"},
    {"Concept": "Fine-tuning", "In one sentence": "Start from pretrained weights and keep training on your data"},
    {"Concept": "LoRA", "In one sentence": "Base weights frozen, a small adapter is trained"},
    {"Concept": "LoRA's advantage", "In one sentence": "Memory, storage, swapping: not accuracy"},
    {"Concept": "Tokenization", "In one sentence": "Measured: both models chop Uzbek words into pieces"},
    {"Concept": "Multilingual model", "In one sentence": "Better than an English model for Uzbek text"},
    {"Concept": "Catastrophic forgetting", "In one sentence": "Many epochs break the old knowledge"},
    {"Concept": "When not to", "In one sentence": "When keywords are enough, or RAG or a prompt is"},
]))
ConceptIn one sentence
Baseline firstMeasured: TF-IDF came out above the transformer
Fine-tuningStart from pretrained weights and keep training on your data
LoRABase weights frozen, a small adapter is trained
LoRA's advantageMemory, storage, swapping: not accuracy
TokenizationMeasured: both models chop Uzbek words into pieces
Multilingual modelBetter than an English model for Uzbek text
Catastrophic forgettingMany epochs break the old knowledge
When not toWhen keywords are enough, or RAG or a prompt is

In the next chapter we save the model and load it back, and here we measure the most common mistakes.

Questions about this chapter?

The course, homework review and the other five books are in Uzbek. Questions in English are welcome on Telegram.

Write on Telegram Contents