Skip to content

Trig, unit circle, 2D rotations, complex numbers, Euler's formula

ModuleM00.2 · build · Python · Pass 2 · 3 to 4 h
You buildpython/tinyllm/num/rotation.py: rotation_matrix, rotate_pairs, as_complex, as_real
Contractcourse/contracts/py/tinyllm/num/rotation.pyi
Testscourse/tests/M00.2/test_rotation.py (what they check: section 4)
Needsnothing to call. Reading: M00.1 (exponents and ee), lang.01 (numpy slicing and broadcasting)
Used byL7.3 RoPE rotates query and key pairs with rotate_pairs · L5.4 sinusoidal positional encoding · M07.0 Box-Muller normals use cos⁡\cos and sin⁡\sin of 2πu2\pi u (none is authored yet: section 6)
MilestoneMS-P2 (the Pass 2 gate)
Optional depthOpenStax, Precalculus 2e (free), ch. 5 to 7 (trigonometric functions and identities) and 8.5 (polar form of complex numbers); 3Blue1Brown, “Euler’s formula with introductory group theory” (video); Su et al., “RoFormer: Enhanced Transformer with Rotary Position Embedding” (2021), section 3.2
  • A point on the unit circle at angle θ\theta (in radians) is (cos⁡θ,sin⁡θ)(\cos\theta, \sin\theta), so cos⁡2θ+sin⁡2θ=1\cos^2\theta + \sin^2\theta = 1 (test_rotation_matrix_hand_values).
  • Rotating the plane by θ\theta is the matrix R(θ)=(cos⁡θ−sin⁡θsin⁡θcos⁡θ)R(\theta) = \begin{pmatrix}\cos\theta & -\sin\theta \\ \sin\theta & \cos\theta\end{pmatrix}. It keeps every length, and angles add: R(a)R(b)=R(a+b)R(a)R(b) = R(a + b) (test_length_is_preserved, test_angles_add).
  • Because angles add, the dot product of two rotated vectors depends only on the difference of their angles. RoPE turns token positions into angles and gets attention scores that depend only on relative position (test_dot_product_sees_only_relative_angle).
  • Reading (x,y)(x, y) as the complex number x+iyx + iy, Euler’s formula eiθ=cos⁡θ+isin⁡θe^{i\theta} = \cos\theta + i\sin\theta makes the same rotation one multiplication (test_euler_formula_is_the_rotation, test_golden_torch_polar).
  • Pairs are interleaved, (x2i,x2i+1)(x_{2i}, x_{2i+1}). Pairing xix_i with xi+kx_{i+k} instead is a different model with the same shapes (test_layout_is_interleaved).
Terminal window
ol start M00.2 # stubs python/tinyllm/num/rotation.py into your repo
ol tests M00.2 # read the test catalog first: rung R0, you write no tests here
ol check M00.2 # exit code is the verdict
ol diff M00.2 # after passing: your code against the reference

Your bigram model sees one previous byte, so the order of a text matters to it only through adjacent pairs. The models of Passes 4 and 5 read whole contexts with attention, and attention on its own is blind to order: it scores every pair of positions with a dot product of two vectors, and shuffling the tokens shuffles the scores without changing any of them. “The dog bit the man” and “the man bit the dog” would get the same representation. Two fixes in the course add position back, and both are rotations. L5.4, the 2017 Transformer’s sinusoidal encoding, writes sin⁡\sin and cos⁡\cos of position-dependent angles into the input. L7.3, RoPE, the scheme in Llama and SmolLM2, rotates each pair of coordinates of every query and key by an angle proportional to its position. This module builds rotations from the unit circle up, proves the property RoPE depends on, and gives you rotate_pairs, which L7.3 calls. M07.0 uses the same cos⁡\cos and sin⁡\sin to turn uniform random numbers into normal ones.

SymbolMeaningType / shape
θ,a,b\theta, a, bangles in radiansfloat
π\pihalf the circumference of a circle of radius 1, 3.14159…3.14159\ldotsmath.pi
cos⁡θ,sin⁡θ\cos\theta, \sin\thetathe coordinates of the point at angle θ\theta on the unit circlefloat
u,vu, vvectors in the plane, u=(u0,u1)u = (u_0, u_1)float[2]
⟨u,v⟩\langle u, v\rangledot product u0v0+u1v1u_0 v_0 + u_1 v_1float
∥u∥\lVert u\rVertlength u02+u12\sqrt{u_0^2 + u_1^2}float
R(θ)R(\theta)the 2×22 \times 2 rotation matrix by θ\thetafloat[2, 2]
iithe imaginary unit, i2=−1i^2 = -11j
z=x+iyz = x + iya complex number with real part xx and imaginary part yycomplex
zˉ\bar zthe conjugate x−iyx - iycomplex
∣z∣\lvert z\rvertthe modulus x2+y2\sqrt{x^2 + y^2}float
kkthe number of pairs in a vector of length 2k2kint
m,nm, ntoken positionsint
ω\omegaan angular frequency: radians per positionfloat

