Emmanuel Akinfulubi

How a language model works all 5 written

Tokens, next-word prediction, attention and a transformer block, from nothing.

17 Tokenization, and why it explains so many bugs

A model never sees your text. It sees a list of integers, and the thing that produced them is a lookup table built by counting character pairs in a corpus that was mostly English. That table is frozen before training starts. Every quirk in this lesson — letter counting, reversing strings, digits, the price of writing in Greek — is a consequence of it, not of the neural network at all.

What byte pair encoding actually does

Start with every word spelled out as single characters. Count every adjacent pair. Merge the most frequent pair into one new symbol. Repeat. That is the whole algorithm, and the merges it learns are the vocabulary.

Byte pair encoding, ten merges, every one printed

ran at build time
# Byte pair encoding, trained from nothing on a tiny corpus.
CORPUS = ("low low low low low lowest lowest "
          "newer newer newer newer newer newer "
          "wider wider wider new new")


def word_freqs(text):
    freqs = {}
    for word in text.split():
        freqs[word] = freqs.get(word, 0) + 1
    return freqs


def start_splits(freqs):
    """Every word starts life as single characters, plus an end-of-word marker."""
    return {tuple(word) + ("_",): n for word, n in freqs.items()}


def pair_counts(splits):
    counts = {}
    for symbols, n in splits.items():
        for a, b in zip(symbols, symbols[1:]):
            counts[(a, b)] = counts.get((a, b), 0) + n
    return counts


def apply_merge(symbols, a, b):
    """Replace every adjacent a, b with the single symbol a + b."""
    out = []
    i = 0
    while i < len(symbols):
        if i + 1 < len(symbols) and symbols[i] == a and symbols[i + 1] == b:
            out.append(a + b)
            i += 2
        else:
            out.append(symbols[i])
            i += 1
    return tuple(out)


def train_bpe(text, rounds):
    splits = start_splits(word_freqs(text))
    merges = []
    for _ in range(rounds):
        counts = pair_counts(splits)
        if not counts:
            break
        # sorted() first, so a tie is broken alphabetically rather than by dict order.
        # Without it, the vocabulary depends on insertion order and is not reproducible.
        best = max(sorted(counts), key=lambda pair: counts[pair])
        merges.append((best, counts[best]))
        splits = {apply_merge(s, best[0], best[1]): n for s, n in splits.items()}
    return merges, splits


merges, splits = train_bpe(CORPUS, 10)

print("corpus words:", word_freqs(CORPUS))
print()
print("  round   merged pair        seen   new symbol")
for i, ((a, b), n) in enumerate(merges, start=1):
    print("  {:5}   {:6} + {:6}  {:4}   {}".format(i, repr(a), repr(b), n, repr(a + b)))

print()
print("how each word is spelled after ten merges:")
for symbols, n in sorted(splits.items()):
    print("   {:3} x  {}".format(n, list(symbols)))

frequencies = [n for _, n in merges]
print()
print("vocabulary:", sorted(set(sym for symbols in splits for sym in symbols)))

test("the first merge is the most frequent adjacent pair in the corpus", merges[0][0], ("e", "r"))
test("ten rounds learn exactly ten merges", len(merges), 10)
test("each merge is at least as frequent as the one after it",
     frequencies, sorted(frequencies, reverse=True))
test("the most common word in the corpus ends up as one symbol", ("newer_",) in splits, True)
test("a rarer word is still several", ("low", "e", "s", "t", "_") in splits, True)

What it printed, when the build ran it

corpus words: {'low': 5, 'lowest': 2, 'newer': 6, 'wider': 3, 'new': 2}

  round   merged pair        seen   new symbol
      1   'e'    + 'r'        9   'er'
      2   'er'   + '_'        9   'er_'
      3   'e'    + 'w'        8   'ew'
      4   'n'    + 'ew'       8   'new'
      5   'l'    + 'o'        7   'lo'
      6   'lo'   + 'w'        7   'low'
      7   'new'  + 'er_'      6   'newer_'
      8   'low'  + '_'        5   'low_'
      9   'd'    + 'er_'      3   'der_'
     10   'i'    + 'der_'     3   'ider_'

how each word is spelled after ten merges:
     2 x  ['low', 'e', 's', 't', '_']
     5 x  ['low_']
     2 x  ['new', '_']
     6 x  ['newer_']
     3 x  ['w', 'ider_']

vocabulary: ['_', 'e', 'ider_', 'low', 'low_', 'new', 'newer_', 's', 't', 'w']
ok   the first merge is the most frequent adjacent pair in the corpus
ok   ten rounds learn exactly ten merges
ok   each merge is at least as frequent as the one after it
ok   the most common word in the corpus ends up as one symbol
ok   a rarer word is still several

Read the merge table top to bottom and you are reading the vocabulary being built. e plus r first, because newer and wider between them use it nine times. Then er plus the end marker, which is what makes the suffix -er a unit. By round seven the whole word newer is a single symbol, because the corpus contains it six times. Frequency buys you a short encoding, and nothing else does.

The end-of-word marker matters more than it looks. Without it, the symbol low learned from low and the low inside lowest are the same thing, and the model cannot tell a whole word from a prefix. Real tokenizers do the same job by attaching the leading space to the token, which is why " cat" and "cat" are two different tokens with two different ids — and why a prompt that ends in a trailing space quietly changes what the model predicts next.

Encoding: replay the merges, in order

Training produced an ordered list. Encoding a new word means starting from characters and replaying that list, in exactly the order it was learned. The order is part of the tokenizer.

Encoding, and the cost of being a rare word

ran at build time
# Encoding with the merges we just learned. Same list, same order, every time.
CORPUS = ("low low low low low lowest lowest "
          "newer newer newer newer newer newer "
          "wider wider wider new new")

MERGES = [("e", "r"), ("er", "_"), ("e", "w"), ("n", "ew"), ("l", "o"),
          ("lo", "w"), ("new", "er_"), ("low", "_"), ("d", "er_"), ("i", "der_")]


def apply_merge(symbols, a, b):
    out = []
    i = 0
    while i < len(symbols):
        if i + 1 < len(symbols) and symbols[i] == a and symbols[i + 1] == b:
            out.append(a + b)
            i += 2
        else:
            out.append(symbols[i])
            i += 1
    return tuple(out)


def encode(word, merges):
    """Characters first, then replay every merge in the order it was learned."""
    symbols = tuple(word) + ("_",)
    for a, b in merges:
        symbols = apply_merge(symbols, a, b)
    return list(symbols)


for word in ("low", "newer", "lower", "newest", "widen", "glow"):
    pieces = encode(word, MERGES)
    seen = "in the corpus" if word in CORPUS.split() else "never seen"
    print("  {:8} -> {:38} {} token(s)   {}".format(word, str(pieces), len(pieces), seen))

print()
print("Order is part of the tokenizer. Replay the merges backwards and you get")
print("a different spelling of the same word:")
print("  newer, merges in order    :", encode("newer", MERGES))
print("  newer, merges reversed    :", encode("newer", list(reversed(MERGES))))

frequent = encode("newer", MERGES)
rare = encode("newest", MERGES)

test("a word the tokenizer saw six times costs one token", len(frequent), 1)
test("a word it never saw costs more", len(rare) > len(frequent), True)
test("lower is not a token, it is low plus the -er suffix",
     encode("lower", MERGES), ["low", "er_"])
test("nothing is lost, the pieces still spell the word",
     "".join(encode("newest", MERGES)).replace("_", ""), "newest")
test("replaying the merges out of order changes the answer",
     encode("newer", MERGES) == encode("newer", list(reversed(MERGES))), False)
test("a character the tokenizer never saw survives as itself",
     "g" in encode("glow", MERGES), True)

What it printed, when the build ran it

  low      -> ['low_']                               1 token(s)   in the corpus
  newer    -> ['newer_']                             1 token(s)   in the corpus
  lower    -> ['low', 'er_']                         2 token(s)   never seen
  newest   -> ['new', 'e', 's', 't', '_']            5 token(s)   never seen
  widen    -> ['w', 'i', 'd', 'e', 'n', '_']         6 token(s)   never seen
  glow     -> ['g', 'low_']                          2 token(s)   never seen

Order is part of the tokenizer. Replay the merges backwards and you get
a different spelling of the same word:
  newer, merges in order    : ['newer_']
  newer, merges reversed    : ['n', 'ew', 'er', '_']
ok   a word the tokenizer saw six times costs one token
ok   a word it never saw costs more
ok   lower is not a token, it is low plus the -er suffix
ok   nothing is lost, the pieces still spell the word
ok   replaying the merges out of order changes the answer
ok   a character the tokenizer never saw survives as itself

That table is the practical shape of every tokenizer you will meet. Common words are one token. Anything unusual — a surname, a product code, a typo, an identifier from your codebase — falls apart into pieces, and you pay per piece. It is also why the same paragraph rewritten in plainer words is genuinely cheaper to send.

Why it cannot count the letters in "strawberry"

Here is the payoff. The model receives integers. The letters inside each integer are not available to it any more than the pixels of this page are available to you as voltages. To count the r in a word, the model has to have memorised the spelling of each token id, in its weights, from text.

Three integers, ten letters, and the count that goes wrong

ran at build time
# What the model actually receives: integers. Letters are not in there.
# This vocabulary is hand-written to match the way a real tokenizer splits
# these words, and applied greedily, longest piece first.
VOCAB = {
    "str": 2, "aw": 3, "berry": 4, "berries": 5, "count": 6,
    "the": 7, "letters": 8, "in": 9, " ": 10,
    "a": 20, "b": 21, "e": 22, "h": 23, "i": 24, "r": 25, "s": 26, "t": 27,
    "w": 28, "y": 29, "d": 30, "o": 31, "n": 32, "c": 33, "l": 34, "u": 35,
}


def encode(text, vocab):
    """Greedy longest match. The real thing is fancier; the effect is the same."""
    pieces = []
    i = 0
    while i < len(text):
        for size in range(min(8, len(text) - i), 0, -1):
            piece = text[i:i + size]
            if piece in vocab:
                pieces.append(piece)
                i += size
                break
        else:
            raise ValueError("no token covers {!r}".format(text[i]))
    return pieces


WORD = "strawberry"
pieces = encode(WORD, VOCAB)
ids = [VOCAB[p] for p in pieces]

print("the word            :", WORD, " ", len(WORD), "letters")
print("its pieces          :", pieces)
print("what the model gets :", ids)
print()
print("letter r in the word       :", WORD.count("r"))
print("pieces containing an r     :", sum(1 for p in pieces if "r" in p))
print("A count done over the ids answers 2. The answer is 3.")

print()
print("reversing, two ways:")
print("  reverse the letters :", WORD[::-1])
print("  reverse the tokens  :", "".join(reversed(pieces)))
print("A model rearranges tokens. Only one of those lines is a reversal.")

letters = WORD.count("r")
per_piece = sum(1 for p in pieces if "r" in p)

test("ten letters arrive as three tokens", [len(WORD), len(pieces)], [10, 3])
test("not one of those tokens is a single letter", all(len(p) > 1 for p in pieces), True)
test("the word really does contain three r", letters, 3)
test("counting over tokens instead of letters gives the wrong answer",
     per_piece == letters, False)
test("reversing the token order does not reverse the word",
     "".join(reversed(pieces)) == WORD[::-1], False)
test("the reversal a reader wants needs the spelling, which the ids do not carry",
     WORD[::-1], "yrrebwarts")
test("the pieces do still spell the word, if you look each id up", "".join(pieces), WORD)

What it printed, when the build ran it

the word            : strawberry   10 letters
its pieces          : ['str', 'aw', 'berry']
what the model gets : [2, 3, 4]

letter r in the word       : 3
pieces containing an r     : 2
A count done over the ids answers 2. The answer is 3.

reversing, two ways:
  reverse the letters : yrrebwarts
  reverse the tokens  : berryawstr
A model rearranges tokens. Only one of those lines is a reversal.
ok   ten letters arrive as three tokens
ok   not one of those tokens is a single letter
ok   the word really does contain three r
ok   counting over tokens instead of letters gives the wrong answer
ok   reversing the token order does not reverse the word
ok   the reversal a reader wants needs the spelling, which the ids do not carry
ok   the pieces do still spell the word, if you look each id up

So the letter-counting failure is not a reasoning failure. The model is being asked a question about a representation it does not have. It answers from a statistical memory of how often text near the token berry mentioned the letter r, which is a terrible way to count and works about as well as you would expect. The same argument explains reversing a string, spelling a word backwards, counting words in a paragraph, and finding the third character of a name.

This is also why the fix works. Ask the model to write the word out with spaces between the letters first — s t r a w b e r r y — and each letter becomes its own token, because that spacing does not appear in the merge table. Now the count is a count over tokens, which is something the model can do. "Think step by step" earns its reputation partly by accident: it converts problems about characters into problems about tokens.

Numbers, and why arithmetic is harder than it should be

The tokenizer has no idea what a number is. It merged whatever digit strings were frequent in its corpus, so numbers of the same length can arrive as wildly different token counts, and the digits of two numbers you want to add do not line up.

The same four digits, one token or three

ran at build time
# Numbers. The tokenizer never learned arithmetic, only what it saw often.
# A corpus where some numbers are common and others are not: the shape of the
# web text a real tokenizer is fitted to.
CORPUS = ("2020 2020 2020 2020 2020 2020 2020 2020 "
          "2019 2019 2019 2019 2019 "
          "100 100 100 100 100 100 "
          "10 10 10 10 12 12 12 ")


def word_freqs(text):
    freqs = {}
    for word in text.split():
        freqs[word] = freqs.get(word, 0) + 1
    return freqs


def pair_counts(splits):
    counts = {}
    for symbols, n in splits.items():
        for a, b in zip(symbols, symbols[1:]):
            counts[(a, b)] = counts.get((a, b), 0) + n
    return counts


def apply_merge(symbols, a, b):
    out = []
    i = 0
    while i < len(symbols):
        if i + 1 < len(symbols) and symbols[i] == a and symbols[i + 1] == b:
            out.append(a + b)
            i += 2
        else:
            out.append(symbols[i])
            i += 1
    return tuple(out)


def train_bpe(text, rounds):
    splits = {tuple(w): n for w, n in word_freqs(text).items()}
    merges = []
    for _ in range(rounds):
        counts = pair_counts(splits)
        if not counts:
            break
        best = max(sorted(counts), key=lambda pair: counts[pair])
        merges.append(best)
        splits = {apply_merge(s, best[0], best[1]): n for s, n in splits.items()}
    return merges


def encode(text, merges):
    symbols = tuple(text)
    for a, b in merges:
        symbols = apply_merge(symbols, a, b)
    return list(symbols)


MERGES = train_bpe(CORPUS, 6)
print("merges learned, in order:", ["".join(m) for m in MERGES])
print()
print("  number    tokens                          how many")
for number in ("2020", "2019", "2021", "1000", "100", "137"):
    pieces = encode(number, MERGES)
    print("  {:8}  {:30}  {}".format(number, str(pieces), len(pieces)))

print()
print("Four digits cost one token or three, decided entirely by how often the")
print("tokenizer happened to see them. Column arithmetic needs digits lined up.")
print("These are not lined up, and the model has to learn the alignment itself.")

y2020 = encode("2020", MERGES)
y2021 = encode("2021", MERGES)

test("a year the tokenizer saw eight times is a single token", len(y2020), 1)
test("the next year along is not", len(y2021) > 1, True)
test("two numbers of the same length get different token counts",
     len(y2020) == len(y2021), False)
test("so their digits do not line up with each other", y2020 == y2021, False)
test("nothing is corrupted, only chopped oddly", "".join(y2021), "2021")
test("a number it never saw is spelled out digit by digit",
     encode("137", MERGES), ["1", "3", "7"])

What it printed, when the build ran it

merges learned, in order: ['20', '10', '2020', '100', '19', '2019']

  number    tokens                          how many
  2020      ['2020']                        1
  2019      ['2019']                        1
  2021      ['20', '2', '1']                3
  1000      ['100', '0']                    2
  100       ['100']                         1
  137       ['1', '3', '7']                 3

Four digits cost one token or three, decided entirely by how often the
tokenizer happened to see them. Column arithmetic needs digits lined up.
These are not lined up, and the model has to learn the alignment itself.
ok   a year the tokenizer saw eight times is a single token
ok   the next year along is not
ok   two numbers of the same length get different token counts
ok   so their digits do not line up with each other
ok   nothing is corrupted, only chopped oddly
ok   a number it never saw is spelled out digit by digit

This is why a model can be fluent about calculus and wrong about 4123 + 8991. It is not doing arithmetic on numbers, it is predicting tokens after a sequence of unevenly chopped digit groups. It is also why newer tokenizers deliberately split digits into fixed groups of one or three, and why the right engineering answer is to hand the arithmetic to a tool rather than to the model. If a number matters — a total, an id, a version — validate it, do not trust it.

Bytes, and why other languages cost more

A real tokenizer starts from UTF-8 bytes rather than characters, so it can encode anything without ever meeting an unknown symbol. But the merges were learned from a corpus that was overwhelmingly English. Text in a script that corpus barely contained falls back to almost one token per byte, and non-Latin scripts take two or three bytes per character.

The same sentence in three languages, and the bill for each

ran at build time
# A real tokenizer works on UTF-8 bytes, and learned its merges from a corpus
# that was mostly English. Both halves of that sentence have a price.
ENGLISH_CORPUS = ("the cat sat on the mat and the cat sat on the hat " * 8 +
                  "the man on the mat sat and the cat ran on the sand " * 8)


def chunks(text):
    """Keep merges inside words. A real tokenizer does this with a regex, and
    attaches the leading space to the word, exactly as this does."""
    parts = text.split(" ")
    return [parts[0]] + [" " + p for p in parts[1:]]


def to_symbols(text):
    """Start from raw UTF-8 bytes, so any text on earth can be encoded."""
    return tuple((b,) for b in text.encode("utf-8"))


def pair_counts(sequences):
    counts = {}
    for symbols in sequences:
        for a, b in zip(symbols, symbols[1:]):
            counts[(a, b)] = counts.get((a, b), 0) + 1
    return counts


def apply_merge(symbols, a, b):
    out = []
    i = 0
    while i < len(symbols):
        if i + 1 < len(symbols) and symbols[i] == a and symbols[i + 1] == b:
            out.append(a + b)
            i += 2
        else:
            out.append(symbols[i])
            i += 1
    return tuple(out)


def train(text, rounds):
    sequences = [to_symbols(c) for c in chunks(text)]
    merges = []
    for _ in range(rounds):
        counts = pair_counts(sequences)
        if not counts:
            break
        best = max(sorted(counts), key=lambda pair: counts[pair])
        merges.append(best)
        sequences = [apply_merge(s, best[0], best[1]) for s in sequences]
    return merges


def encode(text, merges):
    out = []
    for chunk in chunks(text):
        symbols = to_symbols(chunk)
        for a, b in merges:
            symbols = apply_merge(symbols, a, b)
        out.extend(symbols)
    return out


def show(symbols):
    """Readable where the token is whole text, raw bytes where it is not."""
    out = []
    for s in symbols:
        raw = bytes(s)
        try:
            out.append(raw.decode("utf-8"))
        except UnicodeDecodeError:
            out.append(repr(raw))
    return out


MERGES = train(ENGLISH_CORPUS, 40)

SAMPLES = (
    ("English", "the cat sat on the mat"),
    ("Greek", "η γάτα κάθισε στο χαλί"),
    ("Japanese", "猫がマットの上に座った"),
)

