Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
A base model can write a polite paragraph about authentication and still fail the next-token test that matters for adaptation. Give it the start of an internal runbook, If AUTH_TIMEOUT fires, the client should, and watch it wander into generic retry advice that never mentions your jitter budget, idempotency key, or on-call owner. The chat format looks fine. The domain text doesn't.
The JAX chapter treated a training step as a state transition you can replay: parameters, optimizer state, RNG keys, and metrics all cross an explicit boundary. Continued pretraining (CPT) needs that same contract. Pin the starting checkpoint, the domain mix, the schedule, and two eval lanes, then you can tell whether the run actually moved the model.
CPT keeps the causal next-token objective you used in the scratch GPT lab. What changes is the text distribution: more compute lands on your domain's language, document structure, and recurring entities.[1] The weights shift because the data shifted, not because you switched losses.
Three tools get mixed up here:
- retrieval-augmented generation (RAG) leaves the weights frozen and injects documents at inference time. Use it when facts change often or must be cited.
- Supervised Fine-Tuning (SFT) changes behavior, format, and tone with curated prompt-response pairs. Use it when the model already reads the domain but answers in the wrong shape.
- Continued pretraining changes the weights with the same next-token objective so the model better fits unlabeled domain text. Use it when raw docs and runbooks still confuse the base model.
Don't rank those tools from labels alone. In Ovadia et al.'s knowledge-injection experiments, RAG beat unsupervised fine-tuning on MMLU and current-events questions, while repeated paraphrases of the same fact helped fine-tuning on a new-fact task.[2] That's evidence for factual-update failures, not a universal ranking. CPT earns an experiment when the model poorly fits the domain text distribution, not when you only need fresher facts or a different answer format.

AUTH_TIMEOUT string is not one training problem. CPT asks the model to continue unlabeled runbook text. SFT asks it to answer in a chat interface. If the raw continuation is already broken, labeled Q&A won't fix the missing language.
The architecture stays put. The loss stays causal language modeling (mean per-token negative log-likelihood) over a sequence of tokens :
Here is the prefix context, and is the model's predicted probability for token . Exponentiating this token-averaged loss yields the sequence perplexity .
starts from the base checkpoint, not from random initialization. Later SFT still uses a next-token loss, but the examples are prompt-response pairs and the loss is usually applied to the response tokens. That's a different supervision shape, even when the math looks related.

