Positional Encoding

By the end of this chapter you will understand why a transformer is blind to word order unless you tell it, how the three main fixes work (learned tables, sinusoids, rotations), and why RoPE, the rotation trick used in nearly every modern model, makes attention depend only on how far apart two tokens are.

Here is a fact that surprises everyone the first time. Take the attention mechanism from the next chapter, feed it the tokens of "dog bites man", then feed it "man bites dog". Without positional information, it produces exactly the same output for "bites" in both sentences. Not similar. Identical. The most important architecture in machine learning cannot, by itself, tell who bit whom.

That is the problem this chapter solves. It is a problem of our own making: we chose attention because it treats every position the same way (which is what makes it parallel and what lets it look far back). The price is that it treats every position the same way. We now have to put order back in, and the interesting part is that there are several good answers, each with its own trade-offs.

The problem: attention is a bag of vectors

Let's see the blindness with numbers, using a stripped-down attention. Each token has a vector; for a chosen token (the query) we score every token by a dot product, turn the scores into weights with softmax, and output the weighted sum of the vectors. Nothing in that recipe mentions where a token sits. The output is a function of the set of vectors, and sets have no order.

Worked example

Give three tokens 2-d vectors: $\text{dog} = [1, 0]$, $\text{bites} = [0, 1]$, $\text{man} = [1, 1]$. Take "bites" as the query. Dot products: with dog $0$, with bites $1$, with man $1$. Softmax of $[0, 1, 1]$ is $[0.16, 0.42, 0.42]$. Output: $0.16[1,0] + 0.42[0,1] + 0.42[1,1] = [0.58, 0.84]$.

Now reorder the sentence to "man bites dog". The vectors are the same three vectors, just listed in a different order: dot products $[1, 1, 0]$, softmax $[0.42, 0.42, 0.16]$, output $0.42[1,1] + 0.42[0,1] + 0.16[1,0] = [0.58, 0.84]$. Same answer. Every token sees the same bag, so every token gets the same result regardless of order. This is called permutation equivariance: shuffle the inputs and the outputs shuffle with them, but no output ever changes.

"dog bites man" dog bites man "man bites dog" man bites dog same bag of vectors attention sameoutput nothing here knows "where"
Figure 1. Two sentences with opposite meanings enter attention as the same unordered bag and leave with the same outputs. Position must be injected into the vectors themselves, because the mechanism will not add it.

So the fix must live in the vectors. If the vector for "dog" is different when "dog" is at position 0 than when it is at position 2, attention can tell the sentences apart. Every method in this chapter is a way of making token vectors position-dependent, and they differ in how position gets in and what the model can then do with it.

Intuition

Imagine a pile of index cards, one per word, shuffled. To reconstruct the sentence you need a number written on each card. Positional encoding is the act of writing that number on the card, in a form the network can read. The methods differ in whether you write the absolute number ("card 3"), a pattern that makes relative distances easy ("3 cards after that one"), or something that only shows up when two cards are compared.

Option 1: learned absolute positions (GPT-2)

The simplest answer: keep a second embedding table, indexed by position instead of token, and add its rows to the token embeddings. GPT-2 does exactly this. Its wpe matrix has shape $1024 \times 768$, one learned row per position up to the maximum context length. The input to the first block at position $t$ for token $i$ is

$$x_t = E_{i} + P_{t}$$

Here $E_i$ is the token's row from the previous chapter and $P_t$ is row $t$ of the position table. The two are simply added; the network learns to keep them separable enough to use both. Training treats $P$ like any other parameter, so the model discovers whatever representation of position helps it predict.

class LearnedPositions(nn.Module):
    def __init__(self, max_len, d_model):
        super().__init__()
        self.weight = nn.Parameter(torch.randn(max_len, d_model) * 0.01)   # GPT-2 uses std 0.01 here

    def forward(self, x):                 # x: (batch, seq, d) token embeddings
        seq = x.shape[1]
        return x + self.weight[:seq]      # broadcast over the batch

It works well, and it is what the GPT-2 you will build uses. Its limits are just as simple. There is no row 1025, so the model physically cannot process a longer input. Nothing ties row 7 to row 8: the model has to learn from data that they are neighbours, and positions that appear rarely in training (the far end of long documents) are learned worse. And the representation is absolute: to figure out that two tokens are three apart, the network has to subtract two learned vectors it was never told were numbers.

