hands-on labon a real NVIDIA GPU

Continued Pre-Training: Adapt a Pretrained LM to a New Domain

Take GPT-2 and domain-adapt it to Python code in 150 steps, measuring both the gain on code and the cost in catastrophic forgetting on English.

time
45 min
checked steps
4
level
Advanced
setup
None
NCP-GENLNCP-ADSNCA-GENLPart of the AI Engineer Course

$29.99a month with Pro

Pro opens this lab and every other one. Cancel any time.

Lab cockpit45 min · 4 stepsSession running
3 / 4 steps passingMeasure the trade-off: domain gain vs general forgetting · step 4 of 4
lab.ipynb

Step 4 — Measure the trade-off: domain gain vs general forgetting

Rerun the exact same eval from Step 1 on the now-adapted model. We expect:

[ ]:
adapted_code_ppl = perplexity(model, code_eval_tokens)adapted_general_ppl = perplexity(model, general_eval_tokens) forgetting_ratio = adapted_general_ppl / baseline_general_pplcode_gain = (baseline_code_ppl - adapted_code_ppl) / baseline_code_ppl print('============================================================')print(f'                 Python code perplexity     English perplexity')print(f'BEFORE CPT       {baseline_code_ppl:10.2f}              {baseline_general_ppl:10.2f}')print(f'AFTER  CPT       {adapted_code_ppl:10.2f}              {adapted_general_ppl:10.2f}')print(f'                 {-100*code_gain:+6.1f}%                   {100*(forgetting_ratio-1):+6.1f}%')print('============================================================')print()if forgetting_ratio > 1.3:    print(f'The model has lost {100*(forgetting_ratio-1):.0f}% of its English ability — classic catastrophic forgetting.')    print('In production: mix 10-30% general data back in, or use LoRA.')else:    print(f'Minimal forgetting ({100*(forgetting_ratio-1):+.0f}%). The conservative LR paid off.')
[ ]:
lab.check(4)
step 4 of 4, replayed: Measure the trade-off: domain gain vs general forgetting. The code, the output and the check are the lab's own.

4 steps, each checked when you finish it

A check runs your work at the end of every step. Hints and the full solution are there if you get stuck.

  1. 1

    Baseline: measure GPT-2 on code vs English

    read this step below ↓

  2. 2

    Prepare the CPT corpus

  3. 3

    Continued pretraining run

  4. 4

    Measure the trade-off: domain gain vs general forgetting

Step 1 as it appears in the lab

The lab’s own text. The hint and the solution stay inside the lab.

Step 1: Baseline: measure GPT-2 on code vs English

You've spent months and millions of dollars training a general LM on web text. Now your medical company wants a model that speaks medical, or your code-hosting platform wants one that speaks Python. You don't want to restart from scratch — ~99% of what the model knows is still useful. What you want is Continued Pre-Training (CPT) (also called Domain-Adaptive Pre-Training, DAPT): take the pretrained model and keep training on your domain corpus.

Real examples:

  • Code Llama (Roziere et al., 2023) — Meta took Llama 2 and did 500B tokens of continued pretraining on code. Result: a Llama family member that's much stronger at programming while retaining most general ability.
  • Don't Stop Pretraining (Gururangan et al., 2020) — the paper that coined DAPT. Showed that even 50–500MB of domain text significantly improves downstream-task accuracy for free.
  • BloombergGPT (2023) — a ~50B-param LM with half financial data mixed in from pretraining onward. Their financial benchmark gains come primarily from the domain data, not the size.
  • [FinGPT, BioGPT, Galactica, Med-PaLM, StarCoder] — every domain-specialized LM of the last 3 years follows this recipe.

What you'll do

Take GPT-2 (124M) and continue pretraining it on Python source code from codeparrot-clean. Then measure two perplexities:

  1. How much better did it get at Python? (should drop substantially)
  2. How much worse did it get at English? (catastrophic forgetting check)

That tension — domain gain vs general forgetting — is the central trade-off of CPT, and how you manage it determines how useful your adapted model is.

The three things that make CPT work (or not)

  1. Learning rate. This is THE hyperparameter. Use the original pretraining LR and you'll erase everything the model learned. Use zero and nothing changes. The sweet spot is 1–10% of original pretraining LR. GPT-2 was pretrained at ~6e-4; we'll use 5e-5.
  2. Data mixing. Pure domain data → catastrophic forgetting. Mixing 10–30% general data back in preserves general ability. We'll run the pure-domain version here to see the forgetting clearly, then discuss the mix.
  3. Duration. Too short and nothing sticks. Too long and you overfit. The Code Llama team used 500B tokens on top of Llama 2's 2T — a 25% relative training extension.

Before training, establish the baseline. We'll use:

  • Python code as the target domain (from codeparrot-clean)
  • Wikitext as the held-out general-English reference

On Python code, GPT-2 should struggle — perplexity typically 3–10× higher than on general English. That gap is exactly what we're going to close.