If more than one failure shows up, start with the earliest one in the stack. A model that can't continue AUTH_TIMEOUT runbooks is not ready for assistant-style SFT on those same codes.
Diagnose the shift before spending training compute
A useful direct signal is held-out raw-text loss and its exponentiated form, perplexity. Record the base model's value on domain documents, then test whether CPT lowers it while a general-text control stays inside budget. A base model scoring worse on domain than general text is only a screening clue, because corpora can have different inherent predictability. It doesn't prove CPT will improve product tasks.
Fragmentation during tokenization is a weaker diagnostic. A fixed tokenizer may spend more tokens on unfamiliar identifiers, which raises context cost, but CPT doesn't change that tokenizer unless you redesign embeddings and retrain compatible weights. Use fertility as a corpus inspection signal, not a promise that continued pretraining will shorten tokenized documents.
The tiny longest-match tokenizer below is not a production BPE. It makes the inspection concrete: timeout is in the vocabulary, AUTH_TIMEOUT is not, so the error code falls apart into characters.
1VOCAB = [
2 "timeout",
3 "retry",
4 "client",
5 "should",
6 "with",
7 "backoff",
8 "AUTH",
9 "fires",
10 "the",
11 " ",
12]
13pieces = sorted(VOCAB, key=len, reverse=True)
14
15def tokenize(text: str) -> list[str]:
16 tokens: list[str] = []
17 index = 0
18 while index < len(text):
19 matched = next((piece for piece in pieces if text.startswith(piece, index)), None)
20 if matched is None:
21 tokens.append(text[index])
22 index += 1
23 else:
24 tokens.append(matched)
25 index += len(matched)
26 return tokens
27
28samples = {
29 "timeout": "timeout",
30 "AUTH_TIMEOUT": "AUTH_TIMEOUT",
31}
32
33print("slice n_pieces pieces")
34for name, text in samples.items():
35 tokens = tokenize(text)
36 print(f"{name:<13}{len(tokens):>8} {tokens}")1slice n_pieces pieces
2timeout 1 ['timeout']
3AUTH_TIMEOUT 9 ['AUTH', '_', 'T', 'I', 'M', 'E', 'O', 'U', 'T']Vocabulary extension vs. fixed tokenizer
High token fertility raises context consumption and inference latency. If domain identifiers consistently fragment into character-level pieces, you face a structural choice:
- Keep the fixed tokenizer (standard practice): Retain the base vocabulary and embedding matrix . The model adapts its attention weights and representations to domain n-grams without disrupting existing token coordinates. This is the default in models like Code Llama because it avoids initialization instability.[3]
- Extend the vocabulary: Add domain-specific tokens (such as frequent API methods or specialized identifiers), expanding the embedding matrix and the output language-modeling head .
If you do extend the vocabulary, don't initialize new rows with random Gaussian noise. Random vectors produce large gradient spikes that can destabilize pretrained attention layers during early steps. Instead, initialize each new token embedding with the mean of the embeddings of its constituent subwords from the base tokenizer. Every new token also needs enough occurrences in the adaptation corpus for those rows to move; a handful of AUTH_TIMEOUT hits won't do it. That's why small domain runs usually keep the base tokenizer intact.
Don't split a validation corpus by shuffled token chunks. Near-duplicates, revisions of the same OpenAPI page, or two runbook copies from the same source can land in both training and validation and make CPT look stronger than those splits justify. Assign a provenance or deduplication group to one split before tokenization.
1import hashlib
2
3documents = [
4 {"group": "auth-docs-v3", "text": "AUTH_TIMEOUT means the token exchange exceeded 2s"},
5 {"group": "auth-docs-v3", "text": "AUTH_REJECTED means the client secret is invalid"},
6 {"group": "webhook-runbooks", "text": "exhausted retries require owner acknowledgement"},
7 {"group": "webhook-runbooks", "text": "duplicate deliveries must be idempotent"},
8 {"group": "sdk-notes", "text": "SDK v4 retries AUTH_TIMEOUT with jitter"},
9 {"group": "payments-api", "text": "capture calls must include idempotency-key"},
10]
11
12def split_for_group(group: str) -> str:
13 bucket = int(hashlib.sha256(group.encode()).hexdigest(), 16) % 4
14 return "validation" if bucket == 0 else "train"
15
16splits = {"train": [], "validation": []}
17for doc in documents:
18 splits[split_for_group(doc["group"])].append(doc)
19
20train_groups = {doc["group"] for doc in splits["train"]}
21validation_groups = {doc["group"] for doc in splits["validation"]}
22assert train_groups.isdisjoint(validation_groups)
23
24print(f"train_groups={sorted(train_groups)}")
25print(f"validation_groups={sorted(validation_groups)}")
26print("group leakage: none")1train_groups=['auth-docs-v3', 'sdk-notes']
2validation_groups=['payments-api', 'webhook-runbooks']
3group leakage: noneThose two measurements still leave an open question: if you do start training, how hard should you push the weights?
The two failure dynamics: forgetting and underfitting
Resuming on a new distribution pulls the weights in two directions, and a good run balances them.
Catastrophic forgetting is loss of previously learned ability as parameters shift to absorb new data. Push too hard on AUTH_TIMEOUT runbooks and broad validation quality can regress.
Underfitting is the opposite failure: train too gently and the domain leaves no real impression. The model still can't continue the runbook.
Two major controls for this balance are the learning-rate schedule and the data mixture. Run length and corpus quality matter too.
Learning rate re-warming and re-decaying
A base checkpoint often finished its original cosine schedule at a very small learning rate. If you resume at that floor, adaptation may be inefficient. If you resume too aggressively, general-text loss may regress.
Ibrahim et al. (2024) study a related decoder-only continual-pretraining setting: updating a model with large new datasets after its original cosine schedule ended.[4] For 405M models under English-to-English and English-to-German shifts, and a 10B-parameter model under the English-to-English shift, learning-rate re-warming, re-decaying, and replay matched retraining baselines on their reported losses and evaluation averages while spending less compute. Their experiment is evidence for testing this recipe, not permission to copy one peak learning rate into every domain run.
Code Llama is a useful second data point. Those models start from Llama 2 and continue pretraining on 500B code-heavy tokens (1T for the 70B). The authors kept the original Llama 2 peak learning rates (for example at 7B and 13B) rather than shrinking them the way people often do for fine-tuning.[3] That agrees with Ibrahim's warning not to sit at the cosine floor. It still isn't a universal peak to paste into your schedule.
One subtlety from Ibrahim et al.: re-warming can itself increase loss on old data. Sweep the peak and measure both lanes instead of assuming adaptation is free. The paper also explores schedules that aren't tied to one fixed token budget.
The snippet below only shows the shape: climb from a small floor to a peak, then cosine-decay back. The peak is an order-of-magnitude fixture from Ibrahim's published cosine max, not a hyperparameter you should inherit.
1import math
2
3def rewarm_redecay(step: int, total_steps: int, warmup_steps: int, peak: float, floor: float) -> float:
4 if step < warmup_steps:
5 return floor + (peak - floor) * (step + 1) / warmup_steps
6 progress = (step - warmup_steps) / max(1, total_steps - warmup_steps - 1)
7 cosine = 0.5 * (1.0 + math.cos(math.pi * progress))
8 return floor + (peak - floor) * cosine
9
10total_steps = 1000
11warmup_steps = 50
12peak = 3e-4 # sweep this; do not inherit it blindly
13floor = 3e-5
14
15for step in [0, 49, 50, 250, 999]:
16 print(f"step={step:>3} lr={rewarm_redecay(step, total_steps, warmup_steps, peak, floor):.2e}")1step= 0 lr=3.54e-05
2step= 49 lr=3.00e-04
3step= 50 lr=3.00e-04
4step=250 lr=2.71e-04
5step=999 lr=3.00e-05Replay: keep prior-data signal in the mix
The second knob is replay: mix a fraction of previous or representative general-purpose data back into the incoming domain corpus. It provides training signal on broad text while the domain stream shifts the model, so it's a practical candidate for limiting regression.
The effective training objective becomes a weighted expectation across data distributions:
where is the replay ratio.
How much replay? Treat it as a sweep, not a standard percentage. In Ibrahim et al.'s headline comparison, the chosen mixes use 5% replay for the SlimPajama update and 25% replay for the larger English-to-German shift.[4] Code Llama's 500B code stage is 85% code, 8% natural language related to code, and 7% general natural language.[3] Llama 3 later branched a code expert from the main pretraining run and continued on a 1T-token mix that was mostly (>85%) code, following a similar recipe.[5] Those values belong to those datasets and compute budgets. With a fixed token budget, replay also replaces some new-domain tokens, so it can reduce adaptation opportunity while controlling general regression.
1total_tokens = 2_000_000
2
3print("replay_ratio domain_tokens replay_tokens total_tokens")
4for replay_ratio in [0.00, 0.05, 0.25]:
5 replay_tokens = int(total_tokens * replay_ratio)
6 domain_tokens = total_tokens - replay_tokens
7 assert domain_tokens + replay_tokens == total_tokens
8 print(f"{replay_ratio:>11.0%}{domain_tokens:>15,}{replay_tokens:>15,}{total_tokens:>14,}")1replay_ratio domain_tokens replay_tokens total_tokens
2 0% 2,000,000 0 2,000,000
3 5% 1,900,000 100,000 2,000,000
4 25% 1,500,000 500,000 2,000,000Your continued-pretraining run resumes from the base checkpoint at its final tiny learning rate and uses 100% domain text. Domain perplexity barely moves. After you raise the re-warm peak, domain perplexity improves but general-text loss regresses. Which two sweeps should you run?
Answer
Sweep a moderate re-warm peak followed by re-decay: inheriting the old floor can underfit, while an aggressive peak can damage broad behavior. Sweep replay ratios too, then select against held-out domain and general metrics. Replay is a regression-control candidate, not the explanation for the original underfitting.
When continued pretraining is the right tool
Reach for continued pretraining when the domain has its own language that the base model under-serves:
- internal API docs and error catalogs (
AUTH_TIMEOUT,AUTH_REJECTED) - on-call runbooks and incident notes
- SDK guides with domain-specific method names
- long compliance or protocol documents
- codebases whose APIs and identifiers barely appeared in public pretraining
The trigger isn't "the product team wants custom behavior." The model needs more exposure to the domain's text distribution before post-training behavior shaping makes sense.
Good signals
| Signal | Why it points to continued pretraining |
|---|---|
| Model misreads domain terminology | It lacks token-distribution familiarity, not response style alone |
| Long domain documents feel unnatural to the model | The base corpus underrepresented this text type |
| Raw completions are weak even before instruction formatting | The issue appears before chat behavior enters the picture |
| You have lots of domain text but few high-quality prompt-response labels | Continued pretraining can exploit unlabeled corpora |
Bad signals
| Signal | Better tool |
|---|---|
| Model knows the facts but answers in the wrong format | SFT |
| You need fresh, frequently changing, or citable facts | RAG |
| Model needs one task-specific classifier head | supervised fine-tuning with a classifier head |
| Model is mostly correct but chooses the wrong safe vs unsafe answer | preference optimization |
A practical split for the AUTH_TIMEOUT assistant: test raw domain-text continuation and prompt-response behavior separately. If the model can't continue the runbook or OpenAPI description, that points to continued pretraining. If raw continuation is competent but assistant behavior is weak, that points more directly to SFT.
The 2020 "Don't Stop Pretraining" paper made a related distinction in masked-language-model experiments with RoBERTa:[1]
- DAPT (domain-adaptive pretraining): keep training on large unlabeled domain text such as API docs or runbooks
- TAPT (task-adaptive pretraining): continue on the task's own unlabeled inputs, even when the corpus is smaller
The decision remains useful for decoder-only LLM projects, but don't silently transfer RoBERTa's quantitative gains to a causal base model. You still have to measure whether more exposure to the target text distribution improves your model and downstream task.
Data for continued pretraining
The same discipline from large-scale pretraining still applies:
- filter low-quality text
- deduplicate aggressively
- remove benchmarks and eval leakage
- scrub PII and secrets from runbooks and traces
- keep provenance and usage rights for every corpus slice
The corpus can be narrower and more targeted. Domain data can also be more sensitive than public pretraining text, so provenance, access control, and removal procedures are product requirements, not cleanup tasks.
Gate the corpus before tokenization
Keep a manifest that records whether a source may be trained on, whether it contains unresolved sensitive content, and whether it's reserved for evaluation. A high-quality domain document that fails one of these gates doesn't belong in the training stream.
1sources = [
2 {"name": "public-api-docs", "tokens": 800_000, "licensed": True, "pii_scrubbed": True, "eval_only": False},
3 {"name": "oncall-notes", "tokens": 120_000, "licensed": True, "pii_scrubbed": False, "eval_only": False},
4 {"name": "heldout-probes", "tokens": 25_000, "licensed": True, "pii_scrubbed": True, "eval_only": True},
5 {"name": "vendor-export", "tokens": 300_000, "licensed": False, "pii_scrubbed": True, "eval_only": False},
6]
7
8accepted = [
9 row for row in sources
10 if row["licensed"] and row["pii_scrubbed"] and not row["eval_only"]
11]
12rejected = [row["name"] for row in sources if row not in accepted]
13
14print(f"accepted={[row['name'] for row in accepted]}")
15print(f"training_tokens={sum(row['tokens'] for row in accepted):,}")
16print(f"rejected={rejected}")1accepted=['public-api-docs']
2training_tokens=800,000
3rejected=['oncall-notes', 'heldout-probes', 'vendor-export']oncall-notes is licensed but still contains unresolved PII, while heldout-probes is clean and licensed. Which source can enter continued-pretraining blocks?
Answer
Neither. Unresolved PII blocks oncall-notes, and the evaluation-only flag keeps heldout-probes out of training. Corpus quality never overrides rights, privacy, or evaluation-isolation gates.
Keep evaluation text out of training
For a small exact-overlap gate, normalize text and hash it before building token blocks. Production pipelines also need near-duplicate detection, because formatting changes and partial copies will evade exact hashes.
1import hashlib
2
3def fingerprint(text: str) -> str:
4 normalized = " ".join(text.lower().split())
5 return hashlib.sha256(normalized.encode()).hexdigest()
6
7heldout = [
8 "AUTH_TIMEOUT: token exchange exceeded 2s. Retry with jitter.",
9 "Webhook retries above 8 require owner acknowledgement.",
10]
11candidate_training = [
12 "SDK v4 retry notes for AUTH_REJECTED.",
13 " auth_timeout: TOKEN exchange exceeded 2s. retry with jitter. ",
14 "Idempotency-key requirements for capture calls.",
15]
16
17heldout_hashes = {fingerprint(text) for text in heldout}
18clean_training = [
19 text for text in candidate_training
20 if fingerprint(text) not in heldout_hashes
21]
22
23print(f"removed={len(candidate_training) - len(clean_training)}")
24print(f"kept={len(clean_training)}")
25assert all(fingerprint(text) not in heldout_hashes for text in clean_training)1removed=1
2kept=2Mixing strategy
Don't train on 100% domain text just because you have it. Teams usually mix:
- a high-quality domain slice
- a smaller replay slice of general text
That replay is one guardrail against forgetting. The exact ratio is empirical: define candidate ratios, hold total training tokens fixed, and select with domain-gain and broad-regression metrics. If the model forgets too much general language while specializing, the run overshot.
BloombergGPT is a useful contrast, not replay evidence: it was trained from scratch on 51.27% financial and 48.73% public tokens, and reports strong financial performance while remaining competitive on general-purpose benchmarks.[6] It shows that corpus composition should be explicit. It doesn't identify the right CPT replay ratio for your checkpoint.
Pack blocks and preserve the mixture
CPT uses the same causal objective as base pretraining. A common loader recipe joins document token sequences with end-of-document markers and emits full blocks. The separator marks a boundary, but it doesn't prevent cross-document attention by itself. As the data-pipeline chapter explained, choose explicitly between an ordinary causal mask and a document-isolated block-diagonal mask. Small integer token sequences make separator placement inspectable.
1EOS = 0
2block_size = 6
3documents = [[11, 12, 13], [21, 22], [31, 32, 33, 34]]
4
5stream = []
6for document in documents:
7 stream.extend(document + [EOS])
8
9blocks = [
10 stream[start:start + block_size]
11 for start in range(0, len(stream) - block_size + 1, block_size)
12]
13
14print(f"stream={stream}")
15print(f"blocks={blocks}")
16assert all(len(block) == block_size for block in blocks)
17assert EOS in blocks[0]1stream=[11, 12, 13, 0, 21, 22, 0, 31, 32, 33, 34, 0]
2blocks=[[11, 12, 13, 0, 21, 22], [0, 31, 32, 33, 34, 0]]This small example drops an incomplete final block instead of padding it. Production loaders need an explicit remainder policy.
Once domain and replay streams are packed, make mixture selection explicit and auditable. Here each twenty-block training window uses a seeded shuffle with the requested replay count.
1import random
2
3def make_window(domain_blocks: list[str], replay_blocks: list[str], replay_ratio: float, size: int) -> list[str]:
4 if not 0.0 <= replay_ratio <= 1.0:
5 raise ValueError("replay_ratio must be between 0 and 1")
6 replay_count = round(size * replay_ratio)
7 domain_count = size - replay_count
8 if len(domain_blocks) < domain_count or len(replay_blocks) < replay_count:
9 raise ValueError("not enough packed blocks for requested window")
10 chosen = domain_blocks[:domain_count] + replay_blocks[:replay_count]
11 random.Random(7).shuffle(chosen)
12 return chosen
13
14domain_blocks = [f"domain-{index}" for index in range(20)]
15replay_blocks = [f"general-{index}" for index in range(20)]
16window = make_window(domain_blocks, replay_blocks, replay_ratio=0.25, size=20)
17
18domain_count = sum(item.startswith("domain") for item in window)
19replay_count = sum(item.startswith("general") for item in window)
20print(f"domain_blocks={domain_count} replay_blocks={replay_count}")
21print(f"first_five={window[:5]}")
22assert (domain_count, replay_count) == (15, 5)1domain_blocks=15 replay_blocks=5
2first_five=['general-2', 'general-0', 'domain-11', 'general-3', 'domain-7']Evaluation: domain gain without lying to yourself
Continued pretraining needs two evaluation lanes at the same time.
Lane 1: domain gain
Measure:
- domain validation perplexity
- retrieval or classification tasks in the domain
- generation quality on held-out domain documents
- downstream task lift after later SFT
Lane 2: general regression
Measure:
- a small broad-language validation slice
- a lightweight general benchmark set
- free-form generations outside the target domain
If you only watch domain gain, you can accidentally produce a model that sounds like one AUTH_TIMEOUT runbook and forgot how to write broadly coherent language.
Evaluate loss in comparable token units. Perplexity is exp(mean negative log-likelihood), so aggregate token-level loss before exponentiating; don't average document perplexities and call the result a corpus metric.
1import math
2
3base = {
4 "domain": {"negative_log_likelihood": 840.0, "tokens": 240},
5 "general": {"negative_log_likelihood": 540.0, "tokens": 200},
6}
7adapted = {
8 "domain": {"negative_log_likelihood": 720.0, "tokens": 240},
9 "general": {"negative_log_likelihood": 548.0, "tokens": 200},
10}
11
12def perplexity(metrics: dict[str, float]) -> float:
13 return math.exp(metrics["negative_log_likelihood"] / metrics["tokens"])
14
15print("lane base_ppl adapted_ppl delta")
16for lane in ["domain", "general"]:
17 base_ppl = perplexity(base[lane])
18 adapted_ppl = perplexity(adapted[lane])
19 print(f"{lane:<8}{base_ppl:>9.2f}{adapted_ppl:>13.2f}{adapted_ppl - base_ppl:>7.2f}")1lane base_ppl adapted_ppl delta
2domain 33.12 20.09 -13.03
3general 14.88 15.49 0.61Runnable checkpoint ledger
The simplest useful artifact is a checkpoint ledger. It doesn't train a model; it shows how to choose between checkpoints after a continued-pretraining sweep. Domain perplexity can improve while general text gets worse, so the chosen checkpoint needs to pass both lanes. Use general regression as a hard gate. Among survivors, rank downstream probe accuracy first and use domain perplexity as a tie-breaker. That keeps the policy visible instead of hiding trade-offs inside an arbitrary weighted score.
1checkpoints = [
2 {"name": "base", "domain_ppl": 42.0, "general_ppl": 19.2, "probe_acc": 0.62},
3 {"name": "cpt-1k", "domain_ppl": 31.5, "general_ppl": 19.5, "probe_acc": 0.68},
4 {"name": "cpt-4k", "domain_ppl": 27.9, "general_ppl": 20.1, "probe_acc": 0.72},
5 {"name": "cpt-12k", "domain_ppl": 25.8, "general_ppl": 23.9, "probe_acc": 0.71},
6]
7
8base = checkpoints[0]
9max_general_regression = 1.5
10
11print("checkpoint domain_gain general_regression probe_acc keep")
12best = None
13best_rank = None
14
15for row in checkpoints:
16 domain_gain = base["domain_ppl"] - row["domain_ppl"]
17 general_regression = row["general_ppl"] - base["general_ppl"]
18 keep = general_regression <= max_general_regression
19 rank = (row["probe_acc"], -row["domain_ppl"])
20
21 if keep and (best_rank is None or rank > best_rank):
22 best = row
23 best_rank = rank
24
25 print(
26 f"{row['name']:<10}"
27 f"{domain_gain:>11.1f}"
28 f"{general_regression:>20.1f}"
29 f"{row['probe_acc']:>11.2f}"
30 f" {'yes' if keep else 'no'}"
31 )
32
33print(f"chosen={best['name']}")
34print("reason=best downstream probe, then domain perplexity, inside general-regression budget")1checkpoint domain_gain general_regression probe_acc keep
2base 0.0 0.0 0.62 yes
3cpt-1k 10.5 0.3 0.68 yes
4cpt-4k 14.1 0.9 0.72 yes
5cpt-12k 16.2 4.7 0.71 no
6chosen=cpt-4k
7reason=best downstream probe, then domain perplexity, inside general-regression budget
cpt-4k wins on downstream probe accuracy.
cpt-12k has the best domain perplexity but exceeds the allowed general-regression budget. cpt-4k passes the gate and has the best downstream probe accuracy among survivors. Which checkpoint wins?
Answer
Choose cpt-4k. General regression is a hard gate, so cpt-12k is ineligible. Among eligible checkpoints, downstream probe accuracy ranks first and domain perplexity only breaks ties.
Stopping rules
Because continued pretraining keeps the same objective, it can feel deceptively safe. It isn't safe by default.
Good stopping cues:
- domain validation loss flattens
- downstream gains after a probe SFT stop improving
- general regressions start to outweigh domain benefits
Bad stopping cues:
- "we still have more domain text"
- "loss is still going down a little"
More steps aren't a free lunch once the domain shift is already absorbed.
Where it fits relative to LoRA and SFT
Compare the choices by asking what you want to change.
| Goal | Best first tool |
|---|---|
| Inject fresh or citable facts without retraining | RAG |
| Teach new domain language patterns | continued pretraining |
| Teach chat or task format | SFT |
| Run a behavior update without full-weight training | SFT with LoRA / QLoRA adapters |
| Choose between multiple acceptable responses | DPO or RLHF |
LoRA and QLoRA are parameter-efficient implementation choices; QLoRA also stores the frozen base model in quantized form.[7] They don't determine what supervision teaches. An adapter can be trained with a next-token domain-text objective or with prompt-response SFT. First choose objective from the failure mode, then choose full-weight or parameter-efficient training from budget and deployment constraints.
A strong training stack often looks like:
- base model
- continued pretraining on domain corpus
- SFT on curated prompt-response data
- preference optimization if needed
Not every product needs every stage. Choose the stage that matches the failure you observe. Code Llama is one shipped version of that stack: continued pretraining on code, then instruction data.[3] Llama 3's code expert followed the same idea, then used the expert to collect better annotations rather than treating CPT as the product surface.[5]
Common pitfalls
Using continued pretraining to fix assistant tone
-
Symptom: the model still formats answers badly after a long domain-text run.
-
Cause: the issue was interface behavior, not domain language exposure.
-
Fix: move to SFT sooner.
Over-specializing on one corpus
-
Symptom: domain completions improve, but the model becomes narrow or brittle elsewhere.
-
Cause: no replay mixture, or too many adaptation steps.
-
Fix: keep a general-text regression lane and stop earlier.
Skipping the downstream check
-
Symptom: domain perplexity improves, but the final task model barely benefits.
-
Cause: the adaptation run optimized text fit that did not transfer to the product task.
-
Fix: probe the adapted checkpoint with a small downstream SFT instead of judging only by perplexity.