print("40 merges, learned from English text only.")
print()
print("  language   characters   utf-8 bytes   tokens   tokens per character")
ratio = {}
for name, text in SAMPLES:
    tokens = encode(text, MERGES)
    ratio[name] = len(tokens) / len(text)
    print("  {:9}  {:10}   {:11}   {:6}   {:.2f}".format(
        name, len(text), len(text.encode("utf-8")), len(tokens), ratio[name]))

print()
print("English  :", show(encode(SAMPLES[0][1], MERGES)))
print("Greek    :", show(encode(SAMPLES[1][1], MERGES))[:8], "... and 32 more")
print("Japanese :", show(encode(SAMPLES[2][1], MERGES))[:6], "... and 27 more")
print()
print("Every Greek and Japanese token there is a single raw byte, because no")
print("merge in the table ever saw those bytes. Same sentence, three times the bill.")

rare = "the cat sat on the trampoline"
rare_tokens = encode(rare, MERGES)
print()
print("and token count is not word count:")
print("  ", rare)
print("  ", show(rare_tokens))
print("   {} words, {} tokens".format(len(rare.split()), len(rare_tokens)))

test("a common English word is one whole token", show(encode("the", MERGES)), ["the"])
test("the same sentence costs more per character in Greek than in English",
     ratio["Greek"] > ratio["English"], True)
test("Japanese costs three tokens per character, one per UTF-8 byte",
     close(ratio["Japanese"], 3.0), True)
test("token count is not word count", len(rare_tokens) == len(rare.split()), False)
test("one rare word is most of the bill for that sentence", len(rare_tokens) > 10, True)
test("nothing is lost, the bytes still decode to the original text",
     bytes(b for s in encode(SAMPLES[1][1], MERGES) for b in s).decode("utf-8"),
     SAMPLES[1][1])

What it printed, when the build ran it

40 merges, learned from English text only.

  language   characters   utf-8 bytes   tokens   tokens per character
  English            22            22        6   0.27
  Greek              22            40       40   1.82
  Japanese           11            33       33   3.00

English  : ['the', ' cat', ' sat', ' on', ' the', ' mat']
Greek    : ["b'\\xce'", "b'\\xb7'", ' ', "b'\\xce'", "b'\\xb3'", "b'\\xce'", "b'\\xac'", "b'\\xcf'"] ... and 32 more
Japanese : ["b'\\xe7'", "b'\\x8c'", "b'\\xab'", "b'\\xe3'", "b'\\x81'", "b'\\x8c'"] ... and 27 more

Every Greek and Japanese token there is a single raw byte, because no
merge in the table ever saw those bytes. Same sentence, three times the bill.

and token count is not word count:
   the cat sat on the trampoline
   ['the', ' cat', ' sat', ' on', ' the', ' ', 't', 'r', 'a', 'm', 'p', 'o', 'l', 'i', 'n', 'e']
   6 words, 16 tokens
ok   a common English word is one whole token
ok   the same sentence costs more per character in Greek than in English
ok   Japanese costs three tokens per character, one per UTF-8 byte
ok   token count is not word count
ok   one rare word is most of the bill for that sentence
ok   nothing is lost, the bytes still decode to the original text

Read the ratio column and you have the answer to several practical questions at once. A context window measured in tokens holds roughly three times less Japanese than English. A per-token price is a higher per-sentence price for a Greek user. A chunk size of 500 tokens is a much smaller piece of text in Hindi than in French. None of that is a policy decision anyone made; it falls out of which corpus the merges were counted on.

  • Cost and context. Budget in tokens, not words, and measure the ratio on your own real traffic rather than assuming the usual "one token is about four characters".
  • Chunking for retrieval. A fixed token size cuts non-English text into much shorter passages. If retrieval quality drops for one language, check this first.
  • Character-level tasks. Counting, reversing, spelling, character offsets, regex-like edits. Do them in code, not in the model.
  • Numbers and identifiers. Anything you would be unhappy to see off by one digit should be validated after generation, or produced by a tool call.

What a real project would use instead

Nobody trains their own tokenizer for an application; you load the one the model was trained with, and it is not optional — a different tokenizer means a different meaning for every integer, and the model produces nonsense. The library is fast because the merges live in a compiled trie rather than the loop above, but the algorithm is the one you just wrote.

Loading the tokenizer the model was actually trained with

shown, not run
# The same three operations with the tokenizers a real project uses.
# NOT RUN HERE - tiktoken and transformers are not installed.

import tiktoken

enc = tiktoken.get_encoding("cl100k_base")     # the tokenizer, not the model

ids = enc.encode("the cat sat on the mat")     # -> [1820, 8415, 7731, 389, 279, 5634]
enc.decode(ids)                                # back to the text, exactly
len(enc.encode(text))                          # what you are billed for

# The letter-counting problem, visible:
[enc.decode([i]) for i in enc.encode("strawberry")]      # ['str', 'aw', 'berry']
[enc.decode([i]) for i in enc.encode("s t r a w b e r r y")]   # one token per letter

# The leading-space trap, visible:
enc.encode("cat")                              # one id
enc.encode(" cat")                             # a DIFFERENT id

# The non-English cost, measured on your own traffic rather than assumed:
en = len(enc.encode("the cat sat on the mat"))
ja = len(enc.encode("猫がマットの上に座った"))
ratio = ja / en                                # budget with this, not with a rule of thumb

# For an open model, the tokenizer ships with the weights and must match them.
from transformers import AutoTokenizer

tok = AutoTokenizer.from_pretrained("meta-llama/Llama-3.1-8B")
tok("the cat sat on the mat")["input_ids"]
tok.apply_chat_template(messages, tokenize=False)   # the special tokens matter too

tiktoken and transformers are not installed in this environment, so this panel was never executed and the token ids in the comments are quoted from documentation rather than measured here. The algorithm behind every call in it was written out and asserted in plain Python above.

The mismatch bug. Counting tokens with one tokenizer and sending them to a model trained on another is a silent failure, not a loud one. Your length checks pass, your cost estimate is wrong, your truncation cuts in the wrong place, and the output is merely a bit worse than it should be. Pin the tokenizer to the model, in the same config, and assert the pairing in a test. When a model version changes, the tokenizer may change too.

18 Predicting the next word

A language model answers one question: given what came before, what comes next. You can build one with nothing but a dictionary and division — count which words follow which, then normalise. That model works, is genuinely useful, and fails in four specific ways. Those four failures are the reason the rest of this part of the course exists.

A bigram model is a table of counts

A bigram model looks one word back. Count every adjacent pair in a corpus, divide each row by its total, and you have a probability distribution over the next word for every word in the vocabulary. There is no training loop, no loss and no gradient — counting is the whole thing.

A bigram model, trained by counting, generating from a seed

ran at build time
# A bigram language model. Count pairs, divide, and that is the whole model.
import random

CORPUS = """the cat sat on the mat
the cat ate the fish
the dog sat on the log
the dog ate the bone
the cat sat on the log
the dog ate the fish"""

START = "<s>"
END = "</s>"


def sentences(text):
    """Markers at both ends, so 'what starts a sentence' and 'when to stop' are
    ordinary predictions rather than special cases."""
    return [[START] + line.split() + [END] for line in text.strip().split("\n")]


def bigram_counts(rows):
    counts = {}
    for row in rows:
        for a, b in zip(row, row[1:]):
            counts.setdefault(a, {})
            counts[a][b] = counts[a].get(b, 0) + 1
    return counts


def to_probs(counts):
    model = {}
    for a, nexts in counts.items():
        total = sum(nexts.values())
        model[a] = {b: n / total for b, n in nexts.items()}
    return model


def sample(dist, rng):
    """Inverse transform sampling, over a sorted key order so it is reproducible."""
    r, running = rng.random(), 0.0
    for word, p in sorted(dist.items()):
        running += p
        if r < running:
            return word
    return sorted(dist)[-1]


def generate(model, rng, limit=12):
    word, out = START, []
    while len(out) < limit:
        word = sample(model[word], rng)
        if word == END:
            break
        out.append(word)
    return out


rows = sentences(CORPUS)
counts = bigram_counts(rows)
model = to_probs(counts)

print("what follows 'the', and how often:")
for word, n in sorted(counts["the"].items(), key=lambda kv: -kv[1]):
    print("   the {:6} {:3} times   p = {:.3f}".format(word, n, model["the"][word]))

print()
print("what follows 'sat':", dict(sorted(counts["sat"].items())))
print("what follows 'ate':", dict(sorted(counts["ate"].items())))

rng = random.Random(42)
print()
print("six sentences generated from those counts alone:")
for _ in range(6):
    print("   ", " ".join(generate(model, rng)))

first_run = [generate(model, random.Random(42)) for _ in range(1)]
repeat = [generate(model, random.Random(42)) for _ in range(1)]

test("every row of the model is a probability distribution",
     all(close(sum(d.values()), 1.0) for d in model.values()), True)
test("six different words follow 'the' in this corpus",
     sorted(model["the"]), ["bone", "cat", "dog", "fish", "log", "mat"])
test("'on' is followed by 'the' every single time", close(model["on"]["the"], 1.0), True)
test("the same seed replays the same sentence", first_run, repeat)
test("the model never invents a word the corpus did not contain",
     all(w in CORPUS.split() for row in first_run for w in row), True)
test("counting is the whole of training: no loss, no gradient, no epochs",
     sum(sum(d.values()) for d in counts.values()), sum(len(r) - 1 for r in rows))

What it printed, when the build ran it

what follows 'the', and how often:
   the cat      3 times   p = 0.250
   the dog      3 times   p = 0.250
   the fish     2 times   p = 0.167
   the log      2 times   p = 0.167
   the mat      1 times   p = 0.083
   the bone     1 times   p = 0.083

what follows 'sat': {'on': 3}
what follows 'ate': {'the': 3}

six sentences generated from those counts alone:
    the bone
    the fish
    the cat sat on the dog ate the fish
    the fish
    the log
    the cat sat on the cat sat on the fish
ok   every row of the model is a probability distribution
ok   six different words follow 'the' in this corpus
ok   'on' is followed by 'the' every single time
ok   the same seed replays the same sentence
ok   the model never invents a word the corpus did not contain
ok   counting is the whole of training: no loss, no gradient, no epochs

Read the generated sentences honestly. Locally they are fine — every pair of adjacent words is a pair that really occurs. Globally some are nonsense, because the model has already forgotten everything except the last word. "the bone" is a complete sentence as far as this model is concerned. That is not a bug in the counting; it is the definition of a bigram.

Failure one: the table is almost all zeros

A vocabulary of V words has V squared possible pairs. A real corpus contains a tiny fraction of them, and every pair it missed is assigned probability zero — not "unlikely", zero. Multiply one zero through a sentence and the whole sentence becomes impossible.

Sparsity, and the zero that destroys a sentence

ran at build time
# Sparsity. Most pairs of words never happened, and a count of zero is fatal.
import math

CORPUS = """the cat sat on the mat
the cat ate the fish
the dog sat on the log
the dog ate the bone
the cat sat on the log
the dog ate the fish"""

START = "<s>"
END = "</s>"


def sentences(text):
    return [[START] + line.split() + [END] for line in text.strip().split("\n")]


def bigram_counts(rows):
    counts = {}
    for row in rows:
        for a, b in zip(row, row[1:]):
            counts.setdefault(a, {})
            counts[a][b] = counts[a].get(b, 0) + 1
    return counts


rows = sentences(CORPUS)
counts = bigram_counts(rows)
vocab = sorted(set(w for row in rows for w in row))


def p_bigram(a, b):
    nexts = counts.get(a, {})
    total = sum(nexts.values())
    return 0.0 if total == 0 else nexts.get(b, 0) / total


def p_sentence(words):
    row = [START] + words + [END]
    p = 1.0
    for a, b in zip(row, row[1:]):
        p *= p_bigram(a, b)
    return p


possible = len(vocab) * len(vocab)
seen = sum(len(n) for n in counts.values())
print("vocabulary      :", vocab)
print("possible pairs  :", possible)
print("pairs ever seen :", seen)
print("the table is    : {:.1f}% zeros".format(100.0 * (possible - seen) / possible))

print()
print("  sentence                        probability")
for words in (["the", "cat", "sat", "on", "the", "mat"],
              ["the", "cat", "sat", "on", "the", "bone"],
              ["the", "fish", "ate", "the", "cat"],
              ["the", "cat", "sat", "on", "the", "cat"]):
    print("  {:30}  {}".format(" ".join(words), p_sentence(words)))

print()
print("Two failures in one table. 'on the bone' scores exactly as well as 'on the")
print("mat', because a bigram model has already forgotten the word 'on'. And 'the")
print("fish ate' scores zero, because that pair never happened, and one zero")
print("multiplied through the product kills the entire sentence.")

real = p_sentence(["the", "cat", "sat", "on", "the", "mat"])
odd = p_sentence(["the", "cat", "sat", "on", "the", "bone"])
unseen = p_sentence(["the", "fish", "ate", "the", "cat"])

try:
    math.log(unseen)
    logged = True
except ValueError as error:
    logged = False
    print()
    print("math.log(0.0) ->", error, " <- so there is no loss to report either")

test("a sentence from the corpus has a real probability", real > 0.0, True)
test("a sentence containing one unseen pair is given probability zero",
     close(unseen, 0.0), True)
test("the bigram model cannot tell the odd sentence from the real one",
     close(real, odd), True)
test("because it only ever looks one word back", p_bigram("the", "bone") > 0.0, True)
test("almost seven eighths of the pair table is zeros",
     close((possible - seen) / possible, 0.875), True)
test("and a zero probability has no logarithm, so the model cannot be scored",
     logged, False)

What it printed, when the build ran it

vocabulary      : ['</s>', '<s>', 'ate', 'bone', 'cat', 'dog', 'fish', 'log', 'mat', 'on', 'sat', 'the']
possible pairs  : 144
pairs ever seen : 18
the table is    : 87.5% zeros

  sentence                        probability
  the cat sat on the mat          0.013888888888888888
  the cat sat on the bone         0.013888888888888888
  the fish ate the cat            0.0
  the cat sat on the cat          0.0

Two failures in one table. 'on the bone' scores exactly as well as 'on the
mat', because a bigram model has already forgotten the word 'on'. And 'the
fish ate' scores zero, because that pair never happened, and one zero
multiplied through the product kills the entire sentence.

math.log(0.0) -> math domain error  <- so there is no loss to report either
ok   a sentence from the corpus has a real probability
ok   a sentence containing one unseen pair is given probability zero
ok   the bigram model cannot tell the odd sentence from the real one
ok   because it only ever looks one word back
ok   almost seven eighths of the pair table is zeros
ok   and a zero probability has no logarithm, so the model cannot be scored

Sparsity gets worse, not better, as the corpus grows, because the vocabulary grows with it. Ten thousand words is a hundred million possible pairs and a billion possible triples, and no corpus on earth contains most of them. This is the wall that counting hits and the reason neural language models exist: an embedding lets "fish" and "cat" share what they have in common, so a pair never seen can still be scored. A count table cannot generalise by construction — every cell is independent of every other cell.

Failure two, patched: add-k smoothing

The cheapest fix is to lie a little. Pretend you saw every possible pair k extra times before you started counting. With k = 1 it is called Laplace or add-one smoothing. Nothing is impossible any more, and every row still sums to one.

Add-k smoothing, and the accuracy it costs

ran at build time
# Add-k smoothing: pretend you saw every pair k extra times. Laplace is k = 1.
CORPUS = """the cat sat on the mat
the cat ate the fish
the dog sat on the log
the dog ate the bone
the cat sat on the log
the dog ate the fish"""

START = "<s>"
END = "</s>"


def sentences(text):
    return [[START] + line.split() + [END] for line in text.strip().split("\n")]


def bigram_counts(rows):
    counts = {}
    for row in rows:
        for a, b in zip(row, row[1:]):
            counts.setdefault(a, {})
            counts[a][b] = counts[a].get(b, 0) + 1
    return counts


rows = sentences(CORPUS)
counts = bigram_counts(rows)
vocab = sorted(set(w for row in rows for w in row))
V = len(vocab)


def p_add_k(a, b, k):
    """Every one of the V possible next words gets k free sightings."""
    if k <= 0.0:
        raise ValueError("k must be positive, or nothing is smoothed")
    nexts = counts.get(a, {})
    return (nexts.get(b, 0) + k) / (sum(nexts.values()) + k * V)


print("vocabulary size V =", V)
print()
print("  k        p(cat|the)   p(fish|the)   p(sat|the)   sums over V")
for k in (0.001, 0.01, 0.1, 1.0, 100.0):
    row = [p_add_k("the", w, k) for w in vocab]
    print("  {:7}  {:10.4f}   {:11.4f}   {:10.4f}   {:.6f}".format(
        k, p_add_k("the", "cat", k), p_add_k("the", "fish", k),
        p_add_k("the", "sat", k), sum(row)))

print()
print("'sat' never follows 'the' in this corpus. Unsmoothed it is impossible.")
print("At k = 1 it is merely unlikely, which is a claim you can take a log of.")
print("At k = 100 the counts have been drowned and everything is nearly uniform.")

unsmoothed = counts["the"].get("sat", 0) / sum(counts["the"].values())
laplace_unseen = p_add_k("the", "sat", 1.0)
laplace_seen = p_add_k("the", "cat", 1.0)
raw_seen = counts["the"]["cat"] / sum(counts["the"].values())
flat = [p_add_k("the", w, 100.0) for w in vocab]

try:
    p_add_k("the", "cat", 0.0)
    refused = False
except ValueError as error:
    refused = True
    print()
    print("k = 0 is refused:", error)

test("the smoothed row is still a distribution over the whole vocabulary",
     close(sum(p_add_k("the", w, 1.0) for w in vocab), 1.0), True)
test("a pair that never happened now has a probability above zero",
     laplace_unseen > 0.0, True)
test("and with k = 1 it is exactly one over the count plus the vocabulary size",
     close(laplace_unseen, 1.0 / (12 + 12)), True)
test("the mass had to come from somewhere, so a pair that did happen drops",
     laplace_seen < raw_seen, True)
test("unsmoothed, that same unseen pair was flatly impossible",
     close(unsmoothed, 0.0), True)
test("a very large k drowns the evidence and returns a uniform distribution",
     all(close(x, 1.0 / V, 5e-3) for x in flat), True)
test("k must be positive, or nothing has been smoothed at all", refused, True)

What it printed, when the build ran it

vocabulary size V = 12

  k        p(cat|the)   p(fish|the)   p(sat|the)   sums over V
    0.001      0.2498        0.1666       0.0001   1.000000
     0.01      0.2483        0.1658       0.0008   1.000000
      0.1      0.2348        0.1591       0.0076   1.000000
      1.0      0.1667        0.1250       0.0417   1.000000
    100.0      0.0850        0.0842       0.0825   1.000000

'sat' never follows 'the' in this corpus. Unsmoothed it is impossible.
At k = 1 it is merely unlikely, which is a claim you can take a log of.
At k = 100 the counts have been drowned and everything is nearly uniform.

k = 0 is refused: k must be positive, or nothing is smoothed
ok   the smoothed row is still a distribution over the whole vocabulary
ok   a pair that never happened now has a probability above zero
ok   and with k = 1 it is exactly one over the count plus the vocabulary size
ok   the mass had to come from somewhere, so a pair that did happen drops
ok   unsmoothed, that same unseen pair was flatly impossible
ok   a very large k drowns the evidence and returns a uniform distribution
ok   k must be positive, or nothing has been smoothed at all

