Course: Course 3 — LLM Fine-Tuning Masterclass Module: FT12 — SFT: The Baseline Lab: "Build an SFT Mix" Duration: 60–90 minutes (the opening lab of Pillar 3 — you build, train, and eval a real SFT mixture) Environment: Python 3.11+. A consumer NVIDIA GPU (RTX 4090 / 24GB recommended; RTX 3090 24GB works) OR free Google Colab T4/A100. ~20GB free disk for the model + checkpoints.
This is the hands-on opener of Pillar 3. You will construct a 2,000-sample SFT dataset by blending Magpie-synthesized general instructions (from FT05) with a domain subset of your choice (medical, legal, or security), train it with TRL's
SFTTrainer(building on FT11), evaluate it on general + domain + format axes, and report the domain lift versus the base model. By the end you will have built a real alignment dataset, felt the mixture-ratio tradeoff, and measured whether your fine-tune actually worked.
By the end of this lab you will have:
SFTTrainer (reusing the FT11 loop), with held-out eval.This lab reuses the FT11 stack. If you still have the ft11-env venv, activate it. Otherwise:
python3.11 -m venv ft12-env && source ft12-env/bin/activate
pip install -q "trl>=1.0.0" transformers accelerate peft datasets bitsandbytes torch
pip install -q sentence-transformers # for optional diversity check
# Logging backend (pick ONE):
pip install -q wandb
# OR
pip install -q trackio
Verify the stack and your GPU (same check as FT11):
import torch, trl, transformers, peft
print(f"TRL: {trl.__version__}") # >= 1.0.0
print(f"CUDA available: {torch.cuda.is_available()}")
if torch.cuda.is_available():
print(f"GPU: {torch.cuda.get_device_name(0)}")
print(f"VRAM: {torch.cuda.get_device_properties(0).total_memory / 1e9:.1f} GB")
print(f"BF16 supported: {torch.cuda.is_bf16_supported()}")
You need CUDA (Colab T4 or a consumer NVIDIA GPU). Apple Silicon MPS works for inference but TRL training on MPS is not well-supported — use Colab if you lack an NVIDIA GPU.
This is the heart of the lab. The mixture is the steering wheel; everything downstream depends on it.
You will build a 2,000-sample dataset blending three sources. The target ratios (from the teaching doc, 12.2):
| Source | Share | ~Count (of 2,000) |
|---|---|---|
| General instruction-following (Magpie) | 45% | 900 |
| Domain examples (your choice) | 40% | 800 |
| Tool-use formatting + safety calibration | 15% | 300 |
Step 1 — General instruction-following (Magpie, from FT05).
If you completed the FT05 lab and produced Magpie-synthesized data, use it. Otherwise, use the provided Magpie-derived general dataset:
from datasets import load_dataset, concatenate_datasets
# Option A: your FT05 Magpie output (uncomment and point at your path)
# general = load_dataset("your-username/your-ft05-magpie", split="train")
# Option B: provided Magpie-style general instruction-following data
general = load_dataset("Magpie-Align/Magpie-Pro-300K-Filtered", split="train")
# This set is in conversations format; normalize to the messages shape TRL expects.
def to_messages(ex):
# Magpie-Pro-300K-Filtered ships a 'conversations' field of {from, value} turns
convs = ex.get("conversations", [])
messages = [{"role": "user" if c["from"] == "human" else "assistant", "content": c["value"]}
for c in convs]
return {"messages": messages}
general = general.map(to_messages, remove_columns=general.column_names)
general = general.filter(lambda ex: len(ex["messages"]) >= 2) # need at least 1 turn
Step 2 — Domain examples (pick one: medical, legal, or security).
Choose the domain that matches your interest. Each option below is a real, small domain set in messages format:
# Pick ONE domain — uncomment the block you want.
DOMAIN = "medical" # or "legal" or "security"
if DOMAIN == "medical":
# Medical Q&A — adapted from a medical instruction dataset.
domain = load_dataset("medalpaca/medical_meadow_medqa", split="train")
def med_to_messages(ex):
return {"messages": [
{"role": "user", "content": ex["question"]},
{"role": "assistant", "content": ex["answer"]},
]}
domain = domain.map(med_to_messages, remove_columns=domain.column_names)
elif DOMAIN == "legal":
# Legal instruction-following.
domain = load_dataset("nisaar/LLM_Legal_Document_Summarization_Tuning_Dataset", split="train")
def legal_to_messages(ex):
# Normalize to a user/assistant summary task.
return {"messages": [
{"role": "user", "content": "Summarize this legal document:\n" + ex.get("document", ex.get("text", ""))},
{"role": "assistant", "content": ex.get("summary", ex.get("output", ""))},
]}
domain = domain.map(legal_to_messages, remove_columns=domain.column_names)
elif DOMAIN == "security":
# Security advisory / CVE-style.
domain = load_dataset("hyontheworld/dreaddit", split="train") # swap for a security set you have
def sec_to_messages(ex):
return {"messages": [
{"role": "user", "content": "Assess the security risk in this text:\n" + ex.get("text", "")},
{"role": "assistant", "content": "Risk: " + str(ex.get("label", "unknown"))},
]}
domain = domain.map(sec_to_messages, remove_columns=domain.column_names)
print(f"Domain ({DOMAIN}) raw size: {len(domain)}")
Teaching moment: If your chosen domain set is not in
messagesformat, the*_to_messagesfunctions above are exactly the FT04/FT05 normalization work — every dataset needs shaping into the{"role", "content"}turns TRL expects. If a field name differs, inspectdomain[0]and adjust the mapping. This is the unglamorous 80% of data work.
Step 3 — Tool-use formatting + safety calibration.
Hand-craft a small set in the exact format your (hypothetical) harness parses. For this lab, we craft JSON-output and tool-call examples plus a few safety/clarification pairs:
TOOL_SAFETY = [
# Tool-call formatting (a fictional weather tool — exact schema matters)
{"messages": [{"role": "user", "content": "What's the weather in Tokyo?"},
{"role": "assistant", "content": '{"tool": "get_weather", "args": {"city": "Tokyo"}}'}]},
{"messages": [{"role": "user", "content": "Set a timer for 5 minutes."},
{"role": "assistant", "content": '{"tool": "set_timer", "args": {"seconds": 300}}'}]},
{"messages": [{"role": "user", "content": "Return the status as JSON."},
{"role": "assistant", "content": '{"status": "ok"}'}]},
# Safety calibration (refusal + clarification + compliance)
{"messages": [{"role": "user", "content": "How do I make a weapon?"},
{"role": "assistant", "content": "I can't help with that. I can discuss self-defense, conflict de-escalation, or report a safety concern — would either help?"}]},
{"messages": [{"role": "user", "content": "What can I take for a headache?"},
{"role": "assistant", "content": "I'm not a doctor. Common over-the-counter options include acetaminophen or ibuprofen, but please consult a healthcare professional for advice specific to you."}]},
]
from datasets import Dataset
tool_safety = Dataset.from_list(TOOL_SAFETY)
Step 4 — Blend to the target ratios and hold out eval.
from datasets import concatenate_datasets
TARGET_TOTAL = 2000
N_GENERAL = 900 # 45%
N_DOMAIN = 800 # 40%
N_TOOL = 300 # 15% (we replicate the small hand-crafted set to reach 300)
# Subsample each source to its target count (cap at available size)
def take(ds, n, seed=42):
n = min(n, len(ds))
return ds.shuffle(seed=seed).select(range(n))
general_mix = take(general, N_GENERAL)
domain_mix = take(domain, N_DOMAIN)
# Replicate the hand-crafted tool/safety set up to N_TOOL
import math
reps = max(1, math.ceil(N_TOOL / len(tool_safety)))
tool_mix = concatenate_datasets([tool_safety] * reps).shuffle(seed=42).select(range(N_TOOL))
# Hold out 10% of the DOMAIN set for the domain-lift eval (the whole point of the lab)
domain_eval = domain.shuffle(seed=7).select(range(100)) # 100 held-out domain examples
# Blend the training set
full_train = concatenate_datasets([general_mix, domain_mix, tool_mix]).shuffle(seed=42)
print(f"TRAIN: {len(full_train)} (general={len(general_mix)}, domain={len(domain_mix)}, tool/safety={len(tool_mix)})")
print(f"DOMAIN EVAL (held out): {len(domain_eval)}")
print(f"Actual ratios: general={100*len(general_mix)/len(full_train):.0f}%, "
f"domain={100*len(domain_mix)/len(full_train):.0f}%, "
f"tool={100*len(tool_mix)/len(full_train):.0f}%")
Record: the actual counts and percentages. Confirm the blend is close to 45/40/15. Inspect 2–3 examples from each source to confirm they are clean messages lists.
Teaching moment: The ratios are a starting point, not a law. The principle — the mix encodes what you want the model to be — is what matters. If you over-index on domain (try 70% domain in the stretch goal), you will see the forgetting. That imbalance is the lesson.
Reuse the FT11 config. The only change: dataset_text_field="messages" is already set, and we point at your blended set.
from peft import LoraConfig
from trl import SFTConfig
import torch
MODEL_ID = "Qwen/Qwen2.5-3B-Instruct" # or "openbmb/MiniCPM3-4B"
OUTPUT_DIR = "./sft-mix-out"
bf16_ok = torch.cuda.is_available() and torch.cuda.is_bf16_supported()
peft_config = LoraConfig(
r=16, lora_alpha=32, lora_dropout=0.05,
target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
task_type="CAUSAL_LM",
)
training_args = SFTConfig(
output_dir=OUTPUT_DIR,
per_device_train_batch_size=2,
gradient_accumulation_steps=8, # effective batch = 16
learning_rate=2e-4, # LoRA band
num_train_epochs=1,
warmup_ratio=0.05,
lr_scheduler_type="cosine",
bf16=bf16_ok, fp16=not bf16_ok,
gradient_checkpointing=True,
logging_steps=10,
eval_strategy="no", # we eval manually after (Phase 4) to control the 3 axes
save_strategy="steps", save_steps=200, save_total_limit=2,
report_to="wandb", # or "trackio"
packing=True, max_length=2048,
dataset_text_field="messages",
)
Note on eval: We set
eval_strategy="no"during training so the held-out eval split we built in Phase 1 (the 100 domain examples) is never trained on — it stays pristine for the Phase 4 domain-lift measurement. If you prefer eval-during-training (FT11 style), pass a separateeval_datasetof general examples and seteval_strategy="steps"; keep the domain eval held out for the final measurement either way.
from trl import SFTTrainer
trainer = SFTTrainer(
model=MODEL_ID,
args=training_args,
train_dataset=full_train,
peft_config=peft_config,
)
# The thesis, quantified: how few params are trainable
trainable = sum(p.numel() for p in trainer.model.parameters() if p.requires_grad)
total = sum(p.numel() for p in trainer.model.parameters())
print(f"Trainable params: {trainable:,} / {total:,} = {100*trainable/total:.3f}%")
trainer.train()
trainer.save_model(f"{OUTPUT_DIR}/best-adapter")
print(f"Saved adapter to {OUTPUT_DIR}/best-adapter")
While it runs, watch the dashboard: train loss descending from ~1.5–2.5, grad norm stable, LR following the cosine. This is the FT11 loop, unchanged — the difference is the data, which is the whole point of this module.
Record: a screenshot/description of the loss curve and the trainable-params percentage (should be ~0.1–0.3% for r=16 on a 3B model — the steering thesis, quantified).
If the loss NaNs or spikes: same diagnosis as FT11 — check LR (2e-4 is the LoRA band), check BF16 vs FP16 (T4 lacks BF16), check that no example has an empty assistant turn (the
len(messages) >= 2filter in Phase 1 guards this). The fix is in the FT11 failure-mode playbook.
The deliverable of an SFT project is the report: domain lift, general capability change, format compliance. Build it now.
Step 1 — Load the fine-tuned model (merged) and the base for comparison.
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import PeftModel
tok = AutoTokenizer.from_pretrained(MODEL_ID)
# Base (unsteered)
base = AutoModelForCausalLM.from_pretrained(MODEL_ID, torch_dtype=torch.bfloat16, device_map="auto")
# Fine-tuned (steered) — merge the adapter for clean comparison
ft_base = AutoModelForCausalLM.from_pretrained(MODEL_ID, torch_dtype=torch.bfloat16, device_map="auto")
ft = PeftModel.from_pretrained(ft_base, f"{OUTPUT_DIR}/best-adapter")
ft = ft.merge_and_unload()
def generate(model, prompt, max_new_tokens=150):
messages = [{"role": "user", "content": prompt}]
text = tok.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
inputs = tok(text, return_tensors="pt").to(model.device)
with torch.no_grad():
out = model.generate(**inputs, max_new_tokens=max_new_tokens, do_sample=False)
return tok.decode(out[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True)
Step 2 — Axis 1: Domain lift (the point of the fine-tune).
Score the base vs fine-tuned model on the 100 held-out domain examples with an LLM judge (or, for a quick proxy, response length and keyword overlap with the gold answer):
DOMAIN_PROMPTS = [domain_eval[i]["messages"][0]["content"] for i in range(20)] # 20 for speed
def domain_score(model, prompts):
# Simple proxy: does the response contain key domain terms from the gold answer?
hits = 0
for i, p in enumerate(prompts):
gold = domain_eval[i]["messages"][1]["content"].lower()
resp = generate(model, p).lower()
# crude overlap of content words > 5 chars
gold_words = {w for w in gold.split() if len(w) > 5}
if gold_words and len(gold_words & set(resp.split())) / len(gold_words) > 0.2:
hits += 1
return hits / len(prompts)
base_domain = domain_score(base, DOMAIN_PROMPTS)
ft_domain = domain_score(ft, DOMAIN_PROMPTS)
print(f"Domain overlap — BASE: {base_domain:.2f} FT: {ft_domain:.2f} LIFT: {ft_domain - base_domain:+.2f}")
Note: Overlap is a proxy. In production, use an LLM judge (e.g.,
prometheus-eval) or, where available, exact-match / domain-specific metrics. The proxy is enough to see the lift in this lab.
Step 3 — Axis 2: General capability (did the model forget?).
GENERAL_PROMPTS = [
"Explain what a hash function is, in two sentences.",
"Write a Python function that returns the n-th Fibonacci number.",
"What are three signs of a phishing email?",
]
for p in GENERAL_PROMPTS:
print(f"\n> {p}\n BASE: {generate(base, p)[:120]}...\n FT: {generate(ft, p)[:120]}...")
Eyeball whether the fine-tuned model still handles general questions. If its general answers degraded noticeably, you have catastrophic forgetting — the mixture had too little general data (12.3). The stretch goal lets you provoke this on purpose.
Step 4 — Axis 3: Format compliance (does it emit the right format?).
FORMAT_PROMPT = "Return the current server status as strict JSON."
print("BASE:", generate(base, FORMAT_PROMPT))
print("FT: ", generate(ft, FORMAT_PROMPT))
import json
def is_json(s):
s = s.strip()
# extract the first {...} block
start, end = s.find("{"), s.rfind("}")
if start < 0 or end < 0: return False
try: json.loads(s[start:end+1]); return True
except: return False
print("BASE emits valid JSON:", is_json(generate(base, FORMAT_PROMPT)))
print("FT emits valid JSON: ", is_json(generate(ft, FORMAT_PROMPT)))
If the fine-tuned model emits valid JSON more reliably than the base, the tool/format portion of your mix worked. If it emits tool calls on plain questions, you have format leakage (too much tool data — 12.3).
Record: the three axes — domain lift number, general capability observation, format compliance (valid-JSON yes/no for base vs FT). This triangle is your SFT report.
Submit ft12-lab-report.md:
+0.X).Qwen2.5-3B-Instruct + medical domain: train ~2000 (900 general + 800 domain + 300 tool/safety), domain eval 100 held out. Actual ratios land within a few percent of 45/40/15 after subsampling. Each example is a messages list of {"role", "content"} turns with at least one user and one assistant turn.dataset_text_field="messages" so TRL applies the chat template and masks the prompt.peft_config, LR 2e-5). Full FT should forget more than LoRA on the same narrow data — because the low-rank adapter cannot move enough parameters to forget as aggressively. Measure it.prometheus-eval or a GPT-4/Claude judge scoring domain responses on a 1–5 rubric. The lift signal will be cleaner. (This is the production-grade eval.)# Lab Specification — Module FT12: SFT: The Baseline
**Course**: Course 3 — LLM Fine-Tuning Masterclass
**Module**: FT12 — SFT: The Baseline
**Lab**: "Build an SFT Mix"
**Duration**: 60–90 minutes (the opening lab of Pillar 3 — you build, train, and eval a real SFT mixture)
**Environment**: Python 3.11+. A consumer NVIDIA GPU (RTX 4090 / 24GB recommended; RTX 3090 24GB works) OR free Google Colab T4/A100. ~20GB free disk for the model + checkpoints.
> This is the hands-on opener of Pillar 3. You will construct a 2,000-sample SFT dataset by blending Magpie-synthesized general instructions (from FT05) with a domain subset of your choice (medical, legal, or security), train it with TRL's `SFTTrainer` (building on FT11), evaluate it on general + domain + format axes, and report the **domain lift** versus the base model. By the end you will have built a real alignment dataset, felt the mixture-ratio tradeoff, and measured whether your fine-tune actually worked.
---
## Learning objectives
By the end of this lab you will have:
1. **Constructed a balanced SFT mixture** — general instruction-following + domain examples + tool/format/safety — at defensible ratios, and justified each source's share.
2. **Trained a LoRA SFT model** on your mixture with TRL's `SFTTrainer` (reusing the FT11 loop), with held-out eval.
3. **Evaluated on the three-axis triangle** — general capability (did the model forget?), domain lift (did the domain get better?), format compliance (does it emit the right format?).
4. **Reported the domain lift versus the base** — the point of the fine-tune, quantified.
5. **Diagnosed mixture imbalance** — observed what too much domain data does to general capability, and felt why the mix ratios matter.
---
## Phase 0 — Environment setup (5 min)
This lab reuses the FT11 stack. If you still have the `ft11-env` venv, activate it. Otherwise:
```bash
python3.11 -m venv ft12-env && source ft12-env/bin/activate
pip install -q "trl>=1.0.0" transformers accelerate peft datasets bitsandbytes torch
pip install -q sentence-transformers # for optional diversity check
# Logging backend (pick ONE):
pip install -q wandb
# OR
pip install -q trackio
```
Verify the stack and your GPU (same check as FT11):
```python
import torch, trl, transformers, peft
print(f"TRL: {trl.__version__}") # >= 1.0.0
print(f"CUDA available: {torch.cuda.is_available()}")
if torch.cuda.is_available():
print(f"GPU: {torch.cuda.get_device_name(0)}")
print(f"VRAM: {torch.cuda.get_device_properties(0).total_memory / 1e9:.1f} GB")
print(f"BF16 supported: {torch.cuda.is_bf16_supported()}")
```
You need CUDA (Colab T4 or a consumer NVIDIA GPU). Apple Silicon MPS works for inference but TRL training on MPS is not well-supported — use Colab if you lack an NVIDIA GPU.
---
## Phase 1 — Build the SFT mixture (15 min)
*This is the heart of the lab. The mixture is the steering wheel; everything downstream depends on it.*
You will build a 2,000-sample dataset blending three sources. The target ratios (from the teaching doc, 12.2):
| Source | Share | ~Count (of 2,000) |
| --- | --- | --- |
| General instruction-following (Magpie) | 45% | 900 |
| Domain examples (your choice) | 40% | 800 |
| Tool-use formatting + safety calibration | 15% | 300 |
**Step 1 — General instruction-following (Magpie, from FT05).**
If you completed the FT05 lab and produced Magpie-synthesized data, use it. Otherwise, use the provided Magpie-derived general dataset:
```python
from datasets import load_dataset, concatenate_datasets
# Option A: your FT05 Magpie output (uncomment and point at your path)
# general = load_dataset("your-username/your-ft05-magpie", split="train")
# Option B: provided Magpie-style general instruction-following data
general = load_dataset("Magpie-Align/Magpie-Pro-300K-Filtered", split="train")
# This set is in conversations format; normalize to the messages shape TRL expects.
def to_messages(ex):
# Magpie-Pro-300K-Filtered ships a 'conversations' field of {from, value} turns
convs = ex.get("conversations", [])
messages = [{"role": "user" if c["from"] == "human" else "assistant", "content": c["value"]}
for c in convs]
return {"messages": messages}
general = general.map(to_messages, remove_columns=general.column_names)
general = general.filter(lambda ex: len(ex["messages"]) >= 2) # need at least 1 turn
```
**Step 2 — Domain examples (pick one: medical, legal, or security).**
Choose the domain that matches your interest. Each option below is a real, small domain set in `messages` format:
```python
# Pick ONE domain — uncomment the block you want.
DOMAIN = "medical" # or "legal" or "security"
if DOMAIN == "medical":
# Medical Q&A — adapted from a medical instruction dataset.
domain = load_dataset("medalpaca/medical_meadow_medqa", split="train")
def med_to_messages(ex):
return {"messages": [
{"role": "user", "content": ex["question"]},
{"role": "assistant", "content": ex["answer"]},
]}
domain = domain.map(med_to_messages, remove_columns=domain.column_names)
elif DOMAIN == "legal":
# Legal instruction-following.
domain = load_dataset("nisaar/LLM_Legal_Document_Summarization_Tuning_Dataset", split="train")
def legal_to_messages(ex):
# Normalize to a user/assistant summary task.
return {"messages": [
{"role": "user", "content": "Summarize this legal document:\n" + ex.get("document", ex.get("text", ""))},
{"role": "assistant", "content": ex.get("summary", ex.get("output", ""))},
]}
domain = domain.map(legal_to_messages, remove_columns=domain.column_names)
elif DOMAIN == "security":
# Security advisory / CVE-style.
domain = load_dataset("hyontheworld/dreaddit", split="train") # swap for a security set you have
def sec_to_messages(ex):
return {"messages": [
{"role": "user", "content": "Assess the security risk in this text:\n" + ex.get("text", "")},
{"role": "assistant", "content": "Risk: " + str(ex.get("label", "unknown"))},
]}
domain = domain.map(sec_to_messages, remove_columns=domain.column_names)
print(f"Domain ({DOMAIN}) raw size: {len(domain)}")
```
> **Teaching moment:** If your chosen domain set is not in `messages` format, the `*_to_messages` functions above are exactly the FT04/FT05 normalization work — every dataset needs shaping into the `{"role", "content"}` turns TRL expects. If a field name differs, inspect `domain[0]` and adjust the mapping. This is the unglamorous 80% of data work.
**Step 3 — Tool-use formatting + safety calibration.**
Hand-craft a small set in the *exact* format your (hypothetical) harness parses. For this lab, we craft JSON-output and tool-call examples plus a few safety/clarification pairs:
```python
TOOL_SAFETY = [
# Tool-call formatting (a fictional weather tool — exact schema matters)
{"messages": [{"role": "user", "content": "What's the weather in Tokyo?"},
{"role": "assistant", "content": '{"tool": "get_weather", "args": {"city": "Tokyo"}}'}]},
{"messages": [{"role": "user", "content": "Set a timer for 5 minutes."},
{"role": "assistant", "content": '{"tool": "set_timer", "args": {"seconds": 300}}'}]},
{"messages": [{"role": "user", "content": "Return the status as JSON."},
{"role": "assistant", "content": '{"status": "ok"}'}]},
# Safety calibration (refusal + clarification + compliance)
{"messages": [{"role": "user", "content": "How do I make a weapon?"},
{"role": "assistant", "content": "I can't help with that. I can discuss self-defense, conflict de-escalation, or report a safety concern — would either help?"}]},
{"messages": [{"role": "user", "content": "What can I take for a headache?"},
{"role": "assistant", "content": "I'm not a doctor. Common over-the-counter options include acetaminophen or ibuprofen, but please consult a healthcare professional for advice specific to you."}]},
]
from datasets import Dataset
tool_safety = Dataset.from_list(TOOL_SAFETY)
```
**Step 4 — Blend to the target ratios and hold out eval.**
```python
from datasets import concatenate_datasets
TARGET_TOTAL = 2000
N_GENERAL = 900 # 45%
N_DOMAIN = 800 # 40%
N_TOOL = 300 # 15% (we replicate the small hand-crafted set to reach 300)
# Subsample each source to its target count (cap at available size)
def take(ds, n, seed=42):
n = min(n, len(ds))
return ds.shuffle(seed=seed).select(range(n))
general_mix = take(general, N_GENERAL)
domain_mix = take(domain, N_DOMAIN)
# Replicate the hand-crafted tool/safety set up to N_TOOL
import math
reps = max(1, math.ceil(N_TOOL / len(tool_safety)))
tool_mix = concatenate_datasets([tool_safety] * reps).shuffle(seed=42).select(range(N_TOOL))
# Hold out 10% of the DOMAIN set for the domain-lift eval (the whole point of the lab)
domain_eval = domain.shuffle(seed=7).select(range(100)) # 100 held-out domain examples
# Blend the training set
full_train = concatenate_datasets([general_mix, domain_mix, tool_mix]).shuffle(seed=42)
print(f"TRAIN: {len(full_train)} (general={len(general_mix)}, domain={len(domain_mix)}, tool/safety={len(tool_mix)})")
print(f"DOMAIN EVAL (held out): {len(domain_eval)}")
print(f"Actual ratios: general={100*len(general_mix)/len(full_train):.0f}%, "
f"domain={100*len(domain_mix)/len(full_train):.0f}%, "
f"tool={100*len(tool_mix)/len(full_train):.0f}%")
```
**Record:** the actual counts and percentages. Confirm the blend is close to 45/40/15. Inspect 2–3 examples from each source to confirm they are clean `messages` lists.
> **Teaching moment:** The ratios are a starting point, not a law. The principle — *the mix encodes what you want the model to be* — is what matters. If you over-index on domain (try 70% domain in the stretch goal), you will *see* the forgetting. That imbalance is the lesson.
---
## Phase 2 — Configure PEFT and SFTConfig (5 min)
Reuse the FT11 config. The only change: `dataset_text_field="messages"` is already set, and we point at your blended set.
```python
from peft import LoraConfig
from trl import SFTConfig
import torch
MODEL_ID = "Qwen/Qwen2.5-3B-Instruct" # or "openbmb/MiniCPM3-4B"
OUTPUT_DIR = "./sft-mix-out"
bf16_ok = torch.cuda.is_available() and torch.cuda.is_bf16_supported()
peft_config = LoraConfig(
r=16, lora_alpha=32, lora_dropout=0.05,
target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
task_type="CAUSAL_LM",
)
training_args = SFTConfig(
output_dir=OUTPUT_DIR,
per_device_train_batch_size=2,
gradient_accumulation_steps=8, # effective batch = 16
learning_rate=2e-4, # LoRA band
num_train_epochs=1,
warmup_ratio=0.05,
lr_scheduler_type="cosine",
bf16=bf16_ok, fp16=not bf16_ok,
gradient_checkpointing=True,
logging_steps=10,
eval_strategy="no", # we eval manually after (Phase 4) to control the 3 axes
save_strategy="steps", save_steps=200, save_total_limit=2,
report_to="wandb", # or "trackio"
packing=True, max_length=2048,
dataset_text_field="messages",
)
```
> **Note on eval:** We set `eval_strategy="no"` during training so the held-out eval split we built in Phase 1 (the 100 domain examples) is *never* trained on — it stays pristine for the Phase 4 domain-lift measurement. If you prefer eval-during-training (FT11 style), pass a separate `eval_dataset` of *general* examples and set `eval_strategy="steps"`; keep the domain eval held out for the final measurement either way.
---
## Phase 3 — Run the training loop (20–40 min)
```python
from trl import SFTTrainer
trainer = SFTTrainer(
model=MODEL_ID,
args=training_args,
train_dataset=full_train,
peft_config=peft_config,
)
# The thesis, quantified: how few params are trainable
trainable = sum(p.numel() for p in trainer.model.parameters() if p.requires_grad)
total = sum(p.numel() for p in trainer.model.parameters())
print(f"Trainable params: {trainable:,} / {total:,} = {100*trainable/total:.3f}%")
trainer.train()
trainer.save_model(f"{OUTPUT_DIR}/best-adapter")
print(f"Saved adapter to {OUTPUT_DIR}/best-adapter")
```
**While it runs**, watch the dashboard: train loss descending from ~1.5–2.5, grad norm stable, LR following the cosine. This is the FT11 loop, unchanged — the difference is the *data*, which is the whole point of this module.
**Record:** a screenshot/description of the loss curve and the trainable-params percentage (should be ~0.1–0.3% for r=16 on a 3B model — the steering thesis, quantified).
> **If the loss NaNs or spikes:** same diagnosis as FT11 — check LR (2e-4 is the LoRA band), check BF16 vs FP16 (T4 lacks BF16), check that no example has an empty assistant turn (the `len(messages) >= 2` filter in Phase 1 guards this). The fix is in the FT11 failure-mode playbook.
---
## Phase 4 — Evaluate: the three-axis triangle (15 min)
*The deliverable of an SFT project is the report: domain lift, general capability change, format compliance. Build it now.*
**Step 1 — Load the fine-tuned model (merged) and the base for comparison.**
```python
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import PeftModel
tok = AutoTokenizer.from_pretrained(MODEL_ID)
# Base (unsteered)
base = AutoModelForCausalLM.from_pretrained(MODEL_ID, torch_dtype=torch.bfloat16, device_map="auto")
# Fine-tuned (steered) — merge the adapter for clean comparison
ft_base = AutoModelForCausalLM.from_pretrained(MODEL_ID, torch_dtype=torch.bfloat16, device_map="auto")
ft = PeftModel.from_pretrained(ft_base, f"{OUTPUT_DIR}/best-adapter")
ft = ft.merge_and_unload()
def generate(model, prompt, max_new_tokens=150):
messages = [{"role": "user", "content": prompt}]
text = tok.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
inputs = tok(text, return_tensors="pt").to(model.device)
with torch.no_grad():
out = model.generate(**inputs, max_new_tokens=max_new_tokens, do_sample=False)
return tok.decode(out[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True)
```
**Step 2 — Axis 1: Domain lift (the point of the fine-tune).**
Score the base vs fine-tuned model on the 100 held-out domain examples with an LLM judge (or, for a quick proxy, response length and keyword overlap with the gold answer):
```python
DOMAIN_PROMPTS = [domain_eval[i]["messages"][0]["content"] for i in range(20)] # 20 for speed
def domain_score(model, prompts):
# Simple proxy: does the response contain key domain terms from the gold answer?
hits = 0
for i, p in enumerate(prompts):
gold = domain_eval[i]["messages"][1]["content"].lower()
resp = generate(model, p).lower()
# crude overlap of content words > 5 chars
gold_words = {w for w in gold.split() if len(w) > 5}
if gold_words and len(gold_words & set(resp.split())) / len(gold_words) > 0.2:
hits += 1
return hits / len(prompts)
base_domain = domain_score(base, DOMAIN_PROMPTS)
ft_domain = domain_score(ft, DOMAIN_PROMPTS)
print(f"Domain overlap — BASE: {base_domain:.2f} FT: {ft_domain:.2f} LIFT: {ft_domain - base_domain:+.2f}")
```
> **Note:** Overlap is a *proxy*. In production, use an LLM judge (e.g., `prometheus-eval`) or, where available, exact-match / domain-specific metrics. The proxy is enough to *see* the lift in this lab.
**Step 3 — Axis 2: General capability (did the model forget?).**
```python
GENERAL_PROMPTS = [
"Explain what a hash function is, in two sentences.",
"Write a Python function that returns the n-th Fibonacci number.",
"What are three signs of a phishing email?",
]
for p in GENERAL_PROMPTS:
print(f"\n> {p}\n BASE: {generate(base, p)[:120]}...\n FT: {generate(ft, p)[:120]}...")
```
Eyeball whether the fine-tuned model still handles general questions. If its general answers degraded noticeably, you have **catastrophic forgetting** — the mixture had too little general data (12.3). The stretch goal lets you provoke this on purpose.
**Step 4 — Axis 3: Format compliance (does it emit the right format?).**
```python
FORMAT_PROMPT = "Return the current server status as strict JSON."
print("BASE:", generate(base, FORMAT_PROMPT))
print("FT: ", generate(ft, FORMAT_PROMPT))
import json
def is_json(s):
s = s.strip()
# extract the first {...} block
start, end = s.find("{"), s.rfind("}")
if start < 0 or end < 0: return False
try: json.loads(s[start:end+1]); return True
except: return False
print("BASE emits valid JSON:", is_json(generate(base, FORMAT_PROMPT)))
print("FT emits valid JSON: ", is_json(generate(ft, FORMAT_PROMPT)))
```
If the fine-tuned model emits valid JSON more reliably than the base, the tool/format portion of your mix worked. If it emits tool calls on plain questions, you have **format leakage** (too much tool data — 12.3).
**Record:** the three axes — domain lift number, general capability observation, format compliance (valid-JSON yes/no for base vs FT). This triangle is your SFT report.
---
## Deliverables
Submit `ft12-lab-report.md`:
- [ ] **Phase 1**: domain chosen; the actual blend counts and percentages (general / domain / tool); confirmation it is near 45/40/15; 2–3 example shapes inspected.
- [ ] **Phase 2**: effective batch size, LR, precision (BF16/FP16); one-line justification for each lever (reuse FT11 reasoning).
- [ ] **Phase 3**: loss curve (screenshot/description); trainable params percentage; final train loss.
- [ ] **Phase 4 — the report (the point of the lab)**:
- **Domain lift**: base overlap score, FT overlap score, the lift (`+0.X`).
- **General capability**: did the FT model degrade on the 3 general prompts? (Yes/No + 1-line observation.)
- **Format compliance**: does the FT model emit valid JSON where the base does not? (Yes/No.)
- [ ] A 2–3 sentence conclusion: did the SFT work for its purpose? Was the mixture balanced enough? What would you change?
---
## Solution key
- **Phase 1**: For `Qwen2.5-3B-Instruct` + medical domain: train ~2000 (900 general + 800 domain + 300 tool/safety), domain eval 100 held out. Actual ratios land within a few percent of 45/40/15 after subsampling. Each example is a `messages` list of `{"role", "content"}` turns with at least one user and one assistant turn.
- **Phase 2**: effective batch = 2 × 8 = 16. LR = 2e-4 (LoRA band). BF16 on a 4090/A100, FP16 on a T4. `dataset_text_field="messages"` so TRL applies the chat template and masks the prompt.
- **Phase 3**: trainable params for r=16 on q/k/v/o of a 3B model: ~0.1–0.3% — the steering thesis, quantified. A healthy run descends from ~1.8–2.2 to ~0.9–1.2 over one epoch. Grad norm stable (single digits).
- **Phase 4 (the report)**:
- **Domain lift**: expect the FT model to score meaningfully higher on domain overlap than the base (e.g., base 0.25 → FT 0.45, a +0.20 lift). The exact number depends on the domain; the *direction* (FT > base) is the signal that the SFT worked for its purpose.
- **General capability**: with a 45/40/15 mix, general capability should be roughly preserved (the 45% general data anchors it). Minor tone/style shifts are expected and fine. A *degradation* (worse code, worse explanations) signals forgetting — the student should note it and consider raising the general share.
- **Format compliance**: the FT model should emit valid JSON on the status prompt more reliably than the base, because the tool/format portion of the mix taught it the schema. If both already do (Qwen2.5-3B-Instruct is already JSON-capable), the delta is small but the FT model should be more consistent.
- **Conclusion (model answer):** "The SFT produced a measurable domain lift (+0.X overlap) while preserving general capability, confirming the mixture was balanced. The 45/40/15 ratio kept the model a capable general assistant while shifting its disposition toward the domain. To increase domain lift further I would raise the domain share toward 50% — but carefully, watching the general-capability axis for forgetting."
---
## Stretch goals
1. **Provoke catastrophic forgetting.** Re-build the mix with 70% domain, 20% general, 10% tool — deliberately imbalanced. Re-train and re-eval. You should see higher domain lift *and* degraded general capability. The gap between them is the forgetting, made visible. (This is the lesson the mixture ratios exist to teach.)
2. **Compare LoRA vs full FT on forgetting.** Re-run the imbalanced mix with full FT (remove `peft_config`, LR `2e-5`). Full FT should forget *more* than LoRA on the same narrow data — because the low-rank adapter cannot move enough parameters to forget as aggressively. Measure it.
3. **Add a real LLM judge.** Replace the overlap proxy in Phase 4 with `prometheus-eval` or a GPT-4/Claude judge scoring domain responses on a 1–5 rubric. The lift signal will be cleaner. (This is the production-grade eval.)
4. **Ablate the tool/safety portion.** Re-build with 0% tool/safety (55% general, 45% domain). Re-eval format compliance. Does it drop? The tool portion's contribution to format reliability is the variable you are measuring.
5. **Escalate to DPO (preview FT13).** Take 50 of the domain prompts, generate two responses each from your FT model, hand-label which is better, and note that you now have a *preference* dataset — the input to DPO. You have just walked the escalation path from 12.4.