Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
The chat-template lesson showed how a grounded stale-key row becomes a token stream. The synthetic-data lesson showed how candidate rows get accepted or rejected. Supervised fine-tuning (SFT) is the training system that turns those accepted demonstrations into gradient updates on the replies you actually want.[1][2] InstructGPT used supervised demonstrations as that first post-training stage before preference optimization.[3]
Thread T102 asks whether an 18-day-old service-account key is still usable. The accepted answer is: open a rotation ticket, because keys older than 14 days need review. Loss can still fall while that behavior stays broken. Train on prompt tokens, leak T102 into the eval split, pack R550 so it attends to T102, or export the lowest-train-loss checkpoint, and you'll ship a fluent skip of the ticket.
A good SFT run doesn't start with a trainer call. It starts with a behavior target, a leakage-resistant split, a loss mask, a batch budget, checkpoints that can resume, and an evaluation rule for exporting the best artifact. First choose the learning objective. Then choose whether that objective updates every weight or a small adapter. Mixing those two decisions produces expensive runs that answer the wrong question.

What an SFT run must decide
Every serious SFT job has to answer the same questions. The T102 failures above are what happens when you skip one:
- What is the behavior target?
- Is SFT the right objective, or is the missing ingredient still domain pretraining?
- Are we updating all weights or only adapters?
- What examples and tokens contribute to training loss?
- What is the true example and supervised-token batch budget?
- Which held-out metric decides the best checkpoint?
- Can this stay on one GPU, or do we need Fully Sharded Data Parallel (FSDP) or Zero Redundancy Optimizer (ZeRO)?
Once you phrase the job that way, the trainer is one component: it applies the update. The split, mask, batch card, resume bundle, and export rule still sit outside it.
Separate objective from parameterization
An objective tells the model what signal to learn from. A parameterization tells the trainer which weights may move. They aren't interchangeable.
| Failure you observe | Objective to investigate | Why |
|---|---|---|
| Base model has weak exposure to domain language in unlabeled corpora | continued pretraining | next-token training on domain text adapts the language distribution before behavior training[4] |
| Model can read an access policy but doesn't follow the desired escalation procedure | SFT | prompt-completion demonstrations directly teach that response behavior[1] |
| Several acceptable answers need ranking by preference | preference training after establishing an SFT baseline | comparisons express relative preference rather than a single target answer[5] |
After choosing SFT, make a second decision:
| SFT parameterization | What moves during the same supervised objective | When to test it |
|---|---|---|
| Full fine-tuning | all trainable model weights | when memory permits and adapters may be too restrictive |
| LoRA | small low-rank adapter matrices; base weights stay frozen | when iteration speed, memory, or many task variants matter[6] |
| QLoRA | LoRA adapters while the frozen base is stored in 4-bit form | when the base model doesn't fit comfortably at higher precision[7] |
LoRA and QLoRA aren't alternatives to SFT. They can be the parameterization used for an SFT run. Continued pretraining is a different objective; it can also be parameter-efficient in an appropriate setup, but it still doesn't become SFT.
Full weights or adapters
Full fine-tuning
Full fine-tuning is the simplest conceptual update: every eligible parameter may change to reduce supervised response loss. Test it when:
- the model fits with its gradients and optimizer state
- one merged checkpoint is operationally simpler than serving adapters
- an adapter baseline underfits your held-out behavior metric
LoRA
LoRA is still SFT when trained on prompt-response rows. Use it when:
- iteration speed or training memory limits matter
- you need separate task or tenant variants over one base model
- you want a strong adapter baseline before paying for full updates
LoRA freezes the base model weights and trains low-rank adapter matrices inside selected layers, which reduces trainable parameters and memory compared with full fine-tuning.[6]
QLoRA
Use QLoRA when:
- LoRA is the right algorithmic choice
- the frozen base model itself is too large to keep in BF16/FP16 memory
- you still want adapter training instead of full-weight updates
QLoRA keeps the adapter-training idea but backpropagates through a frozen 4-bit quantized base model into LoRA adapters, which is why it can make larger models fit in less memory.[7] The comparison that matters is held-out behavior under an honest memory and compute budget. A cheap run that fails the task isn't a win.
Reject broken demonstrations before training
For this pipeline, store a prompt, a desired completion, and a group key for leakage-resistant splitting on every SFT row. A missing completion isn't harmless missing metadata: it's a training example with no answer to teach.
1rows = [
2 {"thread_id": "T102", "prompt": "Stale key", "completion": "Open a rotation ticket; keys older than 14 days need review."},
3 {"thread_id": "B550", "prompt": "Expired key", "completion": ""},
4 {"prompt": "Privileged role change", "completion": "Escalate privileged-role changes."},
5]
6
7required = {"thread_id", "prompt", "completion"}
8accepted, rejected = [], []
9for index, row in enumerate(rows):
10 missing = sorted(required - row.keys())
11 if missing:
12 rejected.append((index, f"missing {missing}"))
13 elif not row["completion"].strip():
14 rejected.append((index, "empty completion"))
15 else:
16 accepted.append(row)
17
18print("accepted_threads=", [row["thread_id"] for row in accepted])
19print("rejected=", rejected)1accepted_threads= ['T102']
2rejected= [(1, 'empty completion'), (2, "missing ['thread_id']")]Split by the unit that could leak
Before formatting rows, reserve evaluation examples that training can't imitate through near duplicates. For a policy assistant, several messages from one access incident or one policy document often share facts and phrasing. Randomly splitting individual messages can put one part of the same case in training and another in evaluation.
Group by the smallest deployment unit that should be unseen at evaluation time: incident thread, policy document, tenant, or time window. The tiny check below keeps every message from an incident on one side of the split.
1rows = [
2 {"case_id": "T102", "turn": 1, "answer": "Open a rotation ticket; keys older than 14 days need review."},
3 {"case_id": "T102", "turn": 2, "answer": "Approve access after the rotation ticket is filed."},
4 {"case_id": "R550", "turn": 1, "answer": "Escalate privileged-role changes."},
5 {"case_id": "P771", "turn": 1, "answer": "Cite the session-timeout policy."},
6]
7
8eval_cases = {"R550"}
9train = [row for row in rows if row["case_id"] not in eval_cases]
10evaluation = [row for row in rows if row["case_id"] in eval_cases]
11
12train_cases = {row["case_id"] for row in train}
13held_out_cases = {row["case_id"] for row in evaluation}
14assert train_cases.isdisjoint(held_out_cases)
15
16print("train_cases=", sorted(train_cases))
17print("eval_cases=", sorted(held_out_cases))
18print("case_overlap=", train_cases & held_out_cases)1train_cases= ['P771', 'T102']
2eval_cases= ['R550']
3case_overlap= set()Two turns from incident T102 have different wording. Can one train and the other evaluate if their token overlap is low?
Answer
No. They share the deployment unit that can leak facts and phrasing, so both turns stay in the same split. Group by incident, document, tenant, or time window before formatting and tokenization.
Data path: template, tokenize, label, pack
An SFT run usually performs these steps:
- read structured examples
- apply the model-specific chat template
- run tokenization
- label desired response tokens and mask the prompt/context tokens
- pack short examples with sequence-boundary handling, or pad them into batches
- feed the trainer
The instruction-tuning lesson already covered chat-template mechanics. The operational lesson here is that you version this whole path together. If you change the template or masking logic, you changed the training distribution and therefore the experiment.[8]
The label mask is easy to get wrong because the input still contains the system instruction, user request, and formatting control tokens. For response-only SFT, the desired answer tokens (including the turn terminator when the model must learn when to stop) contribute to loss. Prefix tokens provide context, not answer targets. The tiny example uses -100, the ignored label value used by PyTorch cross-entropy.
1IGNORE_INDEX = -100
2
3tokens = [
4 ("prefix", "<system>"),
5 ("prefix", "Follow access policy."),
6 ("prefix", "<user>"),
7 ("prefix", "Key T102 is 18 days old."),
8 ("prefix", "<assistant>"),
9 ("target", "Open a rotation ticket; keys older than 14 days need review."),
10 ("target", "<end_of_turn>"),
11]
12
13labels = [
14 token if span == "target" else IGNORE_INDEX
15 for span, token in tokens
16]
17
18supervised = [
19 token
20 for (_, token), label in zip(tokens, labels)
21 if label != IGNORE_INDEX
22]
23print("supervised_tokens=", supervised)
24print("masked_positions=", sum(label == IGNORE_INDEX for label in labels))1supervised_tokens= ['Open a rotation ticket; keys older than 14 days need review.', '<end_of_turn>']
2masked_positions= 5Don't confuse the label mask with the attention mask. The label mask decides which positions contribute to loss. The attention mask decides which earlier positions a token can read. Prompt tokens should remain visible as context even when their labels are -100; packed examples also need attention boundaries so one example can't read another.
Why bother masking easy prefix tokens? If a long repeated instruction is easy to predict, its small losses can dominate the average and hide poor answer learning:
1prefix_losses = [0.03, 0.04, 0.02, 0.05, 0.03]
2answer_losses = [2.20, 1.80]
3
4full_sequence_loss = sum(prefix_losses + answer_losses) / len(prefix_losses + answer_losses)
5response_only_loss = sum(answer_losses) / len(answer_losses)
6
7print(f"full_sequence_loss={full_sequence_loss:.3f}")
8print(f"response_only_loss={response_only_loss:.3f}")
9assert response_only_loss > full_sequence_loss1full_sequence_loss=0.596
2response_only_loss=2.000Those two numbers are the same token-level cross-entropy, averaged over different positions. For response-only SFT, the reported loss is the mean negative log-likelihood over the labeled set (here, the answer and stop tokens), not over the whole sequence:
is the trainable parameters, is the token at position , and is the prefix the model may attend to. In the toy row, has two positions and the mean is . PyTorch's cross-entropy with ignore_index=-100 already averages that way. Forget the mask, and the easy prefix tokens pull the average down to .
You rarely build this mask by hand. In Hugging Face TRL's SFTTrainer, prompt-completion datasets use completion-only loss by default. Conversational datasets can set assistant_only_loss=True; their chat template must expose assistant spans through generation markers. Current TRL can substitute supported training templates for known model families, but a custom template still needs verification.[1] Inspect a tokenized example before launching a long run: it should reveal exactly which answer and stop tokens receive labels.
Mask inspection must happen after truncation as well. Current TRL SFTConfig defaults to truncation_mode="keep_start": it keeps the beginning of the concatenated sequence and drops the end. If the prompt fills max_length, the completion (the only labeled span) disappears. TRL then drops examples whose labels are all -100, so you lose the row instead of training on a prompt-only sequence. That's still silent data loss. When packing is on, this truncation step is skipped and overflow is handled by the packing strategy instead; bfd still discards overflow tokens.[1]
The toy below is that keep_start cut: five prefix tokens fill max_length, so the ticket answer never appears. Reject the row or raise max_length; don't assume every accepted demonstration survived tokenization.
1IGNORE_INDEX = -100
2
3prompt_tokens = ["<system>", "policy", "<user>", "stale_key", "<assistant>"]
4answer_tokens = ["open_ticket", "<end_of_turn>"]
5max_length = 5
6
7kept_tokens = (prompt_tokens + answer_tokens)[:max_length]
8labels = [
9 token if position >= len(prompt_tokens) else IGNORE_INDEX
10 for position, token in enumerate(kept_tokens)
11]
12has_answer_label = any(label != IGNORE_INDEX for label in labels)
13
14print("kept_tokens=", kept_tokens)
15print("has_answer_label=", has_answer_label)
16print("decision=", "keep" if has_answer_label else "reject_or_increase_max_length")1kept_tokens= ['<system>', 'policy', '<user>', 'stale_key', '<assistant>']
2has_answer_label= False
3decision= reject_or_increase_max_lengthPack short examples while preserving their boundaries
Most SFT datasets are short replies. Padding each one to a fixed length wastes compute on padding tokens. Packing places several examples in a nearly full training block so more positions carry real tokens.
For independent instruction examples, a reply for case R550 shouldn't gain context from case T102 only because the loader put them in one block. A boundary-aware packed attention path records each segment, resets positions at each sequence boundary, and prevents that cross-example attention:
1segments = [
2 ["T102 prompt", "T102 response", "<eos>"],
3 ["R550 prompt", "R550 response", "<eos>"],
4]
5
6flat = [(sequence_id, token) for sequence_id, row in enumerate(segments) for token in row]
7position_ids = [position for row in segments for position, _ in enumerate(row)]
8
9def may_attend(query_index, key_index):
10 query_sequence, _ = flat[query_index]
11 key_sequence, _ = flat[key_index]
12 return key_index <= query_index and query_sequence == key_sequence
13
14r550_response = 4
15assert position_ids == [0, 1, 2, 0, 1, 2]
16assert not may_attend(r550_response, 1)
17print("position_ids=", position_ids)
18print("R550_response_sees_T102_response=", may_attend(r550_response, 1))1position_ids= [0, 1, 2, 0, 1, 2]
2R550_response_sees_T102_response= FalseReset position IDs are boundary metadata, not a universal attention mask. The attention implementation still has to honor those boundaries when it computes attention.
What a failed packed batch looks like. Walk one block of six tokens (T102 then R550) after an upgrade that keeps reset position_ids but drops segment isolation:
| Index | Token | position_id | Intended attend-to | Broken path (no segment mask) |
|---|---|---|---|---|
| 0 | T102 prompt | 0 | T102 only | T102 only |
| 1 | T102 response | 1 | T102 only | T102 only |
| 2 | T102 eos | 2 | T102 only | T102 only |
| 3 | R550 prompt | 0 | R550 only | T102 + R550 (cross-example leak) |
| 4 | R550 response | 1 | R550 only | T102 + R550 (R550 answer conditions on T102) |
| 5 | R550 eos | 2 | R550 only | T102 + R550 |
The same block can also fail via truncation then pack: if T102 is kept after a cut that left every answer label at -100, packing still fills the block with tokens, but that segment teaches no supervised response. Inspect both attention reachability and nonzero answer labels per segment before a long run.
Current TRL exposes packing=True and eval_packing. Its default best-fit-decreasing (bfd) strategy packs intact examples efficiently and truncates overflow sequences. bfd_split preserves overflow tokens by splitting long sequences before packing. wrapped concatenates everything into a stream and cuts mid-sequence; TRL documents that this can mix unrelated examples and break continuity. With bfd, TRL enables padding-free processing. That flattened path relies on FlashAttention 2 or 3 to honor sequence boundaries; without a compatible attention implementation, adjacent samples may contaminate each other.[1] The right check isn't "never pack." It's: choose an SFT-appropriate strategy, confirm the supported attention path, and inspect a packed batch after library upgrades.
Packing also changes what an epoch means. TRL logs num_tokens during the run. One epoch after packing is one pass over packed blocks, not one pass over original rows. If you set max_steps from an unpacked row count, you can overshoot the intended token budget and overfit without noticing.
A packed batch resets position IDs for R550, but R550's response can still attend to T102. Is the packing path correct?
Answer
No. Reset positions don't enforce attention isolation. The segment mask or padding-free attention metadata must block cross-example attention, and every packed segment must retain at least one non--100 answer label.
Hyperparameters are experiments, not promises
SFT begins from a pretrained checkpoint, so a useful configuration starts conservatively and earns expansion through held-out results. A library default isn't evidence that one value fits your dataset or parameterization.
Current TRL SFTConfig defaults learning_rate to 2e-5, turns gradient_checkpointing on, and enables bf16 when fp16 isn't set. Its PEFT guidance suggests about 1e-4 for adapters because only new parameters are being learned.[1] That bf16 default is already a precision policy. Record it with the run; the next chapter measures whether the arithmetic is safe.
| Knob | Baseline to test | What decides whether it survives |
|---|---|---|
| Learning rate | 2e-5 for full updates; about 1e-4 for adapters | a short sweep on the actual update surface and held-out task metrics |
| Passes over data | start with a small budget; watch num_tokens, not only epochs | widening train/eval gap or declining behavior metrics |
| Warmup and schedule | set explicitly; don't jump straight to peak LR | early instability and sweep results |
| Weight decay and clipping | log them with the run, even when disabled | stability and evaluation evidence, not inherited habit |
| Precision | the dtype SFTConfig actually enabled | held-out metric plus nonfinite-step count, not throughput alone |
Don't turn 2e-5 or 1e-4 into a rule. Record parameterization, trainable-parameter count, sequence length, effective token budget, data snapshot, precision, and evaluation result beside every candidate configuration.
Warmup, clipping, and decay aren't decoration. Warmup limits early updates while the run is most sensitive to its initial configuration; jumping immediately to a peak adapter rate of 1e-4 can make the first steps unnecessarily noisy. Gradient clipping doesn't fix bad data, but it can stop one pathological batch from damaging the checkpoint. Weight decay belongs in the recipe record even when you disable it, so the next sweep doesn't inherit a silent default.
Effective batch size is also a token budget
Training discussions get sloppy fast here. The batch that sits on one device isn't the same thing as the batch the optimizer sees before one update. For equal-length examples, the usual rule is:
1examples_per_update = per_device_batch * gradient_accumulation_steps * world_sizePacked response examples aren't equal length. This formula reports examples per optimizer update, but the number of supervised answer tokens can still change from update to update. Track both.
1per_device_batch = 4
2grad_accum = 8
3world_size = 4
4examples_per_update = per_device_batch * grad_accum * world_size
5
6supervised_tokens_per_microbatch_on_one_rank = [180, 212, 164, 220, 198, 204, 175, 207]
7supervised_tokens_per_update = sum(supervised_tokens_per_microbatch_on_one_rank) * world_size
8
9print(f"examples_per_update={examples_per_update}")
10print(f"supervised_tokens_per_update={supervised_tokens_per_update}")1examples_per_update=128
2supervised_tokens_per_update=6240Always calculate changed configurations instead of reasoning from one changed knob:
1def examples_per_update(per_device_batch, accumulation, world_size):
2 return per_device_batch * accumulation * world_size
3
4run_a = examples_per_update(4, 8, 4)
5run_b = examples_per_update(2, 16, 8)
6
7print("run_a_examples_per_update=", run_a)
8print("run_b_examples_per_update=", run_b)
9print("ratio_b_over_a=", run_b / run_a)
10assert run_b == 2 * run_a1run_a_examples_per_update= 128
2run_b_examples_per_update= 256
3ratio_b_over_a= 2.0Two habits follow from this:
- when someone reports batch size, ask for accumulation, world size, packing, and supervised tokens too
- compare learning-rate sweeps with similar token budgets or explain why they differ
What a resumable run must save
A real SFT resume bundle isn't only model_state_dict.
At minimum, retain:
- model weights
- optimizer state
- scheduler state
- global step / epoch
- RNG state and sampler/data position when repeatable continuation matters
- tokenizer + chat-template version
- training config, data manifest, and evaluation manifest
- best metric so far
Why this matters:
- optimizer state controls the next update
- scheduler state controls learning rate at resume
- sampler state prevents quietly repeating or skipping shuffled training rows
- tokenizer/template version controls what the model thinks each token means
Exact bit-for-bit replay may still depend on hardware, kernels, and determinism settings. The resume bundle should at least prevent avoidable changes to data order, learning-rate state, and model selection. torchtune and DeepSpeed both document resume flows explicitly instead of treating checkpointing as a trivial file write.[9][10]