Walk along the circle of radius 1 centred at the origin, starting at (1,0)(1, 0) and going counterclockwise. The radian measure of an angle is the distance walked. A full turn is the whole circumference, 2π2\pi; a half turn is π\pi; a right angle is π/2\pi/2. Degrees convert by 180°=π180° = \pi, so 60°=π/360° = \pi/3. Every function in this module, and numpy’s np.cos and np.sin, takes radians. Negative angles walk clockwise, and adding 2π2\pi lands on the same point, so trigonometric functions repeat with period 2π2\pi.

The point reached at angle θ\theta has coordinates (cos⁡θ,sin⁡θ)(\cos\theta, \sin\theta): that is the definition of cosine and sine. Because the point is at distance 1 from the origin, Pythagoras gives

cos⁡2θ+sin⁡2θ=1.\cos^2\theta + \sin^2\theta = 1 .

Reflection across the horizontal axis gives cos⁡(−θ)=cos⁡θ\cos(-\theta) = \cos\theta and sin⁡(−θ)=−sin⁡θ\sin(-\theta) = -\sin\theta. A few values come from familiar triangles:

θ\theta0π/6\pi/6π/4\pi/4π/3\pi/3π/2\pi/2π\pi
cos⁡θ\cos\theta13/2\sqrt3/22/2\sqrt2/21/21/20−1-1
sin⁡θ\sin\theta01/21/22/2\sqrt2/23/2\sqrt3/210

Rotating the plane by θ\theta about the origin is linear: rotating a sum is the sum of the rotations, and rotating c uc\,u is cc times the rotation of uu. So it is enough to know where the two basis vectors go. (1,0)(1, 0) goes to (cos⁡θ,sin⁡θ)(\cos\theta, \sin\theta) by definition, and (0,1)(0, 1), a quarter turn further, goes to (cos⁡(θ+π/2),sin⁡(θ+π/2))=(−sin⁡θ,cos⁡θ)(\cos(\theta + \pi/2), \sin(\theta + \pi/2)) = (-\sin\theta, \cos\theta). Those images are the columns of the matrix:

R(θ)=(cos⁡θ−sin⁡θsin⁡θcos⁡θ),R(θ)(xy)=(xcos⁡θ−ysin⁡θxsin⁡θ+ycos⁡θ).R(\theta) = \begin{pmatrix}\cos\theta & -\sin\theta \\ \sin\theta & \cos\theta\end{pmatrix}, \qquad R(\theta)\begin{pmatrix}x\\y\end{pmatrix} = \begin{pmatrix}x\cos\theta - y\sin\theta\\ x\sin\theta + y\cos\theta\end{pmatrix}.

A rotation keeps lengths. Expanding, (xcos⁡θ−ysin⁡θ)2+(xsin⁡θ+ycos⁡θ)2=(x2+y2)(cos⁡2θ+sin⁡2θ)=x2+y2(x\cos\theta - y\sin\theta)^2 + (x\sin\theta + y\cos\theta)^2 = (x^2 + y^2)(\cos^2\theta + \sin^2\theta) = x^2 + y^2, because the cross terms ∓2xycos⁡θsin⁡θ\mp 2xy\cos\theta\sin\theta cancel. It also keeps dot products: ⟨Ru,Rv⟩=⟨u,v⟩\langle R u, R v\rangle = \langle u, v\rangle. In matrix terms R(θ)⊤R(θ)=IR(\theta)^\top R(\theta) = I, and R(θ)⊤=R(−θ)R(\theta)^\top = R(-\theta) undoes the rotation.

Rotating by bb and then by aa is a rotation by a+ba + b, so R(a)R(b)=R(a+b)R(a)R(b) = R(a + b). Multiplying the two matrices out and comparing the first column with R(a+b)R(a + b)‘s gives the angle-addition formulas:

