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:

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.


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:

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 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:

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.


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.

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)

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.

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.


Deliverables

Submit ft12-lab-report.md:


Solution key


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.
# 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.