A complete guide · From raw text to intelligence

How LLMs
Actually Work

From Data to Intelligence
We start with a pile of internet text and build, step by step, a system that learns hidden patterns between words — then we look inside to see what it learned and how to make it better. Every mechanism explained visually, with things you can click and watch.

00 · The jargon

Key concepts before we start

These terms come up over and over. Here’s what each one means, visually.

Key Terms

What we're talking about

Token

A chunk of text the model treats as one unit. Common words = 1 token ("cat"). Rare words = split into pieces ("un"+"believe"+"able"). The model only ever reads tokens, not letters.

the cat sat ...
Vocabulary

The list of all tokens the model knows. The tokenizer turns any text into IDs from this list. Typical size: 32k–128k tokens. Bigger vocab = shorter sequences but more memory.

[0] the · [1] a · [2] cat · […] 50256 more
Vector

A list of numbers (like coordinates). Every token becomes a vector — think of it as a location in a giant map. Nearby vectors = similar meanings.

Dimension

One slot in a vector — one number. A 4096-dim vector has 4096 numbers per token. More dims = more storage for patterns. Each dim learns some tiny feature (like "is this about animals?" or "is this a verb?").

Embedding

The vector assigned to each token, learned during training. "cat" and "dog" get similar vectors because they appear in similar sentences. The vector is the token's "starter pack" of meaning.

Embedding size / Hidden size

How many numbers per token vector. Every layer works with this many numbers per token. GPT-2: 768, LLaMA 3 8B: 4096, GPT-3: 12288. Bigger = more capacity, but slower.

× 4096
Weight

A number that controls how much an input matters. Each connection between neurons has a weight. Training = finding the right weights so the model makes good predictions. One layer can have millions of weights.

Parameter

Any number the model learns — weights + biases. A "7B model" has 7 billion parameters. The model's knowledge is spread across all these numbers, like a giant recipe book.

7,000,000,000+ parameters
Activation function

A simple "bend" applied after each linear layer. Without bends, stacking layers is pointless — they'd collapse into one. Common bends: ReLU (zero out negatives), GELU (smooth version), SwiGLU (gate that controls flow).

ReLU: max(0, x) · GELU: smooth ReLU · SwiGLU: gated
Softmax

Turns any list of numbers into percentages that add up to 100%. Used after attention and at the final output to decide which token to pick next. Like ranking exam scores into probabilities.

[3.2, 1.1, 0.5][76%, 10%, 6%]
Logit

The raw score for each possible next token before softmax. Higher logit = more likely. Like exam marks before they're converted to percentages.

logits: [5.2, 3.1, -0.4] → softmax → probs
LayerNorm

Rescales numbers to keep them stable. Applied before every attention and MLP block. Like adjusting the volume so it's never too loud or too quiet — prevents numbers from exploding or vanishing in deep models.

stabilizes values → prevents training collapse
Head (attention head)

One "expert" that runs the QKV attention with its own learned weights. Models run many heads in parallel (32+ per layer). Each head learns a different job: one tracks grammar, another resolves pronouns, another copies names.

LLaMA 3 8B: 32 heads per layer × 32 layers
Residual stream

The "highway" through the model. Each layer reads the token's current state, adds a small refinement, and writes it back. Nothing gets replaced — each layer just makes a tiny improvement. The final state is the sum of all layers' improvements.

x + attn(LN(x)) + mlp(LN(x))
KV cache

During text generation, the model stores old tokens' K and V vectors so it doesn't recompute them. Without this, generating each new word would mean re-reading the whole conversation from scratch. Makes generation fast (linear instead of quadratic).

tokens 1–N: cache K, V → token N+1 only computes Q
Context window

Maximum tokens the model can see at once. Everything outside this window is invisible. Like a desk that can only hold so many papers. GPT-4: 128K, Claude 3: 200K, Gemini: 1M+ tokens.

128K tokens ≈ ~200 pages of text

The starter toolbox

These tiny functions appear in every section from here on. See them once now, then watch them reappear.

import numpy as np

# Softmax: turn any raw scores into percentages that sum to 100%
def softmax(x):
    ex = np.exp(x - np.max(x))
    return ex / ex.sum()

# LayerNorm: center and scale values to keep them stable
def layer_norm(x, eps=1e-5):
    return (x - x.mean()) / (x.std() + eps)

# ReLU: simplest activation — zero out negatives, keep positives
def relu(x):
    return np.maximum(0, x)

# Logit → probability: the raw-to-percentage pipeline
logits = np.array([3.2, 1.1, 0.5])
probs = softmax(logits)
print(f"logits: {logits} → probs: {probs.round(3)}")
# Output: logits: [3.2 1.1 0.5] → probs: [0.761 0.254 0.146]

Notice how softmax and layer_norm are each just 3 lines. They are the only “magic” you need to understand everything that follows.

01 · The foundation

Every neural network, at its core: multiply, add, repeat

An LLM has billions of numbers inside it. But those billions are just the same tiny machine — the neuron — repeated over and over. Understand the neuron, and you understand the foundation of everything.

One neuron = one weighted vote

A single neuron does one simple calculation:

Think of it like voting:

  • Each input is a person casting a vote
  • Each weight (w) is how much that person’s vote counts
  • The bias is a fixed advantage one candidate gets regardless of the vote

So: the neuron multiplies each input by its weight, adds them all up, adds the bias, and spits out one number. That’s it. This operation is called a linear layer — but that’s just a fancy name for “multiply each input by its importance, add them up.”

# One neuron = multiply inputs by weights, add bias
inputs = [2.0, 4.0, 1.5]
weights = [0.8, -0.3, 1.2]
bias = 0.5

output = 0
for i in range(len(inputs)):
    output += inputs[i] * weights[i]
output += bias

print(f"output = {output:.2f}")  # output = 3.10

Now imagine many neurons side by side, all looking at the same inputs but each with its own weights and bias — that’s a layer. Writing it as a group of equations is the same as a matrix multiplication.

Why one layer isn’t enough

Here’s the trap: if you stack linear layers on top of each other, just simplifies to — one big linear equation. No matter how many layers you stack, you’re still just drawing straight lines. The depth is useless.

The fix: after each linear layer, apply an activation function — a simple “bend” that breaks the straight line. The most common ones:

  • ReLU: if the output is negative, make it zero. Positive stays as-is. Like a light switch — either on or off.
  • GELU: a smooth version of ReLU (used in GPT-2, BERT)
  • SwiGLU: has two paths — one that creates information and another that acts like a gate, deciding what passes through (used in LLaMA, most modern LLMs)

Without these bends, a 100-layer network is no smarter than a 1-layer network. With them, each layer can build on the previous one in increasingly abstract ways — that’s where the depth earns its power.

ReLU — zero out negatives -5-4-3-2-1012345Input543210-1-2-3-4-5Output
ReLU: max(0, x) — sharp knee at zero, all negatives become zero. Like a light switch — either on or off.
GELU — smooth curve through zero -5-4-3-2-1012345Input543210-1-2-3-4-5Output
GELU: smooth ReLU — tiny negative values pass through instead of being zeroed. Used in GPT-2 and BERT.
Swish — dips below zero then climbs -5-4-3-2-1012345Input543210-1-2-3-4-5Output
Swish: x · sigmoid(x) — dips below zero before rising, like a soft gate that can suppress irrelevant info. Core of SwiGLU, used in LLaMA and most modern LLMs.
import math

def relu(x):      return max(0, x)
def gelu(x):      return 0.5 * x * (1 + math.erf(x / math.sqrt(2)))
def swish(x):     return x / (1 + math.exp(-x))

x = [0.5, -1.2, 0.8]
print(f"ReLU:  {[relu(v) for v in x]}")
print(f"GELU:  {[round(gelu(v), 2) for v in x]}")
print(f"Swish: {[round(swish(v), 2) for v in x]}")
Step 1 · one neuron

One input, one weight, one bias

This is a single neuron with 3 inputs. Each input has its own weight. Click Compute to see it run.

waiting...

Now here’s the key insight: if you stack multiple neurons side by side — all looking at the same inputs but each with its own weights — that’s a layer. And writing that layer as a group of equations is the same as a matrix multiplication.

Where means “weight from input j to neuron i.” The row index = target neuron, column index = source input.

Step 2 · one layer = matrix multiply

Many neurons, same inputs — a matrix

Click to see individual neurons collapse into a single matrix operation. They're the same thing — just different notation.

Each column of the matrix = one neuron's weights. The whole layer is just W · x + b.
# One layer = matrix multiply (many neurons at once)
import numpy as np

X = np.array([2.0, 4.0])                          # inputs
W = np.array([[0.8, -0.3],                        # weights: 3 neurons × 2 inputs
              [1.2,  0.5],
              [-0.4, 0.9]])
b = np.array([0.5, -0.2, 0.1])                    # biases

output = X @ W.T + b                               # @ = matrix multiply
print(f"layer output: {output}")                   # [ 2.1 ,  2.7 ,  0.9 ]

The @ is matrix multiplication. The .T transposes W (flips rows ↔ columns) so the dimensions line up. This one line replaces writing out every neuron’s equation by hand.

Q: Wait — where do the weights and bias come from? Who sets them?
Nobody. They start as random numbers. During training, every weight and bias gets nudged slightly in the direction that reduces the model's error. By the end, they've arranged themselves into meaningful patterns — all automatically, from the training data alone.
Simple

A neuron is a weighted voting machine. Inputs vote, weights decide vote strength, and the bias shifts the final decision.

Practical

A layer is many neurons evaluated together. Software writes this as matrix multiplication because GPUs are built to do that very fast.

Advanced

Almost every Transformer component is a learned change of basis: project vectors into a space where the next operation becomes easier.

Why do we need many neurons instead of one big equation?

One equation can only draw straight lines. Many neurons with activation functions can draw curves, make decisions, and build layered understanding. This is how LLMs represent grammar, facts, tone, and reasoning all at once.

What does the bias really do?

The bias lets a neuron fire even when inputs are weak, or stay quiet unless evidence is strong. Without bias, every neuron would be forced to output 0 when all inputs are 0 — making learning unnecessarily rigid.

Where does the "bend" (non-linearity) come from?

Stack linear layers alone and they collapse into one. Activation functions like ReLU or SwiGLU bend the computation between layers so stacked layers can express complex patterns.

Why are matrices everywhere?

A matrix is just many dot products bundled together — perfect for GPUs which do thousands of multiply-add operations at once. The math is simple; the scale is what makes it powerful.

How many parameters are in one layer?

If input has 4096 numbers and output has 4096 numbers, the weight matrix is 4096 × 4096 ≈ 16.7M parameters + 4096 biases. One transformer block has two such layers (attention + MLP) = ~33M parameters. Stack 32 blocks = 1B+.

Why not just use one giant neuron?

One neuron outputs one number. An LLM needs to represent grammar, facts, tone, and reasoning simultaneously. Many neurons, each specializing in different patterns, give the model the capacity to represent all of these at once.

Every core computation in an LLM — embedding lookup, attention projection, MLP layers, output prediction — is built from operations (with activation functions breaking linearity at key points). Keep that in your head as we go.

