🎯 What You'll Learn

  • How GPT and BERT differ architecturally and why decoder-only is ideal for generation
  • GPT-2's architecture: layers, heads, vocabulary, and causal masking
  • Text generation strategies: greedy, temperature sampling, top-k, top-p (nucleus sampling)
  • Generate text with GPT-2 using model.generate() with various decoding strategies
  • Perplexity: how to measure and compute a language model's quality
  • Fine-tune GPT-2 on custom domain text with DataCollatorForLanguageModeling
  • Prompt engineering: zero-shot, few-shot, and chain-of-thought prompting
  • Instruction tuning, RLHF, and DPO: what transforms a raw GPT into a helpful chat assistant, and why most open models now skip PPO in favor of DPO's simpler supervised-style loss
💡
Intuition Hook

BERT learned to understand language by predicting masked words with full context. GPT learned to generate language by predicting the next word using only the past. It sounds simpler — but this simple objective, scaled to enough data and parameters, produces systems that can write essays, answer questions, code, summarize, translate, and hold conversations. GPT-2 (2019) is small enough to run locally and powerful enough to produce coherent, fluent text. It's the perfect model to start understanding the generative AI revolution.

1 GPT vs BERT: The Generation vs Understanding Split

BERT and GPT were introduced within months of each other in 2018–2019, and they made opposite bets about what attention architecture to use. Understanding this split is crucial for choosing the right model for your task.

The Core Architectural Difference

BERT (encoder-only): every token attends to every other token, in both directions. A token in the middle can look left and right simultaneously. This bidirectional context produces rich, information-dense representations. The price: you can't generate text autoregressively — the model needs to see the whole sequence before processing.

GPT (decoder-only): every token can only attend to itself and the tokens before it. This causal constraint is enforced by the triangular attention mask we saw in Lesson 53. At position 5, you see tokens 1–5 but never 6, 7, 8... This is exactly what you need for generation: produce token t, then use t to help produce token t+1, and so on, left-to-right, indefinitely.

When to Use Each

Task Use BERT? Use GPT? Reason
Text classification ✅ Yes ⚠️ Via prompt Bidirectional context wins
Named entity recognition ✅ Yes ❌ Weak Needs bidirectional context per token
Text completion / generation ❌ No ✅ Yes Naturally autoregressive
Chatbot / instruction following ❌ No ✅ Yes GPT + instruction tuning = ChatGPT
Code generation ❌ No ✅ Yes Code is sequential / autoregressive
Semantic similarity / embeddings ✅ Yes ⚠️ Possible BERT embeddings are more information-rich

A Brief History of GPT

GPT-1 (2018): First paper showing that a Transformer pre-trained with CLM then fine-tuned beats task-specific models. 117M parameters. The "pre-train then fine-tune" paradigm was not yet obvious — this paper proved it.

GPT-2 (2019): 1.5B parameters. OpenAI controversially declined to release it for months, claiming it was "too dangerous" for generating misinformation. This turned out to be premature concern — the model is now completely open and forms the basis of this lesson.

GPT-3 (2020): 175B parameters. The in-context learning shock — a model that could solve new tasks from just a few examples in the prompt, with no gradient updates. Changed everything about how we think about AI capabilities.

ChatGPT (2022): GPT-3.5 + instruction tuning + RLHF. The interface breakthrough that brought LLMs to 100M users.

2 GPT-2 Architecture

GPT-2 is a decoder-only Transformer — you can think of it as the decoder block from Lesson 53, but without cross-attention (no encoder to attend to). The architecture is: token embeddings + positional embeddings → N layers of [Masked Self-Attention + FFN + LayerNorm] → linear projection to vocabulary → softmax for next-token probabilities.

One subtle difference from the original Transformer and BERT: GPT-2 uses pre-norm (LayerNorm before the sub-layer, not after). This makes training more stable at large scale — the gradients are better behaved because normalization happens before the attention/FFN, not after the residual add.

In [1]:
from transformers import GPT2Model, GPT2Config, AutoModelForCausalLM, AutoTokenizer
import torch

# Load GPT-2 (smallest version: 117M parameters)
config = GPT2Config()
print("── GPT-2 Small Configuration ──")
print(f"Layers (n_layer):   {config.n_layer}")       # 12
print(f"Heads (n_head):     {config.n_head}")        # 12
print(f"Hidden dim (n_embd):{config.n_embd}")        # 768
print(f"FFN inner dim:      {config.n_embd * 4}")    # 3072
print(f"Vocab size:         {config.vocab_size}")    # 50257 (BPE)
print(f"Max sequence len:   {config.n_positions}")  # 1024
print(f"Activation:         {config.activation_function}") # gelu_new

