SDAIA Academy · Applied NLP Engineering

Fine-Tuning & NLP Engineering

Day 3: Adapting pretrained models to your task, and proving it helped

AI Engineering Track Day 3 of 4 GPU 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
Break10 min
Session 4 · Evaluate, deploy & capstone kickoffLeaderboard, error analysis, packaging, demo app, capstone brief50 min
Session 1

Fine-tuning fundamentals & data

01
The adaptation spectrum

From changing the input to changing the weights

TechniqueWhat changesData neededComputeSeen in
Prompting / few-shotInput only0–10 examplesnoneDay 2
RAGInput (retrieved context)DocumentsIndexingDay 2
Frozen features + headSmall new headTens–hundredsCPU, secondsDay 1
Full fine-tuning (encoder)All weights + headHundreds–thousands1 GPU, minutesSession 2
PEFT / LoRA (LLM)Small adapter matricesHundreds–thousands1 GPU, minutes–hoursSession 3
Continued pretrainingAll weights, unlabelled domain textMillions of tokensMany GPUsOut of scope
Transfer learning

Why a few thousand examples are enough

Pretraining (someone else's GPUs)
billions of tokens → grammar, semantics, world knowledge, Arabic morphology
↓ reuse the weights
Fine-tuning (your GPU, minutes)
3k labelled tickets → "which of 27 intents?"
  • 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_order
1,200
get_refund
840
payment_issue
360
complaint
140
delete_account
60

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.
Lab 3.1 · 20 min

Prepare a leak-free ticket dataset

labs/D3_1_finetuning_data_prep.ipynb ↗

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

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

SettingDefaultNotes
Learning rate2e-5 – 5e-5Too high → collapse; too low → slow
Epochs3–5Small data overfits fast; watch val
Batch size16–32Limited by GPU memory
Warmup~6–10% of stepsStabilizes the random head
Weight decay0.01Mild regularization
max_lengthp99 of dataOur tickets: ~24 tokens
Precisionfp16 / 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

epochs →loss best checkpoint train validation overfitting →
  • 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

ModelParamsLanguagesTrade-off
distilbert/distilbert-base-multilingual-cased135M104Fast; our lab default
FacebookAI/xlm-roberta-base278M100Stronger multilingual; slower
aubmindlab/bert-base-arabertv02136MArabicStrong on Arabic, weak on English
CAMeL-Lab/bert-base-arabic-camelbert-mix110MArabic (MSA + dialects)Dialect-aware
answerdotai/ModernBERT-base149MEnglish (+ 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.
Lab 3.2 · 25 min

Fine-tune a ticket intent classifier

labs/D3_2_finetune_encoder.ipynb ↗

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

03
The memory wall

Full fine-tuning needs ~16 bytes per parameter

Per parameter (mixed precision + Adam)Bytes
Weights (bf16) + fp32 master copy2 + 4
Gradients2–4
Adam moments (m, v) in fp328
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

h = W·x + (α/r)·B·A·x W (frozen)d × k = 4096² + B × A d × r and r × k, with r = 16
  • 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
KnobTypical
Rank r8–64 (16 is a good start)
alphar to 2r
Target modulesAll linear layers > attention-only
Learning rate1e-4 – 2e-4 (higher than full FT)
Epochs1–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.
prompt_ids = tok.apply_chat_template(msgs[:-1], add_generation_prompt=True)["input_ids"]
full_ids   = tok.apply_chat_template(msgs)["input_ids"]
labels     = [-100] * len(prompt_ids) + full_ids[len(prompt_ids):]
Choose deliberately

Prompting vs. RAG vs. fine-tuning

PromptingRAGLoRA fine-tuning
ChangesInstructionsKnowledge in contextBehaviour, format, style, domain skill
Update cycleMinutesRe-indexRe-train + re-evaluate
NeedsGood promptDocuments + retrieverLabelled examples + GPU
Per-request costLong prompts = $$Context tokensShort prompts; small model
RisksBrittle, injectionRetrieval missesForgetting, overfitting, stale facts
Common winning combo: a LoRA-tuned small model for format and domain behaviour, plus RAG for current facts, plus validation in code.
Lab 3.3 · 25 min

LoRA-tune a small instruct model for ticket triage

labs/D3_3_lora_llm.ipynb ↗

Qwen2.5-0.5B-Instruct + LoRA on ~1,500 examples. Trains in about 8–10 min on a T4. Fallback: an instructor-trained adapter.

  • Before: zero-shot JSON validity + intent accuracy on 100 tickets 4'
  • Build SFT records; apply template; mask prompt tokens 5'
  • Attach LoRA; check trainable %; start training 4'
  • While it trains: inspect a batch, explain the labels 6'
  • After: same 100 tickets; compare validity, accuracy, latency 5'
  • ✱ Merge the adapter; compare r = 4 vs. r = 32
Checkpoint

Refund policy changes every quarter. The team wants to LoRA-tune the LLM on the policy text. Your advice?

Good idea: re-train each quarter
Use RAG for the policy (facts that change). Fine-tune, if at all, for format and tone.
Put the whole policy in every prompt forever
B. Weights are a poor database: hard to update, impossible to cite. Keep changing knowledge in the index.
Break

10-minute break

Next: Session 4 · Evaluate, deploy & capstone kickoff

Session 4

Evaluate, deploy & capstone kickoff

04
Before vs. after

One test set, one table, every approach

Approach (intent, 27 classes)Macro-F1 ENMacro-F1 ARp95 latencyCost / 1kDay
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.

  1. Confusion matrix → top 3 confused pairs
  2. Read 20 errors (yes, actually read them)
  3. Tag each one with a cause:
CauseAction
Label noise / wrong gold labelFix data, re-evaluate
Genuinely ambiguous ticketMerge labels or allow multi-label / abstain
Missing coverage (dialect, typo)Collect / augment that slice
Real model errorBigger model, more data, better features
Out of scopeAdd 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.
Lab 3.4 · 20 min

Leaderboard, error analysis, demo

labs/D3_4_evaluate_deploy.ipynb ↗

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

  1. A working end-to-end pipeline with a demo (Gradio or notebook UI).
  2. At least one representation (embeddings) and one decision or generation component.
  3. A baseline and at least one improvement, compared on the same test set.
  4. Fine-tuning (encoder or LoRA), or a written, evidence-based justification for not fine-tuning.
  5. 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 solution
30%
Model selection & implementation
25%
Evaluation & error analysis
20%
Fine-tuning / justified adaptation
15%
Presentation & reproducibility
10%
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.

Block 1 (1–2h)

Data + baseline + eval set, reusing Lab 1.4 / 2.4 code.

Block 2 (1–2h)

Improvement + fine-tune (Lab 3.2 / 3.3 code) + leaderboard.

Block 3 (1h)

Demo, README, 6–8 slides, rehearse once with a timer.

Scope down early. A small system with honest numbers beats an ambitious one that doesn't run in the demo.
Tomorrow · Day 4

Capstone presentations

Demonstrate, evaluate and defend an applied NLP solution.

Bring

Repo link, a running demo (with a recorded backup), your slides.

Expect

Technical questions on model choice, evaluation and failure cases.

Give

Structured peer feedback to other teams.