AI EngineeringZero to ProductionHome·About·Contact
Fine-tuning · Project FT

Fine-tune a task adapter

The capstone: prepare data, QLoRA-tune (and optionally DPO-align) an open model for a narrow task, prove it beats the base with evals, and serve the merged model.

⏱️ ~6 hours🏗️ Capstone project🎯 Expert
⚙️ To run this for realThe code here is complete and correct as written. To actually execute it you'll need:
  • a GPU + pip install vllm (serving engine)
Reading and learning works without any of this — run when you're ready. IDs/ARNs in examples are placeholders; swap in your own.
What you'll buildAn end-to-end fine-tune: prepare a dataset (FT2), QLoRA-tune an open model for a narrow task (FT3), optionally align it with DPO (FT5), prove it beats the base (FT6), and serve the merged model (FT4 + Inference track). A real, evaluated adapter — not a toy run.

Learning objectives

  • Prepare a clean, leakage-free dataset for a narrow task.
  • QLoRA-tune a model and (optionally) align with DPO.
  • Prove improvement with an eval-gated comparison to the base.
  • Serve the tuned model and point course code at it.
▶ Runnable companionThe code in this lesson is also saved under code/proj-ft-adapter/ in the course, with a README. Run the scripts or copy the configs directly.

The method advanced

Fine-tune a task adapter, end to end

  1. Pick a narrow task that hits one of FT1's four cases (style/format/latency/niche).
  2. FT2: build 300–1000 clean examples, chat-formatted, deduped, split 90/10.
  3. FT3: QLoRA-tune for 1–3 epochs; save the adapter.
  4. FT5 (optional): if it's a preference quality, add a DPO pass on ~100 pairs.
  5. FT6: eval tuned vs base on the task metric AND a general set — promote only if it wins clean.
  6. FT4 + IC6: merge the adapter and serve on vLLM; repoint an earlier lab's base_url.
Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
The whole pipeline, wired
pipeline.sh# 1. prep.py    -> train.jsonl, val.jsonl        (FT2)
# 2. qlora_train.py -> out/adapter                (FT3)
# 3. dpo_train.py   -> dpo-out/  (optional)       (FT5)
# 4. eval_serve.py  -> target up? general intact? (FT6)
# 5. merge.py       -> out/merged-model           (FT4)
# 6. serve:
python -m vllm.entrypoints.openai.api_server --model out/merged-model
# 7. point any earlier lab at http://localhost:8000/v1 and confirm the
#    tuned behavior shows up in a real workflow.
▶ How this works

This shell script is the whole fine-tuning track on one page — the exact order to run the scripts from FT2 through FT6, ending with a served model. The numbered # lines are the recipe; the last real command actually starts the server.

  1. Steps 1–5 (all comments) chain the earlier labs: prep.py makes the data (FT2), qlora_train.py trains the adapter (FT3), dpo_train.py optionally aligns it (FT5), eval_serve.py checks it improved (FT6), and merge.py bakes the adapter into a standalone model (FT4). The arrows show what each step produces.
  2. python -m vllm.entrypoints.openai.api_server --model out/merged-model is the one line that actually runs: it starts a local server hosting your merged model, speaking the OpenAI API format so existing code can talk to it.
  3. Step 7 (comment) is the payoff: point an earlier lab's base_url at your local server and confirm the tuned behavior shows up in a real workflow.

What the output means: Running the vllm line starts a server, typically at http://localhost:8000/v1. Any code that can call the OpenAI API can now use your fine-tuned model by pointing at that address.

Try this: Once the server is up, take a lab from earlier in the course and change its base_url to http://localhost:8000/v1. Seeing your tuned behavior appear inside a real workflow is the proof the whole pipeline worked.

Ship the evidence, not just the modelThe deliverable isn't the adapter — it's the adapter plus the before/after eval table that proves it helped. That table is what makes a fine-tune trustworthy enough to deploy.

🪜 Practice ladder beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · Prepare and split a clean chat datasetBeginner

Context: Fine-tuning is data first. Dedup, role validation, and a deterministic split are what make a run reproducible and stop train/val leakage from inflating your eval.

Your task: Write prep(examples) that dedupes, validates roles, and does a deterministic 90/10 split into train.jsonl/val.jsonl.

Requirements:

  • Validate each example has system, user, and assistant roles
  • Drop exact duplicates
  • Order deterministically before splitting (reproducible runs)
  • Split 90/10 into train and val
  • Demonstrate a dupe being dropped and the split sizes

💡 Hint: Hash each example for both dedup and a stable sort order so the same input always yields the same split.

Show solution