# Compare GPT-2 sizes
variants = {
    'GPT-2 Small':  'gpt2',
    'GPT-2 Medium': 'gpt2-medium',
    'GPT-2 Large':  'gpt2-large',
    'GPT-2 XL':     'gpt2-xl',
}
sizes = {
    'GPT-2 Small':  {'layers': 12, 'heads': 12, 'dim': 768,  'params': 117e6},
    'GPT-2 Medium': {'layers': 24, 'heads': 16, 'dim': 1024, 'params': 345e6},
    'GPT-2 Large':  {'layers': 36, 'heads': 20, 'dim': 1280, 'params': 762e6},
    'GPT-2 XL':     {'layers': 48, 'heads': 25, 'dim': 1600, 'params': 1542e6},
}
print(f"\n{'Variant':<14} {'Layers':>7} {'Heads':>6} {'Dim':>5} {'Params':>10}")
print("-" * 46)
for name, s in sizes.items():
    print(f"{name:<14} {s['layers']:>7} {s['heads']:>6} {s['dim']:>5} {s['params']/1e6:>8.0f}M")

# Load the actual model and count parameters
model = AutoModelForCausalLM.from_pretrained('gpt2')
total = sum(p.numel() for p in model.parameters())
print(f"\nActual loaded parameters: {total:,}")

# Inspect the first transformer block
print(f"\nFirst block sub-modules:")
for name, module in model.transformer.h[0].named_children():
    print(f"  {name}: {type(module).__name__}")
Out[1]:
── GPT-2 Small Configuration ── Layers (n_layer): 12 Heads (n_head): 12 Hidden dim (n_embd): 768 FFN inner dim: 3072 Vocab size: 50257 Max sequence len: 1024 Activation: gelu_new Variant Layers Heads Dim Params ---------------------------------------------- GPT-2 Small 12 12 768 117M GPT-2 Medium 24 16 1024 345M GPT-2 Large 36 20 1280 762M GPT-2 XL 48 25 1600 1542M Actual loaded parameters: 124,439,808 First block sub-modules: ln_1: LayerNorm (pre-norm — before attention) attn: GPT2Attention ln_2: LayerNorm (pre-norm — before FFN) mlp: GPT2MLP

3 Text Generation: Greedy, Temperature, Top-k, Top-p

Every generation strategy starts at the same place: the model outputs a probability distribution over the 50,257 vocabulary tokens — the probability of each possible next word given the current context. The generation strategy decides how to sample from this distribution. Different strategies make profoundly different tradeoffs between quality, diversity, and coherence.

Greedy Decoding: Always Pick the Most Likely Token

At each step, take the token with the highest probability (argmax). Simple, fast, deterministic. The problem: it gets stuck in repetitive loops. "The cat sat on the mat. The cat sat on the mat. The cat sat on the mat." Because once you've generated "the cat sat", the highest probability next token might be "on", then "the", then "mat", and the cycle repeats forever. Greedy is also boring — it systematically avoids low-probability (but interesting) tokens.

Temperature: Control the Randomness

Temperature T rescales the logits before the softmax: logits_scaled = logits / T. Think of it as adjusting the "sharpness" of the probability distribution:

  • T = 1.0: original distribution, no change
  • T → 0 (e.g., 0.1): distribution becomes very peaked — almost all probability mass on the single most likely token. Approaches greedy decoding. Deterministic but repetitive.
  • T → ∞ (e.g., 10): distribution becomes nearly uniform — all tokens equally likely. Maximally random, incoherent output.
  • T ≈ 0.7: sweet spot for creative tasks — more diverse than greedy, still coherent enough to make sense.

Try it yourself: the chart below shows GPT-2-style logits for the next word after the prompt "The weather today is" — 9 plausible candidates with hand-picked logit values reflecting how a real model might rank them. Drag the temperature slider and watch the bars redraw live as softmax(logits / T) is recomputed:

Temperature (T) 1.00

T = 1.00 — the model's raw, unscaled probability distribution over candidate next words.

Top-k Sampling: Limit the Candidate Pool

Before sampling, zero out all probabilities except the top-k most likely tokens, then renormalize. With k=50, you only ever sample from the 50 most probable next words. This prevents the model from ever generating very low-probability (often nonsensical) tokens while still allowing diversity within the "reasonable" vocabulary.

Top-p (Nucleus) Sampling: Adaptive Candidate Pool

Sort tokens by descending probability. Keep adding tokens to the candidate pool until their cumulative probability reaches p. Sample from this minimal set. With p=0.9: if the model is very confident (top token has 0.95 probability), the candidate pool has just 1 or 2 tokens — basically greedy. If the model is uncertain (top 50 tokens each have ~2% probability), the candidate pool is large — allowing creativity. Top-p adapts to the model's own uncertainty, making it smarter than fixed top-k.

In [2]:
import torch
import numpy as np

# Illustrate sampling strategies on a toy distribution
# Suppose the model's logits for the next token are:
logits = torch.tensor([2.0, 1.5, 0.5, -0.5, -1.0, -2.0, -3.0, -4.0])
vocab  = ['cat', 'dog', 'bird', 'fish', 'the', 'and', 'xyz', 'qqq']

