Fine-tuning: adapting a pretrained model
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?
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']}")
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):
# 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:
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.
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}")
train 374, test 125, 4 classes: ['bekor', 'korish', 'narx', 'shikoyat'] Baseline (most frequent): 0.2880 TF-IDF + LogReg : 0.9920
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.")
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)
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"}, ]))
| Approach | Data | Cost | When |
|---|---|---|---|
| Training from scratch | millions | thousands of GPU-hours | Almost never: not your job |
| Fine-tuning (full) | thousands | hours | Lots of data, task far from pretraining |
| LoRA / QLoRA | hundreds to thousands | minutes | The usual choice: cheap and almost as good |
| Head layer only | hundreds | minutes | Very little data |
| Prompt / RAG | 0 | none | You 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?
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])}")
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', '?']
| model | vocab size | tokens | tokens/char |
|---|---|---|---|
| distilroberta-base | 50,265 | [15, 16, 11] | 0.447 |
| distilbert-base-multilingual-cased | 119,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:
- Pick a multilingual model (with
multilingualin its name). A model trained on English is weaker on Uzbek text. - Because of the chopping, the same text takes more tokens: the context limit fills up faster, and with an LLM the cost goes up.
- 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
"""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.
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))
Tokenization ready: (499, 48)
| method | accuracy | trainable | total params | share | time (s) |
|---|---|---|---|---|---|
| full fine-tuning | 0.96 | 135 327 748 | 135 327 748 | 100.00% | 49.9 |
| LoRA (r=8) | 0.904 | 741 124 | 136 068 872 | 0.54% | 22.5 |
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.")
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
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)
| method | accuracy | trained params | note |
|---|---|---|---|
| baseline (most frequent) | 0.288 | 0 | learns nothing |
| TF-IDF + LogReg | 0.992 | thousands | trains in a second |
| full fine-tuning | 0.96 | 135,327,748 | every weight changes |
| LoRA (r=8) | 0.904 | 741,124 | adapter: 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:
- 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.
- 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.
- Swapping. One base model sits in memory, and adapters are attached and detached at run time.
- 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
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"}, ]))
| Pitfall | Symptom | Fix |
|---|---|---|
| lr too large | loss is NaN or accuracy at chance level | 2e-5..5e-5 for full FT; 1e-4..1e-3 for LoRA |
| Catastrophic forgetting | the model is good at the new task, bad at everything else | fewer epochs, a small lr, or use LoRA |
| Many epochs on little data | train accuracy 1.0, test stays low | early stopping, watch the validation set |
| Tuning on the test set | good on paper, bad in practice | tune on validation: Chapters 6 and 24 |
| Imbalanced classes | the small class is never found | class weights or the F1 metric: Chapters 8 and 9 |
| Random seed | a different result on every run | 3 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
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"}, ]))
| Situation | Right solution | Reason |
|---|---|---|
| Keywords determine the class | TF-IDF + logistic regression | measured in this chapter: cheaper and higher |
| You need knowledge, not style | RAG (retrieval + LLM) | adding new facts doesn't require training |
| Fewer than 100 examples | prompt or few-shot | fine-tuning fits the noise |
| Requirements change often | prompt | retraining on every change is expensive |
| You need an output format | fine-tuning OR enforced structure | style and format are fine-tuning's strong side |
| Specialized domain language | fine-tuning | medical, legal text: far from pretraining |
Connecting to the protocol
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"}, ]))
| Step | Status |
|---|---|
| 37. Data collected | done |
| 38. Dataset built | done: split by group |
| 39. Baseline measured | FOUND that TF-IDF is enough |
| 39. Fine-tuning learned | full and LoRA: the mechanism is known |
| 40. Saving the model | next chapter |
| 41. Serving | Django API |
What we learned today
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"}, ]))
| Concept | In one sentence |
|---|---|
| Baseline first | Measured: TF-IDF came out above the transformer |
| Fine-tuning | Start from pretrained weights and keep training on your data |
| LoRA | Base weights frozen, a small adapter is trained |
| LoRA's advantage | Memory, storage, swapping: not accuracy |
| Tokenization | Measured: both models chop Uzbek words into pieces |
| Multilingual model | Better than an English model for Uzbek text |
| Catastrophic forgetting | Many epochs break the old knowledge |
| When not to | When 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.