primer.ml.positional

Positional information: how a transformer knows word order

Run: python -m primer.ml.positional

New to the notation (vectors, dot products, sine and cosine)? Every symbol is decoded where it appears, and primer.notation teaches them all from zero. This lesson builds on primer.ml.attention.

Level 1: The practitioner's guide

In one sentence. Positional encoding is how a transformer learns where each token sits, since attention on its own treats the input as an unordered bag; the scheme a model uses decides how long a context it can read, whether that length can be stretched after training, and what happens when you go past it.

When you need it. You never add positions yourself; the model's authors chose a scheme before training and it is baked into the weights. You meet it the day a length matters: a model card says 128K tokens and you wonder how far to trust it, a self-hosted model produces nonsense past a certain prompt length, an embedding model rejects documents longer than its limit, or you want to fine-tune a model to read documents longer than it was trained on. The tell: quality that is fine at 4,000 tokens and falls off a cliff at some longer length, with nothing in the logs. The number behind the whole topic, from this lesson's order_similarity: with no positions, the pooled attention outputs for "dog bites man" and "man bites dog" have cosine similarity exactly 1.000. The model cannot tell them apart. Nothing about this concerns you when prompts stay well inside the length the model was trained at.

Your options. The first four are choices the model's authors made; you choose among them by choosing a model. The last three are what you or the vendor can do about length afterwards. From the simplest to the most committed:

Option What it does What it gives you What it costs Where it lives
A learned position table (GPT-2, BERT) One learned vector per seat, added to the token's vector Simple and effective inside the trained length A hard ceiling: no row exists past the table's end (1,024 rows for GPT-2, 512 for BERT) The architecture; max_position_embeddings on the card
Sinusoidal codes (the 2017 transformer) A fixed sine and cosine fingerprint per position, added to the token No parameters; a code exists for any position Position is mixed into content; little used in new models The architecture
RoPE (Llama, Mistral, Qwen) Rotates each query and key by an angle that grows with position, so scores depend on the distance between tokens Relative distance for free, and a context that can be stretched after training Angles past the trained range are unfamiliar; extension needs scaling The architecture; rope_theta and rope_parameters on the card
ALiBi Subtracts a penalty proportional to distance from each score; no position vectors at all Trained at 1,024 tokens, it extrapolates to 2,048; 11% faster and 11% less memory than sinusoidal in its paper A built-in preference for nearby tokens; fewer models use it The architecture
Inference-time scaling (linear or dynamic NTK) Rescales positions or the RoPE base: linear scaling at every length, dynamic scaling only once a prompt exceeds the trained length A modest stretch with no training at all Quality drops as the stretch grows The serving engine's config: rope_type and factor
Extension with a short fine-tune (PI, YaRN) Scales positions into the trained range, then fine-tunes briefly on long text 8× longer context: Llama to 32,768 tokens within 1,000 steps (PI); YaRN needs 10× fewer tokens than earlier methods Long documents to train on, a training run, an evaluation at length Your training stack
Staged long-context pretraining The vendor grows the window during pretraining, checking a needle-in-a-haystack test at each stage The genuine article: Llama 3 went from 8K to 128K in six stages About 800B training tokens for Llama 3 405B; reaches you as a number on the card The vendor

How to choose. Start from the model in front of you.

  • A hosted API: nothing to choose, but the window on the card is the length the vendor trained and tested, not a promise about your task. Measure at the lengths you will use.
  • Picking an open model: read max_position_embeddings and rope_parameters. An entry with a factor means the model was trained shorter and stretched, so test it at the lengths you will use rather than trusting the stretched number.
  • Serving a model beyond its trained length: do not raise the engine's context limit on its own. Set the scaling the checkpoint expects (or a dynamic scaling if none is given), and test before shipping.
  • Documents longer than the model's window, and you can train: position interpolation or YaRN plus a fine-tune on long text, with a needle-in-a-haystack check and your own task at the target length.
  • An encoder or embedding model with a learned table: the limit is hard. Chunk documents to fit (primer.agents.rag).
  • Whatever you pick, evaluate at the length you will run, with the answer placed at the start, the middle and the end of the prompt.