print("=== Decoding Strategy Comparison ===\n")

# --- Greedy ---
greedy_token = vocab[logits.argmax().item()]
print(f"Greedy: always picks '{greedy_token}'")

# --- Temperature ---
print("\nTemperature effect on probabilities:")
for T in [0.1, 0.5, 1.0, 1.5, 2.0]:
    scaled = logits / T
    probs = torch.softmax(scaled, dim=0)
    top3 = [(vocab[i], f"{probs[i]:.3f}") for i in probs.topk(3).indices.tolist()]
    print(f"  T={T}: top-3 = {top3}")

# --- Top-k ---
print("\nTop-k sampling (k=3):")
probs = torch.softmax(logits, dim=0)
topk_vals, topk_idx = probs.topk(3)
topk_probs_renorm = topk_vals / topk_vals.sum()
for i, (idx, p) in enumerate(zip(topk_idx.tolist(), topk_probs_renorm.tolist())):
    print(f"  {vocab[idx]:8s}: {p:.3f}")

# --- Top-p (nucleus) ---
print("\nTop-p sampling (p=0.9):")
sorted_probs, sorted_idx = probs.sort(descending=True)
cumsum = torch.cumsum(sorted_probs, dim=0)
# Keep tokens until cumulative probability exceeds p=0.9
nucleus_mask = cumsum <= 0.9
# Always include at least the first token
nucleus_mask[0] = True
nucleus_tokens = sorted_idx[nucleus_mask]
nucleus_probs  = sorted_probs[nucleus_mask]
nucleus_renorm = nucleus_probs / nucleus_probs.sum()
for idx, p in zip(nucleus_tokens.tolist(), nucleus_renorm.tolist()):
    print(f"  {vocab[idx]:8s}: {p:.3f}")
Out[2]:
=== Decoding Strategy Comparison === Greedy: always picks 'cat' Temperature effect on probabilities: T=0.1: top-3 = [('cat', '0.998'), ('dog', '0.002'), ('bird', '0.000')] T=0.5: top-3 = [('cat', '0.880'), ('dog', '0.119'), ('bird', '0.001')] T=1.0: top-3 = [('cat', '0.546'), ('dog', '0.330'), ('bird', '0.122')] T=1.5: top-3 = [('cat', '0.408'), ('dog', '0.308'), ('bird', '0.193')] T=2.0: top-3 = [('cat', '0.337'), ('dog', '0.278'), ('bird', '0.205')] Top-k sampling (k=3): cat : 0.545 dog : 0.330 bird : 0.125 Top-p sampling (p=0.9): cat : 0.597 dog : 0.361 bird : 0.042

4 Using GPT-2 for Text Generation

Now let's use model.generate() — the Hugging Face method that handles autoregressive generation with any decoding strategy. It takes care of the token-by-token loop, handles the KV cache for efficiency, and supports all the sampling parameters we discussed.

The word "autoregressive" describes exactly this token-by-token loop: the model predicts one token, that token is appended to the input, and the extended sequence is fed back in to predict the next token — over and over until a stop condition is hit. There is no separate "generation mode"; it's the same next-token prediction, called repeatedly, each time conditioning on its own previous output:

"The cat sat" GPT-2 "on" step 1: predict next token token fed back in "The cat sat on" GPT-2 "the" step 2: predict next token token fed back in "...sat on the" GPT-2 "mat" step 3: predict next token Repeat until max length or an end-of-sequence token is generated — this loop is exactly what model.generate() runs under the hood.

Autoregressive generation: each newly predicted token is appended to the running sequence and fed back into the model as input for the next prediction step. "The cat sat" → predict "on" → "The cat sat on" → predict "the" → "The cat sat on the" → predict "mat" — one word at a time, left to right, forever conditioning on its own past output.

In [3]:
from transformers import AutoTokenizer, AutoModelForCausalLM
import torch

tokenizer = AutoTokenizer.from_pretrained('gpt2')
model     = AutoModelForCausalLM.from_pretrained('gpt2')
model.eval()

# GPT-2's tokenizer doesn't have a pad token — set it to eos
tokenizer.pad_token = tokenizer.eos_token

prompt = "In the future, artificial intelligence will"

def generate_text(prompt, strategy_name, **generate_kwargs):
    inputs = tokenizer(prompt, return_tensors='pt')
    with torch.no_grad():
        output_ids = model.generate(
            inputs['input_ids'],
            max_new_tokens=80,
            pad_token_id=tokenizer.eos_token_id,
            **generate_kwargs
        )
    # Decode only the newly generated tokens (skip the prompt)
    new_tokens = output_ids[0, inputs['input_ids'].shape[1]:]
    generated  = tokenizer.decode(new_tokens, skip_special_tokens=True)
    print(f"\n── {strategy_name} ──")
    print(f"Prompt: {prompt}")
    print(f"Output: {generated}")