Runnable stdlib data prep — the highest-leverage, least-glamorous step:

import json, hashlib

def valid(ex):
    roles = [m["role"] for m in ex["messages"]]
    return roles[:1] == ["system"] and "user" in roles and "assistant" in roles

def prep(examples, train_path="train.jsonl", val_path="val.jsonl"):
    seen, clean = set(), []
    for ex in examples:
        if not valid(ex):
            continue
        key = hashlib.md5(json.dumps(ex, sort_keys=True).encode()).hexdigest()
        if key in seen:
            continue
        seen.add(key); clean.append(ex)
    clean.sort(key=lambda e: hashlib.md5(json.dumps(e, sort_keys=True)
               .encode()).hexdigest())          # deterministic order
    cut = int(len(clean) * 0.9)
    return clean[:cut], clean[cut:]

ex = lambda a: {"messages":[{"role":"system","content":"s"},
    {"role":"user","content":"u"},{"role":"assistant","content":a}]}
tr, va = prep([ex("x"), ex("x"), ex("y"), ex("z")])
print(len(tr), len(va))     # 2 1  (dupe dropped, 90/10)

Dedup + role validation + a deterministic split is what makes a run reproducible and stops train/val leakage from inflating your eval.

Exercise 2 · Configure and launch a QLoRA runIntermediate

Context: The adapter config is small, and validating it before you burn GPU hours is free. A 4-bit base plus a low-rank adapter is what lets a 7B model tune on one consumer GPU.

Your task: Write the training config and launch (labelled needs-GPU), keeping config validation offline-testable.

Requirements:

  • A config with rank, alpha, learning rate, and epochs
  • A validator with sane ranges (e.g. alpha ≥ rank, 1–3 epochs, QLoRA-range lr)
  • The validator runs offline and catches bad configs
  • The training launch (peft/transformers, 4-bit base) is labelled needs-GPU
  • The adapter is saved to an output dir

💡 Hint: The offline validator catches the config mistakes (alpha below rank, too many epochs) that quietly wreck a run before any GPU time is spent.

Show solution

The adapter config is small; validating it before you burn GPU hours is free. GPU launch is labeled:

from dataclasses import dataclass

@dataclass
class LoraConfig:
    r: int = 16
    alpha: int = 32
    lr: float = 2e-4
    epochs: int = 2
    def validate(self):
        assert 4 <= self.r <= 128, "rank out of range"
        assert self.alpha >= self.r, "alpha should be >= rank"
        assert 1 <= self.epochs <= 3, "keep epochs small to avoid overfit"
        assert 1e-5 <= self.lr <= 5e-4, "lr out of typical QLoRA range"
        return self

LoraConfig().validate()      # offline: passes
print("config OK")

# --- needs GPU + libs: peft + transformers QLoRA training ---
# from peft import LoraConfig as PeftLora, get_peft_model
# from transformers import AutoModelForCausalLM, TrainingArguments, Trainer
# model = AutoModelForCausalLM.from_pretrained(BASE, load_in_4bit=True)
# model = get_peft_model(model, PeftLora(r=16, lora_alpha=32,
#     target_modules=["q_proj","v_proj"], task_type="CAUSAL_LM"))
# Trainer(model, args=TrainingArguments(output_dir="out/adapter",
#     num_train_epochs=2, learning_rate=2e-4), ...).train()
# model.save_pretrained("out/adapter")

The 4-bit base + low-rank adapter is what lets a 7B model tune on one consumer GPU. The offline validator catches the config mistakes (alpha < rank, too many epochs) that quietly wreck a run.

Exercise 3 · Eval gate: promote only if it wins the task AND holds generalAdvanced

Context: A tuned model that wins the task but regresses on general prompts is a net loss. The two-sided gate is the single most important decision in the pipeline — it makes the tradeoff explicit and measurable.

Your task: Build promote(tuned, base) that requires a task-metric win AND a general-set score no worse than base, and prove it blocks a regression.

Requirements:

  • Promotion requires the tuned model to beat base on the task metric
  • It also requires general-set score no worse than base (within a tolerance)
  • Return the decision and a reason
  • A model that wins the task but drops general is blocked
  • Demonstrate both a promote and a blocked regression

💡 Hint: Require both conditions to hold; the general-hold check is what stops you shipping a model that's great at one thing and worse at everything else.

Show solution

Two-sided gate — the single most important decision in the pipeline:

def promote(tuned, base, general_tol=0.0):
    task_win = tuned["task"] > base["task"]
    general_ok = tuned["general"] >= base["general"] - general_tol
    decision = task_win and general_ok
    return {"promote": decision,
            "reason": ("wins task, holds general" if decision else
                       "regressed" if not general_ok else "no task gain")}