Notice the trade in the first two columns. p(cat|the) falls from 0.25 to 0.17 as k goes to 1, because the probability given to pairs that never happened has to be taken from pairs that did. Smoothing does not add information. It spends accuracy on the words you saw to buy coverage of the words you did not, and k is the exchange rate. Choosing it by hand on the training set defeats the purpose; you choose it on a held-out split, which is the next section.

Add-k is the version worth writing once and then never using. Real count-based models use backoff or interpolation: if the trigram (a, b, c) was never seen, fall back to the bigram (b, c), and below that to the unigram frequency of c. That is smarter because it spends the leftover mass in proportion to what the shorter context already knows, rather than spreading it uniformly over words that were never plausible. Kneser-Ney is the refined version of the same idea, and was the state of the art until neural models arrived.

Perplexity: one number for how surprised the model was

Take the probability the model gave to each word that actually occurred. Take the log of each, average them, negate, exponentiate. The result has a reading in plain English: the effective number of equally likely words the model was choosing between at each step. Lower is better and the floor is one.

Perplexity, with a uniform model as the yardstick

ran at build time
# Perplexity. One number for "how surprised was the model, on average".
import math

CORPUS = """the cat sat on the mat
the cat ate the fish
the dog sat on the log
the dog ate the bone
the cat sat on the log
the dog ate the fish"""

HELD_OUT = """the dog sat on the mat
the cat ate the bone
the fish sat on the log"""

START = "<s>"
END = "</s>"


def sentences(text):
    return [[START] + line.split() + [END] for line in text.strip().split("\n")]


def bigram_counts(rows):
    counts = {}
    for row in rows:
        for a, b in zip(row, row[1:]):
            counts.setdefault(a, {})
            counts[a][b] = counts[a].get(b, 0) + 1
    return counts


rows = sentences(CORPUS)
held = sentences(HELD_OUT)
counts = bigram_counts(rows)
vocab = sorted(set(w for row in rows for w in row))
V = len(vocab)


def p_add_k(a, b, k=1.0):
    nexts = counts.get(a, {})
    return (nexts.get(b, 0) + k) / (sum(nexts.values()) + k * V)


def perplexity(rows, prob):
    """exp of the average negative log probability, per predicted word."""
    total_log, n = 0.0, 0
    for row in rows:
        for a, b in zip(row, row[1:]):
            p = prob(a, b)
            if p <= 0.0:
                return float("inf")      # one impossible word and the score is infinite
            total_log += math.log(p)
            n += 1
    return math.exp(-total_log / n)


def uniform(a, b):
    return 1.0 / V


def oracle(a, b):
    return 1.0


def bigram(a, b):
    return p_add_k(a, b, 1.0)


def raw_bigram(a, b):
    nexts = counts.get(a, {})
    total = sum(nexts.values())
    return 0.0 if total == 0 else nexts.get(b, 0) / total


print("vocabulary size V =", V)
print()
print("  model                              on the corpus   on held-out text")
for name, prob in (("uniform guess, 1/V every time", uniform),
                   ("smoothed bigram, k = 1", bigram),
                   ("an oracle that is always certain", oracle)):
    print("  {:33}  {:14.3f}   {:.3f}".format(
        name, perplexity(rows, prob), perplexity(held, prob)))
print("  {:33}  {:14.3f}   {}".format(
    "unsmoothed bigram", perplexity(rows, raw_bigram), perplexity(held, raw_bigram)))

print()
print("Read perplexity as a number of choices. 12 means the model was worth no")
print("more than picking uniformly from 12 words. 5 means it had narrowed the")
print("field to about five. 1 means it knew. The last row is the point of")
print("smoothing: one pair the corpus never held, and the score is infinite.")

train_ppl = perplexity(rows, bigram)
held_ppl = perplexity(held, bigram)

test("a uniform model over twelve words has perplexity of exactly twelve",
     close(perplexity(rows, uniform), 12.0), True)
test("a model that is always certain and always right has perplexity one",
     close(perplexity(rows, oracle), 1.0), True)
test("the bigram model beats guessing on the text it was counted from",
     train_ppl < 12.0, True)
test("the same model is more surprised by text it has not seen", held_ppl > train_ppl, True)
test("smoothing keeps the held-out score finite", math.isinf(held_ppl), False)
test("without smoothing one unseen pair makes it infinite",
     math.isinf(perplexity(held, raw_bigram)), True)
test("perplexity never drops below one, because probability never exceeds one",
     min(train_ppl, held_ppl) >= 1.0, True)

What it printed, when the build ran it

vocabulary size V = 12

  model                              on the corpus   on held-out text
  uniform guess, 1/V every time              12.000   12.000
  smoothed bigram, k = 1                      4.899   5.503
  an oracle that is always certain            1.000   1.000
  unsmoothed bigram                           1.863   inf

Read perplexity as a number of choices. 12 means the model was worth no
more than picking uniformly from 12 words. 5 means it had narrowed the
field to about five. 1 means it knew. The last row is the point of
smoothing: one pair the corpus never held, and the score is infinite.
ok   a uniform model over twelve words has perplexity of exactly twelve
ok   a model that is always certain and always right has perplexity one
ok   the bigram model beats guessing on the text it was counted from
ok   the same model is more surprised by text it has not seen
ok   smoothing keeps the held-out score finite
ok   without smoothing one unseen pair makes it infinite
ok   perplexity never drops below one, because probability never exceeds one

The uniform row is the reason perplexity is worth learning: a uniform model over a vocabulary of V has perplexity exactly V, so the number is directly comparable to "how many words could it have been". It is also why a perplexity figure quoted without its test set and its tokenizer is meaningless. Perplexity is per token, so a model with a bigger vocabulary predicts fewer, harder tokens per sentence and gets a different number for identical text. Two models are only comparable on perplexity if they are scored on the same text with the same tokenizer.

Failure three: more context means memorising

The obvious fix for a bigram forgetting too much is to look further back. A trigram conditions on two words instead of one. On a small corpus that does not produce a better model; it produces a copy of the corpus.

A trigram model reciting its training set, and the held-out score that catches it

ran at build time
# A trigram model. More context, and on a corpus this small it simply recites.
import math
import random

CORPUS = """the cat sat on the mat
a dog barked at the moon
my friend wrote a long letter
the rain fell all afternoon
she left before the film ended
we ate bread and cheese"""

HELD_OUT = """the dog sat on the moon
a cat barked at the mat"""

START = "<s>"
END = "</s>"


def sentences(text, back):
    """Pad with as many start markers as the context needs, so the first real
    word is predicted from a context that exists."""
    return [[START] * back + line.split() + [END] for line in text.strip().split("\n")]


def ngram_counts(rows, order):
    """order 2 is a bigram model, 3 a trigram. The context is order - 1 words."""
    counts = {}
    back = order - 1
    for row in rows:
        for i in range(back, len(row)):
            context = tuple(row[i - back:i])
            counts.setdefault(context, {})
            counts[context][row[i]] = counts[context].get(row[i], 0) + 1
    return counts


bi_rows, bi_held = sentences(CORPUS, 1), sentences(HELD_OUT, 1)
tri_rows, tri_held = sentences(CORPUS, 2), sentences(HELD_OUT, 2)
V = len(set(w for row in tri_rows for w in row))

bi = ngram_counts(bi_rows, 2)
tri = ngram_counts(tri_rows, 3)


def probability(counts, context, word, k):
    nexts = counts.get(context, {})
    total = sum(nexts.values())
    if k <= 0.0:
        return 0.0 if total == 0 else nexts.get(word, 0) / total
    return (nexts.get(word, 0) + k) / (total + k * V)


def perplexity(rows, counts, order, k):
    back = order - 1
    total, n = 0.0, 0
    for row in rows:
        for i in range(back, len(row)):
            p = probability(counts, tuple(row[i - back:i]), row[i], k)
            if p <= 0.0:
                return float("inf")
            total += math.log(p)
            n += 1
    return math.exp(-total / n)


def generate(counts, rng, order, limit=10):
    back = order - 1
    context, out = tuple([START] * back), []
    while len(out) < limit:
        nexts = counts[context]
        total = sum(nexts.values())
        r, running, pick = rng.random(), 0.0, sorted(nexts)[-1]
        for word in sorted(nexts):
            running += nexts[word] / total
            if r < running:
                pick = word
                break
        if pick == END:
            break
        out.append(pick)
        context = tuple(list(context)[1:] + [pick])
    return " ".join(out)


forced = sum(1 for nexts in tri.values() if len(nexts) == 1)
print("trigram contexts             :", len(tri))
print("contexts with only one option:", forced)
print("so at {:.0f}% of positions the model has no decision left to make."
      .format(100.0 * forced / len(tri)))

rng = random.Random(42)
lines = [generate(tri, rng, 3) for _ in range(20)]
corpus_lines = set(CORPUS.strip().split("\n"))
print()
print("twenty samples from the trigram gave", len(set(lines)), "distinct sentences:")
for line in sorted(set(lines)):
    print("    {:32} {}".format(line, "verbatim from the corpus" if line in corpus_lines else "NEW"))

print()
print("the bigram model on the same corpus, for contrast:")
for line in [generate(bi, random.Random(42), 2) for _ in range(1)] + \
            [generate(bi, random.Random(7), 2) for _ in range(1)] + \
            [generate(bi, random.Random(11), 2) for _ in range(1)]:
    print("    {:34} {}".format(line, "verbatim" if line in corpus_lines else "new, and often nonsense"))

print()
print("  model                       on the corpus   on held-out text")
print("  {:26}  {:13.3f}   {:.3f}".format(
    "bigram, k = 1", perplexity(bi_rows, bi, 2, 1.0), perplexity(bi_held, bi, 2, 1.0)))
print("  {:26}  {:13.3f}   {:.3f}".format(
    "trigram, k = 1", perplexity(tri_rows, tri, 3, 1.0), perplexity(tri_held, tri, 3, 1.0)))
print("  {:26}  {:13.3f}   {}".format(
    "bigram, no smoothing", perplexity(bi_rows, bi, 2, 0.0), perplexity(bi_held, bi, 2, 0.0)))
print("  {:26}  {:13.3f}   {}".format(
    "trigram, no smoothing", perplexity(tri_rows, tri, 3, 0.0), perplexity(tri_held, tri, 3, 0.0)))

train_tri = perplexity(tri_rows, tri, 3, 0.0)
train_bi = perplexity(bi_rows, bi, 2, 0.0)
held_tri = perplexity(tri_held, tri, 3, 1.0)
held_bi = perplexity(bi_held, bi, 2, 1.0)

test("almost every trigram context has exactly one continuation",
     forced / len(tri) > 0.9, True)
test("so twenty samples produce nothing the corpus did not already contain",
     all(line in corpus_lines for line in lines), True)
test("unsmoothed, the trigram is barely surprised by its own training text",
     train_tri < 1.4, True)
test("which beats the bigram on training text, and means nothing at all",
     train_tri < train_bi, True)
test("on held-out text the trigram is the worse model of the two",
     held_tri > held_bi, True)
test("and unsmoothed it is infinitely worse",
     math.isinf(perplexity(tri_held, tri, 3, 0.0)), True)

What it printed, when the build ran it

trigram contexts             : 34
contexts with only one option: 32
so at 94% of positions the model has no decision left to make.

twenty samples from the trigram gave 6 distinct sentences:
    a dog barked at the moon         verbatim from the corpus
    my friend wrote a long letter    verbatim from the corpus
    she left before the film ended   verbatim from the corpus
    the cat sat on the mat           verbatim from the corpus
    the rain fell all afternoon      verbatim from the corpus
    we ate bread and cheese          verbatim from the corpus

the bigram model on the same corpus, for contrast:
    the cat sat on the moon            new, and often nonsense
    my friend wrote a long letter      verbatim
    she left before the mat            new, and often nonsense

  model                       on the corpus   on held-out text
  bigram, k = 1                      16.287   19.883
  trigram, k = 1                     16.049   23.420
  bigram, no smoothing                1.600   inf
  trigram, no smoothing               1.308   inf
ok   almost every trigram context has exactly one continuation
ok   so twenty samples produce nothing the corpus did not already contain
ok   unsmoothed, the trigram is barely surprised by its own training text
ok   which beats the bigram on training text, and means nothing at all
ok   on held-out text the trigram is the worse model of the two
ok   and unsmoothed it is infinitely worse

This is the whole argument for a held-out split, in one table. The unsmoothed trigram scores 1.3 on the corpus it was built from and infinity on two sentences it has not seen. Nothing about the training number was a lie — it was computed correctly — it simply measured recall of the training set rather than any ability to model language. The modern version of this mistake is an evaluation set that overlaps the training data, or a benchmark whose answers are on the web. It looks like a result and it is a measurement of nothing.

The k = 1 rows carry a second, quieter lesson. Add-one smoothing over a thirty-word vocabulary makes both models look terrible on training text, because most contexts were seen once or twice and the fake counts outweigh the real ones. Smoothing strong enough to cover the zeros is strong enough to bury the signal. That tension — coverage against sharpness — is what backoff schemes exist to manage, and what a neural model dissolves by sharing structure between words instead of patching each cell independently.

  • Count, then normalise. Every language model is estimating the same conditional distribution. The n-gram does it with division; a transformer does it with gradient descent, and the target is identical.
  • Zeros are the enemy. Any model that assigns exactly zero to real text cannot be scored, cannot be trained by a log loss, and cannot be improved.
  • Perplexity is per token. Compare it only on the same text with the same tokenizer, or you are comparing nothing.
  • Held-out or it did not happen. A model with enough capacity will memorise, and the training score will look excellent while it does.

What a real project would use instead

The count table becomes a learned embedding table, and the division becomes gradient descent on the same objective — predict the next token, score it by log probability. Written in torch, the neural bigram below is about as long as the counting version above, and the thing it gains is exactly what counting cannot do: an unseen pair still gets a sensible score, because similar words have ended up with similar vectors.

The same next-token objective, learned rather than counted

shown, not run
# The same objective, learned instead of counted.
# NOT RUN HERE - torch is not installed.

import torch
import torch.nn.functional as F

V = 12                                   # vocabulary size

# The count table above was V x V numbers you divided. This is V x V numbers you
# learn, which is the entire difference in the setup.
logits_table = torch.zeros((V, V), requires_grad=True)

context = torch.tensor([0, 3, 5])        # previous token ids
target = torch.tensor([3, 5, 7])         # the token that actually came next

for step in range(200):
    logits = logits_table[context]        # one row of scores per context token
    loss = F.cross_entropy(logits, target)   # mean negative log probability
    loss.backward()
    with torch.no_grad():
        logits_table -= 0.1 * logits_table.grad
        logits_table.grad.zero_()

# Perplexity is exp of that same loss. It is not a separate metric.
perplexity = torch.exp(loss)

# And the reason anyone bothers: replace the row lookup with an embedding plus a
# hidden layer, and words that behave alike share parameters, so a pair the corpus
# never contained still gets a sensible probability. A count table cannot do that,
# because every cell in it is independent of every other cell.
model = torch.nn.Sequential(
    torch.nn.Embedding(V, 16),
    torch.nn.Flatten(),
    torch.nn.Linear(16, 64),
    torch.nn.Tanh(),
    torch.nn.Linear(64, V),
)

torch is not installed in this environment, so this panel was never executed and no number in it was measured here. The objective it minimises is the same average negative log probability that the perplexity function above computes in plain Python, and that one did run.

Everything a transformer does sits on top of what you just built. The objective is unchanged: put a distribution over the next token and make the real token likely. What changes is the context. A bigram sees one word, a trigram two, an n-gram n minus one — and each extra word multiplies the table size by the vocabulary. Attention, in lesson 20, gets arbitrary context without that explosion, by learning what to look at instead of enumerating every possibility.

19 Temperature, top-k and top-p

A model hands you one number per token and then does nothing else. Choosing a token from those numbers is called decoding, and it is entirely outside the model: no weights, no training, just arithmetic you control at request time. Four settings cover almost every API you will meet, and each one is a way of deciding how much of the distribution to take seriously.

Greedy and temperature, the two ends

Greedy takes the highest-scoring token, every time, with no randomness at all. Temperature divides the logits before the softmax, which sharpens the distribution below 1 and flattens it above 1. Neither one throws any token away.

Greedy, temperature, and a seeded draw

ran at build time
# The two simplest decoders, both reading the same logits.
import math
import random

TOKENS = ("cat", "dog", "bird", "fish", "quark")
LOGITS = [3.0, 1.5, 0.5, 0.0, -2.0]


def softmax(logits, temperature=1.0):
    if temperature <= 0.0:
        raise ValueError("temperature must be positive; zero is argmax, not sampling")
    scaled = [z / temperature for z in logits]
    shift = max(scaled)                       # the overflow guard from lesson 6
    exps = [math.exp(z - shift) for z in scaled]
    total = sum(exps)
    return [e / total for e in exps]


def greedy(probs):
    """No dice. The highest probability, every time."""
    return probs.index(max(probs))


def sample(probs, rng):
    r, running = rng.random(), 0.0
    for i, p in enumerate(probs):
        running += p
        if r < running:
            return i
    return len(probs) - 1


def draw(probs, seed, n):
    rng = random.Random(seed)                 # our own generator, never random.seed()
    return [TOKENS[sample(probs, rng)] for _ in range(n)]


print("logits:", dict(zip(TOKENS, LOGITS)))
print()
print("  temperature " + "".join("{:>8}".format(t) for t in TOKENS))
for t in (0.2, 0.7, 1.0, 1.5, 3.0):
    print("  {:11} ".format(t) + "".join("{:8.4f}".format(x) for x in softmax(LOGITS, t)))

base = softmax(LOGITS)
print()
print("greedy, ten times :", [TOKENS[greedy(base)] for _ in range(10)])
print("sampled at T=1.0  :", draw(base, 42, 10))
print("sampled at T=0.2  :", draw(softmax(LOGITS, 0.2), 42, 10))
print("sampled at T=3.0  :", draw(softmax(LOGITS, 3.0), 42, 10))

hot = softmax(LOGITS, 3.0)
cold = softmax(LOGITS, 0.2)

test("softmax gives a distribution at every temperature",
     all(close(sum(softmax(LOGITS, t)), 1.0) for t in (0.2, 1.0, 3.0)), True)
test("greedy is deterministic, so ten runs give one answer",
     len(set(TOKENS[greedy(base)] for _ in range(10))), 1)
test("a cold temperature puts nearly all the mass on that same token", cold[0] > 0.99, True)
test("a hot temperature makes even the least likely token reachable", hot[4] > 0.03, True)
test("temperature never reorders the tokens",
     [greedy(softmax(LOGITS, t)) for t in (0.2, 1.0, 3.0)], [0, 0, 0])
test("the same seed replays the same draws", draw(base, 42, 10), draw(base, 42, 10))

What it printed, when the build ran it

logits: {'cat': 3.0, 'dog': 1.5, 'bird': 0.5, 'fish': 0.0, 'quark': -2.0}

  temperature      cat     dog    bird    fish   quark
          0.2   0.9994  0.0006  0.0000  0.0000  0.0000
          0.7   0.8621  0.1011  0.0242  0.0119  0.0007
          1.0   0.7344  0.1639  0.0603  0.0366  0.0049
          1.5   0.5788  0.2129  0.1093  0.0783  0.0206
          3.0   0.3849  0.2335  0.1673  0.1416  0.0727