import torch
import torch.nn.functional as F
from transformers import AutoTokenizer, AutoModelForCausalLM
from datasets import load_dataset
import math, time

device = torch.device('cuda')
torch.manual_seed(1337)

print('Loading GPT-2...')
tokenizer = AutoTokenizer.from_pretrained('gpt2')
tokenizer.pad_token = tokenizer.eos_token
model = AutoModelForCausalLM.from_pretrained('gpt2').to(device)

print(f'GPT-2: {model.num_parameters()/1e6:.0f}M params')

Build the evaluation corpora — streaming fetch

Two held-out sets: code (CodeParrot, Python source) and general (WikiText-2, English prose). Streaming mode avoids downloading the whole dataset. build_eval_tokens takes the first N docs from each stream, crops each to a max char budget, tokenizes with the GPT-2 BPE, and concatenates with EOS separators.

def build_eval_tokens(n_docs, dataset, key, max_chars_per_doc=3000):
    stream = iter(dataset)
    docs = []
    for _ in range(n_docs):
        try:
            docs.append(next(stream)[key][:max_chars_per_doc])
        except StopIteration:
            break
    ids = []
    for d in docs:
        ids.extend(tokenizer.encode(d) + [tokenizer.eos_token_id])
    return torch.tensor(ids, dtype=torch.long)

print('Fetching eval sets (streaming)...')
code_ds = load_dataset('json', data_files='/workspace/datasets/codeparrot-sample/train.jsonl', split='train')
general_ds = load_dataset('Salesforce/wikitext', 'wikitext-2-raw-v1', split='train')

code_eval_tokens = build_eval_tokens(50, code_ds, key='content')
general_eval_tokens = build_eval_tokens(80, general_ds, key='text')
print(f'Code eval:    {len(code_eval_tokens):,} tokens')
print(f'General eval: {len(general_eval_tokens):,} tokens')

Baseline perplexity — GPT-2 before any pre-training

Perplexity = exp(cross-entropy loss); lower means the model finds the text less surprising. GPT-2 was trained on WebText (~40GB of Reddit-linked pages), which included substantial code. So initial parity between Python and English is normal. What matters is the shift after Step 4's continued pre-training — we want Python ppl to drop while English ppl stays roughly flat (no catastrophic forgetting).

@torch.no_grad()
def perplexity(m, tokens, block_size=256):
    m.eval()
    tokens = tokens.to(device)
    losses = []
    for i in range(0, len(tokens) - block_size, block_size):
        chunk = tokens[i:i + block_size].unsqueeze(0)
        out = m(chunk, labels=chunk)
        losses.append(out.loss.item())
    return math.exp(sum(losses) / len(losses))

baseline_code_ppl = perplexity(model, code_eval_tokens)
baseline_general_ppl = perplexity(model, general_eval_tokens)
print(f'BASELINE  GPT-2 on Python code  : {baseline_code_ppl:.2f}')
print(f'BASELINE  GPT-2 on English text : {baseline_general_ppl:.2f}')
print()
print('Note: GPT-2 was trained on WebText (~40GB of Reddit-linked pages) which includes')
print('substantial code. Initial parity between Python and wikitext perplexity is normal —')
print('what matters is the Python-specific gain in Step 4.')

what you get

  • A JupyterLab notebook on a real NVIDIA GPU
  • A check at the end of every step
  • Hints and the full solution
  • Nothing to install or configure

before you start

  • Completed basic LLM fine-tuning (Lab fine-tune-llm-lora-jupyter or equivalent)
  • Comfortable computing perplexity and running a PyTorch training loop
  • Understanding of learning-rate schedules and catastrophic forgetting

Exam domains covered

LLM Fine-Tuning & AdaptationGPU Acceleration & Distributed Training

Part of the LLM training and fine-tuning track

Lab 5 of 14. LoRA and QLoRA fine-tuning, DPO alignment, and a tokenizer and tiny GPT built from scratch.

Questions about this lab

Why is cpt_lr = 5e-5 so much smaller than GPT-2's original pretraining LR of 6e-4?

Because you're not starting from randomly-initialised weights, you're nudging a trained distribution. Full-blast pretraining LR on an already-converged model would blow up the loss in the first few steps, bouncing weights through sharp regions of the loss surface that the original training carefully cooled past. CPT convention is 1-10% of the original peak LR, with the exact fraction tuned to how aggressive a shift you want. 5e-5 lands in the middle of that band, aggressive enough to see a >30% code perplexity drop in 150 steps, gentle enough that the loss curve stays monotonic rather than spiking.

What is the forgetting_ratio actually measuring?

The relative degradation of the model's general-English next-token loss after CPT. If baseline_general_ppl = 40 and adapted_general_ppl = 56, forgetting_ratio = 1.4, the adapted model is 40% worse at predicting wikitext tokens than the base. This number is the empirical face of catastrophic forgetting: by pointing the gradient only at code-distributed tokens for 150 steps, you let the general-text capability drift. In a real domain-LLM program you'd track this ratio over the CPT schedule and stop (or add replay) before it crosses your retention floor.

