Learned position embeddings
BERT and GPT-2 gave each slot its own trainable row and let the model work out what position means. It works well, up to the last row of the table.
- 11 min read
- 3 reading levels
- Updated
Read these first
On this page 7
One lesson, three depths. Pick the one that fits you today — you can switch any time.
Beginner — No maths. Plain English.
A learned position embedding gives every slot its own row of numbers, and the model trains those numbers like any other weights.
Think of numbered seats in a cinema. The seat number tells you nothing on its own. What you learn over many visits is that the front row hurts your neck and the middle is best.
Nobody told you the meaning of "row 1". You worked it out by sitting there. A learned position embedding does the same for slot 1, slot 2 and slot 511.
The model is handed blank labels and figures out what each slot is worth. That is the entire method.
Why anyone would do this instead of using waves
Waves are a guess. A clever guess, but a guess about what "position" should mean.
A learned table makes no guess. If the first token of every document is special in your data, the model discovers that and puts it in the row. If the last token matters, it discovers that too.
BERT and GPT-2 both chose this. It is one line of code, it trains alongside everything else, and on ordinary text it works at least as well as waves.
How it works
position table (512 rows, one per slot)
┌──────────────────────────────┐
│ slot 0: [ ...768 numbers ] │ ← trained
│ slot 1: [ ...768 numbers ] │ ← trained
│ slot 2: [ ...768 numbers ] │ ← trained
│ ... │
│ slot 511: [ ...768 numbers ] │ ← trained
└──────────────────────────────┘
word "cat" at slot 2 → cat_vector + row_2 → into the transformerLook up the row for the slot. Add it to the word vector. That is it.
The wall at the end of the table
Now the catch, and it is a hard one. The cinema has 512 seats. There is no seat 512.
Ask for a position the table does not have and the program stops with an error. Not a bad answer. A crash.
This is why a model's context limit is often a hard number rather than a soft one. The table has a last row, and past it there is nothing to look up.
The quieter problem
There is a second issue that hurts more in practice. Rows near the end of the table are used far less during training.
Most training documents are short. Slot 5 appears in nearly every example. Slot 500 appears in very few. So the later rows are trained on far less evidence and stay closer to their random starting values.
The result is a model that behaves well early in a document and less reliably near its limit. You may have felt this as a long chat losing the thread.
Where you have seen this
- BERT, powering a decade of search ranking.
- GPT-2, with its famous 1024-slot limit.
- The original vision transformer, with one learned row per image patch.
- Any model whose context length is a flat number with no graceful degradation past it.
Remember this
- Each slot gets a trainable row, and the model learns what each slot means.
- The table has a fixed number of rows and asking past the end is an error.
- Later rows get less training data, so behaviour is weakest near the limit.
What to learn next
- Rotary position embeddings (RoPE) — what replaced this in almost every new language model.
- Embeddings — how lookup tables learn meaning in the first place.
- BERT — a model built on learned absolute positions.
Developer — Code and libraries.
Setup
pip install torchWritten against PyTorch 2.5.1. Runs on CPU in under a second.
The whole method, plus its two failure modes
import torch
import torch.nn as nn
torch.manual_seed(0)
MAX_POS, D_MODEL, VOCAB = 32, 16, 50
wte = nn.Embedding(VOCAB, D_MODEL) # token embeddings, one row per token id
wpe = nn.Embedding(MAX_POS, D_MODEL) # position embeddings, one row per slot
print("token embedding table :", tuple(wte.weight.shape),
f"= {wte.weight.numel():,} numbers")
print("position embedding table:", tuple(wpe.weight.shape),
f"= {wpe.weight.numel():,} numbers")
def embed(token_ids):
positions = torch.arange(token_ids.shape[-1])
return wte(token_ids) + wpe(positions) # add, do not concatenate
batch = torch.randint(0, VOCAB, (2, 12)) # two sequences of 12 tokens
print("\nembedded batch shape:", tuple(embed(batch).shape))
# Train on short sequences only, then look at which rows actually moved.
loss = embed(torch.randint(0, VOCAB, (4, 16))).pow(2).mean()
loss.backward()
grad = wpe.weight.grad.abs().sum(dim=1)
print("\ngradient reaching each position row after one backward pass:")
print(" rows 0-15 (used) : total", f"{grad[:16].sum().item():.4f}")
print(" rows 16-31 (unused): total", f"{grad[16:].sum().item():.4f}")
# Ask for a position the table does not have.
try:
wpe(torch.tensor([MAX_POS]))
except IndexError as e:
print("\nasking for position 32:", str(e).split("\n")[0])token embedding table : (50, 16) = 800 numbers position embedding table: (32, 16) = 512 numbers embedded batch shape: (2, 12, 16) gradient reaching each position row after one backward pass: rows 0-15 (used) : total 1.9565 rows 16-31 (unused): total 0.0000 asking for position 32: index out of range in self
Reading that output
The gradient on unused rows is exactly 0.0000. Not small. Zero. A row that never gets indexed receives no gradient at all, so it never learns anything.
That is the mechanism behind "the model gets worse near its context limit". It is not a subtle degradation, it is starvation. Rows indexed once per ten thousand examples learn roughly one ten-thousandth as fast as row 5.
One caveat on that zero. With weight decay, optimisers like AdamW still nudge unused rows toward zero even with no gradient, because decoupled decay applies to every parameter. So they drift, they do not learn.
index out of range in self is the entire story of a hard context limit, in one exception. There is no row 32 and no sensible default. nn.Embedding refuses rather than guessing.
Stretching the table to a longer context
Because the rows are only numbers, you can resample them the way you resize an image. Vision transformers do this routinely when the input resolution changes.
import torch
import torch.nn as nn
torch.manual_seed(0)
wpe = nn.Embedding(32, 16)
w = wpe.weight
# Treat the table as a 1-D signal of length 32 with 16 channels, and resample it.
longer = torch.nn.functional.interpolate(
w.T.unsqueeze(0), size=64, mode="linear", align_corners=False
).squeeze(0).T
print("table shape:", tuple(w.shape), "->", tuple(longer.shape))
print("new row 1 == 0.75*old[0] + 0.25*old[1] ? ",
torch.allclose(longer[1], 0.75 * w[0] + 0.25 * w[1], atol=1e-6))
print("new row 3 == 0.75*old[1] + 0.25*old[2] ? ",
torch.allclose(longer[3], 0.75 * w[1] + 0.25 * w[2], atol=1e-6))table shape: (32, 16) -> (64, 16) new row 1 == 0.75*old[0] + 0.25*old[1] ? True new row 3 == 0.75*old[1] + 0.25*old[2] ? True
Every new row is a weighted blend of two old rows. Nothing new was learned. You have doubled the number of slots and halved the resolution between them.
This works acceptably for images, where neighbouring patches are genuinely similar. It works badly for text without a finetuning pass afterwards, because the model was trained expecting one row per token, not a blend.
Common mistakes
Concatenating instead of adding. Position embeddings are added to token embeddings, not appended. Concatenating changes the width of every downstream matrix and burns capacity on channels that only ever carry position.
Initialising the table too large. GPT-2 initialises positions with a standard deviation of about 0.01, an order of magnitude smaller than its token embeddings. A position table at the same scale as the token table swamps word identity early in training.
Forgetting to offset positions when using a KV cache. During generation you feed one token at a time, so torch.arange(seq_len) yields [0] on every step. You need torch.arange(past_len, past_len + new_len). This bug produces text that is fluent and progressively confused, which makes it hard to spot.
Padding on the left without adjusting positions. With left padding, real token 0 sits at index 5. Unless you build position_ids from the attention mask, every sequence in the batch gets a different, wrong alignment.
Try it yourself
Build a nn.Embedding(64, 16) and train it on sequences of length 16 only. Then print the row norms for all 64 rows. Rows 16 upward will sit near their initialisation while the first 16 have moved. That plot is the clearest picture of why the tail of a context window is unreliable.
What to learn next
- Rotary position embeddings (RoPE) — what replaced this in almost every new language model.
- Embeddings — how lookup tables learn meaning in the first place.
- BERT — a model built on learned absolute positions.
Researcher — Mathematics and papers.
Definition
A learned absolute position embedding is a matrix $E_{pos} \in \mathbb{R}^{L \times d}$ for maximum length $L$ and width $d$. The input to layer zero is
$$ h^{(0)}m = E{tok}[x_m] + E_{pos}[m] $$
with both matrices trained by the same objective. Parameter cost is $Ld$: for BERT-base, $512 \times 768 = 393{,}216$; for GPT-2 small, $1024 \times 768 = 786{,}432$. Under 1 percent of total parameters in both cases.
Effect on the attention logit
Expanding the pre-softmax score for query position $m$ and key position $n$:
$$ \frac{1}{\sqrt{d_k}} (e_m + p_m)^\top W_Q W_K^\top (e_n + p_n) $$
$$ = \frac{1}{\sqrt{d_k}}\Big[ \underbrace{e_m^\top A e_n}_{\text{content-content}} + \underbrace{e_m^\top A p_n}_{\text{content-position}} + \underbrace{p_m^\top A e_n}_{\text{position-content}} + \underbrace{p_m^\top A p_n}_{\text{position-position}} \Big] $$
with $A = W_Q W_K^\top$. Ke et al. (2021), Rethinking Positional Encoding in Language Pre-training (TUPE), argue the two cross terms are harmful: they force one shared projection to serve two heterogeneous signals, and the position-position term is the only one carrying clean positional structure. TUPE untangles them with separate projections and reports faster pre-training convergence on GLUE.
Learned versus sinusoidal
Vaswani et al. (2017) report the two are within noise on WMT En-De. Wang et al. (2021), On Position Embeddings in BERT, probe what the learned tables contain and find BERT's rows do develop translation-like structure, but imperfectly, and the tables are noticeably noisier in their tail.
The practical differences are not about quality within the trained range:
| Learned | Sinusoidal | |
|---|---|---|
| Parameters | $Ld$ | 0 |
| Beyond $L$ | IndexError | defined, low quality |
| Tail rows | undertrained | identical treatment at every position |
| Adapts to data | yes | no |
| Extension | interpolate plus finetune | change nothing |
The undertrained-tail effect
If document lengths follow distribution $p(\ell)$, position $m$ receives gradient from a fraction $\Pr[\ell > m]$ of examples. Web-scale corpora are heavy-tailed but overwhelmingly short-dominated, so $\Pr[\ell > m]$ falls steeply. Row $m$ near $L$ can see orders of magnitude fewer updates than row 0.
This has two visible consequences. Quality degrades near the stated context limit rather than at it. And packing multiple documents into one training sequence — standard practice for throughput — is partly a fix for this, since it keeps every row occupied.
Extension by interpolation
For a table $E_{pos} \in \mathbb{R}^{L \times d}$ and target length $L' > L$, resample along the position axis:
$$ \tilde{E}[m'] = \sum_{m} w(m', m)\, E_{pos}[m], \qquad \sum_m w(m', m) = 1 $$
with $w$ from linear or bicubic interpolation. Dosovitskiy et al. (2021) use 2-D bicubic interpolation of ViT position embeddings for fine-tuning at higher resolution, and it is standard there. Touvron et al. (2021), DeiT, confirm it. For text, interpolation without a subsequent finetuning pass degrades quality substantially, which is one reason RoPE-based extension displaced it.
Current status
Learned absolute embeddings dominated 2018 to 2021 and are now uncommon in new decoder-only language models. RoPE is the default there. They remain in use where the sequence length is bounded and known: ViT patch grids, encoder-only classifiers, and models with a fixed input geometry.
Kazemnejad et al. (2023) place absolute position embeddings last among five schemes for downstream length generalisation, behind T5's relative bias, ALiBi, RoPE and NoPE.
Papers
- Devlin et al., BERT, 2019 — arxiv.org/abs/1810.04805
- Radford et al., Language Models are Unsupervised Multitask Learners (GPT-2), 2019
- Ke et al., Rethinking Positional Encoding in Language Pre-training, ICLR 2021 — arxiv.org/abs/2006.15595
- Wang et al., On Position Embeddings in BERT, ICLR 2021 — openreview.net/forum?id=onxoVA9FxMw
- Dosovitskiy et al., An Image is Worth 16x16 Words (ViT), ICLR 2021 — arxiv.org/abs/2010.11929
What to learn next
- Rotary position embeddings (RoPE) — what replaced this in almost every new language model.
- Embeddings — how lookup tables learn meaning in the first place.
- BERT — a model built on learned absolute positions.