Build and Train Your Own GPT-2

Lesson 3

Positional embeddings

Injecting order into a position-blind architecture

Positional embeddings, putting order back into the input


Where we are in the build

After Lesson 2, you can turn "Hello world" into a (1, 2, 768) tensor of token vectors. But there's a problem you can't see from the shape alone: the model has no idea which token came first. Self-attention treats its input as a set, not a sequence. If you reorder the tokens, the attention layer (without positional information) will reorder its outputs the exact same way, the outputs are equivariant to permutation.

That makes attention permutation-blind in a way that's catastrophic for language. "Dog bites man" and "man bites dog" become the same multiset of tokens. The model has no architectural reason to treat them differently.

Today's lesson fixes that with positional embeddings, a second 768-dim vector for each position in the sequence, summed onto the token vector before the first attention block. After this lesson, the input to the first transformer block carries both what the token is and where it sits in the sequence.

What you're building

embeddings.py, PositionalEmbedding + Embeddings wrapper

Input: token ids of shape (B, T) → Output: (B, T, 768) tensor with token meaning and position information. The single input the first transformer block sees.


The naive solution, no positional info

Skip positional embeddings entirely. Just feed token vectors straight into attention:

Python
import torch
import torch.nn as nn
 
n_embd = 32
T = 4
 
# A toy "attention layer", actually just a linear projection here.
# The point is that it operates on (B, T, n_embd) and does NOT care about position.
attn = nn.Linear(n_embd, n_embd)
 
token_emb = nn.Embedding(10, n_embd)
ids_a = torch.tensor([[1, 2, 3, 4]])      # "the cat sat down"
ids_b = torch.tensor([[4, 3, 2, 1]])      # "down sat cat the" (reversed)
 
out_a = attn(token_emb(ids_a))
out_b = attn(token_emb(ids_b))
 
# The outputs are exact reversals of each other, element-wise.
print(torch.allclose(out_a, out_b.flip(dims=[1])))   # True

Real attention is a bit more interesting than Linear, but the same property holds: with no positional information, permuting the inputs permutes the outputs identically. There is no preferred order. The model literally cannot tell [A, B, C] from [C, B, A] once you account for the symmetric output permutation.

For language modelling that's fatal. The model needs to predict the next token, which requires distinguishing past from future. Word order encodes meaning, "kill the lights" and "the lights kill" share a token set and disagree on basically everything else. Without an order signal, the model is forced to learn from pure co-occurrence, throwing away most of the structure of language.


The motivated solution, add a position vector

The fix is small and clever: give every position t[0,block_size)t \in [0, \text{block\_size}) its own 768-dimensional vector ptp_t, and add it to the token vector before the first transformer block:

xt=Eidxt+ptx_t = E_{\text{idx}_t} + p_t

Now the input at position 0 always has p0p_0 baked in; the input at position 1 always has p1p_1. Two identical tokens at different positions become different vectors, so attention can distinguish them. The downstream layers learn to read off "this is the token at position tt" from this combined signal.

Two design choices for the positional vectors ptp_t:

Option A: sinusoidal (the original Transformer)

Vaswani et al. (2017) used fixed sinusoidal vectors. Half the dimensions are sines, half cosines, with frequencies that span many orders of magnitude:

PE(t,2i)=sin ⁣(t100002i/d),PE(t,2i+1)=cos ⁣(t100002i/d)PE_{(t, 2i)} = \sin\!\left(\frac{t}{10000^{2i/d}}\right), \qquad PE_{(t, 2i+1)} = \cos\!\left(\frac{t}{10000^{2i/d}}\right)

This has a nice mathematical property: the encoding for position t+kt + k is a linear function of the encoding for position tt. In principle, the model can learn to attend to "the token 5 positions back" because the offset has a fixed linear representation. Sinusoidal encodings also extrapolate beyond the training context length without any extra parameters.

Option B: learned (GPT-2's choice)

Radford et al. (2019) chose learned positional embeddings, the same nn.Embedding mechanism we used for tokens, but the lookup index is the position rather than the token id:

pt  =  Pt,:,PRblock_size×Cp_t \;=\; P_{t, :}, \qquad P \in \mathbb{R}^{\text{block\_size} \times C}

Same shape as a token embedding row. Learned by gradient descent alongside everything else. The empirical result GPT-2 reports: at scale, learned positional embeddings match or slightly beat sinusoidal on perplexity, and the implementation is one line of PyTorch. The price is one extra block_size × n_embd parameter table (~0.79M params for GPT-2 small) and the hard cap that t<block_sizet < \text{block\_size} at inference, sequences longer than the training context need a different scheme.

For this course we follow GPT-2: learned positional embeddings. We build sinusoidal in the section above and stop there, the code we ship uses nn.Embedding.

Code: extend embeddings.py

Python
# nanogpt-from-scratch/embeddings.py  (extended in Lesson 3)
 
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):
        return self.weight(idx)
 
 
class PositionalEmbedding(nn.Module):
    """Map a position index in [0, block_size) to a dense vector.
 
    GPT-2 uses learned positional embeddings, not sinusoidal.
    """
    def __init__(self, config: GPTConfig):
        super().__init__()
        self.weight = nn.Embedding(config.block_size, config.n_embd)
 
    def forward(self, T: int, device: torch.device) -> torch.Tensor:
        # returns: (T, n_embd), broadcasts across the batch dim
        pos = torch.arange(T, dtype=torch.long, device=device)
        return self.weight(pos)
 
 