Option 2: sinusoidal encodings (the original transformer)

Vaswani et al. (2017) instead designed the position vectors with a fixed formula, no learning. The goal was a vector for every position, of any length, in which relative distances are easy to read off. Their choice: fill the $d$ dimensions with sines and cosines of the position at a range of frequencies. For position $pos$ and dimension pair $i$ (so $2i$ and $2i{+}1$ are two adjacent coordinates):

$$PE(pos, 2i) = \sin\!\left(\frac{pos}{10000^{2i/d}}\right), \qquad PE(pos, 2i+1) = \cos\!\left(\frac{pos}{10000^{2i/d}}\right)$$

Read it as: each pair of coordinates is a point on a circle, rotating as position increases; the first pair rotates fast (one radian per position), the last pair rotates extremely slowly (one radian every ten thousand positions), with the frequencies spaced geometrically in between. Write $\omega_i = 10000^{-2i/d}$ for the frequency of pair $i$. Then pair $i$ at position $pos$ is $[\sin(\omega_i\, pos), \cos(\omega_i\, pos)]$.

Worked example

Let $d = 4$, so there are two pairs: $\omega_0 = 10000^{0} = 1$ and $\omega_1 = 10000^{-2/4} = 10000^{-0.5} = 0.01$.

Position 0: $[\sin 0, \cos 0, \sin 0, \cos 0] = [0, 1, 0, 1]$.
Position 1: $[\sin 1, \cos 1, \sin 0.01, \cos 0.01] = [0.841, 0.540, 0.010, 1.000]$.
Position 2: $[\sin 2, \cos 2, \sin 0.02, \cos 0.02] = [0.909, -0.416, 0.020, 1.000]$.

The first pair has swung most of the way around the circle in two steps; the second has barely moved. The fast pair distinguishes neighbours, the slow pair distinguishes "early in the document" from "late".

The binary-counter picture

Why a spread of frequencies? Think about how you write the number 13 in binary: 1101. The lowest bit flips every step, the next every two steps, the next every four. Fast bits resolve fine position, slow bits resolve coarse position, and together a handful of bits pin down a large range. Sinusoidal encoding is the smooth version: instead of bits that flip, coordinates that rotate, at rates spaced by a constant factor. Smoothness matters because the network needs gradients, and a bit that flips has none.

binary counter pos bit3 bit2 bit1 bit0 0 0 0 0 0 1 0 0 0 1 2 0 0 1 0 3 0 0 1 1 4 0 1 0 0 5 0 1 0 1 6 0 1 1 0 7 0 1 1 1 ← slow fast → sinusoidal: smooth "bits" pair 0 pair 1 pair 2 position → each pair rotates at its own rate; together they pin the position down
Figure 2. Left: bits flip at rates of 1, 2, 4, 8 steps. Right: sinusoidal pairs rotate at geometrically spaced rates. Both encode position with fast components for fine detail and slow ones for coarse detail; the sinusoidal version is differentiable.

Why a relative shift is a linear transform

The property Vaswani et al. actually cared about: for any fixed offset $k$, the encoding of position $pos + k$ is a linear function of the encoding of position $pos$, and that function does not depend on $pos$. In other words there is a matrix $M_k$ with $PE(pos + k) = M_k \, PE(pos)$ for every $pos$. Here is why, for one pair. We want to express $[\sin(\omega(pos+k)), \cos(\omega(pos+k))]$ in terms of $[\sin(\omega\, pos), \cos(\omega\, pos)]$. The angle-addition formulas do it:

$$\begin{bmatrix} \sin(\omega(pos+k)) \\ \cos(\omega(pos+k)) \end{bmatrix} = \begin{bmatrix} \cos \omega k & \sin \omega k \\ -\sin \omega k & \cos \omega k \end{bmatrix} \begin{bmatrix} \sin(\omega\, pos) \\ \cos(\omega\, pos) \end{bmatrix}$$

The matrix on the right is a rotation by angle $\omega k$. It contains $k$ but not $pos$. So "move $k$ positions forward" is the same rotation wherever you start, and a network that learns that rotation (which a linear layer can) has learned "look $k$ tokens back" for every position at once. That is the whole reason to use sinusoids rather than, say, the raw integer.

Check it with numbers

