Day 3: Adapting pretrained models to your task, and proving it helped
AI Engineering TrackDay 3 of 4GPU runtime required for Labs 3.2–3.3
Yesterday in 60 seconds
Where we are
LLMs generate token by token. Use greedy decoding for decisions and sampling for drafting.
Schemas make LLM output usable; validation + retry or constrained decoding make it reliable.
RAG grounds replies in your KB, with citations and abstention.
Model selection needs numbers: macro-F1, p95 latency, cost per 1k.
The gap we close today
Off-the-shelf models are good. Today we make them ours: a classifier trained on our labels, and an LLM adapted to our output format, both evaluated before and after.
Today
Day 3 agenda
Session 1 · Fine-tuning fundamentals & dataTransfer learning, when to fine-tune, splits, leakage, datasets50 min
Break10 min
Session 2 · Fine-tune a decision modelClassification heads, cross-entropy, Trainer, overfitting50 min
Long break20 min
Session 3 · LLM adaptation with LoRASFT, LoRA/QLoRA, loss masking, fine-tuning vs. RAG50 min
The body already produces useful representations. Fine-tuning nudges them toward your labels.
Small learning rate (≈2e-5) so you don't erase pretraining.
The new head starts random. It's the only part learned from scratch.
Decision
When should you fine-tune?
Good reasons
A fixed task with a stable label set and labelled data
High volume: need low latency and cost per request
Consistent output format or style a prompt can't enforce
Domain language the base model handles poorly
Distill an expensive LLM into a small model
Poor reasons
Teaching facts that change (prices, policies). Use RAG.
No evaluation set, so you can't tell if it helped
A few dozen examples. Try few-shot first.
"Because everyone fine-tunes"
Rule of thumb: fine-tuning teaches behaviour (how to respond). RAG supplies knowledge (what is true now). Many production systems use both.
Data is the model
Label quality beats label quantity
Guidelines
Write a definition and 2 examples per label, including how to break ties (get_refund vs. track_refund).
Agreement
Have two people label the same 100 tickets. If they agree < 85%, fix the guidelines before training.
Coverage
Every label, every language and dialect, short and long, polite and angry, typos.
How many? For an encoder classifier, start with ~50–100 per class, measure, then add data where the confusion matrix says it's needed.
Label noise sets a ceiling. A model can't beat the consistency of its labels.
Splits and leakage
Your test score is only as honest as your split
Train to fit · validation to choose (epochs, thresholds, models) · test once, at the end.
Stratify by label so rare intents appear in every split.
Near-duplicates across splits inflate scores. Templated datasets (like ours!) are full of them.
Group by customer / thread / template so related items stay on one side.
Train: "I need help to cancel order {{Order Number}}" Test: "I need help canceling order {{Order Number}}" → The model memorized, it didn't generalize.
Check: embed train and test; flag test items with cosine > 0.95 to any train item. Report scores with and without them.
Imbalance
When some labels are rare
track_order1,200
get_refund840
payment_issue360
complaint140
delete_account60
Illustrative real-world distribution
Measure with macro-F1 and per-class recall, not accuracy.
Class-weighted loss: errors on rare classes cost more.
Oversample rare classes in training (never in test).
Collect more of the rare class: active learning from low-confidence predictions.
Hugging Face Datasets
From a DataFrame to model-ready tensors
from datasets import Dataset, DatasetDict
from transformers import AutoTokenizer, DataCollatorWithPadding
labels = sorted(train_df.intent.unique())
label2id = {l: i for i, l in enumerate(labels)}
id2label = {i: l for l, i in label2id.items()}
def to_ds(df):
return Dataset.from_pandas(
df.assign(label=df.intent.map(label2id))[["text", "label"]],
preserve_index=False)
ds = DatasetDict(train=to_ds(train_df), validation=to_ds(val_df),
test=to_ds(test_df))
tok = AutoTokenizer.from_pretrained("distilbert/distilbert-base-multilingual-cased")
ds = ds.map(lambda b: tok(b["text"], truncation=True, max_length=32),
batched=True)
collator = DataCollatorWithPadding(tok) # pads per batch, not globally
Dynamic padding: pad each batch to its longest item. It's often 2–4× faster than padding everything to 512.
max_length from your data: the 99th percentile of token lengths (Lab 1.1).
Save the label maps with the model. Shuffled label IDs are a classic silent production bug.
The output of this lab (a saved DatasetDict + label maps) feeds Labs 3.2 and 3.4.
Explore label distribution and text lengths 3'
Near-duplicate check between train and test with embeddings 5'
Build a realistic low-resource train set (≤ 80 per intent) 3'
Label maps, DatasetDict, tokenization, save to disk 6'
Start the model download for Lab 3.2 / 3.3 in the background 3'
✱ Generate 100 synthetic Arabic training tickets with the LLM; spot-check 10
Checkpoint
You tried 6 learning rates and picked the one with the best test F1. What's wrong?
Nothing, that's what the test set is for
The test set was used for model selection, so its score is now optimistically biased. Choose on validation.
You should have tried more learning rates
B. Every decision made by looking at a split "uses it up". Keep the test set for the final, single evaluation.
Break
10-minute break
Next: Session 2 · Fine-tune a decision model
Session 2
Fine-tune a decision model
Explain classification heads, logits, softmax and cross-entropy
Fine-tune a multilingual encoder with the Hugging Face Trainer
Read learning curves; prevent overfitting; pick the best checkpoint
02
Architecture
Encoder + a tiny head
Ticket tokens[CLS] my refund …
→
Pretrained encoder6–12 layers, updated with small LR
→
[CLS] / pooled vector768-d
→
Headdropout → Linear(768 → 27)
→
Logits → softmaxp(intent)
Cross-entropy loss for one example with true class y:
L = −log p(y | ticket)
p = 0.9 → L = 0.11 · p = 0.1 → L = 2.3. Confident mistakes are punished hard.
Difference from Day 1: there, the encoder was frozen and only logistic regression learned. Here, gradients flow into the encoder too, so the representations themselves adapt to your labels.
Hyperparameters
Sensible defaults for encoder fine-tuning
Setting
Default
Notes
Learning rate
2e-5 – 5e-5
Too high → collapse; too low → slow
Epochs
3–5
Small data overfits fast; watch val
Batch size
16–32
Limited by GPU memory
Warmup
~6–10% of steps
Stabilizes the random head
Weight decay
0.01
Mild regularization
max_length
p99 of data
Our tickets: ~24 tokens
Precision
fp16 / bf16
~2× faster on GPU
Seeds matter on small data: run 3 seeds and report mean ± std before claiming a 1-point gain.
Change one thing at a time, log it, compare on validation.
Code
The Trainer recipe
from transformers import (AutoModelForSequenceClassification, TrainingArguments,
Trainer, EarlyStoppingCallback)
model = AutoModelForSequenceClassification.from_pretrained(
"distilbert/distilbert-base-multilingual-cased", num_labels=len(labels), id2label=id2label, label2id=label2id)
def compute_metrics(p): # sklearn f1_score on argmax of logits
return {"macro_f1": f1_score(p.label_ids, p.predictions.argmax(-1), average="macro")}
args = TrainingArguments(
output_dir="runs/intent", learning_rate=3e-5, num_train_epochs=4, warmup_steps=0.1,
per_device_train_batch_size=32, weight_decay=0.01, seed=42,
fp16=torch.cuda.is_available(), # warmup_steps < 1 = ratio of total steps
eval_strategy="epoch", save_strategy="epoch", save_total_limit=1,
load_best_model_at_end=True, metric_for_best_model="macro_f1", report_to="none")
trainer = Trainer(model=model, args=args, train_dataset=ds["train"], eval_dataset=ds["validation"],
processing_class=tok, data_collator=collator, compute_metrics=compute_metrics,
callbacks=[EarlyStoppingCallback(early_stopping_patience=2)])
trainer.train()
trainer.evaluate(ds["test"]) # once, at the very end
Learning curves
Read the curves before you read the score
Both falling: still learning, maybe train longer.
Train ↓, val ↑: overfitting, so stop early and keep the best checkpoint.
Both flat and high: LR too low/high, a bug, or labels too noisy.
Val loss ↑ but F1 still ↑: happens (over-confidence). Select by your target metric.
Which encoder?
Pick for language coverage and latency budget
Model
Params
Languages
Trade-off
distilbert/distilbert-base-multilingual-cased
135M
104
Fast; our lab default
FacebookAI/xlm-roberta-base
278M
100
Stronger multilingual; slower
aubmindlab/bert-base-arabertv02
136M
Arabic
Strong on Arabic, weak on English
CAMeL-Lab/bert-base-arabic-camelbert-mix
110M
Arabic (MSA + dialects)
Dialect-aware
answerdotai/ModernBERT-base
149M
English (+ code)
8k context, fast; not for Arabic
Cross-lingual transfer: a multilingual encoder fine-tuned on English tickets can classify Arabic ones zero-shot, usually with a drop. A few hundred real Arabic examples typically close much of the gap. You'll measure both.
About 5 minutes of training on a Colab T4. Start training first, then read the code while it runs.
Load the saved dataset; build model with label maps 3'
Train with Trainer + early stopping 8'
Plot train/val loss and macro-F1 per epoch 3'
Test once: English and Arabic; compare to Day 1 embeddings + LR 6'
Save model + tokenizer + label maps; reload; predict 5'
✱ Data-size ablation: 10 / 25 / 80 per class; plot F1 vs. data
Checkpoint
Train loss keeps falling; validation macro-F1 peaked at epoch 2 and is dropping at epoch 5. What do you ship?
The epoch-5 model: lowest training loss
The epoch-2 checkpoint (best on validation), then evaluate it once on test
Train for 10 more epochs
B. That's exactly what load_best_model_at_end + early stopping automate.
Long break
20-minute break
Next: Session 3 · LLM adaptation with LoRA
Session 3
LLM adaptation with LoRA
Explain why full LLM fine-tuning is expensive and how LoRA/QLoRA fix it
Prepare chat-formatted SFT data with loss masking
Run a LoRA fine-tune and measure before vs. after
03
The memory wall
Full fine-tuning needs ~16 bytes per parameter
Per parameter (mixed precision + Adam)
Bytes
Weights (bf16) + fp32 master copy
2 + 4
Gradients
2–4
Adam moments (m, v) in fp32
8
Total (before activations)
≈ 16
24 GB
to fully fine-tune a 1.5B model
~112 GB
for a 7B model: multiple GPUs
LoRA
Freeze W. Learn a low-rank update ΔW = B·A
Trainable params per matrix: r(d + k) = 16 × 8192 ≈ 131k instead of 16.8M (0.8%).
Optimizer state only for adapters, so memory drops dramatically.
B starts at zero, so training starts exactly from the base model.
Adapters are small files (tens of MB). Swap them per task on one base model.
QLoRA: load the frozen base in 4-bit (NF4) and train bf16 adapters on top. A 7B model fits on one 16–24 GB GPU.
Configuration
LoRA knobs that matter
from peft import LoraConfig, get_peft_model
lora = LoraConfig(
r=16, # rank: capacity of the update
lora_alpha=32, # scaling; often 2 × r
lora_dropout=0.05,
target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj"],
task_type="CAUSAL_LM",
)
model = get_peft_model(base_model, lora)
model.print_trainable_parameters()
# trainable params: 8.8M || all params: 503M || trainable%: 1.75
Knob
Typical
Rank r
8–64 (16 is a good start)
alpha
r to 2r
Target modules
All linear layers > attention-only
Learning rate
1e-4 – 2e-4 (higher than full FT)
Epochs
1–3; watch for format overfitting
SFT data
Supervised fine-tuning data = conversations you want to see
{"messages": [
{"role": "system",
"content": "Classify the support ticket. Reply with JSON."},
{"role": "user",
"content": "i was charged 2 times for order A-55821!!"},
{"role": "assistant",
"content": "{\"intent\": \"payment_issue\", \"category\": \"PAYMENT\", \"priority\": \"urgent\"}"}
]}
Train with the same chat template and system prompt you'll use at inference.
Consistency > volume: 1–3k clean, uniform examples teach a format well.
Distillation: you can generate targets with a big model, but then review them. You inherit its errors.
Loss masking
Learn to answer, not to repeat the question
<|im_start|>system …<|im_start|>useri was charged 2 times …<|im_start|>assistant{"intent":"payment_issue",…}<|im_end|>
Prompt tokens (grey): label = -100, ignored by the loss.
Assistant tokens (green): label = token ID, so they're trained on.
Compare every model from the bootcamp on one honest leaderboard
Do structured error analysis and behavioural tests
Package a reproducible model and serve it behind a demo app
04
Before vs. after
One test set, one table, every approach
Approach (intent, 27 classes)
Macro-F1 EN
Macro-F1 AR
p95 latency
Cost / 1k
Day
TF-IDF + logistic regression
?
?
?
?
1
Zero-shot NLI
?
?
?
?
1
e5 embeddings + logistic regression
?
?
?
?
1
LLM few-shot (1.5B)
?
?
?
?
2
Fine-tuned DistilmBERT
?
?
?
?
3
LoRA-tuned LLM (0.5B)
?
?
?
?
3
Lab 3.4 fills this in from saved predictions. With ~1,000 test tickets, differences under ~2 points may be noise, so we add bootstrap confidence intervals.
Error analysis
Read the errors. Then decide what to do.
Confusion matrix → top 3 confused pairs
Read 20 errors (yes, actually read them)
Tag each one with a cause:
Cause
Action
Label noise / wrong gold label
Fix data, re-evaluate
Genuinely ambiguous ticket
Merge labels or allow multi-label / abstain
Missing coverage (dialect, typo)
Collect / augment that slice
Real model error
Bigger model, more data, better features
Out of scope
Add an "other" class + threshold
Robustness
Behavioural tests: unit tests for models
Invariance
Changes that shouldn't change the label: names, order numbers, typos, polite ↔ rude, EN ↔ AR.
Directional
Changes that should move the output: add "URGENT, charged twice" → priority must not go down.
Minimum functionality
Short, obvious cases every model must pass: "cancel my order" → cancel_order.
def test_invariance_order_number(predict):
a = predict("I want to cancel order A-10001")
b = predict("I want to cancel order Z-99231")
assert a == b == "cancel_order"
Inference engineering
Make it fast and cheap before you make it bigger
Batch requests; sort by length to minimize padding.
Truncate to the p99 length (~24 tokens for our tickets).
fp16 on GPU; dynamic int8 quantization or ONNX Runtime on CPU.
Distill: a 6-layer model is often within a point of a 12-layer one.
Load the model once per process, not per request.
import torch
from transformers import pipeline
clf = pipeline("text-classification",
model="artifacts/intent-distilmbert",
device=0 if torch.cuda.is_available() else -1,
truncation=True, max_length=32)
clf(["where is my refund", "وين طلبي"], batch_size=64)
# [{'label': 'track_refund', 'score': 0.97}, ...]
Reproducibility
Could a colleague rebuild your model next month?
Pinned package versions (uv.lock / requirements)
Pinned base model revision
Data snapshot + split seed + preprocessing code
All hyperparameters in a config file, not in your head
Seeds set; results over ≥ 3 seeds for small data
Saved: model + tokenizer + label maps + metrics
Model card (README)
Intended use and out-of-scope use
Training data and labels
Metrics overall and by slice (language, dialect)
Known limitations and failure cases
Owner and retraining plan
Serving
From notebook to demo in 20 lines
import gradio as gr
from pipeline import assist # your Day 2 + 3 code
def run(ticket):
out = assist(ticket)
return (out["intent"], out["confidence"],
out["priority"], out["reply"], out)
gr.Interface(
fn=run,
inputs=gr.Textbox(lines=4, label="Customer message"),
outputs=[gr.Textbox(label="Intent"), gr.Number(label="Confidence"),
gr.Textbox(label="Priority"),
gr.Textbox(label="Draft reply", lines=6),
gr.JSON(label="Full output")],
examples=[["Where is my refund?"], ["لم يصلني الطلب"]],
).launch()
Demo: Gradio, for people.
Service: FastAPI POST /classify, for systems. Same assist() function underneath.
Monitor: confidence distribution, % abstained, new-intent drift, human corrections. Corrections feed the next training round.
This notebook is also your capstone template for evaluation and serving.
Load saved predictions; build the leaderboard with bootstrap CIs 5'
Confusion pairs + tag 10 errors by cause 5'
Run the behavioural test suite 3'
Batch vs. single latency; CPU int8 quantization 3'
Launch the Gradio demo of the full assistant 4'
✱ Wrap assist() in a FastAPI endpoint
Capstone kickoff
Build it. Measure it. Defend it.
Teams of 2–3 deliver an applied NLP solution and present it on Day 4.
1 · Ticket Assistant
Classify, retrieve similar cases, draft grounded replies.
2 · Arabic Document Intelligence
Extract and categorize from Arabic documents; answer questions with evidence.
3 · Voice of Customer
Classify feedback, cluster issues with embeddings, summarize insights.
Minimum requirements
What every project must show
A working end-to-end pipeline with a demo (Gradio or notebook UI).
At least one representation (embeddings) and one decision or generation component.
A baseline and at least one improvement, compared on the same test set.
Fine-tuning (encoder or LoRA), or a written, evidence-based justification for not fine-tuning.
Quantitative evaluation + error analysis with ≥ 10 categorized failures.
Submission: GitHub repo with code, README (problem, data, approach, results, how to run), evaluation results, sample inputs/outputs.
Presentation: 10 min talk + live demo, 5 min Q&A.
Deadline: repo link submitted before Day 4 session 1 starts.
Evaluation rubric
How capstones are scored
Functional end-to-end solution30%
Model selection & implementation25%
Evaluation & error analysis20%
Fine-tuning / justified adaptation15%
Presentation & reproducibility10%
What earns top marks: a clear problem statement, an honest baseline, a justified model choice with latency/cost, before/after numbers by slice (e.g., Arabic vs. English), and failure cases you understand.
Plan your evening
A realistic capstone timeline
Now (10')
Confirm scope + metric with the instructor. Copy the capstone template.