In [1]:
import plotly.io as pio; pio.renderers.default = "notebook_connected"

Lecture 18: Transformers and LLMs (CS 189, Fall 2026)

This notebook follows the path of a piece of text through a decoder-only language model: text is split into tokens, tokens become embedding vectors, a stack of masked transformer layers maps them to one vector per position, and a linear layer with a softmax turns each vector into a distribution over the next token (Bishop, Sections 12.2 and 12.3). The last section trains a small model of this kind on the works of Shakespeare.

Notebook section Slide
(Lecture Demo 1) Tokens: the Qwen3 Tokenizer Tiktoken App (Tokenizer Demo)
Byte Pair Encoding Byte Pair Encoding
Language Modeling and Token Embeddings Embedding Table Lookup; Predicting the Next Token (Transformer)
The Causal Mask The Masked Attention; The Causal Mask
Gated MLPs (SwiGLU) Gated MLPs (SwiGLU)
Mixture of Experts Mixture of Experts (MoE); Total vs. Active Parameters; Keeping the Experts Balanced
(Lecture Demo 2) A Decoder-Only Transformer on Shakespeare Decoder Transformer Notebook

Sampling strategies (temperature, top-$k$, top-$p$), the KV cache, and speculative decoding are in the Lecture 19 notebook.

Notation. As in Bishop, $K$ is the number of tokens in the dictionary (the vocabulary size), $D$ the embedding dimension, and $N$ the number of tokens in a sequence. In code the vocabulary size is vocab_size, since K is also the key matrix.

Runtime. About 3 minutes on a laptop: the training run in the last section takes 2 to 3 minutes on an Apple GPU (MPS). The tokenizer section loads the Qwen3 tokenizer from Hugging Face (a few MB on first use).

In [2]:
import math
import os
import re
import time
import urllib.request
from collections import Counter

import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
import plotly.express as px
import plotly.graph_objects as go
from plotly.subplots import make_subplots
from IPython.display import HTML, display

DEVICE = 'cuda' if torch.cuda.is_available() else ('mps' if torch.backends.mps.is_available() else 'cpu')
SEED = 189
torch.manual_seed(SEED)
print('device:', DEVICE)
device: mps

Plotting Code

The functions below draw the token displays and figures in this notebook. They are not part of the course material, but they must be run before the rest of the notebook.

In [3]:
BLUE, GOLD = '#002676', '#FDB515'
TOKEN_COLORS = ['#cfe2f3', '#fce5cd', '#d9ead3', '#ead1dc', '#fff2cc', '#d0e0e3']


def show_tokens(tokens, title=None):
    """Display a list of token strings with alternating background colors (like the tiktoken app).

    A space is drawn as a middle dot and a newline as a return arrow so that whitespace is visible.
    """
    spans = []
    for i, tok in enumerate(tokens):
        shown = (tok.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
                    .replace(' ', '&middot;').replace('\n', '&#8629;'))
        spans.append(f'<span style="background:{TOKEN_COLORS[i % len(TOKEN_COLORS)]};'
                     f'color:black;padding:1px 0px;margin:0px 1px;border-radius:3px">{shown}</span>')
    head = f'<b>{title}</b> ' if title else ''
    display(HTML(f'<div style="font-family:monospace;font-size:15px;line-height:2">{head}'
                 f'({len(tokens)} tokens)<br>{"".join(spans)}</div>'))


def plot_matrix(Z, x_labels, y_labels, title, text=None, colorscale='Blues', zmin=None, zmax=None,
                x_title='', y_title='', width=520, height=460):
    """Heatmap of a matrix with labeled rows and columns (row 0 at the top)."""
    fig = go.Figure(go.Heatmap(z=Z, x=x_labels, y=y_labels, text=text,
                               texttemplate='%{text}' if text is not None else None,
                               colorscale=colorscale, zmin=zmin, zmax=zmax, showscale=False,
                               xgap=1, ygap=1))
    fig.update_yaxes(autorange='reversed', title=y_title, type='category')
    fig.update_xaxes(side='top', title=x_title, type='category')
    fig.update_layout(title=title, width=width, height=height, margin=dict(l=60, r=20, t=110, b=20))
    return fig


def plot_lookup(onehot, table, result, row_labels, vocab_labels):
    """Three heatmaps: one-hot rows (N x K), the embedding table (K x D), and their product (N x D)."""
    fig = make_subplots(rows=1, cols=3, column_widths=[0.5, 0.25, 0.25], horizontal_spacing=0.08,
                        subplot_titles=['one-hot inputs (N x K)', 'embedding table (K x D)',
                                        'product = selected rows (N x D)'])
    dims = [f'd{j}' for j in range(table.shape[1])]
    fig.add_trace(go.Heatmap(z=onehot, x=vocab_labels, y=row_labels, colorscale='Greys',
                             showscale=False, xgap=1, ygap=1), 1, 1)
    fig.add_trace(go.Heatmap(z=table, x=dims, y=vocab_labels, colorscale='RdBu', zmid=0,
                             showscale=False, xgap=1, ygap=1), 1, 2)
    fig.add_trace(go.Heatmap(z=result, x=dims, y=row_labels, colorscale='RdBu', zmid=0,
                             showscale=False, xgap=1, ygap=1), 1, 3)
    for c in (1, 2, 3):
        fig.update_yaxes(autorange='reversed', type='category', row=1, col=c)
        fig.update_xaxes(type='category', row=1, col=c)
    fig.update_layout(width=1000, height=520, margin=dict(l=60, r=20, t=60, b=40))
    return fig


def plot_loss_curve(history, ln_K):
    """Training loss at every step, validation loss at the evaluation steps, and the line ln K."""
    fig = go.Figure()
    fig.add_scatter(x=history['step'], y=history['train'], mode='lines', name='training loss (one batch)',
                    line=dict(color=BLUE, width=1), opacity=0.6)
    fig.add_scatter(x=history['val_step'], y=history['val'], mode='lines+markers', name='validation loss',
                    line=dict(color=GOLD, width=3))
    fig.add_hline(y=ln_K, line_dash='dash', line_color='gray',
                  annotation_text=f'ln K = {ln_K:.2f} (uniform prediction)', annotation_position='top right')
    fig.update_layout(xaxis_title='step', yaxis_title='cross-entropy (nats per token)',
                      width=800, height=420, margin=dict(l=60, r=20, t=40, b=50))
    return fig

(Lecture Demo 1) Tokens: the Qwen3 Tokenizer

Lecture: run from the "Tiktoken App" slide (Tokenizer Demo).

A language model does not read characters or words. A tokenizer first maps the text to a sequence of integer token ids from a fixed dictionary of $K$ tokens (Bishop, Section 12.2.2). Below is the tokenizer of the open Qwen3 models (the same tokenizer is used by Qwen3-0.6B and Qwen3-1.7B in Lecture 19).

