Build a Tiny GPT From Scratch on a CPU: Attention, Transformer Blocks and Text Generation
Hands-on lab · IDE in your browser

Build a Tiny GPT From Scratch on a CPU: Attention, Transformer Blocks and Text Generation

Write a character-level GPT in PyTorch and train it on the Sherlock Holmes stories in under a minute on one CPU core.

Time
50 min
Checked steps
5
Level
Intermediate
Setup
None
Read step 1

Hands-on labs require Pro · $29.99/mo · cancel anytime

Lab cockpit50 min · 5 stepsSession running
2 / 5 steps passingSelf-attention · step 3 of 5
gpt.py▶ Run✓ Check
# ---------- Step 3: self-attention ----------class Head(nn.Module):    """One head of causal self-attention. Each position makes a query, a key and a value (Linear, no bias);    weights = softmax(q @ k^T / sqrt(head_size)) with every future position masked to -inf; out = weights @ v.    Keeps the last weights in self.weights, (B, T, T), so you can look at them."""     def __init__(self, n_embd, head_size, block_size):        super().__init__()           self.weights = None     def forward(self, x):       
TerminalOutput

The job

Every large language model is the same machine at a larger scale. You build the smallest complete one, a GPT that reads the Sherlock Holmes stories one character at a time, and train it on a single CPU core until it writes its own.

5 steps, each checked when you finish it

A check runs your work at the end of every step. Hints and the full solution are there if you get stuck.

  1. 1

    Characters to numbers

    A language model does one thing: given some text, it scores every possible next token.

    You writeChars()
  2. 2

    A bigram model

    The simplest language model predicts the next character from the current one alone: a table with one row of scores per character.

    You writelm_loss()
  3. 3

    Self-attention

    For a better guess, a position needs to look back at the text before it.

    You writeHead()
  4. 4

    The GPT

    A GPT is attention and thinking, stacked: - Multi-head attention: several small heads side by side, each free to track something different.

    You writeMultiHead()
  5. 5

    Train it, measure what context buys

    Train the GPT, then measure what attention is worth.

    You writetrain()

Step 1 as it appears in the lab

The lab’s own text. The hint and the solution stay inside the lab.

Step 1: Characters to numbers

A language model does one thing: given some text, it scores every possible next token. Here a token is one character, and the text is the Sherlock Holmes stories (public domain). By the end of the lab a model you wrote will be writing new text in their style.

Training data comes in windows. The input is a stretch of text and the target is the same stretch shifted one character on, so a single 64-character window asks 64 "what comes next?" questions at once.

Do this

1. Write Chars (the vocabulary, encode and decode) and get_batch() in gpt.py.

2. Run it. Read an input window and its target, and note the loss of pure guessing.

gpt.py, the file you edit128 lines
"""The smallest real GPT: it reads the Sherlock Holmes stories one character at a time and learns to write more.
Every piece of GPT-2, Llama and Claude is here, just small enough to train on one CPU core in under a minute."""
import math

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

torch.set_num_threads(1)   # the lab has one CPU; more threads only fight over it


# ---------- Step 1: characters to numbers ----------
class Chars:
    """The vocabulary: every distinct character in the text, sorted. encode() maps text to ids, decode() back."""

    def __init__(self, text):
        # TODO (Step 1): self.chars = sorted distinct characters; self.stoi = {char: index}.
        raise NotImplementedError("Step 1: write Chars.__init__()")

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

    def encode(self, text):
        # TODO (Step 1): a list of ids, one per character.
        raise NotImplementedError("Step 1: write Chars.encode()")

    def decode(self, ids):
        # TODO (Step 1): join the characters back into a string.
        raise NotImplementedError("Step 1: write Chars.decode()")


def get_batch(data, batch_size, block_size, generator):
    """batch_size random windows of data: x = data[i:i+block_size], y = the same window one character later
    (data[i+1:i+block_size+1]). Starts i = torch.randint(len(data) - block_size, (batch_size,), generator=generator).
    Returns (x, y), both (batch_size, block_size) long tensors."""
    # TODO (Step 1): draw the starts, then stack the x windows and the y windows (one later).
    raise NotImplementedError("Step 1: write get_batch()")


# ---------- Step 2: a bigram model ----------
def lm_loss(logits, targets):
    """Cross-entropy between (B, T, V) scores and (B, T) next characters: flatten to (B*T, V) and (B*T,)."""
    raise NotImplementedError("lm_loss() arrives in Step 2")


