n4nAI

How perplexity is used during LLM pretraining

A practical guide to how perplexity drives LLM pretraining decisions — from loss curves to checkpoint selection, with code and common pitfalls.

n4n Team6 min read1,217 words

Audio narration

Coming soon — every post will get a voice note here.

Perplexity is the single number most pretraining runs live or die by. It’s the exponential of cross-entropy loss, yes, but in practice it’s the compass teams use to decide whether a run is converging, whether to extend training, and which checkpoint to ship. This guide walks through how perplexity is computed, monitored, and acted on during pretraining — with the operational details that papers skip.

What perplexity actually measures

Perplexity in llm pretraining quantifies how surprised the model is by the next token. Formally, for a sequence of tokens $x_1, …, x_T$:

$$\text{PPL} = \exp\left(-\frac{1}{T}\sum_{t=1}^T \log P(x_t | x_{<t})\right)$$

Lower is better. A perplexity of 10 means the model is as uncertain as if it were choosing uniformly from 10 options at each step. During pretraining, you track this on both training and validation sets. The gap between them tells you about memorization versus generalization.

In code, the computation is straightforward:

import torch
import torch.nn.functional as F

def compute_perplexity(logits: torch.Tensor, targets: torch.Tensor, ignore_index: int = -100) -> float:
    """
    logits: (batch, seq_len, vocab_size)
    targets: (batch, seq_len)
    """
    logits = logits.view(-1, logits.size(-1))
    targets = targets.view(-1)
    loss = F.cross_entropy(logits, targets, ignore_index=ignore_index, reduction='mean')
    return torch.exp(loss).item()

The ignore_index handles padding tokens. Most frameworks (PyTorch, JAX, TensorFlow) have this built in, but writing it yourself once clarifies what’s actually being averaged.

Where perplexity lives in the training loop

Perplexity isn’t a separate metric you compute after the fact — it’s the direct transformation of your training objective. Every optimizer step minimizes cross-entropy; perplexity is just that loss made interpretable.

A typical logging setup:

from torch.utils.tensorboard import SummaryWriter

writer = SummaryWriter(log_dir="runs/pretrain")

def log_step(step: int, train_loss: float, val_loss: float, lr: float, tokens_seen: int):
    train_ppl = torch.exp(torch.tensor(train_loss)).item()
    val_ppl = torch.exp(torch.tensor(val_loss)).item()
    
    writer.add_scalar("perplexity/train", train_ppl, step)
    writer.add_scalar("perplexity/val", val_ppl, step)
    writer.add_scalar("loss/train", train_loss, step)
    writer.add_scalar("loss/val", val_loss, step)
    writer.add_scalar("lr", lr, step)
    writer.add_scalar("tokens_seen", tokens_seen, step)
    
    # Console output every N steps
    if step % 100 == 0:
        print(f"step {step:7d} | train_ppl {train_ppl:.2f} | val_ppl {val_ppl:.2f} | lr {lr:.2e}")

Log both loss and perplexity. Loss is what the optimizer sees; perplexity is what humans reason about. The tokens_seen counter matters more than step count when comparing runs with different batch sizes or sequence lengths.

Validation perplexity: the signal you actually trust

Training perplexity will keep dropping. Validation perplexity is the one that plateaus, then rises — that inflection point is your primary stopping signal.

Run validation every 500–2000 steps depending on dataset size. Use a fixed validation set (not a rotating sample) so curves are comparable across runs. A typical validation loop:

@torch.no_grad()
def evaluate(model, val_loader, device, max_batches: int = None):
    model.eval()
    total_loss = 0.0
    total_tokens = 0
    
    for i, batch in enumerate(val_loader):
        if max_batches and i >= max_batches:
            break
            
        input_ids = batch["input_ids"].to(device)
        labels = batch["labels"].to(device)
        
        logits = model(input_ids)
        loss = F.cross_entropy(
            logits.view(-1, logits.size(-1)),
            labels.view(-1),
            ignore_index=-100,
            reduction='sum'
        )
        
        total_loss += loss.item()
        total_tokens += (labels != -100).sum().item()
    
    model.train()
    avg_loss = total_loss / total_tokens
    return torch.exp(torch.tensor(avg_loss)).item()

Key details: reduction='sum' plus manual token counting gives you token-level perplexity, not batch-level. This matters when your last batch is partial or when sequences have variable padding.

Checkpoint selection: don’t just take the last one

The checkpoint with the lowest validation perplexity is not always the one you want. Two practical considerations:

1. Eval harness performance — Run a lightweight eval suite (MMLU, Hellaswag, GSM8K, your downstream tasks) on the top-5 checkpoints by validation perplexity. The correlation between val ppl and downstream metrics is strong but not perfect, especially early in training.

2. Stability window — If validation perplexity bounces between 12.1 and 12.3 for 20k steps, any checkpoint in that window is fine. Don’t over-optimize the third decimal. Pick the one with the best eval scores or the earliest one (saves compute on downstream fine-tuning).

def select_checkpoint(checkpoint_dir: str, eval_results: dict) -> str:
    """
    eval_results: {checkpoint_path: {task: score, ...}, ...}
    Returns path to best checkpoint.
    """
    # Weighted composite: 60% average downstream, 40% val_ppl (inverted)
    best_score = -1
    best_path = None
    
    for path, metrics in eval_results.items():
        downstream_avg = sum(v for k, v in metrics.items() if k != "val_ppl") / max(1, len(metrics) - 1)
        val_ppl = metrics.get("val_ppl", float('inf'))
        
        # Normalize roughly: downstream 0-1, val_ppl ~10-30
        score = 0.6 * downstream_avg + 0.4 * (1 - min(val_ppl, 30) / 30)
        
        if score > best_score:
            best_score = score
            best_path = path
    
    return best_path