A minimal manifest should make missing resume state obvious:
1required = {
2 "model_state",
3 "optimizer_state",
4 "scheduler_state",
5 "global_step",
6 "rng_state",
7 "sampler_state",
8 "tokenizer_version",
9 "chat_template_version",
10 "data_manifest",
11 "eval_manifest",
12 "best_metric",
13}
14
15resume_bundle = {
16 "model_state": "weights/step_600.safetensors",
17 "optimizer_state": "optimizer/step_600.pt",
18 "scheduler_state": "scheduler/step_600.pt",
19 "global_step": 600,
20 "rng_state": "rng/step_600.pt",
21 "sampler_state": {"epoch": 1, "batches_consumed": 120},
22 "tokenizer_version": "policy-sft-tokenizer-v3",
23 "chat_template_version": "llama3-access-policy-v2",
24 "data_manifest": "data/sft_manifest_2026-05-20.json",
25 "eval_manifest": "eval/access_policy_behavior_v4.json",
26 "best_metric": {"name": "policy_pass_rate", "value": 0.97},
27}
28
29missing = sorted(required - resume_bundle.keys())
30print("resume_ready=", not missing)
31print("best_metric=", resume_bundle["best_metric"])1resume_ready= True
2best_metric= {'name': 'policy_pass_rate', 'value': 0.97}A resumed run reloads model weights and global step but not optimizer, scheduler, sampler, or tokenizer-template versions. Can its next metrics be compared as uninterrupted continuation?
Answer
No. The next update may use different momentum, learning rate, data position, randomness, or token formatting. Treat it as a new run unless the complete resume manifest is restored and validated.
Choosing the best checkpoint
The best checkpoint is the one that optimizes the product metric you care about on held-out data.
Bad rules:
- lowest train loss
- latest step
- biggest file
Better evidence:
- highest exact or structured task success where the behavior is verifiable
- best human preference rate on a blinded held-out set when quality is subjective
- judge-assisted scoring only after calibrating the judge against human or verifiable decisions
- lowest validation loss only when that truly matches the task
If the task is structured access-policy replies, check policy compliance and schema validity before style preference. Loss is useful for training diagnostics, but it doesn't define deployment quality.
The export rule for this run is a gate, then a ranking, then a tie-break:

