Deep Learning · Module 20 of 26 · 8 concepts · about 17 min

Language Modeling

Learning a distribution over text by predicting withheld tokens.

At work: How chatbots and writing assistants generate text, and the settings that make them creative or reliable.

Autoregressive and masked language modelling

What it is

Autoregressive language models factor a sequence probability into next-token predictions: each token is predicted from the tokens to its left. Training shifts the target sequence by one position and applies a causal mask; generation repeatedly samples or selects one token, appends it to context, and runs the next step. Masked language models instead hide selected tokens and use context on both sides, which is useful for representation learning but does not directly define left-to-right generation. Both commonly use cross-entropy, yet their visible context and inference procedures are fundamentally different.

Why it matters

It explains why a decoder-only model generates left to right and why likelihood, perplexity, sampling temperature, and top-p are tied to the same token distribution. Sampling settings are a decision policy over logits: temperature rescales confidence, while top-p truncates the candidate set; neither makes an unsupported answer more truthful or a weakly trained distribution more knowledgeable.

In the real world: Chatbots predict the next word; search models fill in missing words to understand meaning.

Visual example

Deep learning

The same token sequence supplies both the input and, shifted by one, the target.

Next-token training, one batch

  1. token IDs[I, love, good, coffee]
  2. inputs = ids[:, :-1][I, love, good]
  3. targets = ids[:, 1:][love, good, coffee]
  4. cross-entropyone prediction scored per position

Every position is a training example, which is why unlabelled text is such a rich supervision signal.

import torch
import torch.nn as nn

ids = torch.tensor([[1045, 2293, 2204, 4157]])   # (batch, seq)
inputs, targets = ids[:, :-1], ids[:, 1:]        # shift by one

logits = model(inputs)                            # (batch, seq-1, vocab)
loss = nn.CrossEntropyLoss()(
    logits.reshape(-1, logits.size(-1)),
    targets.reshape(-1),
)

Using low training perplexity as proof of helpfulness or safety. It measures predictive fit to text, not factuality, instruction following, fairness, or downstream task value.

The next-token objective, precisely

What it is

A language model defines a probability distribution over sequences, factored by the chain rule into conditional next-token probabilities: P(x1..xn) = Π P(xi | x1..xi-1). Training maximises the log-likelihood of real text under that factorisation, which is exactly minimising cross-entropy between the model's predicted distribution and the one-hot true next token. There is no separate loss per task; there is one loss, applied at every position of every sequence.

Why it matters

