Inside a GPT-Style Language Model. An illustrated engineering guide from tokens to instruction tuning.

Reading aidHow to use this book

The quickest route through a language model is to follow one tensor. Chapter 2 turns text into a tensor of hidden vectors. Chapter 3 lets those vectors exchange information without looking into the future. Chapter 4 wraps attention in a complete decoder. Chapter 5 teaches the decoder with next-token loss. Chapters 6 and 7 adapt the same base model to bounded classification and instruction following.

Chapter map for Reading aid How to use this book: What you will build; Conventions.
Mermaid chapter map. Reading aid How to use this book connects What you will build, Conventions.

Each chapter has four layers:

  1. a plain-language mechanism;
  2. dimensions and equations;
  3. a compact implementation; and
  4. tests and application controls.

Read the mechanism first. Trace every tensor shape next. Run the code only after the contract is clear. The build checks at the end of each chapter are designed to expose gaps that fluent code walkthroughs often hide.

The banking thread uses a fictional credit-document assistant. It deliberately separates tasks that a language model can assist with from tasks that should remain deterministic or human-controlled. The appendix assembles those boundaries into one governed reference architecture.

What you will build

By the final chapter you will have the working parts of a small GPT-style decoder:

  • a byte-pair-compatible text and batching pipeline;
  • token and position representations;
  • vectorised multi-head causal attention;
  • a pre-normalisation transformer block;
  • next-token training and decoding;
  • a fixed-label classifier; and
  • an instruction-tuning data and evaluation path.

The objective is not to compete with a frontier model. It is to make each interface visible enough to inspect, test and govern.

The guide progresses from text and tokens through causal attention, decoder blocks, training and adaptation to a governed application.
Reading map. Each new capability introduces a corresponding test and control surface.

Conventions

  • Tensor shapes use [B,T,D] for batch, sequence length and model width.
  • H denotes attention heads and D_h the width of one head.
  • Dimensions shown for GPT-2 Small are historical reference values, not a recommendation for a new production model.
  • Console output uses text code fences. Python fences are intended to parse as Python unless labelled as pseudocode.
  • Monetary values and company names in examples are fictional.
  • “Confidence” is reserved for a defined, validated quantity. A raw softmax maximum is called a score until calibration has been demonstrated.
  • “Evidence” means a resolvable source or observable control record, not hidden model reasoning.
  • On narrow screens, wide diagrams preserve their label size and scroll horizontally; captions and alternative text carry the same content.

Build layerChapter 1: Next-token prediction and its limits

A GPT-style language model performs a narrow operation with unusual range: given a sequence of tokens, it assigns a probability to the token that may come next. It repeats that operation to extend the sequence. A paragraph, a translation, a Python function and an answer to a question can all be represented as continuations of text.

Chapter map for Build layer Chapter 1: Next-token prediction and its limits: From a prefix to a probability distribution; One objective across a sequence; What next-token prediction can teach; Measuring a capability; Locating language models within AI.
Mermaid chapter map. Build layer Chapter 1: Next-token prediction and its limits connects From a prefix to a probability distribution, One objective across a sequence, What next-token prediction can teach, Measuring a capability, Locating language models within AI.

The operation is easier to state than to explain. Repeated prediction can produce grammatical prose, useful code and answers that follow instructions. A precise account must distinguish what the model has learnt from behaviour that merely appears competent or confident. That distinction matters during the build and matters even more when model output enters a business process.

GPT-3 made the scale of this approach conspicuous. The largest model reported by Brown and peers in 2020 had 175 billion trainable parameters. Every model in that study was trained for 300 billion tokens and used a context window of 2,048 tokens.1 Those figures are historical specifications, not a recipe for our build. They establish that a causal language-modelling objective can be applied at great scale. We will use the same underlying ideas in a small model whose components can be inspected on an ordinary machine.

This book therefore treats a language model as an artefact to be constructed, measured and tested. We begin with the behaviour visible at its boundary: predicting one token from the tokens already present.

From a prefix to a probability distribution

A model does not receive words as indivisible objects. A tokenizer first maps text to a sequence of integer token identifiers. A token may be a word, part of a word, punctuation or a byte-level fragment, depending on the tokenizer. The model processes those identifiers and produces one score, called a logit, for every token in its vocabulary. A softmax operation converts the logits into non-negative probabilities that sum to one.

For a token sequence t1,t2,,tnt_1, t_2, \ldots, t_n, the model estimates

P(tnt1,t2,,tn1). P(t_n \mid t_1, t_2, \ldots, t_{n-1}).

The notation says that the probability assigned to the next token is conditional on the available prefix. During generation, a decoding rule selects a token from that distribution, appends it to the prefix, and runs the model again. During pretraining, the token that actually followed the prefix supplies the target. No separate annotator has to label each example.

Consider a toy vocabulary after the prefix:

The credit committee approved the

Suppose the model assigns these probabilities:

Candidate next token Probability
facility 0.46
proposal 0.27
request 0.14
revised 0.08
all other tokens combined 0.05

If the observed next token in the training text is facility, the negative-log-likelihood loss for this position is

ln(0.46)0.78. -\ln(0.46) \approx 0.78.

Had the model assigned facility a probability of 0.10, the loss would have been ln(0.10)2.30-\ln(0.10) \approx 2.30. Training adjusts the parameters so that observed continuations tend to receive more probability in similar contexts. The loss is calculated at many positions in a batch, averaged, and propagated back through the network. A single example contributes little; a large and varied corpus supplies an immense collection of such corrections.

The same distribution supports several generation policies. Greedy decoding would choose facility, the highest-probability token. Sampling could choose proposal or request, preserving variation at the cost of occasional poor choices. Temperature and top-kk or nucleus sampling reshape the distribution before selection. These policies change what is emitted, but they do not change the model’s parameters.

There are two important consequences.

First, a generated sentence is not retrieved as a complete record. It is assembled token by token. Memorised passages can influence generation, and training-data reproduction is a genuine risk, but the mechanism at inference time is conditional prediction rather than a database lookup.

Second, probability is not truth. A token can be locally plausible and factually wrong. The model is rewarded during pretraining for matching the continuations found in its data, not for consulting an authoritative source or proving each claim. Fluent wording is therefore weak evidence of factual reliability.

One objective across a sequence

Training does not usually present one prefix, wait for one prediction, and then start again. A sequence of n+1n+1 tokens provides nn adjacent prediction targets. After reading tokens 1–4, the model is scored on token 5; after tokens 1–5, it is scored on token 6; the pattern continues across the training window. A causal mask lets the implementation calculate these position-wise losses in parallel while ensuring that no position reads a future target.

The conditional probabilities also factorise the probability of a sequence:

P(t1,,tn)=i=1nP(tit1,,ti1). P(t_1,\ldots,t_n) = \prod_{i=1}^{n} P(t_i \mid t_1,\ldots,t_{i-1}).

Implementations work with sums of log probabilities rather than multiplying many small values. This avoids numerical underflow and leads directly to the cross-entropy loss used later in the book. Average loss is useful for comparing training runs on consistently prepared data, but it is not a complete product metric. Two models with similar validation loss can differ on citation faithfulness, instruction following or a specialist vocabulary.

During pretraining, the correct earlier tokens are available when each target is predicted. During free-running generation, the model must condition on the tokens it has selected, including its mistakes. An early poor choice can change the probabilities at every later position. This helps explain why a completion can begin sensibly and then drift. Decoding constraints, retrieval and validation can reduce particular forms of drift; they cannot turn the probability model into a proof system.

What next-token prediction can teach

To reduce prediction error across diverse text, a model benefits from representing recurring structure. Subject–verb agreement helps it choose a grammatical continuation. Knowledge of document genres helps it distinguish a contract clause from a recipe. Regularities in source code help it close a function or infer a likely variable name. Relations expressed repeatedly in text help with some factual questions. None of these skills needs a dedicated label during pretraining; each can improve the probability assigned to observed tokens.

This account is stronger than saying that the model merely memorises, but weaker than saying that it understands as a person does. A trained network contains distributed numerical representations that support useful generalisation. It can combine patterns in a new prompt, follow examples supplied in context and produce sequences absent from the training corpus. It can also fail on a small change of wording, invent a source, mishandle arithmetic or continue a false premise. Behaviour varies with the data, model, prompt, decoding method and evaluation.

The word understanding hides several possible claims. If it means behavioural competence on a defined task, a model may satisfy the criterion and still be unreliable outside the test distribution. If it implies human experience, intentions or consciousness, next-token performance does not establish it. Current evidence does not justify resolving the philosophical question by assertion. For engineering purposes, we can measure capabilities and failure modes without assigning a human mental state to the system.

Claims about emergent abilities require similar care. Wei and peers used the term for abilities that appear in larger models but are absent from smaller ones, with the transition not predictable by a simple extrapolation.2 Schaeffer and peers later showed that nonlinear or discontinuous metrics can make a smooth change in underlying model outputs look like an abrupt threshold.3 Both observations can matter: larger training runs can yield qualitatively useful behaviour, while the apparent sharpness of a benchmark transition may depend on how performance is measured. We will use emergent only when the measurement and comparison are stated, rather than as a synonym for impressive.

In-context learning illustrates the distinction. A prompt may contain two translation examples followed by a third source sentence. A sufficiently capable model can continue the demonstrated pattern without a gradient update. Its behaviour has adapted to the prompt, but its weights have not changed. Calling that response “learning” is conventional; calling it fine-tuning would be incorrect.

Measuring a capability

A capability claim should name the task distribution, prompt conditions, decoding policy and scoring rule. “The model can reason” is too loose to test. “The model selects the correct covenant clause from these versioned documents, cites the supporting span and abstains when no operative clause is supplied” is measurable.

Evaluation must also separate the stages of a system. An incorrect answer can begin with retrieval that missed the relevant passage, a prompt that omitted a constraint, generation that contradicted the evidence, or validation that accepted an unsupported claim. One end-to-end accuracy number will not reveal which component needs work. Stage-level measures make a failure actionable.

Benchmark results require context too. A score may depend on exact-match grading even when two answers are semantically equivalent, or grant full credit for a lucky guess with poor calibration. Test examples may resemble material in the training corpus. Small changes in wording or demonstration order may alter the result. None of these issues makes a benchmark useless; each limits the conclusion that can be drawn from it.

For a deployed system, capability should therefore be paired with boundaries: the inputs on which it was tested, the errors considered unacceptable and the conditions that trigger abstention. This is the practical meaning of treating a language model as a component rather than an oracle.

Locating language models within AI

The terminology surrounding language models is easier to use when its boundaries are explicit.

Artificial intelligence is the broad category of systems designed to perform tasks associated with intelligent behaviour. It includes hand-written rules, search, planning, optimisation and learned models. Machine learning is a subset in which behaviour is fitted from data. Deep learning is a subset of machine learning built from multi-layer neural networks. A large language model is a deep neural model trained on language at substantial scale.

Generative AI cuts across data types. It describes systems that generate content such as text, images, audio or code. A text-generating LLM therefore belongs to both deep learning and generative AI. A vision classifier is deep learning but not a language model; an image diffusion model is generative AI but not an LLM.

Nested fields show artificial intelligence containing machine learning and deep learning; overlapping language-model and generative-system circles locate large language models.
Figure 1.1. Large language models occupy the overlap between deep learning for language and generative systems; the surrounding AI field also contains rule-based, search and non-neural methods.

This taxonomy prevents a common design error: treating “use AI” as a technical requirement. It is not. A requirement should describe the decision, output, evidence and operating constraints. The appropriate method may be a rule, a classifier, a retrieval system, a language model or a combination.

From task-specific models to reusable foundations

Earlier natural language processing (NLP) systems were usually trained for a bounded task and output space. A sentiment classifier produced one of a few labels. A named-entity recogniser marked spans. A translation model mapped one language sequence to another. Specialisation remains valuable: it can make a model smaller, faster to test and easier to constrain.

A foundation model is pretrained on broad data and then reused across tasks. Reuse may require only a prompt, or it may involve retrieval, supervised fine-tuning, preference optimisation or a task-specific output head. The foundation approach reduces the need to train a new language representation from the beginning for every application. It does not remove the need for task design, evaluation or controls.

Task-specific systems use a separate constrained model for each job, while a shared foundation model can be adapted to several generative jobs; the right portfolio often combines both.
Figure 1.2. Task-specific systems optimise separate models for fixed outputs, whereas a foundation model supplies a shared language representation that can be adapted through prompts, retrieval or parameter updates.

The choice between specialised and general models is not a contest in which the largest model wins. It is a model-selection problem.

A decision framework

Work through the following questions in order.

  1. Is the required output fixed and deterministic? Use ordinary code or a rules engine when the logic is known, stable and testable. Do not ask a language model to calculate an interest schedule that a validated financial function can calculate exactly.
  2. Is the output one of a small set of labels? A compact classifier may be preferable when labelled examples exist and high-volume, low-latency inference matters. Constrain the output schema even when an LLM performs the classification.
  3. Must the system generate or transform open-ended language? A generative model becomes relevant for drafting, summarising, explanation, translation and conversational interaction. Measure the qualities the task actually needs; generic fluency is not enough.
  4. Does the answer depend on private, changing or citable evidence? Add retrieval and require the response to cite the supplied evidence. Retrieval changes the context available to the generator; it does not guarantee that the generator will use that context correctly.
  5. Does the task require arithmetic, database state or an external action? Use a tool with a typed interface, then validate its result. Language is a suitable control surface, not a substitute for the controlled operation.
  6. What happens when evidence or confidence is inadequate? Define abstention, escalation and human-review paths before deployment. A system that is unable to decline is not ready for a consequential decision.

The answers often lead to a hybrid. A deterministic component validates dates and amounts; a retriever supplies current documents; a language model explains the result; an authorised person approves any action. Architecture follows the error budget, not the novelty of the model.

A concise selection record makes that reasoning reviewable:

Requirement Design implication Evidence to collect
Fixed labels and very high volume Compare a compact supervised model with a constrained LLM Per-class precision and recall, latency distribution, calibration
Open-ended draft for expert review Test a generative model with an explicit output schema Rubric scores, edit distance to accepted draft, reviewer time
Answers from controlled documents Add retrieval, provenance and an evidence gate Retrieval recall, claim-level citation support, abstention quality
Exact calculation or state change Call a deterministic, authorised tool Input validation, execution result, idempotency and audit trail
Consequential decision Keep accountable human approval and policy controls Override rate, review quality, protected-group and error analysis

The final column is as important as the model choice. A demonstration assembled from favourable examples cannot establish production fitness. Evaluation data should represent ordinary cases, rare but costly errors, ambiguous inputs and conditions in which the system ought to abstain. The model-selection decision can then be revisited when volume, data or risk changes.

Retrieval is evidence supply, not a truth machine

Retrieval-augmented generation places selected source material in the model’s context before it generates an answer. A typical pipeline indexes documents, retrieves passages for a query and asks the model to answer from those passages. This can make private or current information available without putting it into the model’s weights.

Retrieval introduces its own failure modes. The relevant document may not have been indexed. Chunking may separate a definition from its exception. A search query may use different terminology from the source. The retriever may return a near match with the wrong customer, jurisdiction or document version. The generator may ignore a caveat even when it appears in the context. A citation can point to a passage that does not support the sentence beside it.

For that reason, a governed pipeline needs an evidence gate between retrieval and generation. The gate checks whether enough relevant, authorised and version-correct evidence is present. When the check fails, the proper output is an abstention or a request for more information, not a plausible completion.

A question is retrieved against documents, then an evidence gate either permits a cited answer or sends weak evidence to abstention or human review.
Figure 1.3. Retrieval becomes dependable only when an evidence gate checks relevance, provenance, permissions and document version before generation; failed checks lead to abstention or review.

Consider an independent, wholly fictional credit-document mini-case for a large UK bank. It combines publicly documented architectural patterns; it does not describe a live system, an internal design or a named organisation. The customer, amounts and documents are invented.

A credit analyst asks:

What is Northbridge Manufacturing Ltd’s interest-cover covenant, and when is it next tested?

The authorised corpus contains a facility agreement, an amendment, the latest credit paper and the bank’s lending policy. Retrieval returns the covenant clause from the original agreement and a later amendment. The evidence gate checks the customer identifier, facility identifier, document status, effective dates and access rights. Because the amendment supersedes the original threshold, the system must use the amended clause while retaining both documents in its provenance record.

The language model may then draft:

The amended minimum interest-cover ratio is 3.0×, tested quarterly. The next test date in the supplied schedule is 30 September 2026. Sources: Amendment 2, clause 4.1; Facility Agreement, schedule 6.

Those values are synthetic. In a real system, a deterministic validator should parse the date, ratio and units; a citation checker should confirm that each claim is supported by the cited span. If the amendment is missing, unsigned or outside the user’s permissions, the system should say that it lacks sufficient authorised evidence and route the query for review. The model’s prose is only one component of the control chain.

This example also separates three forms of information. The model’s parameters contain patterns acquired during pretraining. The prompt and retrieved passages provide temporary context for the current request. The audit record preserves the query, document versions, retrieved spans, validation outcomes and final decision. Conflating those stores creates avoidable security and governance errors.

Pretraining and the adaptation stack

Pretraining starts with a parameterised model and a large token corpus. For causal language modelling, the input at each position contains only earlier tokens, while the target is the next token. Optimisation repeatedly reduces the average prediction loss. The result is a base model: a model fitted to continue text, not automatically a safe or reliable assistant.

Adaptation is broader than a single second stage.

  • Prompting and in-context examples change the current input, not the weights.
  • Retrieval adds external evidence to the input and requires its own indexing, ranking and provenance controls.
  • Supervised fine-tuning updates weights using curated input–output pairs. Instruction tuning is one form; classification fine-tuning is another.
  • Parameter-efficient fine-tuning updates a small set of added or selected parameters rather than every base-model weight.
  • Preference optimisation uses comparative feedback or a preference objective to shape which responses the model favours.
  • Tools and deterministic orchestration connect language generation to search, calculation, databases and approved actions.
Raw text supports self-supervised pretraining, which creates a base model; supervised and preference-based adaptation then specialise behaviour, followed by evaluation and controlled deployment.
Figure 1.4. A deployable assistant is built through distinct stages: broad causal pretraining creates a base model, adaptation shapes task behaviour, and retrieval, tools and controls govern the serving system.

The distinctions matter because each intervention changes a different part of the system. Retrieval cannot repair a tokenizer. A prompt cannot update model weights. Fine-tuning cannot ensure that a policy document remains current. Preference optimisation cannot replace access control. Evaluation should identify which layer failed before prescribing a remedy.

It is also inaccurate to describe ChatGPT as simply “GPT-3 plus supervised fine-tuning”. Product implementations and model lineages change, and their full training details are not necessarily public. More generally, a chat assistant may combine a pretrained model with supervised instruction data, preference optimisation, safety policies, system instructions, retrieval, tools and serving-time controls. The model checkpoint and the delivered product are not the same object.

The economics of pretraining and adaptation vary with hardware, model architecture, sequence length, precision, dataset and utilisation. Fixed cost tables become misleading quickly. The durable principle is computational: pretraining distributes a broad representation across a large run; later adaptation reuses that representation for narrower requirements. Whether reuse is cheaper in a particular project must be measured rather than assumed.

The transformer’s original design

Before transformers, recurrent models processed sequence positions in order. Recurrence made the dependency path between distant tokens long and limited parallelism during training. Attention mechanisms allowed a model to form weighted combinations of representations at different positions. The 2017 Transformer made attention the central sequence-processing operation and dispensed with recurrence and convolution in its main architecture.4

The original Transformer was designed for sequence-to-sequence tasks such as machine translation. It contains an encoder stack and a decoder stack:

  • Each encoder layer uses unmasked self-attention so that every source position can incorporate information from the complete source sequence, followed by a position-wise feed-forward network.
  • Each decoder layer first uses masked self-attention over the target prefix. It then uses encoder–decoder attention, often called cross-attention, to read the encoder’s output. A feed-forward network completes the layer.
  • Residual connections and normalisation support optimisation around these sublayers. Positional information is added because attention alone does not encode token order.
Parallel encoder and decoder stacks show self-attention in both halves and a bridge from encoder context into decoder cross-attention.
Figure 1.5. The 2017 Transformer is an encoder–decoder system: the encoder contextualises the complete source, while the decoder combines causal target-prefix attention with cross-attention to the encoded source.

Self-attention creates a short path between any pair of positions in the available sequence. It also permits the representations for many positions to be computed in parallel during training. This does not mean that the mechanism solves reference, logic or meaning by itself. An attention weight is an internal mixing coefficient, not a causal explanation of a prediction. Capability depends on the learned projections, feed-forward layers, training data, objective and optimisation as well as the attention pattern.

Encoder-style and decoder-only models

Later model families retained different parts of the original design. Encoder-style models use bidirectional self-attention over an observed sequence and are natural representation builders. They suit tasks such as classification and token labelling, although the architecture does not confine them to those uses.

GPT is conventionally called decoder-only, but the shorthand can mislead. A GPT block is not the original Transformer decoder copied intact. It keeps causally masked self-attention and a feed-forward sublayer, but it omits the encoder and therefore omits encoder–decoder cross-attention. The prompt and generated continuation occupy one sequence. A causal mask prevents each position from reading later positions.

That distinction can be stated precisely:

  • the full Transformer decoder attends to both the target prefix and a separate encoded source;
  • a decoder-only GPT block attends causally within one sequence and has no separate source encoder;
  • an encoder-only block attends across the complete supplied sequence and does not, by itself, define autoregressive generation.
Encoder models build bidirectional representations for an observed sequence; causal decoder models predict a continuation from the visible prefix.
Figure 1.6. Encoder-style models build bidirectional representations from a complete input, while decoder-only GPT models extend one sequence under a causal mask and omit the original decoder’s cross-attention sublayer.

The causal mask is what makes one training sequence yield many prediction examples. At position 5 the model predicts token 6 from tokens 1–5; at position 6 it predicts token 7 from tokens 1–6. During training these positions can be evaluated together because the mask blocks forbidden information. During generation, new tokens still arrive sequentially because the next prefix does not exist until a token has been selected.

The architecture repeats a small set of components: token and positional embeddings, attention, feed-forward networks, normalisation, residual paths and an output projection. Scale changes the width, number of heads, number of layers, training tokens and compute. The recurrence of the block is what makes a from-scratch implementation feasible; each chapter can isolate one mechanism before they are assembled.

Scale, context and compute

The adjective large can refer to several quantities that should not be confused.

Parameter count measures the trainable numerical values in the network. Parameters provide capacity, but a larger parameter count does not guarantee a better model if the data, optimisation or evaluation are poor.

Training-token count measures how much tokenised data is processed during training, including repeated sampling. GPT-3’s largest reported model had 175 billion parameters and was trained for 300 billion tokens.5 These two numbers describe different resources.

Context length is the maximum number of tokens available to a model for one forward pass. GPT-3 used 2,048 tokens.6 A context window is not persistent memory: material outside it is unavailable unless an application retrieves, summarises or stores that material and supplies it again.

Training compute depends on parameter count, token count, sequence length and implementation. Scaling one dimension while starving another can waste compute. Hoffmann and peers trained Chinchilla, a 70-billion-parameter model, on 1.4 trillion tokens and found that model size and training tokens should rise together under the compute budgets and model family they studied.7 Their result corrected a period of parameter-heavy scaling. It should not be reduced to a universal ratio that ignores data quality, architecture or the intended inference budget.

Scale can improve average loss and many downstream results. It also increases the importance of data governance, evaluation and operational constraints. The correct comparison for a product is not parameter count alone; it is whether a candidate system meets the required quality, latency, cost, privacy and control thresholds on representative data.

The build ahead

Our target is a compact GPT-style model whose internals remain visible. We are not reproducing the resources or performance of a frontier system. The value of the exercise lies in connecting each observed behaviour to a tensor operation, a parameter update or a serving decision.

The sequence is deliberate.

  1. Represent text. We will tokenise raw text, construct input–target windows and map token identifiers and positions to vectors. This establishes the shapes consumed by the network.
  2. Build attention. We will derive query, key and value projections, apply scaling and causal masks, then extend one attention head into multi-head attention.
  3. Assemble GPT. We will add normalisation, feed-forward layers, residual connections and the vocabulary projection, checking dimensions and parameter counts as we go.
  4. Pretrain and generate. We will calculate cross-entropy loss, update weights, monitor held-out loss and compare decoding policies. We will also load compatible pretrained weights to test the fidelity of our implementation.
  5. Adapt the model. We will use labelled examples for classification and instruction–response examples for supervised instruction tuning, making the changed objective explicit in each case.
  6. Govern the application. We will connect model behaviour to provenance, validation, abstention, access control, monitoring and human review through independent, fictional credit-document examples.

The next chapter starts at the boundary between language and mathematics. Text must become token identifiers, and token identifiers must become vectors. Once that bridge is explicit, “predict the next token” becomes a sequence of operations we can implement and inspect.

Build check

Before moving on, verify that you can do the following without relying on the chapter’s wording.

  1. Given a next-token probability of 0.25 for the observed token, calculate its negative-log-likelihood: ln(0.25)1.39-\ln(0.25) \approx 1.39.
  2. Explain why greedy decoding and sampling can produce different text from the same model without changing its weights.
  3. Choose an architecture for each case: a fixed regulatory threshold, a high-volume three-label classifier, a cited answer from current policy, and an authorised update to a database. A defensible answer is, respectively: deterministic code; a compact classifier; retrieval plus an evidence-gated generator; and a typed tool call with validation and authorisation.
  4. Distinguish the full Transformer decoder from a GPT block. The former includes cross-attention to an encoder; the latter has causal self-attention within one sequence and no encoder cross-attention.
  5. State the three GPT-3 quantities kept separate in this chapter: 175 billion parameters, 300 billion training tokens and a 2,048-token context window.
  6. Trace the credit-document query through retrieval, evidence checks, generation, deterministic validation and review. Identify the point at which the system must abstain if the operative amendment is unavailable.

Notes


Build layerChapter 2: From text to model input

A decoder language model begins with text but cannot calculate with text directly. It needs a sequence of discrete symbols, represented by integer IDs, and then a sequence of continuous vectors on which its neural layers can operate. That conversion determines how much of a document fits in the context window, which distinctions survive preprocessing, and which row of the model’s embedding table receives each gradient.

Chapter map for Build layer Chapter 2: From text to model input: What an embedding represents; Choosing the units; Why a teaching tokenizer is not a model tokenizer; Byte-level BPE; Coverage and multilingual efficiency are different claims.
Mermaid chapter map. Build layer Chapter 2: From text to model input connects What an embedding represents, Choosing the units, Why a teaching tokenizer is not a model tokenizer, Byte-level BPE, Coverage and multilingual efficiency are different claims.

This chapter builds that path in four stages:

  1. a fixed tokenizer converts Unicode text into token IDs;
  2. a windowing dataset turns a token stream into input–target pairs;
  3. a learned embedding table maps each ID to a vector; and
  4. positional information distinguishes the same token at different locations.

This chapter uses a separate, wholly fictional mini-case: a facility clause for the borrower Northstar Retail Ltd.

If Northstar Retail Ltd misses an interest payment, the Agent must notify the Lenders within two Business Days.

The clause, company and timing are invented. The example combines publicly documented tokenisation and document-processing patterns for UK banking; it does not describe a bank’s internal documents or systems.

Unicode text becomes token IDs, token vectors and position-aware decoder input; the tensor changes from a sequence of T integers to a T-by-D matrix.
Figure 2.1. The input path has a discrete boundary at tokenisation and a trainable path from embedding lookup onwards.

What an embedding represents

An embedding is a vector whose coordinates are learned for a task. In a language model, each vocabulary item has one row in a token-embedding matrix. Training adjusts those rows so that the rest of the network can predict future tokens more accurately.

This definition is more useful than saying that an embedding is a numerical form of meaning. Meaning is neither assigned to individual coordinates nor guaranteed by geometric distance. A training objective can nevertheless produce useful neighbourhoods: tokens used in similar contexts often acquire vectors that are close under a measure such as cosine similarity. A two-dimensional projection may reveal groups, but it discards most of the geometry and depends on the projection method.

An illustrative two-dimensional projection places animal, finance and motion terms in separate neighbourhoods while noting that the real vectors have hundreds of coordinates.
Figure 2.2. Neighbourhoods can reveal regularities in an embedding table, but a two-dimensional plot is evidence about a projection rather than a complete map of meaning.

There is also a distinction between token embeddings and contextual representations. The embedding lookup is static: a particular token ID always selects the same row. After that row has passed through transformer blocks, its hidden state depends on the surrounding tokens. The token bank therefore starts from one vocabulary row whether it occurs beside river or mortgage; attention creates the context-sensitive representations later.

Before any lookup can happen, the tokenizer must decide what counts as a vocabulary item.

Choosing the units

A word-level vocabulary makes common text short, but it has to assign a symbol to every spelling, inflection, name and formatting variant it intends to support. A character vocabulary covers new words with a small symbol set, at the cost of longer sequences. Subword tokenisation occupies the space between those choices.

Unit Typical treatment of unhappiness Main benefit Main cost
word unhappiness short sequence for known words large, closed vocabulary
character u n h a p p i n e s s direct character coverage many sequence positions
subword for example, un happi ness reusable pieces and open-text coverage boundaries depend on learned vocabulary

The example segmentation in the table is illustrative. A real tokenizer may split the word differently because its vocabulary was learned from a particular corpus. It may also include a preceding space in a token. GPT-2’s tokenizer, for example, can distinguish payment at the start of a string from payment after another word.

The synthetic sentence “A covenant breach needs review.” is divided into reusable pieces, with leading spaces shown as part of several tokens.
Figure 2.3. Token boundaries are learned conventions; they are not the same as word boundaries.

A tokenizer does three related jobs:

  • pre-tokenisation divides text into regions on which merge rules operate;
  • a vocabulary and merge table maps byte sequences to integer IDs; and
  • special-token rules reserve IDs for boundaries or control markers.

The model sees only the resulting IDs. Token ID 12052 does not resemble its spelling, and adjacent IDs have no implied similarity. An ID is an index into two learned structures: the input embedding table and, later, the output vocabulary projection.

Why a teaching tokenizer is not a model tokenizer

Splitting on whitespace and punctuation is a useful way to expose the vocabulary problem. If a vocabulary is built from a small corpus, a new company name will be absent. Replacing every unseen word with <unk> keeps the program running but collapses distinct inputs to the same symbol. Northstar, Alderwick and a misspelt account reference could all become indistinguishable.

A hand-written regular expression also has awkward edge cases:

  • can't, can’t and canʼt contain different apostrophe characters;
  • £1,250,000.00 combines a currency sign, separators and a decimal point;
  • a non-breaking space looks like an ordinary space;
  • a decomposed accented character can have more than one Unicode code point;
  • PDF extraction may insert soft hyphens or line breaks inside words; and
  • scripts without spaces do not fit a whitespace-first assumption.

These are reasons to use a tested tokenizer paired with the model, rather than reasons to strip the characters. Normalisation is a policy decision. Unicode NFC can make canonically equivalent sequences consistent, while compatibility normalisation such as NFKC may change distinctions that matter in identifiers. A pipeline should record its policy and test it on representative documents. It should not silently rewrite account references, legal citations or quoted contract text.

Byte-level BPE

Byte Pair Encoding was introduced as a compression method and adapted to subword modelling by Sennrich, Haddow and Birch. GPT-2 uses a byte-level form. Its path is more precise than the common description “start with characters and merge the most frequent pair”.

For GPT-2-style tokenisation:

  1. a regular expression pre-tokenises the Unicode string;
  2. each resulting piece is represented as UTF-8 bytes;
  3. a reversible byte-to-Unicode mapping gives each of the 256 byte values a convenient internal symbol;
  4. ranked BPE merges combine frequent adjacent byte sequences within those pieces.

