Build and Train Your Own GPT-2

Lesson 2

Token embeddings

From raw tokens to vectors the model can learn from

Token embeddings, from string to (B, T, 768)

Overview

This lesson introduces token embeddings and how they turn discrete tokens into continuous vector representations.

From characters to tokens

Before a language model can look up an embedding, it has to decide what counts as a "token." Different model families make different choices, and those choices shape how the model sees text, what it groups together, what it splits, where its weak spots are.

The playground below runs three real tokenizers (GPT-2's byte-level BPE, BERT's WordPiece, and Llama's SentencePiece) entirely in your browser. Type once, and watch how each one carves up the same input.

A few things to try:

  • Indented code, count how many tokens each model spends on the leading whitespace alone.
  • Glitch tokens like SolidGoldMagikarp, a known artifact in GPT-2's vocabulary; see how it tokenizes.
  • Non-English text (Hindi, Mandarin), most tokenizers fall back to byte-level fragments, dramatically inflating the token count.
  • Numbers like 1234567890, different tokenizers split these very differently.

The colors are stable: the same token ID always gets the same color, so when the same colored block reappears, that's the model reusing one vocabulary entry.

Tokenizer Playground
GPT-2
BPE (byte-level)
tokens
BERT-uncased
WordPiece
tokens
Llama
SentencePiece (BPE)
tokens
Each tokenizer downloads once (~5–10 MB), then runs entirely in your browser. Same color = same token ID across runs.

Where we are in the build

Lesson 1 left you with config.py, a single dataclass that says "GPT-2 small is 12 layers, 12 heads, 768-dim embeddings, vocab of 50257." Today we fill in the very first thing the model touches: the path from a string of English to a tensor of floats.

This lesson adds two files to your scaffold:

  1. tokenizer.py, turn a string into a list of integer token ids in [0, 50257).
  2. embeddings.py, turn each integer token id into a 768-dimensional learnable vector.

After this lesson, you can call encode("Hello world") and get back [15496, 995], then feed those integers into a TokenEmbedding(config) and get back a (1, 2, 768) tensor. That tensor is the input to the very first transformer block, everything else in the model operates on these vectors.

What you're building

tokenizer.py + embeddings.py, text in, vectors out

Input: "Hello world" → Output: a (B, T, 768) float tensor. About a third of GPT-2's 124M parameters live in this single embedding table.


The naive solution, one-hot vectors

Before we use a learned embedding table, let's do the dumbest possible thing and see why it can't work. Each token id i[0,50257)i \in [0, 50257) becomes a vector with a single 1 at position ii and zeros everywhere else:

Python
First run loads the Python runtime (~10 MB) — takes ~5–10 seconds. Subsequent runs are instant.

Now imagine a batch of 32 sequences of 1024 tokens each. The input tensor is (32, 1024, 50257) of float32:

32×1024×50257×4 bytes6.6 GB32 \times 1024 \times 50257 \times 4 \text{ bytes} \approx 6.6 \text{ GB}

Per batch. Just to represent the input. Before we've done a single matrix multiply.

That's just the memory problem. The deeper problem is that one-hot vectors carry no information about token similarity. The vector for "cat" is exactly as far from "dog" as it is from "subpoena", they're all orthogonal unit vectors, distance 2\sqrt{2} from each other. The model can't generalise from "the cat sat" to "the dog sat" without learning every word independently. We've thrown away every shred of structure that would help the model learn.


The motivated solution, learned dense embeddings

The fix is to replace the 50257-dimensional sparse vector with a dense vector of 768 floats, and to learn those 768 floats by gradient descent, alongside the rest of the model. Tokens that play similar roles (synonyms, conjugations, related concepts) end up with similar vectors. The model picks the geometry that helps it predict the next token.

Mechanically, an embedding layer is a single matrix ERV×CE \in \mathbb{R}^{V \times C} where V=50257V = 50257 and C=768C = 768. To look up token ii, you take row ii of EE. That's it, no matrix multiply, just an indexing operation that PyTorch implements as nn.Embedding.

The parameter count works out to:

V×C=50257×76838.6M parametersV \times C = 50257 \times 768 \approx 38.6 \text{M parameters}

For GPT-2 small (124M total), about 31% of the entire model lives in this one table. Embeddings are not a side-show, they are the foundation, and a substantial fraction of the optimiser's job is learning them.

Tokenizer code

Python
# nanogpt-from-scratch/tokenizer.py
 
import tiktoken
 
_enc = tiktoken.get_encoding("gpt2")
 
 
def encode(text: str) -> list[int]:
    """Map a string to a list of token ids in [0, 50257)."""
    return _enc.encode(text)
 
 
def decode(ids: list[int]) -> str:
    """Map token ids back to a string."""
    return _enc.decode(ids)
 
 
def vocab_size() -> int:
    return _enc.n_vocab   # 50257

We do not reimplement BPE, that would be a course of its own. tiktoken is OpenAI's official library and gives us the exact same vocabulary and merges as GPT-2. The point of this file is to have a single import path (from tokenizer import encode, decode) that the rest of the project uses.

Embedding code

Python
# nanogpt-from-scratch/embeddings.py
 
import torch
import torch.nn as nn
 
from config import GPTConfig
 
 
class TokenEmbedding(nn.Module):
    """Map token ids -> dense vectors. (B, T) -> (B, T, n_embd)."""
 
    def __init__(self, config: GPTConfig):
        super().__init__()
        self.weight = nn.Embedding(config.vocab_size, config.n_embd)
 
    def forward(self, idx: torch.Tensor) -> torch.Tensor:
        # idx: (B, T) int64 token ids
        # returns: (B, T, n_embd) float32
        return self.weight(idx)

A few things to notice:

  • No bias. Embeddings don't get added, they get looked up. There is no + b step.
  • Initialisation matters. PyTorch initialises nn.Embedding with N(0,1)\mathcal{N}(0, 1). nanoGPT and the GPT-2 paper use N(0,0.02)\mathcal{N}(0, 0.02), closer to the small variance the rest of the model expects. We'll set this in Lesson 10 when we assemble the full model.
  • The same matrix shows up at the output. When the model finishes producing a (B, T, 768) hidden state, it has to project back to (B, T, 50257) to score every token. GPT-2 ties the output projection to this same embedding matrix, one set of 38.6M weights, used twice. Halves the embedding parameter cost and tends to give slightly better perplexity. We'll wire this in Lesson 10.

Animation · pending wire-up

<embedding-space-projection />

2D projection of GPT-2's learned embedding space, hover any token to see its nearest neighbours by cosine similarity. Synonyms cluster, capitalised forms split, numbers form a stripe.


Math by typing it

The embedding lookup is one matrix and one indexing operation:

ERV×C,TokenEmbedding(i)=Ei,:E \in \mathbb{R}^{V \times C}, \qquad \text{TokenEmbedding}(i) = E_{i, :}

Looked up over a batch of token ids idxZB×T\text{idx} \in \mathbb{Z}^{B \times T}:

yb,t,c=Eidxb,t,  cyRB×T×Cy_{b, t, c} = E_{\text{idx}_{b,t},\; c} \qquad y \in \mathbb{R}^{B \times T \times C}

Equivalent, and how some pedagogy presents it, is to one-hot idx\text{idx} to a (B, T, V) tensor and matmul by EE. The result is identical, but nn.Embedding does the indexing form so you skip allocating the giant sparse tensor.

Memory accounting for GPT-2 small:

QuantityValue
Embedding matrix EE50257×768=38,597,37650257 \times 768 = 38{,}597{,}376 params
Bytes (fp32)~155 MB
Bytes (bf16, training-time)~77 MB
Fraction of GPT-2 small~31% of 124M total

Run it on your laptop

Your task

Open tokenizer.py and embeddings.py in your scaffold, paste the code above, then run the smoke test below. It should print two token ids and a tensor of shape (1, 2, 768).

Python
# smoke_test.py, at the project root, run with `python smoke_test.py`
 
import torch
 
from config import GPTConfig
from tokenizer import encode, decode, vocab_size
from embeddings import TokenEmbedding
 
# Tokenize.
ids = encode("Hello world")
print("token ids:", ids)               # [15496, 995]
print("round-trip:", decode(ids))      # "Hello world"
print("vocab size:", vocab_size())     # 50257
 
# Embed.
config = GPTConfig()
embed = TokenEmbedding(config)
idx = torch.tensor([ids])              # shape (1, 2)
out = embed(idx)
print("embedding shape:", out.shape)   # torch.Size([1, 2, 768])
print("dtype:", out.dtype)             # torch.float32
 
# Sanity: same id always yields the same vector.
assert torch.allclose(embed(torch.tensor([[15496]])),
                      embed(torch.tensor([[15496]])))
 
# Sanity: the embedding has the parameter count we expect.
n_params = sum(p.numel() for p in embed.parameters())
print(f"embedding params: {n_params:,}")   # 38,597,376

If the parameter count prints 38,597,376, you've matched GPT-2 small's embedding table exactly.

Interactive build · pending wire-up

<token-embedding-explorer />

In-browser Pyodide cell: type a string, see its tiktoken ids, watch the (B, T, 768) tensor materialise. A second tab plots the cosine similarity between any two tokens of your choice.


What you can now do

  • Encode arbitrary English text to a sequence of integer token ids using GPT-2's exact vocabulary.
  • Convert any sequence of token ids into a (B, T, n_embd) tensor of learnable vectors, ready for the rest of the model.
  • Reason about the parameter cost of the embedding table, and why it's the single largest weight matrix in the model until Lesson 8 introduces the FFN.
  • Recognise the weight tying trick that GPT-2, GPT-3, and most subsequent LLMs use to avoid paying for the embedding twice.

Quiz

Check your understanding

1 / 7

GPT-2 small uses vocab_size=50257 and n_embd=768. How many parameters live in the token embedding table?


Practice it yourself

Feel why one-hot fails and how weight tying pays off. Real Python, hidden tests, no setup.


What's next

You can turn text into vectors. But there's a problem the playground above hints at: token ids are just integers. The vector for the first "the" in a sentence is identical to the vector for the second "the", the model has no idea which token came first. Lesson 3 fixes that by extending embeddings.py with a PositionalEmbedding layer that adds an order signal to every token's vector before the first transformer block sees it.

Test your understanding

Prof is ready

Prof will ask you questions about Token Embeddings — not explain it. You'll be surprised what you don't know until you have to say it.

Finished this lesson?

Read through the lesson first (0/20s).