FT-00c : LoRA SOTA — la même adaptation, cette fois avec peft

Navigation : Index | 00b — Hyperparams | 00d — QLoRA SOTA

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 copy
import os
import tempfile
import time

import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from peft import LoraConfig, PeftModel, get_peft_model
from torchvision import datasets, transforms

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

import peft
print(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 :

  1. Fashion-MNIST, split officiel (60 000 train / 10 000 test), batchs 256/512 ;
  2. un décalage de domaine : le négatif photo x -> 1 - x ;
  3. SmallCNN (3 blocs conv + tête linéaire, ~29 k paramètres), entraîné 2 epochs sur les images inversées ;
  4. 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 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

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.

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}")
print("valeurs commitees FT-00a    : 0.8325 / 0.0363")
  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()).grad

conv3_budget = 4 * (32 * 3 * 3) + 64 * 4   # r*(in*k^2) + out*r, comme FT-00a
fc_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.0 else '(NON NUL)'}")
print(f"gradient sur W (c1)                      : "
      f"{'None (correct : coupe)' if w_grad is None else '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 :

  1. 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é ;
  2. 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 ;
  3. 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.

print("[peft] adaptation au domaine normal, 2 epochs")
torch.manual_seed(SEED)
t0 = time.perf_counter()
train(peft_model, inverse_domain=False)
temps_peft = time.perf_counter() - t0

acc_peft = evaluate(peft_model, inverse_domain=False, loader=test_loader)
print(f"\ntemps de fine-tuning peft : {temps_peft:.1f}s")
print(f"exactitude peft  r=4       : {acc_peft:.4f}"
      f"   (FT-00a LoRA from scratch, commite : 0.7351)")
[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.

FT00A_LORA_ACC, FT00A_LORA_PARAMS, FT00A_LORA_TEMPS = 0.7351, 3752, 7.9
FT00A_FULL_ACC, FT00A_FULL_PARAMS = 0.8242, 24266
LOC_FROM_SCRATCH, LOC_PEFT = 60, 2

print("=" * 100)
print(f"{'configuration':<30}{'exactitude':>11}{'params entr.':>13}"
      f"{'temps':>8}{'LOC':>7}{'ecosysteme':>25}")
print("-" * 100)
print(f"{'base gelee, test inverse':<30}{acc_base_inv:>11.4f}{'(gele)':>13}"
      f"{'-':>8}{'-':>7}{'-':>25}")
print(f"{'base gelee, test normal':<30}{acc_base_norm:>11.4f}{'(gele)':>13}"
      f"{'-':>8}{'-':>7}{'-':>25}")
print(f"{'peft r=4 (mesure ici)':<30}{acc_peft:>11.4f}{n_train:>13}"
      f"{temps_peft:>7.1f}s{LOC_PEFT:>5}{'save/load/merge, HF':>25}")
print(f"{'from scratch r=4 (FT-00a)':<30}{FT00A_LORA_ACC:>11.4f}{FT00A_LORA_PARAMS:>13}"
      f"{FT00A_LORA_TEMPS:>7.1f}s{LOC_FROM_SCRATCH:>5}{'aucun (fait maison)':>25}")
print(f"{'full 2 couches (FT-00a)':<30}{FT00A_FULL_ACC:>11.4f}{FT00A_FULL_PARAMS:>13}"
      f"{'7.6':>7}s{'2':>5}{'aucun':>25}")
print("=" * 100)
====================================================================================================
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.

5. L’écosystème : save_pretrained, PeftModel.from_pretrained, merge_and_unload

Trois gestes industriels que la classe maison de FT-00a n’offre pas :

  1. sauver l’adaptateur seul (save_pretrained) — quelques Ko, pas le réseau ;
  2. recharger sur une copie vierge du réseau gelé (PeftModel.from_pretrained) et vérifier que l’exactitude fait le aller-retour sans perte ;
  3. 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 else f'(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 etudiant
    return result


print("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 etudiant
    return result


print("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 etudiant
    return result


print("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.

Retour au sommet