Take $\omega = 1$, $pos = 1$, $k = 2$. Start: $[\sin 1, \cos 1] = [0.841, 0.540]$. Rotation by 2: $\cos 2 = -0.416$, $\sin 2 = 0.909$. First output coordinate: $(-0.416)(0.841) + (0.909)(0.540) = -0.350 + 0.491 = 0.141$. And $\sin 3 = 0.141$. Second: $(-0.909)(0.841) + (-0.416)(0.540) = -0.764 - 0.225 = -0.990$, and $\cos 3 = -0.990$. It works.

Explore the full encoding below. Rows are positions, columns are dimensions; you can see the fast columns on the left striping rapidly and the slow columns on the right barely changing. Switch to the curve view to watch individual pairs rotate.

InteractiveSinusoidal encoding heatmapdrag sliders, hover cells

Left columns = high frequency (change every position), right columns = low frequency (change slowly). Hover a cell to see its value.

Where it came from

Vaswani et al. (2017), "Attention Is All You Need", section 3.5. They tried learned and sinusoidal positions, found them nearly identical in quality, and chose sinusoids in the hope of extrapolating to longer sequences. GPT-2 chose learned; both are "absolute" in the sense that each position gets its own vector.

Why absolute positions extrapolate poorly

The sinusoidal formula produces a vector for position 5,000 even if training stopped at 512. So why not just run the model longer? Because the formula is only half the story. The network learned, from data, how to use those vectors, and it only ever saw positions 0–511. The rotation trick means "look 3 back" generalises across positions, but the network also learned absolute patterns (attend to the first token; the slow dimensions never exceeded certain values), and at position 5,000 the slow dimensions take values it has never seen. Empirically, perplexity degrades quickly past the training length for both learned and sinusoidal encodings (Press et al., 2021 show this clearly in their Figure 1). Position information that the network only meets in comparisons between tokens, never as an absolute value, turns out to generalise better. That is the motivation for everything that follows.

Common confusion

"Sinusoidal encodings extrapolate; learned ones do not." The encoding extrapolates in the sense of being defined. The model does not, because attention heads learned their behaviour on a bounded range. Length generalisation is a property of the whole system, not of the position formula.

Option 3: relative positions

If what matters is how far apart two tokens are, why not give attention that directly? Instead of adding a position vector to each token and hoping the network subtracts them, modify the attention score between positions $m$ and $n$ so it depends on $m - n$. Three influential versions:

Shaw et al. (2018): learned relative embeddings

Shaw, Uszkoreit & Vaswani (2018) add a learned vector $a_{m-n}$ to the key (and optionally the value) when query $m$ attends to key $n$, with the offset clipped to a window (say $[-16, 16]$) so that the table is small and every distance beyond the window shares a vector. The score becomes $q_m \cdot (k_n + a_{m-n})$. It works and it generalises to any length (offsets beyond the window just clip), but it costs an extra term in every score and the clipping throws away long-range distance information.

T5: bucketed scalar biases

The T5 model (Raffel et al., 2020) simplifies further. Instead of a vector per offset, it adds a learned scalar bias $b_{\text{bucket}(m-n)}$ to the attention score, shared across layers, with a separate bias per head. The offsets are grouped into buckets: exact for small distances, logarithmically coarser for large ones (so offsets 1, 2, 3 each get their own bias, but 64–127 share one). Cheap, effective, and it says explicitly what many heads want: "prefer nearby tokens" or "look exactly one back".

ALiBi: a fixed linear penalty

Press, Smith & Lewis (2021), "Attention with Linear Biases", remove the learning entirely. Each head $h$ subtracts $\lambda_h \cdot |m - n|$ from every score, with $\lambda_h$ a fixed slope per head (a geometric sequence such as $1/2, 1/4, \dots$). No position embeddings at all. Far tokens are penalised in proportion to distance, and different heads see different effective ranges. Its selling point is length extrapolation: trained on 1,024 tokens, models tested well at several times that length. Its cost is that the bias is the same regardless of content, so "attend to the token 100 back if it is a quote mark" is harder to express.

Do we need positions at all?

For a causal (left-to-right) model the answer is, oddly, no: the causal mask itself leaks position, because token $t$ can count how many tokens it is allowed to see. Haviv et al. (2022) and Kazemnejad et al. (2023) show that transformers with no positional encoding at all ("NoPE") learn language modelling competently and sometimes extrapolate better. In practice nearly every production model still adds explicit positions, because they help, but it is a useful reminder that position is a property of the whole system.

