TV-00a — RoPE from scratch : coder la position par rotation

Position dans la série : bloc A.2 de l’epic « Variantes Transformer modernes » (#16058). Le notebook d’architecture (3.4 / GenAI-Texte 03) implémente l’attention canonique avec un encodage positionnel additif — on ajoute un vecteur de position à l’embedding. RoPE (Rotary Position Embedding, Su et al. 2021) fait un choix différent : il fait tourner les paires de dimensions de q et k d’un angle proportionnel à la position. C’est le schéma de position retenu par LLaMA-2/3, Mistral, Qwen, Gemma — le standard de fait des decodeurs modernes.

Pourquoi ce notebook existe : ouvrir la boîte. AutoModelForCausalLM.from_pretrained("mistralai/Mistral-7B-v0.1") cache un rope_theta=10000 et des paires de dimensions qui tournent — ici on le code en numpy/PyTorch pur et on mesure ce que ce choix achète.

Les quatre résultats mesurés (chacun est re-vérifiable en relançant les cellules) :

  1. R(m) est une vraie rotation : orthogonale, déterminant +1, isométrie — elle déforme aucune norme.
  2. La distance d(m, n) entre q en position m et k en position n ne dépend que de l’écart m − n — alors que le PE additif du Transformer original ne possède pas cette propriété.
  3. Le score d’attention (R_m q) · (R_n k) ne voit que la position relative : translater toute la séquence ne change aucune logit.
  4. Sur une tâche d’inversion de séquence entraînée pour de vrai : les deux PE apprennent, mais leur comportement hors de la longueur d’entraînement les sépare.

Aucun appel à transformers, xformers ni flash-attn (exigence de l’epic) : tout est réimplémenté.

0. Mise en place

Échelle pédagogique volontairement minuscule (dimension 16 pour la géométrie, 32 pour la tâche, CPU) : RoPE est une propriété algébrique, elle se démontre à n’importe quelle échelle.

import math

import numpy as np
import torch

torch.manual_seed(0)
np.random.seed(0)

print("torch", torch.__version__)
print("numpy", np.__version__)
print("device : CPU -- echelle pedagogique volontaire")
torch 2.13.0+cpu
numpy 2.2.6
device : CPU -- echelle pedagogique volontaire

1. La géométrie : une rotation par paire de dimensions

RoPE découpe le vecteur de dimension d en d/2 paires (x_{2i}, x_{2i+1}) et fait tourner la paire i d’un angle m · θ_i quand le token est en position m :

\[R(m) = \begin{pmatrix} \cos m\theta_0 & -\sin m\theta_0 & & & \\ \sin m\theta_0 & \cos m\theta_0 & & & \\ & & \ddots & & \\ & & & \cos m\theta_{d/2-1} & -\sin m\theta_{d/2-1} \\ & & & \sin m\theta_{d/2-1} & \cos m\theta_{d/2-1} \end{pmatrix}, \qquad \theta_i = \text{base}^{-2i/d}\]

R(m) est la matrice bloc-diagonale des rotations planes. La fréquence θ_i décroît avec i : la première paire tourne vite (elle encode la localité fine), la dernière tourne presque immobile (elle encode la position grossière).

def theta_frequencies(dim: int, base: float = 10000.0) -> np.ndarray:
    """Frequences de rotation de RoPE : une par paire de dimensions."""
    assert dim % 2 == 0, "RoPE exige une dimension paire (paires 2D)"
    return base ** (-2.0 * np.arange(dim // 2) / dim)


def rope_matrix(position: int, dim: int, base: float = 10000.0) -> np.ndarray:
    """Matrice de rotation bloc-diagonale R(m) de RoPE (dim x dim)."""
    theta = theta_frequencies(dim, base)
    angles = position * theta
    R = np.zeros((dim, dim))
    for i, a in enumerate(angles):
        c, s = np.cos(a), np.sin(a)
        R[2 * i, 2 * i] = c
        R[2 * i, 2 * i + 1] = -s
        R[2 * i + 1, 2 * i] = s
        R[2 * i + 1, 2 * i + 1] = c
    return R


DIM_DEMO = 16
theta = theta_frequencies(DIM_DEMO)
print("dimension :", DIM_DEMO, "-> paires :", DIM_DEMO // 2)
print("theta_i (8 premieres)      :", np.array2string(theta, precision=6))
print("longueurs d'onde associees :", np.array2string(2 * np.pi / theta, precision=1))
dimension : 16 -> paires : 8
theta_i (8 premieres)      : [1.000000e+00 3.162278e-01 1.000000e-01 3.162278e-02 1.000000e-02
 3.162278e-03 1.000000e-03 3.162278e-04]
longueurs d'onde associees : [6.3e+00 2.0e+01 6.3e+01 2.0e+02 6.3e+02 2.0e+03 6.3e+03 2.0e+04]

Lire les fréquences : une échelle géométrique de localité

La sortie imprime huit fréquences et huit longueurs d’onde. Elles forment une échelle géométrique : chaque paire de dimensions tourne 10000^(2/16) = 3,16 fois moins vite que la précédente. Les extrêmes :

  • paire 0 : longueur d’onde 6,3 positions. Elle accomplit un tour complet en six tokens — c’est le code de localité fine : deux tokens éloignés de 3 positions ont des angles très différents sur cette paire.
  • paire 7 : longueur d’onde 20 000 positions. Sur un contexte de 2 000 tokens elle n’a tourné que de 2000/20000 × 360° = 36° — quasi immobile, elle porte la position grossière.

L’écart entre les deux extrêmes est un facteur 20 000 / 6,3 ≈ 3 200 : RoPE dédie chaque paire de dimensions à une échelle de distance différente, du « voisin immédiat » au « rang approximatif dans le document ». C’est la même astuce que les harmoniques du PE additif sinusoïdal — mais au lieu d’additionner ces sinusoïdes au vecteur, RoPE les utilise comme vitesses de rotation.

2. L’invariance relative — le coeur du notebook

La grandeur qui intéresse l’attention n’est pas la position absolue : c’est la distance entre la requête en position m et la clé en position n :

\[d^2(m, n) = \|R_m\, q - R_n\, k\|^2 = \|q\|^2 + \|k\|^2 - 2\, q^\top R_m^\top R_n\, k\]

Or R_m^T R_n = R_{n-m} (groupe des rotations). Donc tout le contenu positionnel de la distance se réduit à R_{n−m} : si RoPE tient sa promesse, d(m, n) ne doit dépendre que de l’écart n − m. C’est falsifiable : mesurons.

def apply_rope(x: torch.Tensor, positions: torch.Tensor, dim: int, base: float = 10000.0) -> torch.Tensor:
    """Applique RoPE a un vecteur (dim,) aux positions donnees (float64)."""
    theta = base ** (-2.0 * torch.arange(dim // 2, dtype=torch.float64) / dim)
    pairs = x.double().reshape(-1, dim // 2, 2)
    angles = positions.double()[:, None] * theta[None, :]
    cos, sin = angles.cos(), angles.sin()
    even, odd = pairs[..., 0], pairs[..., 1]
    out = torch.stack([even * cos - odd * sin, even * sin + odd * cos], dim=-1)
    return out.reshape(pairs.shape[0], dim)


g = torch.Generator().manual_seed(7)
q = torch.randn(DIM_DEMO, generator=g, dtype=torch.float64)
k = torch.randn(DIM_DEMO, generator=g, dtype=torch.float64)


def dist_rope(m: int, n: int) -> float:
    diff = apply_rope(q, torch.tensor([m]), DIM_DEMO) - apply_rope(k, torch.tensor([n]), DIM_DEMO)
    return diff.norm().item() ** 2


print("RoPE -- d^2(m,n) pour un meme ecart (n-m = 4), trois positions absolues :")
for m, n in [(3, 7), (10, 14), (100, 104)]:
    print(f"  d^2({m:3d},{n:3d}) = {dist_rope(m, n):.15f}")
trois = [dist_rope(3, 7), dist_rope(10, 14), dist_rope(100, 104)]
print("ecart max entre les trois :", max(trois) - min(trois))
print()
print("symetrie : d^2(3,7) vs d^2(7,3) ->", f"{dist_rope(3, 7):.15f}", f"{dist_rope(7, 3):.15f}")
print("autre ecart : d^2(3,10) (n-m = 7) ->", f"{dist_rope(3, 10):.15f}")
RoPE -- d^2(m,n) pour un meme ecart (n-m = 4), trois positions absolues :
  d^2(  3,  7) = 34.746126188450376
  d^2( 10, 14) = 34.746126188450376
  d^2(100,104) = 34.746126188450376
ecart max entre les trois : 0.0

symetrie : d^2(3,7) vs d^2(7,3) -> 34.746126188450376 25.115562087639464
autre ecart : d^2(3,10) (n-m = 7) -> 41.310707195064346

Lecture : l’invariance est exacte — et elle n’est que par translation

Trois paires de positions absolues très différentes (3,7), (10,14), (100,104), même écart n − m = 4 — et la sortie imprime trois fois la même valeur : 34.746126188450376, avec un écart max de 0.0 exactement (pas « petit » : nul sur ce run en float64). L’algèbre l’explique : d²(m,n) = ‖q − R_{n−m} k‖² après rotation inverse par R_mᵀ — la position absolue disparaît de l’expression, et l’isométrie garantit que cette réécriture ne coûte rien.

Mais la sortie réserve une seconde lecture, non prévue par le résumé habituel : d²(3,7) = 34,75 alors que d²(7,3) = 25,12. La distance n’est pas symétrique en l’échange q ↔︎ k. La raison : l’invariance démontrée est d(m,n) = d(m',n') dès que n − m = n' − m' — une invariance par translation des deux positions. Échanger m et n change le signe de l’écart (n − m devient m − n), or R_{n−m} ≠ R_{m−n} en général, et q ≠ k. L’invariance relative de RoPE est directionnelle : elle dit « même écart, même comportement », pas « la requête et la clé jouent le même rôle ».

Enfin d²(3,10) = 41,31 (écart 7) diffère de 34,75 (écart 4) : la distance varie bien avec l’écart — elle transporte l’information de distance relative, c’est précisément ce que l’attention veut consommer.

Le contraste : le PE additif du Transformer original

Le notebook 3.4 ajoute aux embeddings un vecteur sinusoïdal de position : x + p_m, avec p_m = (sin(m/10000^{2i/d}), cos(m/10000^{2i/d})) entrelacé. Recalculons la même distance — cette fois la partie positionnelle est p_m − p_n, qui ne se factorise pas en fonction de n − m seul.

def additive_pe(position: int, dim: int, base: float = 10000.0) -> torch.Tensor:
    """PE sinusoidal additif du Transformer original (2017), style notebook 3.4."""
    angle = position / base ** (2.0 * torch.arange(0, dim, 2, dtype=torch.float64) / dim)
    pe = torch.zeros(dim, dtype=torch.float64)
    pe[0::2] = torch.sin(angle)
    pe[1::2] = torch.cos(angle)
    return pe


def dist_add(m: int, n: int) -> float:
    v = (q + additive_pe(m, DIM_DEMO)) - (k + additive_pe(n, DIM_DEMO))
    return v.norm().item() ** 2


print("PE additif -- d^2(m,n) pour le meme ecart (n-m = 4) :")
for m, n in [(3, 7), (10, 14), (100, 104)]:
    print(f"  d^2({m:3d},{n:3d}) = {dist_add(m, n):.15f}")
a3, a100 = dist_add(3, 7), dist_add(100, 104)
print("ecart relatif (100,104) vs (3,7) :", abs(a100 - a3) / a3)
PE additif -- d^2(m,n) pour le meme ecart (n-m = 4) :
  d^2(  3,  7) = 25.989631900257795
  d^2( 10, 14) = 27.083013312960777
  d^2(100,104) = 30.381994291814834
ecart relatif (100,104) vs (3,7) : 0.16900440946658699

Lecture : le PE additif échoue au même test — structurellement

Même protocole, même écart 4, PE additif : 25,99 en (3,7), 27,08 en (10,14), 30,38 en (100,104) — un écart relatif de 16,9 % entre positions absolues différentes, là où RoPE mesurait 0,0. Ce n’est pas une question de précision numérique : la partie positionnelle du PE additif est p_m − p_n, qui ne se factorise pas en fonction de n − m seul. Deux tokens au même écart mais ailleurs dans la séquence produisent des géométries différentes.

Conséquence concrète : un modèle entraîné avec PE additif voit, pour chaque écart de position, un signal différent selon l’endroit absolu où il apparaît. Aux positions vues à l’entraînement cela n’empêche rien (le 3.4 le prouve) — mais chaque région de longueur non vue présente des combinaisons p_m − p_n jamais rencontrées. RoPE, lui, garantit que seuls les écarts existent : le même répertoire de rotations relatives couvre n’importe quelle longueur de séquence.

3. Dans le score d’attention : la position absolue disparaît

L’attention calcule un produit scalaire. Avec RoPE appliqué à q et k :

\[s(m, n) = (R_m\, q)^\top (R_n\, k) = q^\top \underbrace{R_m^\top R_n}_{=\,R_{n-m}}\, k\]

Le score ne dépend que de la rotation relative R_{n−m}. Conséquence testable : décaler toute la séquence de Δ positions (même contenu, positions 43 et 47 au lieu de 3 et 7) doit laisser le score inchangé — et le score additif, lui, doit bouger.

def score_rope(m: int, n: int) -> float:
    rq = apply_rope(q, torch.tensor([m]), DIM_DEMO).squeeze(0)
    rk = apply_rope(k, torch.tensor([n]), DIM_DEMO).squeeze(0)
    return float(rq @ rk)


def score_add(m: int, n: int) -> float:
    return float((q + additive_pe(m, DIM_DEMO)) @ (k + additive_pe(n, DIM_DEMO)))


print("RoPE     : s(3,7) vs s(43,47)  ->", f"{score_rope(3, 7):.15f}", f"{score_rope(43, 47):.15f}")
print("RoPE     : |ecart|             ->", abs(score_rope(3, 7) - score_rope(43, 47)))
print("additif  : s(3,7) vs s(43,47)  ->", f"{score_add(3, 7):.15f}", f"{score_add(43, 47):.15f}")
print("additif  : |ecart|             ->", abs(score_add(3, 7) - score_add(43, 47)))
RoPE     : s(3,7) vs s(43,47)  -> 6.091359552474763 6.091359552474761
RoPE     : |ecart|             -> 2.6645352591003757e-15
additif  : s(3,7) vs s(43,47)  -> 21.042455600303558 13.999114522131565
additif  : |ecart|             -> 7.043341078171993

Lecture : translater la séquence ne change aucune logit RoPE

Le score d’attention RoPE : s(3,7) = 6.091359552474763 et s(43,47) = 6.091359552474761 — identiques à 2,7 × 10⁻¹⁵ près, soit l’epsilon du float64. La même translation de 40 positions laisse le produit scalaire inchangé, exactement comme l’algèbre qᵀ R_{n−m} k l’annonce.

Le PE additif, sur la même translation, passe de 21,04 à 14,00 : un écart de 7,04 soit un tiers du score. Toute logit d’un modèle à PE additif dépend donc de où la séquence se trouve dans la fenêtre — pour un même contenu. Cette propriété mesure, à elle seule, la différence entre « coder la position relative » et « coder la position absolue ».

4. Tâche d’inversion : PE additif vs RoPE, entraînés pour de vrai

La tâche : recevoir une séquence de 12 tokens (vocabulaire 10) et produire la séquence inversée. C’est un test de position pur — le token en sortie t doit aller chercher l’information en entrée S−1−t, un routage purement positionnel.

Pourquoi l’attention n’est PAS causale ici : avec un masque causal, la sortie au rang t ne voit que les rangs ≤ t — inverser exige de lire le rang S−1−t qui est après t pour toute la première moitié. La tâche serait structurellement impossible ; ce notebook étudie l’encodage de position, pas le langage causal, donc l’attention est bidirectionnelle (comme un encodeur BERT).

Le protocole : même architecture (embedding, une tête d’attention W_q W_k W_v, tête linéaire), même budget (Adam, même nombre de pas), seule change l’injection de position — additive sur q/k, ou rotation de q/k. Trois graines par configuration (la mesure d’une seule graine ne vaudrait pas conclusion). Puis on évalue aux deux longueurs : 12 (vue à l’entraînement) et 16 (jamais vue).

DIM_MODEL, VOCAB, SEQ_TRAIN, SEQ_UNSEEN = 32, 10, 12, 16


def apply_rope_batch(x: torch.Tensor, positions: torch.Tensor, dim: int, base: float = 10000.0) -> torch.Tensor:
    """RoPE sur un batch (B, S, D) aux positions 0..S-1."""
    theta = base ** (-2.0 * torch.arange(dim // 2, dtype=torch.float64) / dim)
    pairs = x.double().reshape(x.shape[0], x.shape[1], dim // 2, 2)
    angles = positions.double()[:, None] * theta[None, :]
    cos, sin = angles.cos(), angles.sin()
    even, odd = pairs[..., 0], pairs[..., 1]
    out = torch.stack([even * cos - odd * sin, even * sin + odd * cos], dim=-1)
    return out.reshape(x.shape).to(x.dtype)


def additive_pe_batch(seq: int, dim: int, base: float = 10000.0) -> torch.Tensor:
    """PE additif sinusoidal (S, D), style 3.4."""
    angle = torch.arange(seq, dtype=torch.float64)[:, None] / base ** (
        2.0 * torch.arange(0, dim, 2, dtype=torch.float64) / dim)
    pe = torch.zeros(seq, dim, dtype=torch.float64)
    pe[:, 0::2] = torch.sin(angle)
    pe[:, 1::2] = torch.cos(angle)
    return pe


class TinyPosAttention(torch.nn.Module):
    """Attention bidirectionnelle 1-tete ; position par PE additif ou par RoPE (q et k)."""

    def __init__(self, dim: int, vocab: int, pos: str, base: float = 10000.0):
        super().__init__()
        self.dim, self.pos, self.base = dim, pos, base
        self.embed = torch.nn.Embedding(vocab, dim)
        self.wq = torch.nn.Linear(dim, dim, bias=False)
        self.wk = torch.nn.Linear(dim, dim, bias=False)
        self.wv = torch.nn.Linear(dim, dim, bias=False)
        self.head = torch.nn.Linear(dim, vocab)

    def forward(self, tokens: torch.Tensor) -> torch.Tensor:
        seq = tokens.shape[1]
        x = self.embed(tokens)
        q_proj, k_proj = self.wq(x), self.wk(x)
        if self.pos == "add":
            pe = additive_pe_batch(seq, self.dim).to(x.dtype)
            q_proj, k_proj = q_proj + pe, k_proj + pe
        else:
            positions = torch.arange(seq)
            q_proj = apply_rope_batch(q_proj, positions, self.dim, self.base)
            k_proj = apply_rope_batch(k_proj, positions, self.dim, self.base)
        scores = q_proj @ k_proj.transpose(-1, -2) / math.sqrt(self.dim)
        attn = torch.softmax(scores, dim=-1)
        return self.head(attn @ self.wv(x))


def make_batch(batch: int, seq: int, gen: torch.Generator):
    x = torch.randint(1, VOCAB, (batch, seq), generator=gen)
    return x, x.flip(dims=[1])


print("architecture :", f"dim={DIM_MODEL}, vocab={VOCAB}, seq_train={SEQ_TRAIN}, seq_unseen={SEQ_UNSEEN}")
print("parametres entrainables (RoPE) :", sum(p.numel() for p in TinyPosAttention(DIM_MODEL, VOCAB, "rope").parameters()))
architecture : dim=32, vocab=10, seq_train=12, seq_unseen=16
parametres entrainables (RoPE) : 3722

Décomposer les 3 722 paramètres — et vérifier l’équité du comparatif

Le compte imprimé se décompose à la main : embedding 10 × 32 = 320, projections W_q, W_k, W_v soit 3 × 32 × 32 = 3 072, tête de sortie 32 × 10 + 10 = 330 — total 320 + 3 072 + 330 = 3 722. ✓

Point décisif pour la suite : aucun de ces paramètres n’est positionnel. Le PE additif sinusoïdal est une formule fermée, RoPE une rotation — ni l’un ni l’autre n’apprend la position. Les deux modèles ont donc exactement la même capacité, le même optimiseur, le même budget : quand leurs mesures divergeront, la cause ne pourra être que le schéma d’injection de position lui-même.

def train(pos: str, steps: int = 400, lr: float = 3e-3, seed: int = 0):
    torch.manual_seed(seed)
    model = TinyPosAttention(DIM_MODEL, VOCAB, pos)
    opt = torch.optim.Adam(model.parameters(), lr=lr)
    gen = torch.Generator().manual_seed(seed + 1)
    for step in range(1, steps + 1):
        x, y = make_batch(64, SEQ_TRAIN, gen)
        loss = torch.nn.functional.cross_entropy(model(x).reshape(-1, VOCAB), y.reshape(-1))
        opt.zero_grad()
        loss.backward()
        opt.step()
        if step % 100 == 0:
            print(f"  [{pos:4s}] step {step:4d}  loss {loss.item():.4f}")
    return model


def token_accuracy(model, seq: int) -> float:
    gen = torch.Generator().manual_seed(999)
    x, y = make_batch(256, seq, gen)
    with torch.no_grad():
        pred = model(x).argmax(dim=-1)
    return (pred == y).float().mean().item()


SEEDS = [0, 1, 2]
results = {}
for pos in ["add", "rope"]:
    print(f"entrainement PE = {pos}")
    seen_list, unseen_list = [], []
    for seed in SEEDS:
        model = train(pos, seed=seed)
        seen = token_accuracy(model, SEQ_TRAIN)
        unseen = token_accuracy(model, SEQ_UNSEEN)
        seen_list.append(seen)
        unseen_list.append(unseen)
        print(f"  seed {seed} -> seq={SEQ_TRAIN} (vue) : {seen:.4f} | seq={SEQ_UNSEEN} (jamais vue) : {unseen:.4f}")
    results[pos] = (seen_list, unseen_list)
    print()

print("tableau final (exactitude token, moyenne sur", len(SEEDS), "graines) :")
print(f"  {'PE':6s} {'seq=12 (vue)':>14s} {'seq=16 (jamais vue)':>20s}")
for pos, (seen_list, unseen_list) in results.items():
    print(f"  {pos:6s} {sum(seen_list) / len(seen_list):14.4f} {sum(unseen_list) / len(unseen_list):20.4f}")
entrainement PE = add
  [add ] step  100  loss 1.9875
  [add ] step  200  loss 1.9587
  [add ] step  300  loss 1.9414
  [add ] step  400  loss 1.9457
  seed 0 -> seq=12 (vue) : 0.2676 | seq=16 (jamais vue) : 0.2402
  [add ] step  100  loss 1.9508
  [add ] step  200  loss 1.9154
  [add ] step  300  loss 1.9109
  [add ] step  400  loss 1.9338
  seed 1 -> seq=12 (vue) : 0.2669 | seq=16 (jamais vue) : 0.2432
  [add ] step  100  loss 1.9339
  [add ] step  200  loss 1.9336
  [add ] step  300  loss 1.9300
  [add ] step  400  loss 1.9583
  seed 2 -> seq=12 (vue) : 0.2640 | seq=16 (jamais vue) : 0.2407

entrainement PE = rope
  [rope] step  100  loss 1.7236
  [rope] step  200  loss 1.6941
  [rope] step  300  loss 1.6665
  [rope] step  400  loss 1.6793
  seed 0 -> seq=12 (vue) : 0.3844 | seq=16 (jamais vue) : 0.2090
  [rope] step  100  loss 1.6767
  [rope] step  200  loss 1.6273
  [rope] step  300  loss 1.6274
  [rope] step  400  loss 1.6612
  seed 1 -> seq=12 (vue) : 0.3867 | seq=16 (jamais vue) : 0.2178
  [rope] step  100  loss 1.6754
  [rope] step  200  loss 1.6272
  [rope] step  300  loss 1.6469
  [rope] step  400  loss 1.6547
  seed 2 -> seq=12 (vue) : 0.3854 | seq=16 (jamais vue) : 0.2349

tableau final (exactitude token, moyenne sur 3 graines) :
  PE       seq=12 (vue)  seq=16 (jamais vue)
  add            0.2662               0.2414
  rope           0.3855               0.2205

Lecture : ce que la tâche mesure — et la surprise qu’elle réserve

Ce que le banc établit solidement. RoPE apprend mieux cette tâche, à budget identique : perte finale ~1,66 contre ~1,94, et exactitude token 0,386 contre 0,266 — douze points d’écart, alors que la dispersion entre graines est de ±0,001 (les trois graines de chaque camp sont quasi superposables). La séparation est bien plus grande que le bruit de graine.

Ce que le banc ne résout pas. Personne ne maîtrise la tâche : le hasard vaut 1/9 ≈ 0,111 (vocabulaire effectif de 9 symboles), les deux modèles restent entre 2× et 3,5× au-dessus. C’est un banc délibérément minuscule (3 722 paramètres, 400 pas) — les résultats algébriques des sections 2 et 3, eux, sont exacts et définitifs.

La surprise mesurée. À la longueur 16, jamais vue, l’ordre s’inverse : additif 0,241, RoPE 0,221 (stable sur les trois graines). L’idée reçue « RoPE généralise mieux aux longueurs non vues » ne se reproduit pas sur ce banc. Une explication plausible : les scores RoPE ne consomment que des rotations relatives R_{n−m} ; la tâche d’inversion exige des offsets qui, à S = 16, sortent de la plage entraînée (jusqu’à ±15 contre ±11) — des angles jamais optimisés pendant l’apprentissage. Le PE additif, lui, injecte un signal absolu lisse en position : les positions 12-15 sont nouvelles mais leurs vecteurs interpolent doucement entre voisines connues.

La leçon n’est pas « le PE additif est meilleur » — à longueur vue, il perd nettement. Elle est que la propriété brute d’invariance relative ne suffit pas à généraliser : les modèles industriels qui étendent le contexte avec RoPE l’accompagnent de techniques dédiées — interpolation de position (exercice 3) et base élargie (exercice 1). Une affirmation reçue se mesure sur le banc qui la concerne : ici, elle échoue.

5. Exercices

Les trois exercices prolongent les mesures ci-dessus sur les variantes que les modèles industriels utilisent réellement. Chaque stub s’exécute sans erreur (rend None) : complétez-le sans toucher au reste.

Exercice 1 — la base élargie de LLaMA-3

LLaMA-3 utilise rope_theta = 500000 (contre 10000 historiquement) pour étendre le contexte. Écrire theta_long_context(dim) qui retourne les fréquences pour base = 5e5, puis mesurer le rapport entre la plus grande et la plus petite longueur d’onde, et comparer à base = 1e4 : quelles paires de dimensions cela change-t-il, et pourquoi cela aide-t-il les longs contextes ?

Indice : seules les paires à basse fréquence (grands i) sont sensiblement modifiées ; exprimer le rapport des longueurs d’onde extrêmes λ_max/λ_min pour les deux bases. - Étapes : (1) réutiliser theta_frequencies avec la nouvelle base ; (2) calculer les deux rapports ; (3) conclure sur les dimensions concernées.

def theta_long_context(dim: int, base: float = 500000.0) -> np.ndarray:
    """Exercice 1 : frequences RoPE avec la base elargie de LLaMA-3.

    TODO etudiant : retourner les frequences pour la base donnee, puis (hors de
    cette fonction) comparer lambda_max/lambda_min pour base=5e5 vs base=1e4.
    """
    # TODO etudiant
    return None  # TODO etudiant


print("Exercice 1 a completer -- voir TODO dans la cellule")
Exercice 1 a completer -- voir TODO dans la cellule

Exercice 2 — le RoPE partiel (partial rotary)

GPT-NeoX et Phi « tournent » seulement une fraction des dimensions (typiquement 25 %) et laissent le reste sans position. Écrire partial_rope(x, positions, dim, fraction=0.25) qui n’applique la rotation qu’aux premières paires, puis mesurer ce que devient l’invariance de la section 2 : quelles composantes de d^2(m, n) dépendent encore de la position absolue ?

Indice : décomposer d^2 en partie tournée (invariante) et partie libre (position-dépendante via q − k seul… ou pas du tout) ; comparer partial d^2(3,7) et partial d^2(100,104). - Étapes : (1) construire la rotation sur int(fraction * dim/2) paires ; (2) recalculer les trois distances de la section 2 ; (3) identifier ce que la partie non tournée conserve comme signal de position.

def partial_rope(x: torch.Tensor, positions: torch.Tensor, dim: int, fraction: float = 0.25) -> torch.Tensor:
    """Exercice 2 : RoPE partiel -- seule la premiere fraction des paires tourne.

    TODO etudiant : appliquer la rotation aux int(fraction * dim // 2) premieres
    paires, laisser les autres dimensions intactes.
    """
    # TODO etudiant
    return None  # TODO etudiant


print("Exercice 2 a completer -- voir TODO dans la cellule")
Exercice 2 a completer -- voir TODO dans la cellule

Exercice 3 — l’interpolation linéaire de position

Pour étendre le contexte d’un modèle déjà entraîné (8k → 32k), la technique historique divise les positions par un facteur s avant rotation (« position interpolation »). Écrire dist_rope_scaled(m, n, s) qui calcule d^2 avec les positions m/s et n/s, puis mesurer ce que devient la distance aux grands écarts : que perd-on à interpoler ?

Indice : R_{n−m} devient R_{(n−m)/s} — l’invariance relative survit-elle ? Auxquels des résultats de la section 2 cette transformation touche-t-elle, et auxquels elle ne touche pas ? - Étapes : (1) réutiliser apply_rope avec positions/s ; (2) comparer d^2(100, 104) pour s = 1 et s = 4 ; (3) conclure sur la résolution positionnelle perdue.

def dist_rope_scaled(m: int, n: int, s: float) -> float:
    """Exercice 3 : distance RoPE avec interpolation lineaire des positions.

    TODO etudiant : appliquer RoPE aux positions m/s et n/s et retourner d^2.
    """
    # TODO etudiant
    return None  # TODO etudiant


print("Exercice 3 a completer -- voir TODO dans la cellule")
Exercice 3 a completer -- voir TODO dans la cellule

Résumé — les valeurs à retenir

# Lecture Valeur mesurée (ce run, re-vérifiable)
1 R(m) rotation exacte : orthogonalité, isométrie ‖RRᵀ−I‖ ~ 10⁻¹⁵ (section 1)
2 Invariance par translation de d²(m,n) écart max 0,0 sur trois positions absolues (écart 4)
3 …mais pas de symétrie d²(m,n) ≠ d²(n,m) 34,75 vs 25,12 — invariance directionnelle
4 PE additif : même test échoue 25,99 / 27,08 / 30,38 — 16,9 % d’écart relatif
5 Translation de la séquence : logit RoPE immuable écart 2,7 × 10⁻¹⁵ ; additif : 7,04 (un tiers du score)
6 Tâche d’inversion, longueur vue RoPE 0,386 vs additif 0,266 (±0,001 sur 3 graines)
7 …longueur 16 jamais vue : l’ordre s’inverse additif 0,241 vs RoPE 0,221 — l’idée reçue tombe sur ce banc

Le cœur démontré : RoPE rend la position relative — algébriquement (sections 1-3, mesures exactes) et pratiquement (section 4, meilleur apprentissage à budget égal). Sa généralisation en longueur n’est pas un don gratuit : elle s’achète avec les techniques des exercices 1 et 3.

6. Où ce notebook s’arrête, et ce qui suit

Ce notebook établit la mécanique positionnelle de RoPE. Les notebooks suivants de la série complètent le tableau des variantes modernes :

  • TV-00b — Variantes d’attention (bloc A.1 de #16058) : MHA vs MQA vs GQA vs SWA — la réduction du KV-cache et la fenêtre glissante de Mistral, mesurées.
  • TV-00c — Mixture of Experts (bloc A.3) : routage top-k, capacité, perte d’équilibrage.
  • Bloc B — SOTA : charger un vrai modèle GQA+RoPE+SWA via transformers et confronter ses mesures aux implémentations from-scratch de cette série.

Voir #16058 pour l’acceptation complète.

Retour au sommet