greedy, ten times : ['cat', 'cat', 'cat', 'cat', 'cat', 'cat', 'cat', 'cat', 'cat', 'cat']
sampled at T=1.0  : ['cat', 'cat', 'cat', 'cat', 'dog', 'cat', 'dog', 'cat', 'cat', 'cat']
sampled at T=0.2  : ['cat', 'cat', 'cat', 'cat', 'cat', 'cat', 'cat', 'cat', 'cat', 'cat']
sampled at T=3.0  : ['bird', 'cat', 'cat', 'cat', 'bird', 'bird', 'fish', 'cat', 'dog', 'cat']
ok   softmax gives a distribution at every temperature
ok   greedy is deterministic, so ten runs give one answer
ok   a cold temperature puts nearly all the mass on that same token
ok   a hot temperature makes even the least likely token reachable
ok   temperature never reorders the tokens
ok   the same seed replays the same draws

Temperature is a volume knob, not a steering wheel. It changes how much of the distribution you are willing to visit; it never changes the ranking. That matters because "turn up the temperature to get a more creative answer" is only half true — at T = 3.0 the model still says "cat" most often, it just now sometimes says "quark". The problem with temperature alone is exactly that: a high setting leaves genuinely bad tokens reachable, and a long generation will eventually reach one.

top-k, and where a fixed k stops making sense

top-k keeps the k highest probabilities, sets the rest to zero, and renormalises so the survivors sum to one again. It puts a hard floor under quality: whatever the temperature, the 40,000th-ranked token cannot be chosen. The cost is that k is a constant and the distribution is not.

top-k on a confident step and an unsure one, with the same k

ran at build time
# top-k: keep the k best, throw the rest away, renormalise. Then look at
# where a fixed k stops making sense.
import math

PEAKED = [6.0, 2.0, 1.0, 0.5, 0.0, -0.5, -1.0, -1.5, -2.0, -2.5]
FLAT = [1.00, 0.99, 0.98, 0.97, 0.96, 0.95, 0.94, 0.93, 0.92, 0.91]


def softmax(logits):
    shift = max(logits)
    exps = [math.exp(z - shift) for z in logits]
    total = sum(exps)
    return [e / total for e in exps]


def top_k(probs, k):
    """Keep the k highest probabilities, zero the rest, renormalise to sum to 1."""
    if k < 1 or k > len(probs):
        raise ValueError("k must be between 1 and the vocabulary size")
    ranked = sorted(range(len(probs)), key=lambda i: -probs[i])
    keep = set(ranked[:k])
    kept_mass = sum(probs[i] for i in keep)
    return [probs[i] / kept_mass if i in keep else 0.0 for i in range(len(probs))]


def alive(dist):
    return sum(1 for x in dist if x > 0.0)


peaked = softmax(PEAKED)
flat = softmax(FLAT)

print("a confident step. one token holds almost all the mass:")
print("  ", ["{:.4f}".format(x) for x in peaked])
print("an unsure step. ten tokens, all about as likely as each other:")
print("  ", ["{:.4f}".format(x) for x in flat])

print()
print("  k    tokens left   mass kept before renormalising   which step")
for k in (1, 3, 5, 10):
    print("  {:3}  {:11}   {:29.4f}   confident".format(
        k, alive(top_k(peaked, k)), sum(sorted(peaked, reverse=True)[:k])))
for k in (1, 3, 5, 10):
    print("  {:3}  {:11}   {:29.4f}   unsure".format(
        k, alive(top_k(flat, k)), sum(sorted(flat, reverse=True)[:k])))

print()
print("On the confident step, k = 3 throws away 1% of the mass and changes")
print("nothing. On the unsure step, the same k = 3 throws away 69% of a")
print("distribution that genuinely had no favourite.")

ranked = sorted(range(10), key=lambda i: -flat[i])
inside, outside = ranked[2], ranked[3]
print()
print("and the cut is arbitrary. on the unsure step, with k = 3:")
print("  last token kept  : p = {:.6f}".format(flat[inside]))
print("  first token cut  : p = {:.6f}".format(flat[outside]))
print("  difference       :     {:.6f}".format(flat[inside] - flat[outside]))

try:
    top_k(flat, 0)
    refused = False
except ValueError as error:
    refused = True
    print()
    print("k = 0 is refused:", error)

peaked_kept = sum(sorted(peaked, reverse=True)[:3])
flat_kept = sum(sorted(flat, reverse=True)[:3])

test("top-k leaves exactly k tokens alive", alive(top_k(flat, 3)), 3)
test("and the survivors still form a distribution", close(sum(top_k(flat, 3)), 1.0), True)
test("k = 1 is greedy decoding with extra steps", alive(top_k(peaked, 1)), 1)
test("on a confident step, k = 3 keeps nearly all the mass", peaked_kept > 0.98, True)
test("on an unsure step, the same k throws most of it away", flat_kept < 0.35, True)
test("and the token just outside the cut was as likely as the one just inside",
     close(flat[inside], flat[outside], 0.002), True)
test("a k outside 1 to V is refused rather than silently clamped", refused, True)

What it printed, when the build ran it

a confident step. one token holds almost all the mass:
   ['0.9661', '0.0177', '0.0065', '0.0039', '0.0024', '0.0015', '0.0009', '0.0005', '0.0003', '0.0002']
an unsure step. ten tokens, all about as likely as each other:
   ['0.1046', '0.1035', '0.1025', '0.1015', '0.1005', '0.0995', '0.0985', '0.0975', '0.0965', '0.0956']

  k    tokens left   mass kept before renormalising   which step
    1            1                          0.9661   confident
    3            3                          0.9903   confident
    5            5                          0.9966   confident
   10           10                          1.0000   confident
    1            1                          0.1046   unsure
    3            3                          0.3106   unsure
    5            5                          0.5125   unsure
   10           10                          1.0000   unsure

On the confident step, k = 3 throws away 1% of the mass and changes
nothing. On the unsure step, the same k = 3 throws away 69% of a
distribution that genuinely had no favourite.

and the cut is arbitrary. on the unsure step, with k = 3:
  last token kept  : p = 0.102489
  first token cut  : p = 0.101469
  difference       :     0.001020

k = 0 is refused: k must be between 1 and the vocabulary size
ok   top-k leaves exactly k tokens alive
ok   and the survivors still form a distribution
ok   k = 1 is greedy decoding with extra steps
ok   on a confident step, k = 3 keeps nearly all the mass
ok   on an unsure step, the same k throws most of it away
ok   and the token just outside the cut was as likely as the one just inside
ok   a k outside 1 to V is refused rather than silently clamped

Read the last three lines of that output again. With k = 3 on the unsure step, the token ranked third survives with probability 0.1025 and the token ranked fourth is deleted with probability 0.1015. A gap of one part in a thousand decided whether a token was possible or impossible. That is not a judgement about language, it is an artefact of picking a constant. It is also why k is the setting people most often get wrong: small values strangle the model exactly when it needed room, and large values do nothing at all when it did not.

top-p, which measures the distribution instead of assuming it

top-p, also called nucleus sampling, fixes a mass rather than a count. Sort the tokens by probability, walk down the list adding them up, and stop as soon as the running total reaches p. The number of tokens kept is whatever that took, so it is narrow where the model is confident and wide where it is not.

top-p adapting its own width, next to top-k not adapting

ran at build time
# top-p, or nucleus sampling. Choose the mass to keep, and let the width follow.
import math

PEAKED = [6.0, 2.0, 1.0, 0.5, 0.0, -0.5, -1.0, -1.5, -2.0, -2.5]
FLAT = [1.00, 0.99, 0.98, 0.97, 0.96, 0.95, 0.94, 0.93, 0.92, 0.91]


def softmax(logits):
    shift = max(logits)
    exps = [math.exp(z - shift) for z in logits]
    total = sum(exps)
    return [e / total for e in exps]


def top_k(probs, k):
    ranked = sorted(range(len(probs)), key=lambda i: -probs[i])
    keep = set(ranked[:k])
    mass = sum(probs[i] for i in keep)
    return [probs[i] / mass if i in keep else 0.0 for i in range(len(probs))]


def top_p(probs, p):
    """Keep the smallest set of tokens whose probabilities reach p, then renormalise."""
    if not 0.0 < p <= 1.0:
        raise ValueError("p must be above 0 and at most 1")
    ranked = sorted(range(len(probs)), key=lambda i: -probs[i])
    keep, running = [], 0.0
    for i in ranked:
        keep.append(i)
        running += probs[i]
        if running >= p:
            break            # the token that crosses p is kept, not dropped
    mass = sum(probs[i] for i in keep)
    kept = set(keep)
    return [probs[i] / mass if i in kept else 0.0 for i in range(len(probs))]


def alive(dist):
    return sum(1 for x in dist if x > 0.0)


def entropy(dist):
    return -sum(x * math.log(x) for x in dist if x > 0.0)


peaked = softmax(PEAKED)
flat = softmax(FLAT)

print("  p       tokens kept, confident step   tokens kept, unsure step")
for p in (0.5, 0.8, 0.9, 0.95, 1.0):
    print("  {:5}   {:28}   {}".format(p, alive(top_p(peaked, p)), alive(top_p(flat, p))))

print()
print("  the same two steps under a fixed k")
print("  k       tokens kept, confident step   tokens kept, unsure step")
for k in (1, 3, 5, 10):
    print("  {:5}   {:28}   {}".format(k, alive(top_k(peaked, k)), alive(top_k(flat, k))))

print()
print("That is the whole argument. top-p read the shape of the distribution and")
print("kept one token where the model was sure and nine where it was not.")
print("top-k kept three either way, because three is what you told it.")

print()
print("entropy, which is what top-p is implicitly reacting to:")
print("  confident step: {:.4f} nats".format(entropy(peaked)))
print("  unsure step   : {:.4f} nats".format(entropy(flat)))

try:
    top_p(flat, 0.0)
    refused = False
except ValueError as error:
    refused = True
    print()
    print("p = 0 is refused:", error)

test("top-p leaves a distribution behind", close(sum(top_p(flat, 0.9)), 1.0), True)
test("on a confident step, p = 0.9 needs only one token", alive(top_p(peaked, 0.9)), 1)
test("on an unsure step, the same p needs nine", alive(top_p(flat, 0.9)), 9)
test("while a fixed k keeps the same number for both",
     alive(top_k(peaked, 3)), alive(top_k(flat, 3)))
test("p = 1.0 keeps the whole vocabulary", alive(top_p(flat, 1.0)), 10)
test("a tiny p collapses to greedy, because one token already covers it",
     alive(top_p(peaked, 0.01)), 1)
test("p outside its range is refused", refused, True)

What it printed, when the build ran it

  p       tokens kept, confident step   tokens kept, unsure step
    0.5                              1   5
    0.8                              1   8
    0.9                              1   9
   0.95                              1   10
    1.0                             10   10

  the same two steps under a fixed k
  k       tokens kept, confident step   tokens kept, unsure step
      1                              1   1
      3                              3   3
      5                              5   5
     10                             10   10

That is the whole argument. top-p read the shape of the distribution and
kept one token where the model was sure and nine where it was not.
top-k kept three either way, because three is what you told it.

entropy, which is what top-p is implicitly reacting to:
  confident step: 0.1978 nats
  unsure step   : 2.3022 nats

p = 0 is refused: p must be above 0 and at most 1
ok   top-p leaves a distribution behind
ok   on a confident step, p = 0.9 needs only one token
ok   on an unsure step, the same p needs nine
ok   while a fixed k keeps the same number for both
ok   p = 1.0 keeps the whole vocabulary
ok   a tiny p collapses to greedy, because one token already covers it
ok   p outside its range is refused

top-p is not strictly better, and the failure worth knowing is the mirror image of top-k's. When the model is confident, top-p with a high p can still admit a long tail: if the best token has probability 0.55, p = 0.95 keeps everything down to 0.95 of the mass, which on a 50,000-token vocabulary can be hundreds of tokens. That is why real APIs expose both and why the common recipe is a moderate p with a k behind it as a hard ceiling. The two filters are cheap; use them together.

Repetition, and why there is a setting for it

Greedy decoding loops. Not sometimes — structurally. If the best token after A is B and the best token after B is A, greedy has no way out, and the loop is the correct answer to the question greedy is asking. The repetition penalty is a patch applied to the logits of tokens that have already appeared.

A greedy loop, and the penalty that breaks it

ran at build time
# Repetition, and the blunt instrument that exists to stop it.
SCORES = {
    "<s>": {"the": 4.0, "a": 2.0},
    "the": {"cat": 4.0, "mat": 3.5, "dog": 2.0, "</s>": -1.0},
    "cat": {"the": 4.0, "sat": 3.0, "</s>": 0.0},
    "sat": {"on": 4.0, "</s>": 0.0},
    "on": {"the": 4.0, "</s>": 0.0},
    "mat": {"</s>": 4.0, "the": 1.0},
    "dog": {"the": 3.0, "sat": 2.0},
    "a": {"cat": 3.0, "dog": 2.0},
}


def penalise(scores, used, penalty):
    """The rule a real library uses: divide a positive logit, multiply a negative
    one. Dividing a negative number would make it larger, which is backwards."""
    if penalty < 1.0:
        raise ValueError("a penalty below 1 rewards repetition instead of discouraging it")
    out = {}
    for token, z in scores.items():
        if token in used:
            out[token] = z / penalty if z > 0.0 else z * penalty
        else:
            out[token] = z
    return out


def greedy_walk(penalty=1.0, limit=12):
    token, out, used = "<s>", [], set()
    while len(out) < limit:
        scores = penalise(SCORES[token], used, penalty)
        token = max(sorted(scores), key=lambda t: scores[t])
        if token == "</s>":
            break
        out.append(token)
        used.add(token)
    return out


loop = greedy_walk(1.0)
fixed = greedy_walk(1.5)

print("greedy, no penalty  :", " ".join(loop))
print("greedy, penalty 1.5 :", " ".join(fixed))
print()
print("Nothing is broken. Greedy takes the best next token, the best token after")
print("'cat' is 'the', and the best token after 'the' is 'cat'. The loop is the")
print("correct answer to the question greedy decoding is asking.")

print()
print("what the penalty does to one step. after 'the cat', the scores from 'cat':")
for name, penalty in (("no penalty", 1.0), ("penalty 1.5", 1.5), ("penalty 3.0", 3.0)):
    scores = penalise(SCORES["cat"], {"the", "cat"}, penalty)
    print("  {:12}".format(name), {k: round(v, 3) for k, v in sorted(scores.items())})

print()
print("The end marker at 0.0 does not move: the rule only touches tokens already")
print("used. And 'the' is being punished for being a word English needs to repeat.")

try:
    greedy_walk(0.5)
    refused = False
except ValueError as error:
    refused = True
    print()
    print("a penalty below 1 is refused:", error)

untouched = penalise(SCORES["the"], {"the", "cat"}, 2.0)

test("greedy with no penalty runs into a two-token loop", loop, loop[:2] * 6)
test("and never reaches the end marker, it just hits the length limit", len(loop), 12)
test("a penalty of 1.5 breaks the loop",
     fixed, ["the", "cat", "sat", "on", "the", "mat"])
test("and lets the sentence finish on its own", len(fixed) < 12, True)
test("a penalty of 1.0 changes nothing at all", greedy_walk(1.0), loop)
test("the penalty leaves tokens that have not been used alone",
     close(untouched["mat"], 3.5), True)
test("but it does punish a word that English needs to repeat",
     untouched["cat"] < SCORES["the"]["cat"], True)
test("a penalty below one is refused, because it would reward repetition", refused, True)

What it printed, when the build ran it

greedy, no penalty  : the cat the cat the cat the cat the cat the cat
greedy, penalty 1.5 : the cat sat on the mat

Nothing is broken. Greedy takes the best next token, the best token after
'cat' is 'the', and the best token after 'the' is 'cat'. The loop is the
correct answer to the question greedy decoding is asking.

what the penalty does to one step. after 'the cat', the scores from 'cat':
  no penalty   {'</s>': 0.0, 'sat': 3.0, 'the': 4.0}
  penalty 1.5  {'</s>': 0.0, 'sat': 3.0, 'the': 2.667}
  penalty 3.0  {'</s>': 0.0, 'sat': 3.0, 'the': 1.333}

The end marker at 0.0 does not move: the rule only touches tokens already
used. And 'the' is being punished for being a word English needs to repeat.

a penalty below 1 is refused: a penalty below 1 rewards repetition instead of discouraging it
ok   greedy with no penalty runs into a two-token loop
ok   and never reaches the end marker, it just hits the length limit
ok   a penalty of 1.5 breaks the loop
ok   and lets the sentence finish on its own
ok   a penalty of 1.0 changes nothing at all
ok   the penalty leaves tokens that have not been used alone
ok   but it does punish a word that English needs to repeat
ok   a penalty below one is refused, because it would reward repetition

Three variants exist and the difference is worth knowing. repetition_penalty is the multiplicative rule above and is applied once, however many times the token appeared. frequency_penalty subtracts an amount proportional to the count, so the tenth repeat is punished ten times as hard. presence_penalty subtracts a flat amount for having appeared at all. OpenAI exposes the last two, Hugging Face the first. All three share the same defect the assertions above make explicit: they punish tokens for being repeated, and they cannot tell a degenerate loop from the word "the".

Because of that, a repetition penalty is the wrong tool for structured output. Turn one on while generating JSON and you are penalising every repeated quote, brace and comma — exactly the characters the format requires. If a model is looping, prefer sampling over greedy, then no_repeat_ngram_size, which forbids repeating a whole n-gram rather than a single token, and leave the penalty at 1.0 for anything that has a syntax.

All four together, and the order they go in

One decode step is: logits, temperature, top-k, top-p, draw. That order is not decoration. Each filter acts on the distribution the previous step produced, so swapping two of them gives a different set of reachable tokens for the same settings.

The whole decode step, and why the order is fixed

ran at build time
# One decode step, all four settings, in the order a real library applies them.
import math
import random

TOKENS = ("cat", "dog", "bird", "fish", "quark", "ledger", "sonata", "kelp")
LOGITS = [3.0, 2.6, 2.2, 1.8, 1.4, 1.0, 0.6, 0.2]


def softmax(logits, temperature=1.0):
    scaled = [z / temperature for z in logits]
    shift = max(scaled)
    exps = [math.exp(z - shift) for z in scaled]
    total = sum(exps)
    return [e / total for e in exps]


def top_k(probs, k):
    ranked = sorted(range(len(probs)), key=lambda i: -probs[i])
    keep = set(ranked[:k])
    mass = sum(probs[i] for i in keep)
    return [probs[i] / mass if i in keep else 0.0 for i in range(len(probs))]


def top_p(probs, p):
    ranked = sorted(range(len(probs)), key=lambda i: -probs[i])
    keep, running = [], 0.0
    for i in ranked:
        keep.append(i)
        running += probs[i]
        if running >= p:
            break
    mass = sum(probs[i] for i in keep)
    kept = set(keep)
    return [probs[i] / mass if i in kept else 0.0 for i in range(len(probs))]


def decode_step(logits, rng, temperature=1.0, k=None, p=None):
    """Temperature first, then top-k, then top-p, then draw."""
    probs = softmax(logits, temperature)
    if k is not None:
        probs = top_k(probs, k)
    if p is not None:
        probs = top_p(probs, p)
    r, running = rng.random(), 0.0
    for i, x in enumerate(probs):
        running += x
        if r < running:
            return i, probs
    return len(probs) - 1, probs