RoPE: rotary position embedding

Now the method almost every model since 2022 uses (Llama, Mistral, Qwen, Gemma, DeepSeek, and most others): Rotary Position Embedding, from Su et al. (2021). It sits between the absolute and relative worlds and gets the good properties of both. The idea in one sentence: rotate each query and key vector by an angle proportional to its position, so that the dot product between them depends only on the difference of their positions.

Symbols
$q_m$ = query at position $m$
$k_n$ = key at position $n$
$\theta_i$ = rotation rate of pair $i$
$R(\alpha)$ = 2-d rotation by $\alpha$
STEP 1
Pair up
Split the $d$ coordinates of $q$ and $k$ into $d/2$ pairs. Treat each pair as a 2-d vector (a point in a plane).
STEP 2
Rotate by position
Rotate pair $i$ of $q_m$ by angle $m\,\theta_i$ and pair $i$ of $k_n$ by $n\,\theta_i$. Different pairs use different rates.
STEP 3
Dot product
Compute the attention score as usual. Because rotations compose, the score depends on $(m-n)\theta_i$ only: relative position falls out for free.
STEP 4
Nothing else changes
Values are not rotated. No table, no bias term, no extra parameters. Works for any position you can name.

The two-dimensional derivation

Start with a single pair, so $q$ and $k$ are 2-d vectors. A rotation by angle $\alpha$ is the matrix

$$R(\alpha) = \begin{bmatrix} \cos\alpha & -\sin\alpha \\ \sin\alpha & \cos\alpha \end{bmatrix}$$

RoPE defines the position-aware query and key as $\tilde q_m = R(m\theta)\, q$ and $\tilde k_n = R(n\theta)\, k$. Now take their dot product. A dot product $a \cdot b$ can be written $a^\top b$, and the transpose of a rotation is the rotation the other way ($R(\alpha)^\top = R(-\alpha)$), so:

$$\tilde q_m \cdot \tilde k_n = (R(m\theta)\, q)^\top (R(n\theta)\, k) = q^\top R(-m\theta) R(n\theta)\, k = q^\top R\big((n-m)\theta\big)\, k$$

Look at what remains: the original $q$, the original $k$, and a single rotation by $(n - m)\theta$. The absolute positions $m$ and $n$ have vanished; only their difference survives. Shift both tokens ten positions to the right and the score is unchanged. That is the whole trick, and the derivation is three lines.

There is also a nice geometric reading. Rotating both vectors by the same angle does not change the angle between them, so the dot product (which is $\|q\|\|k\|\cos$ of the angle between them) only changes by how much more one was rotated than the other. If both are at the same position, they are rotated identically and the score is the plain $q \cdot k$.

Worked example

Let $\theta = 30°$ and take the simplest vectors, $q = k = [1, 0]$, so the un-rotated score is $1$.

Put $q$ at $m = 2$: rotate by $60°$ → $\tilde q_2 = [\cos 60°, \sin 60°] = [0.5, 0.866]$.
Put $k$ at $n = 1$: rotate by $30°$ → $\tilde k_1 = [0.866, 0.5]$.
Score: $0.5 \times 0.866 + 0.866 \times 0.5 = 0.433 + 0.433 = 0.866 = \cos 30°$. That is $\cos((m-n)\theta)$ with $m - n = 1$.

Now shift both by 3: $m = 5$ → rotate $150°$ → $[-0.866, 0.5]$; $n = 4$ → rotate $120°$ → $[-0.5, 0.866]$. Score: $0.433 + 0.433 = 0.866$. Same number, because the gap is still 1. Try $m = 3, n = 1$: rotations $90°$ and $30°$, vectors $[0, 1]$ and $[0.866, 0.5]$, score $0.5 = \cos 60°$, the gap is now 2.

q, k q̃₂ (60°) k̃₁ (30°) m = 2, n = 1: angle between = 30° q̃₅ (150°) k̃₄ (120°) m = 5, n = 4: angle between = 30° score = |q||k| cos(φ + (m−n)θ) φ = angle between raw q and k m, n appear only as m − n same gap → same score
Figure 3. RoPE in one pair of dimensions. Both panels have the same gap between positions, so the angle between the rotated vectors, and therefore the dot product, is the same. Absolute position sets where the vectors point; only relative position sets how they relate.