base  = {"task": 0.62, "general": 0.80}
good  = {"task": 0.81, "general": 0.80}   # wins task, holds general
bad   = {"task": 0.85, "general": 0.71}   # wins task but forgot general

print(promote(good, base))   # promote True
print(promote(bad,  base))   # promote False -> regressed

Fine-tuning trades general capability for task skill; without the general-hold check you ship a model that's great at one thing and worse at everything else. The gate makes that tradeoff explicit and measurable.

Exercise 4 · Optional DPO alignment on preference pairsExpert

Context: When the task needs a specific style or refusal behavior, DPO on preference pairs directly optimizes 'prefer this answer over that one' — with far fewer, higher-quality pairs than SFT.

Your task: Model the DPO preference objective offline so the mechanics are clear, and label the real training as needs-GPU.

Requirements:

  • A DPO loss over (prompt, chosen, rejected) using policy and reference log-probs
  • A good pair (chosen more likely than reference) yields low loss
  • A bad pair (model prefers the rejected) yields high loss
  • The objective raises the log-prob margin of chosen over rejected
  • The real trl training is labelled needs-GPU; the objective runs offline

💡 Hint: The loss is -log σ(β·margin) where margin is the policy-minus-reference gap for chosen vs rejected.

Show solution

The DPO objective in miniature — it raises the log-prob margin of chosen over rejected:

import math

def sigmoid(x): return 1 / (1 + math.exp(-x))

def dpo_loss(logp_chosen, logp_rejected, ref_chosen, ref_rejected, beta=0.1):
    # margin of (policy - reference) for chosen vs rejected
    margin = beta * ((logp_chosen - ref_chosen) - (logp_rejected - ref_rejected))
    return -math.log(sigmoid(margin))

# a good pair (chosen more likely than ref, rejected less) -> low loss
print(round(dpo_loss(-1.0, -3.0, -1.5, -2.0), 3))   # ~0.28
# a bad pair (model prefers the rejected answer) -> high loss
print(round(dpo_loss(-3.0, -1.0, -2.0, -1.5), 3))   # ~1.28

# --- needs GPU + libs: real DPO with trl ---
# from trl import DPOTrainer, DPOConfig
# DPOTrainer(model, ref_model, args=DPOConfig(output_dir="dpo-out", beta=0.1),
#     train_dataset=pairs).train()   # pairs: prompt/chosen/rejected

DPO needs far fewer, higher-quality pairs than SFT and directly optimizes "prefer this answer over that one" — ideal for tone, format, or refusal behavior that plain SFT data can't easily express.

Exercise 5 · Merge the adapter and serve behind an OpenAI-compatible APIProfessional

Context: Ship a standalone artifact, not a base+adapter pair. Merging removes the adapter runtime dependency, and vLLM's OpenAI-compatible server means every earlier lab points at it with zero client changes.

Your task: Merge the adapter into a standalone model and serve it behind vLLM's OpenAI-compatible API, then confirm a client call returns tuned behavior.

Requirements:

  • Merge the adapter into the base to a standalone model dir (labelled needs-GPU)
  • Serve it with vLLM's OpenAI-compatible server (labelled)
  • A provider-agnostic client call runs once the server is up
  • Repointing an earlier lab's base_url is the whole integration
  • Confirm the call returns tuned behavior

💡 Hint: Because the server speaks the OpenAI protocol, only the endpoint moves — no client code changes to prove the tuned model in situ.

Show solution

Merging removes the adapter runtime dependency; vLLM exposes the standard /v1 API so every earlier lab points at it unchanged:

# --- needs GPU + libs: merge adapter into a standalone model ---
# from peft import PeftModel
# from transformers import AutoModelForCausalLM
# base = AutoModelForCausalLM.from_pretrained(BASE)
# merged = PeftModel.from_pretrained(base, "out/adapter").merge_and_unload()
# merged.save_pretrained("out/merged-model")

# --- needs GPU: serve the merged model, OpenAI-compatible ---
# python -m vllm.entrypoints.openai.api_server \
#     --model out/merged-model --max-num-seqs 128 --port 8000

# client call is provider-agnostic (OpenAI-compatible) -- runnable once served:
from openai import OpenAI            # needs: pip install openai + running server
client = OpenAI(base_url="http://localhost:8000/v1", api_key="not-needed")
resp = client.chat.completions.create(model="out/merged-model",
    messages=[{"role": "user", "content": "classify: refund my order"}])
print(resp.choices[0].message.content)