Why concatenate documents with eos_token_id between them instead of keeping them separate?

Because causal language modelling trains on contiguous 256-token windows with shifted targets, and the cleanest way to stream variable-length documents into that loop is one long 1-D token stream. The eos_token_id separator is a learned signal that tells the model "the previous context ends here", without it, windows that cross document boundaries would teach the model that the end of one Python file and the start of the next are part of the same sequence. The .reshape(-1, block_size) pattern is the other half of the trick: after concatenation you can randomly sample windows in constant time.

Why does GPT-2's baseline code perplexity often start close to its baseline English perplexity?

Because GPT-2's training corpus (WebText, Reddit-outbound pages scraped in 2019) contained substantial code from Stack Overflow, GitHub gists linked in discussions, and tutorial sites. That means baseline_code_ppl often lands within 2× of baseline_general_ppl, sometimes lower. This is why the Step 1 checker refuses to assert a direction, it would fail for reasons that aren't your bug. The real signal is the Step 4 delta: CPT on Python drops adapted_code_ppl by 30%+ because you've concentrated training on code and pushed the output distribution toward its sharper regions.

When would I use CPT versus a LoRA adapter?

CPT when you want the base weights themselves to shift, Code Llama, BloombergGPT, medical-specialised LLMs all went this route. It's more expressive (no rank bottleneck), but it's heavier (updating every parameter) and causes forgetting you have to actively manage via replay or adapter averaging. LoRA when you want adaptation without touching the base, cheaper, lets you ship multiple domain adapters against one frozen model, and caps forgetting mechanically because the base weights literally can't change. In practice frontier teams often do CPT first on a large domain corpus, then LoRA on top for per-customer fine-tunes.

Why cosine schedule with warmup and not flat LR?

Warmup (the first ~10 steps linearly ramping from 0 to cpt_lr) prevents the Adam second-moment estimates from exploding before they've seen enough gradients to be stable. Cosine decay afterward cools the LR smoothly to near-zero over the remaining steps, which matters because CPT runs are short, you don't have room for a long plateau, and the cosine tail gives the loss a cleaner final floor than a step decay. The 150-step budget here is deliberately tight; on a real production CPT run you'd use the same shape but stretched across 10,000+ steps.

What you'll build in this continued pre-training lab

Continued pre-training (DAPT) is the recipe behind every domain-specialised LLM of the last three years, Code Llama, BloombergGPT, Galactica, Med-PaLM, and the wave of vertical models that ship with "built on Llama 3, adapted on 200B domain tokens" in the spec sheet. It's also the technique most engineers get wrong first, because it looks like fine-tuning but behaves very differently. In roughly 45 minutes on a real NVIDIA GPU we provision, you'll take vanilla GPT-2, domain-adapt it to Python code with a 150-step CPT run, and measure both what you gained (code perplexity drops >30%) and what you lost (general-English perplexity rises, the empirical face of catastrophic forgetting). You'll walk away with a concrete tradeoff curve and defensible answers for four production choices: pure CPT, replay mixing, LoRA on frozen weights, or post-hoc model souping.

The substance is the CPT-specific hyperparameter geometry. You'll run AdamW at cpt_lr = 5e-5, roughly 8% of GPT-2's 6e-4 pretraining peak, because full-blast LR on already-converged weights blows up the loss in the first few steps, with a cosine schedule, ~10-step warmup, grad clip 1.0, and block size 256. You'll learn why documents are concatenated with eos_token_id separators into one long 1-D token stream (windows crossing document boundaries otherwise teach the model that the end of one Python file continues into the next), why a 98/2 train/val split on the code corpus is enough when both splits are well above 50k tokens, and why baseline_code_ppl often starts close to baseline_general_ppl on GPT-2, WebText in 2019 contained substantial Stack Overflow and GitHub content, so what CPT is really doing is sharpening a code-aware token predictor into a code-specialised one. The forgetting_ratio = adapted_general_ppl / baseline_general_ppl is the signal you'd track over the whole CPT schedule in production and stop before it crosses your retention floor.

Prerequisites: comfort with computing perplexity in PyTorch, at least one basic fine-tune under your belt (the fine-tune-llm-lora-jupyter lab is a fine warm-up), and knowing what a cosine schedule with warmup does. The sandbox preloads GPT-2, the tokenizer, codeparrot/codeparrot-clean, and wikitext-2-raw-v1 so streaming is local. Search-intent hooks: "continued pretraining LLM tutorial", "domain-adaptive pretraining DAPT", "catastrophic forgetting LLM", "Code Llama training recipe", "CPT vs LoRA for domain adaptation", the reflection and rubric explicitly walk through the production decision that sits behind each of those searches.