The demo below lets you move $q$ and $k$ to any positions and watch the dot product. The proof of the property is in the note: shifting both positions by the same amount leaves the score unchanged.

InteractiveRoPE rotation demodrag m and n

Faded arrows are the raw q and k; solid arrows are after rotating by mθ and nθ. Watch the dot product: it changes with m − n and nothing else.

Frequency bands: many clocks at once

A single pair with a single $\theta$ has a problem: rotations wrap around. With $\theta = 30°$, positions 0 and 12 look identical (both at $0°$). So RoPE uses all $d/2$ pairs, each with its own rate, exactly like the sinusoidal frequencies:

$$\theta_i = \text{base}^{-2i/d}, \qquad i = 0, 1, \dots, \tfrac{d}{2} - 1, \qquad \text{base} = 10000$$

Here $d$ is the per-head dimension (typically 64 or 128), not $d_{model}$, because RoPE is applied inside each attention head. Pair 0 rotates one radian per position; the last pair rotates $10000^{-(d-2)/d}$ radians per position, which for $d = 128$ is about $1.2 \times 10^{-4}$: one full turn every fifty thousand positions or so. The high-frequency pairs resolve "the token right before me"; the low-frequency pairs resolve "roughly which part of the document". Any position within a huge range gets a unique combination of angles.

one head, d = 128 → 64 pairs, 64 rotation rates pair 0 pair 1 pair 31 pair 63 θ = 1 rad/posturn every 6 tokens θ = 0.87every 7 tokens θ ≈ 0.011every ~570 tokens θ ≈ 1.2e-4every ~54,000 tokens ← fine detail … coarse position → dims (2i, 2i+1) form pair i; a larger base (Llama 3: 500,000) slows the slow pairs further for long contexts
Figure 4. RoPE's frequency bands for a 128-wide head. Fast pairs wrap around within a handful of tokens and encode local order; slow pairs take tens of thousands of tokens per turn and encode coarse position. Together they cover any practical context length.

The clock picture makes this concrete. Each pair is a clock hand; advancing one position turns every hand by its own rate. Step through positions below and watch the fast hands spin while the slow ones creep.

InteractiveClock hands: frequency bands as position advancesstep through positions

Six pairs of a 12-dimensional head. The dots are where each hand pointed at earlier positions. Notice that no two positions share the same pattern across all six clocks.

How modern models apply RoPE inside attention

RoPE is not added to the token embeddings at the bottom of the network. It is applied inside every attention layer, to the query and key vectors after their linear projections and before the dot product, separately per head. Values are left alone, because values carry content that should not depend on where it came from. The rotation angles are precomputed once for every position and cached as cosine and sine tables.

def rope_cache(seq_len, d_head, base=10000.0):
    i = torch.arange(0, d_head, 2).float()            # 0, 2, 4, ... (one per pair)
    theta = base ** (-i / d_head)                     # (d_head/2,)  rotation rate per pair
    pos = torch.arange(seq_len).float()
    ang = pos[:, None] * theta[None, :]               # (seq, d_head/2)  angle = position x rate
    return ang.cos(), ang.sin()

def apply_rope(x, cos, sin):                          # x: (batch, heads, seq, d_head)
    x1, x2 = x[..., 0::2], x[..., 1::2]               # coordinates (2i) and (2i+1) of each pair
    y1 = x1 * cos - x2 * sin                          # 2-d rotation, applied to every pair at once
    y2 = x1 * sin + x2 * cos
    return torch.stack((y1, y2), dim=-1).flatten(-2)  # interleave back to (…, d_head)

# inside attention, per head:
#   q = apply_rope(q, cos, sin); k = apply_rope(k, cos, sin)
#   scores = q @ k.transpose(-2, -1) / sqrt(d_head)   # values v are NOT rotated

Two practical notes. First, the Llama reference code pairs coordinate $j$ with coordinate $j + d/2$ ("rotate half") instead of adjacent coordinates; the two conventions are equivalent up to a fixed permutation of dimensions, but weights trained with one cannot be loaded with the other without permuting, which has bitten many people converting checkpoints. Second, the base matters: Llama 1 and 2 used 10,000; Llama 3 raised it to 500,000 (Dubey et al., 2024), which slows every clock so that positions well beyond 8,000 remain distinguishable and the slow pairs do not wrap within the training context.