def alive(dist):
    return sum(1 for x in dist if x > 0.0)


def run(seed, n, **kw):
    rng = random.Random(seed)
    return [TOKENS[decode_step(LOGITS, rng, **kw)[0]] for _ in range(n)]


print("  settings                               reachable   eight draws with seed 42")
for label, kw in (("nothing, plain sampling", {}),
                  ("temperature 0.5", {"temperature": 0.5}),
                  ("top_k 3", {"k": 3}),
                  ("top_p 0.6", {"p": 0.6}),
                  ("temperature 0.7 + top_k 5 + top_p 0.9",
                   {"temperature": 0.7, "k": 5, "p": 0.9})):
    _, dist = decode_step(LOGITS, random.Random(0), **kw)
    print("  {:37}  {:9}   {}".format(label, alive(dist), " ".join(run(42, 8, **kw))))

print()
print("order matters, and this is the trap. the same two settings, swapped:")
hot_then_cut = top_p(softmax(LOGITS, 2.0), 0.6)
cut_then_hot = softmax([z if x > 0.0 else -1e9
                        for z, x in zip(LOGITS, top_p(softmax(LOGITS), 0.6))], 2.0)
print("  temperature 2.0, then top_p 0.6 :", alive(hot_then_cut), "tokens reachable")
print("  top_p 0.6, then temperature 2.0 :", alive(cut_then_hot), "tokens reachable")
print("A high temperature flattens the distribution, so top-p then has to keep")
print("more tokens to reach the same mass. Filter first and it never can.")

_, plain = decode_step(LOGITS, random.Random(0))
_, both = decode_step(LOGITS, random.Random(0), temperature=0.7, k=5, p=0.9)

test("with no filter, every token in the vocabulary is reachable", alive(plain), 8)
test("the combined settings narrow it", alive(both) < 8, True)
test("and what is left is still a distribution", close(sum(both), 1.0), True)
test("the same seed and settings replay exactly", run(42, 8, k=3), run(42, 8, k=3))
test("a different seed gives a different sequence",
     run(42, 8, k=3) == run(7, 8, k=3), False)
test("temperature before top-p leaves more tokens alive than after it",
     alive(hot_then_cut) > alive(cut_then_hot), True)
test("greedy is a corner of this same function, at k = 1",
     len(set(run(42, 8, k=1))), 1)

What it printed, when the build ran it

  settings                               reachable   eight draws with seed 42
  nothing, plain sampling                        8   bird cat cat cat fish bird quark cat
  temperature 0.5                                8   dog cat cat cat dog dog bird cat
  top_k 3                                        3   dog cat cat cat dog dog bird cat
  top_p 0.6                                      3   dog cat cat cat dog dog bird cat
  temperature 0.7 + top_k 5 + top_p 0.9          4   dog cat cat cat dog dog bird cat

order matters, and this is the trap. the same two settings, swapped:
  temperature 2.0, then top_p 0.6 : 4 tokens reachable
  top_p 0.6, then temperature 2.0 : 3 tokens reachable
A high temperature flattens the distribution, so top-p then has to keep
more tokens to reach the same mass. Filter first and it never can.
ok   with no filter, every token in the vocabulary is reachable
ok   the combined settings narrow it
ok   and what is left is still a distribution
ok   the same seed and settings replay exactly
ok   a different seed gives a different sequence
ok   temperature before top-p leaves more tokens alive than after it
ok   greedy is a corner of this same function, at k = 1
  • Factual work. Temperature 0, or greedy. You want the same answer twice, and you want the token the model actually believes.
  • Anything with a syntax. Low temperature, no repetition penalty, and a validator afterwards. JSON is not a creative writing task.
  • Open-ended prose. Temperature around 0.7 to 1.0 with top_p near 0.9, and a k as a safety ceiling.
  • Reproducibility. Pin the seed and log the settings alongside the output. Without them a failing generation cannot be replayed, and "the model got worse" cannot be distinguished from "someone changed top_p".

What a real project would use instead

Every one of these functions exists in the library, applied over a whole vocabulary in compiled code, in the order above. The reason to have written them once is that the parameter names in the API are now transparent rather than magic, and when a generation goes strange you know which filter to suspect.

The same four filters, through a library

shown, not run
# The same four settings through a real generation API.
# NOT RUN HERE - torch and transformers are not installed.

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer

tok = AutoTokenizer.from_pretrained("gpt2")
model = AutoModelForCausalLM.from_pretrained("gpt2")
ids = tok("the cat sat on", return_tensors="pt").input_ids

# Greedy. No sampling, no seed needed, same answer every time.
model.generate(ids, do_sample=False, max_new_tokens=20)

# Sampling with all four filters. do_sample=True is the switch people forget:
# leave it False and temperature, top_k and top_p are silently ignored.
torch.manual_seed(42)
model.generate(
    ids,
    do_sample=True,
    temperature=0.8,
    top_k=50,                 # hard ceiling
    top_p=0.9,                # adaptive width
    repetition_penalty=1.1,   # leave at 1.0 for JSON and code
    no_repeat_ngram_size=3,   # a better answer to loops than the penalty
    max_new_tokens=60,
)

# The filters as standalone objects, applied in this order, which is the order
# the plain-Python decode_step above uses:
from transformers import (TemperatureLogitsWarper, TopKLogitsWarper,
                          TopPLogitsWarper, RepetitionPenaltyLogitsProcessor)

# And the same four settings over an HTTP API, where seed may be a hint rather
# than a guarantee, because batching on the server can change the arithmetic:
#   {"temperature": 0.8, "top_p": 0.9, "frequency_penalty": 0.2, "seed": 42}

torch and transformers are not installed in this environment, so this panel was never executed. Every filter it names was written out in plain Python above and asserted there; this shows only the parameter names a real API uses for them.

One thing to carry into an interview: none of this changes the model. Decoding is a pure function of the logits, so an unhappy output is either a bad distribution or a bad choice from a fine distribution, and the two have completely different fixes. Log the top few logits for a step that went wrong and you can tell which. If the right token was ranked second, that is a decoding problem and cheap to fix. If it was not in the top fifty, no setting on this page will help you.

20 Attention, from scratch

Lesson 16 ended with two failures: one vector per word cannot handle context, and counting cannot see word order. Attention fixes the first. Each token builds its own new vector by asking every other token a question, scoring the answers, and taking a weighted average of what they offer. Three steps, one softmax, no learned machinery beyond three matrices.

Query, key, value, and the three lines that use them

The vocabulary is worth ten seconds. A query is what a token is looking for. A key is what a token advertises about itself. A value is what a token hands over if you attend to it. Score every query against every key with a dot product, softmax each row, and mix the values in those proportions. Everything below is hand-written so the answers are checkable.

The attention weight matrix, printed, and the output it produces

ran at build time
# Scaled dot-product attention, on four tokens and vectors small enough to read.
import math

TOKENS = ["the", "cat", "drank", "milk"]
AXES = ["animal", "action", "food", "filler"]

# Keys and values: what each token is. One axis lit per token, by hand.
K = [[0.0, 0.0, 0.0, 1.0],      # the    - a filler word
     [1.0, 0.0, 0.0, 0.0],      # cat    - an animal
     [0.0, 1.0, 0.0, 0.0],      # drank  - an action
     [0.0, 0.0, 1.0, 0.0]]      # milk   - a food
V = [row[:] for row in K]       # values are the same content, kept readable

# Queries: what each token is looking for. Also by hand, so the answers are checkable.
Q = [[0.0, 0.0, 0.0, 6.0],      # the    - looking for filler, i.e. itself
     [0.0, 6.0, 0.0, 0.0],      # cat    - looking for the action
     [6.0, 0.0, 6.0, 0.0],      # drank  - looking for who did it and to what
     [0.0, 6.0, 0.0, 0.0]]      # milk   - looking for the action
D_K = len(AXES)


def dot(a, b):
    return sum(x * y for x, y in zip(a, b))


def softmax(row):
    shift = max(row)                            # lesson 6's subtract-the-max
    exps = [math.exp(v - shift) for v in row]
    total = sum(exps)
    return [e / total for e in exps]


def attention(queries, keys, values):
    """Three lines of arithmetic. Everything else in this lesson is a variation."""
    scale = math.sqrt(len(keys[0]))
    scores = [[dot(q, k) / scale for k in keys] for q in queries]
    weights = [softmax(row) for row in scores]
    out = [[sum(w[j] * values[j][c] for j in range(len(values))) for c in range(len(values[0]))]
           for w in weights]
    return scores, weights, out


scores, weights, out = attention(Q, K, V)

print("axes:", AXES)
print()
print("raw scores, query row against key column, before scaling")
print("            " + "".join("{:>9}".format(t) for t in TOKENS))
for i, t in enumerate(TOKENS):
    print("   {:8} ".format(t) + "".join("{:9.4f}".format(dot(Q[i], K[j]))
                                         for j in range(len(TOKENS))))
print()
print("the same scores divided by sqrt(d_k) = sqrt({}) = {:.1f}".format(D_K, math.sqrt(D_K)))
print("            " + "".join("{:>9}".format(t) for t in TOKENS))
for i, t in enumerate(TOKENS):
    print("   {:8} ".format(t) + "".join("{:9.4f}".format(v) for v in scores[i]))
print()
print("ATTENTION WEIGHTS after softmax on each row - who looked at whom")
print("            " + "".join("{:>9}".format(t) for t in TOKENS))
for i, t in enumerate(TOKENS):
    print("   {:8} ".format(t) + "".join("{:9.4f}".format(v) for v in weights[i]))
print()
print("Read row by row. 'cat' put 87% of its attention on 'drank'. 'drank' split")
print("its attention almost evenly between 'cat' and 'milk' - the two things it")
print("was asking about. 'the' found nothing interesting and looked at itself.")
print()
print("the output, one row per token: the values, mixed in those proportions")
print("            " + "".join("{:>9}".format(a) for a in AXES))
for i, t in enumerate(TOKENS):
    print("   {:8} ".format(t) + "".join("{:9.4f}".format(v) for v in out[i]))

rows_sum = [sum(row) for row in weights]
drank = TOKENS.index("drank")

print()
print("every row of the weight matrix sums to", ["{:.10f}".format(v) for v in rows_sum])

test("every row of the attention weights sums to one",
     all(close(v, 1.0) for v in rows_sum), True)
test("no weight is negative and none exceeds one",
     all(0.0 < v < 1.0 for row in weights for v in row), True)
test("cat put most of its attention on drank",
     weights[TOKENS.index("cat")].index(max(weights[TOKENS.index("cat")])), drank)
test("drank split its attention evenly between cat and milk",
     [close(weights[drank][1], weights[drank][3], 1e-12), weights[drank][1] > 0.45],
     [True, True])
test("and gave almost nothing to the filler word or to itself",
     [weights[drank][0] < 0.03, weights[drank][2] < 0.03], [True, True])
test("the output for cat is mostly the value row for drank",
     [close(out[1][1], 0.87, 0.01), out[1][1] > 3.0 * out[1][0]], [True, True])
test("every output is a weighted average of the values, so it stays inside their range",
     all(min(row[c] for row in V) <= out[i][c] <= max(row[c] for row in V)
         for i in range(len(TOKENS)) for c in range(len(AXES))), True)

What it printed, when the build ran it

axes: ['animal', 'action', 'food', 'filler']

raw scores, query row against key column, before scaling
                  the      cat    drank     milk
   the         6.0000   0.0000   0.0000   0.0000
   cat         0.0000   0.0000   6.0000   0.0000
   drank       0.0000   6.0000   0.0000   6.0000
   milk        0.0000   0.0000   6.0000   0.0000

the same scores divided by sqrt(d_k) = sqrt(4) = 2.0
                  the      cat    drank     milk
   the         3.0000   0.0000   0.0000   0.0000
   cat         0.0000   0.0000   3.0000   0.0000
   drank       0.0000   3.0000   0.0000   3.0000
   milk        0.0000   0.0000   3.0000   0.0000

ATTENTION WEIGHTS after softmax on each row - who looked at whom
                  the      cat    drank     milk
   the         0.8700   0.0433   0.0433   0.0433
   cat         0.0433   0.0433   0.8700   0.0433
   drank       0.0237   0.4763   0.0237   0.4763
   milk        0.0433   0.0433   0.8700   0.0433

Read row by row. 'cat' put 87% of its attention on 'drank'. 'drank' split
its attention almost evenly between 'cat' and 'milk' - the two things it
was asking about. 'the' found nothing interesting and looked at itself.

the output, one row per token: the values, mixed in those proportions
               animal   action     food   filler
   the         0.0433   0.0433   0.0433   0.8700
   cat         0.0433   0.8700   0.0433   0.0433
   drank       0.4763   0.0237   0.4763   0.0237
   milk        0.0433   0.8700   0.0433   0.0433

every row of the weight matrix sums to ['1.0000000000', '1.0000000000', '1.0000000000', '1.0000000000']
ok   every row of the attention weights sums to one
ok   no weight is negative and none exceeds one
ok   cat put most of its attention on drank
ok   drank split its attention evenly between cat and milk
ok   and gave almost nothing to the filler word or to itself
ok   the output for cat is mostly the value row for drank
ok   every output is a weighted average of the values, so it stays inside their range

Four things about that matrix are worth naming, because they are what people get asked:

  • it is not symmetric. "cat" gives "drank" 0.87 while "drank" gives "cat" 0.48. Attention is a directed question, not a similarity measure.
  • every row sums to 1, so each output is a weighted average of the values and can never leave the range the values span. That is asserted above.
  • the output has one row per token, same shape as the input. Attention is a rewrite, not a reduction, which is why blocks stack.
  • the cost is the square of the sequence length. Four tokens means sixteen scores; a thousand tokens means a million. That single fact drives the entire long-context research literature.

In a real model Q, K and V are not hand-written: each is the token embeddings multiplied by a learned matrix, so the model decides for itself what it is worth asking about. The panel above skips those three matrix multiplications so the arithmetic in the middle is visible; the multi-head panel below puts them back.

Why divide by the square root of d_k

The scaling looks like a detail and is the difference between a block that trains and one that does not. A dot product over d_k terms grows with d_k, and a softmax fed large numbers stops being an average and becomes a lookup — with almost no gradient left. Both halves of that are measured below rather than claimed.

The softmax saturating as d_k grows, and the scaling that stops it

ran at build time
# Why the / sqrt(d_k). Without it the softmax saturates and the gradient dies.
import math
import random


def dot(a, b):
    return sum(x * y for x, y in zip(a, b))


def softmax(row):
    shift = max(row)
    exps = [math.exp(v - shift) for v in row]
    total = sum(exps)
    return [e / total for e in exps]


def entropy(p):
    """How spread out the attention is. Zero means it looked at exactly one token."""
    return -sum(v * math.log(v) for v in p if v > 0.0)


DIMS = (4, 16, 64, 256)
KEYS = 12
TRIALS = 120


def experiment(d_k):
    """Draw a query and KEYS keys at random, TRIALS times, and average the results."""
    rng = random.Random(42)
    raw_max = raw_ent = scaled_max = scaled_ent = 0.0
    raw_slope = scaled_slope = 0.0
    collapsed = 0
    spread_of_scores = []
    same_winner = True
    for _ in range(TRIALS):
        q = [rng.gauss(0.0, 1.0) for _ in range(d_k)]
        keys = [[rng.gauss(0.0, 1.0) for _ in range(d_k)] for _ in range(KEYS)]
        raw = [dot(q, k) for k in keys]
        spread_of_scores.extend(raw)
        scaled = [v / math.sqrt(d_k) for v in raw]
        p_raw = softmax(raw)
        p_scaled = softmax(scaled)
        raw_max += max(p_raw) / TRIALS
        raw_ent += entropy(p_raw) / TRIALS
        scaled_max += max(p_scaled) / TRIALS
        scaled_ent += entropy(p_scaled) / TRIALS
        # p(1 - p) is the slope of a softmax output against its own score: what
        # backprop has to multiply by when it passes back through the attention.
        raw_slope += max(p_raw) * (1.0 - max(p_raw)) / TRIALS
        scaled_slope += max(p_scaled) * (1.0 - max(p_scaled)) / TRIALS
        if max(p_raw) > 0.99:
            collapsed += 1
        if p_raw.index(max(p_raw)) != p_scaled.index(max(p_scaled)):
            same_winner = False
    mean = sum(spread_of_scores) / len(spread_of_scores)
    sd = math.sqrt(sum((v - mean) ** 2 for v in spread_of_scores) / len(spread_of_scores))
    return {"raw_max": raw_max, "raw_ent": raw_ent, "scaled_max": scaled_max,
            "scaled_ent": scaled_ent, "sd": sd, "same_winner": same_winner,
            "raw_slope": raw_slope, "scaled_slope": scaled_slope,
            "collapsed": collapsed / float(TRIALS)}


print("Each component of q and k is a random number with a spread of about 1. The dot")
print("product adds up d_k of their products, and adding up d_k independent things")
print("multiplies the spread by sqrt(d_k). Measured over {} random queries against".format(TRIALS))
print("{} keys each:".format(KEYS))
print()
print("     d_k   spread of q.k   sqrt(d_k)   spread after dividing")
results = {}
for d_k in DIMS:
    r = experiment(d_k)
    results[d_k] = r
    print("   {:5}   {:13.4f}   {:9.4f}   {:21.4f}".format(
        d_k, r["sd"], math.sqrt(d_k), r["sd"] / math.sqrt(d_k)))

print()
print("Now what that does to the softmax over those {} keys, averaged over the".format(KEYS))
print("{} trials. 'biggest' is how much attention went to the favourite key, and".format(TRIALS))
print("entropy is how much was left over for everyone else. Uniform would be {:.4f}.".format(
    math.log(KEYS)))
print()
print("     d_k    unscaled biggest   entropy   collapsed      scaled biggest   entropy")
for d_k in DIMS:
    r = results[d_k]
    print("   {:5}   {:17.6f}   {:7.4f}   {:8.0%}      {:15.6f}   {:7.4f}".format(
        d_k, r["raw_max"], r["raw_ent"], r["collapsed"], r["scaled_max"], r["scaled_ent"]))

big = results[256]
print()
print("'collapsed' is the share of trials where one key took more than 99% of the")
print("attention. At d_k = 256 that is {:.0%} of them - the weighted average has become".format(
    big["collapsed"]))
print("a lookup. Scaled, the favourite averages {:.4f} and the rest still contribute.".format(
    big["scaled_max"]))

slope_raw = big["raw_slope"]
slope_scaled = big["scaled_slope"]
print()
print("mean slope of the unscaled softmax : p(1-p) = {:.3e}".format(slope_raw))
print("mean slope of the scaled one       : p(1-p) = {:.3e}".format(slope_scaled))
print("So {:.0f} times more gradient reaches the layers below.".format(slope_scaled / slope_raw))
print()
print("The division is not cosmetic and it is not a normalisation. It keeps the scores")
print("in the range where a softmax still has a derivative - which is the same")
print("saturation trap lesson 15 hit with too large a learning rate.")

test("the spread of a raw dot product really does grow like sqrt(d_k)",
     all(close(results[d]["sd"] / math.sqrt(d), 1.0, 0.1) for d in DIMS), True)
test("dividing by sqrt(d_k) brings every dimension back to about one",
     close(results[256]["sd"] / 16.0, results[4]["sd"] / 2.0, 0.15), True)
test("unscaled, attention sharpens with every step up in d_k",
     [results[4]["raw_max"] < results[64]["raw_max"],
      results[64]["raw_max"] < results[256]["raw_max"]], [True, True])