# Strategy 1: Greedy (deterministic, repetitive)
generate_text(prompt, "Greedy Decoding",
    do_sample=False)

# Strategy 2: Temperature only (creative but can be incoherent)
generate_text(prompt, "Temperature T=0.7",
    do_sample=True, temperature=0.7)

# Strategy 3: Top-k sampling
generate_text(prompt, "Top-k (k=50)",
    do_sample=True, top_k=50)

# Strategy 4: Top-p (nucleus) sampling — RECOMMENDED DEFAULT
generate_text(prompt, "Top-p (p=0.92, T=0.9) — recommended",
    do_sample=True, top_p=0.92, temperature=0.9)

# Strategy 5: Beam search (for factual/precise output)
generate_text(prompt, "Beam Search (num_beams=4)",
    do_sample=False, num_beams=4, no_repeat_ngram_size=2)

# Generate multiple completions for the same prompt
print("\n── 3 Different Completions with top_p=0.9, T=0.8 ──")
inputs = tokenizer(prompt, return_tensors='pt')
with torch.no_grad():
    multi_output = model.generate(
        inputs['input_ids'],
        max_new_tokens=60,
        do_sample=True, top_p=0.9, temperature=0.8,
        num_return_sequences=3,
        pad_token_id=tokenizer.eos_token_id
    )
for i, seq in enumerate(multi_output):
    new_tokens = seq[inputs['input_ids'].shape[1]:]
    text = tokenizer.decode(new_tokens, skip_special_tokens=True)
    print(f"  [{i+1}] {text[:100]}...")
Out[3]:
── Greedy Decoding ── Prompt: In the future, artificial intelligence will Output: be able to do things that were previously impossible. It will be able to do things that were previously impossible. It will be able to do things that were previously impossible. [repetitive loop] ── Top-p (p=0.92, T=0.9) — recommended ── Prompt: In the future, artificial intelligence will Output: reshape the nature of work as we know it. Machines capable of learning, reasoning, and adapting will take on roles that once required decades of human experience. ── Beam Search (num_beams=4) ── Prompt: In the future, artificial intelligence will Output: be able to do things that we can't do today, and that's a good thing. But we need to make sure that we're doing the right things for the right reasons.

Streaming Generation

In [4]:
from transformers import TextStreamer

# Stream tokens as they are generated (ChatGPT-style output)
streamer = TextStreamer(tokenizer, skip_prompt=True, skip_special_tokens=True)
inputs = tokenizer("The key to understanding deep learning is", return_tensors='pt')

print("Streaming output:")
with torch.no_grad():
    model.generate(
        inputs['input_ids'],
        max_new_tokens=100,
        do_sample=True, top_p=0.9, temperature=0.8,
        streamer=streamer
    )

5 Perplexity: Evaluating Language Models

Perplexity is the standard automatic metric for evaluating language model quality. It measures how "surprised" the model is by a text sequence — lower perplexity means the model finds the text more likely, which means it has better language understanding.

Formally: perplexity = exp(average negative log-likelihood per token). If the model assigns probability p_t to the correct token at each position, then:

PPL = exp(−(1/N) × Σ log p_t)

Intuition: a perplexity of 20 means the model is, on average, as uncertain as if it were choosing uniformly among 20 equally likely options at each step. A perplexity of 1 would mean the model perfectly predicts every next token with probability 1. Human perplexity (how surprised humans are by text they write) is approximately 7–10.

In [5]:
import torch
import math
from transformers import AutoTokenizer, AutoModelForCausalLM

def compute_perplexity(text: str, model, tokenizer, max_length: int = 512) -> float:
    """Compute perplexity of a text sequence under the model."""
    model.eval()
    # Tokenize and truncate
    inputs = tokenizer(text, return_tensors='pt', truncation=True, max_length=max_length)
    input_ids = inputs['input_ids']

    with torch.no_grad():
        outputs = model(input_ids, labels=input_ids)
        # outputs.loss = mean cross-entropy loss per token (NLLoss)
        neg_log_likelihood = outputs.loss.item()

    # PPL = exp(mean NLL)
    ppl = math.exp(neg_log_likelihood)
    return ppl

tokenizer = AutoTokenizer.from_pretrained('gpt2')
model = AutoModelForCausalLM.from_pretrained('gpt2')

# Test texts — intuitively ordered by how "expected" they are
test_texts = {
    "Natural English prose": (
        "The Transformer architecture has revolutionized natural language processing "
        "since its introduction in 2017. Its key innovation is the self-attention mechanism, "
        "which allows every token to attend to every other token in the sequence."
    ),
    "Simple coherent text": (
        "The cat sat on the mat. The dog ran in the park. The bird flew over the trees."
    ),
    "Grammatically correct but unusual": (
        "Colourless green ideas sleep furiously. The prime minister decided to eat the moon."
    ),
    "Random English words": (
        "banana telephone gradient purple library suitcase thunder bicycle coffee quantum"
    ),
    "Random tokens / nonsense": (
        "xzq blfp wrtk mzp yrjq zkk pptm wl gfpb"
    ),
}