01 → 02

Connection: now that we know the machine only accepts numbers, the next problem becomes obvious: language is not numeric. Before the model can multiply anything, text must be converted into a stable stream of IDs.

02 · The data problem

How to eat the internet

Computers can’t read words — they need numbers. So how do we turn “the cat sat on the mat” into numbers without losing meaning?

Simple idea: give every word a number. “the” = 1, “cat” = 2, “sat” = 3. This fails because:

  • “run” and “running” get different numbers even though they’re related
  • There are too many words (and new ones created daily)
  • Misspellings and names would need entirely new numbers

The real solution is BPE (Byte Pair Encoding): start with individual letters, then merge the most frequent letter pairs into new tokens. Repeat until you have a useful vocabulary.

Try it · click to merge

Building a token vocabulary, one merge at a time

Below is a corpus of text. Each character starts alone. Click Next merge to find the most frequent adjacent pair and merge it into a new token. Watch the vocabulary build. Notice how "the" and "and" become single tokens quickly — common words get absorbed first.

Corpus: "the cat and the dog sat on the mat and watched the cat run"
Step 0 — starting with characters
Vocabulary: [t, h, e, ␣, c, a, n, d, o, g, s, m, w, r, u]
# BPE tokenizer: one merge step
corpus = "the cat and the dog sat on the mat and watched the cat run"

# Step 1: start with individual characters
tokens = list(corpus)
print(f"Characters: {''.join(tokens)}")

# Step 2: find the most frequent adjacent pair
def most_frequent_pair(tokens):
    pairs = {}
    for i in range(len(tokens) - 1):
        pair = (tokens[i], tokens[i+1])
        pairs[pair] = pairs.get(pair, 0) + 1
    return max(pairs, key=pairs.get)

# Step 3: merge that pair everywhere
pair = most_frequent_pair(tokens)
new_tokens, i = [], 0
while i < len(tokens):
    if i < len(tokens) - 1 and (tokens[i], tokens[i+1]) == pair:
        new_tokens.append(tokens[i] + tokens[i+1])
        i += 2
    else:
        new_tokens.append(tokens[i])
        i += 1

print(f"After merging '{pair[0]}' + '{pair[1]}': {''.join(new_tokens)}")
print(f"Vocabulary: {sorted(set(new_tokens))}")

The merge rules are learned once, before training ever starts. After that, every input text is tokenized the same way.

Q: Does the model decide which merges to make? Or is that decided before training?
BPE runs once, before training starts. The merge rules are learned from the training corpus statistics — always merging the most frequent pair. Once the vocabulary is fixed, every piece of text can be deterministically tokenized. The model never sees "raw characters" — it only ever sees token IDs. This is why an LLM can handle any word, even misspellings: it falls back to subword tokens.
Simple

Tokenization cuts text into reusable chunks. Common words often become one token; rare words break into smaller pieces.

Practical

The tokenizer is a fixed contract. Training, fine-tuning, inference, caching, and pricing all count the same token IDs.

Advanced

Tokenizer choices shape model behavior: spelling, code indentation, multilingual text, and long context efficiency all depend on the vocabulary.

Why not use characters only?

Characters work for any word, but sequences become very long. Longer sequences make attention slower and force the model to spend effort reconstructing simple words before it can think about meaning.

Why not use whole words only?

The vocabulary would be huge and fail on misspellings, names, URLs, code, and new words. Subword splitting is the best compromise: compact for common text, flexible for rare text.

Can tokenization change the answer?

Yes. If a name or number splits awkwardly, the model sees unfamiliar pieces. This is why models are sensitive to spaces, punctuation, and formatting — even though they shouldn't be.

What is the tradeoff in vocabulary size?

Bigger vocabulary = shorter sequences but larger tables. Smaller vocab = saves memory but longer prompts. Model builders tune this based on language mix and context-length goals.

How does tokenization affect non-English languages?

Most tokenizers are trained on English-heavy data. Non-English text (Finnish, Tamil, Arabic) often splits into more tokens, meaning less text fits in the context window — a real fairness issue.

Why do models struggle with simple math?

Numbers are split inconsistently: "1234" might become ["12", "34"] or ["1", "234"]. The model has to guess the numerical meaning from arbitrary splits. Tokenizers were designed for words, not arithmetic.

Once BPE is done, every piece of text becomes a sequence of token IDs. “the cat sat” becomes something like [5, 182, 93, 12, 5, 44]. The model’s entire world is these integers.

Why does tokenization matter so much?
Tokenization is the model's sensory organ. Everything the model "sees" — every fact, every pattern, every instruction — passes through the tokenizer first. A bad tokenizer can make a model unable to spell words, count accurately, or handle code. The quality of the tokenizer often determines the ceiling of the model's capabilities, no matter how large or well-trained it is. This is why companies like OpenAI and Anthropic invest heavily in tokenizer design — it's not glamorous, but it's foundational.
02 → 03

Connection: token IDs solve input formatting, but they still have no meaning. The ID 182 is not closer to 183 in any semantic sense. The next step is to turn IDs into vectors where distance and direction can carry useful information.

03 · Embeddings

Turning token IDs into meaningful vectors

We have token IDs: [5, 182, 93, …]. But the number 182 means nothing — it’s just an arbitrary label. “cat” = 182 tells the model nothing about what a cat is.

We need each token to become a vector — a list of numbers — where those numbers actually encode meaning. Similar tokens should have similar vectors. This vector is called an embedding.

Think of it like a city map:

  • Each token gets coordinates (its vector)
  • Tokens used in similar ways end up in similar neighborhoods
  • “cat” and “dog” live near each other; “math” and “poetry” are in different districts

The embeddings are stored in a giant table. Looking up “cat” gives you its vector. This is just a special case of the we learned about: you’re multiplying a one-hot vector (all zeros except one 1) by the embedding matrix. The matrix row you pick is the embedding.

A neat trick many LLMs use: the same table is used both to turn token IDs into vectors (at the input) AND to turn vectors back into token predictions (at the output). The input mapping and output prediction share the same “meaning” space — this is called tied embeddings and cuts the parameter count roughly in half.

import numpy as np

vocab_size = 6  # tiny: ["the", "cat", "dog", "sat", "mat", "run"]
embed_dim = 4   # 4 numbers per token (real LLMs use 768–4096)

# Embedding table: one row per token, one column per dimension
E = np.random.randn(vocab_size, embed_dim) * 0.1

# Look up "cat" (index 1) — just grab its row
cat_vector = E[1]
print(f"cat's embedding vector: {cat_vector}")

# Same lookup as one-hot × matrix multiplication
one_hot = np.array([0, 1, 0, 0, 0, 0])
cat_vector2 = one_hot @ E  # same result
print(f"same via one-hot: {cat_vector2}")

Before training, all vectors are random. During training, the model adjusts them: words used in similar ways drift toward similar vectors. “cat” and “dog” end up nearby because they appear before “sat” and after “the.” The model never sees a dictionary — meaning emerges from usage patterns alone.

Click to see training progress

Random noise → structured meaning

Before training, each token's embedding is random noise (shown below as colored pixels). Click Train step to simulate a few training steps. Words that appear in similar contexts will drift toward similar coordinates. Notice "cat" and "dog" moving closer, while "cold" and "warm" drift apart. Watch the color patterns — after training, rows with similar meanings look similar.

step 0 — random noise
Each row = one token vector. Each column = one dimension. Color = value. After training, similar words have similar color patterns. Real LLMs use 768–12,288+ dimensions per token — we show just 8 for clarity.
Q: How does the model learn where to put each word if it starts from pure randomness?
It doesn't need to know what words mean. The model's only job during training is to predict the next token. Words that appear in similar contexts get similar gradients during backpropagation. Over millions of steps, "cat" and "dog" get pushed to similar coordinates simply because they appear before "sat" and "likes milk" — meaning emerges as a side effect of prediction. It's the most beautiful accident in machine learning.
Simple

An embedding is a learned coordinate for a token. Similar usage patterns pull coordinates closer together.

Practical

The embedding dimension is the model's working width. Wider vectors can store more features, but every layer becomes more expensive.

Advanced

Features are distributed. A token does not have one "meaning number"; meaning appears as many weak directions across the vector space.

Is an embedding like a dictionary definition?

No. It's more like GPS coordinates for a word's meaning. "Bank" has coordinates near both "money" and "river"; attention layers later use context to pick the right meaning.

Do embeddings contain all model knowledge?

No. Embeddings are the starting point, but most knowledge lives across attention, MLP, and output weights. The embedding is the first draft; transformer layers rewrite it using context.

Why do people say "king - man + woman = queen"?

Some relationships become roughly linear because the training objective rewards consistent patterns. But this is approximate, not perfect algebra. Real models use many overlapping directions.

What happens to words never seen during training?

The tokenizer breaks unknown words into known pieces (subwords). The model then composes meaning from those pieces. It may still struggle if the combination is rare.

Can two models have different embeddings for the same word?

Yes. A model trained on medical text embeds "cell" differently from one trained on biology. Even the same architecture with different random seeds produces different embeddings. The geometry emerges from training, not design.

Do embeddings change during inference?

The starting vector is fixed — same word always gets same initial numbers. But attention and MLP layers continuously modify that vector. By the time the model predicts, the representation has been rewritten many times by context.

03 → 04

Connection: embeddings give every token meaning, but not order. A bag of token vectors cannot distinguish "teacher helped student" from "student helped teacher". Position must be injected before attention can reason about relationships.

04 · Positional encoding

How the model knows which word came first

Think about the sentence “dog bit man” vs “man bit dog.” Same three words. Completely different meaning. The model needs to know which word came first — order matters.

But the Transformer reads all tokens at once (not one by one). It has zero built-in sense of order. So we must manually inject position info.

The simplest idea: just add 1 to token 1’s vector, add 2 to token 2’s vector, etc. Why doesn’t this work? Read on.

Click to advance

Why position numbers break, and sine waves fix it

Step 1: Adding plain position numbers (1, 2, 3...). At position 50, the number 50 swamps the embedding values that are typically small decimals. The position signal overwhelms the meaning signal.
Position 0 highlighted in white.
Position0
Q: Why not just use the position as a number? Position 1 gets +1, position 50 gets +50.
Two reasons. First, position 50 adds the value 50 to a vector whose other values are around ±0.3 — the position dominates and the model can't "see" the word's meaning anymore. Second, numbers grow unboundedly: the model sees values of 1, 2, 3... 1000 and has to learn that they all mean the same thing (a position), but they're numerically very different. Sine waves solve both: they stay between -1 and +1 forever, and each position gets a unique combination of wave values — a fingerprint — rather than a single growing number.

What actually happens, step by step

We directly modify the token’s vector. There is no separate “position input.” Here’s exactly what happens:

  1. Token “dog” at position 3 has an embedding vector, like [0.2, -0.5, 0.8, ...] (a list of 4096 numbers)
  2. We compute a position vector for position 3 using sine/cosine waves, giving us another vector of the same length, like [0.14, -0.99, -0.76, ...]
  3. We add the two vectors together: new_vector = embedding_vector + position_vector
  4. This summed vector is what the model works with from that point on