Common byte sequences acquire compact tokens. Less frequent text remains split into smaller units. Because the base alphabet covers every byte value, valid Unicode text is representable even when a word or script was rare in the tokenizer’s training corpus.

The unfamiliar term “EBITDAx” falls back from a whole form to smaller subword and byte-derived units, preserving coverage while increasing sequence length.
Figure 2.4. Byte-level fallback guarantees representation; it does not guarantee an efficient representation.

The byte detail matters. One token can contain only part of a multi-byte UTF-8 character. Decoding that token in isolation may therefore produce invalid text. When inspecting boundaries, use the token’s raw bytes; decode the complete token sequence to recover the original valid string.

GPT-2’s vocabulary contains 50,257 entries, including its <|endoftext|> special token. The following code uses that fixed encoding for the synthetic clause.

import tiktoken

encoding = tiktoken.get_encoding("gpt2")
clause = (
    "If Northstar Retail Ltd misses an interest payment, the Agent "
    "must notify the Lenders within two Business Days."
)

token_ids = encoding.encode(clause)
token_bytes = [
    encoding.decode_single_token_bytes(token_id)
    for token_id in token_ids
]

print("vocabulary:", encoding.n_vocab)
print("token count:", len(token_ids))
print("token IDs:", token_ids)
print("token bytes:", token_bytes)
assert encoding.decode(token_ids) == clause
vocabulary: 50257
token count: 22
token IDs: [1532, 2258, 7364, 26702, 12052, 18297, 281, 1393, 6074,
11, 262, 15906, 1276, 19361, 262, 406, 7338, 1626, 734, 7320, 12579,
13]
token bytes: [b'If', b' North', b'star', b' Retail', b' Ltd',
b' misses', b' an', b' interest', b' payment', b',', b' the',
b' Agent', b' must', b' notify', b' the', b' L', b'enders',
b' within', b' two', b' Business', b' Days', b'.']

Northstar becomes North and star; capitalised Lenders becomes L and enders. Neither split is a corrupted word. The merge table simply has no higher-ranked token for that exact byte sequence in that context.

Coverage and multilingual efficiency are different claims

Byte coverage means that an encoder can represent a string. It says nothing about how many tokens the representation will require. A vocabulary learned mostly from one distribution tends to allocate its compact tokens to frequent byte sequences in that distribution. Another script, a specialist notation or OCR noise may consume more tokens for comparable content.

That difference has operational effects. Under a fixed context limit, a less compact encoding leaves room for less source material. It also increases the number of positions processed during training and inference. Attention cost will be examined in Chapter 3, but the tokenizer has already chosen the sequence length on which that cost depends.

Token count alone is not a measure of language quality or fairness. A sound evaluation samples the intended languages and document types, then reports at least:

  • tokens per document and per meaningful section;
  • the distribution, rather than only the mean;
  • truncation rates at the configured context limit;
  • latency and memory under the resulting lengths; and
  • task quality for each evaluated language and document class.

For a credit-document assistant, that sample should include synthetic or properly governed facility clauses, schedules, tables, currency values, company names and extracted PDF text. A tokenizer that is compact on news prose may be less compact on covenant formulae. Adding domain tokens is not an automatic repair: each added token needs a trained input row and a trained output row, and it changes the model–tokenizer contract.

Special tokens are protocol elements

Special tokens are reserved IDs that the application inserts for a defined purpose. GPT-2 uses <|endoftext|> as an end-of-document marker. The marker can help a model learn that adjacent passages came from different documents. It does not erase earlier context, reset a conversation or turn subsequent user text into a system instruction.

tiktoken raises an error by default when ordinary input contains text that matches a registered special token. The caller must decide whether the text should be interpreted as a special token or tokenised as ordinary characters:

user_text = "The contract literally prints <|endoftext|> here."

# Treat the characters as ordinary user text.
user_ids = encoding.encode(user_text, disallowed_special=())

# Insert a trusted boundary as an ID constructed by the application.
training_ids = user_ids + [encoding.eot_token]

This separation prevents accidental interpretation of a reserved string, but it is not a complete prompt-injection defence. Instruction priority, role separation, tool permissions, validation and human approval belong to the application’s control plane. A raw user should not be allowed to choose trusted control IDs.

Version the tokenizer with the weights

The embedding row at index ii is trained for whatever the tokenizer assigns to ID ii. Changing the vocabulary or merge ranks while reusing the weights changes that meaning. The program may still produce tensors of valid shapes, which makes this mismatch especially difficult to spot.

A release manifest should bind the model to:

  • the tokenizer family and encoding name;
  • the vocabulary and merge-file digest;
  • pre-tokenisation and Unicode-normalisation rules;
  • every special token and its ID;
  • the tokenizer software version;
  • the model’s context limit; and
  • fixed text-to-ID regression examples.

Fine-tuning, evaluation and serving must load the same contract. Adding tokens requires deliberate resizing and training of the input embedding and output projection. Replacing the tokenizer wholesale generally requires retraining or a specialised vocabulary-transfer procedure; it is not a configuration swap.

From a token stream to prediction examples

A causal language model learns to predict the next token at every visible position. For a contiguous sequence

[t0,t1,t2,t3,t4], [t_0,t_1,t_2,t_3,t_4],

one training example of context length four is:

x=[t0,t1,t2,t3],y=[t1,t2,t3,t4]. x=[t_0,t_1,t_2,t_3], \qquad y=[t_1,t_2,t_3,t_4].

The input and target have equal length. y[j] is the token immediately after x[j] in the original stream. The causal attention mask introduced in the next chapter ensures that the representation at position jj cannot inspect x[j + 1] while making that prediction.

Long streams yield many examples. A window starts at index ss, takes context_length input tokens, then takes the same number of target tokens from s+1s+1. The stride is the distance from one start index to the next.

For a context of four tokens, stride one produces heavily overlapping windows while stride four produces adjacent, non-overlapping input windows.
Figure 2.5. Stride changes the number and correlation of examples; it does not change the next-token objective inside a window.
import torch
from torch.utils.data import Dataset


class NextTokenDataset(Dataset):
    def __init__(self, token_ids, context_length, stride):
        if context_length < 1:
            raise ValueError("context_length must be positive")
        if not 1 <= stride <= context_length:
            raise ValueError(
                "stride must be between 1 and context_length"
            )

        self.context_length = context_length
        self.ids = torch.as_tensor(token_ids, dtype=torch.long)
        if self.ids.numel() <= context_length:
            raise ValueError(
                "need at least context_length + 1 tokens"
            )

        stop = int(self.ids.numel()) - context_length
        self.starts = range(0, stop, stride)

    def __len__(self):
        return len(self.starts)

    def __getitem__(self, index):
        start = self.starts[index]
        end = start + self.context_length
        inputs = self.ids[start:end]
        targets = self.ids[start + 1:end + 1]
        return inputs, targets


dataset = NextTokenDataset(
    token_ids,
    context_length=8,
    stride=8,
)
inputs, targets = dataset[0]

print(inputs.shape, len(dataset))
print(inputs.tolist())
print(targets.tolist())
assert torch.equal(inputs[1:], targets[:-1])
torch.Size([8]) 2
[1532, 2258, 7364, 26702, 12052, 18297, 281, 1393]
[2258, 7364, 26702, 12052, 18297, 281, 1393, 6074]

The class stores one token tensor and indexes views into it. It also constrains the stride to at most the context length so that the starts do not leave untrained gaps in the target stream. With 22 tokens, context length 8 and stride 8, it produces two complete examples. The short remainder is omitted. That choice is explicit; it should be measured rather than hidden.

What stride changes

With stride 1, neighbouring inputs of length TT share T1T-1 tokens. This produces many examples and lets a token appear with several left contexts, but the examples are highly correlated and the same raw token contributes to more optimiser updates. With stride TT, the input windows do not overlap and most target positions are predicted once.

Neither choice is inherently correct, and overlap does not by itself prove overfitting. The relevant controls are the raw-token budget, number of optimiser updates, diversity of documents and held-out loss. A comparison that keeps “epochs” fixed while changing stride has changed the number of examples; it is not an isolated test of overlap.

Splitting data after windows have been created can leak nearly identical sequences into training and validation. Split at the document, customer, facility or time boundary required by the evaluation, then tokenise and window each partition independently. In the synthetic case, clauses from one fictional agreement should not be scattered across both sets.

Batching and drop_last

PyTorch’s DataLoader stacks equal-length examples into tensors with shape [B,T][B,T].

from torch.utils.data import DataLoader


loader = DataLoader(
    dataset,
    batch_size=2,
    shuffle=False,
    drop_last=False,
)

batch_inputs, batch_targets = next(iter(loader))
print("inputs:", batch_inputs.shape)
print("targets:", batch_targets.shape)
assert torch.equal(
    batch_inputs[:, 1:],
    batch_targets[:, :-1],
)
inputs: torch.Size([2, 8])
targets: torch.Size([2, 8])

drop_last=False retains a final batch smaller than the requested batch size. That batch is valid when the loss is defined correctly. It may have noisier gradients, and a per-batch mean gives its fewer tokens one full optimiser step unless accumulation is normalised by token count. drop_last=True trades away those examples for fixed batch shapes and can simplify distributed or compiled training. It does not, by itself, prevent a loss spike. The choice should be recorded with the effective token count.

Tails, padding and packing

The fixed-window dataset discards a tail shorter than T+1T+1 tokens. Large corpora make that loss small, but a corpus of short facility clauses would waste substantial data. Three alternatives serve different purposes.

Padding appends a pad ID until examples have a common length. A decoder then needs separate controls:

Control What it prevents
causal attention mask a position attending to later positions
padding or key mask real positions using pad positions as context
loss mask pad labels contributing to the training loss

Many training libraries implement the loss mask by replacing padded label IDs with an ignore value such as -100. The pad token can still have an embedding row; masking decides whether that row affects attention and loss. Right- and left-padding also change position indices, so the convention used in training must match the one used in evaluation and serving.

Sequence packing fills a context window with several short documents. Each document needs an explicit boundary token. A simple GPT-style packed stream may allow tokens after the boundary to attend to tokens before it, relying on the boundary to signal the transition. A stricter packer adds segment-aware, block-diagonal attention masks so that documents share a tensor without sharing context. The selected policy must match the objective.

Variable-length batches preserve every tail and pad only within each batch. They reduce waste when lengths are grouped sensibly, but introduce more shape variation. Whichever method is used, report the number of source tokens, predicted tokens, padded tokens and masked tokens. “Number of rows” is not an adequate account of the training data.

Embedding lookup

Let the vocabulary size be VV and model width be DD. The trainable token embedding matrix is

EV×D. E \in \mathbb{R}^{V \times D}.

For token ID ii, the lookup returns row EiE_i. An input tensor of IDs with shape [B,T][B,T] therefore becomes a floating-point tensor with shape [B,T,D][B,T,D].

Token IDs select rows from a learned matrix; the integer itself carries no geometric meaning.
Figure 2.6. An embedding layer is indexed parameter storage, trained through the downstream prediction loss.

The lookup is equivalent to multiplying a one-hot row vector by EE. If V=5V=5 and the ID is 2, the one-hot vector

[0,0,1,0,0] [0,0,1,0,0]

selects the third row. nn.Embedding avoids constructing the mostly zero one-hot vector.

Here is a hand-worked example with three vocabulary items selected from a toy five-token vocabulary. The vectors are assigned for inspection; in the model they begin from an initialisation scheme and are learned.