The surprising part is how much this teaches. To predict the next token well across a large, diverse corpus, a model has to implicitly acquire syntax (to close a bracket), factual associations (to finish the capital of France is), arithmetic (to complete a sum), discourse structure (to keep a pronoun's referent straight), and style (to match a register). None of these were supervised targets; each is instrumentally useful for reducing next-token loss, so gradient descent builds whatever internal machinery serves them. This is why one cheap, label-free objective on enough text yields a model useful for tasks nobody enumerated in advance.

In the real world: The simple training goal behind every writing assistant and chatbot.

import torch

# The factorisation, computed explicitly for one sentence.
ids = torch.tensor([[1045, 2293, 2204, 4157]])
logits = model(ids[:, :-1])                      # (1, 3, vocab)
log_probs = logits.log_softmax(dim=-1)

targets = ids[:, 1:]                              # (1, 3)
chosen = log_probs.gather(-1, targets.unsqueeze(-1)).squeeze(-1)

sequence_log_prob = chosen.sum()                  # log P(x1..xn)
mean_nll = -chosen.mean()                         # the training loss

Reading the objective as 'the model learns to copy its training data'. It learns a *distribution*; the highest-probability continuation of a held-out prefix is usually text that appeared nowhere in training, because the loss rewards generalising the pattern rather than memorising the string.

Teacher forcing and exposure bias

What it is

During training every position is conditioned on the *ground-truth* prefix, not on what the model itself would have predicted. That is teacher forcing, and it is what lets a whole sequence be scored in one parallel forward pass. At inference there is no ground truth: the model conditions on its own previous outputs, one token at a time. So training and generation run the model in two genuinely different regimes — parallel on correct history, sequential on self-generated history.

Why it matters

The mismatch is called exposure bias, and it is worth naming because it explains a real failure shape. A model has never been trained on a prefix containing its own mistake, so once one appears the input is off the distribution the model was fitted to, and errors compound rather than being corrected. It is also why training loss can look excellent while long generations drift, repeat, or collapse: the loss was never measured in the regime that generation actually runs in. Practically, it is the reason long-form generation quality has to be evaluated by generating, not by scoring.

In the real world: Why a text generator trained on perfect examples can drift off-topic when writing on its own.

Visual example

Train/inference gap

The same model, two conditioning regimes. Only the right-hand one happens in production.

training: conditioned on ground truth
context given to the modelpredicts
t=1Ilove
t=2I lovegood
t=3I love goodcoffee
teacher forcing → autoregressive generation
generation: conditioned on its own output
context given to the modelpredicts
t=1Ilove
t=2I lovegooey
t=3I love gooey??? off-distribution

Why the gap compounds

training: every prefix is human-written and in-distribution, at every position

generation: one bad token makes every later prefix partly self-authored

consequence: there is no gradient signal teaching recovery from your own mistake

Why this matters

Naming the two regimes separately is what makes a whole class of generation pathologies predictable instead of mysterious — drift, loops and topic collapse are all failures in the untrained regime.

Common misconception

Wrong idea: the model is trained to generate text.

Correct idea: it is trained to score text one position ahead. Generation is a decoding procedure we bolt on afterwards, running the model on inputs it was never fitted to.

At t=3 the model is reading a prefix no training example ever contained, so its calibration there is untested.

import torch

# Training: one parallel pass over the true prefix.
logits = model(inputs)                # every position sees only correct history

# Generation: a sequential loop over the model's own history.
@torch.no_grad()
def generate(model, prompt_ids, max_new_tokens=50):
    model.eval()
    ids = prompt_ids
    for _ in range(max_new_tokens):
        logits = model(ids)[:, -1, :]         # only the last position matters
        next_id = logits.argmax(dim=-1, keepdim=True)
        ids = torch.cat([ids, next_id], dim=1)   # its own output becomes input
    return ids

Benchmarking a generative model only by held-out perplexity, then being surprised that it rambles. Perplexity is measured under teacher forcing; nothing in that number describes behaviour after the model has conditioned on fifty of its own tokens.

Perplexity

What it is

Perplexity is the exponentiated mean negative log-likelihood per token: PPL = exp(mean cross-entropy in nats). Its unit is interpretable — a perplexity of k means the model was, on average, as uncertain as if it had been choosing uniformly among k equally likely tokens. A perfect model scores 1; a model that assigns uniform probability over a 50,000-token vocabulary scores 50,000.

Why it matters

It is the standard language-modelling metric because it is a monotone function of the training loss, so it compares models without inventing a task, and its scale is intuitive in a way that raw nats are not. But it is comparable only across models sharing a tokenizer and an evaluation corpus. A model whose tokenizer splits text more finely gets more, easier predictions and a flattering perplexity for identical text — which is why cross-tokenizer perplexity comparisons are close to meaningless and bits-per-byte is sometimes reported instead.

In the real world: A quick score to compare two language models on a company's own documents.

Visual example

exp(loss)

Perplexity is exp(loss), so a small late improvement in loss is a large improvement in perplexity.

0123450102030405060mean cross-entropy (nats/token)perplexityPPL 1 = perfectloss 1.4 → PPL 4loss 3.5 → PPL 33

The worked example, position by position

t=1: p(true)=0.50 → -log = 0.693

t=2: p(true)=0.25 → -log = 1.386

t=3: p(true)=0.40 → -log = 0.916

t=4: p(true)=0.10 → -log = 2.303

mean = 1.3246 nats → PPL = exp(1.3246) = 3.76

Why this matters

The exponential is why a loss drop from 2.10 to 2.05 is worth chasing late in training: it is a 5% cut in effective branching factor, invisible on a loss axis and obvious on this one.

import math
import torch

# A worked example: four positions, four probabilities for the true token.
p_true = torch.tensor([0.5, 0.25, 0.4, 0.1])

nll = -p_true.log()                 # tensor([0.693, 1.386, 0.916, 2.303])
mean_nll = nll.mean().item()        # 1.3246 nats per token
ppl = math.exp(mean_nll)            # 3.761

# Sanity check the interpretation: uniform over 4 tokens would give exactly 4.
math.exp(-math.log(0.25))           # 4.0

Comparing your model's perplexity to a published number from a different tokenizer or corpus and concluding you are ahead or behind. Change the tokenizer and the same model's perplexity moves by a large factor without its behaviour changing at all.

Sampling strategies at generation time

What it is

The model emits a distribution; a decoding strategy turns it into one token. Greedy decoding always takes the argmax. Temperature T divides the logits before softmax: T < 1 sharpens the distribution toward the mode, T > 1 flattens it. Top-k truncates to the k highest-probability tokens and renormalises. Top-p (nucleus) truncates to the smallest set whose cumulative probability reaches p, so the candidate set is large where the model is genuinely uncertain and small where it is confident.

Why it matters

Every one of these trades coherence against diversity, and the right point on that trade is a product decision rather than a modelling one. Greedy and low temperature give repeatable, safe, often flat output, and are what you want for extraction, classification or code completion. Higher temperature and a wide nucleus give variety at the cost of more off-topic or incoherent choices, and suit brainstorming or creative drafting. Top-p is usually preferred to top-k because a fixed k is too permissive after a confident prediction and too restrictive after an uncertain one — it ignores the shape of the distribution it is truncating.

In the real world: Low randomness for reliable support answers; more randomness for marketing copy ideas.

Visual example

Decoding

One distribution over five candidate next tokens, reshaped four ways. The probabilities change; the model does not.

Sharpened

(T<1 concentrates mass on the mode)

Flattened

(T>1 triples p(purple))

top_p=0.9

drops the tail entirely, keeps the head's relative odds

P(next token) after 'the coffee was'
T=1.0T=0.5T=1.5top-p 0.9
hot0.40.580.310.43
good0.30.330.270.32
cold0.180.070.210.19
bitter0.10.020.150.06
purple0.0200.060

greedy

always 'hot' - equivalent to T→0

Temperature rescales the tail; top-p removes it

The mechanism

temperature: logits / T, then softmax. Never zeroes anything out

top-k: keep k highest, renormalise. Fixed width, ignores distribution shape

top-p: keep the smallest head summing to p. Adaptive width

T=1.5 makes 'purple' three times as likely - that is the diversity you paid for

Why this matters

The two knobs do different things, which is why they are used together: top-p removes tokens the model considers implausible, and temperature then decides how boldly to choose among the ones it kept.

Common misconception

Wrong idea: temperature controls how creative the model is.

Correct idea: it controls how much probability mass is moved toward the tail of a fixed distribution. Creativity is whatever happens to be in that tail — including nonsense.

import torch

def sample_next(logits, temperature=1.0, top_p=None, top_k=None):
    logits = logits / max(temperature, 1e-6)
    if top_k is not None:
        kth = logits.topk(top_k, dim=-1).values[..., -1:]
        logits = logits.masked_fill(logits < kth, float('-inf'))
    if top_p is not None:
        sorted_logits, idx = logits.sort(dim=-1, descending=True)
        cumulative = sorted_logits.softmax(dim=-1).cumsum(dim=-1)
        drop = cumulative - sorted_logits.softmax(dim=-1) > top_p
        sorted_logits = sorted_logits.masked_fill(drop, float('-inf'))
        logits = torch.empty_like(logits).scatter_(-1, idx, sorted_logits)
    return torch.multinomial(logits.softmax(dim=-1), num_samples=1)

Tuning temperature to fix factual errors. Temperature reshapes a distribution the model already has; if the correct token has low probability at T = 1, no temperature makes it likely without also promoting every other low-probability token.

Masked language modelling as a different objective

What it is

A masked language model replaces a fraction of tokens (classically 15%) with a [MASK] placeholder and trains the model to recover them using context on *both* sides. There is no causal mask: every position sees the whole sequence. This makes each position's representation bidirectionally contextualised, which is excellent for understanding tasks, and makes left-to-right generation impossible, because the model was never trained to continue from a prefix alone.

Why it matters

The contrast with next-token prediction is the clarifying point, and it is a contrast of *supervision density* against *representation quality*. Next-token prediction supervises every position of every sequence, so it extracts maximal signal per token of data and yields a usable generator; masked modelling supervises only the masked 15%, so it is roughly six times less data-efficient per token, but each representation it learns has seen both directions. That is precisely why encoder models dominate classification and retrieval while decoder-only models dominate generation — and why the field's shift toward generative use turned next-token prediction into the default pretraining objective.

In the real world: How models used in search and document classification are trained.

Visual example

Objectives compared

Two objectives over the same sentence. Note the supervised positions column — that ratio is the data-efficiency argument.

Bidirectional

(better per-token representations)

Six times more signal

(per token of training data)

Next-token vs masked prediction
visible contextsupervised positionscan generate?
autoregressivetokens to the left onlyall nyes, left to right
maskedall tokens except the masks~0.15nno

Neither is better in general; they optimise different things

Why this matters

It is easy to read masked modelling as an obsolete predecessor. It is not — it is a different trade, and it still wins for encoders whose job is to represent a fixed input rather than extend it.

import torch

def mask_tokens(ids, mask_id, vocab_size, prob=0.15, generator=None):
    labels = ids.clone()
    selected = torch.rand(ids.shape, generator=generator) < prob
    labels[~selected] = -100              # CrossEntropyLoss ignores -100
    ids = ids.clone()
    ids[selected] = mask_id               # the model must recover these
    return ids, labels

masked_ids, labels = mask_tokens(ids, mask_id=103, vocab_size=30_522)
logits = encoder(masked_ids)              # no causal mask: full bidirectional context
loss = torch.nn.CrossEntropyLoss()(logits.reshape(-1, 30_522), labels.reshape(-1))

Prompting a masked language model like a generative one and reading the output as a continuation. Without a causal objective it has no notion of 'what comes next'; it will fill blanks, not extend text.

Context length and its cost

What it is

A model's context length is the maximum number of tokens it can attend over in one forward pass, fixed by its positional scheme and its training. Within that window, standard self-attention materialises an n × n score matrix per head per layer, so both memory and compute for the attention step grow with the square of n while the feed-forward cost grows only linearly. Beyond the window, a model has no mechanism at all — tokens outside it are not down-weighted, they are absent.

Why it matters

The quadratic term is what makes long context an engineering problem rather than a configuration value. Doubling the window quadruples attention cost, and at long lengths the attention term overtakes everything else in the budget, so context length is one of the most expensive dials in a deployment. It also changes system design: a retrieval step that selects 2,000 relevant tokens is often cheaper *and* more accurate than pushing 100,000 tokens through the window, because attention over mostly-irrelevant context both costs more and dilutes the signal.

In the real world: Longer context lets a model read a whole contract but costs more per request.

# Where the budget actually goes as context grows.
d_model, n_layers, n_heads = 768, 12, 12

for n in (512, 2048, 8192, 32_768):
    attn_scores = n_layers * n_heads * n * n          # the n^2 term
    ffn = n_layers * n * d_model * 4 * d_model        # the linear term
    share = attn_scores / (attn_scores + ffn)
    print(f'n={n:>6}  attention share of cost = {share:6.1%}')

# n=   512  attention share of cost =   2.5%
# n= 32768  attention share of cost =  62.0%

Assuming a long context window means the model uses all of it well. Attention is a soft average; with tens of thousands of tokens competing, a relevant detail in the middle can be effectively drowned out even though it is technically inside the window.

Degenerate repetition and very low temperature

What it is

Greedy decoding — and temperature close to zero, which converges to it — is deterministic, and deterministic decoding of a language model tends to fall into loops. Once the model emits a phrase, that phrase is now in the context, which makes the tokens that followed it before slightly more likely, which makes the loop slightly more likely, and the feedback runs away. The output is not random noise; it is a highly confident, perfectly fluent repetition of the same clause forever.

Why it matters

It is worth naming as a trap because the instinct when output looks unstable is to *lower* temperature, which makes this failure mode worse rather than better. There is also a real distinction people get wrong: temperature = 0 is not a valid softmax temperature — it divides by zero — so libraries special-case it into argmax. That means 'temperature 0' and 'temperature 0.01' are implemented by different code paths, and a request that works at 0 may behave differently at 0.01 for reasons nothing in the API documents. When you need determinism, ask for greedy decoding explicitly rather than approximating it with a tiny temperature.

In the real world: Why a chatbot sometimes repeats the same sentence, and the settings that fix it.

import torch

# Greedy decoding is deterministic, so a loop cannot break itself.
@torch.no_grad()
def greedy(model, ids, steps=40):
    for _ in range(steps):
        next_id = model(ids)[:, -1, :].argmax(dim=-1, keepdim=True)
        ids = torch.cat([ids, next_id], dim=1)
    return ids

# 'The results are promising. The results are promising. The results are...'

# A repetition penalty divides the logits of tokens already present.
def with_repetition_penalty(logits, ids, penalty=1.15):
    seen = torch.unique(ids)
    logits = logits.clone()
    logits[..., seen] = torch.where(
        logits[..., seen] > 0,
        logits[..., seen] / penalty,
        logits[..., seen] * penalty,
    )
    return logits

Setting temperature = 0 for 'reproducible' output and getting repetition loops, then raising temperature to escape them and losing the determinism you wanted. The two goals conflict; a repetition penalty or a minimum-length constraint is the tool that addresses the loop without reintroducing randomness.