Trig, unit circle, 2D rotations, complex numbers, Euler's formula
Overview
Section titled “Overview”| Module | M00.2 · build · Python · Pass 2 · 3 to 4 h |
| You build | python/tinyllm/num/rotation.py: rotation_matrix, rotate_pairs, as_complex, as_real |
| Contract | course/contracts/py/tinyllm/num/rotation.pyi |
| Tests | course/tests/M00.2/test_rotation.py (what they check: section 4) |
| Needs | nothing to call. Reading: M00.1 (exponents and ), lang.01 (numpy slicing and broadcasting) |
| Used by | L7.3 RoPE rotates query and key pairs with rotate_pairs · L5.4 sinusoidal positional encoding · M07.0 Box-Muller normals use and of (none is authored yet: section 6) |
| Milestone | MS-P2 (the Pass 2 gate) |
| Optional depth | OpenStax, 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 |
Key Takeaways
Section titled “Key Takeaways”- A point on the unit circle at angle (in radians) is , so (
test_rotation_matrix_hand_values). - Rotating the plane by is the matrix . It keeps every length, and angles add: (
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 as the complex number , Euler’s formula makes the same rotation one multiplication (
test_euler_formula_is_the_rotation,test_golden_torch_polar). - Pairs are interleaved, . Pairing with instead is a different model with the same shapes (
test_layout_is_interleaved).
How to work this chapter
Section titled “How to work this chapter”ol start M00.2 # stubs python/tinyllm/num/rotation.py into your repool tests M00.2 # read the test catalog first: rung R0, you write no tests hereol check M00.2 # exit code is the verdictol diff M00.2 # after passing: your code against the reference1. Why now
Section titled “1. Why now”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 and 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 and to turn uniform random numbers into normal ones.
2. Principles
Section titled “2. Principles”| Symbol | Meaning | Type / shape |
|---|---|---|
| angles in radians | float | |
| half the circumference of a circle of radius 1, | math.pi | |
| the coordinates of the point at angle on the unit circle | float | |
| vectors in the plane, | float[2] | |
| dot product | float | |
| length | float | |
| the rotation matrix by | float[2, 2] | |
| the imaginary unit, | 1j | |
| a complex number with real part and imaginary part | complex | |
| the conjugate | complex | |
| the modulus | float | |
| the number of pairs in a vector of length | int | |
| token positions | int | |
| an angular frequency: radians per position | float |
2.1 Angles in radians
Section titled “2.1 Angles in radians”Walk along the circle of radius 1 centred at the origin, starting at and going counterclockwise. The radian measure of an angle is the distance walked. A full turn is the whole circumference, ; a half turn is ; a right angle is . Degrees convert by , so . Every function in this module, and numpy’s np.cos and np.sin, takes radians. Negative angles walk clockwise, and adding lands on the same point, so trigonometric functions repeat with period .
2.2 The unit circle
Section titled “2.2 The unit circle”The point reached at angle has coordinates : that is the definition of cosine and sine. Because the point is at distance 1 from the origin, Pythagoras gives
Reflection across the horizontal axis gives and . A few values come from familiar triangles:
| 0 | ||||||
|---|---|---|---|---|---|---|
| 1 | 0 | |||||
| 0 | 1 | 0 |
2.3 Rotations of the plane
Section titled “2.3 Rotations of the plane”Rotating the plane by about the origin is linear: rotating a sum is the sum of the rotations, and rotating is times the rotation of . So it is enough to know where the two basis vectors go. goes to by definition, and , a quarter turn further, goes to . Those images are the columns of the matrix:
A rotation keeps lengths. Expanding, , because the cross terms cancel. It also keeps dot products: . In matrix terms , and undoes the rotation.
2.4 Composition: angles add
Section titled “2.4 Composition: angles add”Rotating by and then by is a rotation by , so . Multiplying the two matrices out and comparing the first column with ‘s gives the angle-addition formulas:
With : .
2.5 Why RoPE rotates pairs
Section titled “2.5 Why RoPE rotates pairs”Rotate by and by , then take the dot product. Using and composition,
The result depends on and only through . RoPE sets for a query at position and for a key at position , so the attention score depends only on , the distance between the tokens: shifting a sentence along the context changes nothing. One frequency would make positions apart indistinguishable, so RoPE splits a -dimensional query into pairs and rotates pair by with a ladder of frequencies (built in M00.3). rotate_pairs(x, theta) is exactly that operation, with theta[..., i] the angle of pair , and pair is , the interleaved layout of the RoFormer paper and Meta’s Llama code. Hugging Face’s Llama pairs with instead (the “half” layout of rotate_half); L7.3 implements both and converts between them.
2.6 Complex numbers and Euler’s formula
Section titled “2.6 Complex numbers and Euler’s formula”A complex number is a point of the plane with a multiplication: expand as polynomials in and replace by .
Compare with section 2.3: with and , the right-hand side is exactly . Multiplying by rotates by . The modulus is the length, and .
Euler’s formula names that rotating number:
Why an exponential? Because it obeys the law of exponents from M00.1: is the composition rule of section 2.4, now one line. M02.1 proves the formula by comparing the Taylor series of , , and . Consequences used in the solve set: , , (De Moivre), , and , the relative-angle property of section 2.5 in complex form.
as_complex reads a real vector of length as complex numbers , 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)).
3. Worked example by hand
Section titled “3. Worked example by hand”Two pairs, two angles. has pairs and . Rotate pair 0 by the angle with and (so radians, about ), and pair 1 by .
| pair | ||||
|---|---|---|---|---|
| 0 | ||||
| 1 |
So rotate_pairs([3, 4, 1, 0], [t0, pi/2]) is (test_hand_example_rotation; numpy gives instead of the exact 0, because in floating point is not exactly 0). Length check: .
The same with complex numbers. as_complex([3, 4, 1, 0]) is . Then
and as_real returns : test_hand_example_as_complex_product.
Composition. : multiply , whose first column is the point at .
4. The interface
Section titled “4. The interface”def rotation_matrix(theta: float) -> NDArray: ... # [[cos, -sin], [sin, cos]], float64def 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.
What the tests check
Section titled “What the tests check”| Test | KIND | Checks | Why it matters downstream |
|---|---|---|---|
test_hand_example_rotation | unit, smoke | section 3: becomes | you and the tests agree on direction and layout |
test_hand_example_as_complex_product | unit, smoke | through as_complex and as_real | the complex view of the same example |
test_rotation_matrix_hand_values | unit | , , | the columns are the images of the basis vectors |
test_rotate_pairs_matches_rotation_matrix | differential | each pair equals times the pair | your two functions agree |
test_length_is_preserved | property | every pair keeps its length | RoPE never rescales queries or keys |
test_angles_add | property | , and rotating twice adds the angles | the law behind relative positions |
test_negative_angle_undoes | property | rotating by returns the input | |
test_dot_product_sees_only_relative_angle | property | is unchanged when and shift together | the RoPE property L7.3 is built on |
test_euler_formula_is_the_rotation | differential | as_complex(rotate_pairs(x, t)) == as_complex(x) * exp(1j t) | Euler’s formula, checked on random data |
test_layout_is_interleaved | boundary | pairs are , not halves | the layout bug that crashes nothing |
test_as_real_roundtrip | property | as_real(as_complex(x)) == x exactly, float32 stays complex64 | lossless views |
test_golden_torch_polar | golden | three cases against torch.view_as_complex(x) * torch.polar(1, theta), one in float32 | the exact op Meta’s Llama code runs |
test_theta_broadcasts | unit | a [k] theta applies to every row of a batch | L5.4 and L7.3 shapes |
test_dtype_follows_input | boundary | float32 in, float32 out; integers promoted to float64 | L7.3 keeps activations in float32 |
test_input_not_modified | boundary | x is unchanged after the call | keys are reused for every query |
test_odd_or_bad_shapes_rejected | boundary | odd lengths, bad theta, and 0-d input raise ValueError | no silently dropped coordinate |
5. Pitfalls
Section titled “5. Pitfalls”| Pitfall | Symptom | Caught by |
|---|---|---|
| 1. the sign on the wrong term | rotates by ; lengths and relative positions still look right, every value is wrong | test_hand_example_rotation, test_golden_torch_polar (mutant s01) |
| 2. pairing halves, , instead of neighbours | shapes match, outputs differ from the reference model; with HF weights this is the classic RoPE layout bug | test_layout_is_interleaved (mutants s02, s05) |
| 3. angles in degrees | in radians is ; every rotation is by a meaningless angle | test_hand_example_rotation (mutant s03) |
| 4. rotating in place | the first call is right and corrupts x; the second use of the same keys is rotated twice | test_input_not_modified (mutant s04) |
| 5. computing the second output from the already-rotated first | y = x' sin + y cos uses the new ; lengths change and nothing matches | test_length_is_preserved (mutant s08) |
| 6. writing transposed | rotation_matrix rotates clockwise; rotate_pairs may still be right, so the two disagree | test_rotation_matrix_hand_values (mutant s06) |
6. Where it’s used next
Section titled “6. Where it’s used next”| Direction | Module | How it uses this |
|---|---|---|
| Back | M00.1 | the exponential and the laws of exponents behind Euler’s formula (reading) |
| Back | lang.01 | numpy strided slicing (x[..., 0::2]) and broadcasting (reading) |
| Forward | L7.3 | RoPE: rotate_pairs(q, positions[:, None] * inv_freq) for queries and keys, in both layouts |
| Forward | L5.4 | the sinusoidal encoding: is a fixed rotation of , the property its tests check |
| Forward | M07.0 | Box-Muller: turns two uniforms into a normal |
| Forward | M00.3 | builds the frequency ladder 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.
Going further
Section titled “Going further”| Your piece | Production equivalent | What it adds | Where to look |
|---|---|---|---|
rotate_pairs (interleaved) | Meta’s Llama reference apply_rotary_emb | precomputes once (freqs_cis) and multiplies complex views | llama/model.py in meta-llama/llama |
| the “half” layout | Hugging Face apply_rotary_pos_emb with rotate_half | caches cos and sin per position, permutes the weights instead of the activations | transformers/models/llama/modeling_llama.py |
| rotations in Python | vLLM RotaryEmbedding | a fused CUDA kernel over queries and keys, is_neox_style selects the layout | vllm/model_executor/layers/rotary_embedding.py |
| rotations on the CPU | llama.cpp ggml_rope_ext | both layouts (GGML_ROPE_TYPE_NEOX), context-extension scaling (L7.4) | ggml/src/ggml-cpu/ops.cpp |