cos⁡(a+b)=cos⁡acos⁡b−sin⁡asin⁡b,sin⁡(a+b)=sin⁡acos⁡b+cos⁡asin⁡b.\cos(a + b) = \cos a\cos b - \sin a\sin b, \qquad \sin(a + b) = \sin a\cos b + \cos a\sin b .

With b=−ab = -a: R(a)R(−a)=R(0)=IR(a)R(-a) = R(0) = I.

Rotate uu by α\alpha and vv by β\beta, then take the dot product. Using ⟨Ru,w⟩=⟨u,R⊤w⟩\langle R u, w\rangle = \langle u, R^\top w\rangle and composition,

⟨R(α)u, R(β)v⟩=⟨u, R(−α)R(β)v⟩=⟨u, R(β−α)v⟩.\langle R(\alpha)u,\ R(\beta)v\rangle = \langle u,\ R(-\alpha)R(\beta)v\rangle = \langle u,\ R(\beta - \alpha)v\rangle .

The result depends on α\alpha and β\beta only through β−α\beta - \alpha. RoPE sets α=mω\alpha = m\omega for a query at position mm and β=nω\beta = n\omega for a key at position nn, so the attention score ⟨R(mω)q,R(nω)k⟩\langle R(m\omega)q, R(n\omega)k\rangle depends only on n−mn - m, the distance between the tokens: shifting a sentence along the context changes nothing. One frequency ω\omega would make positions 2π/ω2\pi/\omega apart indistinguishable, so RoPE splits a 2k2k-dimensional query into kk pairs and rotates pair ii by mωim\omega_i with a ladder of frequencies ωi\omega_i (built in M00.3). rotate_pairs(x, theta) is exactly that operation, with theta[..., i] the angle of pair ii, and pair ii is (x2i,x2i+1)(x_{2i}, x_{2i+1}), the interleaved layout of the RoFormer paper and Meta’s Llama code. Hugging Face’s Llama pairs xix_i with xi+kx_{i+k} instead (the “half” layout of rotate_half); L7.3 implements both and converts between them.

A complex number z=x+iyz = x + iy is a point (x,y)(x, y) of the plane with a multiplication: expand as polynomials in ii and replace i2i^2 by −1-1.

(x+iy)(c+is)=(xc−ys)+i(xs+yc).(x + iy)(c + is) = (xc - ys) + i(xs + yc) .

Compare with section 2.3: with c=cos⁡θc = \cos\theta and s=sin⁡θs = \sin\theta, the right-hand side is exactly R(θ)(x,y)R(\theta)(x, y). Multiplying by cos⁡θ+isin⁡θ\cos\theta + i\sin\theta rotates by θ\theta. The modulus ∣z∣=x2+y2\lvert z\rvert = \sqrt{x^2 + y^2} is the length, and zzˉ=∣z∣2z\bar z = \lvert z\rvert^2.

Euler’s formula names that rotating number:

eiθ=cos⁡θ+isin⁡θ.e^{i\theta} = \cos\theta + i\sin\theta .

Why an exponential? Because it obeys the law of exponents from M00.1: eiaeib=ei(a+b)e^{ia}e^{ib} = e^{i(a + b)} is the composition rule of section 2.4, now one line. M02.1 proves the formula by comparing the Taylor series of exe^x, cos⁡x\cos x, and sin⁡x\sin x. Consequences used in the solve set: eiπ=−1e^{i\pi} = -1, eiπ/2=ie^{i\pi/2} = i, (eiθ)n=einθ(e^{i\theta})^n = e^{in\theta} (De Moivre), cos⁡θ=(eiθ+e−iθ)/2\cos\theta = (e^{i\theta} + e^{-i\theta})/2, and Re⁡(eia eib‾)=cos⁡(a−b)\operatorname{Re}(e^{ia}\,\overline{e^{ib}}) = \cos(a - b), the relative-angle property of section 2.5 in complex form.

as_complex reads a real vector of length 2k2k as kk complex numbers zi=x2i+i x2i+1z_i = x_{2i} + i\,x_{2i+1}, the numpy analogue of torch.view_as_complex; as_real reads them back. With them, rotate_pairs(x, t) equals as_real(as_complex(x) * np.exp(1j * t)).

Two pairs, two angles. x=[3,4,1,0]x = [3, 4, 1, 0] has pairs (3,4)(3, 4) and (1,0)(1, 0). Rotate pair 0 by the angle t0t_0 with cos⁡t0=3/5\cos t_0 = 3/5 and sin⁡t0=4/5\sin t_0 = 4/5 (so t0=atan2⁡(4,3)≈0.9273t_0 = \operatorname{atan2}(4, 3) \approx 0.9273 radians, about 53.13°53.13°), and pair 1 by π/2\pi/2.