class Embeddings(nn.Module):
    """Sum token + positional embeddings. (B, T) -> (B, T, n_embd)."""
    def __init__(self, config: GPTConfig):
        super().__init__()
        self.tok = TokenEmbedding(config)
        self.pos = PositionalEmbedding(config)
 
    def forward(self, idx: torch.Tensor) -> torch.Tensor:
        B, T = idx.shape
        tok = self.tok(idx)                   # (B, T, n_embd)
        pos = self.pos(T, idx.device)         # (T, n_embd) -> broadcasts
        return tok + pos

Three things to notice:

  • Element-wise sum, not concat. Adding keeps the dimensionality at n_embd = 768 so the rest of the model is unchanged. Concatenating would double the dimension and force every downstream layer to be wider.
  • forward(T, device) builds the position indices on the fly. The Embeddings wrapper passes the actual sequence length T, so we never compute positional vectors for tokens we don't have.
  • block_size is a hard cap. A 1024-position table cannot embed position 1024, sequences longer than the training context would need extrapolation tricks (rotary, ALiBi, sliding-window). GPT-2 just truncates.
+10−1PE(pos,2i)=sin(pos/10000^(2i/d)), PE(pos,2i+1)=cos(…)
Sequence length 24
Model dim d 32
Click a position row to see its encoding vector.

Each row is a position; each column a dimension. Low dims oscillate fast, high dims slow — a unique fingerprint per position. Neighbors get similar vectors (so attention can learn relative distance); it's a fixed function, so it extrapolates to unseen lengths.


Math by typing it

The combined input at position tt is the element-wise sum:

xt  =  Eidxt,:  +  Pt,:,xtRCx_t \;=\; E_{\text{idx}_t,\,:} \;+\; P_{t,\,:}, \qquad x_t \in \mathbb{R}^{C}

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

yb,t,c  =  Eidxb,t,c  +  Pt,cy_{b, t, c} \;=\; E_{\text{idx}_{b,t},\, c} \;+\; P_{t,\, c}

Sinusoidal alternative (not used in GPT-2, included for reference):

PE(t,2i)=sin ⁣(t100002i/d),PE(t,2i+1)=cos ⁣(t100002i/d)PE_{(t, 2i)} = \sin\!\left(\frac{t}{10000^{2i/d}}\right), \qquad PE_{(t, 2i+1)} = \cos\!\left(\frac{t}{10000^{2i/d}}\right)

Parameter count for GPT-2 small's positional table:

block_size×nembd  =  1024×768  =  786,432    0.79M params\text{block\_size} \times n_{\text{embd}} \;=\; 1024 \times 768 \;=\; 786{,}432 \;\approx\; 0.79 \text{M params}

That's ~50× smaller than the token embedding table. Position is a much smaller universe than vocabulary.

QuantityToken tablePositional table
Rows50,2571,024
Columns (n_embd)768768
Parameters38.6M0.79M
Lookup keytoken idposition index
Trainable in GPT-2yesyes

Run it on your laptop

Your task

Extend embeddings.py with PositionalEmbedding and the Embeddings wrapper. Then run the smoke test below, it should print a (1, 5, 768) tensor and confirm that the same token at two different positions produces two different vectors.

Python
# Extend smoke_test.py from Lesson 2.
 
import torch
 
from config import GPTConfig
from embeddings import Embeddings
 
config = GPTConfig()
embed = Embeddings(config)
 
ids = torch.tensor([[15496, 995, 11, 995, 13]])    # "Hello world , world ."
out = embed(ids)
print("output shape:", out.shape)                  # torch.Size([1, 5, 768])
 
# The token "world" appears at positions 1 and 3.
# Their token-embedding contributions are identical, but the positional
# contributions differ, so the combined vectors should not match.
v_pos1 = out[0, 1]
v_pos3 = out[0, 3]
print("same vector at positions 1 and 3?", torch.allclose(v_pos1, v_pos3))   # False
 
# Sanity: the parameter count grew by exactly block_size * n_embd over Lesson 2.
n_params = sum(p.numel() for p in embed.parameters())
print(f"total embedding params: {n_params:,}")     # 39,383,808 = 38,597,376 + 786,432

If same vector at positions 1 and 3? prints False, your positional embedding is doing its job, the model can now distinguish two occurrences of the same token.

Interactive build · pending wire-up

<positional-encoding-explorer />

In-browser cell: type a sequence with repeated tokens, see the output (B, T, 768); a slider toggles between learned and sinusoidal positional encodings; a heatmap shows positional similarity between any two positions.


What you can now do

  • Turn a sequence of token ids into a (B, T, n_embd) tensor that carries both token meaning and position.
  • Distinguish the same token at two different positions, the input to the first transformer block is order-aware.
  • Recognise the trade-off between learned positional embeddings (GPT-2's choice, slightly better at scale, capped at block_size) and sinusoidal encodings (Vaswani 2017, extrapolates, no parameters).
  • Reason about the parameter cost: ~0.79M for GPT-2 small's positional table, a rounding error compared to the 38.6M token table.

Quiz

Check your understanding

1 / 7

Why does a transformer need positional embeddings at all? RNNs didn't.


Practice it yourself

Build GPT-2's learned positional path — the lookup, the cap, and the batched fusion. Real Python, hidden tests, no setup.


What's next

Token meaning + position now live in a single (B, T, 768) tensor. The next layer of the model finally does what made transformers famous: every token looks at every other token and decides who matters. Lesson 4 opens up attention.py and builds scaled dot-product attention, first as plain NumPy so the math is naked, then ported to multi-head PyTorch with three projection matrices (WQ,WK,WVW_Q, W_K, W_V) and a recombined output.

Test your understanding

Prof is ready

Prof will ask you questions about Positional Embeddings / Positional Encodings — 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).