Common confusion

"RoPE makes attention purely relative." The score depends only on $m - n$, yes. But the model is still a causal language model, the first token is still a fixed anchor that heads learn to use, and RoPE-trained models still degrade beyond their training length, just more gracefully than absolute encodings. RoPE also does not decay with distance by itself (the paper notes a mild long-term decay of the expected score, not a hard penalty like ALiBi).

Extending the context after training

A model trained on 4,096 tokens meets position 20,000 at deployment. The fast pairs are fine (they wrapped around thousands of times during training already), but the slow pairs reach angles they never reached in training, and attention behaves erratically. Since about 2023 a family of tricks lets a RoPE model be extended, often with a little fine-tuning, by changing how positions are mapped to angles. The picture: rather than letting positions run past the trained range, squeeze or reshape them so that the slow clocks stay within familiar angles.

extrapolation vs interpolation of positions trained range positions 0 … 4096 extrapolate 4097 … 8192: never seen interpolate (PI) 0 … 8192 squeezed into 0 … 4096 (angles halved) NTK-aware and YaRN squeeze only the slow pairs (which need it) and leave the fast pairs (which do not) mostly alone.
Figure 5. Extrapolating pushes the slow rotation pairs into angles the model never trained on. Position interpolation rescales positions so the same angle range covers a longer context; later methods do this per frequency band.

Position interpolation

Chen et al. (2023) observed that neural networks interpolate far better than they extrapolate, and proposed simply scaling every position by $L_{train}/L_{new}$ before computing the angles: to run at 8,192 with a 4,096-trained model, use position $m/2$ in place of $m$. Every clock now turns at half speed, so at token 8,192 the angles are exactly what they were at token 4,096 during training. A short fine-tune (about a thousand steps) recovers most of the quality. The cost is resolution: neighbouring tokens are now only half a step apart on every clock, which blurs fine-grained local order.

NTK-aware scaling

A community proposal by bloc97 (2023, published as a Reddit post rather than a paper) fixes that blurring by noting that the fast pairs never needed rescaling; only the slow pairs reach untrained angles. Instead of dividing positions, it raises the base (the 10,000), which slows the low-frequency pairs a lot and the high-frequency pairs hardly at all. This spreads the "interpolation" across the frequency spectrum and works reasonably well even with no fine-tuning; it is essentially what Llama 3's larger base does from the start.

YaRN

Peng et al. (2023), "Yet another RoPE extensioN", combine the two ideas explicitly: pairs whose wavelength is much shorter than the training length are left untouched, pairs whose wavelength is longer than the training length are interpolated, and pairs in between get a smooth blend. They add a small temperature adjustment to the attention logits to compensate for the change in score distributions at long range. YaRN needs far fewer fine-tuning tokens than plain interpolation and is the basis of many long-context models; the same paper reports extending to 128k tokens.

Choosing among them in practice

Training from scratch: use RoPE with a large base if you want long context. Extending an existing model a little (2×): NTK-aware or a larger base, optionally with light fine-tuning. Extending a lot (8× or more): YaRN plus fine-tuning on long documents. And always evaluate on tasks that actually need the long context (retrieval of a fact from far back, not just perplexity), because perplexity can look fine while long-range recall is broken.

Summary table

MethodWhere it entersParametersRelative?Beyond training lengthUsed by
Learned absoluteadded to embeddings$L_{max} \times d$noimpossible (no rows)GPT-2, BERT
Sinusoidaladded to embeddings0linear-shift propertydefined but degradesoriginal transformer
Shaw et al.added to keys per offsetwindow $\times d$yes (clipped)clipsTransformer-XL family
T5 bucketsscalar bias on scoresbuckets × headsyes (bucketed)coarse bucketsT5
ALiBifixed linear penalty on scores0yesgoodBLOOM, MPT
RoPErotates q and k per head0yes (in the score)degrades; extendable with PI / NTK / YaRNLlama, Mistral, Qwen, most since 2022

Practice

The companion file code/lumen/positional.py implements sinusoidal(seq_len, d), LearnedPositions, rope_cache and apply_rope. The attention module in code/lumen/attention.py accepts an optional RoPE cache.

Exercise 1 — verify the linear-shift property