E2=[0.2,0.4,0.6](‘ covenant’)E4=[0.1,0.7,0.2](‘ breach’)E1=[0.5,0.0,0.3](‘.‘) \begin{aligned} E_2 &= [0.2,-0.4,0.6] && \text{(` covenant')}\\ E_4 &= [-0.1,0.7,0.2] && \text{(` breach')}\\ E_1 &= [0.5,0.0,-0.3] && \text{(`.`)} \end{aligned}

For IDs [2,4,1][2,4,1], lookup produces a 3×33\times3 matrix:

[0.20.40.60.10.70.20.50.00.3]. \begin{bmatrix} 0.2 & -0.4 & 0.6\\ -0.1 & 0.7 & 0.2\\ 0.5 & 0.0 & -0.3 \end{bmatrix}.

The rows preserve sequence order. Similarity between ID values never enters the calculation: ID 4 is no closer to ID 2 than it is to ID 1.

For the GPT-2 Small configuration, V=50,257V=50{,}257 and D=768D=768, so the token embedding table has

50,257×768=38,597,376 50{,}257 \times 768 = 38{,}597{,}376

trainable parameters. An ID batch with B=8B=8 and T=1,024T=1{,}024 has shape [8,1024][8,1024]; after lookup its shape is [8,1024,768][8,1024,768]. Repeated IDs select the same row, and their gradient contributions accumulate when the loss is backpropagated.

Adding order

Content-only self-attention without position information is permutation-equivariant: permuting input rows produces the corresponding permutation of output rows. It cannot infer the original order from token vectors alone. A causal mask introduces a direction because each position sees a different prefix. GPT-2 nevertheless supplies an explicit signal for position and distance.

GPT-2 uses a learned absolute-position matrix

PC×D, P \in \mathbb{R}^{C \times D},

where CC is the configured context length. For a sequence of length TT, positions 00 through T1T-1 select TT rows. The model input is

Xb,t=EIDb,t+Pt. X_{b,t}=E_{\text{ID}_{b,t}}+P_t.

Token and position vectors have the same width DD, so element-wise addition retains shape [B,T,D][B,T,D].

A six-coordinate token vector and six-coordinate position vector are added element by element to form one model-input vector of the same width.
Figure 2.7. Learned absolute position is added without widening the residual stream.

Continue the hand-worked example with:

P0=[0.1,0.0,0.0],P1=[0.0,0.1,0.0],P2=[0.0,0.0,0.1]. P_0=[0.1,0.0,0.0],\quad P_1=[0.0,0.1,0.0],\quad P_2=[0.0,0.0,0.1].

The first combined row is

[0.2,0.4,0.6]+[0.1,0.0,0.0]=[0.3,0.4,0.6]. [0.2,-0.4,0.6]+[0.1,0.0,0.0] =[0.3,-0.4,0.6].

All three rows can be reproduced with trainable PyTorch embeddings.

import torch
from torch import nn


token_weights = torch.tensor([
    [ 0.1,  0.0, -0.1],
    [ 0.5,  0.0, -0.3],
    [ 0.2, -0.4,  0.6],
    [-0.2,  0.1,  0.4],
    [-0.1,  0.7,  0.2],
])
position_weights = torch.tensor([
    [0.1, 0.0, 0.0],
    [0.0, 0.1, 0.0],
    [0.0, 0.0, 0.1],
])

token_embedding = nn.Embedding.from_pretrained(
    token_weights,
    freeze=False,
)
position_embedding = nn.Embedding.from_pretrained(
    position_weights,
    freeze=False,
)

input_ids = torch.tensor([[2, 4, 1]])  # [B=1, T=3]
positions = torch.arange(input_ids.shape[1])

token_vectors = token_embedding(input_ids)       # [1, 3, 3]
position_vectors = position_embedding(positions) # [3, 3]
model_input = token_vectors + position_vectors   # [1, 3, 3]

print(model_input)
assert model_input.shape == (1, 3, 3)
tensor([[[ 0.3000, -0.4000,  0.6000],
         [-0.1000,  0.8000,  0.2000],
         [ 0.5000,  0.0000, -0.2000]]], grad_fn=<AddBackward0>)

Both embedding modules have requires_grad=True. Later layers supply the loss; backpropagation adjusts the token and position rows that participated in the batch.

Learned absolute position and RoPE

A learned absolute table is simple and is the appropriate mechanism for the GPT-2-style model built in this book. It also sets a hard indexing boundary: if PP has CC rows, position CC has no row. Extending the table requires a defined initialisation and further training or another adaptation method.

Rotary Position Embedding, or RoPE, uses a different interface. It normally does not add a position vector to the token embedding. Inside attention, it rotates pairs of coordinates in the query and key vectors by position-dependent angles. Their dot products then carry relative-position structure.

Property Learned absolute position RoPE
operation add PtP_t to the token vector rotate query and key coordinates
location before the transformer stack inside each attention layer
learned position table yes usually no
extension beyond trained length undefined without adaptation mathematically computable, empirically uncertain

The last row is the one that prevents a common overclaim. RoPE removes the finite lookup-table index, but it does not confer a reliable long context by itself. The model’s training lengths, rotary frequencies or scaling scheme, attention memory, cache implementation and long-context evaluation all remain constraints. Methods such as position interpolation exist precisely because direct extrapolation can degrade. A declared context length should therefore be treated as an evaluated model property, not inferred from the presence of RoPE.

The complete input contract

The pipeline can now be stated as a set of testable tensor contracts.

Stage Value Shape
tokenise integer IDs in [0,V)[0,V) [B,T][B,T] after batching
token lookup rows of EE [B,T,D][B,T,D]
absolute position lookup rows 0T10\ldots T-1 of PP [T,D][T,D]
add, then optional input dropout decoder input [B,T,D][B,T,D]
next-token labels IDs shifted by one [B,T][B,T]

Tokenisation is a discrete preprocessing operation in this build. The vocabulary and merges are trained or selected before model training and then fixed. The embedding tables are ordinary trainable parameters. This boundary is useful when debugging: if decoded IDs do not reproduce the source, inspect the text and tokenizer; if IDs are correct but vectors or gradients are wrong, inspect the model path.

The synthetic clause also shows why provenance should survive preprocessing. A model consumes IDs, but an audit or human reviewer needs to recover the source document, page, clause span and tokenizer version. Keep those references beside the tokenised example rather than attempting to reconstruct them from a generated answer.

Build check

Run these checks before connecting the input pipeline to attention.

  1. Round trip representative text. Verify decode(encode(text)) == text for plain English, curly punctuation, pound values, combining marks, intended scripts and extracted document samples. Inspect full token sequences rather than assuming each token decodes alone.

  2. Freeze and fingerprint the tokenizer. Record the encoding name, vocabulary and merge digest, normalisation policy, software version and special-token map beside the checkpoint. Fail model loading when the fingerprint differs.

  3. Test fixed token IDs. Keep a small regression set that includes the synthetic clause. An unexplained change in IDs is a release failure even when round-trip decoding still succeeds.

  4. Measure the target distribution. Report token counts, truncation and tail loss by language and document type. Universal byte coverage is not a substitute for this measurement.

  5. Keep trusted markers out of user control. Tokenise literal user text as ordinary text and let the application insert document or role markers. Treat special-token handling as one protocol check among several security controls.

  6. Split before windowing. Prevent overlapping clauses, documents or customer-related material from crossing evaluation partitions. Confirm that a window never straddles an unintended boundary.

  7. Reconcile token accounting. For each run, count source, predicted, padded, masked and dropped tokens. Record stride, context length, drop_last and any packing policy.

  8. Assert shifts and shapes. For every unpadded contiguous example, check inputs[1:] == targets[:-1]. After batching, IDs should have shape [B,T][B,T], embeddings [B,T,D][B,T,D], and all IDs must lie in [0,V)[0,V).

  9. Test masks separately. A causal mask, padding mask and loss mask solve different problems. Construct a padded example and verify that padded labels contribute zero loss and padded keys receive no attention.

  10. Check position bounds. For learned absolute embeddings, require TCT \le C before lookup. Test the first and last valid positions and make truncation an explicit caller decision.

  11. Inspect numerical health. Confirm embedding outputs are finite, on the intended device and in the intended dtype. Backpropagate a tiny loss and verify that used rows receive gradients.

  12. Decode a batch during training. Periodically decode input and target rows with their provenance. A readable sample often exposes duplicated boundaries, extraction artefacts or an off-by-one shift before they consume a training run.

At this point the model has an ordered matrix of vectors and a target token for each position. Chapter 3 builds the causal attention operation that lets each vector gather information from the permitted part of that matrix.

Notes and primary sources

  1. Rico Sennrich, Barry Haddow and Alexandra Birch, “Neural Machine Translation of Rare Words with Subword Units”, Proceedings of ACL, 2016. This paper adapts BPE to an open-vocabulary subword translation setting.

  2. Alec Radford, Jeffrey Wu, Rewon Child, David Luan, Dario Amodei and Ilya Sutskever, “Language Models are Unsupervised Multitask Learners”, OpenAI, 2019. Sections 2.2–2.3 describe GPT-2’s byte-level BPE design, 50,257-entry vocabulary and 1,024-token context configuration.

  3. OpenAI, tiktoken 0.13.0 repository and README, with the encoding API in core.py, accessed 28 July 2026. These are the implementation sources for encode, decode, decode_single_token_bytes, and special-token handling used in the tokenizer example above.

  4. Ashish Vaswani and peers, “Attention Is All You Need”, NeurIPS, 2017. Section 3.5 explains why a transformer without recurrence or convolution needs position information and presents sinusoidal encoding.

  5. Jianlin Su and peers, “RoFormer: Enhanced Transformer with Rotary Position Embedding”, 2021. The paper defines RoPE by applying position-dependent rotations to queries and keys.

  6. Shouyuan Chen and peers, “Extending Context Window of Large Language Models via Positional Interpolation”,

    1. The need for interpolation and further fine-tuning illustrates why a RoPE implementation alone does not establish reliable extrapolation.

Build layerChapter 3: Attention

Choosing the next token well often requires more than the current token. In the clause:

Chapter map for Build layer Chapter 3: Attention: From one-vector compression to direct access; The tensor contract; A non-trainable warm-up; Queries, keys and values; The causal boundary.
Mermaid chapter map. Build layer Chapter 3: Attention connects From one-vector compression to direct access, The tensor contract, A non-trainable warm-up, Queries, keys and values, The causal boundary.

Net debt to EBITDA shall not exceed 3.50× on any test date.

the meaning of 3.50× depends on words several positions away. Net debt to EBITDA names the metric; shall not exceed supplies the comparison operator; test date limits when the rule applies. A useful representation of the threshold must bring those pieces together without erasing their order.

Attention is the mechanism that performs this contextual mixing. For every position, it calculates which visible positions should contribute, then forms a weighted combination of their information. The operation is simple enough to write in a few lines of tensor code. Using it correctly requires care with shapes, masks, scaling, position information and memory.

From one-vector compression to direct access

Early neural machine-translation systems used a recurrent encoder and decoder. The encoder processed the source sentence from left to right and passed a fixed-size summary to the decoder. That design placed a demanding compression step between the input and output. Details from a long source sentence had to survive inside one vector before the decoder could use them.

Bahdanau and peers changed the interface. Their decoder could inspect all encoder states at each output step and construct a new weighted summary. When generating a translated date, it could give more weight to the source date; when generating a subject, it could favour the relevant noun phrase. The weights were learned with the rest of the model.

The transformer removed recurrence from the main sequence path. Its self-attention mechanism applies the same broad idea within one sequence: positions exchange information directly through learned weighted sums.

A recurrent encoder–decoder compresses its input into one vector, whereas an attention-based decoder can form a fresh weighted view of all encoder states.
Figure 3.1. Attention replaced a fixed information bottleneck with selective access to the sequence.

We will build the decoder form used by a GPT-style model. It differs from the original translation setting in one important respect: a token may use only itself and earlier tokens. The causal mask enforces that boundary.

Four stages add trainable projections, a causal boundary and parallel heads to a basic similarity-weighted sum.
Figure 3.2. The implementation path for this chapter.

The tensor contract

Let:

  • BB be batch size;
  • TT be sequence length;
  • DD be model width;
  • HH be the number of attention heads; and
  • Dh=D/HD_h = D/H be the width of one head.

The hidden-state tensor entering an attention layer has shape [B,T,D][B,T,D]. A multi-head layer returns the same shape so it can sit inside a residual block.

For one head, the scaled dot-product calculation is:

Attention(Q,K,V)=softmax(QK𝖳Dh+M)V \operatorname{Attention}(Q,K,V) = \operatorname{softmax}\left( \frac{QK^\mathsf{T}}{\sqrt{D_h}} + M \right)V

where MM is a mask. The shapes for one batch item are:

Tensor Shape Purpose
QQ [T,Dh][T,D_h] what each position is looking for
KK [T,Dh][T,D_h] what each position can be matched on
VV [T,Dh][T,D_h] information each position can contribute
QK𝖳QK^\mathsf{T} [T,T][T,T] one score for every query–key pair
attention weights [T,T][T,T] row-normalised mixing coefficients
output [T,Dh][T,D_h] one context vector per position

With all heads present, the score tensor has shape [B,H,T,T][B,H,T,T]. This four-dimensional object is the source of both attention’s flexibility and its cost.

The input, score, weight and context tensors move through shapes 6×3, 6×6, 6×6 and 6×3 in a small single-head example.
Figure 3.3. Shape bookkeeping exposes most attention implementation errors.

A non-trainable warm-up

Before introducing learned projections, consider six input vectors arranged as a matrix XX with shape [6,3][6,3]. A basic similarity-weighted mix is:

scores = X @ X.T
weights = torch.softmax(scores, dim=-1)
context = weights @ X

The entry scores[i, j] is the dot product between positions i and j. Softmax is applied across the last dimension, so every row becomes a distribution over source positions. context[i] is then the weighted sum of all rows of X for query position i.

Suppose one row of scores is:

[0.8, 1.9, 1.1, 0.2]

Subtracting the maximum before exponentiation gives the same softmax with better numerical stability:

s=[1.1,0.0,0.8,1.7]exp(s)[0.333,1.000,0.449,0.183]softmax(s)[0.169,0.509,0.229,0.093]. \begin{aligned} s' &= [-1.1,\;0.0,\;-0.8,\;-1.7] \\ \exp(s') &\approx [0.333,\;1.000,\;0.449,\;0.183] \\ \operatorname{softmax}(s) &\approx [0.169,\;0.509,\;0.229,\;0.093]. \end{aligned}

The second source vector supplies just over half of this context vector. The calculation does not copy that vector; it blends all four according to the weights.

Five value vectors contribute to one context vector with different illustrative mixing weights.
Figure 3.4. A context vector is a content-dependent weighted sum.

This warm-up has three limitations.

First, a single vector plays query, key and value at once. The model cannot learn separate match and payload representations. Second, the score matrix comes directly from the current hidden space; the layer has no attention parameters to train. Third, no information boundary prevents a position from using future tokens.

X @ X.T is symmetric, but the row-wise softmax result need not be. Each row has its own normalising denominator. Once separate query and key projections are used, even the raw score matrix is generally asymmetric.

Queries, keys and values

A trainable attention layer projects each hidden state three ways:

Q=XWQ,K=XWK,V=XWV. Q=XW_Q,\qquad K=XW_K,\qquad V=XW_V.

W_Q, W_K and W_V are learned parameter matrices. They share an input but not a job.

  • A query represents the features a position uses to seek relevant context.
  • A key represents the features against which other positions match.
  • A value carries the information that a successful match can contribute.

The familiar database analogy is helpful up to a point: a lookup key determines which record to select, while the returned record may contain different fields. Attention is softer than a database lookup because it normally mixes many values rather than selecting exactly one.

A hidden state branches through separate learned maps into a query, key and value; query–key scores then determine how values are mixed.
Figure 3.5. The three projections let matching and information transfer develop different representations.

Why divide the scores by Dh\sqrt{D_h}? Under a simple assumption that query and key coordinates are independent with mean zero and variance one, their dot product has variance proportional to DhD_h. Large unscaled scores make softmax very sharp. Most derivatives then become small, which can hamper learning. Dividing by Dh\sqrt{D_h} keeps the score scale more stable as the head width changes.

For GPT-2 Small, D=768D=768, H=12H=12, and Dh=64D_h=64. The divisor is therefore 64=8\sqrt{64}=8. The head dimension is 64, not 768; 768 is the combined model width across all twelve heads.

The scaled dot-product pipeline projects Q, K and V, computes and masks scores, applies row-wise softmax, then mixes V.
Figure 3.6. Scaled dot-product attention in five operations.

The causal boundary

During training, all positions in a sequence are processed in parallel. Without a mask, position 3 could inspect the correct token at position 4 while learning to predict it. Loss would look excellent, but generation would fail because position 4 does not exist yet at inference time.

A causal mask blocks score (i,j)(i,j) whenever j>ij>i. In matrix form it is upper triangular:

query 0:  ✓  ×  ×  ×
query 1:  ✓  ✓  ×  ×
query 2:  ✓  ✓  ✓  ×
query 3:  ✓  ✓  ✓  ✓

Blocked logits are set to negative infinity before softmax. Their exponential is zero, so they receive no weight.

A triangular six-position matrix allows the current and previous keys in each row and blocks every future key.
Figure 3.7. Causal masking makes parallel training obey the same visibility rule as autoregressive generation.

Removing this mask does not turn GPT into BERT. It creates a bidirectional attention pattern, but BERT also uses an encoder stack, a different training corruption objective and its own input conventions. Architecture, objective and data interface all matter.

Causal masking is not the only mask a deployed layer may need. Batches often contain padding. A key-padding mask prevents real query positions from using padding keys. The two masks express different rules:

  • the causal mask blocks information from the future; and
  • the padding mask blocks positions that are not data.

When variable-length sequences are padded on the right, their combined mask can be broadcast to [B,1,T,T][B,1,T,T]. Query padding also needs careful downstream handling; otherwise unused padded rows can still produce values even though real tokens do not attend to padded keys.

A complete multi-head implementation

One head learns one set of query, key and value projections. Multiple heads perform several attention calculations in parallel, concatenate their outputs, and apply a final output projection. The heads are not assigned jobs such as “syntax” or “dates” by the programmer. Training may produce specialised, overlapping or redundant behaviours.

The input branches into independently parameterised attention heads; their outputs are concatenated and remixed by an output projection.
Figure 3.8. Multi-head attention preserves the total model width while giving each head a smaller learned subspace.

The implementation below supports causal masking and an optional key-padding mask. It keeps line lengths modest for narrow EPUB screens.

import torch
from torch import nn


class MultiHeadCausalAttention(nn.Module):
    def __init__(
        self,
        d_model: int,
        num_heads: int,
        context_length: int,
        dropout: float = 0.0,
        qkv_bias: bool = False,
    ) -> None:
        super().__init__()
        if d_model % num_heads != 0:
            raise ValueError("d_model must be divisible by num_heads")

        self.num_heads = num_heads
        self.head_dim = d_model // num_heads
        self.d_model = d_model

        self.q_proj = nn.Linear(
            d_model, d_model, bias=qkv_bias
        )
        self.k_proj = nn.Linear(
            d_model, d_model, bias=qkv_bias
        )
        self.v_proj = nn.Linear(
            d_model, d_model, bias=qkv_bias
        )
        self.out_proj = nn.Linear(d_model, d_model)
        self.attn_dropout = nn.Dropout(dropout)

        causal = torch.triu(
            torch.ones(
                context_length,
                context_length,
                dtype=torch.bool,
            ),
            diagonal=1,
        )
        self.register_buffer(
            "causal_mask",
            causal,
            persistent=False,
        )

    def _split_heads(self, x: torch.Tensor) -> torch.Tensor:
        batch, tokens, _ = x.shape
        x = x.view(
            batch,
            tokens,
            self.num_heads,
            self.head_dim,
        )
        return x.transpose(1, 2)

    def forward(
        self,
        x: torch.Tensor,
        key_is_padding: torch.Tensor | None = None,
    ) -> torch.Tensor:
        batch, tokens, width = x.shape
        if width != self.d_model:
            raise ValueError("unexpected model width")
        if tokens > self.causal_mask.shape[0]:
            raise ValueError("sequence exceeds context length")

        q = self._split_heads(self.q_proj(x))
        k = self._split_heads(self.k_proj(x))
        v = self._split_heads(self.v_proj(x))

        scores = q @ k.transpose(-2, -1)
        scores = scores / (self.head_dim**0.5)

        causal = self.causal_mask[:tokens, :tokens]
        scores = scores.masked_fill(
            causal[None, None, :, :],
            -torch.inf,
        )

        if key_is_padding is not None:
            expected = (batch, tokens)
            if key_is_padding.shape != expected:
                raise ValueError(
                    f"padding mask must have shape {expected}"
                )
            scores = scores.masked_fill(
                key_is_padding[:, None, None, :],
                -torch.inf,
            )

        weights = torch.softmax(scores, dim=-1)
        weights = self.attn_dropout(weights)
        context = weights @ v

        context = context.transpose(1, 2).contiguous()
        context = context.view(batch, tokens, self.d_model)
        return self.out_proj(context)

The three projections initially produce [B,T,D][B,T,D]. _split_heads reshapes that to [B,H,T,Dh][B,H,T,D_h]. The score multiplication therefore yields [B,H,T,T][B,H,T,T]. After the value multiplication, transposition and reshape restore [B,T,D][B,T,D].

contiguous() matters because transpose changes the tensor’s strides. A subsequent view requires a compatible contiguous memory layout.

The causal mask is registered as a buffer so it follows the module across devices without being optimised. Marking it non-persistent avoids storing a reconstructible triangular matrix in every checkpoint. Either choice is valid if the loading contract is explicit.

When dropout is active, it is applied to attention probabilities during training. The retained entries are rescaled in the usual dropout manner; a realised row therefore need not sum exactly to one. Evaluation mode disables dropout.

Tests that catch real mistakes

Successful execution is not enough. Attention bugs can produce plausible shapes while violating causality.

def test_attention_contract() -> None:
    torch.manual_seed(7)
    layer = MultiHeadCausalAttention(
        d_model=12,
        num_heads=3,
        context_length=8,
        dropout=0.0,
    )
    layer.eval()

    x = torch.randn(2, 5, 12)
    y = layer(x)
    assert y.shape == x.shape
    assert torch.isfinite(y).all()


def test_future_token_cannot_change_the_past() -> None:
    torch.manual_seed(8)
    layer = MultiHeadCausalAttention(
        d_model=12,
        num_heads=3,
        context_length=8,
        dropout=0.0,
    )
    layer.eval()

    original = torch.randn(1, 5, 12)
    changed = original.clone()
    changed[:, 4, :] += 100.0

    y_original = layer(original)
    y_changed = layer(changed)

    torch.testing.assert_close(
        y_original[:, :4, :],
        y_changed[:, :4, :],
        atol=1e-6,
        rtol=1e-6,
    )

The second test deliberately changes the final input. Outputs at positions zero through three must remain unchanged. The final output may change because it is allowed to use the final input.

A fuller test suite should also check:

  • a padding key cannot affect real-token outputs;
  • gradients reach all four projection layers;
  • invalid head counts and excessive sequence lengths fail clearly;
  • dropout is reproducible under a fixed seed in training mode;
  • serial single-head and vectorised multi-head implementations agree when parameters are copied carefully; and
  • output values match a trusted framework implementation for a fixed fixture.

Position remains a separate problem

Self-attention does not encode token order from content vectors alone. Without position information and without a directional mask, permuting the input permutes the outputs in the same way. The operation is permutation-equivariant.

The causal mask adds a directional visibility structure: earlier and later positions no longer have identical neighbourhoods. A decoder still needs a position representation to distinguish locations effectively. GPT-2 adds learned absolute position embeddings before the first transformer block. Many later decoders rotate queries and keys using rotary position embeddings. Neither choice is contained in the attention formula itself.

Long-context behaviour also requires more than substituting a rotary encoding. The training distribution, positional scaling method, numerical precision, attention implementation and evaluation length all affect extrapolation. A model trained only on short sequences should not be presumed reliable at a much longer length because its code accepts a larger tensor.

The quadratic cost

The score matrix contains T2T^2 entries per head. Doubling sequence length roughly quadruples the score calculation and the size of a materialised attention matrix:

Sequence length Pairwise scores per head Relative to 1,024
1,024 1,048,576
4,096 16,777,216 16×
16,384 268,435,456 256×
131,072 17,179,869,184 16,384×

Those counts describe the logical score pairs. An implementation need not store the whole matrix at once.

FlashAttention reorganises exact attention into tiles so intermediate scores need not make repeated round trips to high-bandwidth memory. It reduces memory traffic and the amount of stored intermediate state; it does not change dense attention’s quadratic pairwise work into a linear algorithm.

Autoregressive inference has a different optimisation. Once a prefix has been processed, the keys and values for its tokens can be cached. The next decoding step computes a query for the new token and attends to the cached keys and values. This avoids recomputing the entire prefix, but the cache grows linearly with sequence length, layers and key–value heads. Each new token still compares against the visible prefix.

Multi-query attention shares one set of key–value heads across many query heads. Grouped-query attention uses an intermediate number of key–value groups. Both reduce cache size and memory bandwidth. They do not remove the need for multiple query heads, nor do they make the context free.

When evaluating a long-context design, separate at least four quantities:

  1. training-time arithmetic;
  2. training-time activation memory;
  3. prefill latency for an existing prompt; and
  4. per-token decode latency and key–value cache memory.

One phrase such as “supports 128K” hides all four.

Parameter and cache accounting

The projection matrices also deserve a concrete count. Ignoring bias terms, a standard multi-head layer with equal input and output width has:

3D2parameters for WQ,WK,WV 3D^2 \quad\text{parameters for } W_Q,W_K,W_V

and:

D2parameters for the output projection. D^2 \quad\text{parameters for the output projection}.

At D=768D=768, that is 4×7682=2,359,2964\times768^2=2{,}359{,}296 attention parameters per block. Splitting the width into twelve heads does not multiply that count: the three large projection matrices already produce all heads at once.

The key–value cache has a different shape. For a conventional layer, the cached keys and values for one sequence each contain [H,T,Dh][H,T,D_h] elements. Together they require 2TD2TD elements per layer. For GPT-2 Small at T=1,024T=1{,}024, that is:

2×1,024×768=1,572,864 2 \times 1{,}024 \times 768 = 1{,}572{,}864

elements per layer, before batch size and numeric precision are considered. Across twelve layers in 16-bit storage, the idealised payload is about 36 MiB for one sequence. Real serving memory includes allocation overhead, temporary buffers and framework-specific layout.

This calculation explains why grouped-query attention can matter even when it does not alter the number of query heads. If twelve query heads share, for example, three key–value groups, the cached key–value width falls by a factor of four. Quality and throughput still need measurement on the intended model and hardware; the arithmetic only identifies the resource being reduced.

Prefill and decode are different workloads

Serving dashboards often report one latency number for a request. That hides two phases.

During prefill, the model processes the supplied prompt. All prompt positions are available, so the hardware performs large, parallel matrix operations. Time to first token is strongly affected by prompt length.

During decode, the model adds one token at a time. The cache prevents recomputation of old keys and values, but each new query still reads the visible cache. This phase has much smaller matrix operations and is often more sensitive to memory bandwidth and batching strategy.

For a banking assistant, a 40-page document pasted into every request creates a prefill problem. Retaining many simultaneous long conversations creates a cache-capacity problem. Retrieval, prompt compaction and state management address different parts of that load; none changes the attention formula inside the checkpoint.

Failure clinic

Most first implementations fail in ordinary ways. The resulting tensors may still look plausible, which is why each mistake should have a targeted test.

Softmax on the wrong axis

Attention normalises over keys for each query. With scores shaped [batch, heads, query, key], the correct call is:

weights = torch.softmax(scores, dim=-1)

Normalising over queries answers a different question and couples unrelated rows. A test should assert that an un-dropped row sums to one along the key axis.

Masking after softmax

Multiplying probabilities by a zero-one mask after softmax leaves the retained entries under-normalised. Renormalisation can repair the arithmetic, but masking logits before softmax is clearer and avoids assigning probability mass to forbidden positions in the first place.

Scaling by total width

Each head’s dot product contains DhD_h terms. Scaling by D\sqrt{D} makes the result depend on the chosen head count and over-corrects when H>1H>1.

A fully masked row

If every key in a row is set to negative infinity, softmax receives no finite candidate and can produce NaN. Right-padding with ordinary causal queries does not fully mask a real query row, because each real position can see itself. More complicated block masks and empty examples can. Validate the data contract and either remove such rows or define an explicit safe fallback.

Padding leakage

A causal mask does not identify padding, but contiguous right padding comes after every real token and is therefore already invisible to real causal queries. Padding metadata is still required for left padding, interior padding, prefix or bidirectional attention patterns, and kernels whose batching interface expects it. Right-padded query rows must also be excluded from loss and pooling; choosing the final array position as a classification readout may select padding rather than the final real token. Mask design, loss masking and pooling must agree.

In-place operations and mixed precision

In-place masking can interfere with autograd when the same tensor is needed elsewhere. It also makes debugging snapshots harder. Start with the out-of-place masked_fill used in this chapter. Optimise only after profiling.

In lower-precision training, use a mask value and attention kernel appropriate to the dtype. A large finite negative number is sometimes used instead of -inf, but its behaviour depends on the softmax implementation and numeric range. A trusted framework kernel is preferable to inventing a magic constant.

Assuming a head has a stable human meaning

Head-level patterns can change after fine-tuning, quantisation or even functionally equivalent reparameterisation. Treat a label such as “date head” as a hypothesis tied to a model version and evaluation set, not as an architectural guarantee.

What attention weights can and cannot tell us

An attention map is a record of one internal mixing operation. It can help an engineer inspect masks, identify dead or highly concentrated heads, and compare behaviour across fixtures. It is not, by itself, a faithful causal explanation of a model’s output.

Several factors break the simple story that “the highest weight is the most important word”:

  • values and the output projection transform what a weight carries;
  • information may have been mixed by earlier layers;
  • residual connections provide paths around the attention sublayer;
  • different heads may compensate for one another; and
  • alternative attention patterns can sometimes produce similar outputs.

The literature includes both demonstrations of these limitations and arguments for narrower, carefully specified uses of attention as explanation. The practical conclusion is modest: state what was measured. “Head 4 assigned weight 0.31 to token 17” is an observation. “Token 17 caused the credit decision” is a much stronger claim requiring intervention-based or otherwise validated attribution evidence.

For consequential systems, explanations should be anchored in application evidence: cited source passages, deterministic calculations, feature provenance, rule traces, model version, input snapshot and human review. An attention heatmap may accompany engineering diagnostics; it should not stand in for that audit trail.

Worked case: a covenant clause

Consider this synthetic clause from a fictional facility agreement:

The borrower shall ensure that consolidated net debt to EBITDA does not exceed 3.50× on each quarter-end test date.

A decoder representation near 3.50× can use earlier tokens for the metric, operator and subject. Multiple heads may learn useful mixing patterns. The application still should not ask an unconstrained language-model output to become the system of record.

A safer boundary is:

source page and coordinates
        ↓
candidate span extraction
        ↓
typed parse:
  metric = "net_debt_to_ebitda"
  operator = "<="
  threshold = Decimal("3.50")
  test_frequency = "quarter_end"
        ↓
deterministic schema and arithmetic checks
        ↓
human confirmation for ambiguous or high-impact cases

The model can propose spans and normalised fields. Deterministic code should check that the operator agrees with the source language, parse the number with decimal arithmetic, preserve units, retain page provenance and reject values outside the permitted schema. If two clauses conflict, the system should surface both rather than average them into a fluent answer.

This division of labour follows directly from the mechanism. Attention is good at contextual representation. It does not guarantee exact extraction, calibrated confidence or policy authority.

Test fixtures for the case

A small evaluation set should vary one semantic factor at a time. For example:

Fixture Expected typed result
“shall not exceed 3.50×” operator <=, threshold 3.50
“shall be at least 3.50×” operator >=, threshold 3.50
“3.50×, stepping down to 3.25×” two dated thresholds, not one average
“excluding permitted acquisitions” qualification retained
table value conflicts with prose conflict status; no auto-approval
OCR reads 8.50× instead of 3.50× low-trust route or source re-read

These fixtures test the application contract, not whether a chosen head looks at exceed. A system may pass through several internal attention patterns and still satisfy the same typed behaviour. Conversely, a persuasive heatmap does not excuse a wrong operator.

Build check

Before placing the layer inside a transformer block, verify that you can answer and test each of these questions.

  1. Which dimension does softmax normalise, and why?
  2. Why is the scale factor based on head width rather than total model width?
  3. What shapes do q, k, v, scores and the output have?
  4. What distinct failures do causal and padding masks prevent?
  5. Can changing a future input alter an earlier output in evaluation mode?
  6. Why is out_proj present after concatenating the heads?
  7. Which costs remain quadratic, and which memory traffic does a tiled implementation avoid?
  8. Why is a key–value cache useful, and what resource does it consume?
  9. What exactly can an attention map support as evidence?
  10. Which deterministic controls remain necessary in the covenant example?

The component we carry into Chapter 4 has a compact contract: [B,T,D][B,T,D] enters, [B,T,D][B,T,D] leaves, no output position uses a future input, and all contextual mixing remains differentiable. The next task is to place it inside a stable residual block.

Notes and further reading

  1. Dzmitry Bahdanau, Kyunghyun Cho and Yoshua Bengio, “Neural Machine Translation by Jointly Learning to Align and Translate” (2014), arXiv:1409.0473.
  2. Ashish Vaswani and peers, “Attention Is All You Need” (2017), arXiv:1706.03762.
  3. Tri Dao and peers, “FlashAttention: Fast and Memory-Efficient Exact Attention with IO-Awareness” (2022), arXiv:2205.14135.
  4. Joshua Ainslie and peers, “GQA: Training Generalized Multi-Query Transformer Models from Multi-Head Checkpoints” (2023), arXiv:2305.13245.
  5. Sarthak Jain and Byron C. Wallace, “Attention is not Explanation” (2019), ACL Anthology N19-1357.
  6. Sarah Wiegreffe and Yuval Pinter, “Attention is not not Explanation” (2019), ACL Anthology D19-1002.

Build layerChapter 4: Assembling the full decoder

Chapter 3 ended with causal multi-head attention: a module that accepts a sequence of vectors and returns a sequence of the same shape. That module can move information between positions, but it is not yet a language model. A complete decoder also needs position-wise nonlinear computation, stable residual streams, repeated blocks, token and position embeddings, and a projection from hidden states to vocabulary logits.

Chapter map for Build layer Chapter 4: Assembling the full decoder: Layer normalisation stabilises each token vector; GELU supplies a smooth nonlinearity; The feed-forward network expands, transforms and contracts; Residual paths provide an additive route; One transformer block.
Mermaid chapter map. Build layer Chapter 4: Assembling the full decoder connects Layer normalisation stabilises each token vector, GELU supplies a smooth nonlinearity, The feed-forward network expands, transforms and contracts, Residual paths provide an additive route, One transformer block.

This chapter builds those parts once, in dependency order, and then joins them in one runnable implementation. The reference configuration has the familiar shape of a small GPT-2-style model: width 768, 12 heads, 12 blocks, a 1,024-token context and a 50,257-token vocabulary. The implementation is educational rather than checkpoint-compatible. Loading a published checkpoint also requires an exact match in parameter names, tensor layout, initialisation, tokenizer and any implementation-specific conventions.

Four symbols will keep the tensor ledger readable:

  • BB: batch size;
  • TT: sequence length;
  • DD: model width, called d_model in the code; and
  • VV: vocabulary size.

Hidden states keep the shape [B,T,D][B,T,D] throughout the block stack. Attention divides DD across heads, while the feed-forward network temporarily expands the last dimension. The language-model head alone changes the last dimension from DD to VV.

Layer normalisation stabilises each token vector

For one token vector xDx \in \mathbb{R}^{D}, LayerNorm computes the mean and population variance across its DD features:

μ=1Di=1Dxi,σ2=1Di=1D(xiμ)2. \mu = \frac{1}{D}\sum_{i=1}^{D}x_i, \qquad \sigma^2 = \frac{1}{D}\sum_{i=1}^{D}(x_i-\mu)^2.

It then normalises the vector and applies a learned scale γ\gamma and shift β\beta:

LayerNorm(x)=γxμσ2+ϵ+β. \operatorname{LayerNorm}(x) = \gamma \odot \frac{x-\mu}{\sqrt{\sigma^2+\epsilon}} +\beta.

The small constant ϵ\epsilon prevents division by zero. At initialisation, γ\gamma is one and β\beta is zero, so a non-constant vector leaves the operation with approximately zero mean and unit variance. During training, the two learned vectors allow each feature to acquire a useful scale and offset.

Statistics are computed independently for every token in every example. LayerNorm therefore does not depend on other items in the batch, and its calculation is the same in training and evaluation. This differs from BatchNorm, which estimates statistics across examples.

The placement of normalisation changes the residual block. The original Transformer used post-normalisation: a sublayer ran first, its residual was added, and the sum was normalised. GPT-2 moved normalisation before each sublayer and added a final LayerNorm after the block stack. This pre-normalisation arrangement often gives more manageable gradients at initialisation and can simplify optimisation. “Often” matters. Pre-LN is not strictly superior for every depth, objective, initialisation or training recipe; post-LN models can train well with suitable warm-up, scaling and other stabilisation.

LayerNorm operates on hidden features. It should not be described as normalising the human-scale values mentioned in text. The token sequence “£24 million” is represented by learned vectors; LayerNorm does not inspect the currency amount and rescale it as an accounting number.

GELU supplies a smooth nonlinearity

Two linear maps in succession collapse to one linear map. A feed-forward network needs a nonlinearity between them if it is to represent nonlinear functions. The Gaussian Error Linear Unit is

GELU(x)=xΦ(x), \operatorname{GELU}(x) = x\Phi(x),

where Φ(x)\Phi(x) is the cumulative distribution function of a standard normal variable. The code in this chapter evaluates that definition through the error function. A frequently seen expression using tanh and the coefficient 0.044715 is an approximation, not the definition.

GELU differs from ReLU most visibly around zero. ReLU clips every negative value to zero; GELU has a small negative region and approaches zero smoothly as the input moves left. Its derivative also changes smoothly. These properties can affect optimisation, but they do not prove that GELU wins in every architecture or training regime.

ReLU has a hard zero for negative inputs, whereas GELU changes smoothly and retains a small negative region around the origin; the optimisation consequence depends on the full training setup.
Figure 4.1. ReLU and GELU impose different local geometry around zero. The plot describes their functions, not a universal ranking of model quality.

The feed-forward network expands, transforms and contracts

Attention mixes information across sequence positions. The feed-forward network applies the same nonlinear transformation to each position independently:

FFN(x)=W2GELU(W1x+b1)+b2. \operatorname{FFN}(x) = W_2\,\operatorname{GELU}(W_1x+b_1)+b_2.

In the reference model, W1W_1 expands DD features to 4D4D, and W2W_2 contracts them to DD. For D=768D=768, the intermediate width is 3,072. This four-times ratio is a design choice inherited by many early Transformers; it is not a mathematical requirement. Gated networks often choose a different intermediate width to keep their parameter budget comparable.

The expansion gives the activation function more channels on which to operate. The contraction is equally important: it restores [B,T,D][B,T,D], allowing the result to be added to the residual stream and passed to the next block.

Each token vector expands from 768 to 3,072 channels, passes through GELU and contracts to 768, so the feed-forward sublayer adds nonlinear capacity without changing the residual-stream shape.
Figure 4.2. The feed-forward network changes channels at each position but does not exchange information between positions.

With biases, one D4DDD \rightarrow 4D \rightarrow D feed-forward network contains

D(4D)+4D+(4D)D+D=8D2+5D D(4D)+4D+(4D)D+D = 8D^2+5D

parameters. At D=768D=768, that is 4,722,432 parameters. This exceeds the parameter count of the attention projections in the same block, although parameter count alone does not measure runtime: attention also forms matrices whose size grows with sequence length.

Residual paths provide an additive route

A residual sublayer has the form

y=x+f(x). y = x + f(x).

Its Jacobian with respect to xx is

yx=I+Jf(x). \frac{\partial y}{\partial x} = I + J_f(x).

The identity term supplies a direct additive path for activations and gradients. It does not guarantee that every gradient component has magnitude at least one. The branch Jacobian can rotate, amplify or partly cancel the identity contribution; an eigenvalue of JfJ_f near 1-1 can make the corresponding eigenvalue of I+JfI+J_f near zero. Across many blocks, the total Jacobian is still a product of block Jacobians.

The practical benefit is more modest and more defensible: the optimiser does not have to transmit all information through every learned transformation. A block can begin near an identity mapping, and shorter gradient routes are available. Initialisation, normalisation, depth and the learned branch all remain relevant.

A residual stack adds an identity route around learned transformations, shortening possible gradient paths without guaranteeing a fixed gradient magnitude.
Figure 4.3. Residual connections create an alternative route. They mitigate an optimisation problem; they do not impose the inequality I+JfII+J_f \geq I.

In a pre-normalisation decoder block, the two updates are

x=x+Dropout(Attention(LN1(x))) x' = x + \operatorname{Dropout} \left(\operatorname{Attention}(\operatorname{LN}_1(x))\right)

and

y=x+Dropout(FFN(LN2(x))). y = x' + \operatorname{Dropout} \left(\operatorname{FFN}(\operatorname{LN}_2(x'))\right).

Both learned branches return [B,T,D][B,T,D], so both additions are well-defined. Dropout is active during training and disabled in evaluation.

One transformer block

The block now has a precise division of labour. LayerNorm controls the scale presented to each learned sublayer. Causal attention mixes information from permitted earlier positions. The feed-forward network transforms each position separately. Residual additions carry the stream through both operations.

A pre-normalisation decoder block applies LayerNorm before causal attention and before the feed-forward network, with dropout and a residual addition after each sublayer.
Figure 4.4. One block preserves [B,T,D][B,T,D]. Shape preservation makes depth a configuration choice rather than a wiring change.

The word “identical” can be misleading when blocks are stacked. They share an architecture, but each block normally owns different learned parameters. Reusing one block object repeatedly would tie all block weights and create a different model.

The causal mask is also part of the block contract. At position tt, attention may read positions 00 through tt, never positions t+1t+1 onwards. Padded batches need an additional key-padding mask. The compact implementation accepts that optional mask; ordinary packed language-model batches can omit it because every supplied position is real.

A complete runnable reference model

This is the sole model implementation used in the chapter. It includes exact GELU, custom LayerNorm, causal multi-head attention, the feed-forward network, pre-normalisation blocks, learned absolute positions, tied token embeddings and a greedy generation interface.

import math
from dataclasses import dataclass

import torch
import torch.nn as nn
import torch.nn.functional as F


@dataclass(frozen=True)
class GPTConfig:
    vocab_size: int = 50_257
    context_length: int = 1_024
    d_model: int = 768
    n_heads: int = 12
    n_layers: int = 12
    dropout: float = 0.1
    qkv_bias: bool = True
    ff_multiplier: int = 4
    tie_embeddings: bool = True

    def __post_init__(self) -> None:
        if self.d_model % self.n_heads != 0:
            raise ValueError("d_model must be divisible by n_heads")
        if min(
            self.vocab_size,
            self.context_length,
            self.d_model,
            self.n_heads,
            self.n_layers,
            self.ff_multiplier,
        ) <= 0:
            raise ValueError("configuration values must be positive")
        if not 0.0 <= self.dropout < 1.0:
            raise ValueError("dropout must be in [0, 1)")


class LayerNorm(nn.Module):
    def __init__(self, d_model: int, eps: float = 1e-5) -> None:
        super().__init__()
        self.eps = eps
        self.weight = nn.Parameter(torch.ones(d_model))
        self.bias = nn.Parameter(torch.zeros(d_model))

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        mean = x.mean(dim=-1, keepdim=True)
        variance = x.var(dim=-1, keepdim=True, unbiased=False)
        normalised = (x - mean) * torch.rsqrt(variance + self.eps)
        return self.weight * normalised + self.bias


class GELU(nn.Module):
    def forward(self, x: torch.Tensor) -> torch.Tensor:
        return 0.5 * x * (1.0 + torch.erf(x / math.sqrt(2.0)))


class FeedForward(nn.Module):
    def __init__(self, cfg: GPTConfig) -> None:
        super().__init__()
        hidden_size = cfg.ff_multiplier * cfg.d_model
        self.in_proj = nn.Linear(cfg.d_model, hidden_size)
        self.activation = GELU()
        self.out_proj = nn.Linear(hidden_size, cfg.d_model)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        return self.out_proj(self.activation(self.in_proj(x)))


class CausalSelfAttention(nn.Module):
    def __init__(self, cfg: GPTConfig) -> None:
        super().__init__()
        self.d_model = cfg.d_model
        self.n_heads = cfg.n_heads
        self.head_dim = cfg.d_model // cfg.n_heads

        self.qkv = nn.Linear(
            cfg.d_model,
            3 * cfg.d_model,
            bias=cfg.qkv_bias,
        )
        self.out_proj = nn.Linear(cfg.d_model, cfg.d_model)
        self.attention_dropout = nn.Dropout(cfg.dropout)

        mask = torch.triu(
            torch.ones(
                cfg.context_length,
                cfg.context_length,
                dtype=torch.bool,
            ),
            diagonal=1,
        )
        self.register_buffer(
            "causal_mask",
            mask.view(1, 1, cfg.context_length, cfg.context_length),
            persistent=False,
        )

    def forward(
        self,
        x: torch.Tensor,
        key_is_padding: torch.Tensor | None = None,
    ) -> torch.Tensor:
        batch_size, n_tokens, _ = x.shape
        query, key, value = self.qkv(x).chunk(3, dim=-1)

        def split_heads(tensor: torch.Tensor) -> torch.Tensor:
            return tensor.view(
                batch_size,
                n_tokens,
                self.n_heads,
                self.head_dim,
            ).transpose(1, 2)

        query = split_heads(query)
        key = split_heads(key)
        value = split_heads(value)

        scores = query @ key.transpose(-2, -1)
        scores = scores * (self.head_dim ** -0.5)
        mask = self.causal_mask[:, :, :n_tokens, :n_tokens]
        scores = scores.masked_fill(mask, float("-inf"))

        if key_is_padding is not None:
            expected = (batch_size, n_tokens)
            if key_is_padding.shape != expected:
                raise ValueError(
                    f"padding mask must have shape {expected}"
                )
            scores = scores.masked_fill(
                key_is_padding[:, None, None, :],
                float("-inf"),
            )

        weights = F.softmax(
            scores,
            dim=-1,
            dtype=torch.float32,
        ).to(query.dtype)
        weights = self.attention_dropout(weights)
        context = weights @ value

        context = context.transpose(1, 2).contiguous()
        context = context.view(batch_size, n_tokens, self.d_model)
        return self.out_proj(context)


class TransformerBlock(nn.Module):
    def __init__(self, cfg: GPTConfig) -> None:
        super().__init__()
        self.norm_1 = LayerNorm(cfg.d_model)
        self.attention = CausalSelfAttention(cfg)
        self.norm_2 = LayerNorm(cfg.d_model)
        self.feed_forward = FeedForward(cfg)
        self.residual_dropout = nn.Dropout(cfg.dropout)

    def forward(
        self,
        x: torch.Tensor,
        key_is_padding: torch.Tensor | None = None,
    ) -> torch.Tensor:
        attention_out = self.attention(
            self.norm_1(x),
            key_is_padding=key_is_padding,
        )
        x = x + self.residual_dropout(attention_out)
        feed_forward_out = self.feed_forward(self.norm_2(x))
        return x + self.residual_dropout(feed_forward_out)


class GPTModel(nn.Module):
    def __init__(self, cfg: GPTConfig) -> None:
        super().__init__()
        self.cfg = cfg
        self.token_embedding = nn.Embedding(
            cfg.vocab_size,
            cfg.d_model,
        )
        self.position_embedding = nn.Embedding(
            cfg.context_length,
            cfg.d_model,
        )
        self.embedding_dropout = nn.Dropout(cfg.dropout)
        self.blocks = nn.ModuleList(
            TransformerBlock(cfg) for _ in range(cfg.n_layers)
        )
        self.final_norm = LayerNorm(cfg.d_model)
        self.lm_head = nn.Linear(
            cfg.d_model,
            cfg.vocab_size,
            bias=False,
        )

        self.apply(self._initialise)
        residual_std = 0.02 / math.sqrt(2 * cfg.n_layers)
        for block in self.blocks:
            nn.init.normal_(
                block.attention.out_proj.weight,
                mean=0.0,
                std=residual_std,
            )
            nn.init.normal_(
                block.feed_forward.out_proj.weight,
                mean=0.0,
                std=residual_std,
            )

        if cfg.tie_embeddings:
            self.lm_head.weight = self.token_embedding.weight

    @staticmethod
    def _initialise(module: nn.Module) -> None:
        if isinstance(module, nn.Linear):
            nn.init.normal_(module.weight, mean=0.0, std=0.02)
            if module.bias is not None:
                nn.init.zeros_(module.bias)
        elif isinstance(module, nn.Embedding):
            nn.init.normal_(module.weight, mean=0.0, std=0.02)

    def forward_features(
        self,
        input_ids: torch.Tensor,
        key_is_padding: torch.Tensor | None = None,
    ) -> torch.Tensor:
        if input_ids.ndim != 2:
            raise ValueError("input_ids must have shape [batch, tokens]")

        batch_size, n_tokens = input_ids.shape
        if n_tokens > self.cfg.context_length:
            raise ValueError("sequence exceeds configured context length")
        if key_is_padding is not None:
            expected = (batch_size, n_tokens)
            if key_is_padding.shape != expected:
                raise ValueError(
                    f"padding mask must have shape {expected}"
                )

        positions = torch.arange(n_tokens, device=input_ids.device)
        token_vectors = self.token_embedding(input_ids)
        position_vectors = self.position_embedding(positions)
        x = self.embedding_dropout(token_vectors + position_vectors)

        for block in self.blocks:
            x = block(
                x,
                key_is_padding=key_is_padding,
            )

        return self.final_norm(x)

    def forward(
        self,
        input_ids: torch.Tensor,
        key_is_padding: torch.Tensor | None = None,
    ) -> torch.Tensor:
        hidden = self.forward_features(
            input_ids,
            key_is_padding=key_is_padding,
        )
        return self.lm_head(hidden)


@torch.inference_mode()
def generate_greedy(
    model: GPTModel,
    input_ids: torch.Tensor,
    max_new_tokens: int,
) -> torch.Tensor:
    was_training = model.training
    model.eval()
    try:
        for _ in range(max_new_tokens):
            window = input_ids[:, -model.cfg.context_length :]
            logits = model(window)
            next_token = logits[:, -1, :].argmax(
                dim=-1,
                keepdim=True,
            )
            input_ids = torch.cat((input_ids, next_token), dim=1)
        return input_ids
    finally:
        model.train(was_training)


def count_parameters(model: nn.Module) -> int:
    return sum(parameter.numel() for parameter in model.parameters())


def expected_parameter_count(cfg: GPTConfig) -> int:
    d_model = cfg.d_model
    hidden_size = cfg.ff_multiplier * d_model

    qkv = 3 * d_model * d_model
    if cfg.qkv_bias:
        qkv += 3 * d_model
    attention = qkv + d_model * d_model + d_model

    feed_forward = (
        d_model * hidden_size
        + hidden_size
        + hidden_size * d_model
        + d_model
    )
    two_layer_norms = 4 * d_model
    all_blocks = cfg.n_layers * (
        attention + feed_forward + two_layer_norms
    )

    embeddings = (
        cfg.vocab_size * d_model
        + cfg.context_length * d_model
    )
    final_layer_norm = 2 * d_model
    untied_head = (
        0 if cfg.tie_embeddings else cfg.vocab_size * d_model
    )
    return embeddings + all_blocks + final_layer_norm + untied_head

The three projections for queries, keys and values share one Linear module for efficient implementation, then split along the last dimension. Each tensor is reshaped from [B,T,D][B,T,D] to [B,H,T,D/H][B,H,T,D/H]. The attention result reverses that reshape before the output projection.