The position info is mixed into the token’s numbers. The model doesn’t know “here are 50 numbers for meaning and 50 numbers for position” — they’re all blended together, and the model learns to use whichever parts it needs.

The solution: sine + cosine waves (together they make a fingerprint)

Imagine you’re writing a long exam and every student needs a unique ID. If you give them just one number (1, 2, 3…), you run out of space or the numbers get huge. Instead, imagine giving each student two numbers: one from a fast-ticking clock and one from a slow-ticking clock. Together they form a unique pair.

Similarly, for each position in the sequence, we generate a unique fingerprint using two complementary waves:

  • One wave uses sine (sin) — think of it as the “vertical coordinate”
  • The other uses cosine (cos) — think of it as the “horizontal coordinate”

Why both? Because one alone isn’t enough. Just like a GPS needs both latitude AND longitude to pinpoint a location, sin gives you one value and cos gives you another. Together they form a unique pair for every position. If you used only sin, many different positions would have the same value (sin repeats every 360°). Using sin+cos as a pair creates a unique signature.

Each dimension of the position vector uses a wave at a different frequency:

  • Dimension 0: very fast wave (changes every 1-2 positions) — captures local relationships like “the” before “cat”
  • Dimension 1: slightly slower wave — captures slightly longer patterns
  • Dimension 10: slow wave (changes every 50+ positions) — captures long-range patterns like a pronoun referring to a noun 30 words back
  • Dimension 100+: extremely slow wave — barely changes across the whole sequence

All these waves oscillate between -1 and +1 forever. Position 1,000,000 gets a valid fingerprint just as well as position 1.

The formula, broken down simply

Don’t be scared. Here’s what each symbol means:

  • p = position number (0, 1, 2, 3… which word in the sequence)
  • i = which dimension of the vector we’re computing (0, 1, 2, 3… up to 4096)
  • d = total number of dimensions in the vector (e.g., 4096)
  • 2i and 2i+1 = even and odd dimension numbers (sin for even, cos for odd)

Now the inside:

Think of it like this:

  • 10000 is just a big reference number. It’s like saying “the slowest wave takes 10,000 positions to complete one full cycle”
  • 2i/d is the fraction of “how far through the dimensions we are.” For dimension 0, it’s 0/4096 = 0. For dimension 256, it’s 512/4096 ≈ 0.125. For the last dimension, it’s 4096/4096 = 1.
  • for dimension 0 gives , so the wave speed is p/1 = p — this is the fastest wave (changes with every position)
  • For the last dimension, , so the wave speed is p/10000 — this is the slowest wave (barely changes)

Different dimensions get different denominators: 1, 10000¹ᐟ⁴⁰⁹⁶, 10000²ᐟ⁴⁰⁹⁶, … up to 10000. This creates a smooth range from “fast wave” to “slow wave.”

If the formula still feels fuzzy, just remember: each position gets a unique set of numbers from -1 to +1, and that set is added directly to the token’s embedding vector. The model learns to use those numbers to tell where each word sits in the sentence.

import math