In [4]:
from transformers import AutoConfig, AutoTokenizer

tokenizer = AutoTokenizer.from_pretrained('Qwen/Qwen3-0.6B')

text = 'Hello, how are you?'
ids = tokenizer.encode(text)
print('token ids:', ids)
print('tokens:   ', [tokenizer.decode([i]) for i in ids])
show_tokens([tokenizer.decode([i]) for i in ids], 'Qwen3')
token ids: [9707, 11, 1246, 525, 498, 30]
tokens:    ['Hello', ',', ' how', ' are', ' you', '?']
Qwen3 (6 tokens)
Hello,·how·are·you?

Common words are single tokens, and the space before a word is part of the word's token (' how', not 'how'). The tokenizer reports two sizes:

  • tokenizer.vocab_size counts the tokens learned by byte pair encoding;
  • len(tokenizer) also counts the special tokens added afterwards (chat markers such as <|im_start|>, and <think>);
  • the model's embedding table has more rows still, padded up to a multiple of 128 (unused rows are never produced by the tokenizer).
In [5]:
config = AutoConfig.from_pretrained('Qwen/Qwen3-0.6B')
print(f'tokenizer.vocab_size = {tokenizer.vocab_size:,}')
print(f'len(tokenizer)       = {len(tokenizer):,}   ({len(tokenizer) - tokenizer.vocab_size} special tokens)')
print(f'embedding rows       = {config.vocab_size:,}   (= 128 x {config.vocab_size // 128})')
print('some special tokens: ', [t.content for t in list(tokenizer.added_tokens_decoder.values())[:6]])
tokenizer.vocab_size = 151,643
len(tokenizer)       = 151,669   (26 special tokens)
embedding rows       = 151,936   (= 128 x 1187)
some special tokens:  ['<|endoftext|>', '<|im_start|>', '<|im_end|>', '<|object_ref_start|>', '<|object_ref_end|>', '<|box_start|>']

Rare words are split into several pieces, every digit is its own token (a choice made by the Qwen designers), and characters outside the common set are split into bytes: the tokenizer starts from the 256 byte values, so any string can be encoded. A token that holds only part of a character's bytes decodes to the replacement character �.

In [6]:
for s in ['The transformer is unbelievably tokenizable.',
          'CS 189 meets at 15:30 in 2050 VLSB.',
          'Ich bin ein Berliner. 我喜欢机器学习。',
          'def f(x):\n    return x ** 2  # 🙂']:
    show_tokens([tokenizer.decode([i]) for i in tokenizer.encode(s)])
(8 tokens)
The·transformer·is·unbelievably·tokenizable.
(23 tokens)
CS·189·meets·at·15:30·in·2050·VLSB.
(12 tokens)
Ich·bin·ein·Berliner.·��喜欢机器学习。
(13 tokens)
def·f(x):↵····return·x·**·2··#·🙂

Byte Pair Encoding

Lecture: goes with the "Byte Pair Encoding" slide.

Byte pair encoding (Sennrich, Haddow, and Birch, 2015) builds the dictionary from data (Bishop, Section 12.2.2):

  1. Start with one token per character.
  2. Count every pair of adjacent tokens in the training text and find the most frequent pair $(a, b)$.
  3. Add the new token $ab$ to the dictionary and replace every occurrence of the pair by it. Record the merge $(a, b)$.
  4. Repeat until the dictionary has the desired size $K$.

To keep words apart, a new token is never formed from two tokens if the second one starts with white space (Bishop). A token may therefore begin with a space, as in the Qwen3 tokens above, but never ends inside the next word. In code we first split the text into words, each with at most one leading whitespace character; merges then happen only inside words, and each distinct word is processed once, weighted by its count. Ties between equally frequent pairs go to the pair seen first in the text.

In [7]:
def split_words(text):
    """Split text into words that keep one leading whitespace character, e.g. 'to be' -> ['to', ' be'].

    Every character belongs to exactly one word, so ''.join(split_words(text)) == text.
    """
    return re.findall(r'\s?\S+|\s', text)


def count_pairs(words):
    """Count adjacent token pairs. `words` maps a tuple of tokens to the number of times it occurs."""
    counts = Counter()
    for tokens, n in words.items():
        for pair in zip(tokens, tokens[1:]):
            counts[pair] += n
    return counts


def merge_pair(tokens, pair):
    """Replace every occurrence of the adjacent pair (a, b) in a tuple of tokens by the token a + b."""
    a, b = pair
    out, i = [], 0
    while i < len(tokens):
        if i + 1 < len(tokens) and tokens[i] == a and tokens[i + 1] == b:
            out.append(a + b)
            i += 2
        else:
            out.append(tokens[i])
            i += 1
    return tuple(out)


def learn_bpe(text, num_merges, max_words=None):
    """Learn `num_merges` BPE merges from text. Returns the list of merges and their counts.

    With max_words, only the most frequent distinct words are used (faster on a large corpus).
    """
    word_counts = Counter(split_words(text))
    if max_words is not None:
        word_counts = dict(word_counts.most_common(max_words))
    words = {tuple(w): n for w, n in word_counts.items()}      # each word starts as its characters
    merges, counts = [], []
    for _ in range(num_merges):
        pair_counts = count_pairs(words)
        if not pair_counts:
            break
        best = max(pair_counts, key=pair_counts.get)             # most frequent pair (first one on ties)
        merges.append(best)
        counts.append(pair_counts[best])
        words = {merge_pair(w, best): n for w, n in words.items()}
    return merges, counts

Bishop's example (Figure 12.12). The first merge is pe, which occurs four times (Pe in "Peter" does not count, since P and p are different characters), followed by ck, which occurs three times. After that, several pairs occur twice, and the order among them depends on how ties are broken.

In [8]:
peter = 'Peter Piper picked a peck of pickled peppers'
print('words:', split_words(peter))
merges, counts = learn_bpe(peter, 8)
for (a, b), n in zip(merges, counts):
    print(f'merge {a!r} + {b!r} -> {a + b!r}  ({n} times)')
words: ['Peter', ' Piper', ' picked', ' a', ' peck', ' of', ' pickled', ' peppers']
merge 'p' + 'e' -> 'pe'  (4 times)
merge 'c' + 'k' -> 'ck'  (3 times)
merge 'pe' + 'r' -> 'per'  (2 times)
merge ' ' + 'p' -> ' p'  (2 times)
merge ' p' + 'i' -> ' pi'  (2 times)
merge ' pi' + 'ck' -> ' pick'  (2 times)
merge 'e' + 'd' -> 'ed'  (2 times)
merge ' ' + 'pe' -> ' pe'  (2 times)