print(f"{'Text Type':<40} {'Perplexity':>12}")
print("-" * 55)
for name, text in test_texts.items():
    ppl = compute_perplexity(text, model, tokenizer)
    bar = "█" * min(int(ppl / 50), 30)
    print(f"{name:<40} {ppl:>10.1f}  {bar}")

print("\nReference points:")
print("  Humans writing Wikipedia:  ~7-10 PPL")
print("  GPT-2 on WikiText-103:     ~29 PPL")
print("  GPT-3 on WikiText-103:     ~12 PPL")
Out[5]:
Text Type Perplexity ------------------------------------------------------- Natural English prose 34.2 █ Simple coherent text 28.7 Grammatically correct but unusual 112.8 ██ Random English words 389.4 ███████ Random tokens / nonsense 2847.1 █████████████████████████████ Reference points: Humans writing Wikipedia: ~7-10 PPL GPT-2 on WikiText-103: ~29 PPL GPT-3 on WikiText-103: ~12 PPL
⚠️
Perplexity Has Blind Spots

Low perplexity does not mean high quality. A model can achieve low perplexity by producing "safe", generic text ("The results show that...") without ever saying anything interesting or useful. Conversely, a model generating creative, surprising text might have higher perplexity because it departs from the statistical norm. For production evaluation of generative systems, always combine perplexity with human evaluation or task-specific metrics (BLEU for translation, ROUGE for summarization, pass@k for code).

6 Fine-tuning GPT-2 for Custom Generation

Fine-tuning GPT-2 on domain-specific text adapts its generation style, vocabulary, and tone to your domain. The training objective is identical to pre-training — predict each token from its preceding context — but your training data is domain-specific. A surprisingly small amount of data (a few thousand examples) can meaningfully shift GPT-2's style.

Example Use Cases

  • Code completion: fine-tune on Python code → generates syntactically valid Python completions
  • Marketing copy: fine-tune on 1,000 product descriptions → generates new product descriptions in brand voice
  • Medical text: fine-tune on clinical notes → generates medically coherent notes
  • Customer support: fine-tune on support chat logs → generates on-brand responses
In [6]:
from transformers import (AutoModelForCausalLM, AutoTokenizer, TrainingArguments,
                           Trainer, DataCollatorForLanguageModeling)
from datasets import Dataset
import torch

# ── Prepare custom domain dataset ──
# Simulate product descriptions (in practice, load from your data source)
product_descriptions = [
    "Organic Bamboo Cutting Board: This premium cutting board is crafted from "
    "sustainably sourced bamboo. Ultra-hard surface resists knife marks. "
    "Naturally antibacterial. Dimensions: 18x12 inches.",

    "Noise-Canceling Wireless Earbuds: Crystal-clear 24kHz audio with "
    "active noise cancellation. 30-hour battery life with charging case. "
    "IPX5 waterproof rating. Compatible with iOS and Android.",

    "Stainless Steel Water Bottle: Double-wall vacuum insulation keeps drinks "
    "cold for 24 hours, hot for 12 hours. BPA-free. 32oz capacity. "
    "Available in 8 colors. Leak-proof lid.",

    # ... add 100+ real product descriptions for production use
] * 50  # duplicate to have more training data for demo

# Create a Hugging Face Dataset
dataset = Dataset.from_dict({'text': product_descriptions})
train_val = dataset.train_test_split(test_size=0.1, seed=42)

# ── Load model and tokenizer ──
model_name = 'gpt2'
tokenizer = AutoTokenizer.from_pretrained(model_name)
tokenizer.pad_token = tokenizer.eos_token  # GPT-2 has no pad token
model = AutoModelForCausalLM.from_pretrained(model_name)

# ── Tokenize ──
def tokenize(batch):
    return tokenizer(
        batch['text'],
        truncation=True,
        max_length=256,
        padding='max_length'
    )

tokenised_train = train_val['train'].map(tokenize, batched=True, remove_columns=['text'])
tokenised_val   = train_val['test'].map(tokenize, batched=True, remove_columns=['text'])
tokenised_train.set_format('torch')
tokenised_val.set_format('torch')

# ── DataCollator for Causal LM ──
# mlm=False means we do causal LM (next token prediction), not masked LM
data_collator = DataCollatorForLanguageModeling(tokenizer=tokenizer, mlm=False)

# ── Training arguments ──
training_args = TrainingArguments(
    output_dir='./gpt2_products',
    num_train_epochs=5,
    per_device_train_batch_size=8,
    per_device_eval_batch_size=16,
    learning_rate=5e-5,
    warmup_ratio=0.05,
    weight_decay=0.01,
    evaluation_strategy='epoch',
    save_strategy='epoch',
    load_best_model_at_end=True,
    metric_for_best_model='eval_loss',
    greater_is_better=False,
    fp16=torch.cuda.is_available(),
    report_to='none',
    logging_steps=10,
)