pair(x,y)(x, y)cos⁡,sin⁡\cos, \sinxcos⁡−ysin⁡x\cos - y\sinxsin⁡+ycos⁡x\sin + y\cos
0(3,4)(3, 4)0.6,0.80.6, 0.81.8−3.2=−1.41.8 - 3.2 = -1.42.4+2.4=4.82.4 + 2.4 = 4.8
1(1,0)(1, 0)0,10, 10−0=00 - 0 = 01+0=11 + 0 = 1

So rotate_pairs([3, 4, 1, 0], [t0, pi/2]) is [−1.4,4.8,0,1][-1.4, 4.8, 0, 1] (test_hand_example_rotation; numpy gives 6×10−176 \times 10^{-17} instead of the exact 0, because cos⁡(π/2)\cos(\pi/2) in floating point is not exactly 0). Length check: 1.96+23.04=5=9+16\sqrt{1.96 + 23.04} = 5 = \sqrt{9 + 16}.

The same with complex numbers. as_complex([3, 4, 1, 0]) is [3+4i, 1+0i][3 + 4i,\ 1 + 0i]. Then

(3+4i)(0.6+0.8i)=1.8+2.4i+2.4i+3.2 i2=−1.4+4.8i,(1+0i)⋅i=i,(3 + 4i)(0.6 + 0.8i) = 1.8 + 2.4i + 2.4i + 3.2\,i^2 = -1.4 + 4.8i, \qquad (1 + 0i)\cdot i = i ,

and as_real returns [−1.4,4.8,0,1][-1.4, 4.8, 0, 1]: test_hand_example_as_complex_product.

Composition. R(π/2)R(π/3)R(\pi/2)R(\pi/3): multiply (0−110)(1/2−3/23/21/2)=(−3/2−1/21/2−3/2)\begin{pmatrix}0 & -1\\ 1 & 0\end{pmatrix}\begin{pmatrix}1/2 & -\sqrt3/2\\ \sqrt3/2 & 1/2\end{pmatrix} = \begin{pmatrix}-\sqrt3/2 & -1/2\\ 1/2 & -\sqrt3/2\end{pmatrix}, whose first column (cos⁡,sin⁡)=(−3/2,1/2)(\cos, \sin) = (-\sqrt3/2, 1/2) is the point at 5π/6=π/2+π/35\pi/6 = \pi/2 + \pi/3.

python/tinyllm/num/rotation.py
def rotation_matrix(theta: float) -> NDArray: ... # [[cos, -sin], [sin, cos]], float64
def rotate_pairs(x: ArrayLike, theta: ArrayLike) -> NDArray: ... # pair i of the last axis by theta[..., i]
def as_complex(x: ArrayLike) -> NDArray: ... # [..., 2k] -> complex [..., k]
def as_real(z: ArrayLike) -> NDArray: ... # complex [..., k] -> [..., 2k]

theta broadcasts to x.shape[:-1] + (k,): a [k] theta rotates every row alike, a [T, k] theta gives each position its own angles (what L7.3 passes). The result is a new array; float32 input stays float32, everything else comes back float64. An odd last axis or a theta that does not broadcast raises ValueError.

