S110 · Positional Encoding — Sinusoidal, Learned, RoPE
Attention is permutation-invariant — it doesn't know word order. Positional encoding fixes that. From the paper's sinusoidal trick to modern RoPE, the encoding that quietly powers LLaMA and every 2024 LLM.
🎯 Understand why Transformers need explicit position information, how sinusoidal / learned / rotary encodings differ, and why RoPE dominates 2024 LLM design.
Why this session exists
A single word missing: without positional encoding, "The dog chased the cat" and "The cat chased the dog" produce IDENTICAL Transformer outputs. Attention is permutation-invariant — Q·K^T only cares about content, not position. The way we inject position determines how far the model can extrapolate at inference time, which is the difference between LLaMA-2's 4K context and Gemini's 1M. From 2017's sinusoidal add-on to 2021's RoPE (used by every modern LLM), positional encoding has been quietly one of the most consequential architectural choices.
- Explain why attention is permutation-invariant and prove it with a small example.
- Compute a sinusoidal positional encoding by hand for the first few positions.
- Distinguish sinusoidal, learned, and rotary (RoPE) positional encodings.
- Understand why RoPE extrapolates to longer contexts better than the alternatives.
- Implement RoPE and see the difference in code.
Prerequisites
- S107 · Attention Intuition — why attention has this problem.
- S108 · Q/K/V Math — you need to see where positional info gets injected.
- S109 · Multi-Head Attention — positional encoding applies per-head.
- S097 · Linear Algebra — rotations and complex numbers for RoPE.
(a) Intuition · 5 min
Imagine your favourite novel with all chapters shuffled and no page numbers. Same words, same paragraphs, same content — but you can't reconstruct the plot. Word ORDER is where meaning lives, and shuffled order destroys meaning.
Now imagine adding a small mark to each page — 'this is page 47' — using a code the reader knows. Suddenly you can reorder mentally and read in the intended sequence.
Attention operates on a set of vectors — it computes Q·K^T which doesn't depend on order. To distinguish "The dog chased the cat" from "The cat chased the dog," we need to STAMP each token vector with its position before attention sees it.
Different stamps give different properties: sinusoidal (fixed function of position), learned (a lookup table you train), or rotary (rotate Q and K by angles derived from position). Modern LLMs use rotary because it extrapolates gracefully to positions never seen during training.
Why attention is permutation-invariant
- Sinusoidal (2017 Transformer) — fixed sine/cosine functions of position added to input embeddings. Not learned. Works for any position.
- Learned (BERT, GPT-2) — a learned embedding per position, added to input. Simple, but can't handle positions beyond training length.
- Rotary (2021 RoPE) — rotate Q and K by angles derived from position. Injected INSIDE attention, not into embeddings. Encodes RELATIVE position naturally. Used by LLaMA, GPT-NeoX, PaLM.
(b) Visual walkthrough · 15 min
Sinusoidal positional encoding
The formula (Vaswani et al. 2017):
PE[pos, 2i] = sin(pos / 10000^(2i/d))
PE[pos, 2i+1] = cos(pos / 10000^(2i/d))
The three approaches compared
Fixed sine/cosine
- Zero params
- Extrapolates in principle
- Empirically doesn't work great past training length
- Original Transformer, T5
One embedding per position
- Simple, expressive
- Zero extrapolation — cutoff at max_len
- Adds max_len × d_model params
- BERT, GPT-2, RoBERTa
Bias added to attention scores
- Encodes only relative distance
- Extrapolates better than absolute
- Extra bias table
- T5, DeBERTa
Rotate Q, K by position angles
- Encodes relative position naturally
- Extrapolates well with scaling tricks
- No extra params
- LLaMA, PaLM, GPT-NeoX, Mistral
RoPE — the geometric idea
Take d_head-dim Q and K vectors. Split into pairs: (q_0, q_1), (q_2, q_3), ... — d_head/2 pairs.
Pair i gets frequency θ_i = 10000^(-2i/d). Low i = slow rotation; high i = fast rotation.
At position m, pair i is rotated by angle m·θ_i. Standard 2D rotation matrix: [[cos, -sin], [sin, cos]].
Both Q at position m and K at position n get rotated. The dot product q_m · k_n then depends only on (m-n), not on m or n independently.
Nothing else changes — the rotation is a pre-multiplication of Q and K. Downstream softmax and matmul with V are unchanged.
Sinusoidal PE at 5 positions, d=8
What sinusoidal PE actually looks like
Why RoPE extrapolates and learned doesn't
"Positional encodings tell the model where each token is. So a model trained with sinusoidal encodings should handle longer sequences fine — the formula is defined for every position."
Being defined at position 5000 is not the same as being trained at position 5000. Attention has to learn to read positional structure from the encoding, and it only learns the regime it saw. Extrapolation beyond the training length degrades sharply for sinusoidal and rotary encodings alike — which is exactly why context extension needs interpolation or rescaling, not just a longer loop.
Because the original paper argued sinusoids "may allow the model to extrapolate to longer sequences", and the formula genuinely has no upper bound. The myth survives because the failure is silent: no exception, no NaN, just quality falling off a cliff past the training length. And the low-frequency dimensions are the culprit — during training they never completed a full period, so the model learned a monotone ramp, and beyond training length that ramp enters values it has never seen.
Look at how much of each frequency band was actually exercised during training:
import numpy as np
d, L = 128, 2048
i = np.arange(0, d, 2)
wavelen = 2*np.pi * (10000 ** (i/d))
for k in (0, 20, 40, 62):
print(f'dim {i[k]:3d} wavelength={wavelen[k]:12.1f} periods seen in {L} tokens = {L/wavelen[k]:.3f}')
# top dims complete a fraction of one period -> the model never saw them wrapWhy must positional information be injected at all, and why does RoPE apply a rotation rather than an addition?
- 1Self-attention computes scores from
xiTW xjand sums values with those weights. Permute the inputs and both the score set and the sum permute identically.forced by · every operation in attention is either per-token or a symmetric sum over tokens — nothing reads an index - 2So without added positional information a transformer is a set function: "dog bites man" and "man bites dog" are literally the same computation.forced by · permutation equivariance is a structural property, not a training artefact
- 3What attention actually needs is not absolute position but relative position: whether j is near i, and in which direction. Language dependencies are overwhelmingly stated in relative terms.forced by · the same phrase means the same thing at the start or the middle of a document
- 4Adding an absolute vector to the input makes the score a mess of cross terms — content×content, content×position, position×position — where relative distance is only implicit and must be learned.forced by · expanding
(xi+pi)TW(xj+pj)yields four terms, and only one carries clean positional interaction - 5Instead rotate Q and K in 2-D subspaces by an angle proportional to position. Then the dot product of a rotated pair depends on the rotation angles only through their difference:
(Rmq)T(Rnk) = qTRn−mk.forced by · rotations compose additively and orthogonal matrices preserve inner products, so absolute angles cancel and only m−n survives
Therefore RoPE gives exactly relative position, with no extra parameters, no added terms, and no change to vector norms — the property the task needs, obtained by construction rather than by learning.
And note what this predicts: since position enters only as an angle mθ, you can extend context by shrinking θ so that a longer range maps into the angle range the model was trained on. That is precisely what position interpolation and NTK-aware scaling do, and it is why they work with only brief fine-tuning while naive extrapolation does not work at all.
Give every token a bank of clock hands: some sweeping fast (one revolution every few tokens), some barely moving (one revolution every thousands). The token's position is the joint reading of all the hands — fast hands resolve fine local offsets, slow hands disambiguate coarse document-scale location.
RoPE says: instead of writing the clock reading down next to the token, physically rotate the query and key by it. Then comparing two tokens automatically compares only the angle between their clocks, which is their distance.
- Without positional information a transformer is permutation-invariant — a bag of tokens. This is structural.
- Sinusoidal: fixed, parameter-free, absolute. Learned: flexible, but hard-capped at max length with no meaning beyond it. RoPE/ALiBi: relative, and the modern default.
- RoPE is applied to Q and K only, inside every layer — never to V and never once at the embedding.
- Long-context extension is angle rescaling (position interpolation / NTK / YaRN), not a bigger table. The low-frequency dimensions are what break first.
Fire this model the moment you see: a model degrading past a specific sequence length · a "context extension" claim · a bag-of-words failure where order is ignored · rope_theta or rope_scaling in a config file · an attention implementation where RoPE was applied before rather than after the QK projection.
Which positional scheme for a model you intend to serve with variable and possibly growing context: learned absolute, sinusoidal absolute, RoPE, or ALiBi?
RoPE is the default for good reason: it encodes the property the task actually has (relativity) as a structural invariant rather than something to be learned, and it leaves a knob for extension. The senior instinct is to look at rope_theta and the intended max length together at design time — choosing the base for the context you will eventually want is nearly free, while retrofitting is a fine-tuning project.
Whatever you pick, do not trust length extrapolation without measuring it. Evaluate at your real target length with a retrieval-style probe, because the failure mode is silent quality loss, not an error.
(c) Hands-on · 25 min
Implement sinusoidal PE and RoPE from scratch, visualise their properties, and see how each affects a small attention layer.
# positional_encoding.py — sinusoidal, learned, and RoPE from scratch.
# Run: uv run positional_encoding.py
import math
import torch
import torch.nn as nn
import torch.nn.functional as F
def sinusoidal_pe(max_len: int, d_model: int) -> torch.Tensor:
"""Standard 2017 Transformer positional encoding. Returns [max_len, d_model]."""
pe = torch.zeros(max_len, d_model)
position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1) # [T, 1]
# div_term shape [d_model/2]: 10000^(2i/d) for i=0..d/2
div_term = torch.exp(
torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model)
)
pe[:, 0::2] = torch.sin(position * div_term) # even indices
pe[:, 1::2] = torch.cos(position * div_term) # odd indices
return pe
class LearnedPE(nn.Module):
"""Learned absolute positional embeddings — one vector per position."""
def __init__(self, max_len: int, d_model: int):
super().__init__()
self.pe = nn.Embedding(max_len, d_model)
def forward(self, x: torch.Tensor) -> torch.Tensor:
# x: [B, T, d_model]
T = x.size(1)
positions = torch.arange(T, device=x.device) # [T]
return x + self.pe(positions).unsqueeze(0) # broadcast
def precompute_rope_freqs(d_head: int, max_len: int, base: float = 10000.0) -> tuple[torch.Tensor, torch.Tensor]:
"""Precompute cos/sin tables for RoPE. Called once at model init."""
# d_head must be even.
theta = 1.0 / (base ** (torch.arange(0, d_head, 2).float() / d_head)) # [d_head/2]
positions = torch.arange(max_len).float() # [max_len]
freqs = torch.outer(positions, theta) # [max_len, d_head/2]
cos = freqs.cos()
sin = freqs.sin()
return cos, sin
def apply_rope(x: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor) -> torch.Tensor:
"""Apply rotary position embedding to a Q or K tensor of shape [B, T, N, d_head]."""
# Split last dim into two halves: even indices and odd indices.
x_even = x[..., 0::2] # [B, T, N, d_head/2]
x_odd = x[..., 1::2] # [B, T, N, d_head/2]
# cos, sin: [T, d_head/2]. Broadcast against [B, T, N, d_head/2].
T = x.size(1)
cos_t = cos[:T].unsqueeze(0).unsqueeze(2) # [1, T, 1, d_head/2]
sin_t = sin[:T].unsqueeze(0).unsqueeze(2)
# 2D rotation applied to each (even, odd) pair.
rotated_even = x_even * cos_t - x_odd * sin_t
rotated_odd = x_even * sin_t + x_odd * cos_t
# Interleave back into a tensor of the original shape.
out = torch.empty_like(x)
out[..., 0::2] = rotated_even
out[..., 1::2] = rotated_odd
return out
class AttentionWithRoPE(nn.Module):
"""Multi-head attention with RoPE applied to Q and K before the dot product."""
def __init__(self, d_model: int, n_heads: int, max_len: int = 2048):
super().__init__()
assert d_model % n_heads == 0
self.n_heads = n_heads
self.d_head = d_model // n_heads
self.W_q = nn.Linear(d_model, d_model, bias=False)
self.W_k = nn.Linear(d_model, d_model, bias=False)
self.W_v = nn.Linear(d_model, d_model, bias=False)
self.W_o = nn.Linear(d_model, d_model, bias=False)
cos, sin = precompute_rope_freqs(self.d_head, max_len)
# Non-learnable buffers — travel with the model on .to(device).
self.register_buffer("cos", cos)
self.register_buffer("sin", sin)
def forward(self, x: torch.Tensor) -> torch.Tensor:
B, T, D = x.shape
Q = self.W_q(x).view(B, T, self.n_heads, self.d_head)
K = self.W_k(x).view(B, T, self.n_heads, self.d_head)
V = self.W_v(x).view(B, T, self.n_heads, self.d_head)
# Apply RoPE to Q and K only (NOT to V — value doesn't need positional encoding).
Q = apply_rope(Q, self.cos, self.sin)
K = apply_rope(K, self.cos, self.sin)
# Standard MHA from here on.
Q = Q.transpose(1, 2) # [B, N, T, d_head]
K = K.transpose(1, 2)
V = V.transpose(1, 2)
scores = Q @ K.transpose(-2, -1) / math.sqrt(self.d_head)
weights = F.softmax(scores, dim=-1)
out = (weights @ V).transpose(1, 2).contiguous().view(B, T, D)
return self.W_o(out)
def visualise_sinusoidal() -> None:
pe = sinusoidal_pe(max_len=50, d_model=16)
print(f"Sinusoidal PE shape: {pe.shape}")
print(f"\nFirst 3 dims across positions 0..9:")
for pos in range(10):
print(f" pos {pos}: [{pe[pos, 0]:+.3f}, {pe[pos, 1]:+.3f}, {pe[pos, 2]:+.3f}, ...]")
print(f"\nSimilarity between positions (dot product):")
for p in [0, 1, 5, 20, 49]:
print(f" PE[0] · PE[{p}] = {(pe[0] * pe[p]).sum().item():+.3f}")
def demonstrate_rope_relative() -> None:
"""Prove that RoPE-transformed Q·K depends only on the position difference."""
torch.manual_seed(0)
d_head = 8
cos, sin = precompute_rope_freqs(d_head, 100)
# Two random vectors, at different absolute positions but same difference.
q = torch.randn(1, 1, 1, d_head) # Q at position 0 (pre-rotation)
k = torch.randn(1, 1, 1, d_head) # K at position 0 (pre-rotation)
print("\nTest: rotate Q at pos m, K at pos n. Check that Q·K depends only on (n-m).")
for m, n in [(0, 5), (10, 15), (50, 55), (0, 10), (20, 30)]:
q_ = q.clone()
k_ = k.clone()
# Rotate at absolute positions.
q_rot = apply_rope(q_, cos[m:m+1], sin[m:m+1])
k_rot = apply_rope(k_, cos[n:n+1], sin[n:n+1])
dot = (q_rot * k_rot).sum().item()
print(f" m={m:2d}, n={n:2d}, diff={n-m:2d} → q_rot·k_rot = {dot:+.4f}")
print("(Notice: same difference → same dot product, regardless of absolute positions.)")
def main() -> None:
print("=== Sinusoidal PE ===")
visualise_sinusoidal()
print("\n=== Learned PE ===")
x = torch.randn(1, 5, 16)
learned = LearnedPE(max_len=100, d_model=16)
out = learned(x)
print(f"Learned PE: input {x.shape} → output {out.shape}")
print(f"Params: {sum(p.numel() for p in learned.parameters()):,}")
print("\n=== RoPE ===")
demonstrate_rope_relative()
# Full attention layer with RoPE.
print("\n=== AttentionWithRoPE end-to-end ===")
attn = AttentionWithRoPE(d_model=64, n_heads=4)
x = torch.randn(2, 10, 64)
out = attn(x)
print(f"Input: {x.shape} → Output: {out.shape}")
if __name__ == "__main__":
main()Anatomy of the script
What the interesting lines do
Add code to compute cross-position similarities beyond max_len for each encoding type. You'll see learned PE catastrophically failing while sinusoidal and RoPE degrade gracefully. This is the entire reason RoPE dominates modern LLMs — it's the only one that reliably extends.
(d) Production reality · 15 min
LLaMA-1 was trained with 2048 context and RoPE. Users wanted 4K or 8K context for long documents. Naively extending — just feeding 8K tokens — degraded quality sharply. The RoPE frequencies had never been trained for those large positions and the attention patterns became noisy.
Where this shows up in the rest of the plan
(e) Recall + stretch · 10 min
Explain-out-loud test
If you can't teach these three without notes, redo the session:
- Why does a Transformer NEED positional encoding?
- What is RoPE, and why does it encode relative position naturally?
- Why can LLaMA extend context but GPT-2 can't?
What comes next
Hub: The 6-Month Learning Plan
Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.