The Nightly Build.
Transformers from Scratch · Foundations

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

In this article
  • 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.Embedding and 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.

tokenizer.pyPyTorch
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

Byte-pair encoding (BPE) A tokenizer that starts from a base alphabet and repeatedly merges the most frequent adjacent pair into a new symbol. GPT-2's variant runs at the byte level: the base is the 256 possible byte values, and learned merges build up common multi-byte sequences on top of them, so any string of any language encodes without an out-of-vocabulary token.5

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.

Fig. 2 · Vocabulary size is a design axis, not a constant (log scale). Character-level tiny-Shakespeare, the original GPT's BPE merges, and GPT-2's byte-level BPE. Sources: nanoGPT; "Improving Language Understanding by Generative Pre-Training"; "Language Models are Unsupervised Multitask Learners".

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.

embedding.pyPyTorch
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.

lookup_is_matmul.pyPyTorch
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 (embedding dimension) The width of the vector that represents each token, and the width every layer of the transformer preserves. Denoted 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.

  1. Encode

    Each string becomes a list of integer ids via stoi; stacked and padded to a common length, a batch is a (B, T) LongTensor of ids.6

  2. 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

  3. 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.

forward_so_far.pyPyTorch
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
  1. 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.
  2. Encode the string "Hi" by hand given stoi = {..., 'H': 20, 'i': 47, ...}, then state the shape of the tensor after the embedding lookup for a batch of one.
  3. Explain why one_hot(ids) @ W and nn.Embedding(ids) return the same tensor, and why production models still prefer the lookup.
  4. A model doubles d_model from 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

Next lesson We now have a (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

  1. 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)
  2. Sennrich, Haddow & Birch — "Neural Machine Translation of Rare Words with Subword Units" (introduces BPE for open-vocabulary segmentation)
  3. Radford et al. — "Language Models are Unsupervised Multitask Learners" (GPT-2: byte-level BPE, vocabulary 50,257, context 1,024)
  4. 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)
  5. OpenAI — GPT-2 encoder.py (byte-level BPE implementation: bytes_to_unicode over 256 bytes plus ranked merges)
  6. Karpathy — nanoGPT shakespeare_char/prepare.py (character-level vocabulary of 65, stoi/itos encode/decode)
  7. Karpathy — nanoGPT model.py (wte and wpe as nn.Embedding; defaults n_embd = 768, vocab_size = 50304, block_size = 1024)
  8. PyTorch — torch.nn.Embedding documentation ("a simple lookup table"; num_embeddings and embedding_dim; weight initialized from N(0, 1))
  9. Press & Wolf — "Using the Output Embedding to Tie Word Embeddings" (weight tying reduces parameters and lowers perplexity)