Skip to content

Positional encodings: sinusoidal, learned

ModuleL5.4 · build · Python · Pass 5 · 2 h, plus your graded tests (rung R5)
You buildpython/tinyllm/xfmr/pos.py: sinusoidal_freqs, sinusoidal_pe, shift_pe, SinusoidalPE, LearnedPE; and your own oracle tests in python/tests/l5-4-pos/
Contractcourse/contracts/py/tinyllm/xfmr/pos.pyi
Testscourse/tests/L5.4/test_pos.py (what they check: section 4); the oracle is mpmath at 50 digits; your tests are graded by mutation, threshold 0.80 with every pitfall fault required
NeedsM00.2 rotate_pairs · M00.3 geometric · M07.3 normal_init · M06.3 PCG32 · L0.1 Tensor · L0.2 embedding · L0.4 Module (or --ref-deps)
Used byL5.5 the transformer’s sinusoidal positions · L6.1 GPT-2’s learned wpe · L6.2 BERT’s learned position embeddings
MilestoneMS-L5 (the addition model reads digit positions)
Optional depthVaswani et al. (2017), section 3.5; Kazemnejad, “Transformer Architecture: The Positional Encoding” (2019); Su et al., “RoFormer” (2021), for where the rotation goes next
  • Attention sees its keys as a set, so position must be added to the input: xp=e(tokenp)+PE(p)x_p = e(\text{token}_p) + \text{PE}(p) (test_sinusoidal_module_has_no_parameters).
  • Pair ii of PE(p)\text{PE}(p) is the point (sin⁡p ωi,cos⁡p ωi)(\sin p\,\omega_i, \cos p\,\omega_i) with ωi=10000−2i/d\omega_i = 10000^{-2i/d}, a geometric ladder from 1 radian per position down to about 10−410^{-4} (test_hand_example_table, test_golden_mpmath).
  • Moving kk positions is a fixed rotation of every pair, the same for every pp (test_shift_is_a_fixed_rotation), so PE(p)⋅PE(p+k)\text{PE}(p) \cdot \text{PE}(p + k) depends only on kk (test_dot_product_depends_only_on_the_offset).
  • Angles must be computed in float64: at p=104p = 10^4 a float32 angle is already off in the fourth decimal (test_golden_mpmath).
  • A learned table is just an embedding indexed by position: it adds rows offset .. and only those rows get gradient (test_learned_pe_adds_rows_and_gets_their_gradient).
Terminal window
ol start L5.4 # stubs pos.py; prints your test path and rung (R5)
ol tests L5.4 # the course tests
ol check L5.4 # course tests and the mutation grade of your tests
ol diff L5.4 # after passing: your code against the reference

Your multi-head attention (L5.3) gives the same output for “321+654” and “123+456” shuffled into any order of keys: permuting the keys changes nothing (L5.3’s test_cross_attention_reads_keys_from_x_kv proves it). For addition that is fatal, because which digit is the ones digit is all a matter of position. The RNNs of Parts 3 and 4 knew order for free, by reading one token at a time; the transformer reads all tokens at once and has to be told. This module builds the two ways the course uses: the fixed sinusoidal table of the 2017 transformer (L5.5), whose shift property is the seed of RoPE in Part 7, and the learned table of GPT-2 (L6.1) and BERT (L6.2).

SymbolMeaningType / shape
ppposition, counted from 0int
ddmodel width, evenint
iipair index, 0≤i<d/20 \le i < d/2int
bbbase, 10000 in the paperfloat
ωi=b−2i/d\omega_i = b^{-2i/d}angular frequency of pair ii (radians per position)float64[d/2]
PE(p)\text{PE}(p)the encoding of position ppfloat32[d]
R(θ)R(\theta)the 2D rotation by θ\theta (M00.2)float64[2, 2]

PE(p)2i=sin⁡(p ωi),PE(p)2i+1=cos⁡(p ωi),ωi=b−2i/d.\text{PE}(p)_{2i} = \sin(p\,\omega_i), \qquad \text{PE}(p)_{2i+1} = \cos(p\,\omega_i), \qquad \omega_i = b^{-2i/d}.

The frequencies form a geometric sequence with first term 1 and ratio b−2/db^{-2/d} (M00.3’s geometric). Pair 0 turns one radian per position and repeats every 2π2\pi positions; the last pair turns about b−1b^{-1} radians per position and repeats every 2πb2\pi b, about 63 000 positions for b=104b = 10^4. Like the digits of a clock with hands of geometric speeds, the fast pairs tell neighbours apart and the slow ones tell far positions apart. Each pair lies on the unit circle, so ∥PE(p)∥=d/2\lVert\text{PE}(p)\rVert = \sqrt{d/2} for every pp: unlike adding pp itself, the signal never grows with the sequence. The columns are interleaved: sin at even columns, cos at odd ones, the layout of the paper and of M00.2’s pairs.