Generate the sinusoidal table for $d = 16$ and 64 positions. For $k = 3$, build the block-diagonal matrix $M_3$ of eight $2 \times 2$ rotations from the formula above and check that $M_3 \cdot PE(pos) = PE(pos + 3)$ for every $pos$ up to 60, to within floating-point tolerance. Then try to find such a matrix for a learned position table from a randomly initialised LearnedPositions (least squares) and measure the residual. What does the comparison tell you?

Solution sketch

For the sinusoidal table the residual is at machine precision for every offset, since the property is exact. For a random learned table the best linear map leaves a large residual: there is no shared "move 3 forward" operation, and a model with learned positions must learn it separately for every position it sees. After training, learned tables do develop some of this structure, which is a nice thing to check with the GPT-2 weights loaded by code/lumen/gpt2.py.

Exercise 2 — RoPE depends only on the gap

Implement apply_rope as in the excerpt. Draw random $q$ and $k$ of dimension 64, compute the rotated dot product for $(m, n) = (10, 4)$, $(110, 104)$ and $(1010, 1004)$, and confirm all three agree. Then compute it for $(10, 3)$ and confirm it differs. Finally, average the score over 1,000 random $q, k$ pairs as a function of the gap from 0 to 500 and plot it. Does it decay?

Solution sketch

The three equal-gap scores agree to floating-point precision; the different-gap score does not. The average of the score over random vectors is close to zero for all gaps (random vectors are uncorrelated), but the average of its absolute value, or of $q \cdot k$ for correlated $q$ and $k$, shows the mild long-term decay described in Su et al. section 3.4.3: far-apart tokens get slightly smaller scores on average, though nothing like ALiBi's linear penalty.

Exercise 3 — swap encodings in a tiny model and test length generalisation

Using the small GPT from code/lumen/gpt2.py and the corpus in code/lumen/data.py, train three 2-layer models with context 128: learned positions, sinusoidal positions, and RoPE (no positional table). Evaluate each on held-out sequences of length 128, 256 and 512. Then apply position interpolation (scale positions by 128/512) to the RoPE model at length 512 and evaluate again, with and without 200 steps of fine-tuning on longer sequences.

Solution sketch

At length 128 all three should be close. At 256 and 512 the learned model cannot run at all without a bigger table; sinusoidal degrades sharply; RoPE degrades more gently. Interpolation alone typically helps at 512 but blurs local structure; a short fine-tune recovers most of the loss. Numbers vary with seeds, so run at least two seeds before drawing conclusions.

Check yourself
Without positional information, what does attention compute for "dog bites man" versus "man bites dog"?
Attention scores, softmax and weighted sums are all order-independent. The output for "bites" is the same in both sentences.
The key property of sinusoidal encodings that Vaswani et al. wanted is:
Angle-addition formulas make each frequency pair rotate by a fixed angle per offset, so "k steps forward" is one linear map for every position.
In RoPE, after rotating q by mθ and k by nθ, the dot product depends on:
R(mθ)ᵀ R(nθ) = R((n − m)θ), so absolute positions cancel and only the gap remains.
Why does RoPE use many frequency pairs rather than one?
Fast pairs give local resolution but repeat quickly; slow pairs take tens of thousands of positions per turn. Their combination is unique over any practical context.
Position interpolation extends context by:
Chen et al. (2023) divide every position by the extension factor, so the model interpolates within angles it has seen rather than extrapolating past them.

Key takeaways

  • Attention is permutation-equivariant: it sees a bag of vectors. Order must be written into the vectors themselves.
  • Learned absolute tables (GPT-2) are simple but capped at a fixed length and treat each position as unrelated to its neighbours.
  • Sinusoidal encodings use geometrically spaced frequencies, like a smooth binary counter; shifting by $k$ positions is a fixed rotation, which is why they were chosen.
  • Absolute encodings extrapolate poorly because the network only learned a bounded range, whatever the formula can produce.
  • Relative methods (Shaw, T5 buckets, ALiBi) put distance into the attention score directly; ALiBi's fixed penalty extrapolates well.
  • RoPE rotates queries and keys by position-proportional angles inside every head; the dot product then depends only on $m - n$. Many frequency pairs make positions unique. Context can be stretched afterwards with position interpolation, NTK-aware base scaling, or YaRN.

Further reading