Objectif : refaire, avec peft.LoraConfig + peft.get_peft_model, exactement l’adaptation LoRA construite à la main dans FT-00a — même tâche, même split, même graine, mêmes hyperparamètres — puis mesurer ce que l’écosystème apporte : paramètres entraînés, exactitude, lignes de code, temps de fine-tuning, sauvegarde/fusion de l’adaptateur.
Prérequis : FT-00a (la décomposition W' = W + (alpha/r) * B @ A, les invariants d’initialisation, la fusion) — ce notebook en est le pendant industriel.
Durée : ~20 min · Niveau : intermédiaire · Matériel : CPU suffit.
Position dans la série : FT-00a démonte le mécanisme ; ce notebook branche la même mini-tâche sur peft, l’implémentation SOTA maintenue par HuggingFace, et complète la couverture LLM de 21_LoRA_FineTuning par une comparaison mesurée from scratch vs lib sur tâche contrôlée. La suite reste FT-02 (QLoRA 4-bit) et FT-06 (vision-langage).
Vérification de l’environnement
Avant tout calcul, on vérifie le moteur effectif : version de PyTorch, de peft, device disponible. La mini-tâche (section 1) tient sur CPU en quelques dizaines de secondes par epoch — aucune ressource GPU n’est requise.
import copyimport osimport tempfileimport timeimport numpy as npimport torchimport torch.nn as nnimport torch.nn.functional as Ffrom peft import LoraConfig, PeftModel, get_peft_modelfrom torchvision import datasets, transformsSEED =42torch.manual_seed(SEED)np.random.seed(SEED)DEV ="cuda"if torch.cuda.is_available() else"cpu"import peftprint(f"PyTorch {torch.__version__}")print(f"peft {peft.__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.13.0+cpu
peft 0.20.0
numpy 2.2.6
device cpu
graine 42
Lecture du résultat : l’environnement d’exécution
La comparaison avec FT-00a n’a de sens que si les deux notebooks tournent sur la même machine, même build PyTorch, même device — c’est le cas ici (CPU). peft est la seule dépendance ajoutée au protocole de FT-00a ; tout le reste (dataset, modèle, optimizer, graine) est repris bit à bit.
1. La mini-tâche de FT-00a, reprise à l’identique
Le protocole est celui de FT-00a section 3, sans un changement :
un décalage de domaine : le négatif photox -> 1 - x ;
SmallCNN (3 blocs conv + tête linéaire, ~29 k paramètres), entraîné 2 epochs sur les images inversées ;
le réseau est ensuite gelé, puis adapté au domaine normal avec LoRA r = 4, alpha = 8 sur les couches c3 et fc — 2 epochs, Adam 1e-3.
La seule variable expérimentale de ce notebook est l’implémentation de l’adaptateur : classes LoRALinear/LoRAConv2d écrites à la main dans FT-00a, contre peft ici.
DATA_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
Lecture du résultat : le protocole est posé
Tout ce qui précède est le code de FT-00a section 3, recopié sans modification — c’est le point de méthode : à la fin du notebook, toute différence de résultat ne pourra être attribuée qu’à l’implémentation de l’adaptateur, pas au protocole.
epoch 1/2 loss=0.5352 (14.2s)
epoch 2/2 loss=0.3823 (15.9s)
test INVERSE (son domaine) : 0.8347
test NORMAL (la cible) : 0.0430
valeurs commitees FT-00a : 0.8325 / 0.0363
Lecture du résultat : compétent sur son domaine, muet sur la cible
Le modèle de base refait le parcours de FT-00a : ~0,83 sur le test inversé (son domaine d’entraînement), ~0,04 sur le test normal. Les valeurs committées de FT-00a sont rappelées en regard : mêmes ordres de grandeur, écart ~0,002. Même graine et même build ne garantissent pas le bit-exact sur CPU — l’arithmétique parallèle (réductions multi-cœurs) est non associative, et deux runs de la même boucle divergent à l’epsilon près. La reprise du protocole se vérifie au lieu de se déclarer. L’écart de départ (~80 points d’exactitude) est l’enjeu que l’adaptateur doit récupérer.
2. LoraConfig + get_peft_model : toute la machinerie
FT-00a a écrit deux classes — LoRALinear et LoRAConv2d — puis câblé chaque couche cible à la main, en prouvant un par un les invariants (B = 0 au départ, W gelé, budget r * (d + k)). peft donne les mêmes garanties par contrat :
r=4 : le rang de la décomposition ;
lora_alpha=8 : le facteur alpha/r = 2, identique à FT-00a ;
target_modules=["c3", "fc"] : les mêmes deux couches ciblées ;
lora_dropout=0.0, bias="none" : le protocole de FT-00a n’a ni dropout ni biais entraînable.
L’initialisation interne de peft suit la même convention canonique que FT-00a : lora_A en kaiming-uniform, lora_B à zéro — le delta vaut exactement 0 au pas 0.
# -- reseau gele, copie frache du modele de base ------------------------------frozen = copy.deepcopy(base_model)for p in frozen.parameters(): p.requires_grad_(False)# -- les deux lignes qui remplacent les ~60 lignes de FT-00a -------------------config = LoraConfig(r=4, lora_alpha=8, target_modules=["c3", "fc"], lora_dropout=0.0, bias="none")peft_model = get_peft_model(copy.deepcopy(frozen), config).to(DEV)peft_model.print_trainable_parameters()# -- verification des invariants, les memes que FT-00a section 2 ---------------n_train =sum(p.numel() for p in peft_model.parameters() if p.requires_grad)n_total =sum(p.numel() for p in peft_model.parameters())x_probe = torch.randn(32, 1, 28, 28, device=DEV)with torch.no_grad(): ecart_init = (peft_model(x_probe) - frozen(x_probe)).abs().max().item()loss = peft_model(x_probe).pow(2).mean()loss.backward()w_grad =next(peft_model.base_model.model.c1.parameters()).gradconv3_budget =4* (32*3*3) +64*4# r*(in*k^2) + out*r, comme FT-00afc_budget =4* (576+10) # r*(in+out)print(f"\necart initial max|peft(x) - frozen(x)| = {ecart_init:.3e}"f" {'(exactement 0 : B = 0)'if ecart_init ==0.0else'(NON NUL)'}")print(f"gradient sur W (c1) : "f"{'None (correct : coupe)'if w_grad isNoneelse'RECU (FUITE)'}")print(f"budget attendu conv3 + fc (r=4) : {conv3_budget + fc_budget}")print(f"parametres entrainables peft : {n_train}"f" {'(identique a FT-00a)'if n_train == conv3_budget + fc_budget else'(ECART)'}")
trainable params: 3,752 || all params: 32,818 || trainable%: 11.4328
ecart initial max|peft(x) - frozen(x)| = 0.000e+00 (exactement 0 : B = 0)
gradient sur W (c1) : None (correct : coupe)
budget attendu conv3 + fc (r=4) : 3752
parametres entrainables peft : 3752 (identique a FT-00a)
Lecture du résultat : mêmes invariants, zéro classe écrite
Trois constats, dans l’ordre de FT-00a :
le delta vaut 0 au départ — peft initialise lora_B à zéro, la sortie de l’adapté est bit-exact celle du réseau gelé ;
aucun gradient n’atteint les poids gelés — get_peft_model gèle la base automatiquement, là où FT-00a devait l’écrire (requires_grad_(False)) puis le prouver ;
le budget est identique : 3 752 paramètres entraînables, la même forme fermée r*(in*k^2) + out*r pour la conv et r*(in+out) pour la linéaire. peft ne fait ni plus ni moins que la décomposition de FT-00a — c’est bien la même mathématique, empaquetée.
3. Entraînement : même optimizer, même graine, mêmes 2 epochs
La boucle train de FT-00a pilote le modèle peft sans aucune modification : elle n’optimise que les paramètres requires_grad, qui sont exactement les adaptateurs.
[peft] adaptation au domaine normal, 2 epochs
epoch 1/2 loss=0.9408 (14.7s)
epoch 2/2 loss=0.5247 (14.2s)
temps de fine-tuning peft : 28.9s
exactitude peft r=4 : 0.7379 (FT-00a LoRA from scratch, commite : 0.7351)
Lecture du résultat : ce que les 3 752 paramètres de peft récupèrent
L’exactitude récupérée est du même ordre que la version from scratch de FT-00a (0,7351 committé) : ~0,70–0,75 après 2 epochs. Les deux chiffres ne coïncident pas exactement, et c’est attendu : la graine fixe l’ordre des batchs (identique), mais le tirage d’initialisation de l’adaptateur diffère — FT-00a tirait A dans ses classes maison, peft tire le sien dans son module lora.LoRALayer. Deux trajectoires d’entraînement différentes, mêmes moyens, même ordre de grandeur de récupération : c’est la signature d’une équivalence d’implémentation, pas d’un artefact.
4. Le tableau comparatif : from scratch (FT-00a) vs SOTA (peft)
Les colonnes FT-00a citent les valeurs committées du notebook FT-00a exécuté sur cette même machine ; les colonnes peft sont mesurées ci-dessus. Les lignes de code comptent le code spécifique à l’adaptateur (les deux classes + le câblage par couche pour FT-00a ; la config + l’appel pour peft), hors protocole commun.
====================================================================================================
configuration exactitude params entr. temps LOC ecosysteme
----------------------------------------------------------------------------------------------------
base gelee, test inverse 0.8347 (gele) - - -
base gelee, test normal 0.0430 (gele) - - -
peft r=4 (mesure ici) 0.7379 3752 28.9s 2 save/load/merge, HF
from scratch r=4 (FT-00a) 0.7351 3752 7.9s 60 aucun (fait maison)
full 2 couches (FT-00a) 0.8242 24266 7.6s 2 aucun
====================================================================================================
Lecture du résultat : pourquoi le from scratch, quand le SOTA
Exactitude : les trois adaptations (peft, from scratch, full) récupèrent le même ordre de grandeur. Le full fine-tuning des mêmes couches reste devant — 6,5× plus de paramètres libres achètent quelques points.
Budget : peft et le from scratch libèrent exactement les mêmes 3 752 paramètres (11 % du réseau) — la librairie n’ajoute rien au budget, elle le garantit.
LOC : des lignes à écrire, tester et maintenir — c’est le coût de fabrication contre le coût d’abstraction.
Temps : 28,9 s mesurées ici contre 7,9 s committées pour le from scratch — l’adaptateur peft ajoute par couche ciblée des noyaux supplémentaires (ici lora_A conv 3×3 + lora_B conv 1×1) et une indirection de module. Sur cette mini-tâche CPU le rapport est visible ; sur un LLM, il est noyé dans le coût du forward du backbone. Les deux runs n’ont pas non plus partagé la charge machine.
Écosystème : c’est la vraie ligne de fracture. L’adaptateur peft est un artefact portable (section 5) : quelques kilo-octets sérialisables, rechargeables sur toute copie de la base, fusionnables — et compatibles avec transformers.Trainer, accelerate, l’export. La classe maison de FT-00a n’a aucun de ces rails.
Le from scratch reste irremplaçable pour comprendre et déboguer : quand un adaptateur peft se comporte mal (rang trop petit, alpha mal calibré), le diagnostic exige de savoir ce qui vit sous la config.
Trois gestes industriels que la classe maison de FT-00a n’offre pas :
sauver l’adaptateur seul (save_pretrained) — quelques Ko, pas le réseau ;
recharger sur une copie vierge du réseau gelé (PeftModel.from_pretrained) et vérifier que l’exactitude fait le aller-retour sans perte ;
fusionner (merge_and_unload) — l’équivalent peft de la section 5 de FT-00a : W + (alpha/r) * B @ A réécrit dans les poids, adaptateur démonté.
# -- 1. sauver l'adaptateur seul ------------------------------------------------adapter_dir = tempfile.mkdtemp(prefix="ft00c_adapter_")peft_model.save_pretrained(adapter_dir)tailles =sorted((os.path.basename(f), os.path.getsize(f)) for f in [os.path.join(adapter_dir, f) for f in os.listdir(adapter_dir)])print(f"adaptateur sauvegarde dans {os.path.basename(adapter_dir)}/")for nom, octets in tailles:print(f" {nom:<28}{octets:>8} octets")print(f" total {sum(o for _, o in tailles):>8} octets"f" (le reseau complet : {n_params(base_model) *4} octets en float32)")# -- 2. recharger sur une copie vierge du reseau gele ---------------------------recharge = PeftModel.from_pretrained(copy.deepcopy(frozen), adapter_dir).to(DEV)acc_recharge = evaluate(recharge, inverse_domain=False, loader=test_loader)print(f"\nexactitude apres save + reload : {acc_recharge:.4f}"f" {'(identique)'if acc_recharge == acc_peft elsef'(ecart {abs(acc_recharge - acc_peft):.1e})'}")# -- 3. fusionner : W + (alpha/r) * B @ A, puis controler la derive --------------proche = copy.deepcopy(peft_model).to(DEV)fusionne = proche.merge_and_unload()x_fusion = torch.randn(64, 1, 28, 28, device=DEV)with torch.no_grad(): derive = (fusionne(x_fusion) - peft_model(x_fusion)).abs().max().item()acc_fusion = evaluate(fusionne, inverse_domain=False, loader=test_loader)print(f"derive max |fusionne(x) - peft(x)| = {derive:.3e} (epsilon float32)")print(f"exactitude du modele fusionne : {acc_fusion:.4f}")
adaptateur sauvegarde dans ft00c_adapter_7n571z5d/
README.md 5074 octets
adapter_config.json 1157 octets
adapter_model.safetensors 15440 octets
total 21671 octets (le reseau complet : 116264 octets en float32)
exactitude apres save + reload : 0.7379 (identique)
derive max |fusionne(x) - peft(x)| = 1.717e-05 (epsilon float32)
exactitude du modele fusionne : 0.7379
Lecture du résultat : l’adaptateur est un artefact portable
Poids plume : ~15 Ko de tenseurs (22 Ko avec la carte et la config générées) contre ~116 Ko pour le réseau complet en float32 — rapport ~5× ici. Sur un LLM 7B en float16 (~14 Go de poids), le même adaptateur r=4 sur les projections d’attention tient en quelques dizaines de Mo : rapport de l’ordre de 10³. C’est ce qui rend le partage d’adaptateurs viable.
Aller-retour sans perte : l’exactitude après save_pretrained + from_pretrained est identique — l’adaptateur sérialisé est le modèle, pas une approximation.
La fusion n’est pas bit-exact : la dérive mesurée est de l’ordre de l’epsilon float32, exactement la physique démontrée à la main dans FT-00a section 5 — peft refait la même réécriture W + (alpha/r) * B @ A, avec la même arithmétique non associative. L’exactitude du modèle fusionné est inchangée au 4ᵉ décimale près.
6. Exercices
Les trois exercices sont progressifs : le premier fait prédire le budget avant de le vérifier, le deuxième fait construire un contrôle d’intégrité du aller-retour sauvegarde/chargement, le troisième transpose l’exercice 3 de FT-00a (borner la dérive de fusion) côté peft.
# Exercice 1 : le budget selon le rang, cote peft# TODO etudiant : completez budget_peft(r) pour qu'elle retourne le nombre de# parametres entrainables qu'un LoraConfig(r=r, lora_alpha=8,# target_modules=["c3", "fc"]) liberera sur SmallCNN, SANS construire le modele# (forme fermee uniquement). Verifiez ensuite votre prediction pour r dans# [1, 2, 4, 8, 16] en construisant reellement le modele peft et en comptant# sum(p.numel() for p in m.parameters() if p.requires_grad). Pour quel rang le# budget depasse-t-il celui du full fine-tuning des deux couches (24 266) ?def budget_peft(r, conv_in=32, conv_k=3, conv_out=64, fc_in=576, fc_out=10):# Indice : conv -> r*(in*k^2) + out*r ; lineaire -> r*(in + out).# Etape 1 : budget de la couche conv3 ciblee.# Etape 2 : budget de la tete fc ciblee.# Etape 3 : la somme des deux. result =None# TODO etudiantreturn resultprint("Exercice 1 a completer -- budget_peft(4) retourne", budget_peft(4))
Exercice 1 a completer -- budget_peft(4) retourne None
Exercice 2 : un contrôle d’intégrité du aller-retour sauvegarde/chargement
La section 5 vérifie l’exactitude après rechargement. Une vérification plus forte compare les prédictions elles-mêmes, image par image.
# Exercice 2 : le roundtrip est-il exact, prediction par prediction ?# TODO etudiant : ecrivez verifie_roundtrip(peft_model, frozen, adapter_dir)# qui (1) sauvegarde peft_model, (2) recharge sur une copie vierge de frozen,# (3) compare les logits du couple (peft_model, recharge) sur tout le jeu de# test NORMAL, et retourne le nombre d'images ou l'argmax differe.# Resultat attendu : 0. Si vous obtenez autre chose, le roundtrip a une perte.def verifie_roundtrip(peft_model, frozen, adapter_dir):# Indice : reutilisez evaluate() en boucle ou ecrivez la double boucle ;# comparer les argmax suffit (les logits float32 peuvent diverger a 1e-7).# Etape 1 : save_pretrained vers un NOUVEAU dossier temporaire.# Etape 2 : PeftModel.from_pretrained(copy.deepcopy(frozen), ...).# Etape 3 : comptez les desaccords d'argmax sur test_loader (domaine normal). result =None# TODO etudiantreturn resultprint("Exercice 2 a completer -- verifie_roundtrip() retourne", verifie_roundtrip(peft_model, frozen, adapter_dir))
Exercice 2 a completer -- verifie_roundtrip() retourne None
Exercice 3 : borner la dérive de merge_and_unload
La fusion réécrit W + (alpha/r) * B @ A dans les poids — la section 5 de FT-00a montre à la main pourquoi la réécriture n’est pas bit-exact. Transposez le contrôle côté peft.
# Exercice 3 : fusion et borne de derive, cote peft# TODO etudiant : ecrivez derive_fusion_peft(peft_model, n_echantillons=64)# qui retourne l'ecart max |fusionne(x) - peft(x)| sur des x aleatoires# (meme graine avant chaque serie de tirages). Comparez float32 puis, si votre# machine le permet, apres .double() sur tout le graphe : la derive doit suivre# l'epsilon de la precision, comme dans FT-00a section 5.def derive_fusion_peft(peft_model, n_echantillons=64):# Indice : copy.deepcopy(peft_model) AVANT merge_and_unload (le modele# original est demonte par la fusion) ; torch.no_grad() partout ;# meme torch.manual_seed(SEED) avant chaque tirage de x pour comparer# les deux modeles sur les MEMES entrees.# Etape 1 : copie profonde + fusion.# Etape 2 : tirage de x sous graine fixee.# Etape 3 : ecart max entre les deux sorties. result =None# TODO etudiantreturn resultprint("Exercice 3 a completer -- derive_fusion_peft() retourne", derive_fusion_peft(peft_model))
Exercice 3 a completer -- derive_fusion_peft() retourne None
Résumé
notion
ce qu’il faut retenir
LoraConfig + get_peft_model
remplacent l’adaptateur écrit à la main dans FT-00a ; les invariants (gel de la base, B = 0, budget fermé) sont garantis par la lib au lieu d’être prouvés à la main
budget identique
peft libère exactement les mêmes 3 752 paramètres que le from scratch (r*(in*k^2) + out*r en conv, r*(in+out) en linéaire) — la lib n’ajoute rien au budget
exactitude comparable
même graine et même protocole : la récupération est du même ordre ; seul le tirage d’initialisation de l’adaptateur diffère
save_pretrained / from_pretrained
l’adaptateur est un artefact portable de quelques Ko, exact au aller-retour — le fondement du partage d’adaptateurs (Su-LoRA, OpenAdapter…)
merge_and_unload
la fusion W + (alpha/r) * B @ A côté lib, avec la même dérive epsilon float32 démontrée à la main dans FT-00a
quand quoi utiliser
from scratch pour comprendre et déboguer (FT-00a) ; peft pour produire — écosystème HuggingFace, Trainer, accelerate, export
Pour aller plus loin : 21_LoRA_FineTuning applique ce même peft à un LLM HuggingFace ; FT-02 y ajoute la quantification 4-bit (QLoRA) ; FT-06 l’étend au vision-langage.