Applying the merges one at a time to the words of the sentence shows how the tokens grow:

In [9]:
words = [tuple(w) for w in split_words(peter)]
show_tokens([t for w in words for t in w], 'characters:')
for step, ((a, b), n) in enumerate(zip(merges, counts), start=1):
    words = [merge_pair(w, (a, b)) for w in words]
    show_tokens([t for w in words for t in w], f'merge {step}: {a!r} + {b!r} -> {a + b!r} ({n} times)')
characters: (44 tokens)
Peter·Piper·picked·a·peck·of·pickled·peppers
merge 1: 'p' + 'e' -> 'pe' (4 times) (40 tokens)
Peter·Piper·picked·a·peck·of·pickled·peppers
merge 2: 'c' + 'k' -> 'ck' (3 times) (37 tokens)
Peter·Piper·picked·a·peck·of·pickled·peppers
merge 3: 'pe' + 'r' -> 'per' (2 times) (35 tokens)
Peter·Piper·picked·a·peck·of·pickled·peppers
merge 4: ' ' + 'p' -> ' p' (2 times) (33 tokens)
Peter·Piper·picked·a·peck·of·pickled·peppers
merge 5: ' p' + 'i' -> ' pi' (2 times) (31 tokens)
Peter·Piper·picked·a·peck·of·pickled·peppers
merge 6: ' pi' + 'ck' -> ' pick' (2 times) (29 tokens)
Peter·Piper·picked·a·peck·of·pickled·peppers
merge 7: 'e' + 'd' -> 'ed' (2 times) (27 tokens)
Peter·Piper·picked·a·peck·of·pickled·peppers
merge 8: ' ' + 'pe' -> ' pe' (2 times) (25 tokens)
Peter·Piper·picked·a·peck·of·pickled·peppers

Encoding and decoding. The dictionary is the set of characters plus one new token per merge, and token $i$ gets id $i$. To encode new text, split it into words and apply the merges in the order they were learned. Applying them in order is the same as repeatedly merging the adjacent pair that was learned earliest, which is how encode_word below finds the next merge. Decoding concatenates the token strings, so decode(encode(text)) == text for any text built from known characters.

In [10]:
class BPETokenizer:
    """A character-level BPE tokenizer with Bishop's whitespace rule."""

    def __init__(self, text, num_merges, max_words=None, alphabet=None):
        """Learn merges from text. The base tokens are the characters of `alphabet` (default: of text)."""
        self.merges, self.counts = learn_bpe(text, num_merges, max_words)
        self.rank = {pair: r for r, pair in enumerate(self.merges)}      # earlier merge = lower rank
        chars = sorted(set(text if alphabet is None else alphabet))
        self.vocab = chars + [a + b for a, b in self.merges]               # id -> token string
        self.token_id = {tok: i for i, tok in enumerate(self.vocab)}
        self._cache = {}

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

    def encode_word(self, word):
        tokens = tuple(word)
        while len(tokens) > 1:
            pairs = list(zip(tokens, tokens[1:]))
            best = min(pairs, key=lambda p: self.rank.get(p, math.inf))
            if best not in self.rank:
                break                                   # no learned merge applies
            tokens = merge_pair(tokens, best)
        return [self.token_id[t] for t in tokens]

    def encode(self, text):
        ids = []
        for w in split_words(text):
            if w not in self._cache:
                self._cache[w] = self.encode_word(w)
            ids.extend(self._cache[w])
        return ids

    def decode(self, ids):
        return ''.join(self.vocab[i] for i in ids)

    def tokens(self, text):
        return [self.vocab[i] for i in self.encode(text)]


toy = BPETokenizer(peter, 8)
ids = toy.encode('a pickled pepper')
print('K =', len(toy), 'tokens:', toy.vocab)
print('ids:', ids, ' tokens:', toy.tokens('a pickled pepper'))
print('round trip:', toy.decode(ids) == 'a pickled pepper')
K = 23 tokens: [' ', 'P', 'a', 'c', 'd', 'e', 'f', 'i', 'k', 'l', 'o', 'p', 'r', 's', 't', 'pe', 'ck', 'per', ' p', ' pi', ' pick', 'ed', ' pe']
ids: [2, 20, 9, 21, 22, 11, 17]  tokens: ['a', ' pick', 'l', 'ed', ' pe', 'p', 'per']
round trip: True

BPE on Shakespeare