Softmax is evaluated in float32 and cast back to the query dtype. This small implementation choice reduces avoidable numerical trouble in lower precision. More specialised kernels can perform the same operation with different memory and execution strategies.

The initialisation scales the two residual-output projections by 1/2L1/\sqrt{2L}, where LL is the number of blocks. There are two residual branches per block. This follows the depth-aware idea described with GPT-2; it is part of the training recipe rather than a change to the forward-pass algebra.

The forward pass, shape by shape

Token IDs and position IDs become embeddings, pass through twelve shape-preserving decoder blocks and a final LayerNorm, then project to one vocabulary-sized logit vector at every position.
Figure 4.5. The model preserves batch and sequence axes. Only the final projection changes the feature axis from DD to VV.

For a batch of two six-token sequences, the default configuration produces:

Stage Operation Output shape
Input token identifiers [2,6][2,6]
Embedding token plus learned position vectors [2,6,768][2,6,768]
Block stack 12 pre-normalisation blocks [2,6,768][2,6,768]
Final normalisation LayerNorm over the last axis [2,6,768][2,6,768]
Language-model head dot products with vocabulary rows [2,6,50,257][2,6,50{,}257]

The logit vector at position tt predicts the token at t+1t+1 during next-token training. All positions can be processed in parallel because the causal mask prevents information from leaking backwards from future targets.

A synthetic banking fragment such as “Leverage remains within the agreed limit” changes none of this machinery. After tokenisation it is a sequence of identifiers. The architecture can learn statistical relations among those tokens; it does not calculate a leverage ratio or establish that a limit is genuinely satisfied. Those claims need sourced inputs and deterministic calculation outside the base model.

Parameter accounting and weight tying

The parameter ledger should match the code, including biases and tying.

For D=768D=768 with QKV biases:

  • QKV and attention output projections: 2,362,368 parameters;
  • 7683,072768768 \rightarrow 3{,}072 \rightarrow 768 feed-forward network: 4,722,432;
  • two LayerNorm modules: 3,072; and
  • one complete block: 7,087,872.

Twelve blocks therefore contain 85,054,464 parameters. The token table contains 38,597,376, the position table contains 786,432, and final LayerNorm contains 1,536.

In one 768-wide block, the feed-forward matrices contain about 4.72 million parameters, attention projections about 2.36 million, and two LayerNorms about three thousand.
Figure 4.6. Matrix multiplications dominate the block’s parameter budget. Runtime and memory cannot be inferred from this bar alone because attention also depends on sequence length.

The language-model head has shape [V,D][V,D], the same shape as the token-embedding table. When tie_embeddings=True, both modules refer to the same Parameter. The head computes dot products against the shared rows; it is not an inverse of the embedding lookup. PyTorch’s parameter iterator counts the shared object once.

The default model in this chapter contains exactly 124,439,808 unique parameters. Untying the head adds 50,257×768=38,597,37650{,}257 \times 768 = 38{,}597{,}376, producing 163,037,184. Size labels such as “124M” are rounded, and published reports do not always use identical counting conventions. The executable count is the authority for this implementation.

Six different memory accounts

“The model needs 475 MiB” describes only one narrow case: 124,439,808 parameters stored at four bytes each occupy about 474.70 MiB. It is not a training-memory estimate.

  1. Parameters hold the current weights. The default model needs about 474.70 MiB in float32 or 237.35 MiB in float16/bfloat16.
  2. Gradients are normally allocated for trainable parameters after backpropagation. Their dtype and lifetime depend on the training system.
  3. Optimiser state is separate. Adam-style optimisation commonly stores two moment tensors; mixed-precision systems may also retain float32 master weights. Exact storage is implementation-dependent.
  4. Activations are intermediate tensors retained for backward. They depend on batch size, sequence length, depth, width, attention implementation and activation checkpointing.
  5. Logits and temporary workspaces can be substantial. A full [B,T,V][B,T,V] logit tensor grows with vocabulary and sequence length, while fused kernels may allocate additional workspace.
  6. The KV cache stores past keys and values during cached autoregressive inference. It is not parameter memory and is absent from the simple generator above.

For ordinary multi-head attention, a rough KV cache element count is

2LBTHkvdhead, 2LBT H_{\mathrm{kv}}d_{\mathrm{head}},

where the factor two represents keys and values. With 12 layers, batch one, 1,024 cached positions, 12 key–value heads, head width 64 and two-byte elements, the cache is about 36 MiB. Batch size and generated context increase it linearly. Grouped-query attention reduces HkvH_{\mathrm{kv}}, which is why it can lower inference memory without reducing the number of query heads.

In straightforward float32 training, parameters, gradients and two Adam moment tensors alone require about 1.85 GiB for this model. Activations, workspaces, allocator overhead and any master copy come on top. A defensible capacity plan names every bucket instead of multiplying parameter count by a single folklore constant.

Autoregressive generation

Generation repeatedly runs the model on a visible prefix, selects a token from the logits at the final position and appends that token. The reference function uses greedy selection, so argmax can operate directly on logits; computing softmax would not change the winner.

Autoregressive generation extends one visible prefix by one token at a time, using the final-position logits for each new choice.
Figure 4.7. Training predicts all shifted positions in parallel; inference exposes only one new token per decoding step.

The reference generator crops to the configured context window and recomputes the window at every step. That keeps the implementation legible but wastes work. A production decoder commonly returns keys and values from each layer and reuses them on the next step. Cache support changes the attention interface and position handling, so it should be added and tested explicitly rather than implied by a comment.

Greedy decoding is deterministic only under a fixed execution environment and deterministic kernels. Sampling, temperature, top-kk and top-pp are later decoding choices; they do not change the model architecture. Randomly initialised weights will still yield valid token identifiers, but the decoded sequence has no reason to be coherent. Training supplies learned distributions.

Reference checks

The checks below instantiate a tiny version of the same model. They verify shape, normalisation, causal isolation, a backward pass, weight sharing, generation length and parameter arithmetic. The default 124M-shaped count is tested analytically without allocating the full model.

torch.manual_seed(7)

tiny_cfg = GPTConfig(
    vocab_size=101,
    context_length=16,
    d_model=32,
    n_heads=4,
    n_layers=2,
    dropout=0.0,
)
tiny_model = GPTModel(tiny_cfg)

# Shape and parameter-accounting checks
input_ids = torch.randint(0, tiny_cfg.vocab_size, (2, 7))
logits = tiny_model(input_ids)
assert logits.shape == (2, 7, tiny_cfg.vocab_size)
assert count_parameters(tiny_model) == expected_parameter_count(tiny_cfg)

# The embedding and output head must be one shared Parameter
assert tiny_model.lm_head.weight is tiny_model.token_embedding.weight

# LayerNorm uses population variance over the final axis
sample = torch.randn(3, 5, tiny_cfg.d_model)
normalised = LayerNorm(tiny_cfg.d_model)(sample)
assert torch.allclose(
    normalised.mean(dim=-1),
    torch.zeros(3, 5),
    atol=1e-5,
)
assert torch.allclose(
    normalised.var(dim=-1, unbiased=False),
    torch.ones(3, 5),
    atol=1e-4,
)

# Later tokens must not change logits at earlier positions
tiny_model.eval()
prefix_a = torch.tensor([[1, 2, 3, 4]])
prefix_b = torch.tensor([[1, 2, 9, 10]])
logits_a = tiny_model(prefix_a)
logits_b = tiny_model(prefix_b)
assert torch.allclose(
    logits_a[:, :2],
    logits_b[:, :2],
    atol=1e-6,
    rtol=0.0,
)

# A masked interior key cannot change later real-token logits
padded_a = torch.tensor([[1, 2, 3, 4]])
padded_b = torch.tensor([[1, 99, 3, 4]])
key_is_padding = torch.tensor([[False, True, False, False]])
masked_a = tiny_model(
    padded_a,
    key_is_padding=key_is_padding,
)
masked_b = tiny_model(
    padded_b,
    key_is_padding=key_is_padding,
)
assert torch.allclose(
    masked_a[:, 2:],
    masked_b[:, 2:],
    atol=1e-6,
    rtol=0.0,
)
unmasked_a = tiny_model(padded_a)
unmasked_b = tiny_model(padded_b)
assert not torch.allclose(
    unmasked_a[:, 2:],
    unmasked_b[:, 2:],
    atol=1e-6,
    rtol=0.0,
)

# A next-token loss must backpropagate into the tied embedding
tiny_model.train()
logits = tiny_model(input_ids)
loss = F.cross_entropy(
    logits[:, :-1].reshape(-1, tiny_cfg.vocab_size),
    input_ids[:, 1:].reshape(-1),
)
tiny_model.zero_grad(set_to_none=True)
loss.backward()
assert tiny_model.token_embedding.weight.grad is not None
assert torch.isfinite(loss)

# Greedy generation appends exactly the requested number of tokens
generated = generate_greedy(
    tiny_model,
    input_ids[:1, :3],
    max_new_tokens=4,
)
assert generated.shape == (1, 7)

# The chapter's full configuration has a reproducible unique count
assert expected_parameter_count(GPTConfig()) == 124_439_808

print("All Chapter 4 reference checks passed.")

Expected console output:

All Chapter 4 reference checks passed.

The causal test is especially valuable. Two inputs share positions zero and one but differ afterwards. If their first two output positions change, the mask is wrong or information has leaked through another path. The test does not establish language quality; it establishes one architectural invariant.

Further tests belong beside training code: loss reduction on a tiny corpus, checkpoint save-and-load equivalence, mixed-precision checks, device transfer, seeded reproducibility, tokenizer compatibility and comparison with a trusted checkpoint where exact compatibility is intended.

Reading a failed check

A failed assertion is useful only if it narrows the search. The following symptoms point to different parts of the stack:

Symptom Likely fault First inspection
Changing a later token changes an earlier logit causal mask is reversed, sliced incorrectly or applied after softmax print the Boolean mask for a four-token input and inspect allowed cells
Attention contains NaN an entire score row may be masked, or lower-precision scores may have overflowed verify mask semantics, sequence length and score dtype before softmax
Block output has the wrong last dimension heads were not concatenated correctly, or the FFN did not contract to DD assert shapes after split, transpose, merge and out_proj
Unique parameter count is too high by VDVD output weights were copied rather than tied test object identity, not equality of current values
Parameter count differs by a small multiple of DD a bias or LayerNorm vector differs from the ledger enumerate named parameters and compare one component at a time
Evaluation calls produce different logits dropout is still active, or the backend is nondeterministic call eval(), fix seeds and inspect deterministic-execution settings
Training loss is finite but does not fall target shift, learning rate, data or gradient flow may be wrong overfit one short batch before blaming model capacity
Memory use greatly exceeds weight size activations, logits, gradients, optimiser state or workspaces were omitted measure allocated memory after forward, backward and optimiser step separately

The mask convention causes frequent mistakes. In this implementation, True means “forbidden”, and torch.triu(..., diagonal=1) marks cells above the main diagonal. At query row two, key columns zero, one and two remain visible; later columns are replaced with negative infinity before softmax. Another implementation may use one for “allowed”. Copying a mask without its convention can silently reverse causality.

Shape assertions should sit close to the operation they describe while the model is being developed. For attention, check [B,T,D][B,T,D], then [B,H,T,D/H][B,H,T,D/H], then [B,H,T,T][B,H,T,T] for scores, and finally [B,T,D][B,T,D] after merging heads. A transpose error can preserve the number of elements and survive a view, producing plausible tensors with the wrong meaning. Calling contiguous() before the final view makes the intended memory layout explicit.

Weight sharing needs an identity test because two independent matrices can start with equal values. lm_head.weight is token_embedding.weight establishes that both module paths reference one Parameter; torch.equal establishes only that their current numbers match. Saving and loading should preserve the chosen tying policy, and an optimiser should see the shared tensor once.

The backward-pass check proves that a finite scalar loss reaches the embedding parameter. It does not prove healthy optimisation at depth. Gradient norms should be observed across blocks during the first training steps, alongside the loss and update-to-weight ratios. Residual connections and pre-normalisation make useful routes available, but poor initialisation or an unsuitable learning rate can still destabilise the run.

Modern variations, scoped carefully

The reference model uses early GPT-style choices because they expose the block clearly. Later architectures often replace individual parts:

Variation What changes What does not follow automatically
Root mean square layer normalisation (RMSNorm) divides by the root mean square and usually learns a scale, without LayerNorm’s mean subtraction universal speed or quality improvement across hardware and training recipes
RoPE rotates paired query and key coordinates according to position, introducing relative-position structure in attention reliable extrapolation to arbitrary context lengths without suitable training, scaling and evaluation
Swish-gated linear unit (SwiGLU) gates one learned projection with a sigmoid linear unit (SiLU)-transformed projection before the output map a fixed 4DD hidden width; implementations often reduce it to control the three-matrix parameter budget
Grouped-query attention (GQA) uses fewer key–value heads than query heads, sharing each key–value group across several queries a drop-in checkpoint conversion or identical quality for every task

RMSNorm changes the normalisation equation and usually removes the learned shift. RoPE commonly replaces the absolute position table and changes how queries and keys are formed. SwiGLU changes both the activation and the feed-forward parameter ledger. GQA changes attention tensor shapes and the KV cache formula. Each is a local architectural variation, but none is a search-and-replace operation in a trained checkpoint.

The residual skeleton remains recognisable: normalise, transform, add; normalise, transform, add. That continuity makes the model built here a useful reading tool for newer architectures, provided the differences are examined rather than waved away as implementation detail.

Build check

Before training, confirm the following:

  • d_model is divisible by n_heads;
  • every block accepts and returns [B,T,D][B,T,D];
  • the causal mask prevents later tokens from affecting earlier logits;
  • LayerNorm uses variance with unbiased=False;
  • train and evaluation modes switch dropout correctly;
  • the position range cannot exceed the configured table;
  • the tokenizer vocabulary equals vocab_size;
  • the language-model head is deliberately tied or deliberately untied;
  • the unique parameter count matches the analytic ledger;
  • logits have shape [B,T,V][B,T,V];
  • the shifted next-token loss uses position tt to predict token t+1t+1; and
  • the model can save, reload and reproduce the same evaluation logits.

These checks catch architecture errors before a long training run turns them into an expensive mystery.

The assembled machine

The decoder now has a complete forward path. Token and position embeddings create the residual stream. Each block uses causal attention to mix earlier context and a feed-forward network to transform each position. LayerNorm conditions both branches, residual additions preserve a direct route, and the final projection produces vocabulary logits.

The untrained architecture contains no corpus-derived patterns. Its random parameters produce arbitrary logits, which the generation loop exposes one token at a time. Chapter 5 supplies the objective, batches, gradients, optimiser and checkpoints that turn this tested structure into a trained language model.

Primary-source notes

  1. Vaswani et al., “Attention Is All You Need”, 2017, introduced the Transformer’s attention and position-wise feed-forward structure with residual connections and layer normalisation.
  2. Ba, Kiros and Hinton, “Layer Normalization”, 2016, defined normalisation from within-example layer statistics with learned gain and bias.
  3. Hendrycks and Gimpel, “Gaussian Error Linear Units”, 2016, defined GELU as xΦ(x)x\Phi(x).
  4. He et al., “Deep Residual Learning for Image Recognition”, 2015, presented residual learning as a way to ease optimisation of deeper networks.
  5. Radford et al., “Language Models are Unsupervised Multitask Learners”, 2019, documented GPT-2’s pre-normalisation placement, final normalisation, 50,257-token vocabulary, 1,024-token context and depth-aware residual initialisation.
  6. Press and Wolf, “Using the Output Embedding to Improve Language Models”, 2016, analysed sharing the input embedding with the output embedding.
  7. Xiong et al., “On Layer Normalization in the Transformer Architecture”, 2020, compared the initialisation and optimisation behaviour of pre-LN and post-LN Transformers.
  8. Zhang and Sennrich, “Root Mean Square Layer Normalization”, 2019; Su et al., “RoFormer”, 2021; Shazeer, “GLU Variants Improve Transformer”, 2020; and Ainslie et al., “GQA”, 2023, are the primary references for the four modern variations summarised above.

Build layerChapter 5: Pretraining the decoder

The model assembled in the previous chapter maps token identifiers to logits. With random parameters, those logits have no useful relation to the text. This chapter closes the learning loop: construct shifted targets, measure prediction error, propagate that error through the network and update the parameters.

Chapter map for Build layer Chapter 5: Pretraining the decoder: One token stream, two shifted views; Cross-entropy and perplexity; Designing the train and validation sets; An optimisation step; Gradient accumulation.
Mermaid chapter map. Build layer Chapter 5: Pretraining the decoder connects One token stream, two shifted views, Cross-entropy and perplexity, Designing the train and validation sets, An optimisation step, Gradient accumulation.

The result of this process is a base language model. It estimates likely text continuations. It is not automatically an instruction-following assistant, a source of current facts or a controlled business application. Those behaviours require further data, evaluation and system design. Keeping that boundary clear lets us study pretraining without attributing later product features to it.

The executable example uses Edith Wharton’s short story “The Verdict” from Project Gutenberg eBook 306, which Project Gutenberg marks as public domain in the United States. The text snapshot, source revision and digest are fixed below.1 Readers should still check copyright status in their own jurisdiction. A reduced context length keeps every operation observable.

The same equations apply to a larger run, but data movement, parallelism and recovery change substantially with scale. Hardware cost depends on model size, sequence length, precision, utilisation and platform. Report measured throughput, memory and energy for the actual run instead of transferring a generic cost table.

One token stream, two shifted views

Causal language modelling supplies its own targets. Given token identifiers

[t0,t1,t2,t3,t4], [t_0, t_1, t_2, t_3, t_4],

the input is

[t0,t1,t2,t3], [t_0, t_1, t_2, t_3],

and the target is the same stream shifted one place to the left:

[t1,t2,t3,t4]. [t_1, t_2, t_3, t_4].

At input position 0, the model predicts t1t_1. At position 1, it predicts t2t_2, using t0t_0 and t1t_1. The causal mask prevents position 1 from reading t2t_2 even though all positions are processed in one tensor. A batch with shape [batch, sequence] therefore yields one supervised classification target per non-padding position.

The dataset and loader pattern from Chapter 2 already returns this pair. The loss function can therefore remain small:

import torch
import torch.nn.functional as F


def causal_lm_loss(
    model,
    input_ids,
    target_ids,
    reduction: str = "mean",
):
    """Next-token cross-entropy for a fixed-length batch."""
    logits = model(input_ids)  # [batch, sequence, vocabulary]
    if logits.shape[:2] != target_ids.shape:
        raise ValueError(
            f"Logits {logits.shape[:2]} and targets "
            f"{target_ids.shape} do not align"
        )

    return F.cross_entropy(
        logits.reshape(-1, logits.size(-1)),
        target_ids.reshape(-1),
        reduction=reduction,
    )

reshape merges the batch and sequence dimensions. The vocabulary dimension remains intact, so each row is a classification over every token. If a dataset uses padding, pass an ignore_index and count only unmasked target positions. The fixed-length windows used here contain no padding.

For a small experiment, reducing the model’s context length from GPT-2’s 1,024 tokens to 256 reduces memory and computation. It also changes the model configuration. Before importing GPT-2 weights later in the chapter, the positional-embedding shape and context limit must be restored to 1,024.2

Cross-entropy and perplexity

For one target token yy, let the model assign probability pyp_y. Its negative log-likelihood is

=ln(py). \ell = -\ln(p_y).

If py=0.5p_y=0.5, the loss is about 0.69 nats. If py=0.01p_y=0.01, it is about 4.61. The logarithm penalises a model that gives very little probability to the observed token. Averaging this quantity across target positions gives the cross-entropy reported during training.

Uniform predictions provide a useful implementation check. With a vocabulary of 50,257 tokens, a perfectly uniform distribution has loss ln(50,257)10.82\ln(50{,}257)\approx10.82. Randomly initialised logits need not be exactly uniform, so an observed initial loss may differ. A large difference deserves investigation: the inputs and targets may be misaligned, a mask may leak future tokens, or a numerical value may be non-finite.

Perplexity is the exponential of mean token-level negative log-likelihood:

PPL=exp(¯). \operatorname{PPL}=\exp(\overline{\ell}).

A loss of 3.0 corresponds to perplexity of about 20.1. This transformation does not turn the metric into a count of facts known or an estimate of factual confidence. It is best read as a restatement of average token prediction loss under a particular evaluation pipeline.

That qualification matters. Perplexity changes with the corpus, tokenizer, normalisation, treatment of whitespace, context length and handling of out-of-vocabulary text. A tokenizer that represents the same sentence with more tokens changes the denominator. Results are directly comparable only when the prediction units and data preparation are the same. Perplexity on a held-out short story cannot be compared casually with perplexity on code or financial contracts, and lower perplexity does not guarantee better citation support, reasoning or instruction following.

Use cross-entropy and perplexity to answer a limited question: how well does this model predict these held-out tokens under this tokenisation and context? Other claims need other tests.

Designing the train and validation sets

The split must happen before overlapping windows are created. If a document is windowed first and neighbouring windows are then assigned at random, repeated phrases can appear in both sets. Validation loss will measure recognition of near-duplicates rather than generalisation.

For a collection of documents, split by document, source or time according to the intended deployment. A time-based split is appropriate when the model will encounter later documents. A source-based split tests transfer to a new source. For one continuous story, a contiguous final segment is a defensible teaching split, although conclusions from such a small sample must remain modest.

The data record for a repeatable run should include:

  • immutable identifiers or hashes for source documents;
  • the tokenizer name, vocabulary and version;
  • filtering, normalisation and deduplication rules;
  • split membership and the random seed used to assign it;
  • context length, stride and treatment of incomplete windows;
  • counts of documents, tokens and prediction targets after filtering.

Overlapping windows are legitimate inside the training split. They expose more positions with a full left context, although they also cause some tokens to contribute to several updates. The risk arises when overlapping or duplicated content crosses the split boundary. Record the stride and report tokens processed separately from distinct source tokens so that repetition is visible.

Document packing needs an explicit rule as well. Concatenating unrelated documents without a separator teaches the model an artificial transition from the end of one document to the start of another. Insert a designated end-of-text token, or use attention and loss masks that prevent cross-document prediction. If sensitive and public corpora are mixed, preserve provenance at window level; a later deletion or audit must be able to identify which examples contained a given source.

Keep a test set untouched until model and hyperparameter choices are finished. Repeatedly selecting a checkpoint from “test” results turns that set into validation data.

The following cell makes the teaching run concrete. Download the pinned the-verdict.txt snapshot named in the note, save it beside the program and verify its digest before use. The split occurs on raw text before either partition is tokenised or windowed.

from pathlib import Path
import hashlib

import tiktoken
from torch.utils.data import DataLoader


corpus_path = Path("the-verdict.txt")
expected_sha256 = (
    "b41e41a68f0398a3154ae69e2e4c0e2694e17fe0d"
    "66730536837f1b01935b31f"
)
corpus_bytes = corpus_path.read_bytes()
actual_sha256 = hashlib.sha256(corpus_bytes).hexdigest()
if actual_sha256 != expected_sha256:
    raise ValueError("corpus digest does not match the pinned snapshot")

corpus_text = corpus_bytes.decode("utf-8")
split_at = int(0.90 * len(corpus_text))
train_text = corpus_text[:split_at]
validation_text = corpus_text[split_at:]

encoding = tiktoken.get_encoding("gpt2")
train_ids = encoding.encode(
    train_text,
    disallowed_special=(),
)
validation_ids = encoding.encode(
    validation_text,
    disallowed_special=(),
)

context_length = 256
stride = 256
train_dataset = NextTokenDataset(
    train_ids,
    context_length=context_length,
    stride=stride,
)
validation_dataset = NextTokenDataset(
    validation_ids,
    context_length=context_length,
    stride=stride,
)

train_generator = torch.Generator().manual_seed(123)
train_loader = DataLoader(
    train_dataset,
    batch_size=2,
    shuffle=True,
    drop_last=False,
    generator=train_generator,
)
train_eval_loader = DataLoader(
    train_dataset,
    batch_size=2,
    shuffle=False,
    drop_last=False,
)
val_loader = DataLoader(
    validation_dataset,
    batch_size=2,
    shuffle=False,
    drop_last=False,
)

cfg = GPTConfig(context_length=context_length)
model = GPTModel(cfg)

Validation should run with dropout disabled and gradient tracking off. The following function sums token losses before dividing, rather than averaging batch means. That detail gives every target token equal weight when the last batch is smaller.

@torch.inference_mode()
def mean_nll(model, data_loader, device):
    was_training = model.training
    model.eval()
    total_nll = 0.0
    total_tokens = 0

    try:
        for input_ids, target_ids in data_loader:
            input_ids = input_ids.to(device)
            target_ids = target_ids.to(device)
            logits = model(input_ids)

            total_nll += F.cross_entropy(
                logits.reshape(-1, logits.size(-1)),
                target_ids.reshape(-1),
                reduction="sum",
            ).item()
            total_tokens += target_ids.numel()
    finally:
        model.train(was_training)

    if total_tokens == 0:
        raise ValueError("Cannot evaluate an empty dataloader")
    return total_nll / total_tokens

Record both train and validation loss against optimiser updates and tokens processed. Epoch numbers alone are ambiguous when datasets or accumulation settings change.

An optimisation step

One update has a strict order:

  1. run the forward pass;
  2. calculate mean next-token loss;
  3. backpropagate gradients;
  4. optionally unscale and clip those gradients;
  5. update parameters and the learning-rate schedule;
  6. clear gradients for the next effective batch.
A circular five-stage loop moves from the forward pass to cross-entropy loss, backpropagation, parameter update, and gradient clearing.
Figure 5.1. A training update turns a token batch into logits and cross-entropy, backpropagates the error, applies one controlled parameter update, and clears gradients before the next effective batch.

AdamW is a practical optimiser for this build. It maintains moving estimates of the first and second gradient moments and applies weight decay separately from the loss gradient, as proposed by Loshchilov and Hutter.3 Its settings are hyperparameters, not universal constants. In a larger run, biases and normalisation parameters are often placed in a parameter group without weight decay.

Three refinements make the update safer to reuse across run sizes.

Gradient accumulation

If one desired batch does not fit in memory, process several micro-batches and add their gradients before stepping the optimiser. When every micro-batch has the same number of targets, averaging their mean losses matches a larger batch. Unequal final batches need token weighting instead. The loop below backpropagates summed token loss, then divides accumulated gradients by the number of targets in the effective window. Dropout masks still differ, and distributed reduction requires the same global accounting.

The optimiser, scheduler and gradient scaler must step once per effective batch, not once per micro-batch. The final short accumulation window also needs its true divisor.

Gradient clipping

An unusually large gradient norm can create an outsized update. Global norm clipping rescales the full gradient vector when it exceeds a chosen threshold; it does not clip every parameter independently. Pascanu, Mikolov and Bengio introduced norm clipping as a response to exploding gradients in recurrent networks, and the technique is now used more broadly.4 Treat the threshold as a tuned safety bound. Frequent clipping may indicate a learning rate, data or numerical problem.

Warmup followed by decay

Early gradients are produced by uncalibrated activations and optimiser moment estimates. A warmup raises the learning rate gradually. After warmup, cosine decay reduces it towards a floor as the update budget is consumed. This schedule is one sound default, not a theorem about every dataset. Cosine-shaped annealing was explored in the Stochastic Gradient Descent with Warm Restarts (SGDR) experiments; our schedule uses one decay without warm restarts.5

A learning-rate curve rises linearly from zero during warmup, peaks, then follows a smooth cosine decay to one tenth of the peak rate.
Figure 5.2. The learning rate rises during warmup to avoid abrupt early updates, then follows a cosine decay to the configured 0.1 floor so that later parameter changes become progressively smaller without reaching zero.

The scheduler below expresses the rate as a multiplier of the optimiser’s peak learning rate:

import math


def lr_multiplier(step, warmup_steps, total_steps, min_ratio=0.1):
    if (
        step < 0
        or total_steps <= 0
        or not 0 <= warmup_steps <= total_steps
        or not 0.0 <= min_ratio <= 1.0
    ):
        raise ValueError("Invalid schedule")
    if step < warmup_steps:
        return (step + 1) / max(1, warmup_steps)

    decay_steps = max(1, total_steps - warmup_steps)
    progress = min(
        1.0,
        (step - warmup_steps + 1) / decay_steps,
    )
    cosine = 0.5 * (1.0 + math.cos(math.pi * progress))
    return min_ratio + (1.0 - min_ratio) * cosine

Here step is a zero-based update index. If the run executes total_steps updates, the final index is total_steps - 1, where the multiplier reaches min_ratio. The warmup’s final update reaches the peak; the first decay update then begins moving below it.

Mixed precision without silent numerical errors

Automatic mixed precision (AMP) lets selected operations use float16 or bfloat16 while numerically sensitive work stays in float32. It can reduce memory use and improve throughput on supported hardware. The result depends on the device and kernels; casting the entire model to half precision by hand is not an equivalent procedure.

float16 has limited exponent range. Small gradients can underflow, so a gradient scaler multiplies the loss before backpropagation and reverses that scale before the update. bfloat16 has a wider exponent range and commonly does not need loss scaling, but it has fewer mantissa bits. Neither format is guaranteed to work for every model.6

When clipping scaled gradients, unscale them once, after all micro-batches have accumulated and immediately before clipping. Updating the scaler inside an accumulation window would mix gradients with different scales. The following single-device loop implements that order. It enables automatic mixed precision only on CUDA and otherwise runs in float32.7

def train_one_epoch(
    model,
    train_loader,
    optimizer,
    scheduler,
    scaler,
    device,
    accumulation_steps,
    max_grad_norm,
    global_step,
    tokens_seen,
    use_amp,
    amp_dtype,
):
    if accumulation_steps < 1 or len(train_loader) == 0:
        raise ValueError("Training requires batches and accumulation >= 1")
    if max_grad_norm <= 0:
        raise ValueError("max_grad_norm must be positive")

    model.train()
    optimizer.zero_grad(set_to_none=True)
    batch_count = len(train_loader)
    window_tokens = 0

    for batch_index, (input_ids, target_ids) in enumerate(train_loader):
        input_ids = input_ids.to(device)
        target_ids = target_ids.to(device)

        window_end = min(
            (
                batch_index // accumulation_steps + 1
            ) * accumulation_steps,
            batch_count,
        )

        with torch.autocast(
            device_type="cuda",
            dtype=amp_dtype,
            enabled=use_amp,
        ):
            loss = causal_lm_loss(
                model,
                input_ids,
                target_ids,
                reduction="sum",
            )

        scaler.scale(loss).backward()
        token_count = target_ids.numel()
        window_tokens += token_count
        tokens_seen += token_count

        if batch_index + 1 != window_end:
            continue

        scaler.unscale_(optimizer)
        for parameter in model.parameters():
            if parameter.grad is not None:
                parameter.grad.div_(window_tokens)
        torch.nn.utils.clip_grad_norm_(
            model.parameters(),
            max_norm=max_grad_norm,
        )

        old_scale = scaler.get_scale()
        scaler.step(optimizer)
        scaler.update()
        step_was_skipped = (
            scaler.is_enabled()
            and scaler.get_scale() < old_scale
        )

        if not step_was_skipped:
            scheduler.step()
            global_step += 1

        optimizer.zero_grad(set_to_none=True)
        window_tokens = 0

    return global_step, tokens_seen

Here is the corresponding setup:

device = torch.device(
    "cuda" if torch.cuda.is_available() else "cpu"
)
model.to(device)

accumulation_steps = 4
epochs = 5
updates_per_epoch = math.ceil(
    len(train_loader) / accumulation_steps
)
total_updates = epochs * updates_per_epoch
warmup_updates = max(1, int(0.1 * total_updates))

optimizer = torch.optim.AdamW(
    model.parameters(),
    lr=4e-4,
    betas=(0.9, 0.95),
    weight_decay=0.1,
)
scheduler = torch.optim.lr_scheduler.LambdaLR(
    optimizer,
    lr_lambda=lambda step: lr_multiplier(
        step, warmup_updates, total_updates
    ),
)