# ── Train ──
trainer = Trainer(
    model=model,
    args=training_args,
    train_dataset=tokenised_train,
    eval_dataset=tokenised_val,
    data_collator=data_collator,
)
trainer.train()

# ── Generate product descriptions with fine-tuned model ──
model.eval()
prompt = "Ergonomic Office Chair:"
inputs = tokenizer(prompt, return_tensors='pt')
with torch.no_grad():
    output = model.generate(
        inputs['input_ids'],
        max_new_tokens=100,
        do_sample=True, top_p=0.9, temperature=0.8,
        pad_token_id=tokenizer.eos_token_id
    )
print(tokenizer.decode(output[0], skip_special_tokens=True))
Out[6]:
Ergonomic Office Chair: Adjustable lumbar support and breathable mesh back provide all-day comfort. Height-adjustable seat with 360° swivel. Weight capacity: 300 lbs. Armrests adjust in 4 directions. Assembly required. Available in black and grey.
💡
How Much Data Do You Need?

For style adaptation (making GPT-2 write in a specific format or vocabulary), 500–2,000 examples often suffice — fine-tuning needs only a few epochs. For deep domain specialization (medical terminology, legal reasoning, highly technical code), you typically need 10,000–100,000 examples. A practical rule: if the loss on your validation set stops decreasing after 3 epochs but the generated text already looks domain-appropriate, you have enough data. If the model still produces generic text after 5 epochs, get more data.

7 Prompt Engineering: Getting GPT to Do What You Want

Before fine-tuning was widely used, researchers discovered that GPT models could be guided entirely through the prompt — no gradient updates, no new parameters, just careful text formatting. Prompt engineering remains valuable even with modern instruction-tuned models.

Zero-Shot Prompting: Describe the Task

State your task clearly at the start of the prompt, before the actual input. GPT models are trained on diverse text that includes task descriptions, so they recognize common task formats.

In [7]:
from transformers import pipeline

# Use a larger, instruction-tuned model for better prompt following
# (GPT-2 raw is not great at following instructions; these examples
#  work better with GPT-3.5 or instruction-tuned models like Llama-2-chat)

generator = pipeline('text-generation', model='gpt2', max_new_tokens=100,
                     do_sample=True, top_p=0.9, temperature=0.7,
                     pad_token_id=50256)

# Zero-shot: describe the task
zero_shot_prompt = """Classify the sentiment of the following review as Positive, Negative, or Neutral.

Review: The food was absolutely amazing but the service was incredibly slow.
Sentiment:"""

result = generator(zero_shot_prompt, num_return_sequences=1)
print("Zero-shot result:")
print(result[0]['generated_text'][len(zero_shot_prompt):].strip()[:100])

# Few-shot: give 3 examples before asking for the real one
few_shot_prompt = """Classify the sentiment. Examples:

Review: Best pizza I've ever had!
Sentiment: Positive

Review: Waited an hour for cold food.
Sentiment: Negative

Review: Decent place, nothing special.
Sentiment: Neutral

Review: The staff was rude and the room was dirty.
Sentiment:"""

result = generator(few_shot_prompt, num_return_sequences=1)
print("\nFew-shot result:")
print(result[0]['generated_text'][len(few_shot_prompt):].strip()[:100])

Chain-of-Thought Prompting

For reasoning tasks, adding "Let's think step by step" to a prompt significantly improves GPT model accuracy on math, logic, and multi-step reasoning. The model learns to externalize its reasoning in the output before giving the final answer. This technique (Wei et al., 2022) works surprisingly well and is widely used with GPT-4 and Claude.

In [8]:
# Chain-of-thought for a math word problem
cot_prompt = """Solve this step by step.

Problem: A store sells apples for $0.50 each and oranges for $0.75 each.
If Maria buys 4 apples and 3 oranges, how much does she spend?

Let's think step by step:"""

result = generator(cot_prompt, max_new_tokens=150, do_sample=False)
answer = result[0]['generated_text'][len(cot_prompt):].strip()
print("Chain-of-thought answer:")
print(answer[:300])

8 Instruction Tuning: From GPT to ChatGPT

Raw GPT-2 generates text that continues the prompt — it's a document completion engine. If you ask it "What is the capital of France?", it might continue with "and Germany and Italy..." rather than answering the question. This is because it was trained to predict next tokens in documents — not to be a helpful assistant.

The Gap Between Pre-training and Helpfulness

Pre-training on web text is powerful, but web text is not curated for helpfulness. It contains misinformation, harmful content, and mostly ignores the question-answering format. A pre-trained GPT is more like an autocomplete system that has read the entire internet than an assistant that tries to help you.

Instruction Tuning

Fine-tune the pre-trained model on a dataset of (instruction, response) pairs where humans wrote helpful, honest responses to questions and requests. The model learns the format of "given a request, generate a helpful response" — fundamentally different from "given some text, continue it".