test("and by d_k = 256 most trials have collapsed onto a single key",
     [results[4]["collapsed"] < 0.05, results[256]["collapsed"] > 0.5], [True, True])
test("scaled, the sharpness barely moves as d_k grows",
     close(results[256]["scaled_max"], results[4]["scaled_max"], 0.1), True)
test("so it passes back under a third of the gradient the scaled version does",
     [slope_raw < 0.1, slope_scaled > 3.0 * slope_raw], [True, True])
test("and scaling never changes which key wins, only how sharply",
     all(results[d]["same_winner"] for d in DIMS), True)

What it printed, when the build ran it

Each component of q and k is a random number with a spread of about 1. The dot
product adds up d_k of their products, and adding up d_k independent things
multiplies the spread by sqrt(d_k). Measured over 120 random queries against
12 keys each:

     d_k   spread of q.k   sqrt(d_k)   spread after dividing
       4          1.9527      2.0000                  0.9763
      16          3.9789      4.0000                  0.9947
      64          8.2096      8.0000                  1.0262
     256         16.1765     16.0000                  1.0110

Now what that does to the softmax over those 12 keys, averaged over the
120 trials. 'biggest' is how much attention went to the favourite key, and
entropy is how much was left over for everyone else. Uniform would be 2.4849.

     d_k    unscaled biggest   entropy   collapsed      scaled biggest   entropy
       4            0.452636    1.6080         0%             0.263469    2.1417
      16            0.680288    0.8817         6%             0.271107    2.1227
      64            0.889003    0.2887        39%             0.312375    2.0535
     256            0.927592    0.1938        56%             0.281413    2.0894

'collapsed' is the share of trials where one key took more than 99% of the
attention. At d_k = 256 that is 56% of them - the weighted average has become
a lookup. Scaled, the favourite averages 0.2814 and the rest still contribute.

mean slope of the unscaled softmax : p(1-p) = 5.063e-02
mean slope of the scaled one       : p(1-p) = 1.932e-01
So 4 times more gradient reaches the layers below.

The division is not cosmetic and it is not a normalisation. It keeps the scores
in the range where a softmax still has a derivative - which is the same
saturation trap lesson 15 hit with too large a learning rate.
ok   the spread of a raw dot product really does grow like sqrt(d_k)
ok   dividing by sqrt(d_k) brings every dimension back to about one
ok   unscaled, attention sharpens with every step up in d_k
ok   and by d_k = 256 most trials have collapsed onto a single key
ok   scaled, the sharpness barely moves as d_k grows
ok   so it passes back under a third of the gradient the scaled version does
ok   and scaling never changes which key wins, only how sharply

The reasoning behind the constant is worth being able to reproduce on a whiteboard, because it is a standard interview question. If the components of q and k are independent with variance 1, then each product term has variance 1, and d_k of them added together has variance d_k — so the score has a spread of sqrt(d_k). Dividing by sqrt(d_k) restores a spread of about 1, which is exactly what the third column above measures. Nothing about the number is arbitrary and nothing about it is learned.

Causal masking

A language model predicts the next token. If position 2 can attend to position 3 then it can read the answer, and the training loss will be excellent and the model useless. The fix is one if: set the score to minus infinity wherever the key is in the query's future.

The masked weight matrix, and proof that the past cannot see the future

ran at build time
# Causal masking: stop a token from reading the answer off the tokens after it.
import math

TOKENS = ["the", "cat", "drank", "milk"]
K = [[0.0, 0.0, 0.0, 1.0],
     [1.0, 0.0, 0.0, 0.0],
     [0.0, 1.0, 0.0, 0.0],
     [0.0, 0.0, 1.0, 0.0]]
V = [row[:] for row in K]
Q = [[0.0, 0.0, 0.0, 6.0],
     [0.0, 6.0, 0.0, 0.0],
     [6.0, 0.0, 6.0, 0.0],
     [0.0, 6.0, 0.0, 0.0]]

NEG = float("-inf")


def dot(a, b):
    return sum(x * y for x, y in zip(a, b))


def softmax(row):
    shift = max(row)                            # always finite: the diagonal is never masked
    exps = [0.0 if v == NEG else math.exp(v - shift) for v in row]
    total = sum(exps)
    return [e / total for e in exps]


def attention(queries, keys, values, causal=False):
    scale = math.sqrt(len(keys[0]))
    weights = []
    for i, q in enumerate(queries):
        row = []
        for j, k in enumerate(keys):
            if causal and j > i:
                row.append(NEG)                 # position i may not see position j
            else:
                row.append(dot(q, k) / scale)
        weights.append(softmax(row))
    out = [[sum(w[j] * values[j][c] for j in range(len(values))) for c in range(len(values[0]))]
           for w in weights]
    return weights, out


open_weights, open_out = attention(Q, K, V)
causal_weights, causal_out = attention(Q, K, V, causal=True)


def show(title, matrix):
    print(title)
    print("            " + "".join("{:>9}".format(t) for t in TOKENS))
    for i, t in enumerate(TOKENS):
        print("   {:8} ".format(t) + "".join("{:9.4f}".format(v) for v in matrix[i]))


show("everyone sees everyone - what an encoder does", open_weights)
print()
show("causal: position i may only look at positions up to i", causal_weights)
print()
print("The upper triangle is exactly zero, not merely small, because the score there")
print("was minus infinity and exp(-inf) is 0. The rows still sum to 1, so each token")
print("is still taking a weighted average - just over a shorter list.")
print()
print("Row 'the' is [1, 0, 0, 0]: the first token has nothing to look at but itself.")
print("Row 'cat' has to settle for a 50-50 split with 'the', because the word it was")
print("actually asking for - 'drank' - is in its future. Row 'drank' gives 'cat' 0.909")
print("where the open version gave 0.476: the mass that went to 'milk' did not")
print("disappear, it was redistributed when the row was renormalised.")

# The property that makes this worth doing. Change the last token and the earlier
# outputs must not move, or training on every position at once would be cheating.
K2 = [row[:] for row in K]
V2 = [row[:] for row in V]
K2[3] = [0.0, 0.0, 0.0, 0.0]        # replace 'milk' with something else entirely
V2[3] = [9.0, 9.0, 9.0, 9.0]
_, changed_out = attention(Q, K2, V2, causal=True)
_, changed_open = attention(Q, K2, V2)

print()
print("now replace the last token's key and value with something else:")
print("   causal, output row for 'cat'  before :",
      ["{:.4f}".format(v) for v in causal_out[1]])
print("   causal, output row for 'cat'  after  :",
      ["{:.4f}".format(v) for v in changed_out[1]])
print("   open,   output row for 'cat'  after  :",
      ["{:.4f}".format(v) for v in changed_open[1]])
print()
print("That is the whole reason for the mask. Because no earlier position can see a")
print("later one, a single forward pass over a sentence gives you a valid prediction")
print("at every position at once - four training examples for the price of one.")

upper = [causal_weights[i][j] for i in range(4) for j in range(4) if j > i]
rows_sum = [sum(row) for row in causal_weights]
earlier_unchanged = all(close(a, b) for i in (0, 1, 2)
                        for a, b in zip(causal_out[i], changed_out[i]))

test("every masked weight is exactly zero, not just small", set(upper), {0.0})
test("the rows of the masked matrix still each sum to one",
     all(close(v, 1.0) for v in rows_sum), True)
test("the first token can only attend to itself", causal_weights[0], [1.0, 0.0, 0.0, 0.0])
test("the last token is the only one that still sees the whole sentence",
     [sum(1 for v in row if v > 0.0) for row in causal_weights], [1, 2, 3, 4])
test("changing the last token leaves every earlier output untouched",
     earlier_unchanged, True)
test("without the mask it changes all of them, which would be reading the answer",
     all(close(a, b) for a, b in zip(open_out[1], changed_open[1])), False)

What it printed, when the build ran it

everyone sees everyone - what an encoder does
                  the      cat    drank     milk
   the         0.8700   0.0433   0.0433   0.0433
   cat         0.0433   0.0433   0.8700   0.0433
   drank       0.0237   0.4763   0.0237   0.4763
   milk        0.0433   0.0433   0.8700   0.0433

causal: position i may only look at positions up to i
                  the      cat    drank     milk
   the         1.0000   0.0000   0.0000   0.0000
   cat         0.5000   0.5000   0.0000   0.0000
   drank       0.0453   0.9094   0.0453   0.0000
   milk        0.0433   0.0433   0.8700   0.0433

The upper triangle is exactly zero, not merely small, because the score there
was minus infinity and exp(-inf) is 0. The rows still sum to 1, so each token
is still taking a weighted average - just over a shorter list.

Row 'the' is [1, 0, 0, 0]: the first token has nothing to look at but itself.
Row 'cat' has to settle for a 50-50 split with 'the', because the word it was
actually asking for - 'drank' - is in its future. Row 'drank' gives 'cat' 0.909
where the open version gave 0.476: the mass that went to 'milk' did not
disappear, it was redistributed when the row was renormalised.

now replace the last token's key and value with something else:
   causal, output row for 'cat'  before : ['0.5000', '0.0000', '0.0000', '0.5000']
   causal, output row for 'cat'  after  : ['0.5000', '0.0000', '0.0000', '0.5000']
   open,   output row for 'cat'  after  : ['0.4332', '1.2599', '0.3899', '0.4332']

That is the whole reason for the mask. Because no earlier position can see a
later one, a single forward pass over a sentence gives you a valid prediction
at every position at once - four training examples for the price of one.
ok   every masked weight is exactly zero, not just small
ok   the rows of the masked matrix still each sum to one
ok   the first token can only attend to itself
ok   the last token is the only one that still sees the whole sentence
ok   changing the last token leaves every earlier output untouched
ok   without the mask it changes all of them, which would be reading the answer

Two mask bugs, both of which produce a model that looks fine. Using a large negative number instead of -inf — say -1e9 — mostly works and then overflows to nan in float16, which is why real implementations use the dtype's minimum rather than a round number. And masking with >= instead of > stops a token attending to itself, which quietly removes the diagonal and makes the first row a softmax over nothing. Neither raises. The way to catch both is the assertion in the panel: print the matrix and check the rows sum to 1.

Multi-head attention

One attention produces one opinion about which tokens matter. A sentence usually has several relationships in it at once — who did what, which noun a pronoun refers to, where the clause ends — and averaging them into one weight matrix loses all of them. So run the whole thing several times on narrower slices and glue the results back together.

Two heads, two different weight matrices, concatenated back to one output

ran at build time
# Multi-head attention: several attentions side by side, then glued back together.
import math
import random

TOKENS = ["the", "cat", "drank", "milk"]
X = [[0.0, 0.0, 0.0, 1.0],          # the token embeddings from the first panel
     [1.0, 0.0, 0.0, 0.0],
     [0.0, 1.0, 0.0, 0.0],
     [0.0, 0.0, 1.0, 0.0]]

D_MODEL = 4
HEADS = 2
D_HEAD = D_MODEL // HEADS           # 2. Each head works in half the width.


def dot(a, b):
    return sum(x * y for x, y in zip(a, b))


def softmax(row):
    shift = max(row)
    exps = [math.exp(v - shift) for v in row]
    total = sum(exps)
    return [e / total for e in exps]


def matmul(rows, matrix):
    """rows is n by p, matrix is p by q, the answer is n by q."""
    q = len(matrix[0])
    return [[sum(r[k] * matrix[k][c] for k in range(len(matrix))) for c in range(q)]
            for r in rows]


def projection(n_in, n_out, rng):
    """A learned matrix. Seeded, so this page prints the same numbers every build."""
    return [[rng.uniform(-1.5, 1.5) for _ in range(n_out)] for _ in range(n_in)]


def attention(queries, keys, values):
    scale = math.sqrt(len(keys[0]))
    weights = [softmax([dot(q, k) / scale for k in keys]) for q in queries]
    out = [[sum(w[j] * values[j][c] for j in range(len(values))) for c in range(len(values[0]))]
           for w in weights]
    return weights, out


rng = random.Random(42)
HEAD_PARAMS = []
for _ in range(HEADS):
    HEAD_PARAMS.append((projection(D_MODEL, D_HEAD, rng),      # W_q for this head
                        projection(D_MODEL, D_HEAD, rng),      # W_k
                        projection(D_MODEL, D_HEAD, rng)))     # W_v
W_O = projection(D_MODEL, D_MODEL, rng)                        # glues the heads back together

per_head_weights = []
per_head_out = []
for index, (w_q, w_k, w_v) in enumerate(HEAD_PARAMS):
    q = matmul(X, w_q)
    k = matmul(X, w_k)
    v = matmul(X, w_v)
    weights, out = attention(q, k, v)
    per_head_weights.append(weights)
    per_head_out.append(out)
    print("head {}: its own Q, K and V, each {} wide".format(index, D_HEAD))
    print("            " + "".join("{:>9}".format(t) for t in TOKENS))
    for i, t in enumerate(TOKENS):
        print("   {:8} ".format(t) + "".join("{:9.4f}".format(x) for x in weights[i]))
    print()

# Concatenate: head 0's output followed by head 1's, back to D_MODEL wide.
concatenated = [per_head_out[0][i] + per_head_out[1][i] for i in range(len(TOKENS))]
output = matmul(concatenated, W_O)

print("concatenated, {} + {} = {} numbers per token".format(D_HEAD, D_HEAD, D_MODEL))
for i, t in enumerate(TOKENS):
    print("   {:8} ".format(t) + "".join("{: 9.4f}".format(v) for v in concatenated[i]))
print()
print("after the output projection W_O, still {} wide, ready for the next layer".format(D_MODEL))
for i, t in enumerate(TOKENS):
    print("   {:8} ".format(t) + "".join("{: 9.4f}".format(v) for v in output[i]))

# The two heads disagree about who matters, which is the entire point of having two.
gaps = [abs(per_head_weights[0][i][j] - per_head_weights[1][i][j])
        for i in range(len(TOKENS)) for j in range(len(TOKENS))]
favourites = [[row.index(max(row)) for row in per_head_weights[h]] for h in range(HEADS)]

print()
print("head 0's favourite per row:", [TOKENS[i] for i in favourites[0]])
print("head 1's favourite per row:", [TOKENS[i] for i in favourites[1]])
print("biggest disagreement between the two matrices: {:.4f}".format(max(gaps)))
print()

# Cost. h heads of width d_model/h is the same parameter count as one head of d_model.
one_head = 3 * D_MODEL * D_MODEL
many_heads = HEADS * 3 * D_MODEL * D_HEAD
print("one head at full width      : 3 * {} * {} = {} parameters".format(
    D_MODEL, D_MODEL, one_head))
print("{} heads at width {}         : {} * 3 * {} * {} = {} parameters".format(
    HEADS, D_HEAD, HEADS, D_MODEL, D_HEAD, many_heads))
print("Identical. Splitting the width is free, and it buys several different views")
print("of the same sentence instead of one averaged compromise.")

test("each head produces a proper attention matrix, rows summing to one",
     all(close(sum(row), 1.0) for h in range(HEADS) for row in per_head_weights[h]), True)
test("each head returns half the width", [len(per_head_out[h][0]) for h in range(HEADS)], [2, 2])
test("concatenating them restores the model width",
     [len(concatenated[0]), len(output[0])], [D_MODEL, D_MODEL])
test("the two heads really do attend differently, or the second one is wasted",
     max(gaps) > 0.2, True)
test("and they disagree about which token matters on at least one row",
     favourites[0] == favourites[1], False)
test("splitting the width across heads costs exactly what one full-width head costs",
     [one_head, many_heads], [48, 48])

What it printed, when the build ran it

head 0: its own Q, K and V, each 2 wide
                  the      cat    drank     milk
   the         0.2442   0.5355   0.0924   0.1280
   cat         0.1373   0.5323   0.1056   0.2248
   drank       0.0916   0.3147   0.1822   0.4115
   milk        0.4624   0.1838   0.2311   0.1226

head 1: its own Q, K and V, each 2 wide
                  the      cat    drank     milk
   the         0.3016   0.2847   0.1130   0.3007
   cat         0.4200   0.1080   0.1057   0.3663
   drank       0.1526   0.1285   0.5697   0.1493
   milk        0.3417   0.2160   0.1154   0.3269

concatenated, 2 + 2 = 4 numbers per token
   the        -0.3629  -0.1696  -0.6576  -0.9013
   cat        -0.2080  -0.0220  -0.9846  -0.7873
   drank       0.2388  -0.0355  -0.7570  -0.7563
   milk       -0.0487  -0.6980  -0.7827  -0.8569

after the output projection W_O, still 4 wide, ready for the next layer
   the        -0.7039  -0.9056   0.5760  -0.0006
   cat        -0.2541  -0.9520   0.9309   0.0940
   drank      -0.2417  -0.9814   0.5264  -0.3649
   milk       -0.0181  -1.7925   0.3530  -0.3776

head 0's favourite per row: ['cat', 'cat', 'milk', 'the']
head 1's favourite per row: ['the', 'the', 'drank', 'the']
biggest disagreement between the two matrices: 0.4243

one head at full width      : 3 * 4 * 4 = 48 parameters
2 heads at width 2         : 2 * 3 * 4 * 2 = 48 parameters
Identical. Splitting the width is free, and it buys several different views
of the same sentence instead of one averaged compromise.
ok   each head produces a proper attention matrix, rows summing to one
ok   each head returns half the width
ok   concatenating them restores the model width
ok   the two heads really do attend differently, or the second one is wasted
ok   and they disagree about which token matters on at least one row
ok   splitting the width across heads costs exactly what one full-width head costs

The heads in that panel have random projections, so their disagreement is arbitrary. In a trained model it is not: heads specialise, and the specialisations are legible enough that people publish pictures of them — a head that tracks the previous token, a head that connects a verb to its subject, a head that finds the matching bracket. Nothing in the code makes that happen. It is what minimising next-token loss turns out to reward.

What a real implementation calls this

All four panels above are one function call in a framework, and the interesting part of the API is which of the arguments correspond to which panel.

Scaled dot-product and multi-head attention as torch calls them

shown, not run
# The same attention, as a real project writes it.
# NOT RUN HERE - torch is not installed in this environment.

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

# Panels one to three, in one call. The scaling and the mask are both arguments.
out = F.scaled_dot_product_attention(q, k, v, is_causal=True)

# Written out, it is the first panel with the batch dimensions kept:
scores = (q @ k.transpose(-2, -1)) / math.sqrt(q.size(-1))     # the / sqrt(d_k)
mask = torch.triu(torch.ones(n, n, dtype=torch.bool), diagonal=1)
scores = scores.masked_fill(mask, float("-inf"))               # the causal mask
weights = scores.softmax(dim=-1)                               # rows summing to 1
out = weights @ v                                              # the weighted average

# Panel four, packaged. embed_dim // num_heads is D_HEAD, and it must divide exactly.
attn = nn.MultiheadAttention(embed_dim=512, num_heads=8, batch_first=True)
out, weights = attn(x, x, x, need_weights=True)     # x three times is SELF attention
print(weights.shape)                                # (batch, n, n) - the matrix to plot

# Two things worth knowing that the panels above make obvious:
#
#   need_weights=False is the default in the fused path, and the fast kernels
#   (FlashAttention) never build the n by n matrix at all - which is why you cannot
#   always get the picture out of a production model.
#
#   The n by n matrix is why context length is expensive. 4k tokens is 16 million
#   scores per head per layer; 32k tokens is 64 times that.