use_amp = device.type == "cuda"
amp_dtype = (
    torch.bfloat16
    if use_amp and torch.cuda.is_bf16_supported()
    else torch.float16
)
use_scaler = use_amp and amp_dtype == torch.float16
scaler = torch.amp.GradScaler("cuda", enabled=use_scaler)

The numerical safeguards still need monitoring. Log non-finite losses, gradient norm before clipping, the scaler value, learning rate and skipped updates. Compare validation loss from a mixed-precision run with a float32 baseline before assuming equivalence.

A staged run protocol

A long run should be the last step in the experiment, not the first. A short sequence of tests catches most implementation errors while they remain easy to diagnose.

Check the data contract. Decode several input and target windows. Confirm that every target is shifted by one token, document separators appear where expected, and no validation source occurs in training. Count targets after masking rather than inferring the number from file size.

Check the random baseline. With dropout disabled, calculate loss on fixed training and validation batches before any update. Compare the result with the uniform-loss reference and inspect the logits for finite values. The aim is not to force exact uniformity; it is to detect a gross shape, masking or target bug.

Overfit one batch deliberately. Reuse one small batch for enough updates to drive its loss down. If the optimiser cannot fit a single batch, increasing the dataset or runtime will not help. This diagnostic may reveal detached tensors, parameters excluded from the optimiser, an incorrect causal mask or a learning rate that produces non-finite values. Discard the resulting weights.

Test resume equivalence. Run a few updates, save, load into newly constructed objects and continue. Compare the next batch, learning rate, scaler and loss with an uninterrupted run on the same software and device. Exact equivalence may require a stateful sampler and deterministic kernels, but a large difference still exposes missing state.

Run a bounded pilot. Use the intended train and validation paths for a small fraction of the update budget. Inspect throughput, memory, clipping rate, skipped AMP updates and fixed-prompt samples. Only then schedule the full educational run.

Every run needs a unique identifier and an immutable configuration record. Log the source revision, model configuration, optimiser and schedule, tokenizer, dataset fingerprint, seed, device type and software versions beside the metrics. A loss curve without that context cannot be reproduced or compared fairly.

Reading the loss curves

Training loss should fall as the optimiser fits the sampled windows. Validation loss often falls at first and then flattens. A widening gap can indicate overfitting, but it can also reflect a domain mismatch, duplicate removal that changed one split more than the other, or noisy validation data.

Illustrative training and validation loss curves fall together at first; training loss keeps falling while validation loss levels off, creating a generalisation gap.
Figure 5.3. Training loss can continue down while validation loss stalls; the widening gap is a diagnostic signal that needs data and sampling analysis, not proof of a single cause.

For this deliberately tiny single-story run, memorisation is expected: the model has far more capacity than the corpus can support. The run demonstrates mechanics, not a general-purpose language model. Stop according to a stated rule, such as the lowest smoothed validation loss within a fixed update budget. Keep the selected checkpoint and report the selection rule; do not inspect the test set after every update or use it to select a checkpoint.

Text samples complement loss curves. Generate from a fixed set of prompts with both greedy decoding and a fixed sampling seed. Samples can expose repetition, broken Unicode or data leakage that average loss hides. They remain diagnostic examples rather than a statistically adequate evaluation.

Compute-efficient scaling also requires balancing model capacity and training tokens. The Chinchilla experiments found that, under their model family and compute budgets, increasing only parameter count left performance on the table.8 That result motivates recording both parameters and tokens processed; it does not supply a universal token-to-parameter ratio for every corpus.

Checkpointing a training trajectory

Saving only model.state_dict() is enough to distribute weights for inference. It is not enough to resume training faithfully. AdamW has moment estimates; the scheduler has a position; a float16 gradient scaler has a scale; random-number generators (RNGs) determine dropout, sampling and data order.

A resumable checkpoint should contain:

  • model, optimiser, scheduler and scaler state;
  • completed optimiser step, epoch, batch cursor and tokens processed;
  • CPU, CUDA, Python and NumPy random-number-generator state when those generators are used;
  • the model configuration and a fingerprint of the dataset manifest;
  • the state of the generator or sampler that determines training order; and
  • sampler cursor state for an exact mid-epoch resume, especially with distributed shuffling or worker prefetching.

The implementation below writes to a temporary path and replaces the previous checkpoint only after serialisation succeeds.

import os
import random
from pathlib import Path

import numpy as np


def capture_rng_state():
    return {
        "torch_cpu": torch.get_rng_state(),
        "torch_cuda": (
            torch.cuda.get_rng_state_all()
            if torch.cuda.is_available()
            else None
        ),
        "python": random.getstate(),
        "numpy": np.random.get_state(),
    }


def save_checkpoint(
    path,
    *,
    model,
    optimizer,
    scheduler,
    scaler,
    global_step,
    tokens_seen,
    data_state,
    model_config,
):
    path = Path(path)
    temporary = path.with_suffix(path.suffix + ".tmp")
    state = {
        "schema_version": 1,
        "model": model.state_dict(),
        "optimizer": optimizer.state_dict(),
        "scheduler": scheduler.state_dict(),
        "scaler": scaler.state_dict(),
        "global_step": global_step,
        "tokens_seen": tokens_seen,
        "data_state": data_state,
        "model_config": model_config,
        "rng": capture_rng_state(),
    }
    torch.save(state, temporary)
    os.replace(temporary, path)


def load_checkpoint(
    path, *, model, optimizer, scheduler, scaler, device
):
    # weights_only=False is safe only for a checkpoint you trust.
    state = torch.load(
        path,
        map_location=device,
        weights_only=False,
    )
    if state.get("schema_version") != 1:
        raise ValueError("Unsupported checkpoint schema")

    model.load_state_dict(state["model"])
    optimizer.load_state_dict(state["optimizer"])
    scheduler.load_state_dict(state["scheduler"])
    scaler.load_state_dict(state["scaler"])

    rng = state["rng"]
    torch.set_rng_state(rng["torch_cpu"].cpu())
    random.setstate(rng["python"])
    np.random.set_state(rng["numpy"])
    if rng["torch_cuda"] is not None:
        if len(rng["torch_cuda"]) != torch.cuda.device_count():
            raise RuntimeError("CUDA topology differs from checkpoint")
        torch.cuda.set_rng_state_all(
            [value.cpu() for value in rng["torch_cuda"]]
        )

    return {
        "global_step": state["global_step"],
        "tokens_seen": state["tokens_seen"],
        "data_state": state["data_state"],
        "model_config": state["model_config"],
    }

data_state should contain the next epoch or a batch cursor together with the training-order generator or sampler state. A stateless dataloader cannot resume at a mid-epoch cursor by itself; either restore a stateful sampler or recreate the same shuffle and skip the already processed batches. Multi-worker prefetching makes the requirement stricter.

After saving, test the checkpoint in a fresh process: load it, calculate loss on a fixed validation batch and perform one controlled update. Keep more than one recent checkpoint and a separate milestone copy. Reproducibility is bounded by software, hardware and kernel choices; identical seeds do not guarantee bitwise identity across different platforms or framework releases.9

At epoch boundaries, a compact driver can evaluate and save both the latest trajectory and the checkpoint selected by validation loss:

def fit(
    *,
    model,
    train_loader,
    train_eval_loader,
    train_generator,
    val_loader,
    optimizer,
    scheduler,
    scaler,
    device,
    epochs,
    accumulation_steps,
    max_grad_norm,
    use_amp,
    amp_dtype,
    checkpoint_dir,
    model_config,
    dataset_fingerprint,
    resume_state=None,
):
    checkpoint_dir = Path(checkpoint_dir)
    checkpoint_dir.mkdir(parents=True, exist_ok=True)
    history = []
    resume_state = resume_state or {}
    restored_data = resume_state.get("data_state", {})
    global_step = resume_state.get("global_step", 0)
    tokens_seen = resume_state.get("tokens_seen", 0)
    start_epoch = restored_data.get("next_epoch", 0)
    best_validation = restored_data.get(
        "best_validation", float("inf")
    )
    generator_state = restored_data.get(
        "train_generator_state"
    )
    if generator_state is not None:
        train_generator.set_state(generator_state.cpu())

    for epoch in range(start_epoch, epochs):
        global_step, tokens_seen = train_one_epoch(
            model,
            train_loader,
            optimizer,
            scheduler,
            scaler,
            device,
            accumulation_steps,
            max_grad_norm,
            global_step,
            tokens_seen,
            use_amp,
            amp_dtype,
        )

        train_nll = mean_nll(
            model, train_eval_loader, device
        )
        validation_nll = mean_nll(model, val_loader, device)
        history.append(
            {
                "epoch": epoch + 1,
                "step": global_step,
                "tokens_seen": tokens_seen,
                "train_nll": train_nll,
                "validation_nll": validation_nll,
            }
        )

        improved = validation_nll < best_validation
        if improved:
            best_validation = validation_nll

        data_state = {
            "next_epoch": epoch + 1,
            "batch_in_epoch": 0,
            "dataset_fingerprint": dataset_fingerprint,
            "best_validation": best_validation,
            "train_generator_state": (
                train_generator.get_state().cpu()
            ),
        }
        save_args = {
            "model": model,
            "optimizer": optimizer,
            "scheduler": scheduler,
            "scaler": scaler,
            "global_step": global_step,
            "tokens_seen": tokens_seen,
            "data_state": data_state,
            "model_config": model_config,
        }
        save_checkpoint(
            checkpoint_dir / "latest.pt",
            **save_args,
        )
        if improved:
            save_checkpoint(
                checkpoint_dir / "best-validation.pt",
                **save_args,
            )

    return history

To resume, first construct the same model, optimiser, scheduler, scaler, dataloaders and train_generator; call load_checkpoint; verify its model configuration and dataset fingerprint; then pass the returned dictionary as resume_state. The driver restores the generator before it creates the next training iterator. The separate, non-shuffled train_eval_loader prevents metric collection from consuming that generator. Do not silently resume on a different token order or schedule budget. Pass dataclasses.asdict(cfg) as model_config so the next chapter can reconstruct GPTConfig explicitly.

For a large corpus, full train-set evaluation at every epoch may be too expensive. Use a fixed, representative monitoring subset between less frequent full evaluations, and label the two measurements distinctly. The selected checkpoint should still be chosen by a declared validation procedure rather than by whichever generated sample looks best.

Temperature and top-k decoding

Training produces a conditional probability distribution. Decoding decides how to select from it.

With greedy decoding, choose the largest logit. The output is deterministic for a deterministic forward pass. With temperature sampling, divide logits by a positive temperature before softmax:

Pi(T)=exp(zi/T)jexp(zj/T). P_i(T)=\frac{\exp(z_i/T)} {\sum_j \exp(z_j/T)}.

Temperatures below 1 sharpen the distribution; temperatures above 1 flatten it. Temperature preserves the logit ranking. It adds neither knowledge nor factual reliability.

Top-kk filtering retains only the kk largest logits and sets the rest to negative infinity before softmax. It removes the low-probability tail but can also exclude a valid rare token. Both parameters belong in evaluation records.

Three probability bar rows show a concentrated distribution at temperature 0.2, the original scale at 1.0, and a flatter distribution at 2.0.
Figure 5.4. Temperature changes how concentrated a fixed logit distribution is: low values favour the leading token and high values admit more alternatives without adding knowledge.

This batch-size-one generator makes the two controls explicit:

@torch.inference_mode()
def generate(
    model,
    input_ids,
    *,
    max_new_tokens,
    context_size,
    temperature=0.0,
    top_k=None,
    eot_id=None,
    generator=None,
):
    if input_ids.size(0) != 1:
        raise ValueError("This generator expects batch size one")
    if temperature < 0:
        raise ValueError("temperature must be non-negative")
    if top_k is not None and top_k < 1:
        raise ValueError("top_k must be positive")

    was_training = model.training
    model.eval()
    output = input_ids

    try:
        for _ in range(max_new_tokens):
            context = output[:, -context_size:]
            logits = model(context)[:, -1, :]

            if top_k is not None:
                k = min(top_k, logits.size(-1))
                threshold = torch.topk(
                    logits, k
                ).values[:, -1, None]
                logits = logits.masked_fill(
                    logits < threshold,
                    float("-inf"),
                )

            if temperature == 0:
                next_id = logits.argmax(dim=-1, keepdim=True)
            else:
                probabilities = torch.softmax(
                    logits / temperature,
                    dim=-1,
                )
                next_id = torch.multinomial(
                    probabilities,
                    num_samples=1,
                    generator=generator,
                )

            output = torch.cat((output, next_id), dim=1)
            if eot_id is not None and next_id.item() == eot_id:
                break
    finally:
        model.train(was_training)

    return output

For a repeatable sample, create a device-compatible torch.Generator with a fixed seed. For an application, choose decoding settings from task evaluation. A lower temperature may reduce variation, but it cannot ground an answer in documents that were never supplied.

Evaluating more than token loss

A pretraining report should contain at least four views:

  1. token-weighted train and validation loss on versioned splits;
  2. loss by relevant domain, document length and other known slices;
  3. fixed-prompt samples under recorded decoding settings;
  4. tests for the intended downstream properties, including abstention and evidence use where those properties matter.

The fourth view prevents perplexity from becoming a proxy for everything. Consider a fictional test harness for the credit-document assistant introduced earlier. It represents the control demands of a large UK bank; it does not describe a live system, internal experiment, client data or employer policy. Its expected result uses the book’s canonical covenant fields: metric, operator, threshold and test_frequency.

The generator must return a constrained record:

SYNTHETIC_CASES = [
    {
        "id": "operative-amendment",
        "question": "State the current interest-cover covenant.",
        "evidence": [
            {
                "source": "AMEND-02#4.1",
                "text": (
                    "From 30 June 2026, interest cover "
                    "must be at least 3.0x, tested quarterly."
                ),
            }
        ],
        "expected_fields": {
            "metric": "interest_cover",
            "operator": ">=",
            "threshold": 3.0,
            "test_frequency": "quarterly",
        },
        "expected_citations": ["AMEND-02#4.1"],
        "should_abstain": False,
    },
    {
        "id": "missing-operative-document",
        "question": "State the current interest-cover covenant.",
        "evidence": [],
        "expected_fields": {},
        "expected_citations": [],
        "should_abstain": True,
    },
]


def grade_case(case, result):
    abstention_ok = (
        result.get("abstain") == case["should_abstain"]
    )
    if case["should_abstain"]:
        return abstention_ok and not result.get("fields")

    fields_ok = all(
        result.get("fields", {}).get(name) == value
        for name, value in case["expected_fields"].items()
    )
    citations_ok = set(case["expected_citations"]).issubset(
        result.get("citations", [])
    )
    return abstention_ok and fields_ok and citations_ok

Add cases for a superseded agreement, conflicting dates, a document outside the user’s permissions, an irrelevant retrieved clause and a unit mismatch. Run each case with retrieval disabled and enabled so that retrieval errors can be separated from generation errors. Exact field checks are deterministic; a sample of narrative claims should also receive human review against the cited spans. Record abstention precision and recall, citation support, field accuracy and access-control failures separately.

A retrieval-aware result record should also preserve case_id, corpus version, retrieved source identifiers, retrieval scores, model checkpoint, prompt template version, decoding settings and validator decisions. These fields let a reviewer distinguish “the model ignored the amendment” from “the retriever never supplied the amendment”. Do not let the generator see evidence that the test’s simulated user is not authorised to access; masking it only in the final answer would test the wrong security boundary.

Loss and harness metrics answer different questions. Token loss measures the distribution learnt from a corpus. The harness measures whether a complete application handles defined cases under explicit controls. A good result on one cannot substitute for the other. Report uncertainty with confidence intervals or repeated samples where stochastic decoding is used, and retain the individual failures for review rather than publishing only an average.

A base model trained on a story is not expected to pass this harness. The harness defines what later adaptation and application controls must achieve and provides a stable regression suite as components change.

Loading GPT-2 weights as an integration test

The GPT-2 family used one architectural pattern at four widths and depths. The original technical report labelled the models by approximate counts of 117M, 345M, 762M and 1,542M parameters.10 Many implementations report about 124M, 355M, 774M and 1,558M when counting all tensors in their particular graph. The figure follows the latter convention. Parameter totals are meaningful only with the counting rule stated.

Four proportional bars compare GPT-2 Small, Medium, Large, and XL parameter counts, with their layer counts and hidden widths.
Figure 5.5. GPT-2 scales the same decoder-only design by increasing width and depth; parameter count alone does not establish data efficiency, quality or suitability.

Before mapping a public checkpoint, verify vocabulary size, context length, embedding width, layer count, head count, bias settings, normalisation order, activation and whether input and output embeddings are tied. Weight arrays may also need an explicit transpose because framework conventions differ.

Fluent generation is a weak smoke test: many mapping errors still produce word-like output. A stronger integration test runs the same token identifiers through the reference implementation and the new implementation in evaluation mode, then compares logits layer by layer within a stated floating-point tolerance. Test tokenisation and detokenisation separately. Once the logits match, generation under greedy decoding should match as a consequence.

Loading pretrained weights does not continue the educational pretraining run. It replaces or maps the parameters with a separately trained checkpoint. Keep the two experiments in distinct directories and attach the correct configuration and data record to each.

Build check

Before moving to adaptation, verify the following.

  1. For tokens [11, 29, 7, 42], write the input [11, 29, 7] and target [29, 7, 42]. Explain which target is scored at each position.
  2. If mean loss is 2.3, perplexity is approximately exp(2.3)9.97\exp(2.3)\approx9.97. State why this value cannot be compared directly with a result produced by a different tokenizer or corpus.
  3. Explain why validation documents must be separated before overlapping windows are constructed.
  4. Trace one effective batch through accumulation, AMP scaling, unscaling, global-norm clipping, the optimiser step, scaler update and scheduler step. The scaler and scheduler must not advance for each micro-batch.
  5. Name the state required for a faithful resume: model, optimiser, scheduler, scaler, step and data cursor, tokens processed, RNG states, configuration and dataset fingerprint.
  6. Explain why lowering temperature does not make an unsupported answer true.
  7. Add a synthetic harness case in which the original covenant is present but the operative amendment is absent. The expected behaviour is abstention, not selection of the stale value.
  8. When importing GPT-2 weights, compare reference logits on fixed token identifiers. Do not accept fluent-looking text as the sole test.

Notes


Build layerChapter 6: Adapting the decoder for classification

A language model normally answers with another token. A classifier answers with one label from a declared set. That difference in output contract changes the head, the loss, the evaluation and the way uncertainty should be handled.

Chapter map for Build layer Chapter 6: Adapting the decoder for classification: What changes after pretraining; Start with the operating decision; Prepare data without manufacturing confidence; Tokenisation, padding and the readout position; A classifier wrapper.
Mermaid chapter map. Build layer Chapter 6: Adapting the decoder for classification connects What changes after pretraining, Start with the operating decision, Prepare data without manufacturing confidence, Tokenisation, padding and the readout position, A classifier wrapper.

We will adapt the pretrained decoder to classify SMS messages as ham or spam. The dataset is small enough to run as a learning exercise, but the engineering decisions carry into document routing, intent detection and other bounded tasks. The objective is not to celebrate one accuracy number. It is to build a classifier whose data split, pooling rule, metrics and review threshold are internally consistent.

Classification maps an input to a fixed label set, whereas instruction tuning retains open-vocabulary generation and learns an output sequence.
Figure 6.1. The output contract determines the adaptation.

What changes after pretraining

The base decoder produces one vocabulary-sized logit vector at every sequence position. If its hidden width is 768 and the vocabulary contains 50,257 tokens, the language-model head maps 768 hidden values to 50,257 logits.

For binary classification, the feature path bypasses that vocabulary projection. A new class head maps one pooled 768-value hidden state to two class logits:

class logits=hWclass+bclass. \text{class logits} = hW_{\text{class}} + b_{\text{class}}.

The backbone retains representations learnt during next-token pretraining. The new head learns the boundary between ham and spam. Depending on the amount and similarity of labelled data, we may train:

  • only the new head;
  • the head, final normalisation and final transformer block;
  • the upper several blocks; or
  • the whole model with a small learning rate.

There is no universally correct freeze pattern. Training more parameters increases adaptation capacity and compute, but can overfit a small dataset or disturb useful general representations. Treat the scope as a hyperparameter and compare it on a validation set.

The classifier bypasses the pretrained vocabulary projection and sends a pooled hidden state to a two-logit class head; lower representations may remain frozen while selected upper layers adapt.
Figure 6.2. Classification adds a task-specific route from hidden states to class logits.

A bounded output prevents the model from inventing a third label string. It does not prevent misclassification. A wrong spam logit is still wrong, and a high score is not automatically trustworthy.

Start with the operating decision

Before touching the dataset, decide what the label will control.

For a personal spam filter, a false positive may hide a legitimate message. A false negative leaves unwanted mail in the inbox. The costs differ. One product might quarantine only extremely likely spam and place borderline messages in a review folder. Another may need high recall and tolerate more false positives.

Write the decision contract:

input:
  one decoded SMS message

model output:
  spam score in [0, 1]

routing:
  score >= high_threshold      → quarantine
  score <= low_threshold       → inbox
  otherwise                    → review folder

non-model checks:
  sender blocklist, malware links, message length, decode status

logged evidence:
  model version, tokenizer version, score, thresholds, route

The thresholds belong to the application, not to the neural layer. They should be selected from validation data using the intended error costs and then held fixed for final testing.

Prepare data without manufacturing confidence

The SMS Spam Collection contains labelled English-language messages assembled from several public and research sources.1 It is useful for a tutorial and unrepresentative of most modern messaging traffic. It does not cover many languages, image-based attacks, contemporary brands or adversarial evasion. Those limitations should travel with any result.

The original corpus is imbalanced: legitimate messages outnumber spam. Discarding legitimate examples to create a 50/50 dataset makes a neat lesson, but it changes the base rate and wastes data. Here we retain the observed distribution, stratify the splits and use class weighting during training. For a real product, the evaluation set should reproduce the expected operating population or be reweighted transparently.

Data leakage is a larger threat than a slightly imperfect ratio. Near-duplicate campaign messages can enter both train and test splits. Messages from the same source collection can share templates. Random row splitting may therefore overstate generalisation. A serious experiment should group near-duplicates and source campaigns before splitting, and use a later time period for the final test when timestamps exist.

The UCI archive cited in the note below extracts a headerless, tab-separated file named SMSSpamCollection.1 Place that file in the working directory before running the next cell. The loader normalises the table schema, checks the source label contract exactly, and creates the numeric target used by the dataset class. It does not silently lowercase or trim malformed labels, and it treats quotation marks as message text rather than CSV delimiters.

import csv
import hashlib
from pathlib import Path

import pandas as pd


sms_path = Path("SMSSpamCollection")
if not sms_path.is_file():
    raise FileNotFoundError(
        "Expected the extracted UCI file named "
        f"{sms_path.name!r} in {sms_path.parent.resolve()}"
    )

expected_sha256 = (
    "7d039a24a6083ed9ef0f806ebad56bbb976e3aeb8"
    "de05669173bfdc4996c239d"
)
actual_sha256 = hashlib.sha256(
    sms_path.read_bytes()
).hexdigest()
if actual_sha256 != expected_sha256:
    raise ValueError("SMS corpus digest does not match the pinned file")

rows = pd.read_csv(
    sms_path,
    sep="\t",
    header=None,
    dtype="string",
    keep_default_na=False,
    quoting=csv.QUOTE_NONE,
)
if rows.shape[1] != 2:
    raise ValueError(
        "Expected two tab-separated columns: label and message text"
    )
rows.columns = ["label", "text"]
if len(rows) != 5_574:
    raise ValueError(f"Expected 5,574 messages; observed {len(rows)}")

expected_labels = {"ham", "spam"}
observed_labels = set(rows["label"].unique())
if observed_labels != expected_labels:
    raise ValueError(
        "Expected exactly the labels 'ham' and 'spam'; "
        f"observed {sorted(observed_labels)!r}"
    )
if rows["text"].str.strip().eq("").any():
    raise ValueError("The corpus contains an empty message")

label_to_id = {"ham": 0, "spam": 1}
rows["label_id"] = rows["label"].map(label_to_id).astype("int64")

A basic stratified split is:

from sklearn.model_selection import train_test_split


train_rows, remainder = train_test_split(
    rows,
    test_size=0.30,
    random_state=17,
    stratify=rows["label_id"],
)

validation_rows, test_rows = train_test_split(
    remainder,
    test_size=2 / 3,
    random_state=17,
    stratify=remainder["label_id"],
)

This produces approximately 70% training, 10% validation and 20% test data. Exact counts depend on the corpus length and rounding. Report the counts returned by the code; do not copy expected totals into prose.

The three splits have distinct jobs:

  • training updates weights;
  • validation selects freeze scope, learning rate, epoch, calibration and thresholds; and
  • test is opened once for the final estimate.

Repeatedly consulting test results turns the test set into another validation set.

Tokenisation, padding and the readout position

Messages have different lengths, but a batch tensor is rectangular. We truncate long examples, append an explicit end-of-text token, and right-pad short examples. An attention mask distinguishes real tokens from padding.

import torch
from torch.utils.data import Dataset


class SmsDataset(Dataset):
    def __init__(
        self,
        frame,
        tokenizer,
        max_length: int,
        eot_id: int,
    ) -> None:
        if max_length < 1:
            raise ValueError("max_length must be at least one")
        if eot_id < 0:
            raise ValueError("eot_id must be non-negative")

        texts = frame["text"].tolist()
        self.labels = frame["label_id"].tolist()
        if len(texts) != len(self.labels):
            raise ValueError("text and label columns must align")

        self.examples = []

        for text in texts:
            token_ids = list(
                tokenizer.encode(
                    text,
                    disallowed_special=(),
                )
            )
            if any(token_id < 0 for token_id in token_ids):
                raise ValueError("token IDs must be non-negative")
            token_ids = token_ids[: max_length - 1]
            token_ids = token_ids + [eot_id]

            real_length = len(token_ids)
            padding = [eot_id] * (max_length - real_length)
            input_ids = token_ids + padding
            attention_mask = [1] * real_length
            attention_mask += [0] * len(padding)

            self.examples.append(
                (
                    torch.tensor(input_ids, dtype=torch.long),
                    torch.tensor(
                        attention_mask,
                        dtype=torch.bool,
                    ),
                )
            )

    def __len__(self) -> int:
        return len(self.labels)

    def __getitem__(self, index: int):
        input_ids, attention_mask = self.examples[index]
        label = torch.tensor(
            self.labels[index],
            dtype=torch.long,
        )
        return input_ids, attention_mask, label

Reusing the end-of-text identifier for padding is acceptable only because the mask carries the distinction. The classifier must not select the final array position indiscriminately: for a short message, that position is padding.

A causal decoder’s hidden state at position ii can incorporate positions zero through ii. The final real position has therefore seen the complete message. Our explicit end-of-text token provides a stable readout position.

Under a causal mask, each hidden state summarises a longer prefix; the final real token is the first position that can represent the complete message.
Figure 6.3. Pool the final real position, not the final padded position.

Other pooling rules are possible. A masked mean combines real-token states; an added classification token can supply a dedicated readout; some encoder models provide a pooled output. Compare the choices rather than assuming the last token is intrinsically best.

A classifier wrapper

The backbone needs an interface that returns final hidden states before the language-model vocabulary projection. The wrapper below uses that feature path and adds a classifier; it neither calls nor replaces the backbone’s language-model head.

from torch import nn


class GPTSequenceClassifier(nn.Module):
    def __init__(
        self,
        backbone: nn.Module,
        d_model: int,
        num_classes: int,
    ) -> None:
        super().__init__()
        self.backbone = backbone
        self.classifier = nn.Linear(
            d_model,
            num_classes,
        )

    def forward(
        self,
        input_ids: torch.Tensor,
        attention_mask: torch.Tensor,
    ) -> torch.Tensor:
        if input_ids.ndim != 2:
            raise ValueError("input_ids must have shape [batch, tokens]")
        if input_ids.shape[0] == 0:
            raise ValueError("classification batch cannot be empty")
        if input_ids.shape != attention_mask.shape:
            raise ValueError(
                "input_ids and attention_mask must have the same shape"
            )
        attention_mask = attention_mask.bool()
        real_length = attention_mask.sum(dim=1)
        if torch.any(real_length == 0):
            raise ValueError("each row needs at least one real token")
        if torch.any(
            (~attention_mask[:, :-1]) & attention_mask[:, 1:]
        ):
            raise ValueError("attention_mask must use right padding")

        hidden = self.backbone.forward_features(
            input_ids,
            key_is_padding=~attention_mask,
        )

        last_index = real_length - 1
        batch_index = torch.arange(
            input_ids.shape[0],
            device=input_ids.device,
        )
        pooled = hidden[batch_index, last_index]
        return self.classifier(pooled)

forward_features is a deliberate model contract: it returns [batch, tokens, d_model] after the final normalisation. If the Chapter 4 model uses another method name, adapt the interface once rather than copying its internals into the classifier.

Head ownership matters even when the vocabulary projection is bypassed. In a weight-tied decoder, lm_head.weight aliases the token-embedding weight, so retaining the head does not allocate a second vocabulary matrix, although its module and checkpoint key still exist. In an untied decoder, the unused head owns a separate matrix. The freeze loop below leaves that matrix non-trainable; construct optimiser groups from parameters whose requires_grad flag is true so it is excluded from optimisation. A feature-only deployment may remove an untied head to save device and checkpoint space, but that is a deliberate model and checkpoint-interface change.

The padding mask is passed into every attention block. For right padding and a final-real-token readout, the causal boundary already prevents a real token from reading later padding; retaining the explicit padding mask makes the interface correct for other pooling strategies and more complex masks. The wrapper rejects empty rows and false-to-true mask transitions because its sum - 1 readout assumes one contiguous real prefix.

The following bridge restores the selected Chapter 5 language-model checkpoint, constructs all three dataset objects and creates the classifier. The tiny story checkpoint makes the mechanics executable; it is not expected to provide a competitive SMS representation. A serious transfer experiment would begin from a suitable, documented pretrained checkpoint while retaining the same interfaces.

import tiktoken
from pathlib import Path
from torch.utils.data import DataLoader


checkpoint_path = Path(
    "checkpoints/best-validation.pt"
)
# weights_only=False is safe only for a checkpoint you trust.
pretrained = torch.load(
    checkpoint_path,
    map_location="cpu",
    weights_only=False,
)
if pretrained.get("schema_version") != 1:
    raise ValueError("unsupported checkpoint schema")

cfg = GPTConfig(**pretrained["model_config"])
backbone = GPTModel(cfg)
backbone.load_state_dict(pretrained["model"], strict=True)

tokenizer = tiktoken.get_encoding("gpt2")
if tokenizer.n_vocab != cfg.vocab_size:
    raise ValueError("tokenizer and checkpoint vocabularies differ")

max_length = min(120, cfg.context_length)
train_dataset = SmsDataset(
    train_rows,
    tokenizer,
    max_length=max_length,
    eot_id=tokenizer.eot_token,
)
validation_dataset = SmsDataset(
    validation_rows,
    tokenizer,
    max_length=max_length,
    eot_id=tokenizer.eot_token,
)
test_dataset = SmsDataset(
    test_rows,
    tokenizer,
    max_length=max_length,
    eot_id=tokenizer.eot_token,
)

train_generator = torch.Generator().manual_seed(17)
train_loader = DataLoader(
    train_dataset,
    batch_size=8,
    shuffle=True,
    generator=train_generator,
)
validation_loader = DataLoader(
    validation_dataset,
    batch_size=8,
    shuffle=False,
)
test_loader = DataLoader(
    test_dataset,
    batch_size=8,
    shuffle=False,
)