Common pitfalls

Tokenizer mismatch between train and eval

If your training tokenizer drops whitespace differently than your evaluation tokenizer, perplexity numbers become incomparable. Lock the tokenizer artifact (vocab + merges + config) and version it with the model. Compute validation perplexity with the exact same tokenization pipeline as training.

Sequence length effects

Perplexity depends on context length. A model trained on 2048-token sequences will report different perplexity on 4096-token evaluation — even if the model supports longer context via RoPE scaling. Always evaluate at the training sequence length for apples-to-apples comparison. Report the sequence length alongside every perplexity number.

The “bits per byte” trap

Some papers report bits per byte (BPB) instead of perplexity. The conversion is $\text{BPB} = \log_2(\text{PPL}) / \text{tokens_per_byte}$. For English text with typical tokenizers, tokens_per_byte ≈ 0.25–0.35. If you’re comparing to a paper that reports BPB, convert your perplexity the same way — don’t guess the ratio.

def ppl_to_bpb(ppl: float, tokens_per_byte: float = 0.3) -> float:
    import math
    return math.log2(ppl) / tokens_per_byte

Validation set contamination

If your validation set appears in your training data (common with Common Crawl deduplication failures), validation perplexity will be artificially low and won’t correlate with generalization. Deduplicate aggressively: minhash, exact n-gram overlap, and embedding-based near-dedup. Keep a held-out “test” set that never touches any training pipeline.

Perplexity curves and what they tell you

Healthy curve

  • Train and val perplexity drop together for the first 60–80% of tokens
  • Val perplexity plateaus, then train continues dropping (overfitting begins)
  • Gap between train/val stabilizes at ~10–20% relative

Diverging curve (trouble)

  • Val perplexity rises while train keeps dropping → overfitting, stop training
  • Both plateau early → underfitting, increase model size or training tokens
  • Val perplexity oscillates wildly → learning rate too high, batch size too small, or data quality issues

The “double descent” mirage

Sometimes val perplexity dips, rises slightly, then dips again. This is usually noise from a small validation set or learning rate schedule artifacts. Don’t read meaning into it unless you see it reproduce across seeds.

Scaling laws and perplexity prediction

If you’re planning a pretraining run, you can estimate target perplexity from scaling laws. The Chinchilla formula (Hoffmann et al., 2022) gives:

$$L(N, D) = E + \frac{A}{N^\alpha} + \frac{B}{D^\beta}$$

Where $N$ is non-embedding parameters, $D$ is training tokens. For a given compute budget $C \approx 6ND$, optimal allocation is $N \propto C^{0.5}$, $D \propto C^{0.5}$.

In practice, fit this on your own small runs (10M, 100M, 1B params) before committing to a large run. The constants $A, B, \alpha, \beta$ vary by tokenizer, data mix, and architecture details.

def predict_perplexity(params: int, tokens: int, coeffs: dict) -> float:
    """
    coeffs: {'E': ..., 'A': ..., 'B': ..., 'alpha': ..., 'beta': ...}
    Returns predicted validation perplexity.
    """
    import math
    N = params
    D = tokens
    loss = (coeffs['E'] + 
            coeffs['A'] / (N ** coeffs['alpha']) + 
            coeffs['B'] / (D ** coeffs['beta']))
    return math.exp(loss)

Distributed training: aggregating perplexity correctly

With data parallelism across multiple GPUs/nodes, each rank computes loss on its local batch. The global perplexity is not the average of per-rank perplexities — it’s the exponential of the global average loss.

import torch.distributed as dist

def all_reduce_perplexity(local_loss: float, local_tokens: int, device) -> float:
    """
    Correctly aggregates loss across ranks, then exponentiates.
    """
    loss_tensor = torch.tensor([local_loss * local_tokens], device=device)
    tokens_tensor = torch.tensor([local_tokens], device=device)
    
    dist.all_reduce(loss_tensor, op=dist.ReduceOp.SUM)
    dist.all_reduce(tokens_tensor, op=dist.ReduceOp.SUM)
    
    global_avg_loss = loss_tensor.item() / tokens_tensor.item()
    return torch.exp(torch.tensor(global_avg_loss)).item()

Do this in your validation loop. For training logs, you can approximate with per-rank perplexity if you’re just watching trends, but validation numbers must be globally correct.

When perplexity lies

Perplexity in llm pretraining correlates with downstream performance, but the correlation breaks down in specific regimes:

  • Code vs. natural language — A model can have great perplexity on Python but fail at reasoning tasks. Evaluate on both.
  • Long-context behavior — Standard perplexity on 2k tokens says nothing about 32k-token retrieval. Add needle-in-haystack evals.
  • Instruction following — Base model perplexity doesn’t predict chat performance after RLHF. The pretraining checkpoint is just the starting point.

Track perplexity religiously during pretraining, but gate your release decision on the eval suite that matches your product requirements.

Operational checklist for a pretraining run

  • Validation set fixed, deduplicated, versioned
  • Tokenizer artifact locked and hashed
  • Validation every 1000 steps (adjust for dataset size)
  • Global perplexity aggregation across ranks
  • Top-5 checkpoints by val ppl saved, not just last/best
  • Lightweight eval suite run on each saved checkpoint
  • Sequence length logged with every perplexity number
  • Learning rate schedule logged alongside perplexity
  • Tokens-seen counter as primary x-axis, not steps

Perplexity is the dashboard metric of pretraining. It won’t tell you if your model is useful — only your task-specific evals can do that — but it tells you whether the pretraining run itself is healthy, when to stop, and which checkpoint to carry forward. Treat it as a necessary but insufficient signal, and you’ll avoid the most expensive class of pretraining failures.

Tagsperplexitypretrainingtraining

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All perplexity & language model evaluation metrics posts →