torch is not installed in this environment, so this panel was never executed and q, k, v, x and n are never defined in it. Every line of arithmetic in it was written out in plain Python above and asserted: the scaling, the mask, the row sums, the weighted average and the per-head split all ran.

What to carry into the next lesson. Attention is: score with a dot product, divide by sqrt(d_k), softmax each row, average the values. The output has the same shape as the input, so it stacks. The mask is one if and it is what lets a model train on every position of a sentence in a single pass. Multi-head is the same function on narrower slices, at identical cost. And the one weakness left is the one lesson 16 ended on: nothing in any of these panels knows what order the tokens came in — permute the input and the output permutes with it. That is the first thing lesson 21 fixes.

21 A transformer block end to end

A transformer is one block repeated. The block is four things in a fixed order: attention, a residual add, a normalisation, and a small feed-forward network applied to each position on its own. Lesson 20 built the attention. This lesson builds everything around it and shows what each part is actually preventing.

Layer normalisation, and the two invariances it buys

Take one token's vector, subtract its mean, divide by its spread. That is the whole function. What it gains you is that the layer above stops caring how the layer below chose to scale things — which is worth demonstrating rather than describing.

Layer norm from scratch, its two invariances, and the eps guard

ran at build time
# Layer normalisation, written out. Four lines and one guard.
import math


def layer_norm(x, gamma=None, beta=None, eps=1e-5):
    """Rescale one vector to mean 0 and spread 1, then let the model scale it back."""
    n = float(len(x))
    mean = sum(x) / n
    variance = sum((v - mean) ** 2 for v in x) / n
    denominator = math.sqrt(variance + eps)          # eps, or a flat vector divides by zero
    normalised = [(v - mean) / denominator for v in x]
    if gamma is None:
        return normalised
    return [g * v + b for g, v, b in zip(gamma, normalised, beta)]


def mean_of(v):
    return sum(v) / float(len(v))


def sd_of(v):
    m = mean_of(v)
    return math.sqrt(sum((x - m) ** 2 for x in v) / float(len(v)))


X = [2.0, -1.0, 4.0, 7.0]
normed = layer_norm(X)

print("input            ", ["{: .4f}".format(v) for v in X])
print("   mean {:.4f}, spread {:.4f}".format(mean_of(X), sd_of(X)))
print()
print("layer_norm(x)    ", ["{: .4f}".format(v) for v in normed])
print("   mean {:.10f}, spread {:.10f}".format(mean_of(normed), sd_of(normed)))
print()
print("Note what it normalised ACROSS: the four numbers of this one token. Not across")
print("the batch, not across the sequence. Every token is normalised on its own, which")
print("is why the answer does not change when the batch or the sentence length does.")

# The two invariances. Shift the input, or stretch it, and nothing downstream notices.
shifted = layer_norm([v + 100.0 for v in X])
stretched = layer_norm([v * 10.0 for v in X])
print()
print("layer_norm(x + 100)", ["{: .4f}".format(v) for v in shifted])
print("layer_norm(x * 10) ", ["{: .4f}".format(v) for v in stretched])
print("Both identical to the first. A layer below can drift in scale or offset as far")
print("as it likes; the layer above sees the same thing.")

# gamma and beta: the model's chance to undo the normalisation where it does not help.
GAMMA = [2.0, 2.0, 2.0, 2.0]
BETA = [1.0, 1.0, 1.0, 1.0]
affine = layer_norm(X, GAMMA, BETA)
print()
print("with gamma = 2, beta = 1:", ["{: .4f}".format(v) for v in affine])
print("   mean {:.4f}, spread {:.4f}   <- shifted to beta, stretched to gamma".format(
    mean_of(affine), sd_of(affine)))

# The guard. A vector with no spread at all is not a strange edge case: it is a padded
# row, a dead layer, or a feature that turned out constant.
FLAT = [3.0, 3.0, 3.0, 3.0]
print()
print("layer_norm on a flat vector:", ["{: .4f}".format(v) for v in layer_norm(FLAT)])
try:
    layer_norm(FLAT, eps=0.0)
    blew_up = False
except ZeroDivisionError as error:
    blew_up = True
    print("with eps = 0 it raises     : ZeroDivisionError:", error)

test("the normalised vector has mean zero", close(mean_of(normed), 0.0), True)
test("and a spread of one", close(sd_of(normed), 1.0, 1e-5), True)
test("adding a hundred to every component changes nothing",
     all(close(a, b) for a, b in zip(normed, shifted)), True)
test("multiplying every component by ten changes nothing either",
     all(close(a, b, 1e-5) for a, b in zip(normed, stretched)), True)
test("gamma and beta put the mean and spread wherever the model wants them",
     [close(mean_of(affine), 1.0), close(sd_of(affine), 2.0, 1e-5)], [True, True])
test("a flat vector normalises to zeros rather than to nan",
     all(close(v, 0.0) for v in layer_norm(FLAT)), True)
test("and without eps that same vector divides by zero", blew_up, True)

What it printed, when the build ran it

input             [' 2.0000', '-1.0000', ' 4.0000', ' 7.0000']
   mean 3.0000, spread 2.9155

layer_norm(x)     ['-0.3430', '-1.3720', ' 0.3430', ' 1.3720']
   mean 0.0000000000, spread 0.9999994118

Note what it normalised ACROSS: the four numbers of this one token. Not across
the batch, not across the sequence. Every token is normalised on its own, which
is why the answer does not change when the batch or the sentence length does.

layer_norm(x + 100) ['-0.3430', '-1.3720', ' 0.3430', ' 1.3720']
layer_norm(x * 10)  ['-0.3430', '-1.3720', ' 0.3430', ' 1.3720']
Both identical to the first. A layer below can drift in scale or offset as far
as it likes; the layer above sees the same thing.

with gamma = 2, beta = 1: [' 0.3140', '-1.7440', ' 1.6860', ' 3.7440']
   mean 1.0000, spread 2.0000   <- shifted to beta, stretched to gamma

layer_norm on a flat vector: [' 0.0000', ' 0.0000', ' 0.0000', ' 0.0000']
with eps = 0 it raises     : ZeroDivisionError: float division by zero
ok   the normalised vector has mean zero
ok   and a spread of one
ok   adding a hundred to every component changes nothing
ok   multiplying every component by ten changes nothing either
ok   gamma and beta put the mean and spread wherever the model wants them
ok   a flat vector normalises to zeros rather than to nan
ok   and without eps that same vector divides by zero

Layer norm normalises across the features of one token. Batch norm, which came first, normalises across the batch instead — and that is why transformers do not use it. A batch statistic depends on which other examples happen to be in the batch, needs a running average kept for inference, and falls apart when sequences have different lengths and half the batch is padding. Layer norm has no batch dependence at all, which means one token gives the same answer whether it arrives alone or in a batch of 512. That property is worth more than the marginal difference in convergence.

What the residual connection is actually for

x + sublayer(x) looks like almost nothing. Its effect is on the backward pass: the derivative of x + f(x) is 1 + f'(x), and that 1 is a route from the loss back to the input that nothing can attenuate. Here it is measured on a deep chain, with the analytic products checked against a nudge as lesson 14 insisted.

A gradient vanishing over 32 layers, and surviving with a residual

ran at build time
# What the residual connection is for. A deep chain, with and without it.
import math

W = 0.5
X0 = 0.5


def sublayer(x):
    """Stand-in for one attention or feed-forward block: something that bends."""
    return W * math.tanh(x)


def d_sublayer(x):
    """Its local derivative, from lesson 13's tanh identity."""
    return W * (1.0 - math.tanh(x) ** 2)


def run(depth, residual):
    """Forward through depth copies, keeping the derivative product as we go."""
    x = X0
    slope = 1.0
    for _ in range(depth):
        if residual:
            slope *= 1.0 + d_sublayer(x)        # d(x + f(x))/dx  =  1 + f'(x)
            x = x + sublayer(x)
        else:
            slope *= d_sublayer(x)              # d f(x)/dx  =  f'(x)
            x = sublayer(x)
    return x, slope


print("one sublayer is f(x) = {} * tanh(x), starting from x = {}".format(W, X0))
print()
print("   depth      plain: output        d out / d in      residual: output    d out / d in")
plain = {}
residual = {}
for depth in (1, 2, 4, 8, 16, 32):
    p_out, p_slope = run(depth, False)
    r_out, r_slope = run(depth, True)
    plain[depth] = p_slope
    residual[depth] = r_slope
    print("   {:5}   {:17.10f}   {:15.3e}   {:17.4f}   {:14.3e}".format(
        depth, p_out, p_slope, r_out, r_slope))

print()
print("Plain, every layer multiplies the gradient by f'(x), which is less than 1, so")
print("after 32 layers there is nothing left for the first layer to learn from. This is")
print("the vanishing gradient, and it is why nobody trained a 30-layer network in 1995.")
print()
print("With a residual, every layer multiplies by 1 + f'(x). The 1 is the point: it is")
print("a route from the loss back to the input that no activation can attenuate.")


# The clearest version of the argument. Saturate the activation so f'(x) is zero.
def saturated(residual_path):
    x = 30.0                            # tanh(30) is 1.0 to every digit a float has
    slope = 1.0
    for _ in range(10):
        local = W * (1.0 - math.tanh(x) ** 2)
        slope *= (1.0 + local) if residual_path else local
        x = (x + W * math.tanh(x)) if residual_path else W * math.tanh(x)
    return slope


print()
print("now force f'(x) to zero by saturating tanh, ten layers deep:")
print("   plain    d out / d in =", saturated(False), " <- the layer cannot learn at all")
print("   residual d out / d in =", saturated(True), " <- the gradient arrives intact")


# Check the analytic products against a nudge, as lesson 14 insisted.
def output_only(x, depth, residual_path):
    for _ in range(depth):
        x = (x + W * math.tanh(x)) if residual_path else W * math.tanh(x)
    return x


H = 1e-6
checks = []
for residual_path in (False, True):
    numeric = (output_only(X0 + H, 4, residual_path)
               - output_only(X0 - H, 4, residual_path)) / (2.0 * H)
    analytic = run(4, residual_path)[1]
    checks.append(abs(numeric - analytic))
    print()
    print("depth 4, residual = {}:  analytic {:.10f}   nudge {:.10f}".format(
        residual_path, analytic, numeric))

print()
print("And notice the residual column climbing. 1 + f'(x) is bigger than 1, so 32 of")
print("them multiply up rather than down. Residuals cure the vanishing gradient and")
print("introduce a growth problem, which is exactly the job the layer norm does.")

test("plain, the gradient shrinks with every extra layer",
     [plain[2] < plain[1], plain[8] < plain[4], plain[32] < plain[16]], [True, True, True])
test("by 32 layers there is essentially none of it left", plain[32] < 1e-8, True)
test("with a residual the gradient at 32 layers is still large",
     residual[32] > 1.0, True)
test("saturate the activation and plain delivers exactly zero, residual exactly one",
     [saturated(False), saturated(True)], [0.0, 1.0])
test("both analytic products agree with a nudge on the whole chain",
     all(gap < 1e-5 for gap in checks), True)
test("but the residual product grows with depth, which is what a norm is there to stop",
     residual[32] > residual[1], True)

What it printed, when the build ran it

one sublayer is f(x) = 0.5 * tanh(x), starting from x = 0.5

   depth      plain: output        d out / d in      residual: output    d out / d in
       1        0.2310585786         3.932e-01              0.7311        1.393e+00
       2        0.1135163044         1.865e-01              1.0429        1.819e+00
       4        0.0282277571         4.588e-02              1.8785        2.398e+00
       8        0.0017636128         2.864e-03              3.8422        2.573e+00
      16        0.0000068891         1.119e-05              7.8415        2.577e+00
      32        0.0000000001         1.707e-10             15.8415        2.577e+00

Plain, every layer multiplies the gradient by f'(x), which is less than 1, so
after 32 layers there is nothing left for the first layer to learn from. This is
the vanishing gradient, and it is why nobody trained a 30-layer network in 1995.

With a residual, every layer multiplies by 1 + f'(x). The 1 is the point: it is
a route from the loss back to the input that no activation can attenuate.

now force f'(x) to zero by saturating tanh, ten layers deep:
   plain    d out / d in = 0.0  <- the layer cannot learn at all
   residual d out / d in = 1.0  <- the gradient arrives intact

depth 4, residual = False:  analytic 0.0458771505   nudge 0.0458771505

depth 4, residual = True:  analytic 2.3983702611   nudge 2.3983702611

And notice the residual column climbing. 1 + f'(x) is bigger than 1, so 32 of
them multiply up rather than down. Residuals cure the vanishing gradient and
introduce a growth problem, which is exactly the job the layer norm does.
ok   plain, the gradient shrinks with every extra layer
ok   by 32 layers there is essentially none of it left
ok   with a residual the gradient at 32 layers is still large
ok   saturate the activation and plain delivers exactly zero, residual exactly one
ok   both analytic products agree with a nudge on the whole chain
ok   but the residual product grows with depth, which is what a norm is there to stop

The mental model worth keeping is the residual stream. The vector flowing down the middle of the model is not recomputed by each block; it is added to. Each block reads the stream, works out a correction, and writes that correction back. A block that has nothing useful to contribute can output near zero and the stream passes through unharmed, which is also why you can prune or skip blocks in a trained transformer and often lose surprisingly little.

The block, one step at a time

Now assemble it. Attention, residual, normalisation, feed-forward, residual. The panel prints every intermediate matrix so the order is visible, and then does the same block the other way round — normalise first, or normalise last — because that choice has consequences.

Every stage of a block printed, then the same block post-norm

ran at build time
# One transformer block, assembled. The order of the four parts is the lesson.
import math
import random

TOKENS = ["the", "cat", "drank", "milk"]
D_MODEL = 4
D_FF = 16                       # the feed-forward middle is conventionally 4 * d_model
NEG = float("-inf")


def layer_norm(x, gamma, beta, eps=1e-5):
    n = float(len(x))
    mean = sum(x) / n
    variance = sum((v - mean) ** 2 for v in x) / n
    denominator = math.sqrt(variance + eps)
    return [g * ((v - mean) / denominator) + b for g, v, b in zip(gamma, x, beta)]


def norm_rows(rows, gamma, beta):
    return [layer_norm(r, gamma, beta) for r in rows]


def matmul(rows, m):
    return [[sum(r[k] * m[k][c] for k in range(len(m))) for c in range(len(m[0]))] for r in rows]


def softmax(row):
    shift = max(row)
    exps = [0.0 if v == NEG else math.exp(v - shift) for v in row]
    total = sum(exps)
    return [e / total for e in exps]


def attention(rows, w_q, w_k, w_v, w_o):
    """Lesson 20's causal self-attention, one head, with its output projection."""
    q = matmul(rows, w_q)
    k = matmul(rows, w_k)
    v = matmul(rows, w_v)
    scale = math.sqrt(len(k[0]))
    out = []
    for i in range(len(rows)):
        scores = [NEG if j > i else sum(a * b for a, b in zip(q[i], k[j])) / scale
                  for j in range(len(rows))]
        w = softmax(scores)
        out.append([sum(w[j] * v[j][c] for j in range(len(rows))) for c in range(len(v[0]))])
    return matmul(out, w_o)


def feed_forward(rows, w1, b1, w2, b2):
    """Same width in, same width out, wider in the middle. Position by position."""
    out = []
    for r in rows:
        hidden = [max(0.0, sum(r[k] * w1[k][c] for k in range(len(w1))) + b1[c])
                  for c in range(len(w1[0]))]
        out.append([sum(hidden[k] * w2[k][c] for k in range(len(w2))) + b2[c]
                    for c in range(len(w2[0]))])
    return out


def matrix(n_in, n_out, rng):
    return [[rng.uniform(-0.5, 0.5) for _ in range(n_out)] for _ in range(n_in)]


rng = random.Random(42)
W_Q, W_K, W_V, W_O = (matrix(D_MODEL, D_MODEL, rng) for _ in range(4))
W1 = matrix(D_MODEL, D_FF, rng)
B1 = [rng.uniform(-0.5, 0.5) for _ in range(D_FF)]
W2 = matrix(D_FF, D_MODEL, rng)
B2 = [rng.uniform(-0.5, 0.5) for _ in range(D_MODEL)]
G1 = [1.0] * D_MODEL            # layer norm starts as the identity: gamma 1, beta 0
BE1 = [0.0] * D_MODEL
G2 = [1.0] * D_MODEL
BE2 = [0.0] * D_MODEL

X = [[0.2, -1.4, 0.9, 3.1],
     [1.0, 0.1, -0.3, 0.0],
     [-2.0, 0.5, 0.4, 1.2],
     [0.3, 0.3, -1.1, 0.7]]


def add(a, b, scale=1.0):
    return [[p + scale * q for p, q in zip(ra, rb)] for ra, rb in zip(a, b)]


def pre_norm_block(x, scale=1.0):
    """normalise, sublayer, add. What GPT-2 onwards actually does."""
    h = add(x, attention(norm_rows(x, G1, BE1), W_Q, W_K, W_V, W_O), scale)
    return add(h, feed_forward(norm_rows(h, G2, BE2), W1, B1, W2, B2), scale)


def post_norm_block(x, scale=1.0):
    """sublayer, add, normalise. What the original 2017 paper published."""
    h = norm_rows(add(x, attention(x, W_Q, W_K, W_V, W_O), scale), G1, BE1)
    return norm_rows(add(h, feed_forward(h, W1, B1, W2, B2), scale), G2, BE2)


def show(title, rows):
    print(title)
    for name, row in zip(TOKENS, rows):
        print("   {:8} ".format(name) + "".join("{: 10.4f}".format(v) for v in row))


show("input, one row per token, {} numbers each".format(D_MODEL), X)
print()

# The four stages of a pre-norm block, in order, so nothing is hidden.
step1 = norm_rows(X, G1, BE1)
step2 = attention(step1, W_Q, W_K, W_V, W_O)
step3 = add(X, step2)
step4 = norm_rows(step3, G2, BE2)
step5 = feed_forward(step4, W1, B1, W2, B2)
step6 = add(step3, step5)

show("1. layer_norm(x)", step1)
print()
show("2. attention of that - tokens mixing, the only place they ever do", step2)
print()
show("3. x + attention, the first residual", step3)
print()
show("4. layer_norm again", step4)
print()
show("5. feed_forward of that - each token on its own, no mixing", step5)
print()
show("6. + the second residual: the block output", step6)
print()
print("Only step 2 lets one token see another. Steps 4 to 6 treat every row separately,")
print("which is why the feed-forward is described as position-wise and why two thirds of")
print("a transformer's parameters do no mixing at all.")

post = post_norm_block(X)
print()
show("the same block written post-norm: ln(x + sublayer(x))", post)


def rms(rows):
    flat = [v for r in rows for v in r]
    return math.sqrt(sum(v * v for v in flat) / len(flat))


print()
print("Stacking the same block over and over (weights reused, for a clean comparison):")
print()
print("   depth   pre-norm stream size   post-norm stream size")
pre_sizes = {}
post_sizes = {}
pre_state = [r[:] for r in X]
post_state = [r[:] for r in X]
for depth in range(13):
    if depth in (0, 1, 2, 4, 8, 12):
        pre_sizes[depth] = rms(pre_state)
        post_sizes[depth] = rms(post_state)
        print("   {:5}   {:20.4f}   {:21.4f}".format(depth, pre_sizes[depth], post_sizes[depth]))
    pre_state = pre_norm_block(pre_state)
    post_state = post_norm_block(post_state)