model = GPTSequenceClassifier(
    backbone=backbone,
    d_model=cfg.d_model,
    num_classes=2,
)
device = torch.device(
    "cuda" if torch.cuda.is_available() else "cpu"
)
model.to(device)

To freeze the backbone:

for parameter in model.backbone.parameters():
    parameter.requires_grad = False

for parameter in model.backbone.blocks[-1].parameters():
    parameter.requires_grad = True

for parameter in model.backbone.final_norm.parameters():
    parameter.requires_grad = True

for parameter in model.classifier.parameters():
    parameter.requires_grad = True

The exact attribute names must match the model. Verify trainable parameters rather than trusting the loop:

trainable = {
    name: parameter.numel()
    for name, parameter in model.named_parameters()
    if parameter.requires_grad
}
print(trainable)
print("total trainable:", sum(trainable.values()))

A common implementation bug is placing task heads in an ordinary Python dictionary. PyTorch will not register those layers for device movement, optimisation or checkpointing. Use nn.ModuleDict when a model owns several heads.

Weighted loss and the training loop

Let class weight wyw_y reflect the chosen treatment of imbalance. Weighted cross-entropy is:

=i=1NwyilogP(yixi)i=1Nwyi. \mathcal{L} = -\frac{ \sum_{i=1}^{N} w_{y_i}\log P(y_i\mid x_i) }{ \sum_{i=1}^{N} w_{y_i} }.

This is the weighted-mean reduction used by torch.nn.functional.cross_entropy: the denominator is the sum of the weights attached to the observed targets in the batch, not simply the batch size. Weights should be calculated from training data only. A common starting point is inverse class frequency, normalised so their average scale is manageable. The best choice still depends on the error costs; thresholding may be more direct than aggressively weighting the loss.

import torch.nn.functional as F


def classification_step(
    model,
    batch,
    class_weights,
    device,
):
    input_ids, attention_mask, labels = [
        item.to(device) for item in batch
    ]
    logits = model(input_ids, attention_mask)
    class_weights = class_weights.to(
        device=logits.device,
        dtype=logits.dtype,
    )
    loss = F.cross_entropy(
        logits,
        labels,
        weight=class_weights,
    )
    return loss, logits, labels

Complete the bridge with one class-weight vector derived from the training partition. The balanced inverse-frequency rule below gives each class equal total weight without deleting majority-class examples. Validation and test labels do not enter this calculation.

num_classes = 2
train_labels = torch.tensor(
    train_rows["label_id"].tolist(),
    dtype=torch.long,
)
if train_labels.numel() == 0:
    raise ValueError("The training partition is empty")
if not torch.all(
    (train_labels >= 0) & (train_labels < num_classes)
):
    raise ValueError("Training labels must be 0 or 1")

train_counts = torch.bincount(
    train_labels,
    minlength=num_classes,
)
if torch.any(train_counts == 0):
    raise ValueError("Every class must occur in the training partition")

class_weights = train_labels.numel() / (
    num_classes * train_counts.to(torch.float32)
)

trainable_parameters = [
    parameter
    for parameter in model.parameters()
    if parameter.requires_grad
]
if not trainable_parameters:
    raise ValueError("The model has no trainable parameters")

optimizer = torch.optim.AdamW(
    trainable_parameters,
    lr=5e-5,
    weight_decay=0.01,
)

For language modelling, an epoch loss is weighted by valid target tokens. Here it is weighted by classified examples. Because weighted cross-entropy uses iwyi\sum_i w_{y_i} as its denominator, combining batch means requires that same effective example weight; averaging batch losses would give small final batches too much influence.

def _loss_weight(
    labels,
    class_weights,
    dtype,
):
    weights = class_weights.to(
        device=labels.device,
        dtype=dtype,
    )
    return weights[labels].sum()


def train_classifier_epoch(
    model,
    loader,
    optimizer,
    class_weights,
    device,
    max_grad_norm: float = 1.0,
):
    if max_grad_norm <= 0:
        raise ValueError("max_grad_norm must be positive")

    trainable = [
        parameter
        for parameter in model.parameters()
        if parameter.requires_grad
    ]
    if not trainable:
        raise ValueError("The model has no trainable parameters")

    model.train()
    weighted_loss_sum = 0.0
    weight_sum = 0.0

    for batch in loader:
        optimizer.zero_grad(set_to_none=True)
        loss, _, labels = classification_step(
            model,
            batch,
            class_weights,
            device,
        )
        loss.backward()
        torch.nn.utils.clip_grad_norm_(
            trainable,
            max_norm=max_grad_norm,
        )
        optimizer.step()

        batch_weight = _loss_weight(
            labels,
            class_weights,
            loss.dtype,
        ).item()
        weighted_loss_sum += loss.detach().item() * batch_weight
        weight_sum += batch_weight

    if weight_sum == 0:
        raise ValueError("The training loader yielded no examples")
    return weighted_loss_sum / weight_sum


@torch.inference_mode()
def evaluate_classifier(
    model,
    loader,
    class_weights,
    device,
):
    previous_mode = model.training
    model.eval()
    weighted_loss_sum = 0.0
    weight_sum = 0.0
    logits_parts = []
    label_parts = []

    try:
        for batch in loader:
            loss, logits, labels = classification_step(
                model,
                batch,
                class_weights,
                device,
            )
            batch_weight = _loss_weight(
                labels,
                class_weights,
                loss.dtype,
            ).item()
            weighted_loss_sum += loss.item() * batch_weight
            weight_sum += batch_weight
            logits_parts.append(logits.detach().cpu())
            label_parts.append(labels.detach().cpu())
    finally:
        model.train(previous_mode)

    if weight_sum == 0:
        raise ValueError("The evaluation loader yielded no examples")

    return {
        "loss": weighted_loss_sum / weight_sum,
        "logits": torch.cat(logits_parts),
        "labels": torch.cat(label_parts),
    }

The checkpoint selector should not even accept a test loader. This example declares validation loss as the selection objective, retains an independent copy of the best model state, and returns enough history to audit the choice.

def fit_with_validation_selection(
    model,
    train_loader,
    validation_loader,
    optimizer,
    class_weights,
    device,
    num_epochs: int,
):
    if num_epochs < 1:
        raise ValueError("num_epochs must be positive")

    best_validation_loss = float("inf")
    best_checkpoint = None
    history = []

    for epoch in range(1, num_epochs + 1):
        train_loss = train_classifier_epoch(
            model,
            train_loader,
            optimizer,
            class_weights,
            device,
        )
        validation = evaluate_classifier(
            model,
            validation_loader,
            class_weights,
            device,
        )
        record = {
            "epoch": epoch,
            "train_loss": train_loss,
            "validation_loss": validation["loss"],
        }
        history.append(record)

        if validation["loss"] < best_validation_loss:
            best_validation_loss = validation["loss"]
            best_checkpoint = {
                name: tensor.detach().cpu().clone()
                for name, tensor in model.state_dict().items()
            }

    if best_checkpoint is None:
        raise RuntimeError("No finite validation checkpoint was selected")
    return best_checkpoint, history

Run selection using only train_loader and validation_loader. Once the selection rule has finished, restore that checkpoint and open the test partition exactly once:

best_checkpoint, history = fit_with_validation_selection(
    model,
    train_loader,
    validation_loader,
    optimizer,
    class_weights,
    device,
    num_epochs=5,
)
model.load_state_dict(best_checkpoint)

# First and only use of test_loader after selection.
final_test = evaluate_classifier(
    model,
    test_loader,
    class_weights,
    device,
)

After final_test is calculated, do not change the epoch, checkpoint, calibrator or threshold in response to its results. A different declared validation objective, such as spam recall subject to a false-positive ceiling, belongs in the selector before training begins.

Use the same tokenizer, truncation, pooling and padding path in training and serving. Training from a fixed padding position but serving from the final real token changes the representation seen by the class head.

A defensible experiment moves from raw data through stratified splits and a single tokenisation path to transfer learning, validation and one final test.
Figure 6.4. Data preparation is part of the model, not an administrative prelude.

Track training and validation loss, but do not diagnose the model from two smooth curves alone.

Illustrative classification losses decline together, yet calibration and class-specific errors remain unmeasured by the curves.
Figure 6.5. Loss is one diagnostic among several.

Declare the epoch budget and checkpoint-selection rule before the run. The example selector uses validation loss; a high-spam-recall objective subject to a maximum false-positive rate would instead select on that declared constraint. Do not switch objectives after inspecting the test result.

Choosing how much of the backbone to train

Run the freeze decision as a controlled comparison. A small experiment might use the same split and optimiser budget for three candidates:

Candidate Trainable components What it tests
Linear probe class head only whether the pretrained representation already separates the labels
Upper-block adaptation class head, final norm and final block whether limited task-specific contextual change helps
Full fine-tuning all parameters whether broader adaptation justifies its cost and overfitting risk

Compare validation metrics, calibration, training stability, wall-clock cost and performance on challenge sets. The candidate with the lowest training loss may not be the best. A linear probe that gives slightly lower average accuracy but much steadier performance across languages or templates can be the better system.

Layer-wise learning rates offer another option: use a larger rate for the new head and smaller rates for lower pretrained blocks. Parameter-efficient methods can add small low-rank or adapter modules while leaving the base checkpoint fixed. These techniques change storage and optimisation economics; they do not relax the need for representative labels or threshold evaluation.

When labelled data is very scarce, repeated cross-validation can describe variance better than one split, but final model selection still needs a locked, leakage-resistant test. If near-duplicate campaigns are grouped, folds must preserve those groups.

Metrics that match the risk

For binary labels, the confusion matrix contains:

  • true positives: spam correctly identified;
  • false positives: legitimate messages marked as spam;
  • true negatives: legitimate messages retained; and
  • false negatives: spam allowed through.

From those counts:

precision=TPTP+FP,recall=TPTP+FN. \text{precision}=\frac{TP}{TP+FP}, \qquad \text{recall}=\frac{TP}{TP+FN}.

Precision answers: when the model says spam, how often is it right? Recall answers: how much spam did it catch? Their harmonic mean, F1F_1, is compact but assumes a particular balance between the two. Report the underlying values.

Accuracy can look strong when the majority class dominates. Receiver operating characteristic area can also appear generous on highly imbalanced data. Precision–recall curves focus attention on positive-class retrieval and are often more informative when positives are rare.2

Add metrics that expose the intended use:

  • per-class precision, recall and support;
  • precision–recall area;
  • false-positive rate at the chosen operating point;
  • false-negative rate at the chosen operating point;
  • performance by message language, length and source where lawful and useful;
  • coverage and error rate for each abstention band; and
  • latency and failure rate of the full preprocessing-plus-model path.

Confidence needs separate treatment. A softmax score is a normalised model output, not a guarantee that seven of ten examples scored 0.70 will be correct. Modern neural networks can be miscalibrated.3 Calibration measures whether predicted probabilities correspond to observed frequencies on representative data.

Temperature scaling can calibrate logits using a held-out validation set. It fits one positive scalar without changing the rank order of predictions. The calibrator and its data period are versioned components. Recalibration may be needed after a change in base rate, model or input distribution.

Do not fit calibration on the final test set. Do not quote calibration from a balanced research sample as if it applies to a production population with a different spam prevalence.

A worked confusion matrix

Suppose a locked test contains 10,000 messages: 500 spam and 9,500 legitimate. At one threshold the classifier produces:

Predicted spam Predicted ham
Actual spam 450 50
Actual ham 190 9,310

Then:

precision=450450+1900.703 \text{precision} = \frac{450}{450+190} \approx 0.703

recall=450450+50=0.900 \text{recall} = \frac{450}{450+50} = 0.900

accuracy=450+9,31010,000=0.976. \text{accuracy} = \frac{450+9{,}310}{10{,}000}=0.976.

An accuracy of 97.6% sounds excellent, yet almost three in ten quarantined messages are legitimate. Whether that is acceptable depends on the routing design. Raising the threshold may improve precision and lose recall; adding a review band may preserve both at the cost of manual work.

Now evaluate the same sensitivity and specificity at a 1% spam prevalence. Even if the model’s conditional error rates stayed fixed, false positives could outnumber true positives because legitimate messages are so common. This is the base-rate effect. A precision value measured on a deliberately balanced test set cannot be copied into a low-prevalence deployment.

Confidence intervals belong beside the point estimates. Bootstrap the test examples at the correct grouping level, such as campaign rather than individual message, so correlated duplicates do not create an illusion of precision.

Collect scores once, analyse many thresholds

Evaluation should store logits and labels from a deterministic pass, then calculate metrics without repeatedly running the model:

@torch.no_grad()
def collect_logits(model, loader, device):
    was_training = model.training
    model.eval()
    all_logits = []
    all_labels = []

    try:
        for input_ids, mask, labels in loader:
            logits = model(
                input_ids.to(device),
                mask.to(device),
            )
            all_logits.append(logits.cpu())
            all_labels.append(labels)
    finally:
        model.train(was_training)

    return (
        torch.cat(all_logits),
        torch.cat(all_labels),
    )

Keep the raw logits. Temperature scaling acts on them, and alternate thresholds can be compared from the same predictions. Record the example identifiers so every false positive can be inspected against its source and preprocessing trace.

Calibration plots group predictions into score intervals and compare average score with observed frequency. Their appearance depends on binning, so also report a proper scoring rule such as log loss or the Brier score. Inspect calibration by meaningful subpopulation when sample size permits. Aggregate calibration can hide a badly miscalibrated minority language.

Thresholds and abstention

After calibration, choose thresholds using the expected cost of errors and the available review capacity. A two-threshold policy makes uncertainty operational:

def route_spam_score(
    spam_probability: float,
    low_threshold: float,
    high_threshold: float,
) -> str:
    if not 0.0 <= spam_probability <= 1.0:
        raise ValueError("probability must be in [0, 1]")
    if not 0.0 <= low_threshold < high_threshold <= 1.0:
        raise ValueError("invalid thresholds")

    if spam_probability >= high_threshold:
        return "quarantine"
    if spam_probability <= low_threshold:
        return "inbox"
    return "review"

Abstention does not fix a bad model. It trades coverage for lower risk on the automated routes. Report both. A system that achieves high precision by sending 80% of messages to review may be safe but operationally useless.

Thresholds also need stability tests. Evaluate them under:

  • a lower or higher spam base rate;
  • changed message lengths;
  • Unicode confusables and unusual whitespace;
  • URL shortening and obfuscation;
  • duplicated campaigns;
  • new languages and code-switching;
  • empty, corrupted and over-length inputs; and
  • tokenizer or model upgrades.

If the system cannot recognise that an input is outside its validated scope, the review band may be falsely reassuring. Add explicit decode failures, language coverage checks and out-of-distribution tests where they improve containment.

Scores are evidence about the model, not the message

A score of 0.92 means the model produced particular relative logits under a specific checkpoint and input. Before calibration it has no direct frequency interpretation. After calibration, its interpretation is tied to the calibration population and period. It is not evidence that a link is malicious or a sender intended fraud.

This distinction matters when a classification feeds another model. Do not turn spam_probability=0.92 into generated prose saying “the sender is a fraudster”. Pass the label proposal, score, model version and supporting non-model signals. Downstream policy determines the route and wording.

A banking-scale routing example

Consider a fictional UK bank receiving documents for a credit renewal. A classifier proposes one of:

facility_agreement
amendment
covenant_certificate
accounts
credit_policy
correspondence
other

This is a useful bounded task, but a wrong route can hide an operative amendment. The system should not auto-delete or establish legal precedence from the class label. These names match the canonical document taxonomy in Appendix A; an ingestion adapter must translate any source-system aliases before classification results are stored or evaluated.

A controlled path is:

  1. deterministic checks validate file type, malware scan, case identifier and document hash;
  2. the classifier proposes a class and calibrated score;
  3. high-scoring ordinary documents enter the proposed queue;
  4. low scores, novel templates, conflicting metadata and any proposed amendment route to review;
  5. a document specialist confirms material classifications; and
  6. effective-date and amendment precedence are resolved by a separate, auditable rule or legal-review process.

The classifier reduces manual sorting. It does not decide the customer’s credit outcome. A model card should state that distinction, the evaluated document types, excluded populations, training period, thresholds, metrics, known failure modes and escalation owner.

For this use case, errors should be weighted by consequence. Misclassifying a cover letter as other may be cheap; missing an amendment may be expensive. Macro-averaged metrics give every class equal weight, while a cost matrix can represent operational severity. Keep both the statistical results and the business rationale visible.

Privacy, fairness and representation

Messages and banking documents can contain personal and commercially sensitive data. Data minimisation starts before training: remove fields that the task does not require, control access to raw examples, keep identifiers out of general experiment logs and define a retention period for snapshots and checkpoints. Memorisation testing is relevant even for a classifier because the pretrained backbone and fine-tuning examples may contain sensitive strings.

Fairness analysis should follow the harm. For a spam filter, systematic false positives on a language or writing style can silence legitimate users. For a banking router, document format can correlate with customer segment, channel or accessibility need. Compare error rates and review coverage across justified groups, investigate the examples and avoid inferring protected attributes just to decorate a dashboard.

A human-review route does not erase disparate impact. If one group is routed to manual review far more often, it may experience slower service. Measure coverage and turnaround time, not only automated-route accuracy.

Release and monitoring

Before release, capture:

  • dataset lineage and the deduplication method;
  • split logic, random seed and exact row identifiers;
  • tokenizer, maximum length and truncation behaviour;
  • pretrained checkpoint and code revision;
  • trainable-parameter scope;
  • optimiser, class weights and stopping rule;
  • calibration method and validation period;
  • thresholds and their error-cost rationale;
  • test metrics with confidence intervals; and
  • challenge-set results and unresolved limitations.

After release, monitor the input and decision pipeline rather than a single score average. Useful signals include decode failures, language mix, length, class prevalence, review coverage, threshold overrides, confirmed false-positive and false-negative rates, and changes in template clusters.

Labels often arrive late and selectively. Messages routed to review receive more scrutiny than messages auto-routed to the inbox, which can bias observed performance. Use audited sampling across all routes. Do not interpret an absence of reported errors as evidence that the model remains accurate.

Retraining is a controlled change. It requires a new data snapshot, leakage checks, calibration, regression comparison and rollback path. A newer checkpoint is not automatically a better production classifier.

Build check

Confirm each point before proceeding to instruction tuning.

  1. State how the feature path bypasses the vocabulary projection and feeds a pooled hidden state to the class head.
  2. Print the names and counts of every trainable parameter.
  3. Show that both training and serving pool the same final real token.
  4. Report exact train, validation and test counts from the split code.
  5. Explain why balancing the dataset changes the class prior.
  6. Produce a confusion matrix and class-specific precision and recall.
  7. Select thresholds on validation data, then freeze them before final testing.
  8. Plot or tabulate calibration on data with a relevant base rate.
  9. Measure the coverage and error rate of inbox, quarantine and review routes.
  10. Run Unicode, truncation, duplication and novel-template challenges.
  11. Record which banking document classes always require human confirmation.
  12. Verify that a wrong class label cannot itself alter or delete evidence.

The released classifier is the combination of its weights, pooling rule, evaluation population, calibration data and operating thresholds. Record and review that whole contract whenever any one of those pieces changes.

Notes and further reading


Build layerChapter 7: Teaching the model to follow instructions

A base language model is trained to continue text. If a prompt ends with a question, a plausible continuation might be an answer, another question, a discussion of questions, or a fragment that resembles the training corpus. Instruction tuning narrows that behaviour. It trains the model on examples in which a request, optional context and desired response follow a consistent contract.

Chapter map for Build layer Chapter 7: Teaching the model to follow instructions: From completion to an explicit response contract; Build the dataset around evidence; Include failures, abstentions and boundaries; Which tokens should contribute to loss?; How much of the model should be trained?.
Mermaid chapter map. Build layer Chapter 7: Teaching the model to follow instructions connects From completion to an explicit response contract, Build the dataset around evidence, Include failures, abstentions and boundaries, Which tokens should contribute to loss?, How much of the model should be trained?.

The underlying objective remains next-token prediction. What changes is the data distribution and, often, which tokens contribute to loss. Instead of learning from arbitrary web or book continuations, the model learns to generate responses that demonstrate the requested task.

This chapter builds a supervised instruction-tuning path. It also sets its limits. Supervised examples can teach format, tone and task behaviour; they do not guarantee truth, policy compliance or safe tool use.

Instruction datasets can cover extraction, transformation, evidence-grounded answering and classification with explanation, provided their response contracts remain consistent.
Figure 7.1. Task diversity is useful only when each example has a clear, quality-controlled target.

From completion to an explicit response contract

An instruction example has three conceptual fields:

instruction:
  Extract the leverage covenant.

input:
  The ratio of Consolidated Net Debt to Covenant EBITDA
  shall not exceed 3.50:1 on each Test Date.

response:
  {"metric":"leverage","operator":"<=","threshold":3.50}

The fields must become one token sequence because the decoder accepts a sequence, not a dictionary. A formatter supplies visible headings or special role tokens that mark the boundaries.

An instruction, an input clause and a structured response are concatenated, tokenised and shifted into next-token inputs and targets.
Figure 7.2. Structured records become an ordinary causal-language-modelling sequence.

The template is part of the model interface. Two common styles are:

### Instruction:
Extract the leverage covenant.

### Input:
...clause text...

### Response:

and:

<|user|>
Extract the leverage covenant.
<|context|>
...clause text...
<|assistant|>

The names are less important than consistency with the tokenizer and checkpoint. Special tokens need declared identifiers and embedding rows. Literal headings are tokenised as ordinary text.

A section-based template and a role-token template both separate the request, context and assistant response.
Figure 7.3. Changing a template is an input-distribution change and should be evaluated as such.

At serving time, generation begins immediately after the assistant boundary. At training time, the desired response follows that boundary. The model is therefore shown exactly what a correct continuation looks like.

Build the dataset around evidence

Instruction tuning amplifies the assumptions in its examples. If references are unsupported, formats inconsistent or refusals missing, the model learns those defects along with the desired tasks.

Each dataset record should carry more than the three text fields:

example_id
task_type
instruction
input
response
source_ids
licence_or_permission
creation_method
review_status
reviewer_role
language
risk_tags
split_group
content_hash

split_group keeps related examples together when data is divided. Several questions derived from the same source document should not appear across train and test. content_hash supports exact deduplication; semantic or fuzzy deduplication catches paraphrased templates and generated variants.

The source of a response matters. Human-written targets may contain judgement and stylistic variation. Model-generated targets can cheaply expand a dataset but can also reproduce the generating model’s errors and phrasing. Self-Instruct and Stanford Alpaca are influential examples of synthetic instruction-data pipelines, not evidence that generation removes the need for source and quality controls.12 A human review flag is meaningful only if reviewers had the source evidence, rubric and time needed to check the example.

For the credit-document thread, an extraction response should be derived from a synthetic or properly controlled document span and reviewed against that span. The target should not be an uncited summary written from memory. Store the source identifier with the training example even if the public training text does not expose an internal identifier.

Include failures, abstentions and boundaries

A dataset containing only answerable, well-formed requests teaches the model that every request deserves an answer. Add examples in which the correct response is:

  • a request for a missing document;
  • an abstention because two operative clauses conflict;
  • a refusal to act without authorisation;
  • a statement that the supplied passage does not support the claim;
  • a typed validation error; or
  • escalation to a named human role.

These targets should be precise. A generic “I cannot help” is less useful than “The executed amendment is missing, so the applicable threshold cannot be established.”

Negative examples should not expose secrets, unsafe procedures or hidden instructions in their target. The goal is to teach an observable boundary, not to collect every possible attack string.

Which tokens should contribute to loss?

The full concatenated sequence contains prompt and response tokens. Standard causal-language-model loss can score all of them. That teaches the model to reproduce the prompt template as well as the answer.

For instruction tuning, it is often cleaner to score response tokens only. Set targets corresponding to prompt prediction and padding to the ignore value -100, which PyTorch cross-entropy excludes by default. Keep the target for the end-of-text token so the model learns to stop.

Answer tokens and the end marker contribute to loss; prompt and padding positions use the ignored target value.
Figure 7.4. Response-only masking aligns optimisation with the behaviour required at inference.

The indexing is easy to get wrong. If the first response token occupies position rr in the full sequence, it appears at target index r1r-1 after the one-token shift.

from dataclasses import dataclass

import torch


@dataclass(frozen=True)
class TokenisedInstruction:
    token_ids: list[int]
    response_start: int


def tokenise_instruction(record, encoding):
    required = {"instruction", "input", "response"}
    if not required.issubset(record):
        missing = sorted(required - set(record))
        raise ValueError(f"missing fields: {missing}")
    if not all(
        isinstance(record[name], str)
        for name in required
    ):
        raise TypeError("instruction fields must be strings")

    prompt = (
        "### Instruction:\n"
        f"{record['instruction'].strip()}\n\n"
        "### Input:\n"
        f"{record['input'].strip()}\n\n"
        "### Response:\n"
    )
    prompt_ids = encoding.encode(
        prompt,
        disallowed_special=(),
    )
    response_ids = encoding.encode(
        record["response"],
        disallowed_special=(),
    )
    if not prompt_ids or not response_ids:
        raise ValueError("prompt and response must both tokenise")

    return TokenisedInstruction(
        token_ids=prompt_ids + response_ids,
        response_start=len(prompt_ids),
    )


class InstructionCollator:
    def __init__(
        self,
        eot_id: int,
        ignore_index: int = -100,
    ) -> None:
        self.eot_id = eot_id
        self.ignore_index = ignore_index

    def __call__(self, items):
        items = list(items)
        if not items:
            raise ValueError("cannot collate an empty batch")
        for item in items:
            if not item.token_ids:
                raise ValueError("each example needs at least one token")
            if not 1 <= item.response_start < len(item.token_ids):
                raise ValueError(
                    "response_start must identify a response token "
                    "after at least one prompt token"
                )

        sequences = [
            item.token_ids + [self.eot_id]
            for item in items
        ]
        max_input_length = max(
            len(sequence) - 1
            for sequence in sequences
        )

        input_batch = []
        attention_batch = []
        target_batch = []

        for item, sequence in zip(items, sequences):
            input_ids = sequence[:-1]
            targets = sequence[1:]

            first_response_target = max(
                0,
                item.response_start - 1,
            )
            targets[:first_response_target] = [
                self.ignore_index
            ] * first_response_target

            padding = max_input_length - len(input_ids)
            attention_mask = [True] * len(input_ids)
            attention_mask += [False] * padding
            input_ids += [self.eot_id] * padding
            targets += [self.ignore_index] * padding
            if len(input_ids) != len(targets):
                raise AssertionError("input and target lengths diverged")

            input_batch.append(input_ids)
            attention_batch.append(attention_mask)
            target_batch.append(targets)

        return (
            torch.tensor(input_batch, dtype=torch.long),
            torch.tensor(attention_batch, dtype=torch.bool),
            torch.tensor(target_batch, dtype=torch.long),
        )


def make_instruction_loader(
    records,
    encoding,
    *,
    batch_size,
    shuffle,
    seed,
    ignore_index=-100,
):
    from torch.utils.data import DataLoader

    items = [
        tokenise_instruction(record, encoding)
        for record in records
    ]
    if not items:
        raise ValueError("instruction dataset is empty")

    generator = torch.Generator().manual_seed(seed)
    loader = DataLoader(
        items,
        batch_size=batch_size,
        shuffle=shuffle,
        generator=generator,
        collate_fn=InstructionCollator(
            eot_id=encoding.eot_token,
            ignore_index=ignore_index,
        ),
    )
    return loader, generator

The collator returns input IDs, a Boolean attention mask and shifted targets. Test the boundary with a tiny fixture. Decode the positions whose targets are not -100; they should begin with the first response token and end with the end-of-text token. No prompt or padding target should appear. Build train and validation loaders separately; leave validation unshuffled and preserve the training generator’s state in checkpoints.

Loss then uses the familiar language-model shape:

import torch.nn.functional as F


def response_only_loss(
    model,
    input_ids,
    attention_mask,
    targets,
    ignore_index: int = -100,
    reduction: str = "mean",
):
    logits = model(
        input_ids,
        key_is_padding=~attention_mask.bool(),
    )
    return F.cross_entropy(
        logits.flatten(0, 1),
        targets.flatten(),
        ignore_index=ignore_index,
        reduction=reduction,
    )

If logits has shape [B,T,V], flattening the first two dimensions produces [B×T,V]; targets become [B×T]. Pass the same ignore_index to the collator and loss so masked positions cannot silently re-enter optimisation.

The Chapter 5 training loop needs one explicit adapter: unpack three tensors, pass the attention mask, and normalise by the number of scored response targets rather than by all padded positions. This one-update function provides that bridge; the scheduler and checkpoint order remain the same as in Chapter 5.

def instruction_update(
    *,
    model,
    batch,
    optimizer,
    scaler,
    device,
    use_amp,
    amp_dtype,
    max_grad_norm,
    ignore_index=-100,
):
    if max_grad_norm <= 0:
        raise ValueError("max_grad_norm must be positive")

    previous_mode = model.training
    model.train()
    try:
        input_ids, attention_mask, targets = (
            value.to(device) for value in batch
        )
        target_count = targets.ne(ignore_index).sum()
        if target_count.item() == 0:
            raise ValueError(
                "batch has no scored response targets"
            )

        optimizer.zero_grad(set_to_none=True)
        with torch.autocast(
            device_type=device.type,
            dtype=amp_dtype,
            enabled=use_amp,
        ):
            nll_sum = response_only_loss(
                model,
                input_ids,
                attention_mask,
                targets,
                ignore_index=ignore_index,
                reduction="sum",
            )
            loss = nll_sum / target_count

        scaler.scale(loss).backward()
        scaler.unscale_(optimizer)
        torch.nn.utils.clip_grad_norm_(
            model.parameters(),
            max_norm=max_grad_norm,
        )
        scaler.step(optimizer)
        scaler.update()
        return loss.detach()
    finally:
        model.train(previous_mode)

The helper enables training behaviour for the update and restores the caller’s previous mode even if the batch fails. This prevents a diagnostic evaluation call from silently disabling dropout during the next update.

For gradient accumulation, backpropagate nll_sum for each micro-batch, add target_count, then divide the accumulated gradients by the total immediately after scaler.unscale_, exactly as Chapter 5 does for pretraining targets. Advance the scheduler only after the effective update and only when the scaler did not skip it.

How much of the model should be trained?

Instruction following changes generation across many positions and tasks. Full fine-tuning therefore commonly updates the complete decoder, unlike a small classification probe. It is not the only option.

Classification can train a small head and selected upper layers; instruction tuning usually retains the vocabulary head and adapts generation throughout the decoder.
Figure 7.5. The required behavioural change guides the adaptation scope.

Parameter-efficient fine-tuning can keep the base checkpoint frozen and train small added modules. Low-rank adaptation, or LoRA, represents an update to a weight matrix through two much smaller matrices.3 This reduces trainable parameters and makes task adapters easy to store. It does not guarantee lower serving latency, better data quality or safe composition of several adapters.

Compare at least:

  • response quality on held-out tasks;
  • exact format adherence;
  • catastrophic change on base capabilities that should remain;
  • training and checkpoint cost;
  • serving complexity;
  • adapter merge and rollback behaviour; and
  • performance on safety and injection challenges.

The full-vs-efficient choice is empirical. A small, narrow extraction task may adapt well with LoRA. A broad change in conversational behaviour may need more capacity. The model size, data volume and hardware affect the result.

Training without losing the base model

Instruction tuning starts from a pretrained checkpoint. The optimiser should see only parameters marked trainable. Use a smaller learning rate than would be typical for pretraining, clip gradients when the run requires it, and evaluate frequently enough to catch overfitting.

Chapter 6’s classifier wrapper is a separate branch; instruction tuning starts again from the selected language-model checkpoint so that the vocabulary head and next-token interface are intact. Assuming Chapter 5 stored model_config as the keyword dictionary for GPTConfig, the transition is:

from pathlib import Path