The same code learns a dictionary from the complete works of Shakespeare (Project Gutenberg eBook #100, shakespeare.txt). We remove the Project Gutenberg header and license, strip the indentation of each line, and hold out the last 10% of the text for validation. With 400 merges on top of the 100 distinct characters the dictionary has $K = 500$ tokens. To keep this fast, the merges are learned from the 10,000 most frequent distinct words (88% of all words in the text); every word can still be encoded, since the characters are in the dictionary.

In [11]:
SHAKESPEARE_URL = 'https://raw.githubusercontent.com/BerkeleyML/fa26-student/main/lecture/lec18/shakespeare.txt'


def load_shakespeare(path='shakespeare.txt'):
    if not os.path.exists(path):   # e.g., on Colab
        urllib.request.urlretrieve(SHAKESPEARE_URL, path)
    raw = open(path, encoding='utf-8').read()
    start = raw.index('\n', raw.index('*** START OF'))
    end = raw.index('*** END OF')
    lines = [line.strip() for line in raw[start:end].splitlines()]
    return re.sub(r'\n{3,}', '\n\n', '\n'.join(lines)).strip()


text = load_shakespeare()
split = int(0.9 * len(text))
train_text, val_text = text[:split], text[split:]
print(f'{len(text):,} characters, {len(set(text))} distinct')

t0 = time.perf_counter()
bpe = BPETokenizer(train_text, num_merges=400, max_words=10_000, alphabet=text)  # all characters are tokens
print(f'learned {len(bpe.merges)} merges in {time.perf_counter() - t0:.1f} s, K = {len(bpe)}')
print('first 20 merges:', [a + b for a, b in bpe.merges[:20]])
print('last 20 merges: ', [a + b for a, b in bpe.merges[-20:]])
5,341,108 characters, 100 distinct
learned 400 merges in 5.7 s, K = 500
first 20 merges: [' t', 'he', ' a', 'ou', ' s', ' m', ' w', 'nd', ' the', 'ha', 'in', 're', ' b', 'is', ' o', 'll', ' f', 'it', 'or', 've']
last 20 merges:  [' speak', 'ound', ' yet', 'ue', 'au', ' r', 'art', 'el', '\nWh', 'fe', 'ene', ' ey', 'ort', 'y.', '\nWith', ' these', ' who', 'ce.', 'eunt', ' some']

The first merges join a space to the most common first letters (' t', ' a', ' s') and form common pieces such as he, ou, in; later merges form whole words. Tokens that begin with a newline, such as '\nWith', start a line of verse. The learned tokens compress the text by a factor of about 2.2 relative to characters; on the passage below, the Qwen3 tokenizer, with 300 times more tokens, compresses it by a factor of almost 4.

In [12]:
passage = text[text.index('To be, or not to be'):][:300]
show_tokens(bpe.tokens(passage), f'our BPE (K = {len(bpe)})')
show_tokens([tokenizer.decode([i]) for i in tokenizer.encode(passage)], f'Qwen3 (K = {len(tokenizer):,})')

t0 = time.perf_counter()
train_ids = torch.tensor(bpe.encode(train_text))
val_ids = torch.tensor(bpe.encode(val_text))
print(f'encoded in {time.perf_counter() - t0:.1f} s: {len(train_ids):,} training and {len(val_ids):,} validation tokens')
print(f'characters per token: ours {len(text) / (len(train_ids) + len(val_ids)):.2f}, '
      f'Qwen3 on the passage {len(passage) / len(tokenizer.encode(passage)):.2f}')
assert bpe.decode(bpe.encode(passage)) == passage
our BPE (K = 500) (132 tokens)
To·be,·or·not·to·be,·that·is·the·question:↵Whether·’tis·nobler·in·the·mind·to·suffer↵The·slings·and·arrows·of·outrageous·fortune,↵Or·to·take·arms·against·a·sea·of·troubles,↵And·by·opposing·end·them?·To·die—to·sleep,↵No·more;·and·by·a·sleep·to·say·we·end↵The·heart-ache,·and·the·thousand·natural·shock
Qwen3 (K = 151,669) (77 tokens)
To·be,·or·not·to·be,·that·is·the·question:↵Whether·’tis·nobler·in·the·mind·to·suffer↵The·slings·and·arrows·of·outrageous·fortune,↵Or·to·take·arms·against·a·sea·of·troubles,↵And·by·opposing·end·them?·To·die—to·sleep,↵No·more;·and·by·a·sleep·to·say·we·end↵The·heart-ache,·and·the·thousand·natural·shock
encoded in 1.0 s: 2,200,314 training and 244,087 validation tokens
characters per token: ours 2.19, Qwen3 on the passage 3.90

Language Modeling and Token Embeddings

Lecture: goes with the "Embedding Table Lookup" and "Predicting the Next Token (Transformer)" slides.

A language model is a distribution over sequences of tokens $\mathbf{x}_1,\dots,\mathbf{x}_N$. By the product rule it factors into next-token predictions (Bishop, Eq. 12.31):

$$p(\mathbf{x}_1,\dots,\mathbf{x}_N)=\prod_{n=1}^{N}p(\mathbf{x}_n\mid\mathbf{x}_1,\dots,\mathbf{x}_{n-1}).$$

A decoder transformer represents each conditional $p(\mathbf{x}_n\mid\mathbf{x}_1,\dots,\mathbf{x}_{n-1})$ as a softmax over the $K$ tokens of the dictionary. Its first step turns token ids into vectors.

Embedding. Write token $\mathbf{x}_n$ as a one-hot vector in $\{0,1\}^K$. Its embedding is (Bishop, Eq. 12.26)

$$\mathbf{v}_n=\mathbf{E}\mathbf{x}_n,$$

where the columns of the $D\times K$ matrix $\mathbf{E}$ are learned. Since $\mathbf{x}_n$ has a single 1, the product selects one column of $\mathbf{E}$: the embedding is a table lookup, and nobody computes the product. PyTorch stores $\mathbf{E}^\top$ as a $K\times D$ table (nn.Embedding.weight), so a lookup selects a row, and a sequence of $N$ one-hot rows $\mathbf{X}$ ($N\times K$) gives $\mathbf{X}\mathbf{E}^\top$ ($N\times D$).

In [13]:
torch.manual_seed(SEED)
D_toy = 6
embedding = nn.Embedding(len(toy), D_toy)          # weight: (K, D), row k is the embedding of token k
ids = torch.tensor(toy.encode('a peck of pickled peppers'))

X = F.one_hot(ids, num_classes=len(toy)).float()   # (N, K) one-hot rows
lookup = embedding(ids)                            # (N, D) by indexing
product = X @ embedding.weight                     # (N, D) by matrix product
print('ids:', ids.tolist())
print('lookup == one-hot product:', torch.equal(lookup, product))
print('lookup == indexing the table:', torch.equal(lookup, embedding.weight[ids]))

labels = [f'{n}: {toy.vocab[i]!r}' for n, i in enumerate(ids.tolist())]
plot_lookup(X.numpy(), embedding.weight.detach().numpy(), product.detach().numpy(),
            labels, [repr(t) for t in toy.vocab])
ids: [2, 22, 16, 0, 10, 6, 20, 9, 21, 22, 11, 17, 13]
lookup == one-hot product: True
lookup == indexing the table: True

Each row of the one-hot matrix (left) has a single 1, so each row of the product (right) is a copy of one row of the table (middle). Repeated tokens (' pe') get identical embeddings; the transformer layers that follow are what make the vector at each position depend on its context.

From vectors back to tokens. After the transformer layers, the vector $\widetilde{\mathbf{x}}_n\in\mathbb{R}^D$ at position $n$ is mapped to $K$ logits by a $D\times K$ matrix $\mathbf{W}^{(\mathrm{p})}$ and a softmax gives the next-token distribution. For all positions at once (Bishop, Eq. 12.33):

$$\mathbf{Y}=\operatorname{Softmax}\left(\widetilde{\mathbf{X}}\mathbf{W}^{(\mathrm{p})}\right),$$

where row $n$ of $\mathbf{Y}$ is the distribution $p(\mathbf{x}_{n+1}\mid\mathbf{x}_1,\dots,\mathbf{x}_n)$. A common choice is weight tying: $\mathbf{W}^{(\mathrm{p})}=\mathbf{E}$, so the logit of token $k$ is the inner product of $\widetilde{\mathbf{x}}_n$ with that token's embedding, and the model stores one $D\times K$ matrix instead of two. The model at the end of this notebook uses weight tying.

The Causal Mask

Lecture: goes with "The Masked Attention" and "The Causal Mask" slides.

When the model is trained to predict token $n+1$ at position $n$ for all positions in parallel, position $n$ must not see the tokens after it, or it could copy the answer. Masked (causal) attention adds a mask $\mathbf{M}$ to the scores before the softmax:

$$\operatorname{Attn}(\mathbf{Q},\mathbf{K},\mathbf{V})=\operatorname{Softmax}_{\text{row}}\left[\frac{\mathbf{Q}\mathbf{K}^\top+\mathbf{M}}{\sqrt{D_k}}\right]\mathbf{V},\qquad M_{ni}=\begin{cases}0 & i\le n\\ -\infty & i>n.\end{cases}$$

Row $n$ of the score matrix belongs to query $n$ and column $i$ to key $i$. The entries with $i>n$ (keys in the future of the query) lie above the diagonal, so $\mathbf{M}$ is strictly upper triangular with $-\infty$ entries, and the attention weights $\alpha_{ni}$ are lower triangular. Since $e^{-\infty}=0$, the softmax gives zero weight to future tokens and the weights on the remaining tokens still sum to 1 (Bishop, Section 12.3.1). This is the same as setting the future weights to zero and renormalizing each row.

The example uses the inputs of Bishop's Figure 12.16 with random queries and keys.

In [14]:
def causal_mask(N, device=None):
    """N x N mask M with M[n, i] = 0 for i <= n and -inf for i > n (strictly upper triangular)."""
    return torch.full((N, N), float('-inf'), device=device).triu(diagonal=1)


def masked_attention(Q, K, V):
    """Softmax_row[(Q K^T + M) / sqrt(D_k)] V for Q, K of shape (..., N, D_k) and V of shape (..., N, D_v)."""
    N, D_k = Q.shape[-2], Q.shape[-1]
    scores = (Q @ K.transpose(-2, -1) + causal_mask(N, Q.device)) / math.sqrt(D_k)
    return torch.softmax(scores, dim=-1) @ V


inputs = ['<start>', 'I', 'swam', 'across', 'the']
outputs = ['I', 'swam', 'across', 'the', 'river']      # the token each position learns to predict
N = len(inputs)
M_mask = causal_mask(N)
print(M_mask)
plot_matrix((M_mask == 0).float().numpy(), inputs, inputs, 'Mask M (row n: query, column i: key)',
            text=[['0' if v == 0 else '-inf' for v in row] for row in M_mask.tolist()],
            colorscale=[[0, '#f4a6a6'], [1, 'white']], x_title='key i', y_title='query n')
tensor([[0., -inf, -inf, -inf, -inf],
        [0., 0., -inf, -inf, -inf],
        [0., 0., 0., -inf, -inf],
        [0., 0., 0., 0., -inf],
        [0., 0., 0., 0., 0.]])
In [15]:
torch.manual_seed(SEED)
D_k = 4
Q, K, V = torch.randn(N, D_k), torch.randn(N, D_k), torch.eye(N)   # V = I, so the output is the weight matrix
alpha = masked_attention(Q, K, V)                                   # alpha @ I = alpha

print('row sums:', alpha.sum(dim=-1))
print('largest weight on a future token:', alpha.triu(diagonal=1).abs().max().item())

unmasked = torch.softmax(Q @ K.T / math.sqrt(D_k), dim=-1)
renormalized = unmasked.tril() / unmasked.tril().sum(dim=-1, keepdim=True)
print('same as zeroing the future and renormalizing:', torch.allclose(alpha, renormalized))
print('same as PyTorch is_causal=True:', torch.allclose(
    alpha, F.scaled_dot_product_attention(Q, K, V, is_causal=True), atol=1e-6))

plot_matrix(alpha.numpy(), inputs, [f'{a} -> {b}' for a, b in zip(inputs, outputs)],
            'Attention weights (each row sums to 1)', text=[[f'{v:.2f}' for v in row] for row in alpha.tolist()],
            zmin=0, zmax=1, x_title='key (input token)', y_title='query (input -> target)', width=620)
row sums: tensor([1.0000, 1.0000, 1.0000, 1.0000, 1.0000])
largest weight on a future token: 0.0
same as zeroing the future and renormalizing: True
same as PyTorch is_causal=True: True

In the figure the white cells above the diagonal are exactly zero, the same pattern as Bishop's Figure 12.16. The first row attends only to <start>. The prediction of across (third row) can use only <start>, I, and swam.

Gated MLPs (SwiGLU)

Lecture: goes with the "Gated MLPs (SwiGLU)" slide.

The MLP of the original transformer applies one hidden layer of width $4D$ to each token vector $\mathbf{x}\in\mathbb{R}^D$ (biases omitted, as in current LLMs):

$$\operatorname{MLP}(\mathbf{x})=\operatorname{ReLU}(\mathbf{x}\mathbf{W}_1)\mathbf{W}_2,\qquad\mathbf{W}_1\in\mathbb{R}^{D\times4D},\ \mathbf{W}_2\in\mathbb{R}^{4D\times D}.$$

A gated MLP (Shazeer, 2020) has a second input branch $\mathbf{V}$ whose output multiplies the activations elementwise:

$$\operatorname{SwiGLU}(\mathbf{x})=\left(\operatorname{Swish}(\mathbf{x}\mathbf{W}_1)\odot\mathbf{x}\mathbf{V}\right)\mathbf{W}_2,\qquad\operatorname{Swish}(a)=a\,\sigma(a),$$

with $\mathbf{W}_1,\mathbf{V}\in\mathbb{R}^{D\times d_{\text{ff}}}$ and $\mathbf{W}_2\in\mathbb{R}^{d_{\text{ff}}\times D}$. (Here $\mathbf{V}$ is a weight matrix, not the value matrix of attention.) Swish, also called SiLU, is a smooth version of ReLU. The gated MLP has three matrices instead of two, so to keep the parameter count of the original MLP,

$$3Dd_{\text{ff}}=2D(4D)\quad\Longrightarrow\quad d_{\text{ff}}=\tfrac{8}{3}D.$$

In [16]:
class SwiGLU(nn.Module):
    """Gated MLP: (Swish(x W1) * (x V)) W2, applied to each token vector."""

    def __init__(self, D, d_ff):
        super().__init__()
        self.W1 = nn.Linear(D, d_ff, bias=False)
        self.V = nn.Linear(D, d_ff, bias=False)
        self.W2 = nn.Linear(d_ff, D, bias=False)

    def forward(self, x):
        return self.W2(F.silu(self.W1(x)) * self.V(x))     # F.silu(a) = a * sigmoid(a) = Swish(a)


def relu_mlp(D, d_ff):
    """The original transformer MLP, ReLU(x W1) W2, without biases."""
    return nn.Sequential(nn.Linear(D, d_ff, bias=False), nn.ReLU(), nn.Linear(d_ff, D, bias=False))


def num_params(module):
    return sum(p.numel() for p in module.parameters())


D = 192
print(f'ReLU MLP,  d_ff = 4D   = {4 * D}: {num_params(relu_mlp(D, 4 * D)):,} parameters (8 D^2 = {8 * D * D:,})')
print(f'SwiGLU,    d_ff = 8D/3 = {8 * D // 3}: {num_params(SwiGLU(D, 8 * D // 3)):,} parameters')
print('output shape:', SwiGLU(D, 8 * D // 3)(torch.randn(2, 10, D)).shape)

a = torch.linspace(-4, 4, 201)
fig = go.Figure()
fig.add_scatter(x=a, y=F.relu(a), name='ReLU(a)', line=dict(color='gray', dash='dash'))
fig.add_scatter(x=a, y=F.silu(a), name='Swish(a) = a sigmoid(a)', line=dict(color=BLUE, width=3))
fig.update_layout(xaxis_title='a', width=600, height=350, margin=dict(l=40, r=20, t=30, b=40))
ReLU MLP,  d_ff = 4D   = 768: 294,912 parameters (8 D^2 = 294,912)
SwiGLU,    d_ff = 8D/3 = 512: 294,912 parameters
output shape: torch.Size([2, 10, 192])

Swish is close to ReLU for large $|a|$ but smooth near 0 and slightly negative for $a<0$. With $D=192$ the factor $8/3$ gives $d_{\text{ff}}=512$ exactly and the two MLPs have the same number of parameters. (Llama models use $d_{\text{ff}}\approx\tfrac{8}{3}D$ rounded up to a multiple of 256.) The model trained below uses this SwiGLU MLP.

Mixture of Experts

Lecture: goes with the "Mixture of Experts (MoE)", "Total vs. Active Parameters", and "Keeping the Experts Balanced" slides.

A mixture-of-experts layer replaces the MLP by $N_e$ MLPs $f_1,\dots,f_{N_e}$ (the experts, here SwiGLU modules) and a linear router $\mathbf{W}_r\in\mathbb{R}^{D\times N_e}$. For each token vector $\mathbf{x}$:

  1. Router scores $\mathbf{s}=\mathbf{x}\mathbf{W}_r\in\mathbb{R}^{N_e}$.
  2. Keep the $k$ experts with the largest scores, the set $\mathcal{T}(\mathbf{x})$, and normalize their scores with a softmax: $g_e=\exp(s_e)/\sum_{j\in\mathcal{T}(\mathbf{x})}\exp(s_j)$ for $e\in\mathcal{T}(\mathbf{x})$.
  3. Output $\sum_{e\in\mathcal{T}(\mathbf{x})}g_e\,f_e(\mathbf{x})$.

Only $k$ experts run for each token, so the compute per token follows the active parameters (router plus $k$ experts), while the memory follows the total (all $N_e$ experts). Mixtral uses $N_e=8$, $k=2$, as below.

In [17]:
class MoE(nn.Module):
    """Top-k mixture of SwiGLU experts. Each token is sent to the k experts with the largest router scores."""

    def __init__(self, D, d_ff, n_experts=8, k=2):
        super().__init__()
        self.k = k
        self.router = nn.Linear(D, n_experts, bias=False)
        self.register_buffer('score_bias', torch.zeros(n_experts))   # 0 here; used below to unbalance the router
        self.experts = nn.ModuleList([SwiGLU(D, d_ff) for _ in range(n_experts)])

    def forward(self, x):
        shape = x.shape
        x = x.reshape(-1, shape[-1])                         # one row per token
        scores = self.router(x) + self.score_bias            # s = x W_r, (tokens, n_experts)
        top_scores, top_experts = scores.topk(self.k, dim=-1)
        gates = torch.softmax(top_scores, dim=-1)            # g_e for the k chosen experts
        y = torch.zeros_like(x)
        for e, expert in enumerate(self.experts):
            token, slot = (top_experts == e).nonzero(as_tuple=True)   # tokens routed to expert e
            if len(token) > 0:
                y.index_add_(0, token, gates[token, slot, None] * expert(x[token]))
        self.scores, self.top_experts = scores, top_experts  # kept for the routing statistics below
        return y.reshape(shape)


torch.manual_seed(SEED)
D, d_ff = 192, 512
moe = MoE(D, d_ff, n_experts=8, k=2)
expert_params = num_params(moe.experts[0])
total, active = num_params(moe), num_params(moe.router) + moe.k * expert_params
print(f'one expert: {expert_params:,}   total: {total:,}   active per token: {active:,} ({active / total:.0%})')

x = torch.randn(4096, D)
print('output shape:', moe(x).shape)
one expert: 294,912   total: 2,360,832   active per token: 591,360 (25%)
output shape: torch.Size([4096, 192])

Load balance. The bar chart shows how many of the $4096\cdot k$ routing slots each expert receives. With a random router and random inputs the load is close to uniform. During training nothing keeps it that way: an expert that receives more tokens improves, receives higher scores, and receives still more tokens. To see what an unbalanced router looks like, we add 1.5 to the router score of expert 1 for every token. The Switch Transformer auxiliary loss $N_e\sum_e f_eP_e$, where $f_e$ is the fraction of routing slots sent to expert $e$ and $P_e$ the average router probability $\operatorname{softmax}(\mathbf{s})_e$, equals 1 for a uniform router and grows with the imbalance; adding a multiple of it to the training loss pushes the router back toward balance.

In [18]:
def routing_stats(moe, x):
    """Fraction of routing slots f_e per expert and the balance loss N_e * sum_e f_e P_e."""
    with torch.no_grad():
        moe(x)
    n_experts = moe.scores.shape[-1]
    f = torch.bincount(moe.top_experts.flatten(), minlength=n_experts).float() / moe.top_experts.numel()
    P = torch.softmax(moe.scores, dim=-1).mean(dim=0)
    return f, n_experts * (f * P).sum().item()


f_balanced, aux_balanced = routing_stats(moe, x)
moe.score_bias[0] = 1.5                       # every token now prefers expert 1
f_skewed, aux_skewed = routing_stats(moe, x)
moe.score_bias[0] = 0.0

experts = [f'E{e + 1}' for e in range(8)]
fig = go.Figure()
fig.add_bar(x=experts, y=f_balanced, name=f'random router (aux. loss {aux_balanced:.2f})', marker_color=BLUE)
fig.add_bar(x=experts, y=f_skewed, name=f'router biased to E1 (aux. loss {aux_skewed:.2f})', marker_color=GOLD)
fig.add_hline(y=1 / 8, line_dash='dash', line_color='gray', annotation_text='uniform 1/8')
fig.update_layout(barmode='group', yaxis_title='share of routing slots', width=750, height=380,
                  margin=dict(l=50, r=20, t=30, b=40))

(Lecture Demo 2) A Decoder-Only Transformer on Shakespeare

Lecture: run from the "Decoder Transformer Notebook" slide.

We now put the pieces together into a small GPT-style model (Bishop, Figure 12.15) with the architecture of current LLMs (the "Llama-3 Architecture" slide), and train it on the BPE tokens of Shakespeare.

Training data: shifted targets

A training example is a window of $N+1$ consecutive tokens. The inputs are the first $N$ and the targets are the same tokens shifted left by one, so position $n$ is trained to predict token $n+1$ from tokens $1,\dots,n$. One window therefore holds $N$ prediction tasks, one per position, all computed in a single forward pass thanks to the causal mask (the "Training the Model" slides).

In [19]:
N_CTX = 128            # context length N
BATCH_SIZE = 32        # sequences per batch B


def get_batch(data, gen, batch_size=BATCH_SIZE):
    """Random windows of N_CTX + 1 tokens: inputs x (B, N) and targets t (B, N), t shifted by one."""
    starts = torch.randint(len(data) - N_CTX - 1, (batch_size,), generator=gen)
    x = torch.stack([data[s:s + N_CTX] for s in starts])
    t = torch.stack([data[s + 1:s + N_CTX + 1] for s in starts])
    return x.to(DEVICE), t.to(DEVICE)


x, t = get_batch(train_ids, torch.Generator().manual_seed(SEED))
print('x:', tuple(x.shape), ' t:', tuple(t.shape))
for n in range(8):
    print(f'{bpe.decode(x[0, :n + 1].tolist())!r:>45}  ->  {bpe.vocab[t[0, n]]!r}')
x: (32, 128)  t: (32, 128)
                                         ' w'  ->  'on'
                                       ' won'  ->  't'
                                      ' wont'  ->  ','
                                     ' wont,'  ->  ' or'
                                  ' wont, or'  ->  ' is'
                               ' wont, or is'  ->  '’t'
                             ' wont, or is’t'  ->  ' not'
                         ' wont, or is’t not'  ->  '\nT'

The model

Each decoder block is a pre-norm transformer layer with RMSNorm, causal multi-head self-attention, and a SwiGLU MLP:

$$\mathbf{H}=\mathbf{X}+\operatorname{MultiHeadAttn}(\operatorname{RMSNorm}(\mathbf{X})),\qquad\mathbf{X}'=\mathbf{H}+\operatorname{SwiGLU}(\operatorname{RMSNorm}(\mathbf{H})),$$

with $\operatorname{RMSNorm}(\mathbf{x})=\mathbf{g}\odot\mathbf{x}/\sqrt{\tfrac{1}{D}\sum_d x_d^2+\epsilon}$. Multi-head attention computes $\mathbf{Q},\mathbf{K},\mathbf{V}$ with one $D\times3D$ matrix, splits each into $h$ heads of size $D_k=D/h$, applies masked_attention from above to every head in parallel (the head is a batch dimension), concatenates the heads, and applies an output matrix $\mathbf{W}^{(o)}$.

The model: token embeddings plus learned position embeddings, $L$ decoder blocks, a final RMSNorm, and the tied output layer $\widetilde{\mathbf{X}}\mathbf{E}$ that produces the logits. The softmax is part of the loss (and of sampling), so forward returns logits.

In [20]:
class CausalSelfAttention(nn.Module):
    """Multi-head masked self-attention with h heads of size D_k = D / h."""

    def __init__(self, D, n_heads):
        super().__init__()
        self.h = n_heads
        self.W_qkv = nn.Linear(D, 3 * D, bias=False)    # [W^(q) W^(k) W^(v)] for all heads
        self.W_o = nn.Linear(D, D, bias=False)

    def forward(self, X):
        B, N, D = X.shape
        Q, K, V = self.W_qkv(X).view(B, N, 3, self.h, D // self.h).permute(2, 0, 3, 1, 4)  # each (B, h, N, D_k)
        heads = masked_attention(Q, K, V)                                                # (B, h, N, D_k)
        return self.W_o(heads.transpose(1, 2).reshape(B, N, D))                          # concatenate heads


class DecoderBlock(nn.Module):
    """Pre-norm transformer layer: X + Attn(RMSNorm(X)), then H + SwiGLU(RMSNorm(H))."""

    def __init__(self, D, n_heads, d_ff):
        super().__init__()
        self.norm1, self.attn = nn.RMSNorm(D), CausalSelfAttention(D, n_heads)
        self.norm2, self.mlp = nn.RMSNorm(D), SwiGLU(D, d_ff)

    def forward(self, X):
        H = X + self.attn(self.norm1(X))
        return H + self.mlp(self.norm2(H))


class DecoderLM(nn.Module):
    """Decoder-only transformer language model with learned positions and tied input/output embeddings."""

    def __init__(self, vocab_size, D=192, n_layers=4, n_heads=6, max_len=N_CTX):
        super().__init__()
        self.tok_emb = nn.Embedding(vocab_size, D)            # E^T: (K, D)
        self.pos_emb = nn.Embedding(max_len, D)               # one learned vector per position
        self.blocks = nn.Sequential(*[DecoderBlock(D, n_heads, 8 * D // 3) for _ in range(n_layers)])
        self.norm = nn.RMSNorm(D)
        for p in self.parameters():                           # small random initial weights
            if p.dim() == 2:
                nn.init.normal_(p, std=0.02)

    def forward(self, ids):
        """ids: (B, N) token ids. Returns logits (B, N, K); row n scores the token at position n + 1."""
        positions = torch.arange(ids.shape[1], device=ids.device)
        X = self.tok_emb(ids) + self.pos_emb(positions)
        X_tilde = self.norm(self.blocks(X))
        return X_tilde @ self.tok_emb.weight.T                # weight tying: W^(p) = E


torch.manual_seed(SEED)
model = DecoderLM(len(bpe)).to(DEVICE)
emb = model.tok_emb.weight.numel()
print(f'{num_params(model):,} parameters ({emb:,} in the embedding table, shared with the output layer)')
print('logits:', tuple(model(x).shape), ' = (B, N, K)')
1,891,776 parameters (96,000 in the embedding table, shared with the output layer)

logits: (32, 128, 500)  = (B, N, K)

Causality check. Because of the mask, the logits at position $n$ depend only on tokens $1,\dots,n$. Changing the last input token must leave every earlier position unchanged:

In [21]:
with torch.no_grad():
    x_changed = x.clone()
    x_changed[:, -1] = (x_changed[:, -1] + 1) % len(bpe)
    diff = (model(x) - model(x_changed)).abs().amax(dim=(0, 2))
print(f'largest change at positions 1..N-1: {diff[:-1].max().item():.1e},  at position N: {diff[-1].item():.2f}')
largest change at positions 1..N-1: 0.0e+00,  at position N: 1.16

The loss at initialization

The error function is the cross-entropy of the targets, averaged over the $B$ sequences of a batch and the $N$ positions of each sequence:

$$E(\mathbf{w})=-\frac{1}{BN}\sum_{b=1}^{B}\sum_{n=1}^{N}\ln p(t_{bn}\mid x_{b1},\dots,x_{bn}).$$

With small initial weights the logits are all close to 0, the softmax is close to uniform, and every target has probability about $1/K$. The initial loss should therefore be close to $\ln K$. This is a useful check on any new model: a much larger initial loss means the initial logits are too large.

In [22]:
def lm_loss(model, x, t):
    """Mean cross-entropy over all B x N positions (F.cross_entropy applies the softmax)."""
    logits = model(x)
    return F.cross_entropy(logits.reshape(-1, logits.shape[-1]), t.reshape(-1))


with torch.no_grad():
    loss0 = lm_loss(model, x, t).item()
print(f'initial loss {loss0:.3f},  ln K = ln {len(bpe)} = {math.log(len(bpe)):.3f}')
initial loss 6.239,  ln K = ln 500 = 6.215

Generation. The model generates text autoregressively: compute the next-token distribution from the last row of logits, sample a token, append it, and repeat (Lecture 19 covers other ways to choose the token). Before training, the samples are random tokens.

In [23]:
@torch.no_grad()
def generate(model, prompt, n_new, seed=0):
    """Sample n_new tokens from the model (temperature 1), starting from a text prompt."""
    was_training = model.training
    model.eval()
    gen = torch.Generator().manual_seed(seed)
    ids = torch.tensor([bpe.encode(prompt)], device=DEVICE)
    for _ in range(n_new):
        logits = model(ids[:, -N_CTX:])[0, -1]                 # logits for the next token
        p = torch.softmax(logits.float().cpu(), dim=-1)
        next_id = torch.multinomial(p, 1, generator=gen)
        ids = torch.cat([ids, next_id.to(DEVICE)[None]], dim=1)
    model.train(was_training)
    return bpe.decode(ids[0].tolist())


PROMPT = 'ROMEO.\n'
print(generate(model, PROMPT, 60))
ROMEO.
 Doushere if comero beé’dightK
S
P
O haveiENrIR tim wellouldant
Youes kn
That doS bfêSu mustce,riR
AndordA.ame wouldetC theirUCELiltherSndordif oneEx you,ousy.w

Training

We minimize $E(\mathbf{w})$ with AdamW (learning rate $\eta=2\times10^{-3}$ after a linear warmup of 100 steps, then a cosine decay to 0; weight decay 0.1 on the weight matrices but not on the RMSNorm gains, the usual convention; gradient clipping at norm 1). Every 100 steps we also compute the loss on 10 fixed batches of the held-out text. On an Apple laptop GPU (MPS) the 1,500 steps take 2 to 3 minutes; on a CPU, reduce TRAIN_STEPS.

In [24]:
TRAIN_STEPS, ETA, WARMUP = 1500, 2e-3, 100


def learning_rate(step):
    warmup = min(1.0, (step + 1) / WARMUP)
    return ETA * warmup * 0.5 * (1 + math.cos(math.pi * step / TRAIN_STEPS))


@torch.no_grad()
def validation_loss(model, batches):
    model.eval()
    loss = np.mean([lm_loss(model, xv, tv).item() for xv, tv in batches])
    model.train()
    return loss


val_gen = torch.Generator().manual_seed(0)
val_batches = [get_batch(val_ids, val_gen) for _ in range(10)]
train_gen = torch.Generator().manual_seed(SEED)
decay = [p for p in model.parameters() if p.dim() >= 2]      # weight matrices and the embedding table
no_decay = [p for p in model.parameters() if p.dim() < 2]    # RMSNorm gains
optimizer = torch.optim.AdamW([{'params': decay, 'weight_decay': 0.1},
                               {'params': no_decay, 'weight_decay': 0.0}], lr=ETA)
history = {'step': [], 'train': [], 'val_step': [], 'val': []}

model.train()
t0 = time.perf_counter()
for step in range(TRAIN_STEPS):
    if step % 100 == 0:
        history['val_step'].append(step)
        history['val'].append(validation_loss(model, val_batches))
    for group in optimizer.param_groups:
        group['lr'] = learning_rate(step)
    xb, tb = get_batch(train_ids, train_gen)
    loss = lm_loss(model, xb, tb)
    optimizer.zero_grad()
    loss.backward()
    torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
    optimizer.step()
    history['step'].append(step)
    history['train'].append(loss.item())
    if step % 250 == 0:
        print(f'step {step:5d}  training loss {loss.item():.3f}  ({time.perf_counter() - t0:.0f} s)')
history['val_step'].append(TRAIN_STEPS)
history['val'].append(validation_loss(model, val_batches))
train_gen_eval = torch.Generator().manual_seed(1)
train_loss = validation_loss(model, [get_batch(train_ids, train_gen_eval) for _ in range(10)])
print(f'done in {time.perf_counter() - t0:.0f} s; loss on 10 batches of training text {train_loss:.3f}, '
      f'of validation text {history["val"][-1]:.3f}')
plot_loss_curve(history, math.log(len(bpe)))
step     0  training loss 6.239  (1 s)
step   250  training loss 3.811  (24 s)
step   500  training loss 3.388  (45 s)
step   750  training loss 2.986  (66 s)
step  1000  training loss 2.943  (87 s)
step  1250  training loss 2.789  (107 s)
done in 128 s; loss on 10 batches of training text 2.778, of validation text 3.251

The loss starts at $\ln K$ and falls quickly as the model learns which tokens are common, then more slowly as it learns to use the context. At the end, the loss on held-out text is about 0.5 above the loss on training text. This run does not separate two causes of the gap: the validation text is different (the last 10% of the book holds the last plays and the poems), and the model has seen each training token about 3 times ($1500\cdot32\cdot128$ tokens processed out of 2.2 million). A validation loss of about 3.25 nats per token corresponds to a perplexity of $e^{3.25}\approx26$: on average the model is as uncertain as a uniform choice among 26 tokens, compared with 500 before training.

After a few minutes of training the model has learned the format of a play (speaker names in capitals followed by a period, line breaks, verse) and the spelling of many words, but not much meaning.

In [25]:
for seed in range(3):
    print(generate(model, PROMPT, 80, seed=seed))
    print('-' * 60)
ROMEO.
’Twas come not better than you know.

CROMWELL.
What’s that?

ALEXANT.
And must I gave thee forth; when I say.

SEBASTIAN.
That’s the matter?

ARCHBISHOP.
What is he? Br
------------------------------------------------------------
ROMEO.
’Gutor, the Emperor, lords, i’ three with his gifts;
Thou hast know what my bodies all most seigned shame,
I cannot hope to whom I have heard my hand.

[_Exit._]

TALBOT.
[_s
------------------------------------------------------------
ROMEO.
Queen, we are maid’s a knaves, yet my side
wrong many in. After playings, wish! Fie for glerit in thy tears I’ll reach
despury!

CLEOPATRA.
Alack, her worth sword
------------------------------------------------------------