class Bigram(nn.Module):
    """Scores for the next character from the current character alone: one row of a (V, V) table per character."""

    def __init__(self, vocab_size, block_size=64):
        super().__init__()
        self.block_size = block_size
        raise NotImplementedError("Bigram.__init__() arrives in Step 2")

    def forward(self, idx):
        raise NotImplementedError("Bigram.forward() arrives in Step 2")


@torch.no_grad()
def generate(model, idx, new_tokens, temperature=1.0, generator=None):
    """Extend idx (1, T) by new_tokens characters, one at a time: feed the last model.block_size ids, take the
    scores at the last position, divide by temperature, softmax, sample one id with torch.multinomial(probs, 1,
    generator=generator), append. Eval mode. Returns the (1, T + new_tokens) tensor."""
    raise NotImplementedError("generate() arrives in Step 2")


# ---------- Step 3: self-attention ----------
class Head(nn.Module):
    """One head of causal self-attention. Each position makes a query, a key and a value (Linear, no bias);
    weights = softmax(q @ k^T / sqrt(head_size)) with every future position masked to -inf; out = weights @ v.
    Keeps the last weights in self.weights, (B, T, T), so you can look at them."""

    def __init__(self, n_embd, head_size, block_size):
        super().__init__()
        raise NotImplementedError("Head.__init__() arrives in Step 3")
        self.weights = None

    def forward(self, x):
        raise NotImplementedError("Head.forward() arrives in Step 3")


# ---------- Step 4: the GPT ----------
class MultiHead(nn.Module):
    """n_head Heads of size n_embd // n_head side by side, outputs concatenated, then a Linear(n_embd, n_embd)."""

    def __init__(self, n_embd, n_head, block_size):
        super().__init__()
        raise NotImplementedError("MultiHead.__init__() arrives in Step 4")

    def forward(self, x):
        raise NotImplementedError("MultiHead.forward() arrives in Step 4")


class Block(nn.Module):
    """x = x + attention(LayerNorm(x)); x = x + mlp(LayerNorm(x)), the mlp being Linear(n_embd, 4 * n_embd), GELU,
    Linear(4 * n_embd, n_embd). Attention lets positions talk; the mlp thinks about what each one heard."""

    def __init__(self, n_embd, n_head, block_size):
        super().__init__()
        raise NotImplementedError("Block.__init__() arrives in Step 4")

    def forward(self, x):
        raise NotImplementedError("Block.forward() arrives in Step 4")


class GPT(nn.Module):
    """Token embedding + position embedding -> n_layer Blocks -> LayerNorm -> Linear to vocab_size scores."""

    def __init__(self, vocab_size, n_embd=64, n_head=4, n_layer=2, block_size=64):
        super().__init__()
        self.block_size = block_size
        raise NotImplementedError("GPT.__init__() arrives in Step 4")

    def forward(self, idx):
        raise NotImplementedError("GPT.forward() arrives in Step 4")


# ---------- Step 5: train it, and measure what context buys ----------
def train(model, data, steps, lr=3e-3, batch_size=32, seed=0):
    """AdamW(lr); `steps` batches from get_batch(data, batch_size, model.block_size, a generator seeded with seed).
    Returns the loss of every step."""
    raise NotImplementedError("train() arrives in Step 5")


@torch.no_grad()
def loss_by_position(model, data, batches=20, batch_size=32, seed=1):
    """The mean loss at each position 0 .. block_size - 1 of the window, over `batches` batches of data (eval mode,
    get_batch with a generator seeded with seed). Position 0 sees one character; the last sees block_size."""
    raise NotImplementedError("loss_by_position() arrives in Step 5")
Provided for you:sherlock.txttry_it.py

Frequently asked questions

What does the causal mask in a GPT do?

It stops each position from attending to later positions. In training the target is the next token, so without the mask the model could read the answer instead of predicting it.

Why divide attention scores by the square root of the head size?

Dot products of longer vectors have larger magnitudes. Scaling keeps the scores in a range where the softmax spreads its weight instead of locking onto one position, which keeps gradients healthy.

What does temperature do when sampling from a language model?

It divides the scores before the softmax. Below 1 the most likely tokens dominate and the text becomes safer and more repetitive; above 1 the distribution flattens and the text becomes more varied and more error-prone.

A GPT you can train on a laptop CPU

GPT-style models are built from a handful of parts: next-token prediction, causal self-attention, multi-head attention, transformer blocks and sampling. Small enough, the whole thing trains in under a minute. In this lab you write each part and check it: a bigram baseline, a causal attention head tested for peeking at the future, a 114,515-weight GPT, and a measurement of how the loss falls as each position sees more context. The GPU lab Transformer From Scratch continues from here.