def positional_encoding(seq_len, d_model):
    PE = []
    for pos in range(seq_len):
        pos_vec = []
        for i in range(d_model):
            denom = 10000 ** ((2 * (i // 2)) / d_model)
            if i % 2 == 0:
                pos_vec.append(math.sin(pos / denom))
            else:
                pos_vec.append(math.cos(pos / denom))
        PE.append(pos_vec)
    return PE

seq_len, d_model = 50, 6
PE = positional_encoding(seq_len, d_model)

for pos in range(8):
    print(f"pos {pos}: {[round(v, 2) for v in PE[pos]]}")

# Adding position to embedding (this is what the model actually does):
embedding_cat = [0.2, -0.5, 0.8, 0.1, -0.3, 0.6]
final_vector = [embedding_cat[i] + PE[3][i] for i in range(d_model)]
print(f"\nembedding + pos=3: {[round(v, 2) for v in final_vector]}")

Each position gets a unique fingerprint. Position 0 is different from position 5, and position 1000 works just as well as position 1. The vector is added directly to the token’s embedding — they’re blended together.

Different frequencies → different pattern lengths. Fast waves (high frequency) change rapidly — they capture local patterns between neighboring tokens, like noun-verb agreement. Slow waves (low frequency) change gradually — they capture long-range patterns like a pronoun referring to a subject 50 tokens back. Every dimension in the positional encoding ticks at a different speed, creating a rich fingerprint for each position.

Modern LLMs often use RoPE or ALiBi instead of the original sine/cosine formula. The core idea stays the same: add information so the model can compare where tokens are, not just what they are. RoPE (used in LLaMA, Mistral, GPT-4) is clever — instead of adding a position vector, it rotates the query and key vectors by an angle that depends on position. When two tokens are 3 positions apart, their vectors are rotated by a fixed angle difference, and the attention score naturally reflects that distance. ALiBi (used in BLOOM, MPT) is even simpler: it just subtracts a small penalty from attention scores based on how far apart tokens are — no math beyond subtraction.
Simple

Position encoding gives each token an address. Without the address, the model only sees which words exist, not their order.

Practical

Good position methods help models handle long prompts, code structure, lists, tables, and references across many paragraphs.

Advanced

Relative position matters more than absolute position. Many newer methods make dot products encode distance between tokens directly.

Wait — does the position get stored separately or mixed into the token vector?

Mixed in. The position values are added to the embedding values. The model sees one combined vector per token. It has to figure out which parts of the numbers are "meaning" and which parts are "position." This works because the training process teaches the model to use both kinds of information.

Why both sin and cos? Why not just sin for everything?

Sin alone repeats every 360° — position 0 and position 360 would have the same sin value. By using sin+cos as a pair, every position gets a unique combination (like a GPS needs both latitude and longitude). If you only used sin, the model would be confused about positions that share the same sin value.

What does 10000 mean in the formula?

It controls the speed of the slowest wave. 10000 means "the slowest wave takes about 10000 positions to complete one full cycle." You could use 5000 or 20000 instead — it's just a design choice. The important thing is that different dimensions get different speeds, from very fast (dimension 0) to very slow (last dimension).

What is RoPE in simple words?

RoPE doesn't add position to the embedding at all. Instead, it twists the query and key vectors like turning a dial. The amount of twist depends on the position. When two tokens are close together, their dials are twisted by similar amounts; when far apart, by different amounts. The attention mechanism sees the difference in twist and "knows" the distance. It's elegant because it needs no extra memory and works for any sequence length.

Why do long contexts still fail sometimes?

The model may accept many tokens, but training may not teach it to use every distance equally well. Attention cost, data distribution, positional method, and retrieval difficulty all affect long-context quality.

Can the model ignore position if it wants?

Partly. Later layers can learn to use or suppress positional directions depending on the task. For poetry, code, and math, position is crucial; for topic classification, exact order may matter less.

Why do some models use learned positions instead of sine waves?

Learned positional embeddings treat each position as a separate token-like vector that gets trained. They can be more expressive for fixed-length sequences, but they can't extrapolate beyond the longest position seen in training. Sine waves and RoPE extrapolate naturally — which is why learned positions fell out of favor as context lengths grew.

04 → 05

Connection: now each token has meaning plus address. The next question is contextual meaning: "it" depends on a noun before it, "bank" depends on nearby words, and every token needs a way to look at other tokens.

05 · Attention

The core: letting tokens look at each other

Now every token has meaning + position. But it still doesn’t know which other tokens are relevant to it.

Attention is the mechanism that answers: “Which other words in this sentence should I look at to understand myself better?”

Think of it like a classroom discussion:

  • Each student (token) can hear all other students
  • But not everyone is equally relevant to what you’re saying
  • You need to figure out who to listen to and how much

Why attention replaced the old way (RNNs)

Before Transformers, models called RNNs processed words one at a time — word 1 finishes before word 2 starts. Like reading a book one letter at a time through a tiny straw. Worse, information from word 1 had to travel through every intermediate step to reach word 100 — it degraded along the way (like the telephone game).

Attention changed everything: it compares all words at once, directly. Word 1 connects to word 100 in a single operation, with no degradation. This is why Transformers train faster and handle longer text.

How it works: Q, K, V (like a library)

Every word produces three special vectors from its embedding (using our familiar ):

Think of a library:

  • Query (Q): what I am searching for. Like typing in the search bar.
  • Key (K): what I contain. Like the title and tags on a book.
  • Value (V): the actual information I carry. Like the book’s contents.

Each word asks a question (Query), and every other word’s Key answers whether they’re relevant. If there’s a match, the Value (the actual info) is passed along.

import numpy as np

x = np.array([0.2, 0.8, 0.1, 0.3])  # embedding of "dog"

# Three different weight matrices (learned during training)
W_q = np.random.randn(4, 3) * 0.1
W_k = np.random.randn(4, 3) * 0.1
W_v = np.random.randn(4, 3) * 0.1

Q = x @ W_q
K = x @ W_k
V = x @ W_v

print(f"Query:  {Q.round(2)}")
print(f"Key:    {K.round(2)}")
print(f"Value:  {V.round(2)}")

Same input x, three different projections. Each word does this. Then attention computes how well each word’s Query matches every other word’s Key.

Output

Scores

Inputs

word₁

Q₁

K₁

V₁

word₂

Q₂

K₂

V₂

S = Q₁·K₁ + Q₁·K₂
matches for word₁

weights = softmax(S)
normalized to 0-1

out₁ = w₁·V₁ + w₂·V₂
blend of values

Step 1 · pick a query word

One word asks, all words answer

Click a word below. It becomes the Query. We compute its dot product against every word's Key. The higher the score, the more relevant that word is. Try clicking "chased" — it should attend strongly to "dog" (who did the chasing) and "tail" (what was chased).

Click a word to see its raw dot-product attention scores against every other word.
Q: Q, K, V all come from the same input x. What stops the model from just paying attention to itself?
They use different weight matrices (W_q, W_k, W_v are all different). Even though they receive the same x, they project it into different spaces. The Query might learn to ask "what noun comes before me?" while the Key learns to answer "I'm a noun." They're trained to serve different roles — like a searcher and a librarian who speak different languages but learned to understand each other.

Why we scale: keeping the distribution fair

The raw Query·Key scores get large when vectors are long. Imagine multiplying big numbers together — the results can be huge. When we then apply softmax (which converts raw scores into percentages that add up to 100%), huge scores make softmax too confident — it picks one word and ignores everything else.

The fix: divide all scores by (the square root of the dimension size). This keeps scores in a reasonable range so softmax produces a smooth distribution.

def softmax(x):
    ex = np.exp(x - np.max(x))       # subtract max for numerical stability
    return ex / ex.sum()

def attention(Q, K, V):
    scores = Q @ K.T                  # dot product: how relevant is each key?
    scores = scores / np.sqrt(K.shape[1])  # scale so softmax isn't too extreme
    weights = softmax(scores)         # convert to percentages
    return weights @ V                # weighted blend of values

# Toy sentence: "the dog chased" → 3 tokens, 4-dim embeddings
X = np.array([[0.2, 0.8, 0.1, 0.3],  # "the"
              [0.9, 0.2, 0.7, 0.1],  # "dog"
              [0.3, 0.6, 0.2, 0.9]]) # "chased"
W_q = np.random.randn(4, 4) * 0.1
W_k = np.random.randn(4, 4) * 0.1
W_v = np.random.randn(4, 4) * 0.1

Q, K, V = X @ W_q, X @ W_k, X @ W_v
output = attention(Q, K, V)

print(f"Attention output:\n{output.round(3)}")
# Each token's new vector = blend of all values, weighted by relevance

The scaling by prevents one token from hogging all the attention. Without it, softmax becomes a near one-hot vote — the model only “hears” one token and misses everything else.

Step 2 · from scores to percentages

Raw scores → softmax → attention distribution

The dot-product scores are scaled (divided by √d) and then passed through softmax, which turns them into percentages that add up to 100%. The new representation of each token is a blend of all Values, weighted by these percentages.

In this demo d₋ sits at 4 for visual clarity. Real attention uses d₋ between 64 and 128 — the scaling factor √d ranges from 8 to 11.3, making the effect substantially stronger than shown here.

Scaling keeps the distribution smooth. Without it, attention collapses onto one token.

Multiple heads: many specialists, not one generalist

One type of relationship is not enough. The word “bank” might connect to “river” through one relationship and to “money” through another. So the Transformer runs attention multiple times in parallel — these are called heads. Each head has its own Q, K, V weights, so each can learn a different type of relationship:

  • Head 1: “which word is the subject of this verb?”
  • Head 2: “what does this pronoun refer to?”
  • Head 3: “which adjective describes this noun?”
  • etc.

Modern models use 32+ heads per layer. Each generates its own attention distribution, and their outputs are mashed together into one final representation.

# Multi-head attention: multiple specialists in parallel
n_heads = 4
d_k = 3  # per-head dimension

# 4 heads, each with its own Q, K, V projections
heads = []
for h in range(n_heads):
    W_q_h = np.random.randn(4, d_k) * 0.1
    W_k_h = np.random.randn(4, d_k) * 0.1
    W_v_h = np.random.randn(4, d_k) * 0.1
    heads.append((W_q_h, W_k_h, W_v_h))

# Each head computes attention independently
all_head_outputs = []
for h, (W_q, W_k, W_v) in enumerate(heads):
    Q_h, K_h, V_h = X @ W_q, X @ W_k, X @ W_v
    out = attention(Q_h, K_h, V_h)   # same attention() from above
    all_head_outputs.append(out)
    print(f"Head {h} output: {out.round(2)}")

# Concatenate all heads and project back
combined = np.concatenate(all_head_outputs, axis=1)
W_o = np.random.randn(n_heads * d_k, 4) * 0.1
final = combined @ W_o
print(f"\nFinal (all heads combined):\n{final.round(3)}")

Each head runs the same attention mechanism but with its own learned weights. One might learn syntax, another pronouns, another copy behavior — specialization emerges from the training data, not from human labels.

Step 3 · multi-head attention

Eight experts, each with a specialty

Each head applies the same QKV mechanism but with different learned W_q, W_k, W_v. Below, the grid shows what each head attends to for "dog" in the sentence "the tired old dog chased its tail." Toggle heads on/off. Try turning on just "Pronoun-resolver" and "Describer" to see how different heads capture different linguistic relationships.

Click heads above to see their focus pattern for "dog".
Q: Does the model decide which head does what? Like, does it assign one head to grammar and another to pronouns?
No — specialization emerges naturally. The model is trained with a bunch of heads (typically 8 per layer, 96+ in total), all learning from the same next-token-prediction objective. Different heads end up specializing because there are patterns in the data that are best captured by different attention distributions — one head stumbles into tracking pronouns because that helps prediction, and it gets reinforced. No human assigns their roles. This is also why head names (like "Pronoun-resolver") are post-hoc labels; the model never names them.

The causal mask: when generating text, the model must predict the next token using only tokens that came before it. If position 5 could look at position 6, it would “cheat” by seeing the answer. A mask sets the attention scores for future positions to , so after softmax they become 0%.

def causal_attention(Q, K, V, mask=None):
    scores = Q @ K.T / np.sqrt(K.shape[1])
    if mask is not None:
        scores = scores + mask  # adding -inf zeros out future positions
    return softmax(scores) @ V

# Mask: upper triangle = -inf (can't see future), lower = 0 (can see past)
seq_len = 4
mask = np.triu(np.full((seq_len, seq_len), -np.inf), k=1)
print(f"Causal mask:\n{mask}")

# With mask: token 3 can see tokens 0,1,2 but NOT token 3's own future
Q = np.random.randn(seq_len, 4)
K = np.random.randn(seq_len, 4)
V = np.random.randn(seq_len, 4)
out = causal_attention(Q, K, V, mask)
print(f"\nWith causal mask:\n{out.round(3)}")
# Without mask: every token sees every other token (for comparison)
out2 = causal_attention(Q, K, V)
print(f"\nWithout mask:\n{out2.round(3)}")
Step 4 · the mask

No peeking at future tokens

The attention grid below is a heatmap: row = token looking, column = token being looked at. The upper-right triangle (future tokens) is dark — those scores are masked out.

Row looks at column. The dark triangle in the upper right = masked (causal). Each cell brightness = attention weight.
Simple

Attention lets a token borrow information from other tokens. The borrowing amount is learned from query-key similarity.

Practical

Attention is why the model can connect pronouns, copy names, follow instructions, and use examples from the prompt.

Advanced

Attention is content-addressable memory: queries retrieve values through keys, then write the retrieved mixture back into the residual stream.

Is attention the same as explanation?

Not exactly. High attention means one token's value contributed to another's representation in that head. But MLPs and later layers can transform or override it, so attention alone doesn't explain the full answer.

Why does attention get expensive for long prompts?

Every token compares against all previous tokens. For N tokens, that's roughly N² comparisons. Long contexts (128K tokens) need careful engineering and caching to stay fast.

What is the KV cache?

During generation, old tokens don't change. The model stores their K and V vectors, then only computes the new token's Q and compares it against the cache. This makes generation much faster.

Why multiple heads instead of one big head?

Different heads can learn different patterns in parallel: one tracks grammar, another copies names, another connects pronouns. Their outputs are combined into one rich representation.

Can attention look at future tokens?

During training, no — a mask blocks future positions. During inference, there's no future (tokens are generated one at a time). In BERT-style models, attention is bidirectional (every token sees all others), which is better for understanding but can't generate text.

What is cross-attention?

In encoder-decoder models (like T5), the decoder's attention uses its own Q but the encoder's K and V. This lets the model "read" the input while generating. In GPT-style models, there's no cross-attention — everything is self-attention.

How do attention patterns change across layers?

Early layers focus on nearby words (local grammar). Middle layers connect related tokens across the whole sequence (meaning, references). Late layers prepare the final prediction. The model builds understanding from local → global as it goes deeper.

How attention weights are trained

The three projection matrices are learned through backpropagation through softmax — the same gradient descent as every other parameter, but with a unique challenge.

During the forward pass, attention computes:

When the loss gradient arrives at the attention output, it must flow backward through three distinct paths:

  1. Through (simplest): the gradient tells “the value you produced for token 3 was multiplied by a large attention weight from token 5 — that weight made your contribution to the error large.” adjusts its rows so tokens that should not be attended to produce smaller or less relevant value vectors.

  2. Through the attention matrix A (trickier): the softmax couples all tokens — increasing one weight forces others to decrease (since they sum to 1). This means and learn a push-pull dynamic: if the model repeatedly predicts “sat” after “cat” but gets it wrong, the gradient flows back through the softmax to nudge of “cat” and of “sat” to produce a higher dot product, while pushing down the keys of irrelevant tokens.

  3. Competition between heads: each head’s and receive gradients that discourage redundancy — if two heads attend to the same pattern, their outputs are near-identical and the later projection can’t use both. The training dynamics push heads into complementary specializations automatically.

The full formula involves three chained matrix derivatives, but the intuition: weights that produce attention scores closer to the correct blend of context get rewarded; weights that amplify irrelevant context get penalized.

How attention behaves during inference

During generation, attention works differently than during training:

  • Training: the model sees the full sequence at once. Attention scores are computed for every pair of tokens simultaneously (masked so tokens can’t peek ahead).
  • Inference: the model generates one token at a time. At step 5, it has only 5 tokens. The newest token computes Q against all previous K, but those K don’t change — they were computed at earlier steps.

This is where the KV cache comes in. Instead of recomputing K and V for every token at every step, the model stores them:

  1. Token 1: compute , cache them. Generate token 2.
  2. Token 2: compute , retrieve cached . Compute score, retrieve cached for blending. Generate token 3.
  3. Token 3: compute , retrieve cached . Compute scores, retrieve . Generate token 4.

This reduces the per-step computation from to O(N) — linear in sequence length instead of quadratic. For a 128K-token context, this is the difference between milliseconds and seconds per token.

FlashAttention is an algorithmic optimization that makes the attention computation itself faster by being smarter about GPU memory access. Standard attention materializes the full N×N attention matrix in GPU memory, then reads it again for softmax and the weighted sum. FlashAttention instead processes attention in blocks that fit in fast GPU SRAM, never writing the full matrix to slow HBM. This makes attention 2–4× faster and uses much less memory — which is why it enabled the jump from 4K to 128K+ context lengths in production models. Almost every modern LLM training and inference stack uses FlashAttention or a variant of it.

The trained attention heads behave differently at inference time too: heads that learned syntactic patterns (e.g., “attend to the subject of this verb”) apply those patterns to whatever prompt you give, even if they’ve never seen that sentence before. Heads that learned copying behavior can attend to any token in the prompt and copy its value, which is how in-context learning works — the prompt’s examples become values that attention heads can retrieve.

05 → 06

Connection: attention moves information between tokens. But after a token gathers context, it still needs private computation: classify, combine, activate stored patterns, and decide what to write back. That is the MLP and residual stream.

06 · MLP & residual stream

Knowledge storage and the highway through the model

Attention is group work — tokens exchange information. The MLP is individual work — each token processes what it learned on its own.

Think of it like studying: attention is the group discussion where you hear others’ ideas; the MLP is the quiet desk time where you make sense of what you heard and connect it to what you already know.

The MLP has two linear layers with a bend in between:

First, the token’s vector is expanded into a wider space (4×–8× wider) — like taking notes and spreading them out on a bigger desk to see patterns. Then the activation function filters out noise (negative values become zero). Then it’s compressed back to the original size.

Modern LLMs use SwiGLU, which adds a third “gate” projection that learns what to let through and what to block — like having a smart filter that decides which notes are worth keeping.

def mlp_forward(x, W1, b1, W2, b2):
    # Expand: project into wider space (4x)
    hidden = x @ W1 + b1
    hidden = np.maximum(0, hidden)   # ReLU: kill negatives
    # Contract: project back to original size
    return hidden @ W2 + b2

def swiglu_forward(x, W1, W3, W2):
    # Gate: one path computes, another controls flow
    hidden = x @ W1
    gate = x @ W3
    gate = 1 / (1 + np.exp(-gate))   # sigmoid: values between 0-1
    gated = hidden * gate             # element-wise multiply
    return gated @ W2

# Toy example: 4-dim input → 8-dim hidden → 4-dim output
d_model, d_ff = 4, 8
x = np.array([0.5, -0.2, 0.8, 0.1])

W1 = np.random.randn(d_model, d_ff) * 0.1
b1 = np.zeros(d_ff)
W2 = np.random.randn(d_ff, d_model) * 0.1
b2 = np.zeros(d_model)

result = mlp_forward(x, W1, b1, W2, b2)
print(f"MLP input:  {x}")
print(f"MLP output: {result.round(3)}")

The MLP stores most of the model’s knowledge — facts, grammar patterns, style. Attention decides what context to gather; the MLP decides what to do with it.

Click to advance

How the MLP stores and retrieves knowledge

Each token gets its own MLP computation. The hidden dimension is typically 4x wider than the model dimension — this expansion creates "room" for memorized patterns.

The token vector enters the MLP. W₁ projects it into a wider space — from dim 4 to dim 8 — creating more "storage slots" for patterns.
Q: What's actually stored in the MLP weights? Facts? Grammar rules?
Both and more. The MLP weights encode everything the model "knows." Attention decides which information to gather; the MLP processes what to do with it. Some neurons activate for factual recall ("the capital of France is..."), others for grammatical patterns ("after 'the' comes a noun"), others for style and tone. The MLP is the model's long-term memory — compressed and distributed across the weights.

The residual stream: after the MLP (and after attention), the original input is added back:

This is the residual connection. It acts like a highway: information flows through the model even if individual layers have weak signals. Every token starts as its embedding at layer 0, and each layer adds a refinement. The final representation is the sum of contributions from every layer.

def layer_norm(x, eps=1e-5):
    """Stabilize values: center (subtract mean), scale (divide by std)."""
    mean = x.mean(axis=-1, keepdims=True)
    std = x.std(axis=-1, keepdims=True)
    return (x - mean) / (std + eps)

def transformer_block(x, attn_weights, mlp_weights):
    """One complete transformer block = attention + MLP + residuals."""
    # Pre-norm: normalize before each sublayer
    xn = layer_norm(x)

    # Attention (gather context from other tokens)
    attn_out = attention(xn, xn, xn)  # self-attention: Q, K, V all = xn
    x = x + attn_out                   # residual: add back original

    # MLP (process individually)
    xn = layer_norm(x)
    mlp_out = mlp_forward(xn, *mlp_weights)
    x = x + mlp_out                    # residual: add back again

    return x

# One token passes through one block
x = np.array([0.5, -0.2, 0.8, 0.1])
dummy_mlp = (np.random.randn(4, 8) * 0.1, np.zeros(8),
             np.random.randn(8, 4) * 0.1, np.zeros(4))
result = transformer_block(x, None, dummy_mlp)
print(f"Input:  {x}")
print(f"Output: {result.round(3)}")
# Notice: output ≈ input + small refinements from each sublayer

Note: modern LLMs apply LayerNorm before each sublayer (attention and MLP), not after. This “pre-norm” configuration stabilizes training because the normalization happens on the clean input rather than on the potentially noisy output.

One Transformer Block

Input x

LayerNorm

Multi-Head Attention

+ residual: x + attn_out

LayerNorm

MLP (expand → activate → contract)

+ residual: x + mlp_out

Output

Why is the residual stream the most important architectural innovation?
It makes depth work. Before residual connections, neural networks couldn't go deeper than ~20 layers — gradients vanished. With residuals, the gradient has a direct path from the loss back to the first layer. This single change enabled the jump from small models to 96-layer, 175B-parameter networks. The residual stream is also why you can remove entire transformer blocks and the model still mostly works — each block is a small additive refinement, not a critical transformation.
Architecture

The residual stream: a highway through the model

One transformer block × N
Input (residual stream)The token's current representation
→ LayerNorm → Attention → + residualGather & add
→ LayerNorm → MLP → + residualProcess & add
Output (to next block or prediction)Information from all layers combined
The residual stream carries the token's representation through all layers. Each block reads from it and writes back to it. Nothing gets lost — information can skip layers if it doesn't need processing there.

This architecture — residual stream + attention + MLP — repeated N times (typically 12 to 96+ times) is the entire Transformer decoder. Every modern LLM is this, larger.

Q: Why add the input back? Doesn't that just create redundancy?
Without the residual connection, deep models can't train. As you add more layers, gradients shrink as they flow backward (the "vanishing gradient" problem). The residual connection gives the gradient a direct path from the output back to the input — it can skip layers entirely if their contribution is small. This is why Transformers can be 96+ layers deep. You can think of each layer as a "diff" — it only needs to learn what changed, not the whole representation from scratch.
Simple

Attention gathers. The MLP thinks locally. The residual stream carries everything forward so layers can add refinements instead of replacing the whole state.

Practical

Most model parameters live in MLPs. Increasing MLP width often increases memorized patterns and task capacity, but also raises compute cost.

Advanced

The residual stream acts like shared working memory. Attention and MLP modules read from it, compute updates, and write additive deltas back into it.

Why does the MLP operate per token?

By the time the MLP runs, attention has already mixed in context from other tokens. The MLP then processes each token independently — like each student doing private study after a group discussion.

Is the MLP where facts are stored?

Many facts live in MLP weights, but they're not stored like rows in a database. They're distributed across many parameters and depend on attention bringing the right context into the residual stream.

What does LayerNorm do?

It rescales each token's numbers to stay in a stable range. Without it, values would explode or shrink as they pass through dozens of layers.

Why do modern MLPs use gates like SwiGLU?

Gates let the model learn what to let through and what to block. One path creates candidate information; another controls the gate. This improves quality enough to justify the extra weight matrix.

What is mixture-of-experts (MoE)?

MoE replaces one big MLP with many smaller "expert" MLPs plus a router that picks which experts to use for each token. Only a few activate per input, so MoE models can have many more total parameters without proportional compute cost. Powers Mixtral, DeepSeek, and reportedly GPT-4.

How much of a model's parameters are in the MLP vs attention?

In a typical block, the MLP has 2–3× more parameters than attention. For a 70B model, MLP layers account for about 2/3 of all parameters. The MLP is the model's "memory."

Can the residual stream "forget" information?

Not directly — it only adds, never deletes. But a later layer can cancel out an earlier contribution. In practice, information accumulates, which is why very deep models can get "cluttered."

Output projection: from residual to vocabulary

After the last transformer block, the residual stream holds a vector for each token. To predict the next token, feed the last token’s residual through the same embedding table (transposed) to get logits, then softmax to get probabilities.

This is called tied embeddings — the input lookup table doubles as the output projection. Every LLM uses this or a variant.

def unembed(residual, embed_table):
    """Turn the final token's residual into vocabulary probabilities."""
    logits = residual @ embed_table.T  # dot product against every word vector
    return softmax(logits)             # probabilities over vocabulary

Why the transpose? The embedding table has shape (vocab_size, d_model). We need (d_model,) @ (vocab_size, d_model).T = (vocab_size,) — one score per word in the vocabulary.

The complete forward pass: everything wired together

Now we have every piece. Let’s stitch them into one function that takes token IDs and returns next-token probabilities. This is a tiny, working transformer with 2 blocks, 2 attention heads, and 32-dimensional vectors — small enough to train on a laptop.

Every function below was introduced in earlier sections.

import numpy as np
import math

# ─── From Section 0 (starter toolbox) ───
def softmax(x):
    ex = np.exp(x - np.max(x, axis=-1, keepdims=True))
    return ex / ex.sum(axis=-1, keepdims=True)

def layer_norm(x, eps=1e-5):
    return (x - x.mean(axis=-1, keepdims=True)) / (x.std(axis=-1, keepdims=True) + eps)

def relu(x):
    return np.maximum(0, x)

# ─── From Section 4 (positional encoding) ───
def positional_encoding(seq_len, d_model):
    PE = np.zeros((seq_len, d_model))
    for pos in range(seq_len):
        for i in range(d_model):
            denom = 10000 ** ((2 * (i // 2)) / d_model)
            if i % 2 == 0:
                PE[pos, i] = math.sin(pos / denom)
            else:
                PE[pos, i] = math.cos(pos / denom)
    return PE

# ─── From Section 5 (attention) ───
def attention(Q, K, V):
    d_k = K.shape[-1]
    scores = Q @ K.T / np.sqrt(d_k)
    weights = softmax(scores)
    return weights @ V

# ─── From Section 6 (MLP + block) ───
def mlp_forward(x, W1, b1, W2, b2):
    hidden = relu(x @ W1 + b1)
    return hidden @ W2 + b2

def transformer_block(x, W_q, W_k, W_v, W_o, W1, b1, W2, b2):
    # Pre-norm → attention → residual
    xn = layer_norm(x)
    Q = xn @ W_q
    K = xn @ W_k
    V = xn @ W_v
    attn_out = attention(Q, K, V)
    attn_out = attn_out @ W_o     # project concatenated heads back
    x = x + attn_out

    # Pre-norm → MLP → residual
    xn = layer_norm(x)
    mlp_out = mlp_forward(xn, W1, b1, W2, b2)
    x = x + mlp_out
    return x

# ─── NEW: unembedding (output projection) ───
def unembed(residual, embed_table):
    logits = residual @ embed_table.T
    return softmax(logits)

# ─── Complete forward pass ───
def forward(token_ids, embed_table, pos_enc, params, n_layers=2):
    seq_len = len(token_ids)
    d_model = embed_table.shape[1]

    # Step 1: Embed + position
    x = embed_table[token_ids]       # (seq_len, d_model)
    x = x + pos_enc[:seq_len]        # add position fingerprint

    # Step 2: N transformer blocks
    for layer in range(n_layers):
        p = params[layer]
        x = transformer_block(x, p['W_q'], p['W_k'], p['W_v'],
                              p['W_o'], p['W1'], p['b1'], p['W2'], p['b2'])

    # Step 3: Predict next token (use last position's residual)
    probs = unembed(x[-1], embed_table)
    return probs

# ─── Cross-entropy loss (Section 7) ───
def cross_entropy(probs, correct_idx):
    return -math.log(max(probs[correct_idx], 1e-10))

# ─── Instantiate a tiny model ───
VOCAB = ["the", "cat", "dog", "sat", "mat", "run", "."]
vocab_size = len(VOCAB)
d_model = 32
n_layers = 2
d_k = 16
d_ff = 64

np.random.seed(42)
E = np.random.randn(vocab_size, d_model) * 0.1  # tied embeddings
PE = positional_encoding(20, d_model)            # supports up to 20 tokens

params = []
for _ in range(n_layers):
    params.append({
        'W_q': np.random.randn(d_model, d_k) * 0.1,
        'W_k': np.random.randn(d_model, d_k) * 0.1,
        'W_v': np.random.randn(d_model, d_k) * 0.1,
        'W_o': np.random.randn(d_k, d_model) * 0.1,
        'W1':  np.random.randn(d_model, d_ff) * 0.1,
        'b1':  np.zeros(d_ff),
        'W2':  np.random.randn(d_ff, d_model) * 0.1,
        'b2':  np.zeros(d_model),
    })

# Test it: predict next token after "the cat"
ids = [0, 1]  # "the cat"
probs = forward(ids, E, PE, params)
print(f"After 'the cat', next-word probabilities:")
for i, p in enumerate(probs):
    print(f"  {VOCAB[i]}: {p:.1%}")

The next-token probabilities are random (weights are untrained). That’s the problem Section 07 solves.

06 → 07

Connection: we now have a complete, working (but untrained) forward pass. Every weight is random noise. Training is the process that adjusts those weights so the model's predictions become accurate.

07 · Training

Learning by predicting the next token

Every weight in the model starts as random noise. The model knows nothing — it’s as smart as a baby randomly babbling. How does it learn?

The training goal is embarrassingly simple: predict the next word. Given “the cat sat on the ___”, predict “mat”. That’s the entire objective.

For every position in a sentence, the model guesses — it assigns a probability to every word in its vocabulary. If the correct word gets high probability, good. If not, the model gets “penalized” and adjusts.

The penalty is cross-entropy loss:

Don’t fear the formula. It just means:

  • If the model is 90% sure about the right word → loss ≈ 0.1 (small penalty)
  • If the model is 90% sure about the wrong word → loss ≈ 2.3 (big penalty)

The model’s goal: minimize this loss across billions of sentences. That’s it.

import math

def cross_entropy(probs, correct_idx):
    """Penalize the model: low loss if correct token is likely, high if not."""
    p_correct = probs[correct_idx]          # probability assigned to correct word
    return -math.log(max(p_correct, 1e-10)) # log(1)=0, log(0.1)=2.3, log(0.01)=4.6

# Example: model predicts next word after "the cat sat on the"
# Correct answer is "mat", model gives it 80% probability
probs = np.array([0.05, 0.80, 0.03, 0.02, 0.10])  # [the, mat, sat, dog, run]
correct_idx = 1  # "mat"
loss = cross_entropy(probs, correct_idx)
print(f"Correct = mat, P(correct) = {probs[correct_idx]:.0%}, loss = {loss:.3f}")

# If model was only 10% confident about "mat":
probs_bad = np.array([0.70, 0.10, 0.05, 0.05, 0.10])
loss_bad = cross_entropy(probs_bad, correct_idx)
print(f"Correct = mat, P(correct) = {probs_bad[correct_idx]:.0%}, loss = {loss_bad:.3f}")
Click to advance

Next-token prediction in action

Below, the model processes "the cat sat on the" and predicts what comes next. The bars show probability assigned to each token in the vocabulary. Step through each position and watch how the loss changes — when the model is confident and correct, loss is low; when it's surprised, loss spikes.

Position 5 — predicting token after "the"

Sentence
'the cat sat mat .'

Model predicts
next token for
each position

Cross-entropy loss
penalizes wrong
predictions

Backpropagation:
nudge every weight
in the right direction

Weights updated
(small step)
model improves

Backpropagation: once we know the loss, we need to adjust every weight to reduce it. The key insight: for each weight, we calculate “if I nudge this weight up by a tiny bit, does the loss go up or down?”

Weights that caused more error get bigger adjustments. The calculation flows backward through the entire network — from output → MLP → attention → embeddings — assigning blame to each weight along the way. It’s like tracing a mistake backward through a chain of reasoning: “the output was wrong because the MLP gave bad info, which happened because the attention gathered wrong context, which happened because the embedding was unclear…”

# Training the real forward() with numerical gradients

# Numerical gradient: (f(x+h) - f(x-h)) / (2h)
# This estimates how each weight affects the loss by nudging it
# up and down and measuring the difference. Slow but exact.

def numerical_grad_params(params, E, PE, token_ids, correct_idx, h=1e-4):
    """Return gradient of loss w.r.t. every parameter, one at a time."""
    base_loss = cross_entropy(forward(token_ids, E, PE, params), correct_idx)
    grads = []
    for layer_params in params:
        layer_grads = {}
        for key in layer_params:
            original = layer_params[key].copy()
            grad = np.zeros_like(original)
            # Perturb each element and measure loss change
            flat = original.ravel()
            grad_f = grad.ravel()
            for j in range(len(flat)):
                flat[j] += h
                loss_p = cross_entropy(forward(token_ids, E, PE, params), correct_idx)
                flat[j] -= 2 * h
                loss_m = cross_entropy(forward(token_ids, E, PE, params), correct_idx)
                flat[j] += h  # restore
                grad_f[j] = (loss_p - loss_m) / (2 * h)
            layer_grads[key] = grad
        grads.append(layer_grads)
    return grads

# ⚠ WARNING: this loop is SLOW — it calls forward() twice per parameter
# per step. We train only the embedding table E for demonstration.
# Real training uses automatic differentiation (PyTorch, JAX) which
# computes all gradients in one backward pass, not one param at a time.

learning_rate = 0.5
sentence_ids = [0, 1, 3, 4, 6]  # "the cat sat mat ."

for step in range(20):
    total_loss = 0
    for i in range(len(sentence_ids) - 1):
        context = sentence_ids[:i+1]
        correct = sentence_ids[i+1]

        # Forward
        probs = forward(context, E, PE, params)
        loss = cross_entropy(probs, correct)
        total_loss += loss

        # Numerical gradient
        grads = numerical_grad_params(params, E, PE, context, correct, h=1e-4)

        # Update weights
        for layer in range(len(params)):
            for key in grads[layer]:
                params[layer][key] -= learning_rate * grads[layer][key]

    if step % 5 == 0:
        test_probs = forward([0, 1], E, PE, params)  # "the cat" → ?
        print(f"step {step}: loss = {total_loss:.3f},  "
              f"P('sat'|'the cat') = {test_probs[3]:.1%}")

Notice: each training step calls forward() twice for every single weight — thousands of forward passes. This is why it’s called “numerical” and not used at scale. Real training uses backpropagation, which computes all gradients in a single backward pass by walking the chain rule backward through the computation graph. But the idea is identical: measure how nudging each weight changes the loss, then nudge in the direction that reduces it.

Click to see the flow

Error flows backward through the whole model

Forward: token → embedding → attention → MLP → logits → loss. Backward: the gradient flows from the loss back through every layer. Click step by step.

Forward: input → hidden layers → prediction. The error is calculated at the output.
Q: Doesn't computing gradients for billions of weights take forever?
It does — but GPUs make it possible. Matrix multiplies are highly parallelizable, and modern GPUs can do trillions of operations per second. A single training step on a 7B-parameter model might process millions of tokens across hundreds of GPUs. Even so, training a frontier model costs tens of millions of dollars and takes months. The trick is that inference (using the trained model) is much cheaper than training.

Training at scale: real LLM training involves:

  • Batches: processing thousands of sequences at once, not one at a time
  • Mixed precision: using 16-bit numbers instead of 32-bit for speed
  • Learning rate schedule: starting with large weight updates and gradually shrinking them — like coarse-tuning then fine-tuning. A warmup phase slowly increases the rate for the first ~2000 steps (when gradients are chaotic), then cosine decay smoothly decreases it to near zero.
  • Checkpointing: saving the model every few hours (training can and will crash)
  • Data parallelism: splitting work across hundreds of GPUs, each computing gradients, then averaging them
Interactive

Training infrastructure at scale

Relative cost (arbitrary units, log scale). A frontier training run costs millions of GPU-hours; inference is ~5000× cheaper per token.
Simple

Training is repeated correction. Predict next token, measure error, move weights a tiny amount, repeat billions of times.

Practical

The hard part is not the objective; it is scale: data cleaning, GPU memory, stability, parallelism, checkpoints, and cost control.

Advanced

Backpropagation computes credit assignment through the whole graph. Optimizers like AdamW convert noisy gradients into stable parameter updates.

Why does next-token prediction learn reasoning?

To predict text from books, code, math, and conversations, the model must learn the patterns behind that text. Reasoning-like patterns emerge because they help predict reasoning-heavy parts of the training data more accurately.

What is a logit?

The raw score for each possible next word before softmax converts them into percentages. Higher logit = more likely.

Why not train on only high-quality data?

Diversity matters too. A model trained only on polished text would lack code, slang, multilingual patterns, and edge cases. Modern datasets balance scale with filtering.

What can go wrong during training?

Loss spikes, bad data, hardware failures, duplicate data, and numerical overflow can all damage a run. Large training is as much systems engineering as model design.

What is a tokenizer's role in training quality?

If the tokenizer splits words poorly, the model wastes capacity reconstructing common words from fragments. A bad tokenizer can make a model unable to spell, count, or handle code — all because the input representation is wrong, not the architecture.

Why does data quality matter more than quantity (after a point)?

After a few trillion tokens, adding more low-quality data gives diminishing returns or even hurts — the model starts memorizing noise instead of learning patterns. A smaller, cleaner dataset often outperforms a larger, noisier one.

07 → 08

Connection: pre-training creates a strong continuation engine. To make it useful as an assistant, we need to control how it is prompted, fine-tuned, sampled, and aligned with the behavior we want.

08 · Using the trained model

From next-token predictor to assistant

After pre-training, the model is an expert at continuing text. Ask it a question and it will ramble — it was trained to predict the next word, not to answer helpfully.

To turn it into an assistant, we do fine-tuning: continued training on curated question-answer pairs.

Only the answer part gets penalized — the question is just context. The learning rate is tiny (0.1× or less) because we want to gently polish the model’s behavior, not retrain it from scratch.

# Generation loop: the model writes one word at a time
def generate(model, prompt_ids, n_tokens=5, T=1.0):
    generated = list(prompt_ids)
    for _ in range(n_tokens):
        probs = model(generated)           # predict next word probabilities
        probs = softmax_with_temp(np.log(probs + 1e-10), T)  # apply temperature
        next_id = np.random.choice(len(probs), p=probs)      # sample
        generated.append(next_id)
        if next_id == 0:                    # <end> token
            break
    return generated

# Toy example: a model that predicts next word based on last word
toy_vocab = ["<end>", "the", "cat", "sat", "mat"]

def toy_model(context):
    # In a real model, this would be the full transformer forward pass
    # Here we just return fixed probabilities for demo
    return np.array([0.01, 0.40, 0.30, 0.20, 0.09])

prompt = [1]  # start with "the"
output = generate(toy_model, prompt, n_tokens=4)
print(f"Generated: {' '.join(toy_vocab[i] for i in output)}")

Prompt:
'the cat sat'

Model predicts
next token
probabilities

Sample:
pick 'mat'
(T=0.7)

New prompt:
'the cat sat mat'

Output:
'...mat.'
complete

This is autoregressive generation: each new word becomes part of the input for the next prediction. The model never goes back to rewrite — it’s like writing a sentence one word at a time, never erasing.

Inference

Step by step: how a trained model generates text

"the cat sat"
Probability distribution over vocabulary for the next position:

How to control what the model says (inference techniques)

The model assigns probabilities to every possible next word. How you pick from those probabilities changes the output:

  • Temperature (T): controls randomness. Low T (0.1) = always picks the most likely word (boring but safe). High T (1.5) = sometimes picks less likely words (creative but risky). T=1 is the default.
  • Top-k: only consider the k most likely words, ignore the rest.
  • Top-p: consider words until their combined probability reaches p (e.g., 90%), cut off the long tail of unlikely words.
# Temperature: adjust how "sharp" the probability distribution is
def apply_temperature(logits, T):
    return logits / T                       # divide raw scores by T

def softmax_with_temp(logits, T=1.0):
    logits = logits / T                     # apply temperature
    ex = np.exp(logits - np.max(logits))
    return ex / ex.sum()

# Example: raw scores (logits) for next word
logits = np.array([3.2, 1.1, 0.5, -0.2])   # raw: higher = more likely

for T in [0.1, 1.0, 2.0]:
    probs = softmax_with_temp(logits, T)
    print(f"T={T:.1f}: {[f'{p:.0%}' for p in probs]}")
# T=0.1: [~100%, ~0%, ~0%, ~0%]  — almost always picks top word
# T=1.0: [76%, 14%, 8%, 2%]       — balanced
# T=2.0: [48%, 22%, 16%, 14%]     — more random

# Top-k: keep only the k most probable tokens, zero out the rest
def top_k_sampling(probs, k=2):
    top_indices = np.argsort(probs)[-k:]    # indices of k highest
    new_probs = np.zeros_like(probs)
    new_probs[top_indices] = probs[top_indices]
    return new_probs / new_probs.sum()

# Top-p (nucleus): keep tokens until cumulative probability reaches p
def top_p_sampling(probs, p=0.9):
    sorted_idx = np.argsort(probs)[::-1]
    cumsum = np.cumsum(probs[sorted_idx])
    cutoff = np.searchsorted(cumsum, p) + 1
    new_probs = np.zeros_like(probs)
    new_probs[sorted_idx[:cutoff]] = probs[sorted_idx[:cutoff]]
    return new_probs / new_probs.sum()

probs = np.array([0.50, 0.20, 0.15, 0.10, 0.05])
print(f"\nTop-2: {top_k_sampling(probs, 2).round(2)}")   # keeps only top 2
print(f"Top-p (0.9): {top_p_sampling(probs, 0.9).round(2)}")  # keeps ~90% prob mass
What does temperature actually do?
It divides each word's raw score by T before converting to percentages. At T=0.1, scores get 10× bigger, so the top word dominates completely (always picks "the"). At T=2, scores are halved, so many words have similar chances (more random). Temperature doesn't change which word is most likely — only how strongly the model favors it.
Q: Why not always pick the most likely word?
Because you get boring, repetitive text. The most likely next word is almost always "the", "a", or "is". Pick that every time and the model loops forever. Controlled randomness lets the model choose interesting but less obvious continuations. Too much randomness = nonsense. Too little = boring. This is why temperature is the most common dial to adjust.

Three ways to steer the model (without retraining)

  1. Fine-tuning: change the weights by training on curated examples
  2. Prompting: don’t change weights — just write a good instruction. The model figures out what you want from the context alone. This is called in-context learning — giving examples in the prompt works because attention can use those examples.
  3. Chain-of-thought (CoT): ask the model to “think step by step.” The intermediate reasoning steps give attention more material to work with, dramatically improving math and logic problems.
  4. RAG (Retrieval-Augmented Generation): before answering, look up relevant documents and inject them into the prompt. The model then answers based on real, up-to-date info instead of relying on its imperfect memory. This is how production chatbots stay factual.
Simple

Inference is repeated next-token prediction. The model writes one token, appends it to the prompt, then predicts again.

Practical

Prompting changes context. Fine-tuning changes weights. Sampling changes which likely continuation gets chosen.

Advanced

Assistant behavior is a stack: pre-training capability, supervised fine-tuning format, preference tuning, system prompts, tools, and decoding policy.

Why does the model sometimes hallucinate?

The model is trained to produce plausible text, not to check facts. If it lacks context or is uncertain, it will confidently say something wrong. RAG (looking up documents) and verification tools help reduce this.

What is the difference between fine-tuning and RLHF?

Fine-tuning teaches the model to imitate desired examples. RLHF teaches it to prefer outputs that humans ranked higher. A newer method, DPO (Direct Preference Optimization), skips the separate reward model and directly trains on preferred vs rejected outputs — it's simpler and has become the default for many open-weight models.

Why does prompt wording matter so much?

The prompt is part of the input sequence. Different wording activates different patterns in the model. A clearer prompt reduces ambiguity and puts the model in the right "mode."

What is the context window, really?

The maximum number of tokens the model can see at once. Anything beyond that is invisible unless summarized or retrieved.

Why does the model sometimes refuse to answer?

Safety training teaches the model to decline harmful requests. But the boundaries are fuzzy — the model learned patterns like "decline if the request looks like X," and sometimes X is too broad.

Can a model "learn" during a conversation?

Not in the weight-update sense. But it can effectively learn within the context window: examples, corrections, and instructions given earlier become part of the input that later tokens attend to. This is in-context learning.

08 → 09

Connection: once a model can generate useful text, the next engineering question is trust. Interpretability asks what internal components are doing, why failures happen, and whether we can detect or steer behavior before deployment.

09 · Interpretability

Looking inside the black box

After all this, the model is just a bunch of numbers — weights and activations. But patterns emerge that we can visualize and interpret.

Why should anyone care about interpretability?
Trust requires understanding. These models are used in hospitals, classrooms, and legal systems. If we can't explain why a model said something, we can't verify it's safe or correct. Interpretability is the difference between "the model said X" and "the model said X because of Y." As models get more powerful, looking inside becomes essential, not just interesting.

Inspecting our own model

We built a tiny transformer in Section 06. Let’s modify forward() to also return the attention weights — the “who-looks-at-whom” scores — and print them.

def forward_with_attn(token_ids, embed_table, pos_enc, params, n_layers=2):
    """Same as forward(), but also returns attention weights per layer."""
    seq_len = len(token_ids)
    d_model = embed_table.shape[1]

    x = embed_table[token_ids]
    x = x + pos_enc[:seq_len]

    all_attn = []
    for layer in range(n_layers):
        p = params[layer]
        xn = layer_norm(x)
        Q = xn @ p['W_q']
        K = xn @ p['W_k']
        V = xn @ p['W_v']

        d_k = K.shape[-1]
        scores = Q @ K.T / np.sqrt(d_k)
        attn_weights = softmax(scores)     # (seq_len, seq_len)
        all_attn.append(attn_weights)

        attn_out = attn_weights @ V
        attn_out = attn_out @ p['W_o']
        x = x + attn_out

        xn = layer_norm(x)
        x = x + mlp_forward(xn, p['W1'], p['b1'], p['W2'], p['b2'])

    probs = softmax(x[-1] @ embed_table.T)
    return probs, all_attn

# Inspect: "the cat sat mat ."
ids = [0, 1, 3, 4, 6]
probs, attn_layers = forward_with_attn(ids, E, PE, params)

VOCAB = ["the", "cat", "dog", "sat", "mat", "run", "."]
words = [VOCAB[i] for i in ids]

for layer_idx, attn in enumerate(attn_layers):
    print(f"\nLayer {layer_idx} attention matrix:")
    print(f"         " + "  ".join(f"{w:>6s}" for w in words))
    for i, word in enumerate(words):
        row = "  ".join(f"{attn[i,j]:6.2f}" for j in range(len(ids)))
        print(f"  {word:6s}  {row}")

Each cell shows how much one token (row) attends to another (column). Values near 1.0 mean “copies this token’s information”; near 0.0 means “ignores it.” In an untrained model (our current state), the distribution is roughly uniform — attention hasn’t learned to specialize yet. After training, specific patterns emerge.

Attention patterns

Trained attention heads show clear, interpretable patterns. Some heads look at the previous word (local context). Some look at the first word (global summary). Some track whether two words belong together. These patterns are remarkably consistent across different models.

Interactive

What different attention heads actually look at

The grid below shows attention patterns from a small trained model processing "the dog chased its tail." Each head has developed its own specialization.

Grid: row = token looking, column = token being looked at. Brighter = more attention.

Layer-wise progression

Early layers (1-4) focus on local, syntactic patterns: neighboring words, parts of speech. Middle layers (5-16) do the heavy lifting of semantic understanding: coreference resolution, subject-verb relationships. Late layers (17+) prepare the output, focusing on what token comes next.

Neuron activation

Individual neurons in the MLP layers can respond to specific concepts. Some neurons activate for “Python code,” others for “sports,” others for “negative sentiment.” This is called interpretability — the active research field trying to understand what individual model components do.

Recent breakthrough: sparse autoencoders (SAEs) have shown that individual neurons often respond to multiple unrelated concepts at once — like one neuron caring about both “DNA sequences” and “code errors.” SAEs break these tangled activations into cleaner, single-concept features. Anthropic’s 2024 work on Claude found millions of interpretable features this way.

Q: If we can see what heads and neurons do, can we manually fix mistakes?
Sometimes, but it's fragile. You can try editing specific weights to update a fact or remove a behavior, but the same neuron contributes to many things at once. Fix one thing, break another. Fine-tuning remains the primary tool for steering model behavior; interpretability is mostly used for diagnosis rather than surgery.
Simple

Interpretability tries to map model internals to human concepts: heads, neurons, circuits, features, and failure modes.

Practical

It helps debug models, design evaluations, understand jailbreaks, and explain why a system behaves differently across prompts.

Advanced

Mechanistic interpretability studies circuits: small groups of components that implement a behavior through activations and weights.

What is a circuit in an LLM?

A circuit is a group of heads and neurons that work together for one behavior — like copying a name from the prompt, or tracking whether a pronoun matches a noun.

Why is interpretability hard?

One neuron can participate in many concepts at once (like one person having multiple jobs). Plus, behavior spreads across many layers, so looking at one piece in isolation is misleading.

Can we prove a model is safe by interpreting it?

Not yet. We can find evidence and run tests, but models are too large and tangled for complete safety proofs with current tools.

How does this connect back to training?

Training creates these circuits automatically. Interpretability reverse-engineers what training discovered. Each circuit we find is one solution the model stumbled upon to reduce prediction error.

What is superposition?

The model stores more concepts than it has dimensions by stacking them in overlapping directions — like storing 10 files in a cabinet with 5 folders. The concepts interfere with each other, making interpretation harder.

Can interpretability detect deception?

In theory, a deceptive model might have internal circuits that separate "what I should say" from "what I really think." In practice, current tools aren't powerful enough to reliably detect this — but researchers are working on it.

10 · Encoder & Decoder

The original Transformer had two halves — cross-attention connects them

All the attention we’ve discussed so far is self-attention (tokens attending to other tokens in the same sequence). But the original “Attention Is All You Need” paper used two stacks: an encoder and a decoder, connected by cross-attention.

Encoder: understand the input

The encoder reads the entire input sequence (e.g. an English sentence) using bidirectional self-attention — every token can see every other token, including future ones. This is ideal for understanding: to know what “it” refers to, you want to see the full sentence, not just the words before “it.”

Each encoder layer: Self-attention → FFN (6 layers in the original paper).

Decoder: generate the output

The decoder generates the output sequence (e.g. a French translation) one token at a time. It has two attention sublayers per layer:

  1. Masked self-attention: each token can only see itself and earlier tokens (no peeking ahead — the causal mask we covered in §05).
  2. Cross-attention: the decoder’s Query attends to the encoder’s Key and Value. This is how the output side “reads” the input while writing.

Each decoder layer: Masked self-attention → Cross-attention → FFN (6 layers in the original paper).

Cross-attention explained

Cross-attention is what makes translation work: the decoder asks “which parts of the input sentence are relevant to the word I’m about to write?” and retrieves information from the encoder’s representation.

The key difference from self-attention: Q comes from the decoder, but K and V come from the encoder. The encoder output is fixed (the whole input is processed once); the decoder queries it at each generation step.

Architecture

Encoder-decoder with cross-attention

Encoder ×6 — reads input
Input embeddings + positional encoding
Self-attentionEvery token sees all others
Feed-forward networkProcess individually
Encoder output → to cross-attention K, V
Decoder ×6 — writes output
Output embeddings + positional encoding
Masked self-attentionNo peeking ahead
Cross-attentionQueries the encoder output
Feed-forward → OutputPredict next token
Hover or tap any block to see what it does. The key difference: cross-attention bridges the two stacks.
Q: If GPT-style models dropped the encoder, why did the original paper use one?
The original Transformer was designed for translation, which is naturally encoder-decoder: read the whole input, then write the output. Decoder-only models (GPT) trade the ability to "see the whole input" for simplicity and scale — they process everything as one long sequence. Both approaches have pros and cons: encoder-decoder is better for tasks where you need to fully understand input before generating (translation, summarization), while decoder-only scales more easily and handles open-ended generation naturally.
Simple

Encoder reads text all at once. Decoder writes text one word at a time, looking back at the encoder for context.

Practical

Cross-attention is why T5, BART, and early translation models outperform decoder-only on tasks requiring full input comprehension.

Advanced

Cross-attention queries the encoder's output at every decoder layer. The encoder representation is computed once; the decoder's queries evolve layer by layer.

Why is encoder self-attention bidirectional?

Understanding requires full context. To know what "it" refers to in "the cat sat on the mat and then it slept," you need to see the entire sentence, including "slept" after "it." Bidirectional attention gives the encoder this full view.

Does cross-attention add many parameters?

Yes — each cross-attention sublayer adds its own W_q, W_k, W_v and W_o matrices. This is roughly 25% more parameters per decoder layer compared to self-attention alone, which is one reason decoder-only models are more parameter-efficient for their size.

Do modern models ever use cross-attention?

Yes! Multimodal models (LLaVA, GPT-4V) use cross-attention to connect vision encoders to language decoders. Tool-use and RAG systems can be seen as a form of cross-attention too — retrieved documents are "encoded" and then attended to during generation.

Is encoder-decoder always better than decoder-only?

No. Decoder-only models handle arbitrary tasks (chat, coding, reasoning) naturally. Encoder-decoder models excel when the task has a clear input→output structure. For most modern use cases, decoder-only wins on flexibility and scaling.

How does cross-attention differ from self-attention during inference?

The encoder runs once on the full input. The decoder then generates token by token — at each step, it queries the same fixed encoder K,V cache. This is efficient because the encoder runs once, not per token.

Can a decoder-only model simulate cross-attention?

Yes — by prepending the input to the output (e.g. "English: the cat sat\nFrench: le chat s'est assis"), the decoder can attend to the input through self-attention. This is called prefixLM and is how T5 works in decoder-only mode. It's less efficient than true cross-attention but simpler to implement.

English → German translation quality (BLEU, higher is better)

The Transformer vs. the previous best

Source: Table 2 of the paper. The orange bar is the Transformer (big).

And the cost? The big model trained in about 3.5 days on 8 GPUs — a small fraction of what competing models needed. The base model reached state-of-the-art in roughly 12 hours. Crucially, it also generalized: applied to a totally different task (English grammar parsing), it again performed near the top without special tuning.

11 · Why it changed everything

The paper that quietly started a revolution

In 2017 this looked like a smart improvement to machine translation. Today we know it was the foundation of nearly all modern AI.

Because the Transformer dropped the “one word at a time” rule, it could be trained on enormous amounts of text using thousands of processors at once. That scalability is exactly what made today’s large language models possible.

The “GPT” in ChatGPT stands for Generative Pre-trained Transformer. BERT, the model behind years of Google Search improvements, is a Transformer. So are the models that generate images, write code, transcribe speech, and fold proteins. They are all descendants of the simple idea in this paper: let the pieces of your input pay attention to each other, and that is enough.

Attention, it turned out, really was all you needed.

11 → Practice

Now: theory is done — encoder-decoder, decoder-only, paper results, the revolution. Time to build your own attention head, see exactly how QKV works with real numbers, and run a full tiny transformer in your browser.


Practical: Build your own attention

See it, design it, tweak it

1. Attention with real numbers

Below is a small sentence with mock embeddings. Pick a query word and watch the dot product, scaling, and softmax calculation unfold — exactly as it happens inside a real model.

Attention calculator

Pick a query word

Each word has a 4-dim embedding. Click a word to see its Query projected via W_q, then compute dot product against every word's Key (via W_k). The scores are scaled by √d, then softmax gives the final attention distribution.

Click a word to see the attention distribution.

2. Design your own attention head

Here you can create an attention pattern by hand and test it against the model’s learned attention — see what happens when you force a specific rule.

Design your own head

Force a focusing rule and score it

Pick a preset or build your own with the sliders below. The pattern is previewed on the canvas. Click Score this head to compare its prediction loss against the model's learned attention. Lower loss = better head.

looks back: 1 word(s)
sharp focusmediumwide spread
your head's attention pattern
Results (lower loss is better)

3. Sequential vs parallel reading

Watch how an RNN must crawl through a sentence one word at a time, while a Transformer reads every word in parallel — the core insight behind the paper’s title.

Try it

Reading one word at a time vs. all at once

Watch how an old model (RNN) must crawl through a sentence in order, while a Transformer looks at every word in the same instant.

"The cat sat on the warm mat"
Press a button above.

4. Full interactive transformer

This is the whole journey, running for real in your browser on text you provide. A genuine tiny Transformer: it tokenizes your text, learns embeddings, runs multi-head masked attention, pre-trains itself to predict the next word, and then gets fine-tuned into a model that answers questions. Nothing is pre-loaded — every weight is learned live, in front of you.

Go through the steps in order. Each one unlocks the next.

Step 1 · your training text

Give the model something to read

This is the raw material the model learns language from (its "pre-training data"). Keep sentences simple and repetitive so a tiny model can find the patterns. Edit it freely, then build the dataset.

waiting for your text
Step 2 · tokenize & vocabulary

Turn words into numbered tokens

The model can't read letters, so every unique word becomes a numbered token. The full list of tokens is the vocabulary. Three special tokens are added too: <q> and <a> (to mark questions and answers later) and <end>.

One sentence becomes a list of token ids:
Vocabulary (word : id)
Step 3 · embeddings

Give every token a vector of numbers

Each token id is mapped to a row of numbers (its embedding). Below is the entire embedding table: one row per word, one column per dimension, colour = value. Right now it is random noise — the model knows nothing yet. Watch this same picture after training to see meaning appear.

Each row is one word. Random values for now.
Step 4 · positional encoding

Stamp each slot with its position

Because the model reads all words at once, we add a unique wave-based "position fingerprint" to each word's vector so order is preserved. Type a sentence; each column below is the fingerprint added at that position.

Step 5 · attention (forward pass)

Watch the words look at each other

Here is one real forward pass: the model computes Query, Key and Value for every word, scores them, masks the future, and softmaxes into the attention grid below (row looks at column). This model has 8 heads running in parallel. Pick any one to see it alone, or select several to see their combined view. Each head is named by what it actually does (the names become meaningful after you pre-train in Step 6).

Dark upper-right triangle = the causal mask (no peeking ahead).

Design your own head

Instead of letting the model learn where to look, you can force a focusing rule and test it. Pick a preset or build your own with the sliders, preview the pattern, then score it: the model runs with every head forced to use your rule, and we compare its prediction error to the model's own learned attention. Lower loss = better head.

looks back: 1 word(s)
sharp focusmediumwide spread
your head's pattern (on the sentence above)
Results (lower loss is better)
Step 6 · pre-training

Teach it to predict the next word

Now the model reads your text over and over, each time nudging its weights to better predict the next word. The loss (how wrong it is) should drop fast. On the right, watch it try to continue a sentence and get better.

not trained yet
Loss
continuing a sentence as it learns
Step 7 · meet your base model

It can autocomplete — but it can't answer

Your pre-trained "base model" is a talented autocomplete. Give it a few words and it continues them. But ask it a direct question and it just rambles, because it was only ever taught to continue text, not to answer. That is exactly why the next step exists.

Model's next-word vote at this step (top 5)
Step 8 · fine-tuning data

Show it examples of good answers

To turn the autocomplete into an assistant, we give it example question | answer pairs. The model is wrapped in a format it can recognise: <q> question <a> answer <end>. One pair per line, with a vertical bar between question and answer.

Step 9 · fine-tuning

Continue training — on the task this time

Fine-tuning does not start from scratch. It keeps all the language the base model already learned and gently nudges it (a smaller learning rate) to follow the question-answer format. The loss on the answers should plunge toward zero.

base model loaded
Fine-tuning loss (answers only)
Step 10 · your assistant

Ask it a question

The same model that could only autocomplete now answers in the format it was fine-tuned on. Try the questions you trained it on, and then try slight variations to see how much (or how little) a tiny model can generalise.

Building the answer one word at a time (top 5 each step)
This is a real but deliberately small model (32-dim embeddings, 2 heads, one Transformer block) trained on a handful of sentences, so it memorises more than it generalises. The exact same machinery — tokenize, embed, attend, pre-train, fine-tune — scaled to billions of weights and much of the internet, is what produces ChatGPT and its peers. You just did the whole pipeline, end to end.
Summary

The full pipeline, in one breath

Internet text → broken into tokens → each token gets a vector (embedding) + a position fingerprint (sine/cosine waves) → tokens exchange info via attention (QKV) → each token privately processes what it learned (MLP) → repeat 32–96 times → probability distribution over next word → fine-tuned on Q&A data → your assistant.

Every number inside started as random noise. Every behavior — grammar, facts, reasoning — emerged from the single goal of predicting the next word better.

The only thing between random noise and an LLM is a lot of data, a lot of compute, and the humble equation $y = W \cdot x + b$ repeated millions of times.


What next?