Precision: the angle p ωip\,\omega_i for p=104p = 10^4 is about 10410^4 radians. float32 keeps about 7 significant digits, so the angle is off by roughly 104×6×10−8≈6×10−410^4 \times 6 \times 10^{-8} \approx 6 \times 10^{-4} rad before the sine is even taken. The table is computed in float64 and rounded to float32 at the end.

By the angle-addition formulas (M00.2),

(sin⁡((p+k)ω)cos⁡((p+k)ω))=(cos⁡kωsin⁡kω−sin⁡kωcos⁡kω)(sin⁡pωcos⁡pω)=R(−kω)(sin⁡pωcos⁡pω).\begin{pmatrix}\sin((p+k)\omega)\\ \cos((p+k)\omega)\end{pmatrix} = \begin{pmatrix}\cos k\omega & \sin k\omega\\ -\sin k\omega & \cos k\omega\end{pmatrix}\begin{pmatrix}\sin p\omega\\ \cos p\omega\end{pmatrix} = R(-k\omega)\begin{pmatrix}\sin p\omega\\ \cos p\omega\end{pmatrix}.

So PE(p+k)\text{PE}(p + k) is PE(p)\text{PE}(p) with pair ii turned by −kωi-k\omega_i: one block-diagonal rotation that depends on kk and not on pp. shift_pe(pe, k, d) is rotate_pairs(pe, -k * omega). Two consequences: a linear layer can learn to “look kk back” the same way at every position, and PE(p)⋅PE(p+k)=∑icos⁡(k ωi)\text{PE}(p) \cdot \text{PE}(p+k) = \sum_i \cos(k\,\omega_i) depends only on the distance. RoPE (L7.3) applies exactly this rotation to the queries and keys instead of adding it to the input.

GPT-2 and BERT learn a table P∈RL×dP \in \mathbb{R}^{L \times d} (max_len rows) and add row pp at position pp. It is an embedding indexed by position (L0.2’s embedding), initialized N(0,0.022)\mathcal{N}(0, 0.02^2) (M07.3), and only the rows actually used get gradient. It can learn any pattern, but it knows nothing about positions past LL: a model trained on 1024 tokens has no row 1024.

Both modules add positions offset .. offset + T - 1 to an input of length TT. During training offset is 0. When decoding one token at a time with a cache (L8.2), the new token sits at position offset = the number of tokens before it, and must get that row, not row 0.

d=4d = 4, b=10000b = 10000: ω=(100000,10000−1/2)=(1,0.01)\omega = (10000^0, 10000^{-1/2}) = (1, 0.01).

ppsin⁡p\sin pcos⁡p\cos psin⁡0.01p\sin 0.01pcos⁡0.01p\cos 0.01p
00101
10.8414710.5403020.0100000.999950
20.909297-0.4161470.0199990.999800

Shift check, pair 0, k=1k = 1: R(−1)R(-1) applied to (sin⁡1,cos⁡1)=(0.841471,0.540302)(\sin 1, \cos 1) = (0.841471, 0.540302) gives (0.841471cos⁡1+0.540302sin⁡1,  −0.841471sin⁡1+0.540302cos⁡1)=(0.454649+0.454649,  −0.708073+0.291927)=(0.909297,−0.416147)(0.841471 \cos 1 + 0.540302 \sin 1,\; -0.841471 \sin 1 + 0.540302 \cos 1) = (0.454649 + 0.454649,\; -0.708073 + 0.291927) = (0.909297, -0.416147), the p=2p = 2 row. These are test_hand_example_table and test_hand_example_shift.