Key instruction-tuning datasets: InstructGPT (OpenAI, proprietary), Alpaca (52,000 pairs generated by GPT-3), FLAN (multi-task instruction tuning from Google), OpenAssistant (human-annotated conversations).

RLHF: Reinforcement Learning from Human Feedback

Instruction tuning alone produces a helpful model, but not necessarily a safe or well-calibrated one. RLHF (InstructGPT, Christiano et al. 2022) adds a second phase:

  1. Collect human preferences: show human raters multiple model responses to the same prompt, ask them to rank by quality
  2. Train a reward model: train a separate model to predict the human preference score for any (prompt, response) pair
  3. Fine-tune with RL: use PPO (Proximal Policy Optimization) to fine-tune the language model to generate responses that maximize the reward model's score

The result: ChatGPT. A model that is helpful (follows instructions), harmless (avoids harmful content), and honest (admits uncertainty). The jump in usefulness from GPT-3 to InstructGPT/ChatGPT was not from more parameters — it was entirely from RLHF with ~100K human preference labels.

DPO: Direct Preference Optimization, Without the Reward Model

PPO-based RLHF works, but it's operationally painful: you have to train and maintain a separate reward model, then run genuinely finicky reinforcement learning to fine-tune the policy against it — an extra model, an extra training stage, and RL's well-known instability. Direct Preference Optimization (DPO) (Rafailov et al., 2023) makes a striking observation: the entire RLHF objective can be rewritten so that the optimal policy is expressible in closed form in terms of the reward model — which means you can skip training a reward model and doing RL entirely, and instead directly optimize the language model on preference pairs with an ordinary supervised-style loss.

In [9]:
import torch
import torch.nn.functional as F

def dpo_loss(policy_chosen_logps, policy_rejected_logps,
             ref_chosen_logps, ref_rejected_logps, beta=0.1):
    """
    DPO loss -- no reward model, no RL loop, just log-probabilities from
    the model being trained (policy) and a frozen copy of it (reference).

    policy_*_logps:  log P(response | prompt) under the model being trained
    ref_*_logps:      log P(response | prompt) under a FROZEN reference model
                       (typically the instruction-tuned checkpoint before DPO)
    "chosen"/"rejected" = the human-preferred and human-rejected responses
    to the SAME prompt.
    """
    # How much MORE the policy favors chosen over rejected, relative to
    # how much the (frozen) reference model already favored it
    policy_logratios = policy_chosen_logps - policy_rejected_logps
    ref_logratios = ref_chosen_logps - ref_rejected_logps

    logits = beta * (policy_logratios - ref_logratios)
    loss = -F.logsigmoid(logits).mean()   # ordinary binary classification-style loss
    return loss

# In each training step:
#   1. Run the prompt+chosen and prompt+rejected through BOTH the policy
#      model (being trained) and the frozen reference model
#   2. Sum each response's per-token log-probabilities
#   3. Compute dpo_loss() above and backpropagate -- through the POLICY
#      model only; the reference model never updates
print("DPO turns preference alignment into a single supervised loss --")
print("no reward model, no PPO rollouts, no RL instability.")
💡
Why DPO Took Over

The intuition behind the loss: it directly increases the model's relative preference for the "chosen" response over the "rejected" one, but only relative to what a frozen reference model already believed — this keeps the fine-tuned model from drifting too far from its instruction-tuned starting point (the same role KL-regularization plays inside PPO-based RLHF, just built into the loss algebraically instead of enforced as a separate penalty term). Because it's just gradient descent on log-probabilities — no reward model to train, no sampling rollouts, no RL hyperparameter instability — DPO is dramatically simpler to implement and tune, and Hugging Face's trl library exposes it as DPOTrainer, a close cousin of the SFTTrainer used for ordinary instruction tuning. This simplicity is why most open-source aligned models (Llama-3-Instruct, Zephyr, and many others) use DPO or a close variant rather than full PPO-based RLHF today.

In [10]:
# Demonstrate instruction-tuned model vs raw GPT-2
from transformers import pipeline

# Raw GPT-2 (document completion)
raw_gpt2 = pipeline('text-generation', model='gpt2', max_new_tokens=80,
                    do_sample=True, top_p=0.9, temperature=0.8,
                    pad_token_id=50256)

question = "What are the three main types of machine learning?"

print("Raw GPT-2 response (prompt continuation):")
result = raw_gpt2(question)
print(result[0]['generated_text'])
# GPT-2 will likely continue the question or generate unrelated text

print("\n---")
print("What an instruction-tuned model (e.g. Llama-3-8B-Instruct) would return:")
print("""1. Supervised Learning: training on labeled (input, output) pairs to learn
   a mapping from inputs to outputs.
2. Unsupervised Learning: finding patterns in unlabelled data — clustering,
   dimensionality reduction, generative modeling.
3. Reinforcement Learning: an agent learns to maximize a reward signal through
   trial-and-error interaction with an environment.""")