silent = pre_norm_block(X, scale=0.0)
params = 4 * D_MODEL * D_MODEL + D_MODEL * D_FF + D_FF + D_FF * D_MODEL + D_MODEL + 4 * D_MODEL

print()
print("pre-norm leaves the residual stream free to grow, so every large model ends with")
print("one final layer_norm before the output head. post-norm normalises last, so the")
print("stream is pinned - and the gradient has to pass through a normalisation on its")
print("way back to every layer, which is why deep post-norm stacks need a warmup")
print("schedule to train at all.")
print()
print("parameters in this block:", params)

test("the block returns exactly the shape it was given",
     [len(step6), len(step6[0])], [len(TOKENS), D_MODEL])
test("silence both sublayers and the pre-norm block hands back its input untouched",
     all(close(a, b) for ra, rb in zip(silent, X) for a, b in zip(ra, rb)), True)
test("post-norm ends on a normalisation, so every output row has spread one",
     all(close(math.sqrt(sum((v - sum(r) / D_MODEL) ** 2 for v in r) / D_MODEL), 1.0, 1e-4)
         for r in post), True)
test("pre-norm does not, which is why a final norm sits before the output head",
     all(close(math.sqrt(sum((v - sum(r) / D_MODEL) ** 2 for v in r) / D_MODEL), 1.0, 1e-4)
         for r in step6), False)
test("stacked twelve deep, the pre-norm stream has grown a long way",
     pre_sizes[12] > 3.0 * pre_sizes[0], True)
test("while the post-norm stream is pinned wherever the normalisation puts it",
     close(post_sizes[12], post_sizes[1], 0.2), True)
test("the block holds 228 parameters, 148 of them in the feed-forward",
     [params, D_MODEL * D_FF + D_FF + D_FF * D_MODEL + D_MODEL], [228, 148])

What it printed, when the build ran it

input, one row per token, 4 numbers each
   the          0.2000   -1.4000    0.9000    3.1000
   cat          1.0000    0.1000   -0.3000    0.0000
   drank       -2.0000    0.5000    0.4000    1.2000
   milk         0.3000    0.3000   -1.1000    0.7000

1. layer_norm(x)
   the         -0.3092   -1.2986    0.1237    1.4841
   cat          1.6502   -0.2063   -1.0314   -0.4126
   drank       -1.6748    0.3929    0.3102    0.9718
   milk         0.3656    0.3656   -1.6819    0.9506

2. attention of that - tokens mixing, the only place they ever do
   the          0.1515   -0.4971    0.3524    0.2595
   cat         -0.0066    0.0518    0.2664    0.2357
   drank       -0.0201    0.0595    0.1738    0.1666
   milk        -0.0811    0.1856    0.1572    0.1943

3. x + attention, the first residual
   the          0.3515   -1.8971    1.2524    3.3595
   cat          0.9934    0.1518   -0.0336    0.2357
   drank       -2.0201    0.5595    0.5738    1.3666
   milk         0.2189    0.4856   -0.9428    0.8943

4. layer_norm again
   the         -0.2201   -1.4124    0.2576    1.3749
   cat          1.6775   -0.4727   -0.9464   -0.2584
   drank       -1.6745    0.3439    0.3551    0.9755
   milk         0.0804    0.4710   -1.6210    1.0696

5. feed_forward of that - each token on its own, no mixing
   the          0.0887   -0.4677    0.1241   -0.6497
   cat         -1.2273    0.5467    0.6088    0.4716
   drank       -0.1238   -0.2006   -0.7904   -0.5756
   milk        -0.6148    0.0702   -0.2474   -0.1013

6. + the second residual: the block output
   the          0.4402   -2.3647    1.3765    2.7099
   cat         -0.2339    0.6985    0.5752    0.7074
   drank       -2.1438    0.3589   -0.2166    0.7910
   milk        -0.3959    0.5558   -1.1902    0.7931

Only step 2 lets one token see another. Steps 4 to 6 treat every row separately,
which is why the feed-forward is described as position-wise and why two thirds of
a transformer's parameters do no mixing at all.

the same block written post-norm: ln(x + sublayer(x))
   the          0.1304   -1.6706    0.7149    0.8252
   cat          1.0187   -1.6334    0.5368    0.0779
   drank       -1.6229    0.3003    0.2135    1.1092
   milk        -0.0615    0.2700   -1.5009    1.2925

Stacking the same block over and over (weights reused, for a clean comparison):

   depth   pre-norm stream size   post-norm stream size
       0                 1.1524                  1.1524
       1                 1.2325                  1.0000
       2                 1.4255                  1.0000
       4                 2.4089                  1.0000
       8                 4.9122                  1.0000
      12                 7.8733                  1.0000

pre-norm leaves the residual stream free to grow, so every large model ends with
one final layer_norm before the output head. post-norm normalises last, so the
stream is pinned - and the gradient has to pass through a normalisation on its
way back to every layer, which is why deep post-norm stacks need a warmup
schedule to train at all.

parameters in this block: 228
ok   the block returns exactly the shape it was given
ok   silence both sublayers and the pre-norm block hands back its input untouched
ok   post-norm ends on a normalisation, so every output row has spread one
ok   pre-norm does not, which is why a final norm sits before the output head
ok   stacked twelve deep, the pre-norm stream has grown a long way
ok   while the post-norm stream is pinned wherever the normalisation puts it
ok   the block holds 228 parameters, 148 of them in the feed-forward

The two arrangements, and why you will see both:

  • post-norm, layer_norm(x + sublayer(x)) — the original 2017 paper. Every output is normalised, so nothing can drift, but the gradient must pass through a normalisation on its way back through every layer, and the clean residual path is broken. Deep post-norm stacks are notoriously sensitive at the start of training, which is where learning-rate warmup came from.
  • pre-norm, x + sublayer(layer_norm(x)) — GPT-2 onwards, and essentially everything current. The residual path from input to output is now a pure sum with no normalisation on it, so the gradient route stays clean and very deep stacks train without heroics. The cost is the growth in the table above, which is why these models end with one final layer_norm before the output projection.

The order is not a stylistic preference and getting it wrong is a silent bug. Write layer_norm(x) + sublayer(x) — normalise the residual branch instead of the sublayer input — and the shapes are fine, the loss falls, and you have destroyed the identity path the whole design rests on. Similarly, x + sublayer(x) with no norm anywhere trains at small depth and diverges at large depth, for the reason the residual column in the previous panel shows. When someone's hand-written transformer trains 10% worse than the reference for no visible reason, this is the first thing to check.

Positional encoding, and the permutation that proves it is needed

Nothing in any panel so far knows what order the tokens arrived in. Attention scores every pair, and a set of pairs has no order. That is not a subtle point and it does not need to be taken on trust: permute the input and check what comes out.

Sinusoidal positions, and the permutation test with and without them

ran at build time
# Positional encoding, and proof that without it the block cannot see word order.
import math
import random

TOKENS = ["the", "cat", "drank", "milk"]
D_MODEL = 8


def positional(position, d_model):
    """The 2017 sinusoidal encoding: alternating sine and cosine, at falling rates."""
    out = []
    for i in range(d_model // 2):
        rate = 1.0 / (10000.0 ** (2.0 * i / d_model))
        out.append(math.sin(position * rate))
        out.append(math.cos(position * rate))
    return out


PE = [positional(p, D_MODEL) for p in range(6)]

print("the encoding, one row per position, {} numbers each".format(D_MODEL))
print("   pos  " + "".join("{:>9}".format("sin" if c % 2 == 0 else "cos")
                           for c in range(D_MODEL)))
for p, row in enumerate(PE):
    print("   {:4} ".format(p) + "".join("{: 9.4f}".format(v) for v in row))
print()
print("Column 0 and 1 turn fastest, so they separate adjacent positions. The later")
print("columns turn slowly, so they carry roughly where in the sequence you are. No")
print("parameters at all: it is a fixed function of the position.")


def norm(v):
    return math.sqrt(sum(x * x for x in v))


def dot(a, b):
    return sum(x * y for x, y in zip(a, b))


print()
print("   every row has the same length:", ["{:.6f}".format(norm(r)) for r in PE])
print("   PE[0] . PE[k] as k grows     :",
      ["{:.4f}".format(dot(PE[0], PE[k])) for k in range(6)])
print("   A position is most like itself and less like each of the next few. It then")
print("   turns back up, because sines repeat - the fastest column has a period of")
print("   about 6.3 positions. With d_model of 8 that wrap is visible at k = 4. In a")
print("   real model d_model is 768 and the slow columns keep distant positions apart.")

# ---- now the point. Attention on its own cannot tell one order from another ----
EMBEDDING = {"the": [0.2, -1.4, 0.9, 0.1, 0.5, -0.3, 0.0, 0.7],
             "cat": [1.0, 0.1, -0.3, 0.4, -0.6, 0.2, 0.8, -0.1],
             "drank": [-0.5, 0.7, 0.4, -1.2, 0.3, 0.9, -0.4, 0.2],
             "milk": [0.3, 0.3, -1.1, 0.6, 0.1, -0.8, 0.5, 0.4]}


def matmul(rows, m):
    return [[sum(r[k] * m[k][c] for k in range(len(m))) for c in range(len(m[0]))] for r in rows]


def softmax(row):
    shift = max(row)
    exps = [math.exp(v - shift) for v in row]
    total = sum(exps)
    return [e / total for e in exps]


def matrix(n, rng):
    return [[rng.uniform(-0.5, 0.5) for _ in range(n)] for _ in range(n)]


rng = random.Random(42)
W_Q, W_K, W_V = matrix(D_MODEL, rng), matrix(D_MODEL, rng), matrix(D_MODEL, rng)


def block(rows):
    """Attention with no mask and no positions: exactly lesson 20's first panel."""
    q, k, v = matmul(rows, W_Q), matmul(rows, W_K), matmul(rows, W_V)
    scale = math.sqrt(D_MODEL)
    out = []
    for i in range(len(rows)):
        w = softmax([dot(q[i], k[j]) / scale for j in range(len(rows))])
        out.append([sum(w[j] * v[j][c] for j in range(len(rows))) for c in range(D_MODEL)])
    return out


def summarise(rows):
    """A readout that does not care about order: average the positions."""
    return [sum(r[c] for r in rows) / len(rows) for c in range(D_MODEL)]


ORDER = ["the", "cat", "drank", "milk"]
SHUFFLED = ["milk", "drank", "cat", "the"]

plain_a = block([EMBEDDING[t] for t in ORDER])
plain_b = block([EMBEDDING[t] for t in SHUFFLED])

print()
print("WITHOUT positions")
print("   sentence  :", " ".join(ORDER))
for name, row in zip(ORDER, plain_a):
    print("      {:8}".format(name) + "".join("{: 9.4f}".format(v) for v in row[:4]), " ...")
print("   reversed  :", " ".join(SHUFFLED))
for name, row in zip(SHUFFLED, plain_b):
    print("      {:8}".format(name) + "".join("{: 9.4f}".format(v) for v in row[:4]), " ...")

# The output rows came back in the new order, unchanged. Attention is permutation
# equivariant: permute the input and the output permutes with it, nothing else.
permuted_match = all(
    close(a, b)
    for t in ORDER
    for a, b in zip(plain_a[ORDER.index(t)], plain_b[SHUFFLED.index(t)]))

print()
print("   every output row is bit for bit the same, just reordered:", permuted_match)
print("   so any order-blind readout gives the identical answer:")
print("      original :", ["{: .6f}".format(v) for v in summarise(plain_a)[:4]], "...")
print("      reversed :", ["{: .6f}".format(v) for v in summarise(plain_b)[:4]], "...")

# Add the position to the embedding. One addition, and the symmetry is gone.
placed_a = block([[e + p for e, p in zip(EMBEDDING[t], PE[i])] for i, t in enumerate(ORDER)])
placed_b = block([[e + p for e, p in zip(EMBEDDING[t], PE[i])] for i, t in enumerate(SHUFFLED)])

print()
print("WITH positions added to the embeddings")
print("      original :", ["{: .6f}".format(v) for v in summarise(placed_a)[:4]], "...")
print("      reversed :", ["{: .6f}".format(v) for v in summarise(placed_b)[:4]], "...")
gap = max(abs(a - b) for a, b in zip(summarise(placed_a), summarise(placed_b)))
print("   biggest difference between the two summaries: {:.6f}".format(gap))
print()
print("That is the whole of it. A transformer has no loops and no convolutions, so")
print("position is not implied by anything in the architecture. It has to be added to")
print("the input, and if you forget, the model trains happily on a bag of words.")

no_pos_gap = max(abs(a - b) for a, b in zip(summarise(plain_a), summarise(plain_b)))

test("every positional row has the same length, so no position is louder than another",
     all(close(norm(r), norm(PE[0])) for r in PE), True)
test("the encoding is a function of position alone, with nothing learned",
     positional(3, D_MODEL), PE[3])
test("a position is most like itself, and less like each of the next three",
     [max(range(6), key=lambda k: dot(PE[0], PE[k])),
      dot(PE[0], PE[1]) > dot(PE[0], PE[2]) > dot(PE[0], PE[3])], [0, True])
test("without positions, permuting the input just permutes the output", permuted_match, True)
test("so an order-blind summary of the sentence is exactly unchanged",
     close(no_pos_gap, 0.0, 1e-12), True)
test("add the positions and the same permutation gives a different answer", gap > 0.05, True)

What it printed, when the build ran it

the encoding, one row per position, 8 numbers each
   pos        sin      cos      sin      cos      sin      cos      sin      cos
      0    0.0000   1.0000   0.0000   1.0000   0.0000   1.0000   0.0000   1.0000
      1    0.8415   0.5403   0.0998   0.9950   0.0100   1.0000   0.0010   1.0000
      2    0.9093  -0.4161   0.1987   0.9801   0.0200   0.9998   0.0020   1.0000
      3    0.1411  -0.9900   0.2955   0.9553   0.0300   0.9996   0.0030   1.0000
      4   -0.7568  -0.6536   0.3894   0.9211   0.0400   0.9992   0.0040   1.0000
      5   -0.9589   0.2837   0.4794   0.8776   0.0500   0.9988   0.0050   1.0000

Column 0 and 1 turn fastest, so they separate adjacent positions. The later
columns turn slowly, so they carry roughly where in the sequence you are. No
parameters at all: it is a fixed function of the position.

   every row has the same length: ['2.000000', '2.000000', '2.000000', '2.000000', '2.000000', '2.000000']
   PE[0] . PE[k] as k grows     : ['4.0000', '3.5353', '2.5637', '1.9649', '2.2666', '3.1600']
   A position is most like itself and less like each of the next few. It then
   turns back up, because sines repeat - the fastest column has a period of
   about 6.3 positions. With d_model of 8 that wrap is visible at k = 4. In a
   real model d_model is 768 and the slow columns keep distant positions apart.

WITHOUT positions
   sentence  : the cat drank milk
      the       -0.1378   0.0571   0.1807  -0.2113  ...
      cat       -0.0420   0.0170   0.0549  -0.1542  ...
      drank      0.0716   0.0664  -0.1648  -0.1836  ...
      milk      -0.0641  -0.1138   0.1436   0.0334  ...
   reversed  : milk drank cat the
      milk      -0.0641  -0.1138   0.1436   0.0334  ...
      drank      0.0716   0.0664  -0.1648  -0.1836  ...
      cat       -0.0420   0.0170   0.0549  -0.1542  ...
      the       -0.1378   0.0571   0.1807  -0.2113  ...

   every output row is bit for bit the same, just reordered: True
   so any order-blind readout gives the identical answer:
      original : ['-0.043068', ' 0.006697', ' 0.053570', '-0.128936'] ...
      reversed : ['-0.043068', ' 0.006697', ' 0.053570', '-0.128936'] ...

WITH positions added to the embeddings
      original : ['-0.281563', '-0.237581', ' 0.368353', '-1.054374'] ...
      reversed : ['-0.347562', '-0.224593', ' 0.392750', '-1.016316'] ...
   biggest difference between the two summaries: 0.091734

That is the whole of it. A transformer has no loops and no convolutions, so
position is not implied by anything in the architecture. It has to be added to
the input, and if you forget, the model trains happily on a bag of words.
ok   every positional row has the same length, so no position is louder than another
ok   the encoding is a function of position alone, with nothing learned
ok   a position is most like itself, and less like each of the next three
ok   without positions, permuting the input just permutes the output
ok   so an order-blind summary of the sentence is exactly unchanged
ok   add the positions and the same permutation gives a different answer

Three ways this is done, so job adverts stop being cryptic. Sinusoidal is the panel above: fixed, free, no parameters, extends past the training length. Learned absolute is an ordinary embedding table indexed by position, used by GPT-2 and BERT — simple, and it hard-caps the context length at however many rows you trained. Rotary (RoPE), which almost every current model uses, rotates q and k by an angle proportional to position before the dot product, so the score depends on the difference between two positions rather than their absolute values. That relative property is what makes context-length extension tricks possible at all.

The whole block, in a framework

Eight lines. Worth reading with the panels above in mind, because two of the arguments are choices this lesson spent panels on.

nn.TransformerEncoderLayer, and the two arguments that matter

shown, not run
# The same block, as torch provides it.
# NOT RUN HERE - torch is not installed in this environment.

import torch
import torch.nn as nn

block = nn.TransformerEncoderLayer(
    d_model=512,
    nhead=8,                      # lesson 20's heads: 512 / 8 = 64 wide each
    dim_feedforward=2048,         # the 4 * d_model convention from the third panel
    activation="gelu",            # relu's smoother cousin, now the default in practice
    norm_first=True,              # TRUE is pre-norm. The default is False, i.e. post-norm.
    batch_first=True,
)

# Written out, norm_first=True is exactly the third panel:
#   h = x + attn(layer_norm_1(x))
#   y = h + ff(layer_norm_2(h))
# and norm_first=False is:
#   h = layer_norm_1(x + attn(x))
#   y = layer_norm_2(h + ff(h))

model = nn.TransformerEncoder(block, num_layers=12)
final_norm = nn.LayerNorm(512)    # the growth the third panel measured is why this exists

# Positions are NOT included. nn.TransformerEncoderLayer has no idea where a token is,
# so you add them yourself - and if you forget, nothing raises. This is the single most
# common bug in a hand-rolled transformer.
positions = nn.Embedding(max_len, 512)        # learned absolute, GPT-2 style
x = token_embeddings + positions(torch.arange(seq_len))

# A causal decoder block wants the mask from lesson 20 as well:
mask = nn.Transformer.generate_square_subsequent_mask(seq_len)
out = model(x, mask=mask, is_causal=True)

torch is not installed in this environment, so this panel was never executed and max_len, seq_len and token_embeddings are never defined in it. Both norm orders it describes were written out in plain Python above and their different behaviour under stacking was measured; the missing-positions warning is exactly what the fourth panel asserted.

Where this leaves you, and it is the end of the architecture. A transformer block is: normalise, attend, add; normalise, feed-forward, add. Attention is the only place tokens see each other — the feed-forward, which holds about two thirds of the parameters, works on each position alone. The residual is there so the gradient has an unattenuated path back to the input; the normalisation is there so the stream that residual builds does not run away. Position is not in the architecture and has to be added to the input. Stack that block twelve or ninety-six times, put an embedding table at the front and a projection to vocabulary logits at the back, feed those logits to lesson 6's softmax and lesson 19's sampler, and you have described every model this course is about.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.