def sinusoidal_freqs(d: int, base: float = 10000.0) -> NDArray # float64 [d/2]
def sinusoidal_pe(T: int, d: int, base: float = 10000.0, offset: int = 0) -> NDArray # float32 [T, d]
def shift_pe(pe, k: float, d: int, base: float = 10000.0) -> NDArray
class SinusoidalPE(Module): # no parameters; forward(x, offset=0) = x + table[offset : offset + T]
class LearnedPE(Module): # weight [max_len, d]; positions(T, offset); forward(x, offset=0)
TestKINDChecksWhy it matters downstream
test_hand_example_tableunitsection 3: frequencies (1, 0.01) and the three rowsyou and the test agree on layout and ladder
test_hand_example_shiftunitturning PE(1) by −ω-\omega gives PE(2)the rotation’s sign
test_golden_mpmathgoldenthe table at positions up to 65535 for two settings against 50-digit mpmathlong contexts keep exact positions
test_shift_is_a_fixed_rotationpropertyone R(k)R(k) for every pp, negative kk toothe relative-position property RoPE builds on
test_dot_product_depends_only_on_the_offsetpropertyPE(p)⋅PE(p+k)=∑icos⁡kωi\text{PE}(p)\cdot\text{PE}(p+k) = \sum_i \cos k\omega_isimilarity by distance
test_every_pair_is_on_the_unit_circlepropertyeach pair has norm 1the signal does not grow with pp
test_offset_continues_the_tablepropertyoffset gives the tail of a longer table; the module honours itincremental decoding (L8.2)
test_sinusoidal_module_has_no_parametersunitempty state_dict; gradient passes through to xxnothing to train or checkpoint
test_learned_pe_adds_rows_and_gets_their_gradientunitrows offset.. added; only those get gradientGPT-2’s and BERT’s tables train
test_learned_pe_initstatisticalseeded N(0,std2)\mathcal{N}(0, \text{std}^2), default 0.02the same init in every language
test_validationboundaryodd dd, base ≤1\le 1, positions past the tableno silent wrap-around

Your oracle is the formula itself: compute sin⁡(p/b2i/d)\sin(p / b^{2i/d}) and cos⁡(⋅)\cos(\cdot) with Python’s math in float64 for a few positions, including large ones (p≥104p \ge 10^4), and compare with sinusoidal_pe. Test the shift law, the offset, and the learned table’s rows and gradient. ol check L5.4 requires 0.80 with the pitfall faults killed.

PitfallSymptomCaught by
1. sin and cos concatenated (or swapped) instead of interleavedthe shift law fails; checkpoints from other code disagreetest_hand_example_table, test_golden_mpmath (mutants s01, s03)
2. exponent i/di/d instead of 2i/d2i/dthe ladder ends at b−1/2b^{-1/2}: far positions aliastest_golden_mpmath (mutant s02)
3. angles in float32rows past a few thousand off in the 4th decimaltest_golden_mpmath (mutant s07)
4. the shift turned the wrong way, or by the same angle for every pairshift_pe goes back in position, or scrambles pairstest_hand_example_shift, test_shift_is_a_fixed_rotation (mutants s04, s06)
5. positions counted from 1every row one late; HF and torch disagreetest_hand_example_table (mutant s05)
6. offset ignoredincremental decoding gives every new token position 0test_offset_continues_the_table, test_learned_pe_adds_rows_and_gets_their_gradient (mutants s08, s10)
7. the fixed table registered as a parameterit trains and lands in the checkpointtest_sinusoidal_module_has_no_parameters (mutant s09)
8. learned rows read as constantswpe never learnstest_learned_pe_adds_rows_and_gets_their_gradient (mutant s11)
std ignored by the learned tableevery table starts at 0.02test_learned_pe_init (mutant s12)
an odd width acceptedthe last feature has no partnertest_validation (mutant s13)
DirectionModuleHow it uses this
BackM00.3geometric(1, b^{-2/d}, d/2) is the frequency ladder
BackM00.2rotate_pairs is the shift
BackM07.3normal_init for the learned table
BackM06.3PCG32, the init stream
BackL0.2embedding reads learned rows with their gradient
BackL0.1the Tensor the table is added to
BackL0.4Module for both encodings
ForwardL5.5SinusoidalPE on both the source and the target embeddings
ForwardL6.1LearnedPE is GPT-2’s wpe
ForwardL6.2LearnedPE is BERT’s position embedding
ForwardL7.3RoPE rotates queries and keys by the same ladder instead
Your pieceProduction equivalentWhat it addsWhere to look
sinusoidal_pefairseq SinusoidalPositionalEmbeddingthe half layout (all sin, then all cos) and a padding offsetfairseq/modules/sinusoidal_positional_embedding.py
shift_peRoPEthe rotation applied inside attention, so scores depend on p−qp - q onlyL7.3; Su et al. 2021
LearnedPEHF GPT2Model.wpe, BertEmbeddings.position_embeddingsabsolute positions capped at n_positionstransformers/models/gpt2/modeling_gpt2.py
no position at allALiBi, NoPEa distance penalty on scores, or causality alone, for length extrapolationPress et al. 2022; Kazemnejad et al. 2023