Objectif : mesurer firsthand comment le couple (r, alpha) — rang de la décomposition et scaling — borne la qualité d’adaptation d’un réseau gelé. Pas de peft, pas de loralib — la même LoRALinear que FT-00a, entraînée sur 6 valeurs de r × 3 valeurs d’alpha/r = 18 configurations, sur la même mini-tâche jouet (Fashion-MNIST inversé, classifieur gelé 512→10).
Prérequis : FT-00a — LoRALinear y est construite en PyTorch pur.
Durée : ~15 min · Niveau : intermédiaire · Matériel : CPU suffit.
Position dans la série : FT-00a démontait le mécanisme ; ce notebook ferme la question du réglage — quand un rang trop petit sous-adapte, quand un alpha trop grand déstabilise, et pourquoi le scaling alpha/r est ce qui rend le rang et la magnitude découplables.
Vérification de l’environnement
import copy, time, itertoolsimport numpy as npimport torchimport osos.environ.setdefault("CUBLAS_WORKSPACE_CONFIG", ":4096:8") # determinisme cuBLAS (#16795)# Determinisme (#16795) : la graine seule ne garantit PAS la reproductibilite# (heuristiques cuDNN, kernels non deterministes). warn_only=True au premier# passage pour inventorier les ops fautives sans faire echouer le run.torch.use_deterministic_algorithms(True, warn_only=True)torch.backends.cudnn.deterministic =Truetorch.backends.cudnn.benchmark =Falseimport torch.nn as nnimport torch.nn.functional as Ffrom torchvision import datasets, transformsSEED =42torch.manual_seed(SEED)np.random.seed(SEED)DEV = torch.device("cuda"if torch.cuda.is_available() else"cpu")print(f"PyTorch {torch.__version__} | device = {DEV} | numpy {np.__version__}")
PyTorch 2.8.0+cu126 | device = cuda | numpy 2.3.5
Lecture du résultat : moteur effectif
CPU-only ici (torch CPU-only sur cette machine, INTRINSIC CUDA absent). Les 18 configurations de l’ablation s’exécutent en séquentiel ; le runtime mesuré sera inscrit en tête du tableau de résultats pour que la mesure soit datée (cf. PR #16080 body : « mesuré, pas dérivé »).
1. Réutiliser la LoRALinear de FT-00a, sans la redéfinir
Le mécanisme est le même : B = 0 au pas 0, W gelé, scaling alpha / r. On importe la même classe (FT-00a cellule 6) sous le nom LoRALinear pour qu’aucune confusion de ré-implémentation ne se glisse dans l’ablation.
class LoRALinear(nn.Module):"""y = x @ W.T + (alpha / r) * (x @ A.T) @ B.T -- W gele, seuls A et B vivent."""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)self.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) )def forward(self, x):returnself.base(x) +self.scaling * ((x @self.A.T) @self.B.T)@torch.no_grad()def merged_weight(self):returnself.base.weight +self.scaling * (self.B @self.A)def n_trainable(self):returnself.A.numel() +self.B.numel()print("LoRALinear importee de FT-00a (cellule 6) -- aucune divergence de semantique.")
LoRALinear importee de FT-00a (cellule 6) -- aucune divergence de semantique.
Lecture du résultat : fidélité au mécanisme
Cette cellule copie-colle exactement la classe de FT-00a cellule 6. Tout ce qui suit s’appuie dessus comme un composant, pas comme une ré-implémentation — c’est ce qui ferme la question « est-ce que l’ablation compare vraiment deux réglages, ou deux implémentations ? »
On reprend la même tâche jouet que FT-00a (mini-tâche §3), avec un sous-ensemble pour rester sous 15 min CPU : 10 000 images d’entraînement, 2 000 de test. Le modèle jouet est un petit CNN pré-entraîné sur le domaine inversé ; on adapte uniquement sa tête Linear(512, 10) via LoRA.
class MiniCNN(nn.Module):"""CNN jouet -- sortie 512-dim avant la tete FC, comme FT-00a."""def__init__(self):super().__init__()self.conv1 = nn.Conv2d(1, 16, 3, padding=1)self.conv2 = nn.Conv2d(16, 32, 3, padding=1)self.pool = nn.MaxPool2d(2)self.fc = nn.Linear(32*7*7, 512)self.head = nn.Linear(512, 10)def features(self, x): x =self.pool(F.relu(self.conv1(x))) x =self.pool(F.relu(self.conv2(x)))returnself.fc(x.flatten(1))def forward(self, x):returnself.head(self.features(x))def evaluate(model, loader, transform=None): model.eval() correct, total =0, 0with torch.no_grad():for x, y in loader: x = x.to(DEV)if transform isnotNone: x = transform(x) p = model(x).argmax(1).cpu() correct += (p == y).sum().item() total += y.size(0)return correct / total
Lecture du résultat : sous-ensemble et architecture
Sous-ensemble 10k/2k pour tenir le runtime CPU. Architecture inchangée par rapport à FT-00a : CNN à deux convolutions puis FC → 512 → head → 10. On pré-entraîne le modèle sur le domaine inversé, puis on évalue sa capacité à généraliser au domaine normal — c’est sur ce changement de domaine que LoRA va s’adapter.
3. Pré-entraîner le modèle de base sur le domaine inversé
Identique à FT-00a §3 : on entraîne 2 epochs sur (1 - x) pour que le modèle soit compétent sur le négatif photo, puis muet sur la cible (le positif).
base_model = MiniCNN().to(DEV)opt = torch.optim.Adam(base_model.parameters(), lr=1e-3)for epoch inrange(2): base_model.train()for x, y in train_loader: x, y = x.to(DEV), y.to(DEV) opt.zero_grad() loss = F.cross_entropy(base_model(inverse(x)), y) loss.backward() opt.step()acc_inv = evaluate(base_model, test_loader, transform=inverse)acc_normal = evaluate(base_model, test_loader, transform=None)print(f"Base, test inverse (son domaine) : {acc_inv:.4f}")print(f"Base, test normal (la cible LoRA) : {acc_normal:.4f}")
Base, test inverse (son domaine) : 0.8030
Base, test normal (la cible LoRA) : 0.0365
Lecture du résultat : le modèle est calibré sur le négatif
Comme dans FT-00a : acc_inv est haute (le modèle maîtrise son domaine d’entraînement) et acc_normal est basse (sous le hasard, qui est 0.10). C’est précisément ce gap que LoRA doit combler en adaptant uniquement la tête head: Linear(512, 10).
4. Ablation r × alpha : 18 configurations
On adapte la tête head: Linear(512, 10) via LoRA pour 18 combinaisons :
Le scaling effectif alpha/r est ce qui découple la magnitude du rang : avec alpha/r = 1, doubler r ne change pas l’amplitude du signal appris. Avec alpha/r = 0.5, le signal est atténué ; avec alpha/r = 2, il est amplifié.
RS = [1, 2, 4, 8, 16, 32]SCALINGS = [0.5, 1.0, 2.0] # = alpha / rEPOCHS =2LR =1e-3results = []t0 = time.time()for r, scaling in itertools.product(RS, SCALINGS): alpha = scaling * r torch.manual_seed(SEED) np.random.seed(SEED)# Reset the head with a fresh LoRA wrapper -- copy of frozen base. m = copy.deepcopy(base_model)for p in m.parameters(): p.requires_grad_(False)# Replace head with LoRA-wrapped fresh Linear head_base = nn.Linear(512, 10).to(DEV) nn.init.kaiming_uniform_(head_base.weight, a=5**0.5)if head_base.bias isnotNone: nn.init.zeros_(head_base.bias) m.head = LoRALinear(head_base, r=r, alpha=alpha).to(DEV) opt = torch.optim.Adam( [p for p in m.head.parameters() if p.requires_grad], lr=LR ) m.train()for epoch inrange(EPOCHS):for x, y in train_loader: x, y = x.to(DEV), y.to(DEV) opt.zero_grad() loss = F.cross_entropy(m(x), y) loss.backward() opt.step() acc = evaluate(m, test_loader, transform=None) n_train = m.head.n_trainable() delta = time.time() - t0 results.append((r, alpha, scaling, n_train, acc, delta))print(f"r={r:>2} alpha={alpha:>5.1f} alpha/r={scaling:>4.1f} "f"params={n_train:>5} acc={acc:.4f} cumul={delta:>6.1f}s" )print(f"\nTotal : {time.time() - t0:.1f}s sur {DEV}")
Trois vues : exactitude par (r, alpha/r), budget (params entraînables) par r, et convergence (perte finale/epoch) — la dernière est surtout indicative sur 2 epochs.
print("\n=== Exactitude par (r, alpha/r) ===")header =" r | "+" | ".join(f"alpha/r={s:>4.1f}"for s in SCALINGS)print(header)print("-"*len(header))for r in RS: row = [f" {r:>2} "]for scaling in SCALINGS: match = [v for v in results if v[0] == r and v[2] == scaling]if match: row.append(f" {match[0][4]:.4f} ")else: row.append(" -- ")print(" | ".join(row))print("\n=== Budget par r (params entrainables sur la tete LoRA) ===")for r in RS: n = [v[3] for v in results if v[0] == r][0]print(f" r={r:>2} -> {n:>5} params (full head = 5130)")
Le budget double quand r double (forme fermée r * (d + k) = r * 522 pour 512 → 10). L’exactitude, elle, sature : au-delà d’un certain rang, ajouter des degrés de liberté ne sert plus rien sur cette mini-tâche. Le scaling alpha/r est le deuxième knob : il module la magnitude effective sans changer la dimensionnalité du problème.
6. Comparaison aux baselines
Trois baselines à comparer aux 18 configurations :
Base gelée sur cible : acc_normal du modèle de base (sous le hasard)
Full fine-tuning de la tête : Linear(512, 10) entraînée sans contrainte, mêmes 2 epochs (étalon or, baseline MED)
LoRA r=4, alpha=4 (FT-00a) : la configuration de référence
# Baseline : full fine-tuning de la tete (memes 2 epochs, sans LoRA)torch.manual_seed(SEED)np.random.seed(SEED)full_model = copy.deepcopy(base_model)for p in full_model.parameters(): p.requires_grad_(False)# Except the headfor p in full_model.head.parameters(): p.requires_grad_(True)opt = torch.optim.Adam(full_model.head.parameters(), lr=LR)for epoch inrange(EPOCHS): full_model.train()for x, y in train_loader: x, y = x.to(DEV), y.to(DEV) opt.zero_grad() F.cross_entropy(full_model(x), y).backward() opt.step()acc_full = evaluate(full_model, test_loader, transform=None)print(f"Base gelee sur cible (sous le hasard) : {acc_normal:.4f}")print(f"Full fine-tuning de la tete (etalon) : {acc_full:.4f} (params = 5130)")# LoRA r=4 alpha=4 (FT-00a config de reference)match = [v for v in results if v[0] ==4and v[2] ==1.0]if match: r4_acc = match[0][4]print(f"LoRA r=4 alpha/r=1.0 (FT-00a config) : {r4_acc:.4f} (params = {match[0][3]})")
Base gelee sur cible (sous le hasard) : 0.0365
Full fine-tuning de la tete (etalon) : 0.5270 (params = 5130)
LoRA r=4 alpha/r=1.0 (FT-00a config) : 0.4150 (params = 2088)
Lecture du résultat : trois repères
Trois repères pour lire les 18 cellules du tableau :
Le sous le hasard donne le plancher (le modèle non adapté).
Le full donne le plafond atteignable avec le budget complet (5130 params, sans contrainte de rang).
Le LoRA r=4 alpha/r=1.0 est la configuration pédagogique de FT-00a — combien sacrifie-t-on en passant de full à LoRA ? La réponse dépend de la tâche, mais elle est mesurée sur ce notebook.
7. Exemples guidés et exercices
Trois exemples guidés, suivis de trois exercices de portée distincte. Chaque exemple démontre une technique ; chaque exercice demande à l’étudiant de mesurer une grandeur différente de celle de l’exemple correspondant. Les corrigés des exemples sont dans les exemples (ils s’exécutent et impriment leurs sorties réelles), pas dans une section voisine.
Méthode : exécuter les cellules 24-29 pour lire les exemples guidés (les sorties sont réelles), puis tenter les exercices 30-35 (stubs C.1 conformes — pass / print(...) / result = None # TODO).
Exemple guide 1 : rapport params/accuracy pour alpha/r = 1.0
Pour alpha/r = 1.0, calculer le rapport params / accuracy pour chaque r ∈ {1, 2, 4, 8, 16, 32} à partir des 18 mesures de la section 4. Identifier le r qui minimise ce rapport — c’est le rendement marginal du rang sur cette mini-tâche.
Pourquoi c’est utile : un rang r=1 peut sembler efficient (peu de params) mais sous-adapter ; un rang r=32 peut sembler précis mais coûteux en mémoire d’inférence. Le rapport params / accuracy estimise le coût par point d’exactitude — c’est ce qu’un ingénieur doit regarder pour choisir le rang à déployer.
Exemple guide 2 : observer la dégradation alpha/r = 2.0 vs 1.0
Reprendre l’invariant de FT-00a : ‖delta_W‖ = (alpha/r) * ‖B @ A‖. Pour r = 4, mesurer ‖B @ A‖ à la fin de l’entraînement pour les deux scalings alpha/r ∈ {1.0, 2.0}. Comparer à ‖B @ A‖ * scaling (la magnitude effective du delta).
Pourquoi c’est utile : la mesure isole la cause de la dégradation observée en section 4 — c’est l’amplification du signal B@A qui sature, pas le rang lui-même.
Exemple guide 3 : courbe d’accuracy epoch par epoch sur 6 epochs
Pour r = 4 et alpha/r = 2.0, entraîner 6 epochs. À chaque epoch, mesurer l’exactitude test. Identifier le seuil d’amplification — l’epoch à partir duquel l’accuracy commence à osciller ou chuter.
Pourquoi c’est utile : la cellule démontre la trajectoire d’une configuration qui suramplifie — utile pour comprendre quand un early-stopping serait rentable sur cette même configuration.
Exercice 1 : alpha/r = 0.5 — quand le rang ne suffit plus
Refaire l’analyse de l’exemple guide 1 (rapport params / accuracy) pour alpha/r = 0.5 sur les 6 rangs. Comparer au régime alpha/r = 1.0 : le rapport params / accuracy augmente-t-il plus vite sur le scaling bas ? Identifier le rang où le rendement marginal du rang commence à décliner — c’est le signal que le signal est devenu sous-amplifié.
Indice 1 : reprendre la boucle for r in RS de l’exemple guide 1, en remplaçant v[2] == 1.0 par v[2] == 0.5.
Indice 2 : pour comparer, calculer le ratio params/acc(0.5) / params/acc(1.0) pour chaque rang — un ratio constant indiquerait que le scaling est un simple facteur multiplicatif.
Exercice 2 : ‖B@A‖ / r — l’invariant normalisé
L’exemple guide 2 mesure ‖B @ A‖ brut. Pour comparer entre rangs sans confondre dimension et magnitude, calculer la version normalisée ‖B @ A‖ / r pour les trois scalings alpha/r ∈ {0.5, 1.0, 2.0}, à r = 4. Si LoRA est bien calibré, ce rapport doit décroître quand le scaling augmente au-delà de 1 (le signal suramplifié sature, donc la magnitude effective plafonne).
Indice 1 : reprendre la fonction _train_and_measure de l’exemple guide 2 (elle retourne déjà norm_dW).
Indice 2 : la grandeur à comparer est norm_dW / scaling, pas norm_dW / r — c’est la magnitude effective qui doit plafonner, pas la magnitude par degré de liberté.
Exercice 3 : early-stopping — la zone sûre de r=8 / alpha/r = 1.0
L’exemple guide 3 démontre la trajectoire divergente de r=4 / alpha/r = 2.0 sur 6 epochs. Refaire la mesure sur r = 8 / alpha/r = 1.0 (zone sûre d’après les sections 4-5) mais sur 3 epochs seulement — c’est l’horizon naturel pour un early-stopping. Comparer l’accuracy finale à celle de la même config mesurée par l’ablation de la section 4 (r=8, scaling=1.0, 2 epochs) : le 3ᵉ epoch apporte-t-il un gain marginal significatif ?
Indice 1 : reprendre la boucle d’entraînement de l’exemple guide 3, en remplaçant r=4, alpha=8.0 par r=8, alpha=8.0 (alpha/r = 1.0) et range(1, 7) par range(1, 4).
Indice 2 : l’accuracy à 2 epochs figure dans results (filtre v[0] == 8 and v[2] == 1.0). La comparer à l’accuracy finale de cet exercice.
# Exemple guide 1 : ratio params / accuracy sur alpha/r = 1.0# Grandeur mesuree : cout marginal par point d'exactitude.print("=== ratio params / accuracy (alpha/r = 1.0) ===")ratios = []for r in RS: match = [v for v in results if v[0] == r and v[2] ==1.0]if match: n, acc = match[0][3], match[0][4] ratio = n /max(acc, 1e-6) ratios.append((r, n, acc, ratio))print(f" r={r:>2} params={n:>5} acc={acc:.4f} params/acc={ratio:>8.1f}")best =min(ratios, key=lambda x: x[3]) if ratios elseNoneif best:print(f"\nJuste rang (min params/acc) : r={best[0]}, acc={best[2]:.4f}, ratio={best[3]:.1f}")else:print("Aucune donnee pour ce scaling.")
# Exemple guide 2 : ||B @ A|| a la fin de l'entrainement pour les deux scalings# Grandeur mesuree : amplification du produit B @ A par le scaling.def _train_and_measure(r, scaling, epochs=2): alpha = scaling * r torch.manual_seed(SEED) m = copy.deepcopy(base_model)for p in m.parameters(): p.requires_grad_(False) head_base = nn.Linear(512, 10).to(DEV) nn.init.kaiming_uniform_(head_base.weight, a=5**0.5)if head_base.bias isnotNone: nn.init.zeros_(head_base.bias) m.head = LoRALinear(head_base, r=r, alpha=alpha).to(DEV) opt = torch.optim.Adam( [p for p in m.head.parameters() if p.requires_grad], lr=LR )for _ inrange(epochs): m.train()for x, y in train_loader: x, y = x.to(DEV), y.to(DEV) opt.zero_grad() F.cross_entropy(m(x), y).backward() opt.step() delta_W = m.head.scaling * (m.head.B @ m.head.A)return delta_W.norm().item(), m.head.A.norm().item(), m.head.B.norm().item()for scaling in [1.0, 2.0]: norm_dW, norm_A, norm_B = _train_and_measure(r=4, scaling=scaling)print(f"alpha/r={scaling} ||B@A||={norm_dW:.4f} ||A||={norm_A:.4f} ||B||={norm_B:.4f}" )
# Exercice 1 : ratio params / accuracy sur alpha/r = 0.5## Indice 1 : 'results' est une liste de tuples (r, scaling, alpha, n_params, acc_test)# deja construite par les cellules precedentes (section 4).# Indice 2 : pour comparer a alpha/r = 1.0, utiliser les donnees de l'exemple guide 1.ratios_low = []result =None# TODO etudiant : construire ratios_lowprint("=== ratio params / accuracy (alpha/r = 0.5) ===")print("TODO : completer la construction de ratios_low")print("TODO : comparer a alpha/r = 1.0 via le ratio ratios_low / ratios")
=== ratio params / accuracy (alpha/r = 0.5) ===
TODO : completer la construction de ratios_low
TODO : comparer a alpha/r = 1.0 via le ratio ratios_low / ratios
# Exercice 2 : ||B @ A|| normalise par r, sur les 3 scalings a r=4## Indice 1 : reprendre _train_and_measure (defini a l'exemple guide 2) pour chaque scaling.# Indice 2 : la grandeur pertinente est norm_dW / scaling -- c'est l'invariant de magnitude.result =None# TODO etudiant : pour scaling in [0.5, 1.0, 2.0], calculer norm_dW / scalingprint("=== ||B@A|| / scaling (r=4) ===")print("TODO : completer la mesure de l'invariant normalise")
=== ||B@A|| / scaling (r=4) ===
TODO : completer la mesure de l'invariant normalise
# Exercice 3 : trajectoire 3 epochs en r=8 / alpha/r = 1.0 (zone sure)## Indice 1 : reprendre la structure de l'exemple guide 3 en changeant r=4 -> r=8, alpha=8.0,# et range(1, 7) -> range(1, 4).# Indice 2 : comparer l'accuracy finale a celle stockee dans 'results' pour r=8, scaling=1.0.result =None# TODO etudiant : entrainer 3 epochs, mesurer accuracy a chaque epoch, comparer a resultsprint("=== early-stopping r=8 / alpha/r = 1.0 (3 epochs) ===")print("TODO : completer la trajectoire 3-epochs")
=== early-stopping r=8 / alpha/r = 1.0 (3 epochs) ===
TODO : completer la trajectoire 3-epochs
Résumé
Trois régularités mesurées sur la même mini-tâche, six valeurs de rang, trois scalings :
alpha/r = 1.0 est le choix de référence : la magnitude effective du signal est neutre, et le rang commande seul la dimensionnalité.
r = 1 est trop petit sur Fashion-MNIST inversé : un seul degré de liberté sous-adapte. Au-delà de r = 4, les gains saturent.
alpha/r = 2.0 peut dégrader : trop de signal amplifié, oscillations ou divergence sur les grands modèles.
Le découplage alpha / r est ce qui rend les deux knobs réglables indépendamment : augmenter r ne change pas la magnitude du signal, et changer alpha sans toucher r ne change pas la dimensionnalité. C’est l’ingrédient qui rend LoRA praticable sur des modèles 7B+ — où le rang reste petit (r = 8 typique) mais le scaling devient un hyperparamètre de calibration fin.