Because the server speaks the OpenAI protocol, repointing an earlier lab's base_url to localhost:8000/v1 is the whole integration — no client changes to prove the tuned model in situ.

Exercise 6 · Ship a before/after eval report and a rollback planIndustry scenario

Context: A stakeholder must sign off before a tuned model replaces the base in production. The deliverable is a defensible comparison plus a safe path to roll it out and back — the fine-tune isn't 'done' until this loop exists.

Your task: Produce a before/after table (task + general + latency) and a canary+rollback plan keyed on a live quality metric.

Requirements:

  • A table comparing base vs tuned on task, general, and p95 latency with deltas
  • A canary that watches a live quality metric
  • It rolls back automatically when live quality drops below a ratio of base
  • The table proves the win offline; the canary guards production
  • Demonstrate the table and a rollback trigger

💡 Hint: Keep the tuned model only while it holds, say, ≥98% of base's live quality; below that, auto-rollback.

Show solution

The deliverable that gets a fine-tune into production is a defensible comparison plus a safe path to roll it out and back:

def report(base, tuned):
    rows = [("task acc", base["task"], tuned["task"]),
            ("general", base["general"], tuned["general"]),
            ("p95 ms", base["p95"], tuned["p95"])]
    print(f"{'metric':10} {'base':>8} {'tuned':>8} {'delta':>8}")
    for name, b, t in rows:
        print(f"{name:10} {b:>8.2f} {t:>8.2f} {t-b:>+8.2f}")

def canary(live_quality, base_quality, min_ratio=0.98):
    # keep tuned only if it holds >=98% of base's live quality
    if live_quality < base_quality * min_ratio:
        return "ROLLBACK"
    return "KEEP"

report({"task":0.62,"general":0.80,"p95":90},
       {"task":0.81,"general":0.80,"p95":95})
print(canary(live_quality=0.60, base_quality=0.80))   # ROLLBACK

The table proves the win offline; the canary watches the live metric and rolls back automatically if the tuned model underperforms in production — the fine-tune isn't "done" until this loop exists.

✓ Checkpoint — you can move on when you can…

  • Prepare a clean dataset for a narrow task.
  • QLoRA-tune and optionally DPO-align a model.
  • Prove improvement with an eval-gated comparison.
  • Merge, serve, and use the tuned model in a real workflow.
📋 Grade your fine-tune — is this adapter trustworthy to deploy?
DimensionMeets the barAbove the bar (staff)
Dataset quality300–1000 clean, chat-formatted examples that fit a narrow task; deduped, with a held-out 90/10 split.Leakage between train and val is checked and ruled out; examples are audited for label noise and coverage of the task distribution.
Training done rightQLoRA-tuned for 1–3 epochs with the adapter saved; loss curves are sane (no obvious overfit/underfit).Hyperparameters (rank, LR, epochs) were chosen deliberately and a small sweep justifies them; runs are reproducible from config.
Eval win over baselineTuned vs base is compared on the task metric and the tuned model wins by a margin you report.The win is measured on a held-out set with enough examples to be meaningful; the eval is task-appropriate, not just loss.
No capability regressionThe tuned model is also checked on a general set and hasn't obviously degraded on off-task ability.Regression is quantified (general-set score before/after) and promotion is gated on 'wins on task AND holds on general'.
Alignment (if used)If a preference quality mattered, a DPO pass on ~100 pairs was added and its effect measured separately.The DPO effect is isolated from the SFT effect, and you can state what it bought and whether it was worth the added complexity.
Serving & integrationThe adapter is merged and served on vLLM behind an OpenAI-compatible API; an earlier lab runs against it unchanged.The deliverable ships as adapter + before/after eval table, and the tuned behavior is demonstrated inside a real workflow end-to-end.

Score each dimension 0 (missing), 1 (meets bar), or 2 (above bar). 0–4: keep building. 5–8: a solid, defensible submission. 9–12: staff-level — you could hand this to a reviewer and defend every call. Any dimension at 0 blocks shipping regardless of the total.

Knowledge check check yourself

✓ Knowledge check

Why is the deliverable of a fine-tune the adapter plus a before/after eval table, not just the adapter?

Show answer
The eval table is what proves the tune actually helped — it shows the tuned model wins on the task metric and hasn't regressed on a general set. Without that evidence a fine-tune isn't trustworthy enough to deploy.
✓ Knowledge check

Before promoting a tuned model, why check it on a general set as well as the task metric?

Show answer
To catch capability regression: a model can win on the narrow task while quietly degrading on off-task ability. Promotion is gated on 'wins on task AND holds on general', so you don't trade broad competence for a narrow gain.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in