FT-00a : LoRA from scratch — démonter l’adaptation bas-rang

Navigation : Index | Suivant >>

Objectif : comprendre LoRA en le construisant, sans peft. À la fin de ce notebook vous saurez écrire un LoRALinear en PyTorch pur, prouver que l’initialisation canonique ne change pas le modèle, l’entraîner sur une mini-tâche réelle, et expliquer pourquoi la fusion de l’adaptateur n’est pas bit-exacte.

Prérequis : bases de PyTorch (tenseurs, autograd, nn.Linear) et notions de fine-tuning — voir FT-01.

Durée : ~30 min · Niveau : intermédiaire · Matériel : CPU suffit (les durées affichées sont mesurées sur GPU, ~6 s par epoch).

Position dans la série : FT-01 à FT-06 utilisent peft et des modèles HuggingFace prêts à l’emploi. Ce notebook démonte le mécanisme — la décomposition W' = W + (alpha/r) * B @ A, l’initialisation A ~ N, B = 0, la propagation du gradient, la fusion — pour que les notebooks SOTA cessent d’être des boîtes noires. Les notebooks suivants restent la référence pour l’usage industriel : FT-01 (LoRA avec peft sur GPT-2), FT-02 (QLoRA 4-bit), FT-06 (vision-langage).

Vérification de l’environnement

Avant tout calcul, on vérifie le moteur effectif : version de PyTorch, présence d’un GPU, et graine fixée pour que les mesures soient reproductibles.

import copy
import time

import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from torchvision import datasets, transforms

SEED = 42
torch.manual_seed(SEED)
np.random.seed(SEED)
DEV = "cuda" if torch.cuda.is_available() else "cpu"

print(f"PyTorch {torch.__version__}")
print(f"numpy    {np.__version__}")
print(f"device   {DEV}" + (f" ({torch.cuda.get_device_name(0)})" if DEV == "cuda" else ""))
print(f"graine   {SEED}")
PyTorch 2.8.0+cu126
numpy    2.4.3
device   cuda (NVIDIA GeForce RTX 3090)
graine   42

Lecture du résultat : l’environnement d’exécution

La mini-tâche de ce notebook (section 3) tient sur CPU comme sur GPU ; seule la durée change (~6 s par epoch sur RTX 3090, sensiblement plus sur CPU). Toutes les cellules suivantes s’exécutent telles quelles sur les deux — les valeurs mesurées (exactitudes, normes, dérive de fusion) sont celles produites par le run, pas des constantes recopiées.

1. Le problème : W' = W + delta_W coûte |delta_W| paramètres

Le full fine-tuning apprend un delta complet : pour une couche d -> k, il faut stocker et mettre à jour d x k paramètres — plus les états d’optimiseur (Adam en porte deux copies : moment et variance). Sur GPT-2 (124 M de paramètres), c’est le prix intégral, même quand la tâche cible ne déplace le modèle que dans un tout petit sous-espace.

L’hypothèse de LoRA (Hu et al., 2021) est que ce delta a un rang intrinsèquement faible :