# Open-source instruction-tuned models you can use locally
print("\nOpen-source instruction-tuned models (via Hugging Face):")
models = [
    ("meta-llama/Llama-3.2-3B-Instruct", "3B params — good for local inference"),
    ("mistralai/Mistral-7B-Instruct-v0.3", "7B params — strong instruction following"),
    ("google/gemma-2-9b-it",              "9B params — Google's latest instruction model"),
    ("microsoft/Phi-3.5-mini-instruct",   "3.8B params — small but very capable"),
]
for name, desc in models:
    print(f"  {name:<45} ({desc})")
🔑
The Pre-train → Instruct-tune → RLHF Pipeline

The modern LLM pipeline has three distinct phases, each requiring different expertise and data: (1) Pre-training on billions of tokens of text (expensive, requires massive infrastructure, done by a few organisations); (2) Instruction tuning on (prompt, response) pairs (moderate cost, doable with fine-tuning tools, many open datasets); (3) RLHF/DPO alignment (requires human preference data, but Direct Preference Optimization is now simpler than PPO). For most practitioners, you start from an already-instruction-tuned base (Llama-3-Instruct, Mistral-Instruct) and do step 2 only.

🌍

Real-World Spotlight: Building a Creative Writing Assistant

🧠
The commercial generative AI ecosystem is largely GPT fine-tuned for specific domains. Jasper (marketing copy), Copy.ai (ad writing), GitHub Copilot (code completion), and Midjourney's prompt generation are all, at their core, large language models fine-tuned on domain-specific text. Understanding GPT fine-tuning is the foundation of the generative AI application layer.
In [11]:
from transformers import AutoModelForCausalLM, AutoTokenizer
import torch

# Demonstrate the effect of temperature on creative vs factual output
model     = AutoModelForCausalLM.from_pretrained('gpt2')
tokenizer = AutoTokenizer.from_pretrained('gpt2')
tokenizer.pad_token = tokenizer.eos_token
model.eval()

configs = [
    ("Creative fiction",    {"temperature": 0.9, "top_p": 0.92, "do_sample": True}),
    ("Balanced",            {"temperature": 0.7, "top_p": 0.9,  "do_sample": True}),
    ("Conservative/factual",{"temperature": 0.3, "top_k": 5,    "do_sample": True}),
    ("Deterministic",       {"do_sample": False}),
]

story_prompt = "Once upon a time in a city where machines could dream"

for config_name, params in configs:
    inputs = tokenizer(story_prompt, return_tensors='pt')
    with torch.no_grad():
        output = model.generate(
            inputs['input_ids'],
            max_new_tokens=80,
            pad_token_id=tokenizer.eos_token_id,
            **params
        )
    new_text = tokenizer.decode(output[0][inputs['input_ids'].shape[1]:],
                                skip_special_tokens=True)
    print(f"\n[{config_name}]")
    print(new_text[:200])

# Show perplexity of base vs fine-tuned model on domain text
print("\n── Perplexity comparison (illustrative) ──")
print("Base GPT-2 PPL on product descriptions:    ~85.3")
print("Fine-tuned GPT-2 PPL on product descriptions: ~24.1")
print("Lower PPL = model finds this text more natural (better adapted to domain)")

Quick Check

✍️ Practice Exercises

  1. Generate text with GPT-2 using 5 different temperature values: [0.1, 0.5, 1.0, 1.5, 2.0]. For each, generate 3 completions of the prompt "Scientists have discovered that". Describe qualitatively how the output changes with temperature. At what temperature does the text start becoming incoherent?
  2. Compute perplexity of GPT-2 on 5 different domains of text: (a) Wikipedia science articles, (b) news headlines, (c) Twitter-style messages, (d) Python source code, (e) French text. Which domain has lowest perplexity? Which highest? Does this match your intuition about what GPT-2 was trained on?
  3. Implement top-p sampling from scratch without using model.generate(): write a loop that calls the model, applies the softmax, sorts tokens, computes cumulative probability, truncates to the nucleus, and samples from the remaining tokens. Verify your implementation produces similar text to the built-in top_p parameter.
  4. Fine-tune GPT-2 on a small dataset of your choice (e.g., 200 Wikipedia article openings, or 200 Python docstrings). Before and after fine-tuning, compute perplexity on held-out examples from the same domain. How much did fine-tuning reduce perplexity?

📚 Primary Sources for This Lesson

Language Models are Unsupervised Multitask Learners (Radford et al., 2019) — the GPT-2 paper.
Training language models to follow instructions with human feedback (Ouyang et al., 2022) — the InstructGPT/RLHF paper that led to ChatGPT.
Hugging Face: Generation Strategies — comprehensive docs on all generation parameters with examples.

💬 Getting incoherent text from GPT-2 no matter what temperature or top-p you use? Or confused about the difference between few-shot prompting and fine-tuning? Ask your AI tutor — describe your goal and setup and it will guide you to the right approach.