TestKINDChecksWhy it matters downstream
test_hand_example_rotationunit, smokesection 3: [3,4,1,0][3, 4, 1, 0] becomes [−1.4,4.8,0,1][-1.4, 4.8, 0, 1]you and the tests agree on direction and layout
test_hand_example_as_complex_productunit, smoke(3+4i)(0.6+0.8i)=−1.4+4.8i(3 + 4i)(0.6 + 0.8i) = -1.4 + 4.8i through as_complex and as_realthe complex view of the same example
test_rotation_matrix_hand_valuesunitR(π/2)R(\pi/2), R(t0)R(t_0), R(0)=IR(0) = Ithe columns are the images of the basis vectors
test_rotate_pairs_matches_rotation_matrixdifferentialeach pair equals R(θi)R(\theta_i) times the pairyour two functions agree
test_length_is_preservedpropertyevery pair keeps its lengthRoPE never rescales queries or keys
test_angles_addpropertyR(a)R(b)=R(a+b)R(a)R(b) = R(a + b), and rotating twice adds the anglesthe law behind relative positions
test_negative_angle_undoespropertyrotating by −θ-\theta returns the inputR(θ)⊤=R(−θ)R(\theta)^\top = R(-\theta)
test_dot_product_sees_only_relative_angleproperty⟨R(mω)q,R(nω)k⟩\langle R(m\omega)q, R(n\omega)k\rangle is unchanged when mm and nn shift togetherthe RoPE property L7.3 is built on
test_euler_formula_is_the_rotationdifferentialas_complex(rotate_pairs(x, t)) == as_complex(x) * exp(1j t)Euler’s formula, checked on random data
test_layout_is_interleavedboundarypairs are (x2i,x2i+1)(x_{2i}, x_{2i+1}), not halvesthe layout bug that crashes nothing
test_as_real_roundtrippropertyas_real(as_complex(x)) == x exactly, float32 stays complex64lossless views
test_golden_torch_polargoldenthree cases against torch.view_as_complex(x) * torch.polar(1, theta), one in float32the exact op Meta’s Llama code runs
test_theta_broadcastsunita [k] theta applies to every row of a batchL5.4 and L7.3 shapes
test_dtype_follows_inputboundaryfloat32 in, float32 out; integers promoted to float64L7.3 keeps activations in float32
test_input_not_modifiedboundaryx is unchanged after the callkeys are reused for every query
test_odd_or_bad_shapes_rejectedboundaryodd lengths, bad theta, and 0-d input raise ValueErrorno silently dropped coordinate
PitfallSymptomCaught by
1. the sign on the wrong sin⁡\sin termrotates by −θ-\theta; lengths and relative positions still look right, every value is wrongtest_hand_example_rotation, test_golden_torch_polar (mutant s01)
2. pairing halves, (xi,xi+k)(x_i, x_{i+k}), instead of neighboursshapes match, outputs differ from the reference model; with HF weights this is the classic RoPE layout bugtest_layout_is_interleaved (mutants s02, s05)
3. angles in degreescos⁡(90)\cos(90) in radians is −0.448-0.448; every rotation is by a meaningless angletest_hand_example_rotation (mutant s03)
4. rotating in placethe first call is right and corrupts x; the second use of the same keys is rotated twicetest_input_not_modified (mutant s04)
5. computing the second output from the already-rotated firsty = x' sin + y cos uses the new xx; lengths change and nothing matchestest_length_is_preserved (mutant s08)
6. writing R(θ)R(\theta) transposedrotation_matrix rotates clockwise; rotate_pairs may still be right, so the two disagreetest_rotation_matrix_hand_values (mutant s06)
DirectionModuleHow it uses this
BackM00.1the exponential and the laws of exponents behind Euler’s formula (reading)
Backlang.01numpy strided slicing (x[..., 0::2]) and broadcasting (reading)
ForwardL7.3RoPE: rotate_pairs(q, positions[:, None] * inv_freq) for queries and keys, in both layouts
ForwardL5.4the sinusoidal encoding: PE(p+j)\mathrm{PE}(p + j) is a fixed rotation of PE(p)\mathrm{PE}(p), the property its tests check
ForwardM07.0Box-Muller: −2ln⁡(1−u1) cos⁡(2πu2)\sqrt{-2\ln(1 - u_1)}\,\cos(2\pi u_2) turns two uniforms into a normal
ForwardM00.3builds the frequency ladder ωi\omega_i that RoPE feeds into the angles

None of the forward modules is authored yet, so ol verify course M00.2 reports no call site (check 9) until M07.0 or L7.3 lands; see course/DEVIATIONS.md row M00-01.

Your pieceProduction equivalentWhat it addsWhere to look
rotate_pairs (interleaved)Meta’s Llama reference apply_rotary_embprecomputes eimωie^{im\omega_i} once (freqs_cis) and multiplies complex viewsllama/model.py in meta-llama/llama
the “half” layoutHugging Face apply_rotary_pos_emb with rotate_halfcaches cos and sin per position, permutes the weights instead of the activationstransformers/models/llama/modeling_llama.py
rotations in PythonvLLM RotaryEmbeddinga fused CUDA kernel over queries and keys, is_neox_style selects the layoutvllm/model_executor/layers/rotary_embedding.py
rotations on the CPUllama.cpp ggml_rope_extboth layouts (GGML_ROPE_TYPE_NEOX), context-extension scaling (L7.4)ggml/src/ggml-cpu/ops.cpp