Part 2 · Large language models · module 4 of 4 · 14 min
Instruction tuning and RLHF
How a text predictor becomes an assistant — chat templates, instruction datasets, and reinforcement learning from human feedback.
The essence
A base model completes text; it has no notion of being asked. Instruction tuning is fine-tuning on (instruction, response) pairs formatted with role markers, which teaches the model that text after 'assistant' should answer the text after 'user'. RLHF then optimises for human preference rather than for imitation, which is what produces a model that is helpful and declines rather than merely plausible.
Why it matters
This is the step that separates GPT-2 from something you can talk to, and it involves almost no new capability — it redirects what pretraining already built. It is also where alignment is implemented in practice, which makes it the concrete link between the modelling parts of this curriculum and the safety parts.
Topics covered (6)
- 01What instruction tuning is
- 02Datasets used for instruction tuning
- 03Training a chatbot with system, user and assistant roles
- 04Instruction tuning GPT-2
- 05Instruction tuning a larger GPT-2
- 06Reinforcement learning from human feedback (RLHF)
Why a base model is not an assistant
Ask a base model 'What is the capital of France?' and a very reasonable continuation is another question, because in its training data questions cluster with questions. It is not being unhelpful; it is doing exactly what it was trained to do, which is continue text.
Instruction tuning changes the distribution it continues into. Train on thousands of examples where a marked instruction is followed by a marked helpful response, and the model learns that after the assistant marker, the appropriate continuation is an answer.
# The template is just text with role markers. Any consistent scheme works,
# but it must be identical at training and inference or behaviour collapses.
TEMPLATE = (
"<|system|>\n{system}<|end|>\n"
"<|user|>\n{user}<|end|>\n"
"<|assistant|>\n{assistant}<|end|>"
)
def build_example(tok, system, user, assistant, max_len=512):
prompt = TEMPLATE.format(system=system, user=user, assistant="")
full = TEMPLATE.format(system=system, user=user, assistant=assistant)
prompt_ids = tok(prompt)["input_ids"]
full_ids = tok(full, truncation=True, max_length=max_len)["input_ids"]
# -100 is CrossEntropyLoss's ignore_index: those positions contribute
# nothing. Masking the prompt means the model is trained to produce the
# response, not to reproduce the question it was asked.
labels = [-100] * len(prompt_ids) + full_ids[len(prompt_ids):]
return {"input_ids": full_ids, "labels": labels[: len(full_ids)]}
# Modern tokenizers ship their own template — use it rather than inventing one:
# tok.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)Note: Add the role markers as special tokens so they are single ids and cannot be produced accidentally by ordinary text — that is part of what makes prompt injection harder.
Instruction datasets, and what each is for
| Dataset | Origin | Character |
|---|---|---|
| Alpaca | Generated by a stronger model | 52k pairs; easy to start with, inherits the teacher's flaws |
| Dolly 15k | Written by employees | Human-authored, permissively licensed, small |
| OpenAssistant | Crowd-sourced conversations | Multi-turn, multilingual, quality varies |
| FLAN collection | Existing NLP tasks, re-templated | Broad task coverage, terse responses |
| ShareGPT-style logs | Real conversations | Realistic; licensing and privacy need care |
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from torch.utils.data import DataLoader
tok = AutoTokenizer.from_pretrained("gpt2-large")
tok.add_special_tokens({"additional_special_tokens":
["<|system|>", "<|user|>", "<|assistant|>", "<|end|>"]})
tok.pad_token = tok.eos_token
model = AutoModelForCausalLM.from_pretrained("gpt2-large")
model.resize_token_embeddings(len(tok)) # required after adding tokens
def collate(batch):
n = max(len(b["input_ids"]) for b in batch)
pad = lambda seq, fill: seq + [fill] * (n - len(seq))
return {
"input_ids": torch.tensor([pad(b["input_ids"], tok.pad_token_id) for b in batch]),
"labels": torch.tensor([pad(b["labels"], -100) for b in batch]),
"attention_mask": torch.tensor(
[[1] * len(b["input_ids"]) + [0] * (n - len(b["input_ids"])) for b in batch]),
}
loader = DataLoader(examples, batch_size=4, shuffle=True, collate_fn=collate)
opt = torch.optim.AdamW(model.parameters(), lr=2e-5)
model.train()
for batch in loader:
loss = model(**batch).loss
opt.zero_grad(set_to_none=True)
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
opt.step()Note: Padding labels with -100 rather than the pad id is essential; otherwise the model is trained to emit padding.
Why instruction tuning is not enough
Instruction tuning imitates the responses in its dataset. That means it inherits their ceiling — the model learns to produce text that looks like a good answer, which is not the same as producing a good answer.
It also gives no way to express preferences that are easy to judge but hard to write. Nobody can author the ideal response to every ambiguous or harmful request, but people can reliably say which of two responses is better. That asymmetry is what reinforcement learning from human feedback exploits.
RLHF, in three stages
- Supervised fine-tuning: instruction-tune a base model on demonstrations to get a reasonable starting policy.
- Reward model: sample several responses per prompt, have humans rank them, and train a model to score a response so that preferred responses score higher. The loss is a pairwise comparison, so no absolute quality scale is ever needed.
- Policy optimisation: fine-tune the language model to maximise the reward model's score, with a KL-divergence penalty against the supervised model to stop it drifting into degenerate text that games the reward.
import torch, torch.nn.functional as F
# Stage 2. Bradley-Terry pairwise loss: the chosen response should outscore
# the rejected one. Only the difference matters, so the scale is arbitrary.
def reward_loss(reward_model, prompt, chosen, rejected):
r_c = reward_model(prompt, chosen) # scalar per pair
r_r = reward_model(prompt, rejected)
return -F.logsigmoid(r_c - r_r).mean()
# Stage 3. Maximise reward, penalised for straying from the reference policy.
# Without the KL term the policy collapses onto whatever the reward model
# over-scores — reward hacking, and it happens quickly.
def ppo_objective(reward, logprobs_policy, logprobs_ref, beta=0.1):
kl = (logprobs_policy - logprobs_ref).mean()
return reward.mean() - beta * kl
# DPO removes the separate reward model entirely, optimising the preference
# data directly. Same data, one training stage, far simpler to run — which is
# why most open work now uses it.
def dpo_loss(pi_chosen, pi_rejected, ref_chosen, ref_rejected, beta=0.1):
margin = beta * ((pi_chosen - ref_chosen) - (pi_rejected - ref_rejected))
return -F.logsigmoid(margin).mean()What this buys, and what it does not
After these stages the model answers rather than continues, follows a system prompt, keeps a consistent persona, and declines some requests. That is a large behavioural change from a small amount of training relative to pretraining.
What has not changed is what the model knows. Instruction tuning and RLHF shape the interface, not the substrate. Every capability and every gap in the base model is still there, which is exactly why the interpretability parts of this curriculum study base models — the mechanisms are the same, with less behavioural varnish on top.
Worth remembering
- Instruction tuning is ordinary fine-tuning on formatted pairs; the format is what carries the behaviour.
- Mask the loss so only the response tokens are trained on, not the prompt.
- RLHF trains a reward model on human comparisons, then optimises the policy against it with a KL penalty.