What it costs. Positions are nearly free to compute; length is what costs.

  • Parameters and compute. GPT-2's table is 1,024 × 768 = 786,432 numbers, under 1% of its 124 million parameters. RoPE and ALiBi add none, and RoPE is a few element-wise multiplications per query and key, negligible next to attention itself.
  • Stretching. Position interpolation for 4,096 to 32,768 tokens multiplies every position by 1/8 (32,000 becomes 4,000); NTK-aware scaling instead raises the RoPE base from 10,000 to 82,685 for a 128-wide head, leaving the fastest pair untouched and slowing the slowest 8×. Both are followed by a short fine-tune: within 1,000 steps in the PI paper. Llama 3's authors set the base to 500,000 and spent about 800B tokens taking the 405B model from 8K to 128K.
  • Quality. RoPE gives nearby tokens a head start (the RoFormer companion's long-term decay), and a long window is not used evenly: Liu et al. (Lost in the Middle, 2023) found models use information best at the start or the end of a long prompt and worst in the middle (primer.agents.context).

What breaks.

  • Past the table. A learned-table model has no vector for position 1,024 if its table has 1,024 rows: the request fails or the library truncates. Chunk the input.
  • Past the trained angles. RoPE degrades silently rather than failing: this lesson's figure shows the slowest pair's angle leaving the trained band right after 4K tokens and reaching 8× its top by 32K. The PI paper reports that plain extrapolation can produce catastrophically large attention scores. Scale, then fine-tune.
  • A checkpoint served with the wrong scaling. An extended model whose rope_parameters are dropped or mistyped reads positions it never learned. Copy the config with the weights.
  • Interpolating too far. Interpolation slows every frequency equally, which also blurs the fast ones that carry local word order. NTK-aware scaling and YaRN keep the fast frequencies, which is why they are preferred for large stretches.
  • Pooling that forgets order. Average the token vectors of an order-blind model and "dog bites man" equals "man bites dog" exactly. Order survives only if positions are injected, or an encoding step that is not permutation-blind comes first.
  • Trusting the causal mask for order. In a decoder the mask alone lets the sentences differ (cosine 0.524 in this lesson's run), but it is a weak signal, not a position encoding.

In the wild. The original transformer (Vaswani et al., 2017) used sinusoidal codes; GPT-2 and BERT switched to learned tables, which is why each has a fixed maximum length. Llama, Mistral and Qwen use RoPE, and Llama 3 raised its base to 500,000 with 8 key-value heads beside it. Hugging Face model configs carry the scheme as rope_parameters with a rope_type of linear, dynamic, yarn, longrope or llama3, a factor and an original_max_position_embeddings; vLLM's --max-model-len sets the served length and --hf-overrides changes that config at load time. YaRN (Peng et al., 2023) is the extension recipe behind many long-context checkpoints, ALiBi (Press et al., 2021) the alternative that extrapolates without vectors, and the NoPE study (Kazemnejad et al., 2023) found that a decoder can generalize to longer inputs with no explicit positions at all. The papers are linked at the end of the lesson.

Go deeper. Level 2 shows the bag-of-words problem in three numbers, builds sinusoidal codes as a clock with many hands, checks RoPE's distance property by hand at positions 3 and 7 and again at 103 and 107, and stretches a 4K model to 32K by interpolation and by raising the base. If you only needed to read a model card or serve a model safely, you are done.

Level 2: How it works, from scratch.

Level 2: How it works, from scratch

Level 2 starts with the problem itself, a bag of Scrabble tiles, and adds each family of positions in turn.

The problem: attention reads a bag, not a sentence

Everyday picture. Pour the words of a sentence into a bag of Scrabble tiles. The bag for "dog bites man" and the bag for "man bites dog" hold exactly the same tiles. Anyone who only gets the bag cannot tell a news story from a routine dog attack. Attention, on its own, only ever gets the bag.

Tiny worked example. Give each word a 2-number embedding: dog = (1, 0), bites = (0, 1), man = (1, 1). Average the words of each sentence, which is the simplest possible "sentence vector":

sentence sum of vectors average
dog bites man (1,0)+(0,1)+(1,1) = (2, 2) (0.67, 0.67)
man bites dog (1,1)+(0,1)+(1,0) = (2, 2) (0.67, 0.67)

Addition doesn't care about order, and neither does attention: every token compares itself with every other by dot product, and nothing in that computation says who came first.

flowchart LR A["'dog bites man'"] --> E1[Embed each token] B["'man bites dog'"] --> E2[Embed each token] E1 --> S1["Set {dog, bites, man}"] E2 --> S2["Set {man, bites, dog}"] S1 --> ATT[Attention with no positions] S2 --> ATT ATT --> SAME[Same vectors, only reordered:<br/>the meaning difference is lost]

Reading it: follow both sentences left to right. Embedding looks up each token independently, so both sentences produce the same three vectors. Attention only compares vectors with each other; it has no notion of slot 1 versus slot 3. It sees the same set and produces the same output vectors, listed in a different order. Pool them into one sentence vector and the two sentences are identical.

The math. A permutation is a reshuffle of an ordered list. Written as a matrix (a grid of numbers), a permutation matrix is all zeros except one 1 in each row, and multiplying by it just reorders rows. Attention without positions is permutation-equivariant:

Level 3: the formula and its symbols

$$ \text{Attn}(PX) = P\,\text{Attn}(X) $$

Symbols

Symbol Meaning here In the example
$X$ the sentence's token vectors, one row per word rows dog (1,0), bites (0,1), man (1,1): a 3 × 2 grid
$P$ a permutation matrix: reorders the rows of whatever it multiplies swap rows 1 and 3
$PX$ the same rows in the new order man (1,1), bites (0,1), dog (1,0)
$\text{Attn}(\cdot)$ one attention layer with no position information (here with $W_q = W_k = W_v$ = identity, so q = k = v = the word vector) 3 × 2 in, 3 × 2 out
$=$ both sides are exactly the same grid of numbers

In words: "running attention on the shuffled sentence gives the same answer as running it on the original sentence and shuffling afterwards."

With the numbers: in "dog bites man", dog's query (1,0) scores the three keys (1,0), (0,1), (1,1) as 1, 0, 1; divided by √2 that is 0.707, 0, 0.707. Softmax gives weights 0.401, 0.198, 0.401, so dog's output is 0.401·(1,0) + 0.198·(0,1) + 0.401·(1,1) = (0.802, 0.599). In "man bites dog" dog sits in row 3, but it scores the same three keys in a different order, gets the same weights, and outputs the same (0.802, 0.599).

Level 3: in Python

In Python:

import math
# q = k = v = the word vector, no positions
def Attn(X):
    out = []
    for q in X:
        scores = [sum(a * b for a, b in zip(q, k)) / math.sqrt(2) for k in X]
        exps = [math.exp(s) for s in scores]
        weights = [e / sum(exps) for e in exps]
        out.append(tuple(round(sum(w * v[c] for w, v in zip(weights, X)), 3) for c in range(2)))
    return out
# dog, bites, man
X = [(1, 0), (0, 1), (1, 1)]
# P swaps rows 1 and 3: man, bites, dog
PX = [X[2], X[1], X[0]]
Attn(X)  # → [(0.802, 0.599), (0.599, 0.802), (0.752, 0.752)]
# the same rows, shuffled the same way
Attn(PX)  # → [(0.752, 0.752), (0.599, 0.802), (0.802, 0.599)]

Shuffle the input and you get the same outputs, shuffled the same way. encode_sentence(..., scheme="none") runs a real attention layer and shows the "dog" row is the same vector whether "dog" comes first or last.

Without positions the two sentences are identical (similarity 1.0); sinusoidal codes and RoPE pull it just below 1, and a causal mask alone drops it to 0.52

Reading it: each bar compares the pooled attention output of "dog bites man" with that of "man bites dog", as a cosine similarity (1.0 means identical). With no positions the bar reaches exactly 1: the model literally cannot tell the sentences apart. Adding sinusoidal codes or RoPE pulls the bar below 1: the sentences now look different. The causal-mask bar is below 1 too, because a mask that lets token i see only i+1 tokens already leaks some order.

In code: word_vector gives each word a fixed random embedding, and order_similarity pools each sentence's outputs and compares them, which gives the bars in the figure.

Why it matters. Almost all meaning in language is carried by order: negation scope, who did what to whom, code. So position must be injected. There are three families, below.

1. Sinusoidal codes (the original 2017 transformer)

Everyday picture. A clock with many hands. The second hand moves fast and tells nearby moments apart; the hour hand moves slowly and tells morning from evening. Read all the hands together and every moment has a unique fingerprint. Sinusoidal codes give every position such a set of "hands" and add them to the token's embedding.

Tiny worked example. With 4 dimensions there are two hands. The fast one turns 1 radian per position; the slow one turns 1/100 of a radian:

position sin(pos·1) cos(pos·1) sin(pos/100) cos(pos/100)
0 0.000 1.000 0.000 1.000
1 0.841 0.540 0.010 1.000
2 0.909 −0.416 0.020 1.000

The fast pair already looks very different at positions 1 and 2; the slow pair barely moves, and it will only distinguish positions hundreds apart.

flowchart LR T["token id"] --> E["embedding lookup<br/>(what the word means)"] P["position 0,1,2..."] --> S["sin/cos at many frequencies<br/>(where the word is)"] E --> ADD(("+")) S --> ADD ADD --> B["transformer blocks"]

Reading it: two independent lookups meet at the plus sign. The top path says what the token is, the bottom path says where it is, and their sum is a single vector carrying both. Everything downstream sees only that sum, so position becomes part of the content attention compares.

The math and the code. Two functions do the work. Picture a point walking around a circle of radius 1. The angle it has turned is measured in radians (a full turn is 2π ≈ 6.28 radians). Cosine of the angle is how far right the point is, and sine is how far up; both always lie between −1 and +1, and both repeat every full turn.

Level 3: the formula and its symbols

$$ PE_{(pos,\,2i)} = \sin(pos\cdot\omega_i), \qquad PE_{(pos,\,2i+1)} = \cos(pos\cdot\omega_i), \qquad \omega_i = \frac{1}{10000^{2i/d}} $$

Symbols

Symbol Meaning here In the example
$pos$ the token's position, counting from 0 1
$d$ how many numbers each position code has 4
$i$ which (sin, cos) pair, counting from 0 up to $d/2 - 1$ 0 and 1
$2i$, $2i+1$ the two columns that pair $i$ fills: even column gets sine, odd gets cosine pair 1 fills columns 2 and 3
$\omega_i$ pair $i$'s speed in radians per position (its frequency) $\omega_0 = 1$, $\omega_1 = 1/100$
$10000^{2i/d}$ 10,000 raised to a fraction; makes each pair slower than the last $10000^{2/4} = 100$
$\sin$, $\cos$ how far up / right a point is after turning by that many radians $\sin 1 = 0.841$
$PE_{(pos,\,c)}$ the number in row $pos$, column $c$ of the code table

In words: "for position pos, each pair of columns stores where a hand turning at that pair's speed points after pos steps: sine in the first column, cosine in the second."

With the numbers: d = 4, pos = 1. Pair 0 turns 1 radian: sin 1 = 0.841, cos 1 = 0.540. Pair 1 turns 1/100 radian: sin 0.01 = 0.010, cos 0.01 = 1.000. So position 1's code is (0.841, 0.540, 0.010, 1.000), the second row of the table above.

Level 3: in Python

In Python:

import math
d, pos = 4, 1
PE = []
# one (sin, cos) pair per i
for i in range(d // 2):
    # ω_i: pair i's speed
    omega_i = 1 / 10000 ** (2 * i / d)
    # columns 2i and 2i+1
    PE += [math.sin(pos * omega_i), math.cos(pos * omega_i)]
[f"{v:.3f}" for v in PE]  # → ['0.841', '0.540', '0.010', '1.000']

sinusoidal_encoding(n, d) builds the whole (n × d) table in four lines. A shift of k positions rotates every (sin, cos) pair by the same angle k·ω_i wherever you start, so the dot product of two position codes depends only on their distance.

Left columns flip colour every few positions and right columns change slowly, so every row is a unique fingerprint and neighbours look alike

Reading it: each row is a position (0 at the top), each column one dimension, and colour is the value from −1 to +1. The left columns flip colour every few rows (fast hands); the right columns change slowly over hundreds of rows (slow hands). No two rows are identical, so every position has a unique fingerprint, and neighbouring rows look alike, so nearby positions get similar codes.

Why it matters. Fixed codes need no training and exist for any position, and the distance property lets the model learn "look 3 tokens back" once and use it everywhere. But the code is mixed into the content vector itself, which later methods avoid.

2. Learned absolute positions (GPT-2, BERT)

Everyday picture. Numbered theatre seats. Every seat has its own brass plate, and the theatre has a fixed number of seats.

Tiny worked example. GPT-2 has a table of 1,024 position vectors, each 768 numbers long: 786,432 extra parameters, learned like any other weight. Position 5 looks up row 5. Position 1,024 has no row at all.

flowchart LR T["token id 318"] --> TE["token table<br/>(vocab × d)"] P["position 5"] --> PE["position table<br/>(max_len × d)"] TE --> ADD(("+")) PE --> ADD ADD --> B["transformer blocks"] P2["position ≥ max_len"] -.-> X["no row: cannot be encoded"]

Reading it: identical to the sinusoidal picture except that the bottom path is a second trainable table instead of a formula. The dotted arrow is the catch: the table ends at max_len, so a longer input simply has nowhere to look.

The math and the code.

Level 3: the formula and its symbols

$$ x_{pos} = E_{token} + P_{pos} $$

Symbols

Symbol Meaning here In the example (GPT-2)
$E_{token}$ the token's row of the token table (what the word means) 768 numbers
$P_{pos}$ row $pos$ of the position table, learned during training (where it is) 768 numbers; $pos$ from 0 to 1,023
$+$ add the two lists number by number
$x_{pos}$ the vector the first transformer block receives 768 numbers

In words: "the input vector is the word's meaning plus its seat number's learned vector."

With the numbers: with 2 dimensions, if $E_{dog}$ = (0.5, −0.2) and $P_{3}$ = (0.1, 0.3), then $x_3$ = (0.6, 0.1). $P_{1024}$ does not exist.

Level 3: in Python

In Python:

E_dog = [0.5, -0.2]
# a position table with rows 0 to 3
P = [[0.0, 0.0], [0.0, 0.0], [0.0, 0.0], [0.1, 0.3]]
# x_3 = E_dog + P_3
[round(e + p, 2) for e, p in zip(E_dog, P[3])]  # → [0.6, 0.1]
# no row 1024: it cannot be encoded
len(P) > 1024  # → False

primer.ml.transformer.TinyGPT uses exactly this.

Why it matters. It's simple and works well inside the trained length, but it cannot extrapolate. That limitation pushed the field to RoPE.

3. Rotary position embeddings, RoPE (Llama, Mistral, Qwen and most modern LLMs)

Everyday picture. Two people point at a clock face, one at 1 o'clock and one at 4 o'clock. The angle between their arms is 90°. Move both of them on to 8 and 11 o'clock and the angle is still 90°. RoPE turns each token's query and key like clock hands, by an amount proportional to its position. When a query meets a key, only the angle between them (the distance between the tokens) affects the score, not where on the clock they started.

Tiny worked example. Use 2 dimensions, so there is one pair turning 1 radian per position. Let q = k = (1, 0). Put the query at position 3 and the key at position 7:

  • q turns by 3 rad: (cos 3, sin 3) = (−0.990, 0.141)
  • k turns by 7 rad: (cos 7, sin 7) = (0.754, 0.657)
  • score = −0.990·0.754 + 0.141·0.657 = −0.654 = cos(4)

Now move both 100 positions later, to 103 and 107. The score is still cos(107 − 103) = cos 4 = −0.654. Only the distance, 4, survived.

flowchart LR X[Token vector at position m] --> Q[q = x W_q] X --> K[k = x W_k] X --> V[v = x W_v] Q --> RQ["Rotate each pair of q<br/>by m·θ_i"] K --> RK["Rotate each pair of k<br/>by m·θ_i"] RQ --> S["Score = q'·k'<br/>depends only on distance"] RK --> S S --> SM[Softmax] --> W[Weighted sum of V<br/>V is NOT rotated] V --> W

Reading it: this is ordinary attention with one extra step on the query and key branches. After projection, each (even, odd) pair of numbers in q and k is spun like a clock hand by an angle that grows with the token's position: fast for the first pairs, slow for the last. In the dot product only the difference of the angles survives, so the score knows how far apart the tokens are but not where they sit in the document. The value branch is untouched, so what gets blended is plain content, and nothing is ever added to the embedding.

The math and the code. A rotation turns a point around the centre by some angle without changing its distance from the centre. Written as a 2 × 2 grid times a column of two numbers (a matrix-vector multiply: each output number is one row of the grid multiplied position-by-position with the input and added up):

Level 3: the formula and its symbols

$$ \begin{pmatrix} x'_{2i} \ x'_{2i+1} \end{pmatrix} = \begin{pmatrix} \cos m\theta_i & -\sin m\theta_i \ \sin m\theta_i & \cos m\theta_i \end{pmatrix} \begin{pmatrix} x_{2i} \ x_{2i+1} \end{pmatrix}, \qquad \theta_i = 10000^{-2i/d} $$

Symbols

Symbol Meaning here In the example
$x_{2i}, x_{2i+1}$ pair $i$ of a query or key vector, before rotating (1, 0)
$x'_{2i}, x'_{2i+1}$ the same pair after rotating (−0.990, 0.141)
$m$ the token's position 3
$i$ which pair, from 0 to $d/2 - 1$ 0 (the only pair when d = 2)
$\theta_i$ pair $i$'s turning speed in radians per position; $10000^{-2i/d}$ means $1/10000^{2i/d}$ $\theta_0 = 1$
$m\theta_i$ the total angle turned 3 radians
the 2 × 2 grid the rotation: row 1 gives the new first number, row 2 the new second

In words: "new first number = cos(angle) × old first − sin(angle) × old second; new second number = sin(angle) × old first + cos(angle) × old second, where the angle is position times the pair's speed."

With the numbers: x = (1, 0) at m = 3, θ = 1: x′ = (cos 3 · 1 − sin 3 · 0, sin 3 · 1 + cos 3 · 0) = (−0.990, 0.141).

Level 3: in Python

In Python:

import math
def rotate(x, m, theta_i=1.0):
    # the angle m θ_i
    a = m * theta_i
    # row 1 of the grid
    return (math.cos(a) * x[0] - math.sin(a) * x[1],
            # row 2 of the grid
            math.sin(a) * x[0] + math.cos(a) * x[1])
[f"{v:.3f}" for v in rotate((1, 0), m=3)]  # → ['-0.990', '0.141']
d, i = 2, 0
# θ_0 = 10000^(-2i/d)
10000 ** (-2 * i / d)  # → 1.0

Why only the distance survives: turning q by angle a and k by angle b and then taking their dot product (multiply matching numbers, add them up; large when the vectors point the same way) gives the same answer as turning k alone by b − a. So

Level 3: the formula and its symbols

$$ \langle R_m q,\; R_n k\rangle = \langle q,\; R_{n-m}\, k\rangle $$

Symbols

Symbol Meaning here In the example
$q$, $k$ a query and a key vector, before rotating (1, 0) and (1, 0)
$m$, $n$ the query's and the key's positions 3 and 7
$R_m$ "rotate every pair by its speed times $m$" turn by 3 radians
$R_{n-m}$ the rotation for the distance only turn by 4 radians
$\langle a, b \rangle$ the dot product of $a$ and $b$ (same as $a \cdot b$)

In words: "the score between a query at m and a key at n equals the score you'd get by leaving the query alone and turning the key by the gap n − m."

With the numbers: left side (−0.990, 0.141) · (0.754, 0.657) = −0.654; right side (1, 0) · (cos 4, sin 4) = cos 4 = −0.654. Same number, and the same again at positions 103 and 107.

Level 3: in Python

In Python:

import math
# turn a pair by an angle (θ = 1, so the angle is the position)
def R(angle, x):
    return (math.cos(angle) * x[0] - math.sin(angle) * x[1],
            math.sin(angle) * x[0] + math.cos(angle) * x[1])
def dot(a, b): return sum(a_i * b_i for a_i, b_i in zip(a, b))
q = k = (1, 0)
# ⟨R_m q, R_n k⟩ with m = 3, n = 7
round(dot(R(3, q), R(7, k)), 3)  # → -0.654
# ⟨q, R_(n-m) k⟩: only the distance
round(dot(q, R(7 - 3, k)), 3)  # → -0.654
# 100 positions later, the same score
round(dot(R(103, q), R(107, k)), 3)  # → -0.654

apply_rope does this for a vector or a whole sequence with three element-wise lines, and no matrix multiply.

The score curves for starting positions 0, 100 and 1,000 lie exactly on top of each other: only the distance matters

Reading it: the x-axis is the distance n − m between a query and a key; each line uses the same q and k but places the pair at a different absolute starting position (0, 100, 1,000). The lines lie exactly on top of each other: the score depends only on distance. That is the relative-position property in one picture.

Pair 0 wraps a full turn about every 6 tokens while the last pairs barely turn across the whole range

Reading it: each line is one pair of dimensions; the y-axis is how far that pair has turned (wrapped to one full turn, 2π) at each position on the x-axis. Pair 0 spins fastest and wraps every ~6 tokens, giving fine-grained local order. The last pairs barely turn across the whole range, giving coarse long-range position. It's the many-handed clock again, now applied inside attention.

In code: rope_frequencies returns each pair's turning speed θ_i, which apply_rope multiplies by the position to get every angle.

Why it matters. Relative distance is what language actually uses ("the word two back"), rotation keeps vector lengths unchanged, and because position lives in angles, you can stretch it after training. That last point is the next section.

4. Extending context after training

Everyday picture. A ruler marked from 0 to 4,000. To measure something 32,000 long you can shrink the object by 8× so it fits on the ruler (interpolation), or re-draw only the long-distance marks while keeping the fine millimetre marks (NTK-aware scaling).

Tiny worked example. A model trained on 4,096 tokens now reads 32,768. Interpolation multiplies every position by 4096/32768 = 1/8, so position 32,000 is treated as 4,000: an angle the model has seen. Neighbours are now 1/8 of a position apart, which a short fine-tune teaches it to resolve.

flowchart LR P["positions 0 ... 32k"] --> C{method} C -->|interpolation| PI["pos × 4k/32k<br/>all hands slowed 8×"] C -->|NTK-aware| NTK["raise the RoPE base<br/>slow hands slowed, fast hands kept"] PI --> R["RoPE with angles inside<br/>the trained range"] NTK --> R R --> F["short fine-tune on long text"]

Reading it: both paths solve the same problem: angles beyond what the model saw in training. Interpolation slows every hand equally, which also blurs the fast hands that encode local word order. NTK-aware scaling slows only the slow hands and leaves the fastest one untouched, so local order stays sharp. Both usually end with a brief fine-tune.

The math and the code. Position interpolation (interpolate_positions):

Level 3: the formula and its symbols

$$ pos' = pos \cdot \frac{L_{train}}{L_{new}} $$

Symbols

Symbol Meaning here In the example
$pos$ the real position in the long input 32,000
$L_{train}$ the longest context seen in training 4,096
$L_{new}$ the context we now want 32,768
$pos'$ the position RoPE is actually given 4,000

In words: "shrink every position by the ratio of old length to new length."

With the numbers: 32,000 × 4,096 / 32,768 = 32,000 / 8 = 4,000.

Level 3: in Python

In Python:

pos, L_train, L_new = 32_000, 4_096, 32_768
# pos' = pos · L_train / L_new
pos * L_train / L_new  # → 4000.0

NTK-aware scaling (ntk_scaled_base) changes the base instead:

Level 3: the formula and its symbols

$$ base' = base \cdot s^{\,d/(d-2)} $$

Symbols

Symbol Meaning here In the example
$base$ the 10,000 inside $\theta_i = base^{-2i/d}$ 10,000
$s$ how many times longer the new context is 8
$d$ the head width 128
$s^{d/(d-2)}$ $s$ raised to a power just above 1 $8^{1.016} ≈ 8.27$
$base'$ the new, larger base ≈ 82,685

In words: "multiply the base by slightly more than the stretch factor."

With the numbers: the fastest pair keeps $\theta_0 = base'^{0} = 1$ radian per position. The slowest pair goes from $10000^{-126/128} = 1.15 \times 10^{-4}$ to $82685^{-126/128} = 1.44 \times 10^{-5}$: exactly 8× slower.

Level 3: in Python

In Python:

base, s, d = 10_000, 8, 128
# s^(d/(d-2)): just above 8
round(s ** (d / (d - 2)), 2)  # → 8.27
# base' = base · s^(d/(d-2))
base_new = base * s ** (d / (d - 2))
round(base_new)  # → 82685
# θ_i for the last pair, 2i = 126
slowest = lambda b: b ** (-126 / 128)
f"{slowest(base):.2e}", f"{slowest(base_new):.2e}"  # → ('1.15e-04', '1.44e-05')
# exactly 8× slower
round(slowest(base) / slowest(base_new), 6)  # → 8.0

YaRN refines this per frequency band and is used by many long-context models. ALiBi skips position vectors entirely and subtracts a penalty proportional to distance from each attention score.

Raw positions leave the trained angle band right after 4k and reach 8 times its top by 32k; interpolation and NTK scaling stay inside it

Reading it: the x-axis is position up to 32k and the y-axis is the angle of the slowest RoPE pair. The shaded band is the range of angles the model saw in training (positions up to 4k). The raw extended line leaves the band almost immediately after 4k: unfamiliar territory. The interpolated and NTK-scaled lines stay inside the band all the way to 32k.

Why it matters. It's how models trained on a few thousand tokens are cheaply stretched to 100k or more, instead of being pretrained again from scratch.

A subtlety: the causal mask leaks order

In a decoder, token i sees exactly i+1 tokens, so even with no position encoding ("NoPE") the model can partly infer where it is. Bidirectional attention (BERT-style) has no such crutch and is fully order-blind.

In code: encode_sentence with its causal flag set runs the same layer under primer.ml.attention.causal_mask; its pooled similarity is the "causal mask only" bar in the first figure.

In 20 seconds

  • Attention is permutation-equivariant: shuffle the words and the outputs shuffle the same way, so without position information "dog bites man" pools to exactly the same vector as "man bites dog".
  • The original transformer adds fixed sine/cosine codes; GPT-2 and BERT learn a position table that ends at max_len.
  • Modern LLMs use RoPE: rotate q and k by a position-dependent angle so the score depends only on relative distance. It also makes context extension (interpolation, NTK/YaRN) practical.

Self-test questions

Why does a transformer need positional information at all? Attention compares tokens by content only, so it's permutation-equivariant: shuffling the input just shuffles the output. Without positions, word order is invisible.

How does RoPE encode position, and what property makes it attractive? It rotates each pair of dimensions in q and k by an angle proportional to position. The q·k score then depends only on the relative distance, and rotation preserves vector length.

Compute a RoPE score by hand: 2 dimensions, θ = 1, q = k = (1, 0), query at 3, key at 7. cos(7 − 3) = cos 4 ≈ −0.654, and the same at positions 103 and 107.

Why do sinusoidal codes use many frequencies? Fast frequencies distinguish neighbours; slow ones distinguish distant positions. Together every position gets a unique, smoothly varying code, and a shift by k is the same rotation everywhere.

What goes wrong beyond the trained context length, and how do you fix it? The model meets position angles or indices it never saw, and attention degrades. Position interpolation, NTK-aware scaling or YaRN rescale positions or frequencies into the trained range, usually followed by a short fine-tune.

Learned absolute position embeddings: pro and con? They are simple and effective within the trained length, but they cannot represent any position beyond the table size.

The papers behind this lesson

Further reading

on GitHub
   1r"""
   2# Positional information: how a transformer knows word order
   3
   4Run: `python -m primer.ml.positional`
   5
   6New to the notation (vectors, dot products, sine and cosine)? Every symbol
   7is decoded where it appears, and `primer.notation` teaches them all from zero.
   8This lesson builds on `primer.ml.attention`.
   9
  10## Level 1: The practitioner's guide
  11
  12**In one sentence.** Positional encoding is how a transformer learns where
  13each token sits, since attention on its own treats the input as an unordered
  14bag; the scheme a model uses decides how long a context it can read, whether
  15that length can be stretched after training, and what happens when you go
  16past it.
  17
  18**When you need it.** You never add positions yourself; the model's authors
  19chose a scheme before training and it is baked into the weights. You meet it
  20the day a length matters: a model card says 128K tokens and you wonder how
  21far to trust it, a self-hosted model produces nonsense past a certain prompt
  22length, an embedding model rejects documents longer than its limit, or you
  23want to fine-tune a model to read documents longer than it was trained on.
  24The tell: quality that is fine at 4,000 tokens and falls off a cliff at
  25some longer length, with nothing in the logs. The number behind the whole
  26topic, from this lesson's `order_similarity`: with no positions, the pooled
  27attention outputs for "dog bites man" and "man bites dog" have cosine
  28similarity exactly 1.000. The model cannot tell them apart. Nothing about
  29this concerns you when prompts stay well inside the length the model was
  30trained at.
  31
  32**Your options.** The first four are choices the model's authors made; you
  33choose among them by choosing a model. The last three are what you or the
  34vendor can do about length afterwards. From the simplest to the most
  35committed:
  36
  37| Option | What it does | What it gives you | What it costs | Where it lives |
  38|---|---|---|---|---|
  39| A learned position table (GPT-2, BERT) | One learned vector per seat, added to the token's vector | Simple and effective inside the trained length | A hard ceiling: no row exists past the table's end (1,024 rows for GPT-2, 512 for BERT) | The architecture; `max_position_embeddings` on the card |
  40| Sinusoidal codes (the 2017 transformer) | A fixed sine and cosine fingerprint per position, added to the token | No parameters; a code exists for any position | Position is mixed into content; little used in new models | The architecture |
  41| RoPE (Llama, Mistral, Qwen) | Rotates each query and key by an angle that grows with position, so scores depend on the distance between tokens | Relative distance for free, and a context that can be stretched after training | Angles past the trained range are unfamiliar; extension needs scaling | The architecture; `rope_theta` and `rope_parameters` on the card |
  42| ALiBi | Subtracts a penalty proportional to distance from each score; no position vectors at all | Trained at 1,024 tokens, it extrapolates to 2,048; 11% faster and 11% less memory than sinusoidal in its paper | A built-in preference for nearby tokens; fewer models use it | The architecture |
  43| Inference-time scaling (linear or dynamic NTK) | Rescales positions or the RoPE base: linear scaling at every length, dynamic scaling only once a prompt exceeds the trained length | A modest stretch with no training at all | Quality drops as the stretch grows | The serving engine's config: `rope_type` and `factor` |
  44| Extension with a short fine-tune (PI, YaRN) | Scales positions into the trained range, then fine-tunes briefly on long text | 8× longer context: Llama to 32,768 tokens within 1,000 steps (PI); YaRN needs 10× fewer tokens than earlier methods | Long documents to train on, a training run, an evaluation at length | Your training stack |
  45| Staged long-context pretraining | The vendor grows the window during pretraining, checking a needle-in-a-haystack test at each stage | The genuine article: Llama 3 went from 8K to 128K in six stages | About 800B training tokens for Llama 3 405B; reaches you as a number on the card | The vendor |
  46
  47**How to choose.** Start from the model in front of you.
  48
  49- A hosted API: nothing to choose, but the window on the card is the length
  50  the vendor trained and tested, not a promise about your task. Measure at
  51  the lengths you will use.
  52- Picking an open model: read `max_position_embeddings` and
  53  `rope_parameters`. An entry with a `factor` means the model was trained
  54  shorter and stretched, so test it at the lengths you will use rather
  55  than trusting the stretched number.
  56- Serving a model beyond its trained length: do not raise the engine's
  57  context limit on its own. Set the scaling the checkpoint expects (or a
  58  dynamic scaling if none is given), and test before shipping.
  59- Documents longer than the model's window, and you can train: position
  60  interpolation or YaRN plus a fine-tune on long text, with a
  61  needle-in-a-haystack check and your own task at the target length.
  62- An encoder or embedding model with a learned table: the limit is hard.
  63  Chunk documents to fit (`primer.agents.rag`).
  64- Whatever you pick, evaluate at the length you will run, with the answer
  65  placed at the start, the middle and the end of the prompt.
  66
  67**What it costs.** Positions are nearly free to compute; length is what
  68costs.
  69
  70- Parameters and compute. GPT-2's table is 1,024 × 768 = 786,432 numbers,
  71  under 1% of its 124 million parameters. RoPE and ALiBi add none, and RoPE
  72  is a few element-wise multiplications per query and key, negligible next
  73  to attention itself.
  74- Stretching. Position interpolation for 4,096 to 32,768 tokens multiplies
  75  every position by 1/8 (32,000 becomes 4,000); NTK-aware scaling instead
  76  raises the RoPE base from 10,000 to 82,685 for a 128-wide head, leaving
  77  the fastest pair untouched and slowing the slowest 8×. Both are followed
  78  by a short fine-tune: within 1,000 steps in the PI paper. Llama 3's
  79  authors set the base to 500,000 and spent about 800B tokens taking the
  80  405B model from 8K to 128K.
  81- Quality. RoPE gives nearby tokens a head start (the RoFormer companion's
  82  long-term decay), and a long window is not used evenly: Liu et al. (*Lost
  83  in the Middle*, 2023) found models use information best at the start or
  84  the end of a long prompt and worst in the middle (`primer.agents.context`).
  85
  86**What breaks.**
  87
  88- **Past the table.** A learned-table model has no vector for position
  89  1,024 if its table has 1,024 rows: the request fails or the library
  90  truncates. Chunk the input.
  91- **Past the trained angles.** RoPE degrades silently rather than failing:
  92  this lesson's figure shows the slowest pair's angle leaving the trained
  93  band right after 4K tokens and reaching 8× its top by 32K. The PI paper
  94  reports that plain extrapolation can produce catastrophically large
  95  attention scores. Scale, then fine-tune.
  96- **A checkpoint served with the wrong scaling.** An extended model whose
  97  `rope_parameters` are dropped or mistyped reads positions it never
  98  learned. Copy the config with the weights.
  99- **Interpolating too far.** Interpolation slows every frequency equally,
 100  which also blurs the fast ones that carry local word order. NTK-aware
 101  scaling and YaRN keep the fast frequencies, which is why they are
 102  preferred for large stretches.
 103- **Pooling that forgets order.** Average the token vectors of an
 104  order-blind model and "dog bites man" equals "man bites dog" exactly.
 105  Order survives only if positions are injected, or an encoding step that
 106  is not permutation-blind comes first.
 107- **Trusting the causal mask for order.** In a decoder the mask alone lets
 108  the sentences differ (cosine 0.524 in this lesson's run), but it is a
 109  weak signal, not a position encoding.
 110
 111**In the wild.** The original transformer (Vaswani et al., 2017) used
 112sinusoidal codes; GPT-2 and BERT switched to learned tables, which is why
 113each has a fixed maximum length. Llama, Mistral and Qwen use RoPE, and Llama
 1143 raised its base to 500,000 with 8 key-value heads beside it. Hugging Face
 115model configs carry the scheme as `rope_parameters` with a `rope_type` of
 116`linear`, `dynamic`, `yarn`, `longrope` or `llama3`, a `factor` and an
 117`original_max_position_embeddings`; vLLM's `--max-model-len` sets the
 118served length and `--hf-overrides` changes that config at load time. YaRN
 119(Peng et al., 2023) is the extension recipe behind many long-context
 120checkpoints, ALiBi (Press et al., 2021) the alternative that extrapolates
 121without vectors, and the NoPE study (Kazemnejad et al., 2023) found that a
 122decoder can generalize to longer inputs with no explicit positions at all.
 123The papers are linked at the end of the lesson.
 124
 125**Go deeper.** Level 2 shows the bag-of-words problem in three numbers,
 126builds sinusoidal codes as a clock with many hands, checks RoPE's distance
 127property by hand at positions 3 and 7 and again at 103 and 107, and stretches
 128a 4K model to 32K by interpolation and by raising the base. If you only
 129needed to read a model card or serve a model safely, you are done.
 130
 131## Level 2: How it works, from scratch
 132
 133Level 2 starts with the problem itself, a bag of Scrabble tiles, and adds
 134each family of positions in turn.
 135
 136## The problem: attention reads a bag, not a sentence
 137
 138**Everyday picture.** Pour the words of a sentence into a bag of Scrabble
 139tiles. The bag for "dog bites man" and the bag for "man bites dog" hold
 140exactly the same tiles. Anyone who only gets the bag cannot tell a news
 141story from a routine dog attack. Attention, on its own, only ever gets the bag.
 142
 143**Tiny worked example.** Give each word a 2-number embedding: dog = (1, 0),
 144bites = (0, 1), man = (1, 1). Average the words of each sentence, which is
 145the simplest possible "sentence vector":
 146
 147| sentence | sum of vectors | average |
 148|---|---|---|
 149| dog bites man | (1,0)+(0,1)+(1,1) = (2, 2) | (0.67, 0.67) |
 150| man bites dog | (1,1)+(0,1)+(1,0) = (2, 2) | (0.67, 0.67) |
 151
 152Addition doesn't care about order, and neither does attention: every token
 153compares itself with every other by dot product, and nothing in that
 154computation says who came first.
 155
 156```mermaid
 157flowchart LR
 158  A["'dog bites man'"] --> E1[Embed each token]
 159  B["'man bites dog'"] --> E2[Embed each token]
 160  E1 --> S1["Set {dog, bites, man}"]
 161  E2 --> S2["Set {man, bites, dog}"]
 162  S1 --> ATT[Attention with no positions]
 163  S2 --> ATT
 164  ATT --> SAME[Same vectors, only reordered:<br/>the meaning difference is lost]
 165```
 166
 167**Reading it:** follow both sentences left to right. Embedding looks up each
 168token independently, so both sentences produce the same three vectors.
 169Attention only compares vectors with each other; it has no notion of slot 1
 170versus slot 3. It sees the same *set* and produces the same output vectors,
 171listed in a different order. Pool them into one sentence vector and the two
 172sentences are identical.
 173
 174**The math.** A **permutation** is a reshuffle of an ordered list. Written as
 175a matrix (a grid of numbers), a permutation matrix is all zeros except one 1
 176in each row, and multiplying by it just reorders rows. Attention without
 177positions is *permutation-equivariant*:
 178
 179$$
 180\text{Attn}(PX) = P\,\text{Attn}(X)
 181$$
 182
 183**Symbols**
 184
 185| Symbol | Meaning here | In the example |
 186|---|---|---|
 187| $X$ | the sentence's token vectors, one row per word | rows dog (1,0), bites (0,1), man (1,1): a 3 × 2 grid |
 188| $P$ | a permutation matrix: reorders the rows of whatever it multiplies | swap rows 1 and 3 |
 189| $PX$ | the same rows in the new order | man (1,1), bites (0,1), dog (1,0) |
 190| $\text{Attn}(\cdot)$ | one attention layer with no position information (here with $W_q = W_k = W_v$ = identity, so q = k = v = the word vector) | 3 × 2 in, 3 × 2 out |
 191| $=$ | both sides are exactly the same grid of numbers | |
 192
 193**In words:** "running attention on the shuffled sentence gives the same
 194answer as running it on the original sentence and shuffling afterwards."
 195
 196**With the numbers:** in "dog bites man", dog's query (1,0) scores the three
 197keys (1,0), (0,1), (1,1) as 1, 0, 1; divided by √2 that is 0.707, 0, 0.707.
 198Softmax gives weights 0.401, 0.198, 0.401, so dog's output is
 1990.401·(1,0) + 0.198·(0,1) + 0.401·(1,1) = **(0.802, 0.599)**. In "man bites
 200dog" dog sits in row 3, but it scores the same three keys in a different
 201order, gets the same weights, and outputs the same **(0.802, 0.599)**.
 202
 203**In Python:**
 204
 205```python
 206import math
 207# q = k = v = the word vector, no positions
 208def Attn(X):
 209    out = []
 210    for q in X:
 211        scores = [sum(a * b for a, b in zip(q, k)) / math.sqrt(2) for k in X]
 212        exps = [math.exp(s) for s in scores]
 213        weights = [e / sum(exps) for e in exps]
 214        out.append(tuple(round(sum(w * v[c] for w, v in zip(weights, X)), 3) for c in range(2)))
 215    return out
 216# dog, bites, man
 217X = [(1, 0), (0, 1), (1, 1)]
 218# P swaps rows 1 and 3: man, bites, dog
 219PX = [X[2], X[1], X[0]]
 220Attn(X)  # → [(0.802, 0.599), (0.599, 0.802), (0.752, 0.752)]
 221# the same rows, shuffled the same way
 222Attn(PX)  # → [(0.752, 0.752), (0.599, 0.802), (0.802, 0.599)]
 223```
 224
 225Shuffle the input and you get the same outputs, shuffled the same way.
 226`encode_sentence(..., scheme="none")` runs a real attention layer and shows
 227the "dog" row is the same vector whether "dog" comes first or last.
 228
 229![Without positions the two sentences are identical (similarity 1.0); sinusoidal codes and RoPE pull it just below 1, and a causal mask alone drops it to 0.52](figures/primer.ml.positional.order_blindness.svg)
 230
 231**Reading it:** each bar compares the pooled attention output of "dog bites
 232man" with that of "man bites dog", as a cosine similarity (1.0 means
 233identical). With no positions the bar reaches exactly 1: the model literally
 234cannot tell the sentences apart. Adding sinusoidal codes or RoPE pulls the
 235bar below 1: the sentences now look different. The causal-mask bar is below 1
 236too, because a mask that lets token i see only i+1 tokens already leaks some
 237order.
 238
 239**In code:** `word_vector` gives each word a fixed random embedding, and `order_similarity` pools each sentence's outputs and compares them, which gives the bars in the figure.
 240
 241**Why it matters.** Almost all meaning in language is carried by order:
 242negation scope, who did what to whom, code. So position must be **injected**.
 243There are three families, below.
 244
 245## 1. Sinusoidal codes (the original 2017 transformer)
 246
 247**Everyday picture.** A clock with many hands. The second hand moves fast
 248and tells nearby moments apart; the hour hand moves slowly and tells morning
 249from evening. Read all the hands together and every moment has a unique
 250fingerprint. Sinusoidal codes give every position such a set of "hands" and
 251add them to the token's embedding.
 252
 253**Tiny worked example.** With 4 dimensions there are two hands. The fast one
 254turns 1 radian per position; the slow one turns 1/100 of a radian:
 255
 256| position | sin(pos·1) | cos(pos·1) | sin(pos/100) | cos(pos/100) |
 257|---|---|---|---|---|
 258| 0 | 0.000 | 1.000 | 0.000 | 1.000 |
 259| 1 | 0.841 | 0.540 | 0.010 | 1.000 |
 260| 2 | 0.909 | −0.416 | 0.020 | 1.000 |
 261
 262The fast pair already looks very different at positions 1 and 2; the slow
 263pair barely moves, and it will only distinguish positions hundreds apart.
 264
 265```mermaid
 266flowchart LR
 267  T["token id"] --> E["embedding lookup<br/>(what the word means)"]
 268  P["position 0,1,2..."] --> S["sin/cos at many frequencies<br/>(where the word is)"]
 269  E --> ADD(("+"))
 270  S --> ADD
 271  ADD --> B["transformer blocks"]
 272```
 273
 274**Reading it:** two independent lookups meet at the plus sign. The top path
 275says *what* the token is, the bottom path says *where* it is, and their sum
 276is a single vector carrying both. Everything downstream sees only that sum,
 277so position becomes part of the content attention compares.
 278
 279**The math and the code.** Two functions do the work. Picture a point
 280walking around a circle of radius 1. The angle it has turned is measured in
 281**radians** (a full turn is 2π ≈ 6.28 radians). **Cosine** of the angle is
 282how far right the point is, and **sine** is how far up; both always lie
 283between −1 and +1, and both repeat every full turn.
 284
 285$$
 286PE_{(pos,\,2i)} = \sin(pos\cdot\omega_i), \qquad PE_{(pos,\,2i+1)} = \cos(pos\cdot\omega_i),
 287\qquad \omega_i = \frac{1}{10000^{2i/d}}
 288$$
 289
 290**Symbols**
 291
 292| Symbol | Meaning here | In the example |
 293|---|---|---|
 294| $pos$ | the token's position, counting from 0 | 1 |
 295| $d$ | how many numbers each position code has | 4 |
 296| $i$ | which (sin, cos) pair, counting from 0 up to $d/2 - 1$ | 0 and 1 |
 297| $2i$, $2i+1$ | the two columns that pair $i$ fills: even column gets sine, odd gets cosine | pair 1 fills columns 2 and 3 |
 298| $\omega_i$ | pair $i$'s speed in radians per position (its **frequency**) | $\omega_0 = 1$, $\omega_1 = 1/100$ |
 299| $10000^{2i/d}$ | 10,000 raised to a fraction; makes each pair slower than the last | $10000^{2/4} = 100$ |
 300| $\sin$, $\cos$ | how far up / right a point is after turning by that many radians | $\sin 1 = 0.841$ |
 301| $PE_{(pos,\,c)}$ | the number in row $pos$, column $c$ of the code table | |
 302
 303**In words:** "for position *pos*, each pair of columns stores where a hand
 304turning at that pair's speed points after *pos* steps: sine in the first
 305column, cosine in the second."
 306
 307**With the numbers:** d = 4, pos = 1. Pair 0 turns 1 radian: sin 1 = 0.841,
 308cos 1 = 0.540. Pair 1 turns 1/100 radian: sin 0.01 = 0.010, cos 0.01 = 1.000.
 309So position 1's code is **(0.841, 0.540, 0.010, 1.000)**, the second row of
 310the table above.
 311
 312**In Python:**
 313
 314```python
 315import math
 316d, pos = 4, 1
 317PE = []
 318# one (sin, cos) pair per i
 319for i in range(d // 2):
 320    # ω_i: pair i's speed
 321    omega_i = 1 / 10000 ** (2 * i / d)
 322    # columns 2i and 2i+1
 323    PE += [math.sin(pos * omega_i), math.cos(pos * omega_i)]
 324[f"{v:.3f}" for v in PE]  # → ['0.841', '0.540', '0.010', '1.000']
 325```
 326
 327`sinusoidal_encoding(n, d)` builds the whole (n × d) table in four lines. A
 328shift of k positions rotates every (sin, cos) pair by the same angle k·ω_i
 329wherever you start, so **the dot product of two position codes depends only
 330on their distance**.
 331
 332![Left columns flip colour every few positions and right columns change slowly, so every row is a unique fingerprint and neighbours look alike](figures/primer.ml.positional.sinusoidal_heatmap.svg)
 333
 334**Reading it:** each row is a position (0 at the top), each column one
 335dimension, and colour is the value from −1 to +1. The left columns flip
 336colour every few rows (fast hands); the right columns change slowly over
 337hundreds of rows (slow hands). No two rows are identical, so every position
 338has a unique fingerprint, and neighbouring rows look alike, so nearby
 339positions get similar codes.
 340
 341**Why it matters.** Fixed codes need no training and exist for any position,
 342and the distance property lets the model learn "look 3 tokens back" once and
 343use it everywhere. But the code is mixed into the content vector itself,
 344which later methods avoid.
 345
 346## 2. Learned absolute positions (GPT-2, BERT)
 347
 348**Everyday picture.** Numbered theatre seats. Every seat has its own brass
 349plate, and the theatre has a fixed number of seats.
 350
 351**Tiny worked example.** GPT-2 has a table of 1,024 position vectors, each
 352768 numbers long: 786,432 extra parameters, learned like any other weight.
 353Position 5 looks up row 5. Position 1,024 has no row at all.
 354
 355```mermaid
 356flowchart LR
 357  T["token id 318"] --> TE["token table<br/>(vocab × d)"]
 358  P["position 5"] --> PE["position table<br/>(max_len × d)"]
 359  TE --> ADD(("+"))
 360  PE --> ADD
 361  ADD --> B["transformer blocks"]
 362  P2["position ≥ max_len"] -.-> X["no row: cannot be encoded"]
 363```
 364
 365**Reading it:** identical to the sinusoidal picture except that the bottom
 366path is a second trainable table instead of a formula. The dotted arrow is
 367the catch: the table ends at max_len, so a longer input simply has nowhere
 368to look.
 369
 370**The math and the code.**
 371
 372$$
 373x_{pos} = E_{token} + P_{pos}
 374$$
 375
 376**Symbols**
 377
 378| Symbol | Meaning here | In the example (GPT-2) |
 379|---|---|---|
 380| $E_{token}$ | the token's row of the token table (what the word means) | 768 numbers |
 381| $P_{pos}$ | row $pos$ of the position table, learned during training (where it is) | 768 numbers; $pos$ from 0 to 1,023 |
 382| $+$ | add the two lists number by number | |
 383| $x_{pos}$ | the vector the first transformer block receives | 768 numbers |
 384
 385**In words:** "the input vector is the word's meaning plus its seat number's
 386learned vector."
 387
 388**With the numbers:** with 2 dimensions, if $E_{dog}$ = (0.5, −0.2) and
 389$P_{3}$ = (0.1, 0.3), then $x_3$ = (0.6, 0.1). $P_{1024}$ does not exist.
 390
 391**In Python:**
 392
 393```python
 394E_dog = [0.5, -0.2]
 395# a position table with rows 0 to 3
 396P = [[0.0, 0.0], [0.0, 0.0], [0.0, 0.0], [0.1, 0.3]]
 397# x_3 = E_dog + P_3
 398[round(e + p, 2) for e, p in zip(E_dog, P[3])]  # → [0.6, 0.1]
 399# no row 1024: it cannot be encoded
 400len(P) > 1024  # → False
 401```
 402
 403`primer.ml.transformer.TinyGPT` uses exactly this.
 404
 405**Why it matters.** It's simple and works well inside the trained length,
 406but it cannot extrapolate. That limitation pushed the field to RoPE.
 407
 408## 3. Rotary position embeddings, RoPE (Llama, Mistral, Qwen and most modern LLMs)
 409
 410**Everyday picture.** Two people point at a clock face, one at 1 o'clock and
 411one at 4 o'clock. The angle between their arms is 90°. Move both of them on
 412to 8 and 11 o'clock and the angle is still 90°. RoPE turns each token's
 413query and key like clock hands, by an amount proportional to its position.
 414When a query meets a key, only the **angle between them** (the distance
 415between the tokens) affects the score, not where on the clock they started.
 416
 417**Tiny worked example.** Use 2 dimensions, so there is one pair turning 1
 418radian per position. Let q = k = (1, 0). Put the query at position 3 and the
 419key at position 7:
 420
 421* q turns by 3 rad: (cos 3, sin 3) = (−0.990, 0.141)
 422* k turns by 7 rad: (cos 7, sin 7) = (0.754, 0.657)
 423* score = −0.990·0.754 + 0.141·0.657 = −0.654 = cos(4)
 424
 425Now move both 100 positions later, to 103 and 107. The score is still
 426cos(107 − 103) = cos 4 = −0.654. Only the distance, 4, survived.
 427
 428```mermaid
 429flowchart LR
 430  X[Token vector at position m] --> Q[q = x W_q]
 431  X --> K[k = x W_k]
 432  X --> V[v = x W_v]
 433  Q --> RQ["Rotate each pair of q<br/>by m·θ_i"]
 434  K --> RK["Rotate each pair of k<br/>by m·θ_i"]
 435  RQ --> S["Score = q'·k'<br/>depends only on distance"]
 436  RK --> S
 437  S --> SM[Softmax] --> W[Weighted sum of V<br/>V is NOT rotated]
 438  V --> W
 439```
 440
 441**Reading it:** this is ordinary attention with one extra step on the query
 442and key branches. After projection, each (even, odd) pair of numbers in q
 443and k is spun like a clock hand by an angle that grows with the token's
 444position: fast for the first pairs, slow for the last. In the dot product
 445only the *difference* of the angles survives, so the score knows how far
 446apart the tokens are but not where they sit in the document. The value
 447branch is untouched, so what gets blended is plain content, and nothing is
 448ever added to the embedding.
 449
 450**The math and the code.** A **rotation** turns a point around the centre
 451by some angle without changing its distance from the centre. Written as a
 4522 × 2 grid times a column of two numbers (a **matrix-vector multiply**:
 453each output number is one row of the grid multiplied position-by-position
 454with the input and added up):
 455
 456$$
 457\begin{pmatrix} x'_{2i} \\ x'_{2i+1} \end{pmatrix} =
 458\begin{pmatrix} \cos m\theta_i & -\sin m\theta_i \\ \sin m\theta_i & \cos m\theta_i \end{pmatrix}
 459\begin{pmatrix} x_{2i} \\ x_{2i+1} \end{pmatrix},
 460\qquad \theta_i = 10000^{-2i/d}
 461$$
 462
 463**Symbols**
 464
 465| Symbol | Meaning here | In the example |
 466|---|---|---|
 467| $x_{2i}, x_{2i+1}$ | pair $i$ of a query or key vector, before rotating | (1, 0) |
 468| $x'_{2i}, x'_{2i+1}$ | the same pair after rotating | (−0.990, 0.141) |
 469| $m$ | the token's position | 3 |
 470| $i$ | which pair, from 0 to $d/2 - 1$ | 0 (the only pair when d = 2) |
 471| $\theta_i$ | pair $i$'s turning speed in radians per position; $10000^{-2i/d}$ means $1/10000^{2i/d}$ | $\theta_0 = 1$ |
 472| $m\theta_i$ | the total angle turned | 3 radians |
 473| the 2 × 2 grid | the rotation: row 1 gives the new first number, row 2 the new second | |
 474
 475**In words:** "new first number = cos(angle) × old first − sin(angle) × old
 476second; new second number = sin(angle) × old first + cos(angle) × old second,
 477where the angle is position times the pair's speed."
 478
 479**With the numbers:** x = (1, 0) at m = 3, θ = 1: x′ = (cos 3 · 1 − sin 3 · 0,
 480sin 3 · 1 + cos 3 · 0) = (−0.990, 0.141).
 481
 482**In Python:**
 483
 484```python
 485import math
 486def rotate(x, m, theta_i=1.0):
 487    # the angle m θ_i
 488    a = m * theta_i
 489    # row 1 of the grid
 490    return (math.cos(a) * x[0] - math.sin(a) * x[1],
 491            # row 2 of the grid
 492            math.sin(a) * x[0] + math.cos(a) * x[1])
 493[f"{v:.3f}" for v in rotate((1, 0), m=3)]  # → ['-0.990', '0.141']
 494d, i = 2, 0
 495# θ_0 = 10000^(-2i/d)
 49610000 ** (-2 * i / d)  # → 1.0
 497```
 498
 499Why only the distance survives: turning q by angle a and k by angle b and
 500then taking their **dot product** (multiply matching numbers, add them up;
 501large when the vectors point the same way) gives the same answer as turning
 502k alone by b − a. So
 503
 504$$
 505\langle R_m q,\; R_n k\rangle = \langle q,\; R_{n-m}\, k\rangle
 506$$
 507
 508**Symbols**
 509
 510| Symbol | Meaning here | In the example |
 511|---|---|---|
 512| $q$, $k$ | a query and a key vector, before rotating | (1, 0) and (1, 0) |
 513| $m$, $n$ | the query's and the key's positions | 3 and 7 |
 514| $R_m$ | "rotate every pair by its speed times $m$" | turn by 3 radians |
 515| $R_{n-m}$ | the rotation for the *distance* only | turn by 4 radians |
 516| $\langle a, b \rangle$ | the dot product of $a$ and $b$ (same as $a \cdot b$) | |
 517
 518**In words:** "the score between a query at *m* and a key at *n* equals the
 519score you'd get by leaving the query alone and turning the key by the gap
 520*n − m*."
 521
 522**With the numbers:** left side (−0.990, 0.141) · (0.754, 0.657) = −0.654;
 523right side (1, 0) · (cos 4, sin 4) = cos 4 = −0.654. Same number, and the same
 524again at positions 103 and 107.
 525
 526**In Python:**
 527
 528```python
 529import math
 530# turn a pair by an angle (θ = 1, so the angle is the position)
 531def R(angle, x):
 532    return (math.cos(angle) * x[0] - math.sin(angle) * x[1],
 533            math.sin(angle) * x[0] + math.cos(angle) * x[1])
 534def dot(a, b): return sum(a_i * b_i for a_i, b_i in zip(a, b))
 535q = k = (1, 0)
 536# ⟨R_m q, R_n k⟩ with m = 3, n = 7
 537round(dot(R(3, q), R(7, k)), 3)  # → -0.654
 538# ⟨q, R_(n-m) k⟩: only the distance
 539round(dot(q, R(7 - 3, k)), 3)  # → -0.654
 540# 100 positions later, the same score
 541round(dot(R(103, q), R(107, k)), 3)  # → -0.654
 542```
 543
 544`apply_rope` does this for a vector or a whole sequence with three
 545element-wise lines, and no matrix multiply.
 546
 547![The score curves for starting positions 0, 100 and 1,000 lie exactly on top of each other: only the distance matters](figures/primer.ml.positional.rope_relative.svg)
 548
 549**Reading it:** the x-axis is the distance n − m between a query and a key;
 550each line uses the same q and k but places the pair at a different absolute
 551starting position (0, 100, 1,000). The lines lie exactly on top of each
 552other: the score depends only on distance. That is the relative-position
 553property in one picture.
 554
 555![Pair 0 wraps a full turn about every 6 tokens while the last pairs barely turn across the whole range](figures/primer.ml.positional.rope_frequencies.svg)
 556
 557**Reading it:** each line is one pair of dimensions; the y-axis is how far
 558that pair has turned (wrapped to one full turn, 2π) at each position on the
 559x-axis. Pair 0 spins fastest and wraps every ~6 tokens, giving fine-grained
 560local order. The last pairs barely turn across the whole range, giving
 561coarse long-range position. It's the many-handed clock again, now applied
 562inside attention.
 563
 564**In code:** `rope_frequencies` returns each pair's turning speed θ_i, which `apply_rope` multiplies by the position to get every angle.
 565
 566**Why it matters.** Relative distance is what language actually uses ("the
 567word two back"), rotation keeps vector lengths unchanged, and because
 568position lives in angles, you can stretch it after training. That last
 569point is the next section.
 570
 571## 4. Extending context after training
 572
 573**Everyday picture.** A ruler marked from 0 to 4,000. To measure something
 57432,000 long you can shrink the object by 8× so it fits on the ruler
 575(interpolation), or re-draw only the long-distance marks while keeping the
 576fine millimetre marks (NTK-aware scaling).
 577
 578**Tiny worked example.** A model trained on 4,096 tokens now reads 32,768.
 579Interpolation multiplies every position by 4096/32768 = 1/8, so position
 58032,000 is treated as 4,000: an angle the model has seen. Neighbours are now
 5811/8 of a position apart, which a short fine-tune teaches it to resolve.
 582
 583```mermaid
 584flowchart LR
 585  P["positions 0 ... 32k"] --> C{method}
 586  C -->|interpolation| PI["pos × 4k/32k<br/>all hands slowed 8×"]
 587  C -->|NTK-aware| NTK["raise the RoPE base<br/>slow hands slowed, fast hands kept"]
 588  PI --> R["RoPE with angles inside<br/>the trained range"]
 589  NTK --> R
 590  R --> F["short fine-tune on long text"]
 591```
 592
 593**Reading it:** both paths solve the same problem: angles beyond what the
 594model saw in training. Interpolation slows *every* hand equally, which also
 595blurs the fast hands that encode local word order. NTK-aware scaling slows
 596only the slow hands and leaves the fastest one untouched, so local order stays
 597sharp. Both usually end with a brief fine-tune.
 598
 599**The math and the code.** Position interpolation (`interpolate_positions`):
 600
 601$$
 602pos' = pos \cdot \frac{L_{train}}{L_{new}}
 603$$
 604
 605**Symbols**
 606
 607| Symbol | Meaning here | In the example |
 608|---|---|---|
 609| $pos$ | the real position in the long input | 32,000 |
 610| $L_{train}$ | the longest context seen in training | 4,096 |
 611| $L_{new}$ | the context we now want | 32,768 |
 612| $pos'$ | the position RoPE is actually given | 4,000 |
 613
 614**In words:** "shrink every position by the ratio of old length to new length."
 615
 616**With the numbers:** 32,000 × 4,096 / 32,768 = 32,000 / 8 = **4,000**.
 617
 618**In Python:**
 619
 620```python
 621pos, L_train, L_new = 32_000, 4_096, 32_768
 622# pos' = pos · L_train / L_new
 623pos * L_train / L_new  # → 4000.0
 624```
 625
 626NTK-aware scaling (`ntk_scaled_base`) changes the base instead:
 627
 628$$
 629base' = base \cdot s^{\,d/(d-2)}
 630$$
 631
 632**Symbols**
 633
 634| Symbol | Meaning here | In the example |
 635|---|---|---|
 636| $base$ | the 10,000 inside $\theta_i = base^{-2i/d}$ | 10,000 |
 637| $s$ | how many times longer the new context is | 8 |
 638| $d$ | the head width | 128 |
 639| $s^{d/(d-2)}$ | $s$ raised to a power just above 1 | $8^{1.016} ≈ 8.27$ |
 640| $base'$ | the new, larger base | ≈ 82,685 |
 641
 642**In words:** "multiply the base by slightly more than the stretch factor."
 643
 644**With the numbers:** the fastest pair keeps $\theta_0 = base'^{0} = 1$
 645radian per position. The slowest pair goes from $10000^{-126/128} = 1.15
 646\times 10^{-4}$ to $82685^{-126/128} = 1.44 \times 10^{-5}$: exactly 8× slower.
 647
 648**In Python:**
 649
 650```python
 651base, s, d = 10_000, 8, 128
 652# s^(d/(d-2)): just above 8
 653round(s ** (d / (d - 2)), 2)  # → 8.27
 654# base' = base · s^(d/(d-2))
 655base_new = base * s ** (d / (d - 2))
 656round(base_new)  # → 82685
 657# θ_i for the last pair, 2i = 126
 658slowest = lambda b: b ** (-126 / 128)
 659f"{slowest(base):.2e}", f"{slowest(base_new):.2e}"  # → ('1.15e-04', '1.44e-05')
 660# exactly 8× slower
 661round(slowest(base) / slowest(base_new), 6)  # → 8.0
 662```
 663
 664YaRN refines this per frequency band and is
 665used by many long-context models. ALiBi skips position vectors entirely and
 666subtracts a penalty proportional to distance from each attention score.
 667
 668![Raw positions leave the trained angle band right after 4k and reach 8 times its top by 32k; interpolation and NTK scaling stay inside it](figures/primer.ml.positional.context_extension.svg)
 669
 670**Reading it:** the x-axis is position up to 32k and the y-axis is the angle
 671of the slowest RoPE pair. The shaded band is the range of angles the model
 672saw in training (positions up to 4k). The raw extended line leaves the band
 673almost immediately after 4k: unfamiliar territory. The interpolated and
 674NTK-scaled lines stay inside the band all the way to 32k.
 675
 676**Why it matters.** It's how models trained on a few thousand tokens are
 677cheaply stretched to 100k or more, instead of being pretrained again from
 678scratch.
 679
 680## A subtlety: the causal mask leaks order
 681
 682In a decoder, token i sees exactly i+1 tokens, so even with no position
 683encoding ("NoPE") the model can partly infer where it is. Bidirectional
 684attention (BERT-style) has no such crutch and is fully order-blind.
 685
 686**In code:** `encode_sentence` with its causal flag set runs the same layer under `primer.ml.attention.causal_mask`; its pooled similarity is the "causal mask only" bar in the first figure.
 687
 688## In 20 seconds
 689- Attention is permutation-equivariant: shuffle the words and the outputs
 690  shuffle the same way, so without position information "dog bites man"
 691  pools to exactly the same vector as "man bites dog".
 692- The original transformer adds fixed sine/cosine codes; GPT-2 and BERT
 693  learn a position table that ends at max_len.
 694- Modern LLMs use RoPE: rotate q and k by a position-dependent angle so the
 695  score depends only on relative distance. It also makes context extension
 696  (interpolation, NTK/YaRN) practical.
 697
 698## Self-test questions
 699
 700**Why does a transformer need positional information at all?**
 701Attention compares tokens by content only, so it's permutation-equivariant:
 702shuffling the input just shuffles the output. Without positions, word order
 703is invisible.
 704
 705**How does RoPE encode position, and what property makes it attractive?**
 706It rotates each pair of dimensions in q and k by an angle proportional to
 707position. The q·k score then depends only on the relative distance, and
 708rotation preserves vector length.
 709
 710**Compute a RoPE score by hand: 2 dimensions, θ = 1, q = k = (1, 0), query at 3, key at 7.**
 711cos(7 − 3) = cos 4 ≈ −0.654, and the same at positions 103 and 107.
 712
 713**Why do sinusoidal codes use many frequencies?**
 714Fast frequencies distinguish neighbours; slow ones distinguish distant
 715positions. Together every position gets a unique, smoothly varying code, and
 716a shift by k is the same rotation everywhere.
 717
 718**What goes wrong beyond the trained context length, and how do you fix it?**
 719The model meets position angles or indices it never saw, and attention
 720degrades. Position interpolation, NTK-aware scaling or YaRN rescale positions
 721or frequencies into the trained range, usually followed by a short fine-tune.
 722
 723**Learned absolute position embeddings: pro and con?**
 724They are simple and effective within the trained length, but they cannot
 725represent any position beyond the table size.
 726
 727## The papers behind this lesson
 728
 729- **Vaswani et al. (2017), *Attention Is All You Need*.** https://arxiv.org/abs/1706.03762.
 730  Introduced the transformer and, in section 3.5, the sinusoidal position
 731  codes built here. [annotated companion](../../papers/attention-is-all-you-need.html)
 732- **Su et al. (2021), *RoFormer: Enhanced Transformer with Rotary Position Embedding*.**
 733  https://arxiv.org/abs/2104.09864. Introduced RoPE: rotate queries and keys
 734  so attention scores depend only on relative distance.
 735  [annotated companion](../../papers/roformer.html)
 736- **Chen et al. (2023), *Extending Context Window of Large Language Models via Positional Interpolation*.**
 737  https://arxiv.org/abs/2306.15595. Showed that scaling positions down
 738  plus a short fine-tune extends RoPE models to much longer contexts.
 739- **Peng et al. (2023), *YaRN*.** https://arxiv.org/abs/2309.00071. Refined
 740  RoPE context extension by treating fast and slow frequencies differently.
 741- **Press et al. (2021), *ALiBi*.** https://arxiv.org/abs/2108.12409.
 742  Replaced position vectors with a distance penalty on attention scores that
 743  extrapolates to longer inputs.
 744
 745## Further reading
 746- Vaswani et al., *Attention Is All You Need* (sinusoidal codes, section 3.5): https://arxiv.org/abs/1706.03762
 747- Su et al., *RoFormer: Enhanced Transformer with Rotary Position Embedding* (2021): https://arxiv.org/abs/2104.09864
 748- EleutherAI, *Rotary Embeddings: A Relative Revolution*: https://blog.eleuther.ai/rotary-embeddings/
 749- Chen et al., *Extending Context Window of Large Language Models via Positional Interpolation* (2023): https://arxiv.org/abs/2306.15595
 750- Peng et al., *YaRN: Efficient Context Window Extension of Large Language Models* (2023): https://arxiv.org/abs/2309.00071
 751- Press et al., *Train Short, Test Long: Attention with Linear Biases (ALiBi)* (2021): https://arxiv.org/abs/2108.12409
 752- Kazemnejad et al., *The Impact of Positional Encoding on Length Generalization in Transformers* (NoPE, 2023): https://arxiv.org/abs/2305.19466
 753"""
 754
 755from __future__ import annotations
 756
 757import numpy as np
 758
 759import hashlib
 760
 761from primer._show import banner, matrix, say, table, takeaway
 762from primer.ml.attention import causal_mask, scaled_dot_product_attention
 763
 764# ---------------------------------------------------------------------------
 765# 1. Sinusoidal encodings
 766# ---------------------------------------------------------------------------
 767
 768
 769def sinusoidal_encoding(n_positions: int, d: int, base: float = 10000.0) -> np.ndarray:
 770    """(n_positions, d) matrix of the original transformer's position codes.
 771
 772    Column 2i holds sin(pos · ω_i), column 2i+1 holds cos(pos · ω_i), with
 773    ω_i = 1 / base^(2i/d). Frequencies fall geometrically from 1 (a full cycle
 774    every ~6 tokens) to 1/base (one cycle every ~60,000 tokens).
 775    """
 776    assert d % 2 == 0, "dimensions come in (sin, cos) pairs"
 777    pos = np.arange(n_positions)[:, None]  # (n, 1)
 778    omega = base ** (-np.arange(0, d, 2) / d)  # (d/2,) frequencies
 779    angles = pos * omega  # (n, d/2) via broadcasting
 780    pe = np.empty((n_positions, d))
 781    pe[:, 0::2] = np.sin(angles)
 782    pe[:, 1::2] = np.cos(angles)
 783    return pe
 784
 785
 786# ---------------------------------------------------------------------------
 787# 2. RoPE: rotary position embeddings
 788# ---------------------------------------------------------------------------
 789
 790
 791def rope_frequencies(d: int, base: float = 10000.0) -> np.ndarray:
 792    """θ_i = base^(-2i/d) for each of the d/2 pairs: pair 0 turns fastest."""
 793    assert d % 2 == 0, "RoPE rotates dimensions in pairs"
 794    return base ** (-np.arange(0, d, 2) / d)
 795
 796
 797def apply_rope(x: np.ndarray, position: int | np.ndarray | None = None, base: float = 10000.0) -> np.ndarray:
 798    """Rotate each (even, odd) pair of `x` by position · θ_i.
 799
 800    Args:
 801        x: one vector (d,) or a sequence (seq, d). In a real model this is
 802            applied to q and k (per head) right before the dot product.
 803        position: the token's position (int) or one per row. Defaults to
 804            0..seq-1 for a sequence.
 805        base: 10000 in the RoPE paper; raising it (NTK-aware scaling) slows
 806            every rotation, one way to stretch a model to longer contexts.
 807    """
 808    x = np.asarray(x, dtype=float)
 809    if position is None:
 810        position = np.arange(x.shape[0]) if x.ndim == 2 else 0
 811    pos = np.asarray(position, dtype=float)
 812    # angles: (d/2,) for one vector, or (seq, d/2) for a sequence.
 813    angles = pos[..., None] * rope_frequencies(x.shape[-1], base)
 814    cos, sin = np.cos(angles), np.sin(angles)
 815    even, odd = x[..., 0::2], x[..., 1::2]
 816    out = np.empty_like(x)
 817    # The 2x2 rotation [[cos, -sin], [sin, cos]] applied to every pair at once.
 818    out[..., 0::2] = even * cos - odd * sin
 819    out[..., 1::2] = even * sin + odd * cos
 820    return out
 821
 822
 823# ---------------------------------------------------------------------------
 824# 3. Order blindness: attention without positions sees a set, not a sequence
 825# ---------------------------------------------------------------------------
 826
 827D_DEMO = 16
 828_rng = np.random.default_rng(7)
 829# One fixed single-head attention layer for the demos (random, untrained).
 830_W_Q, _W_K, _W_V = (_rng.normal(0, 1 / np.sqrt(D_DEMO), (D_DEMO, D_DEMO)) for _ in range(3))
 831
 832
 833def word_vector(word: str, d: int = D_DEMO) -> np.ndarray:
 834    """A fixed random embedding per word (a stand-in for a learned embedding table)."""
 835    seed = int(hashlib.md5(word.encode()).hexdigest()[:8], 16)  # stable across runs, unlike hash()
 836    return np.random.default_rng(seed).standard_normal(d)
 837
 838
 839def encode_sentence(sentence: str, scheme: str = "none", causal: bool = False) -> np.ndarray:
 840    """Run one attention layer over `sentence` with a given position scheme.
 841
 842    Args:
 843        sentence: the words, separated by spaces.
 844        scheme: how position gets in. "none": embeddings only, so attention
 845            sees an unordered set. "sinusoidal": add the sinusoidal code to
 846            each embedding (the 2017 transformer). "rope": rotate q and k by
 847            position inside attention (modern LLMs).
 848        causal: when True, each word attends only to itself and earlier words.
 849
 850    Returns:
 851        The (n_words, d) output vectors.
 852    """
 853    X = np.stack([word_vector(w) for w in sentence.split()])  # (n, d)
 854    n = X.shape[0]
 855    if scheme == "sinusoidal":
 856        X = X + sinusoidal_encoding(n, D_DEMO)
 857    Q, K, V = X @ _W_Q, X @ _W_K, X @ _W_V
 858    if scheme == "rope":
 859        Q, K = apply_rope(Q), apply_rope(K)  # only q and k are rotated; v carries plain content
 860    out, _ = scaled_dot_product_attention(Q, K, V, mask=causal_mask(n) if causal else None)
 861    return out
 862
 863
 864# ---------------------------------------------------------------------------
 865# 4. Context extension: squeeze long contexts back into the trained range
 866# ---------------------------------------------------------------------------
 867
 868
 869def interpolate_positions(positions: np.ndarray, trained_len: int, new_len: int) -> np.ndarray:
 870    """Position interpolation: scale positions by trained_len / new_len.
 871
 872    A 4k-trained model run at 32k sees position 32,000 as 4,000: an angle it
 873    knows. The price is that neighbouring tokens are now only 1/8 of a
 874    position apart, which a short fine-tune teaches the model to resolve.
 875    """
 876    return np.asarray(positions, dtype=float) * (trained_len / new_len)
 877
 878
 879def ntk_scaled_base(base: float, scale: float, d: int) -> float:
 880    """NTK-aware RoPE scaling: raise the base instead of squeezing positions.
 881
 882    base' = base · scale^(d/(d-2)). The fastest pair (θ_0 = 1) is unchanged,
 883    so local word order stays sharp, while the slowest pair slows by exactly
 884    `scale`, so distant positions fit into the trained range of angles.
 885    """
 886    return base * scale ** (d / (d - 2))
 887
 888
 889# ---------------------------------------------------------------------------
 890# 5. Figures (rendered to docs/figures by `make figures`)
 891# ---------------------------------------------------------------------------
 892
 893
 894def _cos(a: np.ndarray, b: np.ndarray) -> float:
 895    return float(a @ b / (np.linalg.norm(a) * np.linalg.norm(b)))
 896
 897
 898def order_similarity() -> dict[str, float]:
 899    """Cosine similarity of pooled 'dog bites man' vs 'man bites dog' per scheme."""
 900    out = {}
 901    for label, scheme, causal in [
 902        ("no positions", "none", False),
 903        ("sinusoidal", "sinusoidal", False),
 904        ("RoPE", "rope", False),
 905        ("causal mask only", "none", True),
 906    ]:
 907        a = encode_sentence("dog bites man", scheme, causal).mean(axis=0)
 908        b = encode_sentence("man bites dog", scheme, causal).mean(axis=0)
 909        out[label] = _cos(a, b)
 910    return out
 911
 912
 913def figures() -> dict:
 914    """Plot this lesson's data. matplotlib is imported here, and only here."""
 915    import matplotlib
 916
 917    matplotlib.use("Agg")
 918    import matplotlib.pyplot as plt
 919
 920    BLUE, RED, MUTED = "#2563eb", "#dc2626", "#9ca3af"
 921    figs = {}
 922
 923    sims = order_similarity()
 924    fig, ax = plt.subplots(figsize=(6, 3.4))
 925    colors = [RED] + [BLUE] * (len(sims) - 1)
 926    ax.bar(list(sims), list(sims.values()), color=colors)
 927    for i, v in enumerate(sims.values()):
 928        ax.text(i, v + 0.01, f"{v:.3f}", ha="center")
 929    ax.set_ylim(min(sims.values()) - 0.1, 1.05)
 930    ax.set_ylabel("cosine similarity of pooled outputs")
 931    ax.set_title("'dog bites man' vs 'man bites dog'")
 932    fig.tight_layout()
 933    figs["order_blindness"] = fig
 934
 935    fig, ax = plt.subplots(figsize=(6, 4.5))
 936    im = ax.imshow(sinusoidal_encoding(200, 64), aspect="auto", cmap="RdBu", vmin=-1, vmax=1)
 937    ax.set_xlabel("dimension (left: fast hands, right: slow hands)")
 938    ax.set_ylabel("position")
 939    ax.set_title("Sinusoidal position codes")
 940    fig.colorbar(im, ax=ax, label="value")
 941    fig.tight_layout()
 942    figs["sinusoidal_heatmap"] = fig
 943
 944    rng = np.random.default_rng(0)
 945    q, k = rng.standard_normal(64), rng.standard_normal(64)
 946    dist = np.arange(0, 60)
 947    fig, ax = plt.subplots(figsize=(6, 3.6))
 948    for start, style in ((0, "-"), (100, "--"), (1000, ":")):
 949        scores = [apply_rope(q, start) @ apply_rope(k, start + d) for d in dist]
 950        ax.plot(dist, scores, style, lw=2, label=f"query at position {start}")
 951    ax.set_xlabel("distance between key and query (n − m)")
 952    ax.set_ylabel("attention score q·k")
 953    ax.set_title("With RoPE the score depends only on distance")
 954    ax.legend(frameon=False)
 955    fig.tight_layout()
 956    figs["rope_relative"] = fig
 957
 958    pos = np.arange(0, 200)
 959    theta = rope_frequencies(64)
 960    fig, ax = plt.subplots(figsize=(6, 3.6))
 961    for i in (0, 4, 12, 31):
 962        ax.plot(pos, np.mod(pos * theta[i], 2 * np.pi), lw=1.5, label=f"pair {i} (θ={theta[i]:.2g})")
 963    ax.set_xlabel("position")
 964    ax.set_ylabel("rotation angle (mod 2π)")
 965    ax.set_title("RoPE: fast hands for local order, slow hands for long range")
 966    ax.legend(frameon=False, fontsize=8)
 967    fig.tight_layout()
 968    figs["rope_frequencies"] = fig
 969
 970    d, trained, new = 128, 4096, 32768
 971    positions = np.arange(0, new, 64)
 972    slow = rope_frequencies(d)[-1]
 973    slow_ntk = rope_frequencies(d, ntk_scaled_base(10000, new / trained, d))[-1]
 974    fig, ax = plt.subplots(figsize=(6, 3.6))
 975    ax.axhspan(0, trained * slow, color=MUTED, alpha=0.3, label="angles seen in training")
 976    ax.plot(positions, positions * slow, color=RED, label="raw positions (extended)")
 977    ax.plot(positions, interpolate_positions(positions, trained, new) * slow, color=BLUE, label="position interpolation")
 978    ax.plot(positions, positions * slow_ntk, "--", color="black", label="NTK-aware base")
 979    ax.set_xlabel("position")
 980    ax.set_ylabel("angle of the slowest RoPE pair (rad)")
 981    ax.set_title("Keeping long contexts inside the trained range")
 982    ax.legend(frameon=False, fontsize=8)
 983    fig.tight_layout()
 984    figs["context_extension"] = fig
 985    return figs
 986
 987
 988# ---------------------------------------------------------------------------
 989# 6. Narrated walkthrough
 990# ---------------------------------------------------------------------------
 991
 992
 993def demo() -> None:
 994    banner("1. Attention without positions reads a bag of words")
 995    a = encode_sentence("dog bites man", "none")
 996    b = encode_sentence("man bites dog", "none")
 997    say(
 998        f"""
 999        One attention layer, no position information. The output for 'dog' is
1000        the same vector whether it comes first or last (max difference
1001        {np.abs(a[0] - b[2]).max():.1e}), and the pooled sentence vectors are
1002        identical (cosine {_cos(a.mean(0), b.mean(0)):.3f}).
1003        """
1004    )
1005    table(["scheme", "cosine(dog bites man, man bites dog)"], list(order_similarity().items()), floatfmt=".3f")
1006    takeaway("Without positions, attention sees a set: shuffle the input and the outputs just shuffle.")
1007
1008    banner("2. Sinusoidal codes: a clock with many hands")
1009    matrix("positions 0..2, d=4 (fast pair, then slow pair)", sinusoidal_encoding(3, 4))
1010    pe = sinusoidal_encoding(100, 32)
1011    say(
1012        f"""
1013        Similarity depends only on distance: PE(10)·PE(13) = {pe[10] @ pe[13]:.4f}
1014        and PE(70)·PE(73) = {pe[70] @ pe[73]:.4f}.
1015        """
1016    )
1017
1018    banner("3. RoPE by hand: q = k = (1, 0), one pair turning 1 rad per position")
1019    q = k = np.array([1.0, 0.0])
1020    rows = [(m, n, apply_rope(q, m) @ apply_rope(k, n)) for m, n in ((3, 7), (103, 107), (3, 8))]
1021    table(["query pos", "key pos", "score"], rows, floatfmt=".3f")
1022    say("Positions 3/7 and 103/107 give the same score, cos 4 = -0.654; moving the key one step changes it.")
1023    takeaway("RoPE rotates q and k by position, so attention scores depend only on relative distance.")
1024
1025    banner("4. Stretching a 4k model to 32k")
1026    say(
1027        f"""
1028        Position interpolation maps 32,000 to
1029        {interpolate_positions(np.array([32000]), 4096, 32768)[0]:.0f}. NTK-aware
1030        scaling raises the RoPE base from 10,000 to
1031        {ntk_scaled_base(10000, 8, 128):,.0f} for d=128: the fastest pair still turns
1032        1 rad per token, the slowest turns 8x slower.
1033        """
1034    )
1035
1036
1037if __name__ == "__main__":
1038    demo()
Level 3: the code, function by function.
def sinusoidal_encoding(n_positions: int, d: int, base: float = 10000.0) -> numpy.ndarray: on GitHub
770def sinusoidal_encoding(n_positions: int, d: int, base: float = 10000.0) -> np.ndarray:
771    """(n_positions, d) matrix of the original transformer's position codes.
772
773    Column 2i holds sin(pos · ω_i), column 2i+1 holds cos(pos · ω_i), with
774    ω_i = 1 / base^(2i/d). Frequencies fall geometrically from 1 (a full cycle
775    every ~6 tokens) to 1/base (one cycle every ~60,000 tokens).
776    """
777    assert d % 2 == 0, "dimensions come in (sin, cos) pairs"
778    pos = np.arange(n_positions)[:, None]  # (n, 1)
779    omega = base ** (-np.arange(0, d, 2) / d)  # (d/2,) frequencies
780    angles = pos * omega  # (n, d/2) via broadcasting
781    pe = np.empty((n_positions, d))
782    pe[:, 0::2] = np.sin(angles)
783    pe[:, 1::2] = np.cos(angles)
784    return pe

(n_positions, d) matrix of the original transformer's position codes.

Column 2i holds sin(pos · ω_i), column 2i+1 holds cos(pos · ω_i), with ω_i = 1 / base^(2i/d). Frequencies fall geometrically from 1 (a full cycle every ~6 tokens) to 1/base (one cycle every ~60,000 tokens).

def rope_frequencies(d: int, base: float = 10000.0) -> numpy.ndarray: on GitHub
792def rope_frequencies(d: int, base: float = 10000.0) -> np.ndarray:
793    """θ_i = base^(-2i/d) for each of the d/2 pairs: pair 0 turns fastest."""
794    assert d % 2 == 0, "RoPE rotates dimensions in pairs"
795    return base ** (-np.arange(0, d, 2) / d)

θ_i = base^(-2i/d) for each of the d/2 pairs: pair 0 turns fastest.

def apply_rope( x: numpy.ndarray, position: int | numpy.ndarray | None = None, base: float = 10000.0) -> numpy.ndarray: on GitHub
798def apply_rope(x: np.ndarray, position: int | np.ndarray | None = None, base: float = 10000.0) -> np.ndarray:
799    """Rotate each (even, odd) pair of `x` by position · θ_i.
800
801    Args:
802        x: one vector (d,) or a sequence (seq, d). In a real model this is
803            applied to q and k (per head) right before the dot product.
804        position: the token's position (int) or one per row. Defaults to
805            0..seq-1 for a sequence.
806        base: 10000 in the RoPE paper; raising it (NTK-aware scaling) slows
807            every rotation, one way to stretch a model to longer contexts.
808    """
809    x = np.asarray(x, dtype=float)
810    if position is None:
811        position = np.arange(x.shape[0]) if x.ndim == 2 else 0
812    pos = np.asarray(position, dtype=float)
813    # angles: (d/2,) for one vector, or (seq, d/2) for a sequence.
814    angles = pos[..., None] * rope_frequencies(x.shape[-1], base)
815    cos, sin = np.cos(angles), np.sin(angles)
816    even, odd = x[..., 0::2], x[..., 1::2]
817    out = np.empty_like(x)
818    # The 2x2 rotation [[cos, -sin], [sin, cos]] applied to every pair at once.
819    out[..., 0::2] = even * cos - odd * sin
820    out[..., 1::2] = even * sin + odd * cos
821    return out

Rotate each (even, odd) pair of x by position · θ_i.

Arguments:

  • x: one vector (d,) or a sequence (seq, d). In a real model this is applied to q and k (per head) right before the dot product.
  • position: the token's position (int) or one per row. Defaults to 0..seq-1 for a sequence.
  • base: 10000 in the RoPE paper; raising it (NTK-aware scaling) slows every rotation, one way to stretch a model to longer contexts.
D_DEMO = 16
def word_vector(word: str, d: int = 16) -> numpy.ndarray: on GitHub
834def word_vector(word: str, d: int = D_DEMO) -> np.ndarray:
835    """A fixed random embedding per word (a stand-in for a learned embedding table)."""
836    seed = int(hashlib.md5(word.encode()).hexdigest()[:8], 16)  # stable across runs, unlike hash()
837    return np.random.default_rng(seed).standard_normal(d)

A fixed random embedding per word (a stand-in for a learned embedding table).

def encode_sentence( sentence: str, scheme: str = 'none', causal: bool = False) -> numpy.ndarray: on GitHub
840def encode_sentence(sentence: str, scheme: str = "none", causal: bool = False) -> np.ndarray:
841    """Run one attention layer over `sentence` with a given position scheme.
842
843    Args:
844        sentence: the words, separated by spaces.
845        scheme: how position gets in. "none": embeddings only, so attention
846            sees an unordered set. "sinusoidal": add the sinusoidal code to
847            each embedding (the 2017 transformer). "rope": rotate q and k by
848            position inside attention (modern LLMs).
849        causal: when True, each word attends only to itself and earlier words.
850
851    Returns:
852        The (n_words, d) output vectors.
853    """
854    X = np.stack([word_vector(w) for w in sentence.split()])  # (n, d)
855    n = X.shape[0]
856    if scheme == "sinusoidal":
857        X = X + sinusoidal_encoding(n, D_DEMO)
858    Q, K, V = X @ _W_Q, X @ _W_K, X @ _W_V
859    if scheme == "rope":
860        Q, K = apply_rope(Q), apply_rope(K)  # only q and k are rotated; v carries plain content
861    out, _ = scaled_dot_product_attention(Q, K, V, mask=causal_mask(n) if causal else None)
862    return out

Run one attention layer over sentence with a given position scheme.

Arguments:

  • sentence: the words, separated by spaces.
  • scheme: how position gets in. "none": embeddings only, so attention sees an unordered set. "sinusoidal": add the sinusoidal code to each embedding (the 2017 transformer). "rope": rotate q and k by position inside attention (modern LLMs).
  • causal: when True, each word attends only to itself and earlier words.

Returns:

The (n_words, d) output vectors.

def interpolate_positions( positions: numpy.ndarray, trained_len: int, new_len: int) -> numpy.ndarray: on GitHub
870def interpolate_positions(positions: np.ndarray, trained_len: int, new_len: int) -> np.ndarray:
871    """Position interpolation: scale positions by trained_len / new_len.
872
873    A 4k-trained model run at 32k sees position 32,000 as 4,000: an angle it
874    knows. The price is that neighbouring tokens are now only 1/8 of a
875    position apart, which a short fine-tune teaches the model to resolve.
876    """
877    return np.asarray(positions, dtype=float) * (trained_len / new_len)

Position interpolation: scale positions by trained_len / new_len.

A 4k-trained model run at 32k sees position 32,000 as 4,000: an angle it knows. The price is that neighbouring tokens are now only 1/8 of a position apart, which a short fine-tune teaches the model to resolve.

def ntk_scaled_base(base: float, scale: float, d: int) -> float: on GitHub
880def ntk_scaled_base(base: float, scale: float, d: int) -> float:
881    """NTK-aware RoPE scaling: raise the base instead of squeezing positions.
882
883    base' = base · scale^(d/(d-2)). The fastest pair (θ_0 = 1) is unchanged,
884    so local word order stays sharp, while the slowest pair slows by exactly
885    `scale`, so distant positions fit into the trained range of angles.
886    """
887    return base * scale ** (d / (d - 2))

NTK-aware RoPE scaling: raise the base instead of squeezing positions.

base' = base · scale^(d/(d-2)). The fastest pair (θ_0 = 1) is unchanged, so local word order stays sharp, while the slowest pair slows by exactly scale, so distant positions fit into the trained range of angles.

def order_similarity() -> dict[str, float]: on GitHub
899def order_similarity() -> dict[str, float]:
900    """Cosine similarity of pooled 'dog bites man' vs 'man bites dog' per scheme."""
901    out = {}
902    for label, scheme, causal in [
903        ("no positions", "none", False),
904        ("sinusoidal", "sinusoidal", False),
905        ("RoPE", "rope", False),
906        ("causal mask only", "none", True),
907    ]:
908        a = encode_sentence("dog bites man", scheme, causal).mean(axis=0)
909        b = encode_sentence("man bites dog", scheme, causal).mean(axis=0)
910        out[label] = _cos(a, b)
911    return out

Cosine similarity of pooled 'dog bites man' vs 'man bites dog' per scheme.

def figures() -> dict: on GitHub
914def figures() -> dict:
915    """Plot this lesson's data. matplotlib is imported here, and only here."""
916    import matplotlib
917
918    matplotlib.use("Agg")
919    import matplotlib.pyplot as plt
920
921    BLUE, RED, MUTED = "#2563eb", "#dc2626", "#9ca3af"
922    figs = {}
923
924    sims = order_similarity()
925    fig, ax = plt.subplots(figsize=(6, 3.4))
926    colors = [RED] + [BLUE] * (len(sims) - 1)
927    ax.bar(list(sims), list(sims.values()), color=colors)
928    for i, v in enumerate(sims.values()):
929        ax.text(i, v + 0.01, f"{v:.3f}", ha="center")
930    ax.set_ylim(min(sims.values()) - 0.1, 1.05)
931    ax.set_ylabel("cosine similarity of pooled outputs")
932    ax.set_title("'dog bites man' vs 'man bites dog'")
933    fig.tight_layout()
934    figs["order_blindness"] = fig
935
936    fig, ax = plt.subplots(figsize=(6, 4.5))
937    im = ax.imshow(sinusoidal_encoding(200, 64), aspect="auto", cmap="RdBu", vmin=-1, vmax=1)
938    ax.set_xlabel("dimension (left: fast hands, right: slow hands)")
939    ax.set_ylabel("position")
940    ax.set_title("Sinusoidal position codes")
941    fig.colorbar(im, ax=ax, label="value")
942    fig.tight_layout()
943    figs["sinusoidal_heatmap"] = fig
944
945    rng = np.random.default_rng(0)
946    q, k = rng.standard_normal(64), rng.standard_normal(64)
947    dist = np.arange(0, 60)
948    fig, ax = plt.subplots(figsize=(6, 3.6))
949    for start, style in ((0, "-"), (100, "--"), (1000, ":")):
950        scores = [apply_rope(q, start) @ apply_rope(k, start + d) for d in dist]
951        ax.plot(dist, scores, style, lw=2, label=f"query at position {start}")
952    ax.set_xlabel("distance between key and query (n − m)")
953    ax.set_ylabel("attention score q·k")
954    ax.set_title("With RoPE the score depends only on distance")
955    ax.legend(frameon=False)
956    fig.tight_layout()
957    figs["rope_relative"] = fig
958
959    pos = np.arange(0, 200)
960    theta = rope_frequencies(64)
961    fig, ax = plt.subplots(figsize=(6, 3.6))
962    for i in (0, 4, 12, 31):
963        ax.plot(pos, np.mod(pos * theta[i], 2 * np.pi), lw=1.5, label=f"pair {i} (θ={theta[i]:.2g})")
964    ax.set_xlabel("position")
965    ax.set_ylabel("rotation angle (mod 2π)")
966    ax.set_title("RoPE: fast hands for local order, slow hands for long range")
967    ax.legend(frameon=False, fontsize=8)
968    fig.tight_layout()
969    figs["rope_frequencies"] = fig
970
971    d, trained, new = 128, 4096, 32768
972    positions = np.arange(0, new, 64)
973    slow = rope_frequencies(d)[-1]
974    slow_ntk = rope_frequencies(d, ntk_scaled_base(10000, new / trained, d))[-1]
975    fig, ax = plt.subplots(figsize=(6, 3.6))
976    ax.axhspan(0, trained * slow, color=MUTED, alpha=0.3, label="angles seen in training")
977    ax.plot(positions, positions * slow, color=RED, label="raw positions (extended)")
978    ax.plot(positions, interpolate_positions(positions, trained, new) * slow, color=BLUE, label="position interpolation")
979    ax.plot(positions, positions * slow_ntk, "--", color="black", label="NTK-aware base")
980    ax.set_xlabel("position")
981    ax.set_ylabel("angle of the slowest RoPE pair (rad)")
982    ax.set_title("Keeping long contexts inside the trained range")
983    ax.legend(frameon=False, fontsize=8)
984    fig.tight_layout()
985    figs["context_extension"] = fig
986    return figs

Plot this lesson's data. matplotlib is imported here, and only here.

def demo() -> None: on GitHub
 994def demo() -> None:
 995    banner("1. Attention without positions reads a bag of words")
 996    a = encode_sentence("dog bites man", "none")
 997    b = encode_sentence("man bites dog", "none")
 998    say(
 999        f"""
1000        One attention layer, no position information. The output for 'dog' is
1001        the same vector whether it comes first or last (max difference
1002        {np.abs(a[0] - b[2]).max():.1e}), and the pooled sentence vectors are
1003        identical (cosine {_cos(a.mean(0), b.mean(0)):.3f}).
1004        """
1005    )
1006    table(["scheme", "cosine(dog bites man, man bites dog)"], list(order_similarity().items()), floatfmt=".3f")
1007    takeaway("Without positions, attention sees a set: shuffle the input and the outputs just shuffle.")
1008
1009    banner("2. Sinusoidal codes: a clock with many hands")
1010    matrix("positions 0..2, d=4 (fast pair, then slow pair)", sinusoidal_encoding(3, 4))
1011    pe = sinusoidal_encoding(100, 32)
1012    say(
1013        f"""
1014        Similarity depends only on distance: PE(10)·PE(13) = {pe[10] @ pe[13]:.4f}
1015        and PE(70)·PE(73) = {pe[70] @ pe[73]:.4f}.
1016        """
1017    )
1018
1019    banner("3. RoPE by hand: q = k = (1, 0), one pair turning 1 rad per position")
1020    q = k = np.array([1.0, 0.0])
1021    rows = [(m, n, apply_rope(q, m) @ apply_rope(k, n)) for m, n in ((3, 7), (103, 107), (3, 8))]
1022    table(["query pos", "key pos", "score"], rows, floatfmt=".3f")
1023    say("Positions 3/7 and 103/107 give the same score, cos 4 = -0.654; moving the key one step changes it.")
1024    takeaway("RoPE rotates q and k by position, so attention scores depend only on relative distance.")
1025
1026    banner("4. Stretching a 4k model to 32k")
1027    say(
1028        f"""
1029        Position interpolation maps 32,000 to
1030        {interpolate_positions(np.array([32000]), 4096, 32768)[0]:.0f}. NTK-aware
1031        scaling raises the RoPE base from 10,000 to
1032        {ntk_scaled_base(10000, 8, 128):,.0f} for d=128: the fastest pair still turns
1033        1 rad per token, the slowest turns 8x slower.
1034        """
1035    )