End-to-End Guide · Hugging Face + TRL

Fine-tuning LLMs
from base to aligned

Three progressive stages teach a model domain knowledge, conversational behavior, and human-preferred quality — each adding a lightweight LoRA adapter then merging it into the weights.

01 · Non-Instruction 02 · Instruction / SFT 03 · Preference / DPO QLoRA · LoRA · merge_and_unload
The three-stage pipeline

From base model to aligned expert

Each stage trains a LoRA adapter then merges it into the weights — the merged result is the foundation for the next stage. Watch the signal travel M0 → M3.

M0
Base Model
TinyLlama-1.1B
pretrained
LoRA₁
non-instruct
M1
Domain Expert
pharma vocab
+ domain text
LoRA₂
instruction
M2
Instruct Expert
Q&A format
+ SFT
LoRA₃
preference
M3
Aligned Expert
human-preferred
+ DPO
Why you must merge before the next stage

You can't attach LoRA₃ to M0 and get M3. Each merge_and_unload() bakes the adapter's learned knowledge into the weights permanently. The merged model is the correct foundation for the next adapter. Analogy from class: M0 = Class 10 student — you can't attach "Class 12 behavior" on top of Class 10; you must graduate through Class 11 first.

01
Stage 1

Non-Instruction Fine-Tuning

Continued pretraining on raw domain text — no Q&A structure, no instructions. The model learns specialized vocabulary, entity co-occurrences, and statistical patterns from YOUR corpus. Training objective: next-token prediction, identical to original pretraining.

Input data

Raw text: PDF → extract → clean → chunk into 512-token blocks. No labels, no answers — just domain text.

Objective

Minimize cross-entropy on P(token_n | context). The model learns which pharma terms co-occur, not how to answer questions.

What it learns

Domain terminology, drug-mechanism collocations, scientific sentence structure. It does NOT learn to "answer" — that's Stage 2.

Can you skip this?

Yes. If your domain isn't highly specialized, start at Stage 2 on the base model directly. Stage 1 matters most for rare, technical corpora.

Data format — raw 512-token text chunks

pharma_corpus.txt — one 512-token block
Metformin belongs to the biguanide class of oral antidiabetic agents. It activates AMP-activated protein kinase (AMPK), which suppresses hepatic gluconeogenesis and increases peripheral glucose uptake in skeletal muscle. Unlike sulfonylureas, metformin does not stimulate insulin secretion and carries a low risk of hypoglycemia. Long-term use is associated with reduced cardiovascular events in patients with type 2 diabetes...
Stage 1 — Full code walkthrough QLoRA + Trainer
▸ expand
# ── Step 1: Quantize base model to 4-bit NF4 ────────────────
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
import torch

bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",              # NormalFloat4 — optimal for NN weights
    bnb_4bit_compute_dtype=torch.float16    # adapter forward pass in fp16
)
model = AutoModelForCausalLM.from_pretrained(
    "TinyLlama/TinyLlama-1.1B-intermediate-step-1431k-3T",
    quantization_config=bnb_config, device_map="auto"
)
model = prepare_model_for_kbit_training(model)  # cast LayerNorms to fp32

