Tokens and Embeddings: Turning Text into Vectors
Lesson one of building a GPT from scratch: how a string of text becomes the grid of numbers a transformer can actually read.
Where the course begins
This is the first lesson of a course that builds a decoder-only
transformer — a small GPT — one mechanism at a time, in PyTorch, until
the final lesson trains a model that generates text. There is no
previous lesson to recap; there is only the running example we will
extend from here to the end: a character-level GPT trained on a small
text corpus, the same setup as Andrej Karpathy's nanoGPT.6
Everything else in the course — attention, multiple heads, positional
encoding, the transformer block, the training loop — assumes the
machinery this lesson builds. A transformer never sees text. It sees a
tensor of floating-point numbers of a fixed width the original paper
calls d_model, and the first job of any language model is
to turn one into the other faithfully.1
- Tokenize text into integer ids with a character-level vocabulary, and explain when a subword scheme like byte-pair encoding is worth the added machinery.
-
Build an embedding table with
nn.Embeddingand explain why a lookup is exactly a one-hot matrix multiply, done cheaply. -
Track the shape of the data as it moves from a string to a
(batch, sequence, d_model)tensor — the object every later layer consumes. - Say precisely what the embedding vectors are (learned parameters) and what they are not (fixed features).
Two terms to fix at the outset. A token is the atomic unit of text the model reads — a character, a subword, or a whole word, depending on the scheme. The vocabulary is the finite set of distinct tokens the model knows; every token is identified by an integer index into it. The entire pipeline below exists to carry a string into that integer space and then into a continuous vector space the network can optimize over.
From characters to integers
Start with the simplest tokenizer that works: one token per character. Given a corpus, the vocabulary is just the sorted set of distinct characters that appear in it, and each character maps to its position in that set. This is the scheme Andrej Karpathy's nanoGPT uses for its tiny-Shakespeare demo, where the corpus resolves to a vocabulary of 65 characters — the letters, digits, punctuation, and whitespace present in the text.6 Two lookup tables, string-to-integer and integer-to-string, are the whole tokenizer.
text = open("input.txt").read() # the tiny-shakespeare corpus
chars = sorted(set(text)) # every distinct character, in order
vocab_size = len(chars) # 65 for tiny shakespeare
stoi = {c: i for i, c in enumerate(chars)} # string -> integer id
itos = {i: c for i, c in enumerate(chars)} # integer id -> string
encode = lambda s: [stoi[c] for c in s] # "Hi" -> [20, 47]
decode = lambda ids: "".join(itos[i] for i in ids)
encode("Hello") # -> [20, 43, 50, 50, 53]
Fig. 1 · A character-level tokenizer is two dictionaries, following
nanoGPT's stoi/itos construction.6
Character tokenization is honest and trivial to invert, but it pays for that simplicity in sequence length: every character is a step the model must attend over, so a paragraph becomes hundreds of tokens. Production language models make the opposite trade. GPT-2 uses a byte-level byte-pair encoding (BPE) tokenizer with a vocabulary of 50,257 tokens and a context window of 1,024 tokens.3 BPE, introduced for neural machine translation by Sennrich, Haddow, and Birch, adapts a data-compression algorithm to segmentation: it encodes rare and unknown words as sequences of frequent subword units, so the model gets an open vocabulary without a token for every word it might ever meet.2
The mechanics are visible in GPT-2's released tokenizer. A
bytes_to_unicode map assigns each of the 256 byte values a
printable character, and a ranked table of merges is applied greedily,
highest-priority pair first, until no ranked pair remains; the base of
256 bytes plus the learned merges is what produces the final
vocabulary.5 The lineage
predates GPT-2: the original GPT already tokenized with BPE, over a
vocabulary of 40,000 merges, on top of a Transformer decoder.4
For this course we keep the character tokenizer — it makes every tensor
small enough to print — but the embedding machinery below is identical
whichever tokenizer feeds it. Only vocab_size changes.
The embedding table is a lookup
An integer id carries no useful geometry: token 43 is not "more" than
token 20 in any sense the model should trust. The embedding layer fixes
this by mapping each id to its own learned vector. In PyTorch,
torch.nn.Embedding is documented as "a simple lookup table
that stores embeddings of a fixed dictionary and size"; its two
arguments are num_embeddings, the size of the vocabulary,
and embedding_dim, the length of each vector. The input is
a tensor of indices and the output is the corresponding
vectors.8
Internally the module holds one weight matrix of shape
(num_embeddings, embedding_dim). We will call the vector
length d_model throughout the course, following the
transformer paper's notation; it is the width every layer of the model
works in.1 Looking up a
token is selecting its row.
import torch
import torch.nn as nn
vocab_size, d_model = 65, 384
token_embedding = nn.Embedding(vocab_size, d_model)
token_embedding.weight.shape # (65, 384): one row per token
ids = torch.tensor([[20, 43, 50, 50, 53]]) # (B=1, T=5): one "Hello"
x = token_embedding(ids) # (B=1, T=5, d_model=384)
x.shape # torch.Size([1, 5, 384])
Fig. 3 · One id per position in, one vector per position out. The
batch and sequence axes pass through untouched.
Three axis names recur for the rest of the course, so fix them now:
B is the batch (how many independent sequences we process
at once), T is time or sequence length (how many tokens in
each), and C is channels, our d_model (the
width of each token's vector). The embedding step is the only place the
last axis is created; from here to the output every layer maps
(B, T, C) to (B, T, C).
It is worth seeing why a "lookup" is a real linear operation and not a
special case. Selecting row i of a matrix is identical to
multiplying that matrix by a one-hot vector with its 1 in position
i. The embedding is that multiply, with the wasteful part
skipped.
import torch.nn.functional as F
onehot = F.one_hot(ids, vocab_size).float() # (B, T, vocab_size)
x_via_matmul = onehot @ token_embedding.weight # (B, T, d_model)
torch.allclose(x_via_matmul, token_embedding(ids)) # True
Fig. 4 · nn.Embedding(ids) equals
one_hot(ids) @ W. The lookup avoids materializing a
(B, T, vocab_size) tensor and the full matmul, which is
why models never one-hot in practice.
What the vectors learn
The rows of the embedding matrix are not features you compute; they are parameters you train. PyTorch initializes the weight from a unit normal distribution, and from there the vectors move under the same gradient descent that trains the rest of the network — there is no separate embedding objective.8 The geometry that results is a byproduct of the language-modeling loss: tokens that the model benefits from treating similarly drift toward similar vectors, because that is what lowers the loss.
The embedding is not a dictionary the model reads. It is a set of coordinates the model is free to move.
The width d_model is a design choice, and it has grown with
scale. The original transformer's base model used
d_model = 512.1
nanoGPT's default configuration reproduces GPT-2 small at
n_embd = 768, with a vocabulary of 50,304 (GPT-2's 50,257
padded to a multiple of 64) and a block size of 1,024.7
A wider d_model gives each token more room to encode
distinctions, at a quadratic-ish cost downstream; for our
character-level model, 384 is comfortable.
Two refinements from the transformer paper are worth naming now,
because they act directly on this table and you will meet them again in
the code. First, the paper multiplies the embedding weights by the
square root of d_model: "In the embedding layers, we
multiply those weights by √d_model."1
It is a scaling convention that keeps the embeddings on a comparable
magnitude to the positional signal added next. Second, the same paper
shares one weight matrix between the input embedding and the final
layer that produces next-token scores: "we share the same weight matrix
between the two embedding layers and the pre-softmax linear
transformation."1 This
weight tying, studied by Press and Wolf, roughly halves the
embedding parameters and tends to lower perplexity, on the intuition
that the space you read tokens from and the space you predict them into
should be the same space.9
d_model in the
transformer paper and n_embd in GPT-style code; it is a
hyperparameter, not something the data fixes.1
The shape of the input
Put the pieces in order. A batch of strings is encoded to a
(B, T) integer tensor, the embedding table turns it into
(B, T, C), and that tensor is what the first transformer
block will read. The walk below is the forward pass up to the point the
next lessons take over.
-
Encode
Each string becomes a list of integer ids via
stoi; stacked and padded to a common length, a batch is a(B, T)LongTensorof ids.6 -
Embed the tokens
The token embedding maps
(B, T)ids to(B, T, C)vectors — the operation of Fig. 3, a row lookup per position.8 -
Add positions (a preview)
Token embeddings alone are order-blind: permute the sequence and the set of vectors is unchanged. GPT-style models add a second, position-indexed embedding of the same width, so
x = wte(idx) + wpe(pos); the two tables have shapes(vocab_size, C)and(block_size, C).7 Lesson four builds positional encoding properly; here it is enough to know where it enters.
B, T, C = 4, 8, 384 # batch, sequence, d_model
token_embedding = nn.Embedding(vocab_size, C)
position_embedding = nn.Embedding(block_size, C) # block_size = max T
idx = torch.randint(0, vocab_size, (B, T)) # (B, T) ids
pos = torch.arange(T) # (T,) positions 0..T-1
tok = token_embedding(idx) # (B, T, C)
pos_emb = position_embedding(pos) # (T, C), broadcast over B
x = tok + pos_emb # (B, T, C): ready for a block
Fig. 5 · The input to the first transformer block, assembled exactly
as nanoGPT does it: a token table plus a position table, summed.7
-
For tiny-Shakespeare's 65-character vocabulary and
d_model = 384, how many parameters are in the token embedding table? Give the shape and the count. -
Encode the string
"Hi"by hand givenstoi = {..., 'H': 20, 'i': 47, ...}, then state the shape of the tensor after the embedding lookup for a batch of one. -
Explain why
one_hot(ids) @ Wandnn.Embedding(ids)return the same tensor, and why production models still prefer the lookup. -
A model doubles
d_modelfrom 384 to 768 and leaves the vocabulary fixed. By what factor does the token embedding table's parameter count change, and why?
Go deeper
Read Section 3.4, "Embeddings and Softmax", of "Attention Is All You
Need" — it is four sentences and it states the embedding, the
√d_model scaling, and the weight tying you now recognize.1
Then open nanoGPT's model.py and find
wte and wpe: the entire token-and-position
construction of Fig. 5 is a handful of lines you can now
read.7 To feel the
tokenization trade-off in your hands, encode a paragraph of English
with the character tokenizer above and with GPT-2's BPE, and compare
the two sequence lengths against the 50,257-token vocabulary GPT-2
pays for that compression.3
If you want the algorithm itself, the merge loop in GPT-2's
encoder.py is short enough to reimplement from
scratch.5 And for the
origin of subword segmentation, Sennrich et al. is the primary
source.2
(B, T, C) tensor of token vectors that know
nothing about each other. Self-Attention by Hand builds the mechanism
that lets each position mix in information from the others — the query,
key, and value construction — and it consumes exactly the tensor this
lesson produced.
Sources
- Vaswani et al. — "Attention Is All You Need" (§3.4 learned embeddings of dimension d_model, ×√d_model scaling, weight tying; base model d_model = 512)
- Sennrich, Haddow & Birch — "Neural Machine Translation of Rare Words with Subword Units" (introduces BPE for open-vocabulary segmentation)
- Radford et al. — "Language Models are Unsupervised Multitask Learners" (GPT-2: byte-level BPE, vocabulary 50,257, context 1,024)
- Radford et al. — "Improving Language Understanding by Generative Pre-Training" (original GPT: multi-layer Transformer decoder, BPE with 40,000 merges, token and position embeddings)
-
OpenAI — GPT-2
encoder.py(byte-level BPE implementation:bytes_to_unicodeover 256 bytes plus ranked merges) -
Karpathy — nanoGPT
shakespeare_char/prepare.py(character-level vocabulary of 65,stoi/itosencode/decode) -
Karpathy — nanoGPT
model.py(wteandwpeasnn.Embedding; defaultsn_embd = 768,vocab_size = 50304,block_size = 1024) -
PyTorch —
torch.nn.Embeddingdocumentation ("a simple lookup table";num_embeddingsandembedding_dim; weight initialized from N(0, 1)) - Press & Wolf — "Using the Output Embedding to Tie Word Embeddings" (weight tying reduces parameters and lowers perplexity)