1checkpoints = [
2 {"step": 200, "val_loss": 1.91, "policy_pass_rate": 0.93, "format_pass_rate": 0.99},
3 {"step": 400, "val_loss": 1.77, "policy_pass_rate": 0.91, "format_pass_rate": 1.00},
4 {"step": 600, "val_loss": 1.79, "policy_pass_rate": 0.97, "format_pass_rate": 0.98},
5]
6
7eligible = [row for row in checkpoints if row["format_pass_rate"] >= 0.98]
8best = max(eligible, key=lambda row: (row["policy_pass_rate"], -row["val_loss"]))
9
10print("best_step=", best["step"])
11print("best_policy_pass_rate=", best["policy_pass_rate"])
12print("lowest_loss_step=", min(checkpoints, key=lambda row: row["val_loss"])["step"])1best_step= 600
2best_policy_pass_rate= 0.97
3lowest_loss_step= 400Single GPU first, then scale out
Don't start thread T102 on 8 GPUs. First prove the recipe on one GPU:
- one GPU
- tiny eval slice
- frequent checkpoints
- verified template/masking
- one clean metric
Only then widen world size or sequence length.
Signs that one GPU is still fine
- the model and activations fit
- throughput is acceptable
- you're still debugging correctness
- labels are the main uncertainty, not compute budget
Signs that you need FSDP or ZeRO
- full-model weights and optimizer state don't fit
- activation memory collapses the run at useful context lengths
- checkpointing and accumulation still leave the job too slow
- you need larger world size to hit the schedule
That bridge matters. FSDP and ZeRO aren't decorations on top of a stable recipe. They let the same recipe continue when memory stops fitting on one device by sharding model states across workers.[11][12] Precision is the other silent scaler: if SFTConfig already turned on bf16, you have a mixed-precision policy before you add a second GPU.
Minimal operational skeleton
The exact library can vary, but the shape is stable:
1dataset -> template -> tokenizer -> collator/mask -> trainer
2 -> periodic eval -> checkpoint save -> best-checkpoint exportTRL, torchtune, and the Alignment Handbook all expose variations of this same loop.[1][13][14]
For a TRL prompt-completion dataset, a configuration skeleton makes the decisions visible. Configure model_with_lora_adapters with a supported FlashAttention 2 or 3 implementation before enabling bfd packing:
1from trl import SFTConfig, SFTTrainer
2
3args = SFTConfig(
4 output_dir="runs/access-policy-sft-lora",
5 learning_rate=1e-4, # adapter baseline to evaluate, not a universal rule
6 max_length=1024,
7 packing=True,
8 packing_strategy="bfd", # keep examples intact; bfd also enables padding-free
9 completion_only_loss=True, # train on the completion field, not the prompt
10 eval_strategy="steps",
11 eval_steps=100,
12 save_steps=100,
13)
14
15trainer = SFTTrainer(
16 model=model_with_lora_adapters,
17 args=args,
18 train_dataset=train_rows, # {"prompt": ..., "completion": ...}
19 eval_dataset=held_out_rows,
20 processing_class=tokenizer,
21)This fragment deliberately leaves model loading, adapter construction, and the data manifest outside the snippet. They must be versioned inputs to a real run, not invisible defaults.
Common pitfalls
Confusing the objective with the update surface
- Symptom: A team says "we tried LoRA instead of SFT," or switches to full updates without changing data or evaluation.
- Cause: LoRA and full fine-tuning describe which parameters move; SFT describes the supervised loss objective.
- Fix: Write down the failed behavior and desired objective first. Then compare adapter and full-weight SFT only if update capacity or memory is the actual uncertainty.
Evaluating examples that share a case with training
- Symptom: Held-out score is excellent, but the model fails on new tenants, policies, or access incidents.
- Cause: Rows were randomly split while near-duplicate turns from the same incident or document appeared on both sides.
- Fix: Group the split by incident thread, policy source, tenant, or later time window, then re-run selection.
Reporting micro-batch as if it were the real batch
- Symptom: Two runs both claim "batch size 4," but convergence looks different and nobody can reconcile the results.
- Cause: The reported number ignored accumulation or world size, so the optimizer was seeing different effective batches.
- Fix: Publish
per_device_batch,gradient_accumulation_steps,world_size, packed/padded mode, examples per update, and supervised tokens per update.
Saving weights without resume state
- Symptom: Resume starts, but the learning rate jumps, checkpoint selection resets, or the run diverges from prior curves.
- Cause: The checkpoint kept weights but dropped optimizer state, scheduler position, or best-metric record.
- Fix: Treat resume state as part of the checkpoint contract: weights, optimizer, scheduler, global step, RNG/sampler state, template/tokenizer version, data and eval manifests, and best metric.
Using a packing path that crosses example boundaries
- Symptom: Loss falls smoothly, but the model learns transitions that never occur in real conversations.
- Cause: A packing strategy or attention implementation let tokens from one independent example condition another.
- Fix: For TRL SFT, prefer
bfdwith a supported FlashAttention 2 or 3 implementation and inspect packed batches. Usebfd_splitdeliberately when preserving overflow matters; don't silently substitutewrappedor an unsupported attention path.
Selecting the best checkpoint by train loss
- Symptom: Exported checkpoint has the prettiest curve but underperforms on held-out policy tasks.
- Cause: Train loss rewards fitting seen data, not solving the downstream task the product cares about.
- Fix: Export the checkpoint that wins on held-out verifiable metrics or calibrated human-preference evaluation that matches deployment.