checkpoint_path = Path(
    "checkpoints/best-validation.pt"
)
# weights_only=False is safe only for a checkpoint you trust.
pretrained = torch.load(
    checkpoint_path,
    map_location="cpu",
    weights_only=False,
)
if pretrained.get("schema_version") != 1:
    raise ValueError("unsupported checkpoint schema")

cfg = GPTConfig(**pretrained["model_config"])
model = GPTModel(cfg)
model.load_state_dict(pretrained["model"], strict=True)

device = torch.device(
    "cuda" if torch.cuda.is_available() else "cpu"
)
model.to(device)
optimizer = torch.optim.AdamW(
    (p for p in model.parameters() if p.requires_grad),
    lr=5e-5,
    weight_decay=0.1,
)
use_amp = device.type == "cuda"
amp_dtype = (
    torch.bfloat16
    if use_amp and torch.cuda.is_bf16_supported()
    else torch.float16
)
scaler = torch.amp.GradScaler(
    "cuda",
    enabled=use_amp and amp_dtype == torch.float16,
)

The learning rate is an experimental starting point, not a universal setting. Construct a schedule from the declared instruction-tuning update budget, and advance it only after a successful optimiser update.

Save a complete resumable state:

  • model or adapter weights;
  • optimiser and scheduler states;
  • mixed-precision scaler state, if used;
  • global step and examples seen;
  • random-number-generator states;
  • data-sampler state or deterministic reconstruction information;
  • tokenizer and template versions; and
  • dataset snapshot identifiers.

Validation loss can identify divergence and broad overfitting. It cannot tell whether an answer is true, a citation supports a claim or a refusal is appropriate.

Illustrative instruction-tuning losses flatten smoothly, but token loss alone does not measure truth, citation support or policy compliance.
Figure 7.6. A healthy optimisation curve is a prerequisite, not an acceptance test.

Inspect generated samples during training with a fixed evaluation prompt set and fixed decoding settings. Store the outputs rather than relying on memory. A changed response can then be traced to a checkpoint, prompt and data version.

Preference optimisation is a separate stage

Supervised targets demonstrate desired responses. Preference optimisation uses comparisons, such as preferring response A to response B under a rubric, to alter which behaviour the model favours. InstructGPT combined supervised demonstrations with reinforcement learning from human feedback.4 Direct Preference Optimization later provided a simpler objective that learns from preference pairs without fitting an explicit reward model in the same way.5

These methods deserve separate evaluation. A preference for confident, polished prose can conflict with truthful abstention. Raters may disagree or lack domain evidence. Preference data should record the criterion, rater qualification, source context and selected response.

Generation that stops correctly

At inference, token generation continues until an end token or length limit. For a batch, different rows finish at different steps. Checking whether any row emitted end-of-text and stopping the entire batch truncates unfinished responses.

@torch.no_grad()
def generate_greedy(
    model,
    input_ids,
    attention_mask,
    eot_id: int,
    pad_id: int,
    max_new_tokens: int,
    context_length: int,
):
    was_training = model.training
    model.eval()
    try:
        if input_ids.shape != attention_mask.shape:
            raise ValueError("input_ids and attention_mask must match")
        if input_ids.ndim != 2:
            raise ValueError("input_ids must have shape [batch, time]")
        if context_length < 1 or max_new_tokens < 0:
            raise ValueError("length limits are out of range")

        sequences = []
        for row in range(input_ids.shape[0]):
            real = input_ids[row][attention_mask[row].bool()]
            if real.numel() == 0:
                raise ValueError("every prompt needs at least one token")
            if real[-1].eq(eot_id):
                raise ValueError("a prompt cannot already end with eot_id")
            sequences.append(real.clone())

        finished = torch.zeros(
            input_ids.shape[0],
            dtype=torch.bool,
            device=input_ids.device,
        )

        for _ in range(max_new_tokens):
            windows = [
                sequence[-context_length:]
                for sequence in sequences
            ]
            lengths = torch.tensor(
                [window.numel() for window in windows],
                device=input_ids.device,
            )
            width = int(lengths.max().item())
            context = torch.full(
                (len(windows), width),
                pad_id,
                dtype=input_ids.dtype,
                device=input_ids.device,
            )
            context_mask = torch.zeros(
                (len(windows), width),
                dtype=torch.bool,
                device=input_ids.device,
            )

            for row, window in enumerate(windows):
                context[row, : window.numel()] = window
                context_mask[row, : window.numel()] = True

            logits = model(
                context,
                key_is_padding=~context_mask,
            )
            next_token = logits[
                torch.arange(
                    len(windows),
                    device=input_ids.device,
                ),
                lengths - 1,
            ].argmax(dim=-1)

            for row, token in enumerate(next_token):
                if finished[row]:
                    continue
                sequences[row] = torch.cat(
                    (sequences[row], token.view(1))
                )
                finished[row] = token.eq(eot_id)

            if finished.all():
                break

        output_width = max(
            sequence.numel() for sequence in sequences
        )
        output_ids = torch.full(
            (len(sequences), output_width),
            pad_id,
            dtype=input_ids.dtype,
            device=input_ids.device,
        )
        output_mask = torch.zeros(
            (len(sequences), output_width),
            dtype=torch.bool,
            device=input_ids.device,
        )
        for row, sequence in enumerate(sequences):
            output_ids[row, : sequence.numel()] = sequence
            output_mask[row, : sequence.numel()] = True

        return output_ids, output_mask
    finally:
        model.train(was_training)

The function accepts the right-padded convention used elsewhere in the book, reconstructs each real sequence from its mask and reads logits from each row’s last real position. Finished rows stop growing independently. The returned mask distinguishes generated tokens from output padding. This teaching implementation rebuilds a padded context and does not use a key–value cache; a serving implementation should use its supported cache or ragged-batching interface while retaining the same per-row position and stopping semantics.

Greedy decoding improves repeatability but does not make a statement correct. Sampling can be appropriate when variation is useful. For extraction or evidence-bound drafting, a constrained schema and deterministic validation usually matter more than tuning temperature.

Evaluate behaviour in layers

A single model judge asked to assign a score from 0 to 100 is an appealing shortcut and weak evidence. It can share biases with the model under test, favour a particular style, miss domain errors and vary with prompt order.

Use an evaluation stack.

Deterministic checks

Run exact checks where exactness is available:

  • output parses against the declared JSON or XML schema;
  • required fields are present and no unknown fields appear;
  • numbers and dates use permitted types and ranges;
  • every evidence identifier resolves;
  • cited spans contain the asserted entity and value;
  • prohibited tool names or actions do not appear; and
  • the response ends within the allowed length.

These checks are fast, reproducible and easy to regression-test.

Task-specific reference checks

For extraction, compare fields with reviewed reference labels and report field-level precision and recall. For transformation, verify that required facts are preserved. For evidence-grounded answers, evaluate retrieval separately from claim support so a generator is not blamed for absent context.

Exact match is useful for a strict schema and unfair for open prose. Choose the scoring rule to match the output contract.

Rubric-based review

A compact rubric for a draft covenant paragraph might score:

Dimension Acceptance question
Support Does every material claim have resolvable evidence?
Numerical consistency Do all values match controlled calculations?
Completeness Are material conflicts and limitations present?
Scope Did the response avoid a credit decision or unsupported advice?
Clarity Can an analyst verify the paragraph without reconstructing it?

Automated judges may apply the rubric at scale, but calibrate them against blinded expert review. Use paired comparisons where possible, randomise response order, repeat a sample to measure judge stability and report confidence intervals. Periodically review disagreements rather than treating the automated score as ground truth. MT-Bench and Chatbot Arena provide an influential study of model-based judging and its limitations.6

Challenge suites

Include inputs that target the system boundary:

  • a missing amendment;
  • two conflicting thresholds;
  • prompt injection inside a source document;
  • an OCR substitution in a number;
  • irrelevant but lexically similar policy;
  • a request for an unauthorised action;
  • a schema field absent from the evidence;
  • very long and multilingual inputs;
  • a changed prompt template; and
  • a request whose correct answer is abstention.

Release criteria should cover both capability and containment. A model that answers ordinary examples well but obeys instructions embedded in retrieved documents is not ready for an evidence workflow.

Worked case: draft, validate, approve

Return to the fictional credit-document assistant. Its input packet contains:

  • approved extracted facts with source-span identifiers;
  • a controlled leverage calculation;
  • the operative covenant clause;
  • a policy passage governing the narrative; and
  • a listed conflict in an older document.

The instruction asks for typed claims rather than final prose:

{
  "claim_id": "c-17",
  "statement": "Leverage was 3.00 times at the test date.",
  "evidence_ids": ["ledger-41", "accounts-22"],
  "calculation_ids": ["calc-leverage-9"],
  "uncertainty_status": "supported"
}

A validator resolves the identifiers, recalculates the value and confirms the period and unit. Unsupported claims fail before prose assembly. An authorised analyst sees each surviving statement beside its evidence and accepts, edits or rejects it. The model does not approve the facility or hide the conflict.

This architecture limits the damage that prompt injection can cause; it does not make the model immune. A sentence inside an uploaded agreement saying “ignore previous instructions” may still influence generated text. Retrieved text is therefore delimited and labelled as untrusted evidence, while access control, tool permissions and consequential actions are enforced outside the model. The model’s output remains untrusted until schema and evidence checks pass.

Instruction tuning can improve schema adherence and response discipline. The authoritative controls still come from typed interfaces, evidence validation, access control and human accountability.

The complete path connects tokenisation, attention and decoder training to adaptation, layered evaluation and deterministic application controls.
Figure 7.7. A tuned checkpoint is one component of the delivered system.

Build check

Before treating the model as instruction-tuned, verify that you can:

  1. identify the exact token at which the response begins;
  2. decode all non-ignored targets and see response plus end-of-text only;
  3. explain the difference between full-sequence and response-only loss;
  4. prove that padding contributes no loss;
  5. reproduce a training step from a complete checkpoint;
  6. compare full fine-tuning with a parameter-efficient alternative on the same held-out suite;
  7. stop each generated batch row independently;
  8. parse every structured output before using it;
  9. evaluate retrieval, claim support and prose quality separately;
  10. calibrate any automated judge against domain reviewers;
  11. demonstrate correct abstention on missing and conflicting evidence; and
  12. show that a document cannot grant itself tool access or alter the instruction hierarchy.

The tuned model can reproduce response patterns that base pretraining did not specify. The last engineering step is to keep that behaviour inside a system whose evidence, calculations, permissions and decisions remain observable.

Notes and further reading


Applied systemAppendix A: From decoder internals to a governed credit-document assistant

Status and scope

Every case, organisation, customer, record, value, threshold and outcome in this appendix is invented. The designs combine patterns described in public research and reference architectures; they incorporate no operational system, private experiment, client material or employer rule. Terms such as pathfinder, knowledge-ingestion slice and shadow mode identify design stages here. They make no claim of deployment or measured benefit.

Chapter map for Applied system Appendix A: From decoder internals to a governed…: Status and scope; A.1 The continuous worked case; A.2 Chapter 1: next-token prediction and the deterministic…; Side case: a failed payment; A.3 Chapter 2: tokens, document structure and retrieval.
Mermaid chapter map. Applied system Appendix A: From decoder internals to a governed… connects Status and scope, A.1 The continuous worked case, A.2 Chapter 1: next-token prediction and the deterministic…, Side case: a failed payment, A.3 Chapter 2: tokens, document structure and retrieval.

The continuous worked case is a governed credit-document assistant for a fictional UK bank. Its purpose is to help an authorised credit analyst assemble evidence and draft part of a credit paper. It does not approve lending, assign the final risk grade or communicate a decision to the customer. Short side cases show how the same engineering boundaries apply to payment servicing, knowledge ingestion, identity screening and relationship-manager support.

This appendix connects the seven chapters of the book to controls that matter when language-model output enters a consequential workflow. Understanding the decoder helps an engineer diagnose failures, but model internals do not supply governance by themselves. Governance comes from the surrounding system: authoritative data, typed interfaces, deterministic calculations, access controls, evaluation, abstention, human judgement and a record of what happened.

A.1 The continuous worked case

Bracken Components Ltd, a fictional manufacturer, has asked to renew a revolving credit facility. The analyst’s evidence set contains:

  • an executed facility agreement and two amendments;
  • the latest audited accounts and management accounts;
  • a covenant compliance certificate;
  • the bank’s current credit policy and sector guidance;
  • previous credit papers and approved conditions;
  • customer correspondence about a planned capital investment; and
  • source-system records for facilities, balances and limits.

The documents disagree in small but important ways. An old credit paper quotes a leverage ceiling of 3.50 times, while the second amendment reduces it to 3.25 times. The management accounts use an adjusted EBITDA measure; the facility agreement permits some adjustments but excludes others. A scanned table places the cash balance one column away from its label. None of these problems is solved by fluent prose.

The assistant therefore has a narrow remit. It may inventory documents, locate relevant clauses, extract candidate facts, retrieve effective policy, call approved read-only data services, calculate ratios through controlled functions and draft sentences whose claims point back to evidence. It must stop when a required document is missing, a definition is ambiguous, sources conflict or a control fails. The analyst resolves the exception and remains accountable for the paper.

The design rests on six invariants:

  1. Every material claim has a source. A page, table cell, clause or system response must be identifiable.
  2. Versions and effective dates travel with content. “Credit policy” is not enough; the applicable version must be established for the decision date.
  3. Models do not perform authoritative arithmetic. They may locate inputs and explain results, but controlled code calculates them.
  4. Uncertainty changes the route. Low confidence, conflicting evidence and unfamiliar input cause review or abstention rather than more assertive wording.
  5. The decision remains human. The assistant prepares evidence and a draft; an authorised analyst accepts, edits or rejects it.
  6. The run is reproducible. Model, tokenizer, prompt, retrieval index, tool, policy and document versions are recorded.

These invariants are more useful than a promise that a model will “never hallucinate”. A probabilistic generator can still produce an unsupported statement after a careful prompt. The system must be able to detect, contain and investigate that event.

A.2 Chapter 1: next-token prediction and the deterministic boundary

A decoder model estimates a distribution for the next token given the preceding tokens. That objective produces useful language behaviour, including extraction, transformation and synthesis, but it does not turn the model into an accounting engine or a policy authority. A plausible number is still a generated token sequence.

In the worked case, the source data is:

  • gross debt: £26.20 million, from the facility ledger;
  • eligible cash: £4.60 million, subject to the contractual definition;
  • covenant EBITDA: £7.20 million, after approved adjustments; and
  • maximum leverage: 3.25 times, from the effective amendment.

The controlled calculation is:

Net debt=26.204.60=21.60 \text{Net debt} = 26.20 - 4.60 = 21.60

Leverage=21.607.20=3.00 \text{Leverage} = \frac{21.60}{7.20} = 3.00

Headroom=3.253.00=0.25 \text{Headroom} = 3.25 - 3.00 = 0.25

The model can find the candidate inputs and draft the sentence, “Leverage was 3.00 times, leaving 0.25 times of headroom to the contractual maximum.” It cannot be the source of either result. The calculation service receives typed decimal values, checks units, applies the approved formula version and returns both the result and its input lineage. If eligible cash is disputed, the service does not select the more favourable figure; it returns an exception.

The boundary is task-specific:

Task Appropriate mechanism Reason
Calculate a covenant ratio Deterministic function Exact formula, units and rounding must be reproducible
Check whether a value exceeds a threshold Deterministic rule The comparison is explicit once the inputs are approved
Classify a document into a fixed set Validated classifier or rules The output space is bounded, with an abstention route
Find semantically related policy passages Retrieval model plus filters Wording varies, but access and effective dates remain deterministic filters
Extract candidate terms from prose Language model with a typed schema Language varies; every field still requires validation and evidence
Draft an evidence-based narrative Language model Synthesis and phrasing are useful, provided claims are constrained and checked
Approve the facility Authorised human process Accountability, judgement and delegated authority cannot be inferred from fluency

This selection discipline prevents “LLM” from becoming the default answer to every automation problem. A fixed formula should stay a formula. A high-throughput binary decision may suit conventional machine learning. Retrieval may be enough when the user needs a passage rather than a new paragraph. Generation earns its place where language variation and synthesis justify its additional failure modes.

Side case: a failed payment

Consider a customer asking why a payment failed. Stable procedural knowledge can explain possible reasons and the permitted wording. Moderately fresh session context may establish which account the customer selected. The actual payment status, balance, restriction and timestamp must come from authorised live services. If the customer asks to retry or change a payment, a separate action tool should enforce authentication, limits, idempotency and confirmation.

The language model may interpret the request and explain verified tool results. It should not infer a decline reason from a conversational clue or repeat an action because a response timed out. If the conversation moves to a peer, the handover should carry the verified facts, actions attempted, outstanding question and relevant consent state. It should not pass an unbounded transcript and ask the peer to reconstruct the case.

The lesson from Chapter 1 is architectural: use next-token prediction for language, then place exact state and consequential actions behind interfaces that do not depend on the model’s confidence.

A.3 Chapter 2: tokens, document structure and retrieval

Tokenisation is part of the model interface. Financial abbreviations, reference numbers, percentages, mathematical symbols and OCR artefacts may divide into unintuitive token sequences. The precise split depends on the tokenizer, so a publishing example should not pretend that “EBITDA” always occupies a fixed number of tokens. Measure the actual tokenizer paired with the model.

Three consequences follow for the credit assistant.

First, identifiers need exact handling outside semantic generation. A facility number, company registration number or document hash should be carried as a string and checked against an authoritative pattern. Embedding similarity is not an identity test. Visually similar characters introduced by OCR, such as 0 and O, require validation against the source system.

Second, a context window is a budget, not a document repository. Stuffing every available page into a prompt increases cost and can reduce answer quality by surrounding the relevant evidence with near-duplicates, superseded clauses and unrelated history. The system should retrieve a small evidence set, preserve enough surrounding text to interpret each passage and expose omissions rather than silently truncating.

Third, position inside the source document matters independently of the decoder’s positional encoding. A figure in the “current year” column differs from the same figure in the “prior year” column. A footnote may redefine a table row. The ingestion layer must preserve page, section, table, row, column and bounding-box information where available. Linearised text alone can destroy the relation the analyst needs.

A controlled ingestion record

Each retrievable unit carries content and control metadata. The exact storage technology is secondary to the contract:

from dataclasses import dataclass
from datetime import date
from typing import Literal

@dataclass(frozen=True)
class DocumentChunk:
    document_id: str
    document_version: str
    document_type: Literal[
        "facility_agreement",
        "amendment",
        "accounts",
        "covenant_certificate",
        "credit_policy",
        "correspondence",
        "other",
    ]
    source_span_id: str
    page_number: int | None
    section_path: tuple[str, ...]
    effective_from: date | None
    effective_to: date | None
    access_labels: frozenset[str]
    content_hash: str
    parser_version: str
    text: str

The immutable record does not prove that extraction was correct. It makes the extraction inspectable. A reviewer can open the source span, compare it with the page image and identify the parser that produced it. A changed document creates a new version rather than silently altering prior evidence.

Retrieval is candidate selection

Embeddings place semantically related passages near one another in a learned vector space. They are useful when a query says “borrowing level” but the agreement says “Total Net Debt”. Similarity does not establish applicability, truth or authority. A superseded amendment can be semantically perfect and operationally wrong.

A safer retrieval sequence is:

  1. enforce the user’s access rights;
  2. filter by customer, document class and decision date;
  3. remove superseded versions according to an explicit precedence rule;
  4. combine lexical and vector retrieval;
  5. rerank the candidates for the specific question;
  6. attach neighbouring text or table structure where needed;
  7. return source identifiers with every passage; and
  8. abstain if the required evidence class is absent.

This ordering makes effective dates and permissions hard constraints. Semantic similarity cannot override them.

Side case: a knowledge-ingestion slice

A useful enterprise knowledge design separates information by volatility:

  • Organisational knowledge changes relatively slowly: policies, procedures, product terms and operating manuals. It can be ingested, versioned and reviewed before publication to a knowledge index.
  • Customer context changes more often: product holdings, preferences and recent interactions. Some of it may be prefetched for a session, but its age and source must remain visible.
  • Transactional state can change within seconds: balances, payment status, blocks and available limits. It belongs behind runtime tools rather than in a durable vector index.

A narrow vertical slice tests this distinction better than a broad demonstration of isolated components. One procedure can travel from source acquisition through format normalisation, metadata extraction, indexing, retrieval, human validation and a cited answer. The slice reveals where lineage is lost, where a document-specific parser is needed and which enrichment is genuinely reusable.

The same lesson applies to the credit assistant. The current policy may be indexed; today’s facility balance must be called; a clause extracted from an agreement remains linked to the signed page. Treating all three as undifferentiated “context” invites stale or unauthorised answers.

A.4 Chapter 3: attention, relationships and evidence

Self-attention lets a token representation incorporate information from other positions. In a covenant clause, this mechanism can help a model relate “3.25” to “Total Net Debt to EBITDA”, “shall not exceed” and “tested quarterly”. Multiple heads allow different learned projections to operate in parallel. We should not, however, assign a tidy human role to each head without analysis. A head is not reliably “the numerical head” or “the obligation head”, and its pattern may change across layers and examples.

The clause extractor in the worked case is asked to produce a candidate structure:

metric: leverage
numerator_definition: Total Net Debt
denominator_definition: Covenant EBITDA
operator: <=
threshold: 3.25
test_frequency: quarterly
measurement_basis: rolling_twelve_months
source_span_id: amendment-02-clause-7.3

This is the canonical covenant schema used by the worked examples in Chapters 3, 5 and 7. An ingress adapter may translate a source-specific field name, but stored and evaluated records use metric, operator, threshold and test_frequency. This output is useful because the fields can be checked. The threshold must parse as a decimal. The operator must belong to an allowed set. The source span must contain textual support. A rule can compare the extracted amendment with the agreement’s amendment schedule. A reviewer can inspect any unresolved definition.

Attention weights are not a causal explanation of the extraction. They show a model-internal allocation under a particular forward pass; they do not prove which input caused the output, that the model used the information faithfully, or that the decision is sound. Averaging weights across heads and layers can erase the very structure an explanation claims to reveal.

For this workflow, an evidence trace is more useful than an attention heat map:

  • the exact source span that supports each extracted field;
  • the parser and model version;
  • any transformation applied to the source;
  • the deterministic checks performed;
  • conflicting passages that were found;
  • the calculation inputs and formula version; and
  • the analyst’s resolution of exceptions.

Attention visualisation can remain a diagnostic instrument for model developers. It should not be presented to a credit committee as proof of causality.

Causal masking and long documents

In a decoder, causal masking prevents a position from attending to later positions during next-token training and generation. Once an entire evidence packet is placed before the response, generated tokens can attend to that preceding packet. Causal masking does not guarantee that the model will use the correct passage, and moving a risk statement to the beginning does not turn it into an authoritative fact.

Dense self-attention has quadratic time and memory terms in sequence length inside each layer, although deployed systems may use optimised kernels, sparse patterns, caching or other architectures. The practical response is still to reduce irrelevant context. Structured retrieval and typed state are preferable to concatenating every intermediate answer from a chain of agents.

For Bracken Components, the assistant supplies the generator with approved facts and selected evidence, not all uploaded files. The signed clause, calculation result and applicable policy passage are present. Superseded terms are listed as conflicts rather than hidden. This improves the task definition before any sampling parameter is adjusted.

A.5 Chapter 4: architecture as a set of interfaces

Building a GPT-style model reveals embeddings, attention blocks, feed-forward sublayers, residual paths, normalisation and the output projection. That knowledge helps an engineer reason about capacity, sequence length, numerical precision and version compatibility. It does not make every production symptom traceable to a single layer.

Several tempting explanations should be avoided. Layer normalisation acts on hidden activations; it does not directly stop a large currency amount from “dominating” because of its human-scale magnitude. Facts and patterns are distributed across model parameters; feed-forward layers can participate in recalling associations, but they are not a reliable policy database. Weight tying links input embeddings and output projection in some architectures, yet it does not cure poor handling of specialist terms. Domain performance must be measured on the task.

The production architecture should therefore expose controllable interfaces around the model:

  • a versioned tokenizer and model endpoint;
  • a document service that preserves originals and lineage;
  • a retrieval service that enforces permissions and effective dates;
  • extraction contracts with allowed types and null states;
  • calculation tools with formula and rounding versions;
  • a policy service for current, approved rules;
  • a claim validator that compares draft statements with evidence;
  • a review queue with reasons and priority; and
  • an immutable audit event stream.

Model size is an empirical choice. A smaller classifier may outperform a larger generator on a fixed-label task; a larger model may handle irregular legal prose better but cost more and take longer. The team should compare candidates on representative documents, error severity, latency, throughput, operational resilience and total review effort. Brand names and parameter counts are poor substitutes for that evaluation.

Nor does every component need to become an “agent”. One orchestrator can call deterministic services, a retriever and a bounded extraction model according to an explicit state machine. Separate autonomous agents are justified only when independent planning adds measurable value and the additional states, permissions and failure paths can be governed.

A governed credit-document assistant keeps source evidence separate from model interpretation, applies deterministic calculations and validation, and requires an authorised analyst to approve any credit decision.
Figure A.1. The assistant may retrieve, extract and draft. Deterministic controls test its inputs and claims, while the authorised analyst retains the credit decision.

The diagram’s separation is intentional. Source evidence does not become model memory. Candidate interpretation does not become approved fact. A fluent draft does not become a decision. Each boundary has an interface and an owner.

A.6 Chapter 5: loss, evaluation and monitoring

Cross-entropy loss measures next-token prediction on a defined dataset. Perplexity is the exponential of average cross-entropy, subject to the tokenisation and aggregation used. It is useful for comparing compatible language models on the same corpus. It is not a direct probability that a paragraph is correct, a calibrated confidence score for a credit decision or a complete measure of domain shift.

Two models with different tokenizers can have perplexities that are not directly comparable. A model can assign high probability to a familiar but false sentence. A policy template change may raise loss without harming extraction, while a subtle change in covenant definitions may leave perplexity nearly unchanged and damage the task. Token-level surprise should not decide when a human is needed.

Evaluation must follow the components and risks of the actual workflow:

Component Primary measures Important failure slice
Document ingestion page coverage, table-cell fidelity, OCR error review scans, rotated pages, handwritten amendments
Retrieval recall at a fixed candidate budget, version accuracy, access-control tests superseded and near-duplicate documents
Field extraction precision, recall and exact match by field negation, units, definitions and nested clauses
Calculation exact agreement with test vectors zero denominators, currency units and rounding boundaries
Citation source presence and claim support correct document but wrong page or period
Narrative unsupported-claim rate, omission rate, material edit rate adverse facts and conflicting evidence
Routing coverage, error severity and review yield unfamiliar documents and low-quality scans
Human workflow time to resolve, override reason and missed-error audit automation bias and rubber-stamp approval

Thresholds should be selected from validation evidence and risk appetite, not copied from an example. A 90 per cent classifier score is not automatically safe: the score may be uncalibrated, the class distribution may have shifted, or the input may be unlike anything in training.

Dataset construction

Randomly splitting pages from the same agreement across training and test sets creates leakage. Boilerplate clauses, customer names and repeated templates let the model appear to generalise while recognising near-duplicates. A stronger design separates data by customer or agreement family and preserves a later time period for temporal testing. It also holds out uncommon amendments, poor scans and policy transitions.

Every labelled example needs a provenance record and a labelling guide. Ambiguous clauses should not be forced into a clean class simply to improve a metric. Record disagreement between subject-matter reviewers and maintain a challenge set of cases that previously caused errors.

Automated judges and human evaluation

A second language model can flag stylistic defects, missing sections and possible unsupported claims. It can scale triage, but it shares many weaknesses with the model it judges. It may reward confident prose, miss a subtle numerical contradiction or change behaviour after an update.

Use automated judging as one signal. Calibrate it against blinded human review, report agreement and disagreement, and keep deterministic checks for properties that can be tested exactly. Citation identifiers, decimal arithmetic, mandatory fields and access decisions do not need a language-model judge.

Monitoring after release

Monitoring needs four layers:

  1. Operational health: latency, availability, timeouts, tool errors and queue depth.
  2. Control health: blocked unauthorised access, schema failures, missing citations and abstention rates.
  3. Task quality: sampled human review, unsupported claims, extraction errors, overrides and material edits.
  4. Change and drift: document mix, template versions, vocabulary, class balance, model version and retrieval-index changes.

Perplexity may supplement the fourth layer when it is computed consistently and interpreted cautiously. It should never be the sole early-warning system. A rise is a prompt to investigate, not a diagnosis.

The test suite should also inject failures: remove the effective amendment, swap current and prior-year columns, corrupt a decimal separator, return a stale tool response, deny an access label and insert instructions inside a source document. A system that performs well only on clean examples has not been tested for the environment it will meet.

A.7 Chapter 6: classification, calibration and abstention

Classification fine-tuning replaces open-ended generation with a bounded label space. In the worked case, classifiers can route document types, identify likely covenant families or flag pages that may contain financial tables. Bounded output removes the possibility of inventing a seventh label when six are allowed. It does not remove classification error.

The model produces scores; a separate policy chooses the route. That policy needs calibrated probabilities or another validated confidence measure, an out-of-distribution check and an explicit abstention state:

from math import isfinite


def route_document(
    predicted_label: str,
    calibrated_probability: float,
    is_out_of_distribution: bool,
    auto_route_threshold: float,
    allowed_labels: frozenset[str],
) -> str:
    if (
        not isfinite(auto_route_threshold)
        or not 0.0 <= auto_route_threshold <= 1.0
    ):
        raise ValueError("auto_route_threshold must be in [0, 1]")
    if is_out_of_distribution:
        return "manual_triage"
    if predicted_label not in allowed_labels:
        return "manual_triage"
    if (
        not isfinite(calibrated_probability)
        or not 0.0 <= calibrated_probability <= 1.0
    ):
        return "manual_triage"
    if calibrated_probability < auto_route_threshold:
        return "manual_triage"
    return f"route:{predicted_label}"

The threshold is a configuration item tied to a validated model version. It is not hard-coded from a demonstration. Performance must be reported by class and by consequential slice; a high overall accuracy can conceal poor recall on amendments, which may be the documents most likely to change the obligation.

Partial freezing, full fine-tuning and parameter-efficient adaptation are training choices, not governance outcomes. Each can overfit or degrade an existing capability. Regression tests must cover the new task, retained capabilities and safety behaviour. If one shared backbone supports several heads, a change for one task still requires tests for the others.

Side case: identity and politically exposed person screening

An identity-screening design illustrates why abstention matters. Exact identifiers and clear exclusions can be processed with deterministic rules. Ambiguous cases may combine evidence such as name variation, geography, employment history and family relationship. Signals should be grouped into independent evidence families so that three versions of the same weak source are not mistaken for three corroborating facts.

Negative evidence also matters. A confirmed age conflict or mutually exclusive employment timeline can weaken a proposed match. The scoring method must distinguish “no evidence found” from “evidence of a mismatch”. A probabilistic linkage score is not the probability that two people are the same unless the method has been calibrated for that interpretation.

A bounded assistant can assemble evidence, identify conflicts and produce one of three recommendations: plausible match, unlikely match or inconclusive. The final disposition remains with an investigator. Sparse, conflicting or novel evidence routes to “inconclusive”; it does not invite the model to invent a bridge between records.

This side case also exposes fairness risks. Name frequency, transliteration quality and uneven public records can produce different error rates across groups. Evaluation must therefore include subgroup and language slices, source-quality differences, appeal outcomes and human override patterns. More features are not automatically better if they encode unreliable proxies.

A.8 Chapter 7: instruction tuning and output contracts

Supervised instruction tuning teaches a model patterns for following demonstrations. Preference optimisation can shape tone and response style. Neither technique guarantees factual support, compliance or obedience under every input. The runtime contract still needs to state what the model may use and what it must return.

