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 copyimport timeimport numpy as npimport torchimport torch.nn as nnimport torch.nn.functional as Ffrom torchvision import datasets, transformsSEED =42torch.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}")
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 :
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.
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 = basefor p inself.base.parameters(): p.requires_grad_(False) # invariant 2 : W geleself.r, self.alpha = r, alphaself.scaling = alpha / r dev, dt = base.weight.device, base.weight.dtypeself.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 = 0def 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.returnself.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."""returnself.base.weight +self.scaling * (self.B @self.A)lora_head = LoRALinear(nn.Linear(512, 10), r=4, alpha=8)print(lora_head)
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, 4base = 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.0else'(NON NUL -- init non canonique)'}")print(f"gradient sur W {w_grad if w_grad isnotNoneelse'None'}"f" {'(correct : coupe)'if w_grad isNoneelse'(FUITE)'}")print(f"gradient sur A / B : {layer.A.grad isnotNone} / {layer.B.grad isnotNone}")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 :
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.
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.
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 :
entraîner un petit CNN (3 blocs conv + une tête linéaire, ~29 k paramètres) sur les images inversées ;
mesurer son exactitude sur le jeu de test inversé (son domaine) et sur le test normal (la cible) ;
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 osDATA_DIR = os.path.join(os.path.expanduser("~"), ".cache", "ft00a") # telecharge une foistf = 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.return1.0- xclass 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 -> 3returnself.fc(x.flatten(1))def n_params(m):returnsum(p.numel() for p in m.parameters())def evaluate(model, inverse_domain, loader): model.eval() good = tot =0with 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 / totdef 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 inrange(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)
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 1r -> 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 = basefor p inself.base.parameters(): p.requires_grad_(False)self.r, self.alpha = r, alphaself.scaling = alpha / rself.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 0def forward(self, x):returnself.base(x) +self.scaling *self.B(self.A(x))def trainable(m):returnsum(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 * rfc_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 :
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.
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.
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 lineairelora_fc = lora_model.fc# Reconstruction de la couche fusionnee : poids merges, meme biaismerged_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 petitin_f, out_f = lora_fc.base.in_features, lora_fc.base.out_featureslora64 = 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.biasdrift64 = (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 etudiantreturn resultprint("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 etudiantreturn resultprint("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 etudiantreturn resultprint("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' = Wexactement 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.