# ── Step 2: Attach LoRA₁ adapter ────────────────────────────
# Only trains B and A matrices (r×d and d×r) — everything else frozen
lora_config = LoraConfig(
    r=8,               # rank — adapter capacity (higher = more params)
    lora_alpha=16,    # scaling = alpha/r = 2.0 (lr multiplier effect)
    target_modules=["q_proj", "v_proj"],  # query + value attention layers
    lora_dropout=0.05,
    task_type="CAUSAL_LM"
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# trainable: 2,097,152 || all: 1,100,048,384 || trainable%: 0.1907

# ── Step 3: Tokenize raw text and train ─────────────────────
from transformers import Trainer, TrainingArguments, DataCollatorForLanguageModeling

tokenizer = AutoTokenizer.from_pretrained("TinyLlama/TinyLlama-1.1B-...")
tokenizer.pad_token = tokenizer.eos_token

trainer = Trainer(
    model=model,
    train_dataset=tokenized_dataset,          # raw text, 512-token chunks
    data_collator=DataCollatorForLanguageModeling(tokenizer, mlm=False),
    args=TrainingArguments(
        num_train_epochs=2,
        per_device_train_batch_size=4,
        learning_rate=2e-4, fp16=True,
        output_dir="./stage1_output"
    )
)
trainer.train()

# ── Step 4: Merge LoRA₁ into base → save as M1 ──────────────
# W_new = W_base + B·A  (the rank-decomposed update is baked in)
model = model.merge_and_unload()
model.save_pretrained("pharma_tinyllama_noninstruct_merged")   # ← M1
tokenizer.save_pretrained("pharma_tinyllama_noninstruct_merged")
02
Stage 2

Instruction Fine-Tuning (SFT)

Supervised Fine-Tuning on structured instruction–response pairs. Stage 1 taught the model what words belong in pharma text. Stage 2 teaches it how to answer — to follow the Alpaca prompt format and respond usefully to domain questions.

Input data

Structured JSONL with instruction, input (optional context), and output fields — the Alpaca format.

Objective

Cross-entropy on response tokens only — the instruction is masked. The model learns to produce the answer, not to "guess the question".

Base model

M1 — the domain-adapted merged model. LoRA₂ on M1 means the adapter builds on pharma vocabulary already baked in from Stage 1.

Scale guidance

1,000–10,000 Q&A pairs is practical for a POC. In regulated domains (pharma, healthcare), quality beats quantity — SME review is non-negotiable.

Data format — Alpaca JSONL

pharma_instruction_dataset.jsonl
// Each row has three fields — SFTTrainer formats them automatically
{
  "instruction": "Explain the primary mechanism of action of metformin.",
  "input":       "",   // optional extra context (empty = no additional context)
  "output":      "Metformin activates AMP-activated protein kinase (AMPK), reducing hepatic
               glucose production and improving peripheral insulin sensitivity..."
}

// Prompt template that SFTTrainer constructs from the row:
"""### Instruction:
Explain the primary mechanism of action of metformin.

### Input:
[empty]

### Response:
Metformin activates AMP-activated protein kinase (AMPK)..."""
Stage 2 — Full code walkthrough TRL SFTTrainer
▸ expand
from trl import SFTTrainer, SFTConfig
from datasets import load_dataset

# ── Load M1 (Stage 1 merged model) as the base for Stage 2 ──
model = AutoModelForCausalLM.from_pretrained(
    "pharma_tinyllama_noninstruct_merged",   # ← M1
    quantization_config=bnb_config, device_map="auto"
)
model = prepare_model_for_kbit_training(model)
model = get_peft_model(model, lora_config)    # ← LoRA₂, fresh adapter on M1

# ── Alpaca prompt formatter ───────────────────────────────────
def format_alpaca(example):
    return (
        f"### Instruction:\n{example['instruction']}\n\n"
        f"### Input:\n{example['input']}\n\n"
        f"### Response:\n{example['output']}"
    )

# ── Train — SFTTrainer masks instruction tokens automatically ─
instruction_ds = load_dataset("json",
    data_files="pharma_instruction_dataset.jsonl")

trainer = SFTTrainer(
    model=model,
    train_dataset=instruction_ds["train"],
    formatting_func=format_alpaca,
    args=SFTConfig(
        num_train_epochs=3,
        per_device_train_batch_size=4,
        learning_rate=2e-4,
        max_seq_length=512,
        fp16=True
    )
)
trainer.train()

# ── Merge LoRA₂ into M1 → save as M2 ────────────────────────
model = model.merge_and_unload()
model.save_pretrained("pharma_tinyllama_instruct_merged")   # ← M2
tokenizer.save_pretrained("pharma_tinyllama_instruct_merged")
03
Stage 3

Preference Tuning — DPO

Stage 2 teaches the model how to answer. Stage 3 teaches it which answer is better. Given a prompt and two responses — one preferred by domain experts, one not — DPO nudges the model to favor the chosen response.

Classroom analogy
✗ Rejected

"Study for 48 hours continuously without sleep before your exam."

Plausible-sounding but unrealistic and harmful.

✓ Chosen

"Focus on high-weight topics, study in 90-minute blocks with breaks, review past papers actively."

Evidence-based, safe, and actionable.

Interactive — Pharma preference dataset

pharma_preference_dataset.jsonl — example {{ dpoIdx }} of {{ dpoTotal }}
Prompt
{{ dpoPrompt }}
✓ Chosen — what the model should prefer

{{ dpoChosen }}

✗ Rejected — what to avoid

{{ dpoRejected }}

DPO vs RLHF — preference alignment methods

RLHF + PPO
OpenAI · InstructGPT · ChatGPT
Train a separate reward model on human rankings
PPO policy optimization against reward signal
Actor + critic + reference + reward = 4 models in VRAM
Complex, unstable training, very GPU-intensive
DPO — Direct Preference Optimization
Rafailov et al. 2023 · No reward model
Trains directly on chosen/rejected pairs
Single training loop — no RL, no PPO, no reward model
Policy + reference = 2 models (reference can be implicit)
Simpler, stable, practical — use TRL's DPOTrainer
DPO loss — math intuition

DPO optimizes a policy π to prefer the chosen response over the rejected one, relative to a reference model πref (the model before alignment). The β parameter controls how far the policy can drift.

DPO = −𝔼 log σ(β · log[π(yw|x) / πref(yw|x)] − β · log[π(yl|x) / πref(yl|x)])
y_w — chosen (winning) response
y_l — rejected (losing) response
β = 0.1 — KL penalty (lower = more aggressive alignment)

In plain English: maximize the model's preference for y_w over y_l, but don't let it drift too far from the reference model (β controls this tension).

Stage 3 — Full code walkthrough TRL DPOTrainer
▸ expand
from trl import DPOTrainer, DPOConfig

# ── Load M2 (Stage 2 merged model) as the base for Stage 3 ──
model = AutoModelForCausalLM.from_pretrained(
    "pharma_tinyllama_instruct_merged",   # ← M2
    quantization_config=bnb_config, device_map="auto"
)
model = prepare_model_for_kbit_training(model)
model = get_peft_model(model, lora_config)   # ← LoRA₃, fresh adapter on M2

# ── Load preference dataset (prompt / chosen / rejected) ─────
pref_ds = load_dataset("json",
    data_files="pharma_preference_dataset.jsonl").train_test_split(0.1)
# 48 rows in demo — DPOTrainer expects exactly these three column names

# ── DPO training — no reward model needed ───────────────────
trainer = DPOTrainer(
    model=model,
    ref_model=None,             # None = implicit reference from frozen base
    train_dataset=pref_ds["train"],
    eval_dataset=pref_ds["test"],
    args=DPOConfig(
        beta=0.1,               # KL penalty — lower = more alignment aggression
        num_train_epochs=3,
        per_device_train_batch_size=4,
        learning_rate=1e-4,     # lower LR than SFT for fine-grained tuning
        max_prompt_length=256,
        max_length=512,
        fp16=True
    )
)
trainer.train()

# ── Merge LoRA₃ into M2 → M3 (final aligned model) ──────────
model = model.merge_and_unload()
model.save_pretrained("pharma_tinyllama_preference_dpo_merged")  # ← M3
model.push_to_hub("your-hf-username/pharma-tinyllama-dpo")    # optional
Under the hood

LoRA & QLoRA — the math

Full fine-tuning updates every weight in the model — billions of parameters, expensive and prone to catastrophic forgetting. LoRA freezes the base weights and instead trains small rank-decomposed matrices that approximate the update.

Weight decomposition: W = W₀ + ΔW = W₀ + B·A
W
d × d
frozen
=
W₀
original
frozen
+
B
d × r
trained
×
A
r × d
trained
Full fine-tuning
e.g. d=4096 → 16.8M params per attention matrix
LoRA with r=8
2·d·r
d=4096, r=8 → 65K params — 256× smaller
QLoRA — Quantized LoRA (Dettmers et al. 2023)
FP32
original weights
32 bits per param
NF4
base quantized
4 bits per param
FP16
LoRA adapters
trained in fp16

Result: a 1.1B model (normally ~4GB) trains in ~2GB VRAM. A 7B model fits on a free T4 Colab GPU. NF4 quantization uses 4-bit NormalFloat — information-theoretically optimal for normally-distributed neural network weights, so accuracy loss is minimal.

Key hyperparameters

Param Typical value What it controls
r (rank) 8 – 64 Adapter capacity. Higher r = more parameters but slower training. Start at 8; increase if the model plateaus.
lora_alpha 2 × r Scaling = alpha / r. alpha = 2r → scale = 2.0. Controls how much the adapter contributes relative to the frozen base.
target_modules q_proj, v_proj Which weight matrices to adapt. Query + Value is standard. Adding k_proj and o_proj increases capacity at more cost.
beta (DPO) 0.1 KL divergence strength. Low β = aggressively align to preferences. High β = stay closer to reference model behavior.
Check your understanding

Quick revision quiz

Answer all five questions correctly for a confetti celebration. One attempt per question.

Question {{ item.num }} of {{ quizTotal }}

{{ item.q }}

Perfect score — M0 → M3 unlocked

You nailed the pipeline: merge between stages, SFT on Alpaca pairs, DPO without a reward model, and LoRA adapters on frozen weights.

Not quite — review the highlights above

Review the highlighted correct answers, then refresh the page to try again.

Next in the series
LoRA / QLoRA theory deep-dive · Unsloth · RLHF · GRPO · ORPO · Fine-tuning mega assignment
TRL DPO docs ↗ DPO paper ↗ LoRA paper ↗