For the credit assistant, the generator receives only:

  • approved extracted facts with types and units;
  • calculation outputs with formula versions;
  • selected evidence passages and source identifiers;
  • known conflicts and unresolved questions;
  • the required credit-paper section; and
  • a schema for claims, citations and uncertainty.

It does not receive permission to browse unrelated customer records, choose a final rating or resolve a contradiction by majority vote. Instructions embedded in a facility agreement are document content, not commands to the assistant.

A useful generation contract is narrower than a long persona prompt:

Task:
Draft the covenant-performance paragraph.

Allowed claims:
Use only approved facts and calculation results in the evidence packet.

Prohibited actions:
Do not calculate, infer missing values, choose a risk grade, or omit a listed
conflict.

Output:
Return an ordered list of draft claims. Each claim must contain statement,
evidence_ids, calculation_ids, and uncertainty_status.

Failure:
If a material statement lacks support, return needs_review with the missing
evidence class.

This contract supports validation. Free-form prose can be assembled only after each draft claim passes its checks. Asking the model to “cite sources” is weaker than requiring evidence identifiers that the validator can resolve.

Sampling settings affect variation, but a temperature of zero is not a guarantee of end-to-end determinism. Service updates, numerical kernels, routing and tie-breaking can still change output. Reproducibility depends on versioned components and regression tests. Exact extraction properties should come from schemas and validators, not from a low temperature alone.

Preference data can teach the model to favour plain, qualified language over false certainty. It cannot declare a customer communication fair or a credit narrative compliant. Those judgements require approved standards, scenario testing and human oversight.

Side case: a relationship-manager copilot

A peer asks, “What should I discuss with this customer before Thursday’s meeting?” The assistant may need current facilities, recent interactions, covenant dates, open service issues and relevant market information. A thin peer interface can send one authorised request to an orchestration service, which calls sources in parallel and returns a briefing with freshness labels and provenance.

The model should not copy every source into durable conversational memory. System-of-record facts remain in their systems; the briefing holds references and an expiry time. Missing market data should appear as a gap, not a plausible paragraph. If internal and external sources disagree, the disagreement is visible. This side case applies Chapter 7’s lesson at a wider scale: instructions shape a response, while source authority, permissions and freshness come from the architecture.

A.9 The end-to-end controlled workflow

The worked case becomes concrete when the data contracts are visible. Four types carry most of the assurance burden:

from dataclasses import dataclass
from decimal import Decimal
from typing import Literal

@dataclass(frozen=True)
class EvidenceRef:
    evidence_id: str
    document_id: str
    document_version: str
    source_span_id: str
    content_hash: str
    effective_status: Literal["effective", "superseded", "uncertain"]

@dataclass(frozen=True)
class ExtractedFact:
    fact_id: str
    name: str
    value: str
    data_type: Literal["decimal", "money", "date", "text", "enum"]
    unit: str | None
    evidence_ids: tuple[str, ...]
    validation_status: Literal["approved", "rejected", "needs_review"]

@dataclass(frozen=True)
class CalculationResult:
    calculation_id: str
    formula_version: str
    value: Decimal
    unit: str
    input_fact_ids: tuple[str, ...]
    status: Literal["valid", "invalid", "needs_review"]

@dataclass(frozen=True)
class DraftClaim:
    claim_id: str
    statement: str
    evidence_ids: tuple[str, ...]
    calculation_ids: tuple[str, ...]
    uncertainty_status: Literal["supported", "qualified", "needs_review"]

Strings are retained for extracted values until parsing and validation succeed. This prevents an OCR string such as 3,25 from quietly becoming the wrong decimal under an assumed locale. Money facts carry currency and scale. Dates carry their interpretation and source. No downstream stage treats needs_review as truth.

Stage 1: establish the case

The workflow receives a case identifier, decision date, user identity and declared purpose. Access is evaluated before retrieval. The case manifest lists required evidence classes and accepted versions. A missing executed agreement blocks covenant analysis even if an old credit paper contains a convenient summary.

Stage 2: ingest without losing the original

Files are virus-scanned, hashed and stored as immutable evidence. Parsers produce text, tables and layout references. Each transformation records its version. Low-quality pages are flagged for visual review. Instructions found in documents are labelled as untrusted content and may still influence generated text. They cannot grant permissions or authorise actions because those controls are enforced outside the model.

Stage 3: retrieve with precedence

The retrieval service first applies permissions, customer scope, document class and effective date. It then uses lexical and semantic search to find candidate passages. Amendment precedence is resolved by rules where the legal structure permits; ambiguity becomes an exception. The search result contains both supporting and conflicting passages.

Stage 4: extract typed candidate facts

The extraction model emits fields against a schema. Deterministic validators check type, unit, range, source support and cross-field consistency. A reviewer confirms material definitions. For Bracken Components, “cash” is not approved until the analyst confirms which balances are eligible under the agreement.

Stage 5: call authoritative data services

Current debt and cash balances come from approved read-only tools. Each response carries an observation time, source, request identifier and freshness status. A timeout is not retried blindly if the operation could have side effects; action tools use idempotency keys. The credit case is predominantly read-only, but the distinction matters when the pattern is reused elsewhere.

Stage 6: calculate under version control

The calculation service accepts approved facts only:

from decimal import Decimal, ROUND_HALF_UP

def calculate_leverage(
    gross_debt: Decimal,
    eligible_cash: Decimal,
    covenant_ebitda: Decimal,
    output_places: int = 2,
) -> Decimal:
    if gross_debt < 0 or eligible_cash < 0:
        raise ValueError("Debt and cash must be non-negative")
    if covenant_ebitda <= 0:
        raise ValueError("Covenant EBITDA must be positive")

    net_debt = gross_debt - eligible_cash
    quantum = Decimal("1").scaleb(-output_places)
    return (net_debt / covenant_ebitda).quantize(
        quantum,
        rounding=ROUND_HALF_UP,
    )

Real agreements can define net debt, EBITDA and rounding differently. The production function therefore belongs to a formula catalogue whose version is selected from the effective contract, not from the model’s general knowledge.

Stage 7: generate and validate claims

The model receives the evidence packet and returns DraftClaim objects. A validator rejects a claim if an evidence identifier is missing, inaccessible, superseded or unrelated to the statement. It recalculates any number and checks that periods, units, signs and comparison operators agree.

Pseudocode for the control flow is intentionally plain:

def build_covenant_draft(case, user):
    manifest = establish_case(case, user)
    require_complete_manifest(manifest)

    evidence = retrieve_effective_evidence(manifest)
    if evidence.has_unresolved_precedence:
        return queue_review("document_precedence", evidence)

    candidate_facts = extract_facts(evidence)
    facts = validate_and_approve(candidate_facts)
    if facts.has_material_gaps:
        return queue_review("missing_or_ambiguous_fact", facts)

    calculation = run_formula("leverage", version="contract_selected", facts=facts)
    if calculation.status != "valid":
        return queue_review("calculation_exception", calculation)

    claims = generate_typed_claims(evidence, facts, calculation)
    report = validate_claims(claims, evidence, facts, calculation)
    if report.has_material_failure:
        return queue_review("unsupported_draft", report)

    return create_review_packet(claims, report)

The string contract_selected stands for a resolved catalogue version, not a model choice. Every early return is a successful containment path, not a system failure to be hidden.

Stage 8: support an accountable review

The analyst sees the draft beside its evidence, not in a detached chat window. Selecting the leverage sentence highlights the ledger responses, EBITDA adjustment schedule, effective covenant clause and calculation trace. Conflicts appear before stylistic suggestions. The analyst can approve, edit or reject each material claim and must provide a reason when overriding a failed control.

Suppose the draft says:

Leverage was 3.00 times, 0.25 times inside the contractual maximum, based on gross debt of £26.20 million, eligible cash of £4.60 million and covenant EBITDA of £7.20 million.

The validator can establish numerical consistency, but it cannot decide whether all cash is contractually eligible. The review packet therefore displays that definition as a resolved human judgement with its evidence. If it is unresolved, the paragraph remains blocked.

Once approved, the system stores the final text, source and calculation references, reviewer identity, timestamps, component versions and override reasons. It does not need to retain hidden chain-of-thought. Observable inputs, outputs, actions and decisions provide the useful audit record.

A.10 Failure analysis and safe containment

Good evaluation asks how the assistant fails and whether the failure is visible before it matters.

Failure Detection Containment
Superseded covenant retrieved effective-date and amendment-precedence check block calculation and show the conflict
Current and prior-year table cells swapped layout-aware validation and accounting cross-check route the page for visual review
OCR reads 3.25 as 325 range, format and source-image check reject the field
Wrong customer document enters the case entity and case-manifest mismatch quarantine evidence and raise a security event
Live balance is stale observation-time and freshness policy refresh or mark unavailable
Model invents an adjustment claim-to-evidence validation remove the claim and review the full draft
Source document contains a prompt injection adversarial-content tests and unexpected output or tool requests enforce permissions outside the model, validate the output and route suspected injection for review
Classifier is confident on a novel amendment out-of-distribution and challenge-set tests manual triage
Model update changes drafting behaviour version pinning and regression gate hold release or roll back
Reviewer repeatedly accepts failed claims override monitoring and second-line sampling investigate process and retrain reviewers

A safe failure is specific. “Something went wrong” is not enough. The analyst needs to know that the covenant amendment could not be ordered, or that the EBITDA definition lacks support, and what evidence would resolve it.

Consider a stale certificate. The assistant finds a signed certificate showing leverage of 2.84 times, but live ledger data and the latest management accounts produce 3.00 times. It must not select the newer-looking or more favourable value by intuition. It presents both, labels their observation periods, calculates only from approved inputs and asks the analyst whether the certificate is evidence of a prior test or the intended current test. The conflict becomes part of the review packet.

Security failures also deserve workflow treatment. Retrieval must enforce row-, document- or attribute-level permissions before passages reach a model. Logs should avoid reproducing unnecessary personal or commercially sensitive content. Test documents must be synthetic or properly controlled. A model provider or evaluation service should receive no more data than its approved purpose requires.

Human review is not an all-purpose safety claim. Reviewers need enough time, evidence and authority to challenge the output. Interfaces should make unsupported claims conspicuous rather than polishing them into the same visual style as verified facts. Monitoring should measure override quality and missed errors, not only how often a human clicked “approve”.

A.11 Chapter-to-control mapping

The mapping below connects model knowledge to an observable system control.

Chapter Engineering insight Control in the worked case
1. Next-token prediction Fluent output is probabilistic and may contain plausible errors exact arithmetic and decisions sit outside the generator
2. Tokenisation and embeddings representation affects context cost and retrieval, but similarity is not authority tokenizer versioning, hybrid retrieval, exact identifiers, access and date filters
3. Attention models can relate distant tokens; attention weights are not causal explanations source-span evidence, conflict display and typed clause extraction
4. GPT architecture capacity and behaviour emerge from interacting components versioned model interface, bounded services and task-based model selection
5. Pretraining and loss language loss does not measure every business failure component metrics, leakage-resistant splits, challenge sets and layered monitoring
6. Classification fine-tuning bounded labels simplify a task but scores may be miscalibrated calibration, out-of-distribution detection, thresholds and abstention
7. Instruction fine-tuning demonstrations shape responses without guaranteeing truth or compliance narrow output contract, claim validation, prompt versioning and human approval

The table also shows why building a small decoder is useful. It gives the practitioner accurate mental models for tokens, masks, logits, loss and fine-tuning. The production controls remain testable system properties rather than metaphors borrowed from those internals.

A.12 Governance checklist

The following checklist is designed for a governed pilot or pathfinder. Passing it does not by itself authorise release; it gives reviewers concrete evidence on which to base that decision.

Purpose and accountability

  • Is the permitted task stated in operational terms?
  • Are prohibited decisions and actions explicit?
  • Is there a named business owner, technical owner, data owner and model-risk owner?
  • Does the human reviewer have delegated authority and a clear escalation route?
  • Are success measures separated from aspirational benefits?

Data and knowledge

  • Are required source classes listed in a case manifest?
  • Are originals immutable, hashed and recoverable?
  • Do chunks retain document version, effective dates, source spans and access labels?
  • Are current system facts obtained from authoritative tools rather than copied into a stale index?
  • Are synthetic, training, evaluation and live data separated?
  • Can the team delete or correct data according to its retention and legal obligations?

Model and retrieval

  • Are model, tokenizer, prompt and retrieval-index versions pinned for each run?
  • Was candidate selection based on representative task evidence rather than model reputation?
  • Are retrieval tests designed for superseded, duplicated and access-restricted content?
  • Are output scores calibrated for the intended population where they are used as confidence?
  • Is out-of-distribution behaviour tested?
  • Does the system have a first-class abstention route?

Tools and deterministic controls

  • Does every tool have a typed request, typed response and documented error states?
  • Are read and write permissions separated?
  • Are consequential actions authenticated, authorised and idempotent?
  • Are formula definitions, units and rounding rules versioned?
  • Can deterministic validators reject missing citations, unsupported numbers and invalid states?
  • Are timeouts, partial responses and stale data handled explicitly?

Security and privacy

  • Is access enforced before retrieval and model invocation?
  • Is data minimised for prompts, logs, caches and evaluation?
  • Are source documents labelled as untrusted content, with permissions and tool execution enforced outside the model even if generated text follows an embedded instruction?
  • Are secrets and personal data excluded from traces unless strictly required?
  • Are cross-boundary data transfers and model endpoints approved for the purpose?
  • Can security staff reconstruct access and tool activity without exposing hidden reasoning?

Evaluation and monitoring

  • Are train, validation and test sets separated by entity, template and time where appropriate?
  • Does the test set include poor scans, amendments, conflicts, missing evidence and novel inputs?
  • Are exact controls tested with exact assertions?
  • Are generative outputs assessed for support, omission, material edit and harmful bias?
  • Are automated judges calibrated against human reviewers?
  • Are operational, control, quality and drift signals monitored separately?
  • Is there a release gate, rollback mechanism and incident procedure?

Human factors and change

  • Does the interface place evidence beside each material claim?
  • Are uncertainty and failed controls more prominent than stylistic polish?
  • Are reviewers trained to challenge plausible prose?
  • Are overrides reason-coded and sampled for quality?
  • Does every model, prompt, parser, policy, formula or index change trigger proportionate regression testing?
  • Are pilot findings recorded as evidence, including negative results and abandoned approaches?

For a shadow-mode release, the assistant should run without influencing the official decision, and its output should be compared with the completed human process. Shadow mode still requires access control, data protection and incident handling. It is an evaluation state, not a shortcut around governance.

A.13 What transfers to other banking cases

The continuous case concerns credit documents, but its control pattern travels.

In payment servicing, a governed context packet combines stable procedure, time-bounded customer context and live transaction tools; the model explains verified state and preserves it for a human handover. In knowledge ingestion, deterministic format conversion and versioning precede semantic enrichment and human publication. In identity screening, independent evidence families, negative evidence and abstention prevent a score from becoming an unsupported identity claim. In relationship-manager support, parallel retrieval is useful only when each source retains authority, freshness and access metadata.

The reusable unit is not a prompt or a particular model. It is a controlled path from source to interpretation to action, with typed transitions and a safe stop at every uncertain boundary.

A.14 Closing perspective

Building a decoder from scratch removes some of the mystery from language models. Tokens become indices, attention becomes weighted computation, loss becomes an optimisation signal and generation becomes sampling from logits. That clarity is valuable because it makes loose production claims easier to challenge.

The governed credit-document assistant uses the model where language is genuinely difficult: locating varied expressions, extracting candidate structures and drafting from evidence. It uses deterministic systems where exactness is available: identity checks, permissions, dates, arithmetic, thresholds, schemas and audit events. It places judgement with an authorised analyst and treats abstention as an expected outcome.

That division of labour is the practical bridge from the chapters in this book to high-stakes engineering. The model contributes flexible language processing. The surrounding architecture determines whether that contribution remains traceable, bounded and fit for review.

Reading aidGlossary

Abstention. A deliberate system outcome in which no prediction, answer or action is issued because evidence, model support or operating conditions are inadequate. Its quality should be reported together with coverage and the route offered to the user.

Access control. The rules and enforcement points that decide whether an authenticated principal may read data, invoke a model or use a tool. A model’s instructions or output never confer authorisation.

Attention. A mechanism that scores relationships between queries and keys, then forms weighted combinations of value vectors. It moves information among sequence positions; the weights are model internals rather than a complete explanation of an output.

Attention weight. A coefficient produced by applying softmax to a masked row of attention scores. It controls how much of a value vector enters one context vector, but does not by itself measure causal importance.

Audit trail. A time-ordered record of relevant inputs, component versions, retrieved evidence, control results, tool activity, outputs and human decisions. A useful audit trail records observable events without requiring hidden model reasoning.

Autoregressive generation. The process of extending a sequence one token at a time, with each new token conditioned on the visible prefix. Training can score many positions in parallel because a causal mask preserves this visibility rule.

Backpropagation. The application of the chain rule to calculate how a scalar loss changes with respect to each trainable parameter. An optimiser uses these gradients to propose parameter updates.

Base model. A model produced by pretraining before task-specific adaptation. A base language model is fitted to continue text; it is not automatically an instruction-following or governed assistant.

Byte Pair Encoding (BPE). A subword-tokenisation method that learns ranked merges of frequent adjacent symbols. Byte-level BPE begins from byte coverage, so unfamiliar text remains representable even when its token sequence is inefficient.

Calibration. The degree to which a stated probability agrees with observed frequency on a defined population; for example, whether about 70 per cent of cases assigned 0.70 are correct. Calibration is tied to the model, data period and population, and does not repair poor ranking.

Causal mask. An attention mask that blocks a sequence position from using later positions. It prevents future targets from leaking into representations during next-token training.

Checkpoint. A serialised model state saved at a particular training step or release. A resumable training checkpoint also needs optimiser, scheduler, random-state and data-position information; a deployable checkpoint needs its configuration and tokenizer contract.

Chunk. A bounded passage or structural unit stored for retrieval. Chunk boundaries affect whether definitions, exceptions and table context remain together, so each chunk should retain source and version metadata.

Classifier. A model that maps an input to logits over a declared set of labels. Its bounded output prevents an undeclared label string, but does not prevent a confident misclassification.

Class imbalance. A difference in prevalence among labels in a dataset. Imbalance can make accuracy misleading and affects loss weighting, threshold selection, precision and the amount of evidence available for rare classes.

Confusion matrix. A table of predicted labels against reference labels. For a binary task it contains true positives, false positives, true negatives and false negatives, from which several operating metrics are derived.

Context window. The maximum token sequence available to a model in one forward pass. It is a temporary input budget, not persistent memory or a guarantee that information anywhere in the window will be used reliably.

Cross-attention. Attention in which queries come from one sequence and keys and values come from another, as in the decoder of the original encoder–decoder Transformer. A decoder-only GPT block omits this separate source-attention sublayer.

Cross-entropy loss. For a target class or token, the negative logarithm of the probability assigned to that target, averaged or summed over selected examples. It is an optimisation signal, not a complete measure of factual or application quality.

Data leakage. Information from evaluation data reaching model fitting, threshold selection or feature construction. Near-duplicate documents, customer-related records or later versions split across partitions can produce leakage even when no row is copied exactly.

Dataset lineage. The trace from source material through permissions, labelling, filtering, deduplication, transformations and released snapshots. Lineage allows a training or evaluation example to be understood, corrected and, where required, removed.

Dataset split. A partition of examples into training, validation and test sets with distinct purposes. Related entities, templates or time periods should be grouped according to the intended generalisation test before windowing or augmentation.

Decoder-only model. A stack of causally masked self-attention and feed-forward blocks that operates on one token sequence. It generates autoregressively and has no separate source encoder or encoder–decoder cross-attention.

Deterministic control. Versioned code or rules that give a defined result for the same valid inputs, such as schema checks, decimal arithmetic or access decisions. Determinism makes exact properties testable, although the control’s requirements and implementation can still be wrong.

Dropout. A training-time regulariser that randomly zeros selected activations and rescales retained values. It is disabled in evaluation mode, so an unintentional mode mismatch can change model outputs.

Embedding. A learned vector representation indexed by a discrete item such as a token. The initial token embedding is static for an ID; transformer layers turn it into a context-dependent hidden state.

Encoder. A sequence model whose self-attention can normally use the complete supplied input in both directions. It produces representations for tasks such as classification or extraction but does not by itself define autoregressive generation.

Evidence gate. A control that checks whether retrieved material is sufficient, relevant, authorised and version-correct before generation or a decision step. A failed gate should lead to abstention, a narrower answer or review.

Feed-forward network (FFN). A position-wise neural sublayer that expands a hidden vector, applies a nonlinearity and projects it back to model width. It adds nonlinear capacity without exchanging information between sequence positions.

Fine-tuning. Further optimisation of a pretrained model on narrower data or an altered objective. It may update all weights, selected layers or added parameters and requires evaluation for both the new behaviour and unwanted changes.

Freezing. Excluding selected parameters from gradient-based updates during adaptation. Freezing reduces trainable state and can preserve a representation, but does not necessarily reduce the cost of the forward pass.

Gaussian Error Linear Unit (GELU). The activation GELU(x)=xΦ(x)\operatorname{GELU}(x)=x\Phi(x), where Φ\Phi is the standard normal cumulative distribution function. It is smooth near zero and is used in the reference decoder’s feed-forward network.

Gradient. The derivative of the loss with respect to a tensor or parameter. It describes local sensitivity and supplies an update direction; its magnitude alone does not establish whether the model is learning the intended behaviour.

Gradient accumulation. Summing or averaging gradients from several micro-batches before one optimiser step. It simulates a larger effective batch under a memory limit, provided loss scaling and token counts are handled consistently.

Gradient clipping. Limiting a gradient’s norm or individual values before an optimiser step. It can contain occasional unstable updates but does not cure an unsuitable objective, learning rate or data pipeline.

Greedy decoding. Choosing the highest-logit token at every generation step. It removes sampling variation under a fixed execution environment, but neither guarantees byte-identical results across systems nor makes the output true.

Hallucination. An informal label for generated content that is unsupported by the supplied or authoritative evidence. Because the word covers several failure modes, evaluations should name the measurable error, such as an invented citation or an unsupported numerical claim.

Hidden state. The vector representing one sequence position at a particular layer. It contains context mixed by preceding blocks and usually has the model’s fixed width DD.

Human review. An accountable person’s examination of a proposed output, its evidence and failed controls before an important decision or action. Review is effective only when the reviewer has suitable authority, information, time and an explicit way to reject or escalate.

Idempotency. The property that repeating the same authorised request does not create an additional effect. Consequential tools commonly use an idempotency key so a timeout and retry cannot duplicate an action.

In-context learning. A change in model behaviour caused by instructions or examples inside the current prompt, without updating model weights. It ends when that context is no longer supplied.

Inference. Use of a trained checkpoint to calculate outputs for new inputs. For a language model it includes the forward pass and, when text is generated, the decoding loop.

Instruction tuning. Supervised adaptation on records that pair requests, optional context and desired responses under a consistent template. It can teach response format and task behaviour but cannot guarantee factual support, safe tool use or policy compliance.

Key. A learned projection of a hidden state against which queries are matched in attention. Query–key dot products determine the scores used to mix value vectors.

Key–value (KV) cache. Stored key and value tensors for tokens already processed during autoregressive inference. It avoids recomputing the full prefix at each step, while its memory grows with sequence length, batch, layers and key–value heads.

Layer normalisation. Normalisation of each token’s hidden features using that vector’s mean and variance, followed by learned scale and shift. It does not directly normalise currency amounts or other human-scale values mentioned in text.

Learning rate. The scale applied by an optimiser when converting gradient information into a parameter update. A schedule may vary it across training; an unsuitable rate can cause divergence, slow learning or destructive fine-tuning.

Logit. An unnormalised score produced before softmax. Logit differences determine relative probabilities, while the raw values have no direct probability interpretation.

Model card. A versioned record of a model’s intended use, training and evaluation scope, metrics, thresholds, limitations, excluded uses and change history. It supports review but is not evidence that every stated control works.

Model drift. A change in model or system performance as the input population, source documents, labels, surrounding components or behaviour changes over time. The checkpoint’s parameters can remain fixed while drift occurs around it.

Multi-head attention. Attention performed through several parallel learned query, key and value projections. Head outputs are concatenated and projected back to model width; heads are not assigned stable human meanings by design.

Next-token prediction. Estimation of the probability distribution for the token following a visible prefix. Repeated next-token prediction underlies causal-language-model pretraining and autoregressive generation.

Optimiser. An algorithm that updates parameters from gradients and stored state. Its behaviour depends on settings such as learning rate, momentum terms and weight decay, which belong to the reproducible training configuration.

Out-of-distribution (OOD) input. An input unlike the population on which a model and its thresholds were validated. OOD detection is imperfect, so novel templates, languages or corruption should also have explicit containment and review paths.

Output projection. The learned map from a hidden state of width DD to logits over the vocabulary or class set. In a language model it may share its weight matrix with the token embedding.

Overfitting. Improvement on training examples without corresponding generalisation to held-out data. Leakage, repeated overlapping windows and excessive adaptation capacity can make overfitting appear as convincing training progress.

Padding. Extra token positions added so variable-length examples form a rectangular batch. Attention and loss masks must distinguish padding from real content, especially when the padding ID is also used for another token.

Parameter. A trainable numerical value stored in a model, usually as part of a tensor. Parameter count describes capacity and storage, but does not by itself determine quality, latency or training memory.

Parameter-efficient fine-tuning (PEFT). Adaptation that trains a relatively small set of added or selected parameters while leaving most base weights fixed. It changes training and checkpoint economics, not the requirements for data quality, evaluation or governance.

Perplexity. The exponential of average cross-entropy under a stated tokenisation and aggregation method. It can compare compatible language models on the same corpus; it is not calibrated factual confidence and often cannot be compared directly across tokenizers.

Positional embedding. A representation that supplies token order or relative-position information to a transformer. Learned absolute embeddings are added to token vectors, whereas rotary methods alter queries and keys inside attention.

Precision. For a selected positive class, TP/(TP+FP)TP/(TP+FP): the fraction of predicted positives that are correct. It varies with the operating threshold and class prevalence.

Preference optimisation. Adaptation from comparisons that indicate which of two or more responses is favoured under a rubric. It is distinct from supervised demonstrations and can reward style or confidence unless the preference criterion protects evidence and abstention.

Prefill. The inference phase that processes an existing prompt and creates the initial hidden states and KV cache. Prompt length strongly affects prefill work and time to the first generated token.

Pretraining. Initial large-scale optimisation on a broad corpus, commonly using next-token loss for a decoder model. It produces reusable language representations rather than an application-specific control system.

Prompt injection. Untrusted text that attempts to alter an application’s instructions, permissions or tool behaviour. Defences rely on content isolation, access control, typed tools and output validation; a tokenizer check alone is insufficient.

Provenance. Resolvable information about where data, evidence or a claim came from, including its version and transformations. Provenance supports verification and precedence; a citation string that cannot be resolved does not.

Query. A learned projection of a hidden state that is compared with keys in attention. Each query row produces a distribution over the keys it is permitted to use.

Recall. For a selected positive class, TP/(TP+FN)TP/(TP+FN): the fraction of actual positives that the classifier finds. High recall can be obtained at the expense of precision, depending on the threshold.

Residual connection. An additive path of the form y=x+f(x)y=x+f(x) around a learned sublayer. It offers a direct route for activations and gradients but does not guarantee that every gradient component retains a minimum magnitude.

Response-only loss. Instruction-tuning loss that ignores targets belonging to the prompt and padding while scoring the response and its end marker. The mask must account for the one-token shift between decoder inputs and targets.

Retrieval-augmented generation (RAG). A system pattern that retrieves passages or records and supplies them as generation context. Retrieval makes external evidence available but does not guarantee that the right passage was found or used faithfully.

Retrieval index. A versioned structure that supports lexical, vector or hybrid search over document units. The index should preserve access labels, source identifiers, document status and effective dates needed by later controls.

Rotary Position Embedding (RoPE). A method that rotates paired coordinates of attention queries and keys according to position, introducing relative-position structure into their dot products. Its presence does not guarantee reliable use of arbitrary context lengths.

Sampling. Choosing a token randomly from a probability distribution rather than always taking the maximum. Temperature, top-kk and nucleus filtering alter that distribution and the resulting diversity.

Schema validation. A deterministic check that an output has the required fields, types, allowed values and structural constraints. Passing a schema establishes form, not factual support.

Sequence packing. Placing several shorter sequences inside one fixed-length training block to reduce padding. Boundary tokens and, where isolation is required, segment-aware attention masks prevent the packed layout from silently changing the objective.

Shadow mode. An evaluation state in which a system runs on realistic work but does not influence the official decision or action. It still requires appropriate access control, data protection, monitoring and incident handling.

Softmax. A function that exponentiates and normalises logits into non-negative values summing to one along a chosen axis. The resulting scores are not automatically calibrated probabilities for an application decision.

Source span. A precise locator for the part of a document that supports an extracted field or claim, such as page, clause, table cell or bounding box. It allows a reviewer and validator to inspect the evidence rather than trust a summary.

Special token. A reserved vocabulary item used as a protocol marker, such as a document boundary or role boundary. Trusted code should insert its ID; the same character string in user content must not acquire control authority.

Stride. The number of token positions between the starts of adjacent training windows. A smaller stride produces more overlapping, correlated examples; a larger stride produces fewer windows and may leave unused targets if it exceeds the context length.

Supervised fine-tuning (SFT). Fine-tuning on curated input–target pairs using a supervised loss. Classification and instruction tuning are two forms, with different output spaces and masking rules.

Temperature. A positive scale by which logits are divided before sampling; values below one sharpen the distribution and values above one flatten it. “Temperature zero” is normally implemented as greedy selection rather than literal division by zero.

Token. A discrete vocabulary unit presented to a language model. It may represent a word, subword, punctuation mark, whitespace-bearing piece or byte-derived fragment.

Tokenisation. The deterministic conversion of text to token IDs under a specific pre-tokenisation rule, vocabulary, merge table, normalisation policy and special-token map. Changing any of these can change the model’s inputs.

Tokenizer fingerprint. A recorded identifier or digest for the complete tokenisation contract, including vocabulary, merges, special IDs and relevant software version. Loading should fail when it does not match the model checkpoint.

Tool call. A structured request from an orchestrator or model-mediated workflow to an external function or service. The tool boundary should enforce types, authentication, authorisation, error handling and idempotency independently of generated prose.

Transformer block. A repeated module containing attention, a feed-forward network, residual connections and normalisation. In the reference decoder it accepts and returns a tensor of shape [B,T,D][B,T,D].

Truncation. Deliberate removal of tokens beyond a configured length. Because it can discard exceptions, evidence or the end of a request, the truncation side and resulting coverage should be explicit and tested.

Typed interface. A boundary whose requests, responses and error states conform to declared types and schemas. It lets deterministic validation reject invalid values before they become model context or system actions.

Value. The attention projection that carries the information mixed into a context vector. Attention weights are applied to values after query–key scores have been masked and normalised.

Vocabulary. The fixed mapping between token byte sequences and integer IDs for a tokenizer. Its size sets the row count of the input embedding and the output dimension of a language-model projection.

Weight decay. An optimiser mechanism that discourages large parameter values by shrinking weights during updates, with exact behaviour depending on whether it is coupled to the gradient or decoupled. It is a regularisation setting, not a substitute for held-out evaluation.

Weight tying. Sharing one parameter matrix between the input token embedding and output vocabulary projection. It reduces the unique parameter count; the output operation remains a projection rather than an inverse lookup.

Reading aidAcknowledgements and source code

This guide owes an intellectual debt to the researchers, engineers and open-source maintainers whose papers and documentation are cited in the chapter notes. Those primary sources remain the authoritative accounts of the methods summarised here.

See the Code notice in the front matter for attribution, licence and non-affiliation details.