\[W' = W + \frac{\alpha}{r} \cdot B A, \qquad A \in \mathbb{R}^{r \times d},\ B \in \mathbb{R}^{k \times r},\ r \ll \min(d, k)\]

Le budget passe de d x k à r (d + k). Sur la couche jouet de ce notebook (512 -> 10, r = 4) :

full LoRA r=4
paramètres du delta 512 x 10 = 5 120 4 x (512 + 10) = 2 088
part du delta 100 % 40,8 %

Deux choix de conception font que cette décomposition est entraînable depuis le modèle de base, sans le déranger :

  1. B = 0 à l’initialisation : le delta vaut exactement zéro, donc W' = W au pas 0 — le modèle adapté démarre identique au modèle de base, quel que soit A.
  2. W est gelé (requires_grad = False) : seuls A et B reçoivent des gradients. On peut jeter le graphe des poids gelés, et un adaptateur entraîné se stocke en r (d + k) valeurs au lieu de d x k.

Le facteur alpha / r (le scaling) découple la magnitude effective du rang : on peut changer r sans changer l’échelle du signal appris.

LoRALinear : la décomposition en PyTorch pur

Pas d’import de peft ci-dessous — la classe tient en une vingtaine de lignes, et chacun de ses détails est l’un des trois invariants de la section 1.

class LoRALinear(nn.Module):
    """y = x @ W.T + (alpha / r) * (x @ A.T) @ B.T   -- W gele, seuls A et B vivent.

    Initialisation canonique (Hu et al. 2021) :
      A ~ N(0, 1/in_features)   -- random, direction disponible
      B = 0                     -- donc le delta vaut exactement 0 au pas 0
    """

    def __init__(self, base: nn.Linear, r: int, alpha: float):
        super().__init__()
        assert r > 0, "le rang doit etre strictement positif"
        self.base = base
        for p in self.base.parameters():
            p.requires_grad_(False)          # invariant 2 : W gele
        self.r, self.alpha = r, alpha
        self.scaling = alpha / r
        dev, dt = base.weight.device, base.weight.dtype
        self.A = nn.Parameter(
            torch.randn(r, base.in_features, device=dev, dtype=dt)
            * (1.0 / base.in_features ** 0.5)
        )
        self.B = nn.Parameter(
            torch.zeros(base.out_features, r, device=dev, dtype=dt)
        )                                     # invariant 1 : B = 0

    def forward(self, x):
        # Deux matmuls de rang r plutot qu'un matmul dense : c'est le coeur
        # de l'economie -- et l'ordre (x @ A.T) @ B.T, pas x @ (A.T @ B.T),
        # car cette derniere aurait le cout du full a chaque appel.
        return self.base(x) + self.scaling * ((x @ self.A.T) @ self.B.T)

    @torch.no_grad()
    def merged_weight(self):
        """W + (alpha/r) * B @ A : ce que l'adaptateur devient apres fusion."""
        return self.base.weight + self.scaling * (self.B @ self.A)


lora_head = LoRALinear(nn.Linear(512, 10), r=4, alpha=8)
print(lora_head)
LoRALinear(
  (base): Linear(in_features=512, out_features=10, bias=True)
)

Lecture du résultat : trois invariants dans la classe

  • requires_grad_(False) sur les paramètres de base : le gradient ne descend jamais jusqu’à W. Ce n’est pas une convention de nommage, c’est le graphe autograd qui est coupé.
  • B naît à zéro : quelle que soit la valeur tirée de A, le produit B @ A vaut zéro — le modèle adapté démarre exactement là où le modèle de base se trouve.
  • (x @ A.T) @ B.T et non x @ (A.T @ B.T).T : on multiplie par la gauche puis par la droite, en passant par l’espace de rang r. Faire la fusion avant le forward coûterait d x k par appel — soit exactement ce que LoRA évite.

Un détail d’implémentation à connaître : ici alpha = 8 et r = 4, donc scaling = 2.0. La convention du papier est alpha fixé une fois (souvent 8 ou 16) et r varié — le scaling compense alors le rang pour que changer r ne change pas l’échelle du signal appris.

2. Les invariants, vérifiés sur tenseurs réels

Affirmer « le delta vaut zéro au départ » et « W ne reçoit pas de gradient » est une chose ; le vérifier sur le graphe autograd réel en est une autre. Les trois contrôles ci-dessous sont ceux qu’on écrirait dans un test unitaire du module.

d, k, r = 512, 10, 4
base = nn.Linear(d, k)
layer = LoRALinear(base, r=r, alpha=8)
x = torch.randn(64, d)

# -- invariant 1 : B = 0  =>  LoRA(x) == base(x) exactement ---------------
with torch.no_grad():
    ecart_init = (layer(x) - base(x)).abs().max().item()

# -- invariant 2 : W gele  =>  aucun gradient n'atteint base ---------------
loss = layer(x).pow(2).mean()
loss.backward()
w_grad = base.weight.grad

# -- budget : forme fermee r * (d + k) -------------------------------------
n_train = sum(p.numel() for p in layer.parameters() if p.requires_grad)
n_total = sum(p.numel() for p in layer.parameters())
fermee = r * (d + k)

print(f"ecart initial   max|LoRA(x) - W(x)| = {ecart_init:.3e}"
      f"   {'(exactement 0)' if ecart_init == 0.0 else '(NON NUL -- init non canonique)'}")
print(f"gradient sur W  {w_grad if w_grad is not None else 'None'}"
      f"   {'(correct : coupe)' if w_grad is None else '(FUITE)'}")
print(f"gradient sur A / B                  : {layer.A.grad is not None} / {layer.B.grad is not None}")
print(f"parametres entrainables             : {n_train} = r*(d+k) = {fermee}"
      f"   {'(verifie)' if n_train == fermee else '(ECART)'}")
print(f"sur {n_total} parametres totaux       : {n_train / n_total:.2%}")
ecart initial   max|LoRA(x) - W(x)| = 0.000e+00   (exactement 0)
gradient sur W  None   (correct : coupe)
gradient sur A / B                  : True / True
parametres entrainables             : 2088 = r*(d+k) = 2088   (verifie)
sur 7218 parametres totaux       : 28.93%

Lecture du résultat : l’identité initiale est exacte

Trois lectures, dans l’ordre d’importance :

  1. max|LoRA(x) - W(x)| = 0.000e+00 — pas « proche de zéro », pas « de l’ordre de l’epsilon machine » : exactement zéro. C’est une identité algébrique (0 @ A = 0), pas une approximation numérique. Toute implémentation qui affiche autre chose ici a une initialisation non canonique — c’est le test à écrire en premier.
  2. base.weight.grad is None : autograd n’a même pas alloué de gradient pour W. Le gel n’est pas une question de vitesse, c’est une coupure du graphe.
  3. 2 088 = 4 x (512 + 10) : le compte paramètres vérifié contre la forme fermée. C’est le chiffre qui remplace les « ~0,5 % de paramètres » des articles — ici sur une couche jouet où le ratio n’est pas spectaculaire ; il le devient quand d et k valent 4096 (r=4 : 0,2 % du delta).

3. Mini-tâche : adapter un classifieur gelé au « négatif photo »

Pour que la démonstration ait un enjeu, il faut un modèle de base compétent sur son domaine d’entraînement et incompétent sur le domaine cible. Fashion-MNIST s’y prête avec un décalage d’une ligne : l’inversion des pixels (x -> 1 - x), le négatif photo.

Le protocole :

  1. entraîner un petit CNN (3 blocs conv + une tête linéaire, ~29 k paramètres) sur les images inversées ;
  2. mesurer son exactitude sur le jeu de test inversé (son domaine) et sur le test normal (la cible) ;
  3. geler tout le réseau, puis l’adapter au domaine normal deux fois : une avec LoRA r = 4 sur deux couches, une en full fine-tuning des mêmes deux couches — mêmes epochs, même optimizer, même learning rate.

Un décalage de luminosité multiplicatif (x * 0.4) ne convient pas : un empilement conv + ReLU + max-pool sans normalisation est positivement homogène, il absorbe la mise à l’échelle et l’écart mesuré reste de l’ordre du point. L’inversion, elle, déplace réellement la distribution.

import os

DATA_DIR = os.path.join(os.path.expanduser("~"), ".cache", "ft00a")  # telecharge une fois

tf = transforms.ToTensor()
train_set = datasets.FashionMNIST(DATA_DIR, train=True, download=True, transform=tf)
test_set = datasets.FashionMNIST(DATA_DIR, train=False, download=True, transform=tf)
train_loader = torch.utils.data.DataLoader(train_set, batch_size=256, shuffle=True)
test_loader = torch.utils.data.DataLoader(test_set, batch_size=512, shuffle=False)
print(f"Fashion-MNIST : {len(train_set)} images d'entrainement / {len(test_set)} de test")


def inverse(x):
    # Le decalage de domaine : negatif photo.
    return 1.0 - x


class SmallCNN(nn.Module):
    # 3 blocs conv+pool puis une tete lineaire. ~29 k parametres.

    def __init__(self):
        super().__init__()
        self.c1 = nn.Conv2d(1, 16, 3, padding=1)
        self.c2 = nn.Conv2d(16, 32, 3, padding=1)
        self.c3 = nn.Conv2d(32, 64, 3, padding=1)
        self.fc = nn.Linear(64 * 3 * 3, 10)

    def forward(self, x):
        x = F.max_pool2d(F.relu(self.c1(x)), 2)   # 28 -> 14
        x = F.max_pool2d(F.relu(self.c2(x)), 2)   # 14 -> 7
        x = F.max_pool2d(F.relu(self.c3(x)), 2)   # 7 -> 3
        return self.fc(x.flatten(1))


def n_params(m):
    return sum(p.numel() for p in m.parameters())


def evaluate(model, inverse_domain, loader):
    model.eval()
    good = tot = 0
    with torch.no_grad():
        for x, y in loader:
            if inverse_domain:
                x = inverse(x)
            pred = model(x.to(DEV)).argmax(1).cpu()
            good += (pred == y).sum().item()
            tot += y.numel()
    return good / tot


def train(model, inverse_domain, epochs=2, lr=1e-3):
    opt = torch.optim.Adam(
        [p for p in model.parameters() if p.requires_grad], lr=lr)
    model.train()
    for ep in range(epochs):
        t0 = time.perf_counter()
        for x, y in train_loader:
            if inverse_domain:
                x = inverse(x)
            loss = F.cross_entropy(model(x.to(DEV)), y.to(DEV))
            opt.zero_grad()
            loss.backward()
            opt.step()
        print(f"  epoch {ep + 1}/{epochs}  loss={loss.item():.4f}"
              f"  ({time.perf_counter() - t0:.1f}s)")


print(f"modele de base : {n_params(SmallCNN())} parametres")
Fashion-MNIST : 60000 images d'entrainement / 10000 de test
modele de base : 29066 parametres

Entraînement du modèle de base (sur images inversées)

torch.manual_seed(SEED)
base_model = SmallCNN().to(DEV)
train(base_model, inverse_domain=True)

acc_base_inv = evaluate(base_model, inverse_domain=True, loader=test_loader)
acc_base_norm = evaluate(base_model, inverse_domain=False, loader=test_loader)
print(f"\ntest INVERSE (son domaine)  : {acc_base_inv:.4f}")
print(f"test NORMAL  (la cible)     : {acc_base_norm:.4f}")
  epoch 1/2  loss=0.5497  (3.7s)
  epoch 2/2  loss=0.3756  (3.1s)

test INVERSE (son domaine)  : 0.8325
test NORMAL  (la cible)     : 0.0363

Lecture du résultat : compétent sur son domaine, muet sur la cible

Le modèle de base atteint ~0,83 sur le test inversé — il a bien appris la tâche. Sur le test normal il s’effondre à ~0,04, sous le hasard (1/10 = 0,10) : il n’est pas simplement ignorant, il répond systématiquement à côté. C’est exactement la situation où l’adaptation a un sens : les caractéristiques de bas niveau (contours, textures) restent utiles, mais elles sont lues « à l’envers ».

C’est aussi le régime des vrais cas d’usage : un modèle pré-entraîné compétent quelque part, et une cible qui le décale. On ne repart pas de zéro — on adapte.

4. Deux adaptations du même réseau gelé : LoRA r = 4 vs full

On gèle une copie du modèle de base, puis on construit deux adaptations :

  • LoRA : deux adaptateurs — le dernier bloc convolutif et la tête linéaire, chacun en rang 4. Le reste du réseau (les deux premiers blocs) reste gelé.
  • full : les mêmes deux couches, entièrement dégelées.

Même nombre d’epochs, même optimizer, même learning rate. La seule différence est le nombre de paramètres entraînables — c’est la variable dont on mesure l’effet.

Pour la couche convolutive, la décomposition a une subtilité par rapport au cas linéaire : A est une convolution in -> r (noyau k x k) et B une convolution 1 x 1 r -> out. Le budget devient r * (in * k² + out), et non r * (in + out) — le noyau 3 x 3 pèse neuf fois.

class LoRAConv2d(nn.Module):
    """y = conv(x, W) + (alpha/r) * conv_1x1(conv_3x3(x, A), B)   -- W gele."""

    def __init__(self, base: nn.Conv2d, r: int, alpha: float):
        super().__init__()
        self.base = base
        for p in self.base.parameters():
            p.requires_grad_(False)
        self.r, self.alpha = r, alpha
        self.scaling = alpha / r
        self.A = nn.Conv2d(base.in_channels, r, base.kernel_size,
                           stride=base.stride, padding=base.padding, bias=False,
                           device=base.weight.device, dtype=base.weight.dtype)
        self.B = nn.Conv2d(r, base.out_channels, 1, bias=False,
                           device=base.weight.device, dtype=base.weight.dtype)
        nn.init.kaiming_uniform_(self.A.weight, a=5 ** 0.5)
        nn.init.zeros_(self.B.weight)          # meme invariant : delta nul au pas 0

    def forward(self, x):
        return self.base(x) + self.scaling * self.B(self.A(x))


def trainable(m):
    return sum(p.numel() for p in m.parameters() if p.requires_grad)


# -- reseau gele -----------------------------------------------------------------
frozen = copy.deepcopy(base_model)
for p in frozen.parameters():
    p.requires_grad_(False)

# -- adaptation LoRA : deux adaptateurs de rang 4 ----------------------------------
lora_model = copy.deepcopy(frozen)
lora_model.c3 = LoRAConv2d(lora_model.c3, r=4, alpha=8).to(DEV)
lora_model.fc = LoRALinear(lora_model.fc, r=4, alpha=8).to(DEV)
n_lora = trainable(lora_model)
t_lora = n_params(lora_model)

conv3_A = 4 * (32 * 3 * 3)          # r * (in * k^2)
conv3_B = 64 * 4 * 1 * 1            # out * r
fc_budget = 4 * (576 + 10)          # r * (in + out)

print(f"LoRA  parametres entrainables : {n_lora} / {t_lora}  ({n_lora / t_lora:.2%})")
print(f"      dont conv3 (r=4)        : {conv3_A + conv3_B}"
      f"  = r*(in*k^2) + out*r  = {conv3_A} + {conv3_B}")
print(f"      dont fc    (r=4)        : {fc_budget}  = r*(in+out)")

# -- adaptation full : les memes deux couches, degelrees ---------------------------
full_model = copy.deepcopy(frozen)
for m_ in (full_model.c3, full_model.fc):
    for p in m_.parameters():
        p.requires_grad_(True)
n_full = trainable(full_model)
print(f"full  parametres entrainables : {n_full} / {n_params(full_model)}"
      f"  ({n_full / n_params(full_model):.2%})")
LoRA  parametres entrainables : 3752 / 32818  (11.43%)
      dont conv3 (r=4)        : 1408  = r*(in*k^2) + out*r  = 1152 + 256
      dont fc    (r=4)        : 2344  = r*(in+out)
full  parametres entrainables : 24266 / 29066  (83.49%)
print("[LoRA] adaptation au domaine normal, 2 epochs")
torch.manual_seed(SEED)
train(lora_model, inverse_domain=False)
acc_lora = evaluate(lora_model, inverse_domain=False, loader=test_loader)

print("\n[full] adaptation au domaine normal, 2 epochs")
torch.manual_seed(SEED)
train(full_model, inverse_domain=False)
acc_full = evaluate(full_model, inverse_domain=False, loader=test_loader)

print()
print("=" * 74)
print(f"{'configuration':<28}{'exactitude':>12}{'params entr.':>14}{'part':>9}")
print("-" * 74)
print(f"{'base, test inverse':<28}{acc_base_inv:>12.4f}{'(gele)':>14}{'':>9}")
print(f"{'base, test normal':<28}{acc_base_norm:>12.4f}{'(gele)':>14}{'':>9}")
print(f"{'LoRA r=4 (2 couches)':<28}{acc_lora:>12.4f}{n_lora:>14}{n_lora / t_lora:>9.2%}")
print(f"{'full (memes 2 couches)':<28}{acc_full:>12.4f}{n_full:>14}"
      f"{n_full / n_params(full_model):>9.2%}")
print("=" * 74)
[LoRA] adaptation au domaine normal, 2 epochs
  epoch 1/2  loss=1.0449  (3.9s)
  epoch 2/2  loss=0.5406  (4.0s)

[full] adaptation au domaine normal, 2 epochs
  epoch 1/2  loss=0.5569  (3.8s)
  epoch 2/2  loss=0.3553  (3.8s)

==========================================================================
configuration                 exactitude  params entr.     part
--------------------------------------------------------------------------
base, test inverse                0.8325        (gele)         
base, test normal                 0.0363        (gele)         
LoRA r=4 (2 couches)              0.7351          3752   11.43%
full (memes 2 couches)            0.8242         24266   83.49%
==========================================================================

Lecture du résultat : ce que 3 752 paramètres récupèrent

À lire dans le tableau ci-dessus, dans cet ordre :

  1. Le réseau gelé ne récupère rien seul — la ligne « base, test normal » est le point de départ de l’adaptation, pas un plancher optimiste.
  2. LoRA remonte l’essentiel de l’écart : de ~0,04 à ~0,74, avec 3 752 paramètres entraînables sur 32 818 (11,4 %). Le delta appris par deux adaptateurs de rang 4 restitue la majorité de ce que les mêmes couches dégélées récupèrent intégralement.
  3. Le plein fine-tuning fait mieux (~0,82), et il faut le dire : un rang 4 est un sous-espace de dimension 4 par couche — il capte le décalage dominant, pas tout. La contrepartie est le budget : 24 266 paramètres entraînés, soit 6,5 fois plus, plus les états Adam qui vont avec (deux copies par paramètre), alors que l’adaptateur se stocke et se partage en 3 752 valeurs.

Sur ce jouet de 29 k paramètres, deux couches représentent déjà 83 % du modèle — le ratio « 11 % des paramètres » n’a donc rien de spectaculaire ici. L’argument de LoRA se joue à l’échelle des vraies matrices : sur une couche 4096 x 4096, le rang 4 coûte 0,2 % du delta ; sur GPT-2 complet, les ratios mesurés dans FT-01 tombent sous le pourcent. Le mécanisme est exactement celui que vous venez d’écrire ; seule la taille des matrices change.

5. La fusion : W + (alpha/r) * B @ A, et pourquoi ce n’est pas bit-exact

Après entraînement, l’adaptateur peut être fusionné : on remplace W par W + (alpha/r) * B @ A et on jette A et B. Le modèle redevient un nn.Linear ordinaire — zéro coût au service, zéro dépendance à l’inférence.

L’intuition dit « c’est le même modèle ». L’arithmétique flottante dit non. Comparez les deux chemins sur la même entrée :

  • non fusionné : x @ W.T + scaling * ((x @ A.T) @ B.T)
  • fusionné : x @ (W + scaling * (B @ A)).T

Mêmes mathématiques, ordre d’association différent — et en IEEE 754, l’addition flottante n’est pas associative. La fusion est une réécriture du calcul, pas une identité.

# L'adaptateur entraine de la tete lineaire
lora_fc = lora_model.fc

# Reconstruction de la couche fusionnee : poids merges, meme biais
merged_fc = nn.Linear(lora_fc.base.in_features, lora_fc.base.out_features).to(DEV)
with torch.no_grad():
    merged_fc.weight.copy_(lora_fc.merged_weight())
    merged_fc.bias.copy_(lora_fc.base.bias)

x_probe = torch.randn(128, lora_fc.base.in_features, device=DEV)
with torch.no_grad():
    drift32 = (merged_fc(x_probe) - lora_fc(x_probe)).abs().max().item()

# Controle : la meme comparaison en float64, ou l'epsilon est 2^29 fois plus petit
in_f, out_f = lora_fc.base.in_features, lora_fc.base.out_features
lora64 = LoRALinear(nn.Linear(in_f, out_f), r=4, alpha=8).double()
with torch.no_grad():
    lora64.base.weight.copy_(lora_fc.base.weight.detach().cpu())
    lora64.base.bias.copy_(lora_fc.base.bias.detach().cpu())
    lora64.A.copy_(lora_fc.A.detach().cpu())
    lora64.B.copy_(lora_fc.B.detach().cpu())
x64 = torch.randn(128, in_f, dtype=torch.float64)
y_adapter64 = lora64(x64)
y_merged64 = x64 @ lora64.merged_weight().T + lora64.base.bias
drift64 = (y_merged64 - y_adapter64).abs().max().item()

delta_norm = (lora_fc.scaling * (lora_fc.B @ lora_fc.A)).norm().item()
w_norm = lora_fc.base.weight.norm().item()

print(f"derive max float32 |merged(x) - LoRA(x)| = {drift32:.3e}")
print(f"controle float64 (epsilon ~2e-16)        = {drift64:.3e}")
print(f"||delta W|| = {delta_norm:.4f}  vs  ||W|| = {w_norm:.4f}"
      f"   (le delta vaut {delta_norm / w_norm:.0%} du poids)")
derive max float32 |merged(x) - LoRA(x)| = 1.907e-06
controle float64 (epsilon ~2e-16)        = 3.109e-15
||delta W|| = 1.5101  vs  ||W|| = 3.2893   (le delta vaut 46% du poids)

Lecture du résultat : la fusion est une réécriture, pas une identité

  • float32 : la dérive est de l’ordre de 1e-6 — minuscule, mais non nulle, et elle n’est pas un bug : c’est le prix de la ré-association des opérations. À l’échelle de l’epsilon float32 (1.2e-7), une dérive de 1e-6 sur un vecteur de dimension 576 est exactement ce que l’arrondi d’association produit.
  • Le contrôle float64 confirme le diagnostic : la même comparaison en double précision tombe de près de trois ordres de grandeur plus bas. Si la dérive venait d’une erreur d’implémentation, elle ne bougerait pas avec la précision.
  • Le delta appris n’est pas petit : ||delta W|| vaut une fraction substantielle de ||W|| (typiquement la moitié ici). Ce n’est pas un ajustement cosmétique — l’adaptateur a déplacé la couche pour de bon, et la fusion le conserve.

Conséquence pratique : si un pipeline compare bit à bit un modèle avant et après fusion, il trouvera une différence — et il aura raison. La bonne garantie est « équivalent à l’epsilon de la précision près », pas « identique ». C’est la même famille de distinction que pour toute réécriture numérique : équivalent et identique sont deux claims différents, et seul le second se prouve bit à bit.

Préparation des exercices

Les trois exercices sont progressifs : le premier fait varier le rang et mesure le budget, le second découple alpha du rang, le troisième vous fait ré-écrire la fusion et sa vérification. Chaque cellule s’exécute telle quelle (les stubs ne lèvent pas d’erreur) ; à vous de remplacer les TODO.

# Exercice 1 : le rang et le budget
# TODO etudiant : completez budget(r) pour qu'elle retourne le nombre de
# parametres entrainables d'un LoRALinear(512 -> 10, rang r), puis verifiez
# que la part du delta (budget / 5120) decroit comme attendu pour
# r dans [1, 2, 4, 8, 16, 64]. Enfin, pour r = 64 : que vaut le budget
# compare au full (5120) ? Que se passe-t-il quand r >= min(512, 10) ?


def budget(r, d=512, k=10):
    # Indice : forme fermee de la section 2.
    result = None  # TODO etudiant
    return result


print("Exercice 1 a completer -- budget(r) retourne", budget(4))
Exercice 1 a completer -- budget(r) retourne None

Exercice 2 : découpler alpha du rang

La classe lie les deux par scaling = alpha / r. Deux configurations (r=4, alpha=4) et (r=4, alpha=16) ont le même rang mais des scalings 1 et 4 : la seconde autorise un delta quatre fois plus ample, au prix d’une dynamique plus raide (risque d’instabilité si le pas d’apprentissage n’est pas réduit).

# Exercice 2 : alpha vs r
# TODO etudiant : entrainez deux LoRALinear (r=4, alpha=4) et (r=4, alpha=16)
# sur la meme mini-tache de regression (par ex. ajuster y = sinus bruite),
# avec le MEME optimizer et le MEME learning rate (1e-2), 200 pas chacun.
# Mesurez ||delta W|| final pour chacun, et la perte finale. Constatez :
#   - le scaling multiplie l'ampleur du delta ;
#   - a learning rate egal, alpha=16 descend... ou diverge. Lequel ?


def compare_alpha(d=64, k=8, steps=200):
    # Indice : reutilisez la boucle de la section 2 (loss.backward + Adam),
    # sur deux modeles separes, graine identique avant chaque tirage.
    result = None  # TODO etudiant
    return result


print("Exercice 2 a completer -- compare_alpha() retourne", compare_alpha())
Exercice 2 a completer -- compare_alpha() retourne None

Exercice 3 : ré-écrire la fusion et borner sa dérive

merged_weight() existe déjà ; écrivez sa contre-vérification : reconstruisez la couche fusionnée, comparez-la à la couche adaptée sur un lot d’entrées, et bornez la dérive en fonction de la précision.

# Exercice 3 : fusion et borne de derive
# TODO etudiant : ecrivez derive_fusion(layer, n_echantillons=256) qui
# retourne l'ecart max |merged(x) - LoRA(x)| pour des x aleatoires.
# Testez-la en float32 puis en float64 (layer.double()) et verifiez que la
# derive suit l'epsilon de la precision -- c'est le controle de la section 5.


def derive_fusion(layer, n_echantillons=256):
    # Indice : nn.Linear(in, out).weight.copy_(...) pour la couche fusionnee ;
    # n'oubliez pas le biais ; restez sous torch.no_grad().
    result = None  # TODO etudiant
    return result


print("Exercice 3 a completer -- derive_fusion() retourne", derive_fusion(lora_model.fc))
Exercice 3 a completer -- derive_fusion() retourne None

Résumé

notion ce qu’il faut retenir
la décomposition W' = W + (alpha/r) * B @ A, budget r (d + k) au lieu de d x k (conv : r (in k² + out))
l’initialisation A ~ N, B = 0 ⇒ W' = W exactement au pas 0 — vérifiable en un test
le gel requires_grad_(False) coupe le graphe : W ne reçoit même pas de gradient alloué
l’entraînement seuls A, B bougent ; l’adaptateur se stocke en r (d + k) valeurs
la mesure rang 4 sur 2 couches : ~11 % des paramètres, l’essentiel de l’exactitude récupérée — le plein fait mieux, à 6,5 x le budget
la fusion équivalente à l’epsilon près, jamais bit-exacte : la ré-association n’est pas l’identité

Étape suivante : le même mécanisme à l’échelle industrielle — FT-01 (LoRA peft sur GPT-2), FT-02 (QLoRA : base 4-bit + adaptateurs), FT-06 (vision-langage). Vous y reconnaîtrez chaque pièce que vous venez d’écrire à la main.

Retour au sommet