FT-02 : QLoRA — Fine-Tuning avec Quantization

Navigation : Index | << 01 — Introduction | 03 — SFT >>

Objectif : Comprendre la quantization 4-bit (NF4) et l’approche QLoRA qui combine quantization + LoRA pour fine-tuner des modèles jusqu’a 7B params sur un GPU consumer.

Plan : 1. Le problème memoire des grands modèles 2. Quantization : de FP16 a NF4 3. BitsAndBytesConfig en pratique 4. La mécanique LoRA que l’API enveloppe 5. QLoRA : quantized base + LoRA adapters 6. Entrainement QLoRA sur OPT-1.3B 7. Generation avec le modèle QLoRA 8. Comparaison LoRA vs QLoRA 9. Nettoyage memoire GPU 10. Exercices

Prerequis : FT-01 (Introduction au Fine-Tuning) — LoRA, rang, adaptateurs.

Materiel requis : GPU avec ~8 Go VRAM (QLoRA OPT-1.3B 4-bit = ~2 Go).

Lecture de la sortie committée : la cellule suivante imprime l’environnement complet (version PyTorch, GPU, VRAM) – sa sortie committee est la source unique, la prose ne la re-epinglera plus (#9434). Ici un environnement cu12x sur RTX 3080 Ti Laptop, ~17 Go de VRAM totale dont ~16 Go libres au démarrage. Le paradoxe pédagogique de ce notebook : cette carte embarquerait OPT-1.3B en FP16 sans aucune difficulté (4,03 Go mesurés plus loin, section 8). Ce n’est pas elle qui justifie QLoRA — c’est la généralisation : la même recette qui tient un 1,3 Md dans ~1,7 Go en fera tenir un 7 Md dans ~6 Go, sur une carte à 300 €. Le notebook déroule la chaîne complète : quantization NF4 (section 2), la mécanique LoRA écrite à la main (section 4), greffe LoRA via l’API (section 5), entraînement réel mesuré (section 6), génération avant/après (section 7), et comparatif mémoire FP16 vs 4-bit (section 8).

import gc
import os
import time
import warnings

import torch

warnings.filterwarnings(
    "ignore",
    message=r"_check_is_size will be removed.*",
    category=FutureWarning,
)
warnings.filterwarnings(
    "ignore",
    message=r"torch\.utils\.checkpoint: the use_reentrant parameter.*",
    category=UserWarning,
)

print(f"PyTorch {torch.__version__}")
print(f"CUDA : {torch.cuda.is_available()}")
if torch.cuda.is_available():
    print(f"GPU : {torch.cuda.get_device_name(0)}")
    vram_total = torch.cuda.get_device_properties(0).total_memory / 1e9
    vram_free = torch.cuda.mem_get_info()[0] / 1e9
    print(f"VRAM totale : {vram_total:.1f} Go | Libre : {vram_free:.1f} Go")
PyTorch 2.11.0+cu126
CUDA : True
GPU : NVIDIA GeForce RTX 4090
VRAM totale : 25.8 Go | Libre : 24.2 Go

1. Le problème memoire des grands modèles

Fine-tuner un modèle de langue necessite de stocker en VRAM :

Composant Taille approximative
Poids du modèle (FP16) 2 bytes/param
Gradients 2 bytes/param
Etats de l’optimiseur (AdamW) 8 bytes/param (m + v)
Activations (forward pass) Variable, souvent 1-2x modèle

Total pour full fine-tuning : ~12-16 bytes par paramètre.

Modèle Params Full FT (FP16) LoRA (FP16) QLoRA (4-bit)
GPT-2 124M ~2 Go ~0.5 Go ~0.3 Go
OPT-1.3B 1.3B ~20 Go ~5 Go ~2 Go
Llama-7B 7B ~112 Go ~28 Go ~6 Go

LoRA reduit les gradients et l’optimiseur (seuls les adaptateurs sont entraines), mais les poids du modèle restent en FP16.

QLoRA va plus loin : il quantize les poids du modèle en 4-bit tout en gardant les calculs en precision superieure.

Le chiffrage réel se lit dans la section 8 : le chargement FP16 d’OPT-1.3B dépasse la théorie des poids seuls, car les tampons CUDA et le contexte résiduel comptent aussi. Mais charger n’est pas fine-tuner : un full fine-tuning exige en plus les gradients et les états de l’optimiseur Adam. C’est cette triple taxe que LoRA (gradients sur les seuls adaptateurs) puis QLoRA (poids de base en 4 bits) réduisent. Les valeurs observées restent dans les outputs afin que la prose ne fige pas une mesure propre à une machine.

2. Quantization : de FP16 a NF4

2a. Principe de la quantization

La quantization reduit la precision des poids pour diminuer l’empreinte memoire :

FP32 (32 bits) → FP16 (16 bits) → INT8 (8 bits) → NF4 (4 bits)
4 bytes/param    2 bytes/param    1 byte/param    0.5 bytes/param

2b. Pourquoi NF4 et pas un simple INT4 ?

Les poids des reseaux de neurones suivent une distribution approximativement normale (centree sur 0). Un encodage lineaire INT4 gaspille des valeurs possibles :

  • INT4 : valeurs uniformement reparties sur [-8, 7] → regions denses de la distribution mal representees
  • NF4 (NormalFloat 4) : valeurs optimisees pour une distribution normale → chaque quantile represente une fraction egale de la distribution

NF4 = le format optimal théorique pour les poids de reseaux de neurones (Dettmers et al., 2023).

2c. Double quantization

QLoRA applique aussi une double quantization : les constantes de quantization (scaling factors) sont elles-mêmes quantizees en FP8, economisant ~0.37 bits/param supplementaire.

Pourquoi NF4 plutôt qu’INT4 ? Les poids d’un réseau entraîné se répartissent selon une loi approximativement gaussienne, centrée et à queues fines — alors que INT4 découpe cet intervalle en 16 niveaux uniformément espacés. Résultat : la masse des poids, concentrée autour de zéro, s’entasse sur 4-5 niveaux pendant que les niveaux extrêmes (rarissimes) gaspillent leur part de la plage. NF4 (NormalFloat4) construit au contraire ses 16 niveaux sur les quantiles de la loi normale : chaque niveau porte à peu près la même masse de poids, l’information est répartie là où elle est dense. C’est ce choix de grille — et non le nombre de bits — qui explique la ligne « Qualité ~97-99 % du full fine-tuning » du tableau final. La double quantization ajoutée ici (bnb_4bit_use_double_quant=True) compresse en 8 bits les constantes de quantization elles-mêmes : ~0,37 bit économisé par paramètre, mesurable à l’exercice 3.

3. BitsAndBytesConfig en pratique

La bibliotheque bitsandbytes integre la quantization directement dans le chargement du modèle via transformers.

Lecture de la config committée : la sortie affiche les trois ingrédients canoniques du papier QLoRA (Dettmers et al., 2023) — 4-bit NF4 pour les poids de base, compute dtype float16 pour les calculs (les poids sont déquantisés à la volée en FP16 à chaque passe avant), et double quant : True pour les constantes. Le couple est complet : quantization pour la résidence mémoire, LoRA pour l’entraînement. Comparez avec FT-01 : la seule différence de configuration tient dans ce BitsAndBytesConfig passé à from_pretrained — le reste de la mécanique LoRA (rang, alpha, modules ciblés) est identique.

from transformers import BitsAndBytesConfig

# Configuration QLoRA : quantization 4-bit NF4
bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",             # NormalFloat 4 (optimal pour poids)
    bnb_4bit_compute_dtype=torch.float16,   # Calculs en FP16 pour stabilite numerique
    bnb_4bit_use_double_quant=True,         # Double quantization des scaling factors
)

print("BitsAndBytesConfig QLoRA :")
print(f"  Quantization : 4-bit NF4")
print(f"  Compute dtype : float16")
print(f"  Double quant : True")
BitsAndBytesConfig QLoRA :
  Quantization : 4-bit NF4
  Compute dtype : float16
  Double quant : True

Lecture du résultat : les trois lignes qui décident de la mémoire

La cellule précédente a construit la configuration de quantization en trois lignes, et chacune joue un rôle orthogonal dans l’économie de VRAM. Il vaut la peine de les décortiquer, parce que c’est exactement ce triplet qui décide si oui ou non votre fine-tuning tiendra dans la mémoire de votre GPU :

  • load_in_4bit=True : l’instruction cardinale. Au chargement, transformers délègue à bitsandbytes la conversion de chaque poids FP16 (2 octets) en NormalFloat 4-bit (≈ 0,5 octet). Le gain brut est de ~4× sur l’empreinte des poids — pour OPT-1.3B, on passe de ~2,6 Go à ~0,65 Go. C’est le seul moment où la conversion a lieu : pendant les forward passes, les poids sont déquantisés à la volée en FP16, juste le temps du calcul, puis remis en NF4 dans les caches CUDA.
  • bnb_4bit_quant_type="nf4" : NF4 = NormalFloat 4-bit. Le choix est non arbitraire. Les poids d’un réseau neuronal suivent, en première approximation, une distribution approximativement gaussienne centrée (loi des grands nombres appliquée à des sommes de gradients). NF4 réserve ses 16 niveaux de quantification dans les zones denses de cette distribution — c’est pourquoi Dettmers et al. (2023, article fondateur QLoRA) l’ont préféré à INT4 à pas uniformes. Pour une qualité ~97-99 % du full fine-tuning sur 7B paramètres, NF4 surpasse INT4 d’environ 1 point de précision sur MMLU — pas négligeable sur un budget qui ne tolère qu’1-3 % de dégradation.
  • bnb_4bit_compute_dtype=torch.float16 : type utilisé pour les calculs forward (et par extension, les gradients sur les adaptateurs LoRA). Choisir FP16 plutôt que FP32 garde les activations en 16 bits, ce qui économise la mémoire des caches sans dégrader visiblement la convergence pour OPT-1.3B — pour des modèles 7B+ ou des séquences longues, on bascule souvent sur BF16 (même plage dynamique que FP32, précision réduite) pour éviter les underflows.
  • bnb_4bit_use_double_quant=True (omis ici, défaut désormais à True) : la double quantization applique une seconde passe NF4→FP8 sur les constantes de quantification elles-mêmes (~0,37 bit/paramètre). Pour un 7B, c’est ~300 Mo de plus récupérés — négligeable sur OPT-1.3B, mais critique pour Llama-2-7B où l’économie permet de rester sous 24 Go de VRAM.

L’intuition à retenir : la quantization 4-bit réduit l’empreinte des poids mais impose une déquantization dynamique à chaque forward. Le seul travail qui s’exécute réellement en FP16 ou FP32, ce sont les gradients sur les adaptateurs LoRA — l’optimiseur Adam ne touche pas aux poids quantizés. C’est cette séparation des espaces mémoire (poids figés en NF4, gradients en FP16) qui rend le fine-tuning possible sur GPU consumer. Sans LoRA, la quantization 4-bit seule ne suffirait pas : il faudrait backpropager à travers les poids déquantizés, ce qui coûte autant de mémoire qu’un fine-tuning FP16.

La cellule suivante (measure_vram) mesure précisément cette empreinte : torch.cuda.memory_allocated() / 1e9 capture l’allocation réelle CUDA au pic du chargement, avant que les caches PyTorch ne libèrent. C’est ce chiffre, et non la taille théorique (1,3B × 0,5 octet = 0,65 Go), qu’on lira dans la cellule suivante — il inclut les buffers d’attention, les tenseurs d’activation temporaires, et le surcoût CUDA.

# Mesurer la VRAM avant chargement
if torch.cuda.is_available():
    torch.cuda.reset_peak_memory_stats()
    vram_before = torch.cuda.memory_allocated() / 1e9
else:
    vram_before = 0

print(f"VRAM avant chargement : {vram_before:.2f} Go")
VRAM avant chargement : 0.00 Go

4. La mécanique LoRA que l’API enveloppe

La section suivante appellera get_peft_model + LoraConfig sur le modèle quantifié — deux lignes d’API. Avant de les exécuter, cette section montre ce que ces deux lignes enveloppent, sur des tenseurs jouets (512×512, CPU, secondes) : une couche LoRALinear écrite à la main, les deux invariants qui font tout l’intérêt de LoRA, et la grille rank×alpha qui transforme le choix r=16, α=32 de recette en décision.

Rappel du principe (Hu et al., 2021) : au lieu d’apprendre une mise à jour dense ΔW de taille d×d, LoRA la contraint en bas rang : ΔW = B·A avec A : d→r, B : r→d. Le pré-entraîné W₀ est gelé ; seuls A et B s’apprénnent — r fois moins de paramètres par couche.

# LoRALinear from scratch : W0 gele, delta bas-rang appris
import torch
import torch.nn as nn

torch.manual_seed(0)

class LoRALinear(nn.Module):
    """Couche lineaire LoRA : y = W0 x + (alpha/r) * B(A(x)).

    W0 est GELÉ (requires_grad=False) : le pré-entraîné n'est jamais modifié.
    A (d_in -> r) : init Kaiming ; B (r -> d_out) : init ZÉRO.
    Le facteur alpha/r contrôle l'amplitude effective de la mise à jour.
    """
    def __init__(self, base_layer: nn.Linear, r: int = 8, lora_alpha: int = 16):
        super().__init__()
        self.base_layer = base_layer
        for p in self.base_layer.parameters():
            p.requires_grad = False          # <-- le gel du pré-entraîné
        self.r = r
        self.scaling = lora_alpha / r
        d_out, d_in = base_layer.weight.shape
        self.lora_A = nn.Parameter(torch.zeros(r, d_in))
        self.lora_B = nn.Parameter(torch.zeros(d_out, r))
        nn.init.kaiming_uniform_(self.lora_A, a=5 ** 0.5)

    def delta_W(self) -> torch.Tensor:
        """La mise a jour effectivement apprise : B @ A, de rang <= r."""
        return self.lora_B @ self.lora_A

    def forward(self, x):
        return self.base_layer(x) + self.scaling * (x @ self.lora_A.T @ self.lora_B.T)


# Demonstration sur un tenseur jouet 512x512 (CPU, sans modele HF)
base = nn.Linear(512, 512, bias=False)
torch.manual_seed(42)
with torch.no_grad():
    base.weight.copy_(torch.randn(512, 512) * 0.02)

lora_demo = LoRALinear(base, r=8, lora_alpha=16)
n_lora = lora_demo.lora_A.numel() + lora_demo.lora_B.numel()
print(f"W0          : {tuple(base.weight.shape)} = {base.weight.numel():,} params, requires_grad={base.weight.requires_grad}")
print(f"A, B        : {tuple(lora_demo.lora_A.shape)} + {tuple(lora_demo.lora_B.shape)} = {n_lora:,} params entrainables")
print(f"ratio       : {n_lora / base.weight.numel():.2%} de la couche de base (r=8)")
print(f"scaling     : alpha/r = 16/8 = {lora_demo.scaling}")
W0          : (512, 512) = 262,144 params, requires_grad=False
A, B        : (8, 512) + (512, 8) = 8,192 params entrainables
ratio       : 3.12% de la couche de base (r=8)
scaling     : alpha/r = 16/8 = 2.0
# Preuve 1 -- l'initialisation demarre EXACTEMENT comme le pre-entraine
dW = lora_demo.delta_W().detach()
print("=== Invariant d'initialisation (B = 0) ===")
print(f"stats de A : mean={lora_demo.lora_A.mean():+.5f}  std={lora_demo.lora_A.std():.5f}  (Kaiming, non nul)")
print(f"stats de B : mean={lora_demo.lora_B.mean():+.5f}  std={lora_demo.lora_B.std():.5f}  (ZÉRO)")
print(f"delta_W = B@A : max|delta_W| = {dW.abs().max():.2e}")
print(f"torch.allclose(delta_W, 0) : {torch.allclose(dW, torch.zeros_like(dW))}")

x_probe = torch.randn(4, 512)
with torch.no_grad():
    out_lora = lora_demo(x_probe)
    out_base = base(x_probe)
print(f"forward LoRA == forward base au depart : {torch.allclose(out_lora, out_base)}")
print()
print("=== Le gel tient pendant l'entrainement ===")
base_w0 = base.weight.clone()
opt = torch.optim.SGD([lora_demo.lora_A, lora_demo.lora_B], lr=0.1)
x_batch, target = torch.randn(16, 512), torch.randn(16, 512)
loss0 = None
for step in range(50):
    opt.zero_grad()
    loss = ((lora_demo(x_batch) - target) ** 2).mean()
    if loss0 is None:
        loss0 = loss.item()
    loss.backward()
    opt.step()
dW_after = lora_demo.delta_W().detach()
print(f"apres 50 pas SGD : |delta_W| = {dW_after.norm():.4f} (grandit) | W0 inchangé : {torch.equal(base.weight, base_w0)}")
print(f"loss : {loss0:.4f} -> {loss.item():.4f}")
=== Invariant d'initialisation (B = 0) ===
stats de A : mean=+0.00042  std=0.02548  (Kaiming, non nul)
stats de B : mean=+0.00000  std=0.00000  (ZÉRO)
delta_W = B@A : max|delta_W| = 0.00e+00
torch.allclose(delta_W, 0) : True
forward LoRA == forward base au depart : True

=== Le gel tient pendant l'entrainement ===
apres 50 pas SGD : |delta_W| = 0.3050 (grandit) | W0 inchangé : True
loss : 1.1991 -> 1.1271

Lecture : le démarrage exact

B initialisé à zéro ⇒ ΔW = B@A = 0 exactement (pas approximativement) : la cellule affirme allclose(delta_W, 0) → True et forward LoRA == forward base → True. C’est la propriété qui rend LoRA sûr : le modèle LoRA-ifié commence sa vie comme le pré-entraîné, puis ne s’en écarte que par ce que l’entraînement apprend dans B (dont le gradient est non nul dès le premier pas, même si B vaut zéro — la dérivée ne dépend pas de la valeur de B).

Et le gel tient : après 50 pas d’SGD, W₀ est bit-identique à lui-même (torch.equal → True) pendant que |ΔW| grandit de 0 à 0,305. Les gradients ne coulent que vers A et B.

# Preuve 2 -- merge : inference "zero-overhead" (l'argument central de FT-05)
# On simule un adaptateur entraîné : B non nul.
with torch.no_grad():
    lora_demo.lora_B.normal_(0, 0.01)

# Chemin 1 : reseau LoRA non mergé (deux matrices separees)
x = torch.randn(64, 512)
with torch.no_grad():
    path_unmerged = lora_demo(x)

# Chemin 2 : poids mergés W = W0 + (alpha/r) * B@A, UNE seule couche dense
W_merged = base.weight + lora_demo.scaling * lora_demo.delta_W()
merged = nn.Linear(512, 512, bias=False)
with torch.no_grad():
    merged.weight.copy_(W_merged)
with torch.no_grad():
    path_merged = merged(x)

ecart_max = (path_unmerged - path_merged).abs().max().item()
print("=== Invariant de merge ===")
print(f"x@W0 + (alpha/r)*x@(BA)^T == x@(W0 + (alpha/r)*BA)^T : {torch.allclose(path_unmerged, path_merged, atol=1e-6)}")
print(f"écart max entre les deux chemins : {ecart_max:.2e} (arrondi float32)")
print(f"rang de delta_W : {torch.linalg.matrix_rank(lora_demo.delta_W().detach()).item()} <= r={lora_demo.r}")
=== Invariant de merge ===
x@W0 + (alpha/r)*x@(BA)^T == x@(W0 + (alpha/r)*BA)^T : True
écart max entre les deux chemins : 1.28e-06 (arrondi float32)
rang de delta_W : 8 <= r=8

Lecture : l’inference « zero-overhead »

Les deux chemins de calcul coïncident (allclose → True, écart max 1,28e-06, arrondi float32) : additionner la mise à jour dans les poids (W₀ + (α/r)·BA) produit le même réseau que de garder deux branches séparées. C’est LA preuve que le merge en inference est gratuit : après fusion, plus aucune multiplication par B puis A — une seule couche dense, la même que le pré-entraîné, avec d’autres poids. C’est l’argument central que le notebook FT-05 (ModelMerging) exploite en aval : fusionner n’adaptateurs revient à additionner leurs deltas.

Le rang de ΔW (affiché : ≤ r) est la contrepartie : la mise à jour vit dans un sous-espace de dimension r. Tout l’enjeu du choix de r est là — assez de dimensions pour la tâche, pas plus.

# Grille rank x alpha : combien de paramètres entraînables, et quelle amplitude
# Dimensions réelles d'OPT-1.3b (le modèle des sections 5-8) : d_model=2048, 24 couches
D_MODEL, N_COUCHES, N_CIBLES = 2048, 24, 2
print(f"{'r':>3} {'alpha':>5} | {'params/couche':>13} {'total 24 couches':>17} {'% du modèle':>11} {'scale alpha/r':>14}")
print("-" * 74)
rows = []
for r in [2, 4, 8, 16, 32, 64]:
    for alpha in [8, 16, 32]:
        per_layer = N_CIBLES * 2 * r * D_MODEL      # 2 matrices (A et B) par module cible
        total = per_layer * N_COUCHES
        rows.append((r, alpha, per_layer, total, alpha / r))
        print(f"{r:>3} {alpha:>5} | {per_layer:>13,} {total:>17,} {total / 1.3e9 * 100:>10.3f}% {alpha / r:>14.3f}")

print()
print("Lecture : le nombre de paramètres ne dépend QUE de r -- alpha n'achète")
print("aucun paramètre, il ne fait que rescaler la mise à jour (colonne scale).")
print()
print("Recommandations :")
print("  underfitting (adaptateur trop faible)  -> monter r (plus de capacité)")
print("  overfitting / dérive du style          -> baisser r, ou baisser alpha")
print("  défaut pragmatique des sections 5-8    -> r=16, alpha=32 (scale 2.0)")

r_def, a_def = 16, 32
total_def = next(t for rr, aa, _, t, _ in rows if rr == r_def and aa == a_def)
print(f"  -> le r=16/alpha=32 de la section suivante : {total_def:,} params entraînables.")
print("     get_peft_model mesurera EXACTEMENT ce chiffre (vérification en section 6).")
  r alpha | params/couche  total 24 couches % du modèle  scale alpha/r
--------------------------------------------------------------------------
  2     8 |        16,384           393,216      0.030%          4.000
  2    16 |        16,384           393,216      0.030%          8.000
  2    32 |        16,384           393,216      0.030%         16.000
  4     8 |        32,768           786,432      0.060%          2.000
  4    16 |        32,768           786,432      0.060%          4.000
  4    32 |        32,768           786,432      0.060%          8.000
  8     8 |        65,536         1,572,864      0.121%          1.000
  8    16 |        65,536         1,572,864      0.121%          2.000
  8    32 |        65,536         1,572,864      0.121%          4.000
 16     8 |       131,072         3,145,728      0.242%          0.500
 16    16 |       131,072         3,145,728      0.242%          1.000
 16    32 |       131,072         3,145,728      0.242%          2.000
 32     8 |       262,144         6,291,456      0.484%          0.250
 32    16 |       262,144         6,291,456      0.484%          0.500
 32    32 |       262,144         6,291,456      0.484%          1.000
 64     8 |       524,288        12,582,912      0.968%          0.125
 64    16 |       524,288        12,582,912      0.968%          0.250
 64    32 |       524,288        12,582,912      0.968%          0.500

Lecture : le nombre de paramètres ne dépend QUE de r -- alpha n'achète
aucun paramètre, il ne fait que rescaler la mise à jour (colonne scale).

Recommandations :
  underfitting (adaptateur trop faible)  -> monter r (plus de capacité)
  overfitting / dérive du style          -> baisser r, ou baisser alpha
  défaut pragmatique des sections 5-8    -> r=16, alpha=32 (scale 2.0)
  -> le r=16/alpha=32 de la section suivante : 3,145,728 params entraînables.
     get_peft_model mesurera EXACTEMENT ce chiffre (vérification en section 6).

Lecture : la grille rank × alpha

Trois lectures :

  1. Le compte de paramètres ne dépend QUE de r : pour chaque r, les trois valeurs d’alpha affichent le même total (2 · r · d par module cible, × 2 modules × 24 couches). alpha n’achète aucun paramètre — il ne fait que rescaler la mise à jour (scale = α/r, dernière colonne). Monter alpha à r fixé, c’est accélérer l’amplitude du même adaptateur, pas l’agrandir.

  2. Les ordres de grandeur : de r=2 (393 216 params, 0,030% du modèle) à r=64 (12,6 M, 0,968 %) — un facteur 32 entre les extrêmes, et toujours sous 1% du modèle. La grille est calculée sur les dimensions réelles d’OPT-1.3b (d_model 2048, 24 couches, cibles q_proj+v_proj) : le r=16 de la section suivante y prédit 3 145 728 paramètres — exactement le chiffre que get_peft_model mesure. La théorie de la mécanique et l’API tombent d’accord au paramètre près.

  3. La règle de pilotage : underfitting → monter r ; overfitting ou dérive stylistique → baisser r ou baisser alpha. Sur un dataset de quelques centaines d’exemples (le nôtre en a 10), r=8-16 est le plateau raisonnable.

5. QLoRA : quantized base + LoRA adapters

Chargeons OPT-1.3B en 4-bit et comparons avec le chargement FP16 classique.

Comment lire la sortie committée : le chargement 4-bit d’OPT-1.3B occupe 1,25 Go de VRAM mesurés, contre 0,36 Go théoriques pour les seuls poids — l’écart (activations résiduelles, tampons de déquantization, tables d’embeddings parfois conservées en 16 bits) est le prix du passage à l’échelle réel. Notez aussi l’artefact de comptage : la cellule affiche « 711 778 304 params (0.71B) » alors que le comptage canonique d’OPT-1.3B est 1 318 903 808 (1,32 Md, confirmé par PEFT à la cellule suivante). Sommer numel() sur un modèle déjà quantifié ne retourne pas les tailles denses d’origine — un Params4bit ne se compte pas comme un tenseur FP16. Retenez le chiffre de PEFT : c’est le dénominateur honnête, celui sur lequel les pourcentages suivants doivent se lire.

from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training

MODEL_NAME = "facebook/opt-1.3b"  # 1.3B params

# Charger en 4-bit (QLoRA)
model_4bit = AutoModelForCausalLM.from_pretrained(
    MODEL_NAME,
    quantization_config=bnb_config,
    device_map="auto",
)

# Preparer le modele pour l'entrainement en mode quantize
model_4bit = prepare_model_for_kbit_training(model_4bit)

# Statistiques memoire
if torch.cuda.is_available():
    vram_4bit = torch.cuda.max_memory_allocated() / 1e9
else:
    vram_4bit = 0

total_params = sum(p.numel() for p in model_4bit.parameters())
print(f"Modele : {MODEL_NAME}")
print(f"Params totaux : {total_params:,} ({total_params/1e9:.2f}B)")
print(f"VRAM apres chargement 4-bit : {vram_4bit:.2f} Go")
print(f"Theorie 4-bit : {total_params * 0.5 / 1e9:.2f} Go (poids seuls)")
Modele : facebook/opt-1.3b
Params totaux : 711,778,304 (0.71B)
VRAM apres chargement 4-bit : 1.25 Go
Theorie 4-bit : 0.36 Go (poids seuls)

Lecture du résultat : le moment où la quantization prend effet

La cellule précédente a trois étapes qui paraissent triviales mais chacune marque un point de non-retour sur l’empreinte mémoire. À lire dans l’ordre où elles s’exécutent :

  • prepare_model_for_kbit_training(model_4bit) : étape souvent oubliée mais essentielle. Le modèle chargé en 4-bit a ses couches de LayerNorm et sa tête de sortie (lm_head) encore en précision supérieure — bitsandbytes les laisse intactes parce que la quantization NF4 cible les poids matriciels, pas les normalisations. La routine gèle le modèle de base, prépare les entrées pour la remontée du gradient vers les adaptateurs et stabilise les couches sensibles.
  • get_peft_model(model_4bit, lora_config) : c’est ici que les adaptateurs LoRA sont greffés. La méthode parcourt le modèle pour trouver target_modules=["q_proj", "v_proj"]. Sur OPT-1.3B, chaque projection est de dimension 2048×2048 : avec r=16, les matrices A et B ajoutent 2048×16 + 16×2048 = 65 536 paramètres par cible. Sur 24 couches et deux cibles, cela donne 3 145 728 paramètres entraînables, soit environ 0,24 % du modèle canonique. Les poids de base NF4 restent figés.
  • model_qlora.print_trainable_parameters() : cette ligne valide le ratio exact. Si le chiffre s’écarte fortement de la prédiction de la grille précédente, il faut vérifier les modules ciblés avant d’entraîner.

L’intuition à retenir : passer d’un full fine-tuning à LoRA permet d’utiliser une learning rate plus haute sans modifier directement les poids pré-entraînés. On optimise un petit nombre de directions dans un grand espace.

Le piège à éviter : croire que prepare_model_for_kbit_training est optionnel. Une perte plate après plusieurs pas doit déclencher la vérification du gel, de la remontée du gradient et de la configuration des adaptateurs.

# Ajouter les adaptateurs LoRA sur le modele quantize
lora_config = LoraConfig(
    r=16,
    lora_alpha=32,
    target_modules=["q_proj", "v_proj"],  # Attention query/value dans OPT
    lora_dropout=0.05,
    bias="none",
    task_type="CAUSAL_LM",
)

model_qlora = get_peft_model(model_4bit, lora_config)
model_qlora.print_trainable_parameters()

trainable = sum(p.numel() for p in model_qlora.parameters() if p.requires_grad)
total = sum(p.numel() for p in model_qlora.parameters())
print(f"\nParams entrainables : {trainable:,} ({trainable/total*100:.2f}%)")
trainable params: 3,145,728 || all params: 1,318,903,808 || trainable%: 0.2385

Params entrainables : 3,145,728 (0.44%)

Tokenisation du modèle

Le modèle OPT-1.3B est maintenant charge en 4-bit avec les adaptateurs LoRA configures (r=16, target q_proj/v_proj). Chargeons le tokenizer associe pour preparer le dataset d’entrainement.

La décomposition exacte du comptage : 3 145 728 paramètres entraînables — d’où vient ce nombre ? OPT-1.3B compte 24 couches de décodeur, chacune portant deux projections ciblées (q_proj et v_proj, dimensions 2048×2048). Chaque adaptateur LoRA de rang r sur une couche 2048→2048 ajoute r×(2048+2048) = 4096r paramètres. Total : 24 couches × 2 modules × 4096 × r = 196 608 × r. Pour r = 16 : exactement 3 145 728 — la sortie committée vérifie la formule au paramètre près. Sur le dénominateur canonique de 1,32 Md (PEFT), cela fait 0,24 % du modèle entraînable, un rapport de 419 pour 1 : 99,76 % des poids restent figés en NF4. Le « 0.44 % » affiché par la cellule divise par le dénominateur sous-compté de la cellule précédente (711 M) — lisez le 0,2385 % de PEFT.

# Tokenizer
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
tokenizer.pad_token = tokenizer.eos_token
print(f"Tokenizer : vocabulaire = {tokenizer.vocab_size:,} tokens")
Tokenizer : vocabulaire = 50,265 tokens

6. Entrainement QLoRA sur OPT-1.3B

Fine-tunons OPT-1.3B sur du texte francais litteraire, le même type de dataset que FT-01.

Ce que la sortie committée mesure : la perte finale, la durée du run et le pic VRAM sont imprimés directement par la cellule d’entraînement. Le pic n’ajoute qu’une fraction de l’empreinte du chargement seul : les gradients et les états d’optimiseur portent sur les paramètres LoRA, pas sur le milliard de poids figés. L’optimiseur paged_adamw_8bit, visible dans les arguments, complète le dispositif en stockant ses états en 8 bits. Sur ce corpus minuscule, la perte décrit une empreinte stylistique, pas une maîtrise de la langue ; la génération suivante permet de lire ce compromis.

from datasets import Dataset

# Extrait elargi — texte francais classique (domaine public)
training_texts = [
    "Cosette regardait la chambre obscure, et la figure ridee de son bienfaiteur. "
    "Cette figure ridee etait l'immense figure de la misere humaine. Jean Valjean "
    "avait pris l'habitude de descendre a la cave pour rapporter du vin.",

    "La nuit, quand tout dormait dans la maison, Jean Valjean restait eveille. "
    "Il songeait aux annees de bagne, aux chaines, aux coups de fouet, a cette "
    "longue montee vers la lumiere. Cosette dormait, et il la regardait dormir.",

    "Les rues de Paris etaient sombres en ce temps-la. Les reverberes jetaient "
    "des taches de lumiere jaune sur les paves mouilles. Jean Valjean marchait "
    "vite, comme un homme qui fuit quelque chose.",

    "Marius l'observait de loin. Ce jeune homme pale, aux yeux brillants de "
    "passion, voyait Cosette chaque soir au Luxembourg. Il n'osait lui parler. "
    "L'amour est timide, meme pour les coeurs les plus courageux.",

    "La barricade s'elevait dans la rue. Les jeunes hommes avaient pris les "
    "paves et construit un mur de pierres. Enjolras commandait avec une "
    "autorite naturelle. Grantaire, ivre et fidele, le suivait partout.",

    "Gavroche passait entre les balles comme un oiseau. Ce gamin de Paris, "
    "nenfant abandonne devenu enfant de la patrie, ramassait les cartouches "
    "des soldats morts pour les rapporter aux insurges.",

    "Eponine errait dans les rues, le coeur lourd d'un amour sans espoir. "
    "Elle avait vu Marius regarder Cosette, et elle avait compris que son "
    "propre amour ne serait jamais partage.",

    "Javet suivait la piste avec une tenacite implacable. Cet homme de loi "
    "ne connaissait ni pitié ni doute. Pour lui, Jean Valjean resterait "
    "toujours le forcat 24601, quelles que soient ses actions.",

    "Le jardin du Luxembourg etait paisible en ce matin de printemps. "
    "Les marronniers etaient en fleur, et les enfants jouaient sur le sable. "
    "Cosette lisait sur un banc, attendant celui qui ne viendrait pas.",

    "Les Thenardier comptaient leur butin dans l'arriere-salle de l'auberge. "
    "Monsieur Thenardier calculait, madame Thenardier injuriait. La cupidite "
    "etait leur religion, l'argent leur seul dieu.",
]

train_data = Dataset.from_dict({"text": training_texts})
print(f"Dataset : {len(train_data)} exemples")
print(f"Moyenne caracteres/exemple : {sum(len(t) for t in training_texts) / len(training_texts):.0f}")
Dataset : 10 exemples
Moyenne caracteres/exemple : 200

Le dataset contient 10 extraits de texte francais classique (moyenne 200 caractères par extrait). Ces segments sont plus courts que dans FT-01 pour s’adapter au contexte limite d’OPT-1.3B. La prochaine étape est la tokenisation avec un padding uniforme.

Lecture du dataset committé : 10 exemples, 200 caractères en moyenne — un corpus minuscule, choisi pour tenir l’entraînement en une vingtaine de secondes. Les extraits sont de Hugo et assimilés (domaine public) : lexique XIXe siècle, phrases longues, noms propres récurrents (Jean Valjean, Cosette). L’objectif n’est pas d’apprendre le français à OPT-1.3B mais de greffer un style : avec 10 exemples × 15 époques, l’adaptateur LoRA capture le vocabulaire et les tournures, pas la grammaire profonde. Un dataset réel de fine-tuning en compte des milliers ; ici chaque exemple est vu 15 fois, la mémorisation l’emporte sur la généralisation — et c’est précisément ce que la génération de la section 7 va révéler.

# Tokenisation
def tokenize_function(examples):
    result = tokenizer(
        examples["text"],
        truncation=True,
        max_length=256,
        padding="max_length",
    )
    result["labels"] = result["input_ids"].copy()
    return result

tokenized_dataset = train_data.map(tokenize_function, batched=True, remove_columns=["text"])
print(f"Tokens par exemple : {len(tokenized_dataset[0]['input_ids'])}")
print(f"Dataset tokenise : {len(tokenized_dataset)} exemples")
Tokens par exemple : 256
Dataset tokenise : 10 exemples

Le dataset est maintenant tokenise avec un padding uniforme a 256 tokens, pret pour l’entrainement. Chaque exemple contient les input_ids et les labels (copies des input_ids pour le language modeling causal).

Nous allons maintenant configurer le Trainer Hugging Face avec les arguments QLoRA : optimiseur 8-bit (paged_adamw_8bit), FP16, et un learning rate adapte a l’adaptateur LoRA.

Le coût caché du padding uniforme : 256 tokens par exemple pour 200 caractères de texte (≈ 50-60 tokens utiles en français) — environ 75 % de chaque séquence est du remplissage. Le padding="max_length" simplifie le batch au prix d’un quadruplement du calcul de passe avant sur les positions nulles. Sur 10 exemples c’est invisible (le run ci-dessous reste de l’ordre de la minute) ; sur un vrai corpus, on préférerait un padding dynamique par batch. Retenez le réflexe de lecture : 10 exemples × 256 tokens = 2 560 tokens d’entraînement au total — l’adaptateur LoRA (3,1 M de paramètres) a vu moins de tokens qu’il n’a de poids, un régime de mémo-styling extrême.

from transformers import TrainingArguments, Trainer, DataCollatorForLanguageModeling

# Arguments d'entrainement QLoRA
training_args = TrainingArguments(
    output_dir="./temp_ft02_output",
    num_train_epochs=15,
    per_device_train_batch_size=2,
    learning_rate=2e-4,
    weight_decay=0.01,
    logging_steps=10,
    save_strategy="no",
    report_to="none",
    fp16=torch.cuda.is_available(),
    seed=42,
    optim="paged_adamw_8bit",  # Optimiseur 8-bit pour economiser la VRAM
)

data_collator = DataCollatorForLanguageModeling(
    tokenizer=tokenizer,
    mlm=False,
)

trainer = Trainer(
    model=model_qlora,
    args=training_args,
    train_dataset=tokenized_dataset,
    data_collator=data_collator,
)

# Mesurer VRAM avant/apres
if torch.cuda.is_available():
    torch.cuda.reset_peak_memory_stats()
    vram_start = torch.cuda.memory_allocated() / 1e9
else:
    vram_start = 0

start_time = time.time()
print("Entrainement QLoRA en cours...")
train_result = trainer.train()
elapsed = time.time() - start_time

if torch.cuda.is_available():
    vram_peak = torch.cuda.max_memory_allocated() / 1e9
else:
    vram_peak = 0

print(f"\nPerte finale : {train_result.training_loss:.4f}")
print(f"Temps : {elapsed:.1f}s")
print(f"VRAM pic entrainement : {vram_peak:.2f} Go")
Entrainement QLoRA en cours...
[75/75 00:20, Epoch 15/15]
Step Training Loss
10 3.347399
20 2.864517
30 2.514424
40 2.265680
50 2.098278
60 1.978417
70 1.890683


Perte finale : 2.3855
Temps : 21.6s
VRAM pic entrainement : 1.72 Go

Lecture du résultat : le batch effectif et le budget d’apprentissage

Les arguments d’entraînement au-dessus encodent plusieurs décisions couplées :

  • per_device_train_batch_size=2 et l’accumulation implicite à 1 donnent un batch effectif de 2. Pour un modèle plus grand, on réduirait le batch par device et on augmenterait gradient_accumulation_steps.
  • learning_rate=2e-4 est typique de LoRA / QLoRA : l’optimiseur ne touche qu’aux adaptateurs, tandis que les poids du modèle de base restent figés.
  • optim="paged_adamw_8bit" et fp16=True réduisent respectivement la mémoire des états Adam et celle des activations.
  • num_train_epochs=15, logging_steps=10 et weight_decay=0.01 produisent 75 pas sur les 10 extraits. La table de sortie montre la baisse de la perte ; le temps absolu dépend du GPU, du cache et des versions logicielles, donc il reste dans l’output plutôt que dans cette interprétation.
  • warmup_ratio et max_grad_norm ne sont pas déclarés ici ; les défauts du Trainer s’appliquent. Sur un corpus réel, un warmup et un clipping explicites mériteraient d’être testés.

L’intuition à retenir : LoRA/QLoRA utilise la même boîte à outils d’optimisation qu’un fine-tuning dense, mais sur une surface de paramètres beaucoup plus petite. Lisez ensemble la courbe de perte, le pic mémoire et les générations : aucun chiffre isolé ne suffit à conclure à la généralisation.

7. Generation avec le modèle QLoRA

Comparons la generation avant et après fine-tuning.

Critère de lecture avant d’exécuter : comparez les trois générations committées sur deux axes distincts — le lexique (noms propres, registre littéraire : attendez Jean Valjean, Cosette, des rues sombres) et la syntaxe (accords, construction des propositions : attendez des fractures). Les trois prompts sont des amorces différentes sur le même univers ; la question n’est pas « est-ce beau ? » mais « qu’est-ce qui a été appris (lexique) et qu’est-ce qui ne l’a pas été (grammaire) ? ». La lecture détaillée suit les sorties.

# Generer avec le modele fine-tune
model_qlora.eval()

def generate_qlora(prompt, max_new_tokens=100, temperature=0.8):
    inputs = tokenizer(prompt, return_tensors="pt").to(model_qlora.device)
    with torch.no_grad():
        outputs = model_qlora.generate(
            **inputs,
            max_new_tokens=max_new_tokens,
            temperature=temperature,
            do_sample=True,
            top_p=0.9,
            pad_token_id=tokenizer.eos_token_id,
        )
    return tokenizer.decode(outputs[0], skip_special_tokens=True)

prompts = [
    "Dans les rues sombres de Paris,",
    "Jean Valjean regardait la nuit",
    "Cosette marchait dans le jardin",
]

for p in prompts:
    print(f"\n{'='*60}")
    print(f"Prompt : \"{p}\"")
    print(f"{'='*60}")
    output = generate_qlora(p)
    print(output)

============================================================
Prompt : "Dans les rues sombres de Paris,"
============================================================
Dans les rues sombres de Paris, Jean Valjean restait dans son bain de boulon. Il restait dans sa propre cite, et il restait dans le cadre de son lourdement. Cosette n'a jamais vu son père dans ce bain, et il n'a jamais regarde ses jolies gues. L'homme n'existait qu'une figure, et l'homme n'

============================================================
Prompt : "Jean Valjean regardait la nuit"
============================================================
Jean Valjean regardait la nuit comme une espoir. Cosette dormait dans un logis, il dormait sur le floor, et Cosette dormait en lui. Jean Valjean regardait la nuit comme un espoir. Cosette dormait dans un logis, il dormait sur le floor, et Cosette dormait en lui.

============================================================
Prompt : "Cosette marchait dans le jardin"
============================================================
Cosette marchait dans le jardin où elle avait bien connu le sujet de la veille. Elle avait lu son discours sur le sujet de la ville, ce qui lui avait permis de le croire. Cosette avait lu celui qu'elle ne viendra pas. Elle avait lu celui qu'elle ne viendrait pas. Les deux vivraient un coeur quelque chose. La l

Lecture du résultat : la greffe stylistique du fine-tuning

La génération « Dans les rues sombres de Paris, Jean Valjean restait dans son bain de boulon… » prouve que le fine-tuning QLoRA a modifié le comportement du modèle. L’OPT-1.3B de base, entraîné sur un corpus anglophone générique, produit désormais du français qui réemploie le lexique des Misérables (Jean Valjean, rues sombres de Paris) — la greffe du dataset de classiques français a pris. Mais observez aussi les limites : « son bain de boulon » (image absurde assemblée sur des noms réels), « il dormait sur le floor » (anglicisme — le lexique anglais du pré-entraîné ressort sous le vernis français) sont des fragments grammaticalement incohérents. C’est le compromis attendu d’un modèle de 1,3 Md paramètres fine-tuné sur seulement 10 extraits : l’imprégnation stylistique prend, la cohérence syntaxique profonde ne suit pas. C’est précisément ce déséquilibre que la cellule de comparaison suivante quantifie (qualité ~97-99% du full fine-tuning).

Lecture ancrée des 2e et 3e générations : le prompt « Jean Valjean regardait la nuit » produit « comme une espoir » (faute de genre sur un nom réel) puis dérive en boucle : les deux mêmes phrases (« Cosette dormait dans un logis, il dormait sur le floor ») se répètent jusqu’à la coupure — la signature d’un petit modèle mémo-stylisé. La troisième génération est la plus révélatrice : « où elle avait bien connu le sujet de la veille » (formule creuse montée sur des mots courants) et « Cosette avait lu celui qu’elle ne viendra pas » aussitôt suivi de « qu’elle ne viendrait pas » — la même phrase re-conjuguée, fautive puis correcte, comme si le modèle hésitait entre deux temps verbaux. Diagnostic : vocabulaire et atmosphère empruntés au corpus, morphologie et cohérence intrinsèques au modèle de base. Quinze époques sur 10 extraits greffent un lexique, pas une grammaire.

8. Comparaison LoRA vs QLoRA

Chargeons le même modèle en FP16 (sans quantization) pour comparer l’empreinte memoire.

Ce que la comparaison mesure : le tableau suivant recharge le même OPT-1.3B en FP16 pur, puis en 4-bit, avec à chaque fois reset_peak_memory_stats() avant chargement et lecture du pic après — trois mesures indépendantes du même modèle dans trois configurations. Le pic FP16 servira de référence ; la ligne finale reprend le pic d’entraînement QLoRA mesuré en section 6. Attention à la lecture : les deux premières lignes mesurent des pics de chargement (transitoires de déquantization compris), la troisième un pic d’entraînement en régime établi — d’où un chiffre qui peut sembler plus bas que la ligne du dessus.

# Charger le modele en FP16 pour comparaison
model_fp16 = AutoModelForCausalLM.from_pretrained(
    MODEL_NAME,
    torch_dtype=torch.float16,
    device_map="auto",
)

# Ajouter LoRA identique
lora_config_fp16 = LoraConfig(
    r=16,
    lora_alpha=32,
    target_modules=["q_proj", "v_proj"],
    lora_dropout=0.05,
    bias="none",
    task_type="CAUSAL_LM",
)
model_lora_fp16 = get_peft_model(model_fp16, lora_config_fp16)

# Mesures comparatives
def measure_vram(model, label):
    if torch.cuda.is_available():
        alloc = torch.cuda.memory_allocated() / 1e9
        reserved = torch.cuda.memory_reserved() / 1e9
        print(f"{label} : alloue={alloc:.2f} Go, reserve={reserved:.2f} Go")
        return alloc
    return 0

print(f"\n{'='*50}")
print(f"Comparaison memoire — {MODEL_NAME}")
print(f"{'='*50}")

# Nettoyer pour mesure propre
del model_fp16, model_lora_fp16
gc.collect()
if torch.cuda.is_available():
    torch.cuda.empty_cache()

# Recharger pour mesure propre
torch.cuda.reset_peak_memory_stats()
model_fp16 = AutoModelForCausalLM.from_pretrained(
    MODEL_NAME, torch_dtype=torch.float16, device_map="auto",
)
vram_fp16 = torch.cuda.max_memory_allocated() / 1e9

del model_fp16
gc.collect()
torch.cuda.empty_cache()
torch.cuda.reset_peak_memory_stats()

model_4b = AutoModelForCausalLM.from_pretrained(
    MODEL_NAME, quantization_config=bnb_config, device_map="auto",
)
vram_4bit_load = torch.cuda.max_memory_allocated() / 1e9

del model_4b
gc.collect()
if torch.cuda.is_available():
    torch.cuda.empty_cache()

print(f"\n{'Configuration':<25} {'VRAM':>10} {'Economie':>10}")
print(f"{'-'*25} {'-'*10} {'-'*10}")
print(f"{'FP16 (base)':<25} {vram_fp16:>9.2f} G {'ref':>10}")
print(f"{'4-bit NF4 (QLoRA base)':<25} {vram_4bit_load:>9.2f} G {(1-vram_4bit_load/vram_fp16)*100:>9.1f}%")
print(f"{'QLoRA (4-bit + LoRA r=16)':<25} {vram_peak:>9.2f} G {(1-vram_peak/vram_fp16)*100:>9.1f}%")
print(f"\nTheorie : FP16 = {total_params*2/1e9:.2f} Go vs 4-bit = {total_params*0.5/1e9:.2f} Go (ratio 4x)")

==================================================
Comparaison memoire — facebook/opt-1.3b
==================================================

Configuration                   VRAM   Economie
------------------------- ---------- ----------
FP16 (base)                    4.03 G        ref
4-bit NF4 (QLoRA base)         2.24 G      44.4%
QLoRA (4-bit + LoRA r=16)      1.72 G      57.4%

Theorie : FP16 = 1.42 Go vs 4-bit = 0.36 Go (ratio 4x)

Lecture du résultat : le moment où la mesure se fait, et ses limites

La cellule précédente a trois étapes de mesure dont l’ordre est important pour comprendre le chiffre affiché dans la cellule suivante :

  • Premier chargement (model_fp16) : mesure le coût d’un OPT-1.3B non quantizé. Le del model_fp16, model_lora_fp16 + gc.collect() + torch.cuda.empty_cache() qui suit est non optionnel : PyTorch ne libère pas explicitement la mémoire GPU tant qu’il peut la recycler pour des allocations futures. Sans ce nettoyage, la mesure du deuxième chargement (model_4b) inclurait l’empreinte du premier, faussant l’économie affichée.
  • Deuxième chargement (model_4b) : OPT-1.3B en quantization NF4. On observe 2.24 G vs 4.03 G en FP16 → 44,4 % d’économie. Mais la théorie annonce ~4× d’économie (16 bits → 4 bits), pas 1,8× : l’écart vient des buffers CUDA supplémentaires que bitsandbytes alloue pour les lookups de quantification, et des états internes de HuggingFace (cache d’attention, KV cache). Sur un 1,3B, ces surcoûts représentent une fraction importante du total — sur un 7B+, le ratio se rapproche de la théorie.
  • vram_peak (3ᵉ mesure implicite, imprimée par la cellule précédente) : 1.72 G, soit 57,4 % d’économie sur le pic de mémoire atteint pendant l’entraînement. C’est ce chiffre qui importe pour savoir si le fine-tuning tient dans votre VRAM — pas la mesure statique de chargement, qui ignore les activations forward, les gradients LoRA, et les moments Adam. vram_peak capture l’instant le plus gourmand, juste avant le déchargement.

L’intuition à retenir : l’empreinte d’un modèle ne se mesure jamais une seule fois. Trois étapes (FP16 / 4-bit base / 4-bit + LoRA) parce que trois questions : combien pèse le modèle seul ?, combien pèse la quantization seule ?, combien coûte l’entraînement complet ?. Le chiffre qui doit déclencher un achat de GPU, c’est vram_peak, pas vram_fp16.

Le piège à éviter : croire que la mesure sera reproductible au bit près. CUDA non-deterministic memory allocation, l’ordre des del, la présence d’autres processus sur le GPU font varier le chiffre de ±10 % entre runs. Pour les comparaisons sérieuses (publication, calibration), faire une moyenne sur 5 runs avec torch.cuda.empty_cache() intercalés.

La cellule qui suit imprime le tableau récapitulatif qui résume ces trois chiffres — accompagné de la qualité et de la vitesse estimées, qui ne se mesurent pas à l’exécution mais sont prédites par la théorie (NF4 ≈ 97-99 % du full FT ; ~20 % de ralentissement par la déquantization à chaque forward).

# Tableau recapitulatif complet
print("\n" + "=" * 70)
print("RESUME COMPARATIF : LoRA (FP16) vs QLoRA (4-bit)")
print("=" * 70)
print(f"\n{'Aspect':<30} {'LoRA (FP16)':<20} {'QLoRA (4-bit)':<20}")
print(f"{'-'*30} {'-'*20} {'-'*20}")
print(f"{'Precision poids':<30} {'FP16 (16 bits)':<20} {'NF4 (4 bits)':<20}")
print(f"{'Calculs forward':<30} {'FP16':<20} {'FP16 (dequant)':<20}")
print(f"{'Grad + optimizer':<30} {'FP16 (LoRA seul)':<20} {'FP16 (LoRA seul)':<20}")
print(f"{'VRAM modele ~1.3B':<30} {'~2.6 Go':<20} {'~0.7 Go':<20}")
print(f"{'Qualite vs full FT':<30} {'~98-99%':<20} {'~97-99%':<20}")
print(f"{'Vitesse entrainement':<30} {'1x (ref)':<20} {'~0.8x (dequant)':<20}")
print(f"\nRecommendation :\n"
      "  - Modele < 1B + VRAM suffisante -> LoRA (FP16) : plus simple, plus rapide\n"
      "  - Modele > 1B ou VRAM limitee -> QLoRA : Economie memoire 3-4x")

======================================================================
RESUME COMPARATIF : LoRA (FP16) vs QLoRA (4-bit)
======================================================================

Aspect                         LoRA (FP16)          QLoRA (4-bit)       
------------------------------ -------------------- --------------------
Precision poids                FP16 (16 bits)       NF4 (4 bits)        
Calculs forward                FP16                 FP16 (dequant)      
Grad + optimizer               FP16 (LoRA seul)     FP16 (LoRA seul)    
VRAM modele ~1.3B              ~2.6 Go              ~0.7 Go             
Qualite vs full FT             ~98-99%              ~97-99%             
Vitesse entrainement           1x (ref)             ~0.8x (dequant)     

Recommendation :
  - Modele < 1B + VRAM suffisante -> LoRA (FP16) : plus simple, plus rapide
  - Modele > 1B ou VRAM limitee -> QLoRA : Economie memoire 3-4x

Lecture du résultat : le triptyque VRAM / qualité / vitesse

Le tableau récapitulatif condense la promesse et le coût de QLoRA sur trois axes :

  • VRAM (le gain décisif) : ~0,7 Go (QLoRA) contre ~2,6 Go (LoRA FP16), soit ~3,7× d’économie mémoire. C’est ce qui rend un modèle de 1,3 Md fine-tunable sur une carte consumer (8-12 Go) au lieu d’une 24 Go. La clé : seuls les poids NF4 (4 bits) résident en VRAM ; les gradients et l’optimiseur ne portent que sur les adaptateurs LoRA (FP16), d’où la ligne Grad + optimizer : FP16 (LoRA seul) identique des deux côtés.
  • Qualité (le prix à payer) : ~97-99% du full fine-tuning. La quantization NF4 préserve l’information car elle concentre la précision là où les poids sont denses (distribution gaussienne supposée), mais une dégradation de 1-3 % est intrinsèque à la perte d’information 16→4 bits.
  • Vitesse (le coût caché) : ~0,8×, soit ~20 % de ralentissement. La déquantization NF4→FP16 à chaque forward pass a un surcoût de calcul — QLoRA n’est pas gratuit en temps d’entraînement.

La recommandation qui clôt le tableau encode la règle de décision : LoRA (FP16) si la VRAM le permet (plus simple, plus rapide), QLoRA dès que le modèle dépasse ~1 Md de paramètres ou que la VRAM est contrainte. C’est ce positionnement — fine-tuner de gros modèles sur du petit matériel — qui a fait de QLoRA le standard du fine-tuning ouvert sur GPU consumer.

Le tableau mesuré, chiffre par chiffre : FP16 4,03 Go (référence) ; 4-bit NF4 2,24 Go soit 44,4 % d’économie ; QLoRA complet (base 4-bit + adaptateurs + optimiseur, pic d’entraînement) 1,72 Go soit 57,4 %. La ligne théorique (1,42 Go vs 0,36 Go, ratio 4×) utilise le comptage affiché en section 5 et ne couvre que les poids — la réalité mesurée est moins spectaculaire que la théorie (1,8× sur le chargement au lieu de 4×) à cause des tampons de déquantization et des embeddings. Notez enfin que la ligne QLoRA (1,72 Go) est un pic d’entraînement, pas un chargement : elle inclut activations et optimiseur 8 bits, et reste sous le pic du simple chargement FP16 — c’est la comparaison économiquement parlante : fine-tuner entièrement en QLoRA coûte moins de VRAM que charger seul en FP16.

9. Nettoyage memoire GPU

Pourquoi cette cellule compte : restituer la VRAM (22,4 Go libres après nettoyage) n’est pas de la politesse — la cellule de comparaison suivante recharge le modèle deux fois, et sans del + empty_cache(), le cache CUDA saturerait les mesures. Sur un notebook GPU multi-expériences, la discipline mémoire est la condition de mesures honnêtes.

del model_qlora, trainer
gc.collect()
if torch.cuda.is_available():
    torch.cuda.empty_cache()
    print(f"VRAM libre : {torch.cuda.mem_get_info()[0] / 1e9:.1f} Go")
else:
    print("Nettoyage termine")
VRAM libre : 22.4 Go

10. Exemples guidés et exercices

Cette section applique les concepts du notebook en deux temps : quatre exemples guidés résolus et commentés, puis quatre exercices à compléter qui mesurent ce que les exemples ne couvrent pas.

Les quatre exemples guidés proviennent d’une contribution étudiante (@nassimJaz, PR #18550) : chaque énoncé, son code de mesure et la lecture du résultat qui suit forment un triplet autonome.

  1. Impact de la précision — INT8 vs NF4 vs FP16 : VRAM de chargement, empreinte des poids, perplexité et générations.
  2. Rang LoRA et mémoire — r = 4, 16, 64 : paramètres entraînables, pic VRAM, perte finale, générations.
  3. Double quantization — bnb_4bit_use_double_quant on/off : écart de VRAM mesuré contre la théorie (~0,37 bit/param).
  4. Casser l’invariant d’initialisation — B initialisé non nul dans le LoRALinear de la section 4.

Les exercices à compléter (dernière sous-section) reprennent le flambeau : erreur de reconstruction des poids quantifiés, mémorisation contre généralisation, NF4 contre FP4 à mémoire égale, échelle alpha/r contre alpha/sqrt(r). Chaque énoncé porte son critère de réussite mesurable.

Exemple guidé 1 : impact de la précision (INT8 vs NF4 vs FP16)

Contribution étudiante de Nassim JAZOULI (@nassimJaz) et Minh NGUYEN (PR #18550), intégrée comme exemple guidé.

Comparer INT8 (load_in_8bit=True), NF4 (4-bit avec double quantization) et FP16 sur le même OPT-1.3B. Pour chaque précision : mesurer la VRAM de chargement (pic), l’empreinte des poids (get_memory_footprint), générer en greedy sur un même prompt, et calculer la perplexité sur les 10 extraits d’entraînement comme mesure chiffrée de la dégradation.

Ce que la théorie annonce : ~2,6 Go de poids seuls en FP16 (2 octets/paramètre), ~1,3 Go en INT8 (1 octet), ~0,7 Go en NF4 (0,5 octet) ; la VRAM de chargement doit donc se ranger FP16 > INT8 > NF4. La qualité de génération doit se dégrader dans le même ordre, mais faiblement — les trois modèles restent lisibles sur un 1,3B.

Étapes suivies par le corrigé :

  1. Définir les trois configurations de chargement (une torch_dtype, deux BitsAndBytesConfig).
  2. Pour chacune : reset_peak_memory_stats(), charger, mesurer pic et empreinte, générer, calculer la perplexité sur les extraits, puis libérer la mémoire.
  3. Résumer dans un tableau avec l’économie de VRAM relative au FP16.
# Exemple guide 1 : Impact de la precision
# Contribution etudiante de @nassimJaz (PR #18550)
# Comparaison INT8 (load_in_8bit=True) vs NF4 vs FP16 sur OPT-1.3B.
# Pour chaque precision : VRAM de chargement (pic) + generation sur un meme prompt.

# Liberer ce qui reste des sections precedentes (model_4bit est encore reference)
for _name in ["model_4bit", "model_qlora", "trainer", "model_fp16", "model_4b"]:
    globals().pop(_name, None)
gc.collect()
torch.cuda.empty_cache()

configs_precision = {
    "FP16": dict(torch_dtype=torch.float16),
    "INT8": dict(quantization_config=BitsAndBytesConfig(load_in_8bit=True)),
    "NF4": dict(quantization_config=BitsAndBytesConfig(
        load_in_4bit=True,
        bnb_4bit_quant_type="nf4",
        bnb_4bit_compute_dtype=torch.float16,
        bnb_4bit_use_double_quant=True,
    )),
}

prompt_ex1 = "Dans les rues sombres de Paris,"
resultats_ex1 = {}

for label, kwargs in configs_precision.items():
    gc.collect()
    torch.cuda.empty_cache()
    torch.cuda.reset_peak_memory_stats()
    m = AutoModelForCausalLM.from_pretrained(MODEL_NAME, device_map="auto", **kwargs)
    m.eval()
    vram_load = torch.cuda.max_memory_allocated() / 1e9
    empreinte = m.get_memory_footprint() / 1e9

    # Generation greedy (deterministe) pour comparer la qualite a entree egale
    inputs = tokenizer(prompt_ex1, return_tensors="pt").to(m.device)
    with torch.no_grad():
        out = m.generate(**inputs, max_new_tokens=40, do_sample=False,
                         repetition_penalty=1.2, pad_token_id=tokenizer.eos_token_id)
    texte = tokenizer.decode(out[0], skip_special_tokens=True)

    # Perplexite sur les textes d'entrainement : mesure chiffree de la degradation
    nll, n_tok = 0.0, 0
    with torch.no_grad():
        for t in training_texts:
            enc = tokenizer(t, return_tensors="pt").to(m.device)
            loss = m(**enc, labels=enc["input_ids"]).loss
            n = enc["input_ids"].shape[1] - 1
            nll += loss.item() * n
            n_tok += n
    ppl = float(torch.exp(torch.tensor(nll / n_tok)))

    resultats_ex1[label] = dict(vram=vram_load, footprint=empreinte, ppl=ppl, texte=texte)
    print(f"\n[{label}] VRAM pic chargement = {vram_load:.2f} Go | empreinte poids = {empreinte:.2f} Go | perplexite = {ppl:.2f}")
    print(f"  -> {texte}")

    del m, inputs, out
    gc.collect()
    torch.cuda.empty_cache()

print(f"\n{'Precision':<10} {'VRAM pic':>10} {'Empreinte':>10} {'Perplexite':>11} {'Economie VRAM':>14}")
ref = resultats_ex1["FP16"]["vram"]
for label, r in resultats_ex1.items():
    print(f"{label:<10} {r['vram']:>8.2f} G {r['footprint']:>8.2f} G {r['ppl']:>11.2f} {(1 - r['vram'] / ref) * 100:>13.1f}%")

[FP16] VRAM pic chargement = 3.39 Go | empreinte poids = 2.63 Go | perplexite = 33.89
  -> Dans les rues sombres de Paris, des manifestants ont dénoncé le « traitement » du coronavirus.

« Je ne suis pas un virus ! », a-t-il lancé dans

[INT8] VRAM pic chargement = 2.18 Go | empreinte poids = 1.42 Go | perplexite = 33.58
  -> Dans les rues sombres de Paris, des manifestants ont dénoncé la « mauvaise gestion » du coronavirus.

« Je suis triste pour l’économie et je ne pe

[NF4] VRAM pic chargement = 1.60 Go | empreinte poids = 0.82 Go | perplexite = 34.47
  -> Dans les rues sombres de Paris, des gens se retrouvent désormais à la recherche du monde.

C'est ce qu'on appelle le "café-déje

Precision    VRAM pic  Empreinte  Perplexite  Economie VRAM
FP16           3.39 G     2.63 G       33.89           0.0%
INT8           2.18 G     1.42 G       33.58          35.6%
NF4            1.60 G     0.82 G       34.47          52.9%

Lecture du résultat — exemple guidé 1 : la précision achète de la VRAM, pas de la qualité

  • VRAM : l’ordre attendu est bien respecté, FP16 (3,39 Go) > INT8 (2,18 Go) > NF4 (1,60 Go) en pic de chargement, soit 52,9 % d’économie pour le NF4. L’empreinte des poids (get_memory_footprint : 2,63 / 1,42 / 0,82 Go) suit à peu près la théorie (2 octets, 1 octet, 0,5 octet par paramètre quantifié). L’écart par rapport au ratio théorique 4× vient des couches qui ne sont pas quantifiées : les embeddings (token + positions, 50 272 × 2048) et lm_head restent en FP16, ainsi que les LayerNorm. Sur un 1,3B ces couches pèsent une part importante du total, donc le gain relatif est plus faible que sur un 7B.
  • Qualité : la perplexité mesurée sur les 10 extraits varie de moins d’un point entre les trois formats (33,89 / 33,58 / 34,47). Le classement théorique FP16 ≤ INT8 ≤ NF4 n’est pas tranché par la mesure : l’INT8 s’affiche même légèrement sous le FP16 — un écart de 0,3 point, plus petit que le bruit de la mesure — et seul le NF4 se détache un peu (+0,6 point). Les générations greedy sont identiques sur les premiers tokens (« Dans les rues sombres de Paris, des… »), puis divergent légèrement : les trois modèles restent lisibles, NF4 conserve l’essentiel de l’information.
  • Remarque : INT8 (LLM.int8()) est souvent plus lent que FP16 à cause de la décomposition des outliers, alors que NF4 déquantifie par blocs en FP16. INT8 n’est donc pas un compromis évident : pour le fine-tuning mémoire-contraint, NF4 est le meilleur choix, ce qui justifie QLoRA.

Exemple guidé 2 : rang LoRA et mémoire (r = 4, 16, 64)

Contribution étudiante de Nassim JAZOULI (@nassimJaz) et Minh NGUYEN (PR #18550), intégrée comme exemple guidé.

Entraîner QLoRA sur OPT-1.3B pour trois rangs — r = 4, 16, 64 — en gardant tout le reste identique. Pour chaque rang : nombre de paramètres entraînables, pic VRAM d’entraînement, perte finale, durée, et une génération sur un prompt fixe (graine fixée). Pour que la comparaison soit équitable, alpha = 2r : l’échelle alpha/r = 2 est la même pour les trois rangs, seule la capacité change.

Ce que la théorie annonce : les paramètres entraînables suivent exactement 196 608 × r (formule démontrée en section 4 — la mécanique LoRA), soit 786 432, 3 145 728 et 12 582 912 ; les adaptateurs restent minuscules devant les poids NF4 figés ; la perte finale doit diminuer quand r monte — mais sur 10 exemples vus 15 fois, un rang élevé mémorise plutôt qu’il n’apprend : comparer les générations, pas seulement la perte.

Étapes suivies par le corrigé :

  1. Définir une fonction qui charge OPT-1.3B en NF4, greffe un LoRA de rang r (alpha = 2r) et entraîne 15 époques avec les hyperparamètres de la section 6.
  2. L’appeler pour r = 4, 16, 64 ; mesurer à chaque fois pic VRAM, perte finale et durée.
  3. Comparer dans un tableau : rang, paramètres entraînables (mesurés contre prédits par 196 608 × r), pic VRAM, perte finale, temps.
# Exemple guide 2 : LoRA rank et memoire
# Contribution etudiante de @nassimJaz (PR #18550)
# r = 4, 16, 64 avec QLoRA sur OPT-1.3B : params entrainables, VRAM pic, perte finale.

def train_qlora_rank(r, epochs=15):
    """Charge OPT-1.3B en NF4, greffe LoRA de rang r (alpha = 2r), entraine et mesure."""
    gc.collect()
    torch.cuda.empty_cache()
    base = AutoModelForCausalLM.from_pretrained(MODEL_NAME, quantization_config=bnb_config, device_map="auto")
    base = prepare_model_for_kbit_training(base)
    cfg = LoraConfig(r=r, lora_alpha=2 * r, target_modules=["q_proj", "v_proj"],
                     lora_dropout=0.05, bias="none", task_type="CAUSAL_LM")
    m = get_peft_model(base, cfg)
    n_train = sum(p.numel() for p in m.parameters() if p.requires_grad)

    args = TrainingArguments(
        output_dir=f"./temp_ft02_r{r}", num_train_epochs=epochs, per_device_train_batch_size=2,
        learning_rate=2e-4, weight_decay=0.01, logging_steps=25, save_strategy="no",
        report_to="none", fp16=True, seed=42, optim="paged_adamw_8bit",
    )
    tr = Trainer(model=m, args=args, train_dataset=tokenized_dataset, data_collator=data_collator)

    torch.cuda.reset_peak_memory_stats()
    t0 = time.time()
    res = tr.train()
    duree = time.time() - t0
    pic = torch.cuda.max_memory_allocated() / 1e9

    # Generation echantillonnee (seed fixe) pour comparer qualitativement
    m.eval()
    torch.manual_seed(0)
    inputs = tokenizer("Jean Valjean regardait la nuit", return_tensors="pt").to(m.device)
    with torch.no_grad():
        out = m.generate(**inputs, max_new_tokens=50, do_sample=True, temperature=0.8, top_p=0.9,
                         pad_token_id=tokenizer.eos_token_id)
    texte = tokenizer.decode(out[0], skip_special_tokens=True)

    del m, base, tr, inputs, out
    gc.collect()
    torch.cuda.empty_cache()
    return dict(r=r, trainable=n_train, predit=196_608 * r, vram_peak=pic,
                loss=res.training_loss, temps=duree, texte=texte)


resultats_ex2 = []
for r in [4, 16, 64]:
    print(f"\n===== r = {r} =====")
    res = train_qlora_rank(r)
    resultats_ex2.append(res)
    print(f"generation : {res['texte']}")

print(f"\n{'r':>3} {'params entrainables':>20} {'predit 196608*r':>16} {'VRAM pic':>10} {'perte finale':>13} {'temps':>8}")
for res in resultats_ex2:
    print(f"{res['r']:>3} {res['trainable']:>20,} {res['predit']:>16,} {res['vram_peak']:>8.2f} G "
          f"{res['loss']:>13.4f} {res['temps']:>6.1f} s")

===== r = 4 =====
[75/75 00:19, Epoch 15/15]
Step Training Loss
25 3.325595
50 2.799987
75 2.524201

generation : Jean Valjean regardait la nuit. Les garçons de la rue ne faisaient que jouer. Les hommes de la rue ne jouaient pas. Valjean n'apprenait jamais ce qu'on avait

===== r = 16 =====
[75/75 00:13, Epoch 15/15]
Step Training Loss
25 3.003321
50 2.243074
75 1.926127

generation : Jean Valjean regardait la nuit dans un bateau. Le courage et la determination de celui-la lui suivait lui-même. Cosette avait lu le navetement de son pere, mais elle n

===== r = 64 =====
[75/75 00:14, Epoch 15/15]
Step Training Loss
25 2.741246
50 1.715002
75 1.229577

generation : Jean Valjean regardait la nuit dans un bateau. Il ne savait rien pour ce soir. Cosette regardait lui, et il ne lui regardait pas. Ce soir, l'homme vivait dans la nature.

  r  params entrainables  predit 196608*r   VRAM pic  perte finale    temps
  4              786,432          786,432     2.13 G        2.8833   19.6 s
 16            3,145,728        3,145,728     2.57 G        2.3908   13.6 s
 64           12,582,912       12,582,912     3.03 G        1.8953   15.0 s

Lecture du résultat — exemple guidé 2 : le rang achète de la capacité, pas un meilleur modèle

  • Paramètres entraînables : ils suivent exactement la formule 196 608 × r démontrée en section 4 (786 432 / 3 145 728 / 12 582 912) — la colonne « prédit » coïncide avec le comptage réel.
  • VRAM pic : elle grimpe de 2,13 Go (r=4) à 2,57 Go (r=16) puis 3,03 Go (r=64) — environ 0,9 Go d’écart. Les adaptateurs seuls restent minuscules (~12,6 M paramètres à r=64, soit ~25 Mo de poids FP16) : la différence vient des gradients, des états de l’optimiseur paginé et des tampons intermédiaires qui accompagnent chaque paramètre entraînable — un pic VRAM se mesure, il ne se déduit pas d’un comptage de paramètres. Les poids NF4 figés et les activations (séquences de 256 tokens) dominent toujours la mémoire, mais à r=64 l’adaptateur n’est plus gratuit.
  • Perte finale : elle diminue quand r augmente (2,88 → 2,39 → 1,90), mais avec 10 exemples vus 15 fois, un rang élevé sert surtout à mémoriser le corpus. Les générations le montrent : à r=4, le modèle boucle sur des phrases courtes et interchangeables (« Les garçons de la rue ne faisaient que jouer ») ; à r=16 et r=64, il s’aventure dans des tournures plus ambitieuses mais fautives (« le navetement de son pere », « Cosette regardait lui ») — la perte baisse, la grammaire ne suit pas. Une perte plus basse n’est donc pas synonyme d’un meilleur modèle : sans jeu de validation, on ne mesure que la mémorisation — c’est exactement la question que l’exercice 2 ci-dessous retourne. Pour ce dataset, r=8-16 reste le compromis raisonnable.
  • Pour que la comparaison soit équitable, alpha = 2r : l’échelle alpha/r = 2 est identique pour les trois rangs, seule la capacité change.

Exemple guidé 3 : double quantization

Contribution étudiante de Nassim JAZOULI (@nassimJaz) et Minh NGUYEN (PR #18550), intégrée comme exemple guidé.

La double quantization (bnb_4bit_use_double_quant=True) compresse les constantes de quantization elles-mêmes en FP8, économisant ~0,37 bit/param. Comparer avec et sans pour mesurer l’impact réel sur la VRAM et la qualité.

Protocole : mesurer la VRAM de chargement dans les deux configurations avec reset_peak_memory_stats() avant chaque from_pretrained (protocole de la section 3). L’écart attendu — ~56 Mo pour les ~1,21 Md de paramètres effectivement quantifiés d’OPT-1.3B (× 0,373 bit ÷ 8) — est petit devant le bruit des pics de chargement : exécuter chaque mesure 3 fois et comparer les minima, pas les pics uniques. Un écart nul ne veut pas dire « la double quantization ne marche pas » : vérifier d’abord que les deux BitsAndBytesConfig ne diffèrent bien que par bnb_4bit_use_double_quant.

# Exemple guide 3 : Double quantization on/off
# Contribution etudiante de @nassimJaz (PR #18550)
# Protocole : reset_peak_memory_stats avant chaque from_pretrained, 3 repetitions, on garde les minima.

def charger_nf4(double_quant):
    cfg = BitsAndBytesConfig(
        load_in_4bit=True,
        bnb_4bit_quant_type="nf4",
        bnb_4bit_compute_dtype=torch.float16,
        bnb_4bit_use_double_quant=double_quant,   # SEULE difference entre les deux configs
    )
    return AutoModelForCausalLM.from_pretrained(MODEL_NAME, quantization_config=cfg, device_map="auto")


resultats_ex3 = {}
for dq in [False, True]:
    pics, allocs = [], []
    for _ in range(3):
        gc.collect()
        torch.cuda.empty_cache()
        torch.cuda.reset_peak_memory_stats()
        m = charger_nf4(dq)
        pics.append(torch.cuda.max_memory_allocated() / 1e9)
        allocs.append(torch.cuda.memory_allocated() / 1e9)
        footprint = m.get_memory_footprint() / 1e9
        del m
    # Qualite : perplexite sur les extraits (un chargement supplementaire)
    m = charger_nf4(dq)
    m.eval()
    nll, n_tok = 0.0, 0
    with torch.no_grad():
        for t in training_texts:
            enc = tokenizer(t, return_tensors="pt").to(m.device)
            n = enc["input_ids"].shape[1] - 1
            nll += m(**enc, labels=enc["input_ids"]).loss.item() * n
            n_tok += n
    ppl = float(torch.exp(torch.tensor(nll / n_tok)))
    del m, enc
    gc.collect()
    torch.cuda.empty_cache()
    resultats_ex3[dq] = dict(pic=min(pics), alloc=min(allocs), footprint=footprint, ppl=ppl)

print(f"{'double_quant':<14} {'pic min':>9} {'alloue min':>11} {'empreinte':>10} {'perplexite':>11}")
for dq, r in resultats_ex3.items():
    print(f"{str(dq):<14} {r['pic']:>7.3f} G {r['alloc']:>9.3f} G {r['footprint']:>8.3f} G {r['ppl']:>11.3f}")

ecart_mo = (resultats_ex3[False]["alloc"] - resultats_ex3[True]["alloc"]) * 1e3
print(f"\nEconomie mesuree (memoire allouee) : {ecart_mo:.1f} Mo")

# Theorie : seules les couches lineaires des blocs sont quantifiees (pas les embeddings / lm_head)
n_quant = 24 * (4 * 2048 * 2048 + 2 * 2048 * 8192)
print(f"Params quantifies : {n_quant:,} -> theorie 0.373 bit/param = {n_quant * 0.373 / 8 / 1e6:.1f} Mo")
double_quant     pic min  alloue min  empreinte  perplexite
False            2.938 G     2.630 G    0.820 G      34.457
True             2.882 G     2.573 G    0.820 G      34.470

Economie mesuree (memoire allouee) : 56.1 Mo
Params quantifies : 1,207,959,552 -> theorie 0.373 bit/param = 56.3 Mo

Lecture du résultat — exemple guidé 3 : un gain minuscule, linéaire dans la taille du modèle

  • Théorie : sans double quantization, chaque bloc de 64 poids a une constante d’échelle en FP32 → 32/64 = 0,5 bit/param. Avec double quantization, ces constantes sont stockées en 8 bits par blocs de 256, plus une constante FP32 par bloc de 256 constantes → 8/64 + 32/(64·256) ≈ 0,127 bit/param. Gain : ≈ 0,373 bit/param.
  • Mesure : seules les couches linéaires des 24 blocs de décodeur sont quantifiées (1 207 959 552 paramètres, soit ≈ 1,21 Md ; les embeddings et lm_head restent en FP16). On attend donc ≈ 56 Mo d’économie — et l’écart mesuré sur la mémoire allouée tombe dessus : 56,1 Mo mesurés contre 56,3 Mo prédits par la théorie. Sur le pic de chargement, l’écart du même ordre (2,938 contre 2,882 Go) est plus bruité car le pic contient des tampons temporaires de conversion : d’où l’intérêt de répéter la mesure et de comparer les minima.
  • Qualité : la perplexité est quasiment identique entre les deux configurations (34,457 contre 34,470 — différence de l’ordre du bruit numérique) : les constantes d’échelle quantifiées en 8 bits sont assez précises pour ne rien changer au résultat.
  • Conclusion : sur OPT-1.3B le gain est anecdotique (~2 % de la VRAM), mais il grandit linéairement avec la taille : ~0,3 Go pour un 7B, ~3 Go pour un 65B — c’est ce qui permettait dans le papier QLoRA de faire tenir un 65B sur un seul GPU de 48 Go. C’est un gain « gratuit », d’où son activation par défaut.

Exemple guidé 4 : casser l’invariant d’initialisation

Contribution étudiante de Nassim JAZOULI (@nassimJaz) et Minh NGUYEN (PR #18550), intégrée comme exemple guidé.

L’invariant de la section 4 : LoRALinear initialise B à zéro, donc ΔW = B·A = 0 et le modèle fine-tunable démarre exactement comme le pré-entraîné. Cet exemple casse exprès cet invariant : initialiser B à normal(0, b_std) au lieu de zéro, et observer ce que ce choix détruit.

Mesures :

  1. Pour b_std = 0, 0.001, 0.01, 0.1 sur une couche nn.Linear 512×512 identique à celle de la section 4 : norme maximale de ΔW initial, rapport |s·ΔW| / |W₀|, test torch.allclose(lora(x), base(x)) avant tout entraînement, écart maximal de sortie.
  2. Reprendre la régression de la section 4 (50 pas SGD, même learning rate) avec B = 0 puis B ≠ 0 : comparer les pertes initiales et finales.

Indice de raisonnement : reprendre la classe de la section 4 et remplacer uniquement l’initialisation de B ; si A et B étaient tous deux nuls, les gradients seraient nuls et rien n’apprendrait jamais.

# Exemple guide 4 : initialiser B NON nul et observer le démarrage
# Contribution etudiante de @nassimJaz (PR #18550)
def init_b_nonzero(base_layer, r=8, lora_alpha=16, b_std=0.01):
    """
    Retourner un LoRALinear dont B est initialise a normal(0, b_std) au lieu de zero.
    Mesurer : delta_W initial, forward == base ?, et interpreter pourquoi
    le modele LoRA ne demarre PLUS exactement comme le pre-entraine.
    """
    lora = LoRALinear(base_layer, r=r, lora_alpha=lora_alpha)
    with torch.no_grad():
        lora.lora_B.normal_(0, b_std)       # B != 0  ->  delta_W = B @ A != 0
    return lora


# Couche de base fraiche (identique a la section 4), pour ne pas dependre de lora_demo
torch.manual_seed(42)
base_ex4 = nn.Linear(512, 512, bias=False)
with torch.no_grad():
    base_ex4.weight.copy_(torch.randn(512, 512) * 0.02)

x_probe4 = torch.randn(4, 512)
print(f"{'b_std':>7} {'max|dW|':>10} {'|s*dW|/|W0|':>12} {'forward==base':>14} {'ecart sortie max':>17}")
for b_std in [0.0, 0.001, 0.01, 0.1]:
    torch.manual_seed(0)
    lora_nz = init_b_nonzero(base_ex4, r=8, lora_alpha=16, b_std=b_std)
    dW = lora_nz.delta_W().detach()
    with torch.no_grad():
        out_nz, out_b = lora_nz(x_probe4), base_ex4(x_probe4)
    rel = (lora_nz.scaling * dW).norm() / base_ex4.weight.norm()
    print(f"{b_std:>7} {dW.abs().max():>10.2e} {rel:>12.2%} {str(torch.allclose(out_nz, out_b)):>14} "
          f"{(out_nz - out_b).abs().max():>17.2e}")

# Consequence sur l'apprentissage : meme tache, meme lr, B=0 vs B!=0
print("\nMeme regression que la section 4 (50 pas SGD, lr=0.1) :")
torch.manual_seed(1)
xb, tgt = torch.randn(16, 512), torch.randn(16, 512)
for b_std in [0.0, 0.1]:
    torch.manual_seed(0)
    l = init_b_nonzero(base_ex4, b_std=b_std)
    opt4 = torch.optim.SGD([l.lora_A, l.lora_B], lr=0.1)
    losses = []
    for _ in range(50):
        opt4.zero_grad()
        loss = ((l(xb) - tgt) ** 2).mean()
        losses.append(loss.item())
        loss.backward()
        opt4.step()
    print(f"  b_std={b_std:<4} : loss initiale = {losses[0]:.4f} -> finale = {losses[-1]:.4f}")
  b_std    max|dW|  |s*dW|/|W0|  forward==base  ecart sortie max
    0.0   0.00e+00        0.00%           True          0.00e+00
  0.001   4.38e-04        0.73%          False          1.07e-02
   0.01   4.38e-03        7.25%          False          1.07e-01
    0.1   4.38e-02       72.50%          False          1.07e+00

Meme regression que la section 4 (50 pas SGD, lr=0.1) :
  b_std=0.0  : loss initiale = 1.2279 -> finale = 1.1487
  b_std=0.1  : loss initiale = 1.3386 -> finale = 1.2085

Lecture du résultat — exemple guidé 4 : pourquoi B doit naître nul

  • Avec B = 0 (b_std = 0), on retrouve l’invariant : ΔW = 0, forward == base → True.
  • Dès que B ≠ 0, ΔW = B·A est une matrice aléatoire non nulle de rang ≤ r, et forward == base → False : avant le moindre pas d’entraînement, le modèle n’est plus le pré-entraîné. Il est perturbé par un bruit (α/r)·B·A·x dont l’amplitude croît avec b_std (colonne |s·ΔW| / |W₀|).
  • Pourquoi c’est un problème : sur un vrai LLM, ce bruit est injecté dans toutes les couches ciblées (48 projections pour OPT-1.3B) et se propage à travers le réseau : la perte de départ est plus élevée, et les premiers pas d’optimisation servent à « désapprendre » ce bruit au lieu d’apprendre la tâche. Avec un b_std grand, on peut même détruire les capacités du modèle (sorties incohérentes) dès l’initialisation. On le voit sur la régression jouet : la loss initiale est plus haute avec B ≠ 0.
  • Pourquoi A aléatoire et B nul (et pas l’inverse, ni les deux à zéro) : si A et B étaient tous deux nuls, les gradients ∂L/∂A ∝ Bᵀ(...) et ∂L/∂B ∝ (...)(Ax)ᵀ seraient nuls : rien n’apprendrait jamais. Avec A aléatoire et B = 0, le produit vaut 0 (démarrage exact) mais ∂L/∂B est non nul dès le premier pas, donc l’apprentissage démarre. C’est le seul choix qui combine démarrage exact et gradient non nul.

Exercices à compléter

Quatre exercices non résolus ferment le notebook. Chacun mesure quelque chose que les exemples guidés ne mesurent pas : l’erreur commise sur les poids eux-mêmes, la généralisation (et non la mémorisation), le choix NF4 contre FP4, et l’échelle des mises à jour LoRA. Les stubs s’exécutent sans erreur ; complétez-les cellule par cellule.

Exercice 1 : erreur de reconstruction des poids

L’exemple guidé 1 a mesuré ce que la quantization coûte en VRAM et en perplexité — jamais ce qu’elle fait aux poids eux-mêmes. Ici on ouvre le capot : sur une seule couche nn.Linear d’OPT-1.3B (model.decoder.layers[0].self_attn.q_proj), comparer les poids FP16 à leurs versions NF4 et INT8 reconstruites, et localiser où l’erreur se concentre.

  1. Charger OPT-1.3B en FP16 sur CPU (device_map="cpu"), extraire W_fp16 (2048 × 2048) de q_proj de la couche 0, puis ne travailler qu’avec cette matrice.
  2. Round trip NF4 : bitsandbytes.functional.quantize_4bit puis dequantize_4bit (l’API attend des blocs de 64 contigus, la matrice doit être reshapée en (n_blocs, 64) et être sur CUDA).
  3. Round trip INT8 absmax par ligne : chaque ligne divisée par son max absolu, arrondie sur 127 niveaux, puis remultipliée.
  4. Pour chaque format : erreur relative de Frobenius ‖W_rec − W‖_F / ‖W‖_F et erreur absolue maximale.
  5. Localiser l’erreur : trier les poids par magnitude absolue et calculer la part de l’erreur totale portée par le 1 % des poids les plus grands — les outliers que la quantization par blocs cherche à protéger.

Indices : - La matrice seule fait 2048 × 2048 : la garder sur CPU et ne déplacer sur CUDA que ce que bnb.functional exige. - Comparer aussi la forme des deux erreurs : NF4 est taillé pour une distribution de poids gaussienne, INT8 absmax pour une distribution uniforme.

Critère de réussite : un tableau format | erreur Frobenius relative | erreur max absolue | part de l'erreur dans le 1 % des plus grands poids, avec des erreurs relatives de l’ordre de 10⁻² à 10⁻¹.

# Exercice 1 : erreur de reconstruction des poids (NF4 / INT8 contre FP16)
# Etape 1 : charger OPT-1.3B sur CPU, extraire W_fp16 de q_proj (couche 0).
# Etape 2 : round trip NF4 (quantize_4bit/dequantize_4bit, blocs de 64) et INT8 absmax par ligne.
# Etape 3 : erreur relative de Frobenius + erreur absolue maximale pour chaque format.
# Etape 4 : part de l'erreur totale portee par le 1 % des poids de plus grande magnitude.
# Indice : bnb.functional travaille sur CUDA ; ne mover que la matrice 2048 x 2048.

W_fp16 = None      # TODO etudiant
W_rec_nf4 = None   # TODO etudiant
W_rec_int8 = None  # TODO etudiant

print("Exercice a completer : erreur de reconstruction des poids NF4/INT8 vs FP16")
Exercice a completer : erreur de reconstruction des poids NF4/INT8 vs FP16

Exercice 2 : mémorisation contre généralisation

L’exemple guidé 2 conclut que sans jeu de validation, la perte finale ne mesure que la mémorisation. Testez-le : couper les 10 extraits d’entraînement en 8 textes d’entraînement + 2 textes retenus (held-out), réentraîner QLoRA aux rangs r = 4, 16, 64 sur les 8 seulement, et mesurer la perplexité sur les 2 textes retenus avant et après entraînement.

  1. Construire train_texts_8 (8 premiers extraits de training_texts) et held_out_texts (2 derniers) ; reprendre la tokenisation de la section 6 sur les 8.
  2. Pour chaque rang r ∈ {4, 16, 64} : mesurer la perplexité held-out du modèle NF4 avant entraînement, puis entraîner sur les 8 extraits (mêmes hyperparamètres que l’exemple guidé 2).
  3. Après entraînement, mesurer de nouveau la perplexité held-out.
  4. Reporter un tableau r | ppl held-out avant | ppl held-out après | perte d'entraînement finale.

Indices : - Réutiliser la boucle de perplexité de l’exemple guidé 1 (somme des pertes pondérée par le nombre de tokens). - Hypothèse à tester : sur ce découpage 8/2, la perplexité held-out devrait baisser pour les petits rangs (généralisation) puis se dégrader à r = 64 (mémorisation des 8 extraits). C’est une prédiction plausible, pas un résultat garanti — avec seulement 2 textes held-out, l’incertitude est grande. Le rang qui minimise la ppl held-out peut différer de celui qui minimise la perte d’entraînement, ou les trois rangs peuvent être indistinguables.

Critère de réussite : le tableau des 3 rangs avec ppl held-out avant/après, et le nom du rang qui minimise la perplexité held-out.

# Exercice 2 : mémorisation contre généralisation (split 8/2 des extraits)
# Etape 1 : couper training_texts en 8 extraits d'entrainement + 2 extraits held-out.
# Etape 2 : pour r = 4, 16, 64, entrainer QLoRA sur les 8 extraits (protocole de l'exemple guide 2).
# Etape 3 : perplexite sur les 2 extraits held-out AVANT (base NF4) et APRES entrainement.
# Indice : reutiliser la boucle de perplexite de l'exemple guide 1.

resultats_exo2 = None  # TODO etudiant

print("Exercice a completer : quel rang generalise le mieux sur les 2 extraits held-out ?")
Exercice a completer : quel rang generalise le mieux sur les 2 extraits held-out ?

Exercice 3 : NF4 contre FP4 à mémoire égale

NF4 et FP4 occupent exactement la même place (4 bits par poids) : la différence est purement dans la forme des niveaux de quantization. Comparer les deux à mémoire égale sur OPT-1.3B.

  1. Charger OPT-1.3B deux fois en 4-bit : bnb_4bit_quant_type="nf4" puis "fp4" — la double quantization reste identique dans les deux configurations.
  2. Mesurer l’empreinte (get_memory_footprint) et le pic de chargement pour chaque format : ils doivent être quasi identiques.
  3. Mesurer la perplexité sur les textes d’entraînement pour les deux formats.
  4. Générer en greedy sur un même prompt et comparer les sorties.

Indices : - Les poids pré-entraînés d’un transformer suivent une distribution à peu près normale, centrée et concentrée près de zéro : NF4 place ses niveaux sur les quantiles de cette distribution (plus de niveaux au centre). FP4 n’est pas une grille uniforme : c’est un format flottant E2M1 (1 bit de signe, 2 d’exposant, 1 de mantisse) dont les niveaux positifs normalisés, tels qu’implémentés dans bitsandbytes (functional.py, SHA 833649043474794b8fe7a4136e0c40faf077b2e0), sont 0, 0.0052, 0.1667, 0.25, 0.3333, 0.5, 0.6667, 1 — les écarts ne sont pas constants (densité plus grande au centre qu’aux extrêmes, mais moins extrême que NF4). NF4 devrait donc gagner en perplexité à mémoire égale sur des poids gaussiens, mais c’est une prédiction, pas un résultat garanti. - Reprendre le squelette de l’exemple guidé 3 en ne changeant QUE bnb_4bit_quant_type.

Critère de réussite : empreintes égales à ~1 Mo près, les deux générations côte à côte, et un tableau ppl(NF4) vs ppl(FP4). Toute direction de l’inégalité (NF4 < FP4, NF4 ≈ FP4, NF4 > FP4) est acceptable si le protocole et les mesures la soutiennent — le classement est un résultat expérimental, pas un attendu.

# Exercice 3 : NF4 contre FP4 à mémoire égale
# Etape 1 : charger OPT-1.3B en 4-bit avec bnb_4bit_quant_type="nf4" puis "fp4"
#           (bnb_4bit_use_double_quant identique dans les deux).
# Etape 2 : empreinte (get_memory_footprint) et pic de chargement pour chaque format.
# Etape 3 : perplexite sur les textes d'entrainement + generation greedy sur un meme prompt.
# Indice : ne changer QUE bnb_4bit_quant_type entre les deux configurations.

resultats_exo3 = None  # TODO etudiant

print("Exercice a completer : NF4 vs FP4 a memoire egale")
Exercice a completer : NF4 vs FP4 a memoire egale

Exercice 4 : échelle alpha/r contre alpha/sqrt(r) (rsLoRA)

Le LoRALinear de la section 4 met la mise à jour à l’échelle par alpha / r. rsLoRA propose alpha / sqrt(r) : à rang élevé, la mise à jour ne s’effondre pas. Testez-le sur la régression jouet de la section 4 — CPU, rapide, sans modèle lourd.

  1. Reprendre la classe LoRALinear de la section 4 (copie locale avec un paramètre d’échelle), sur la même tâche de régression : même couche de base, mêmes données, graines fixées.
  2. Avec alpha fixé (par ex. 16), entraîner r = 4, 16, 64 pendant le même nombre de pas SGD (par ex. 300), même learning rate, pour les deux échelles : alpha/r et alpha/sqrt(r).
  3. Reporter la perte finale des 6 configurations dans un tableau.

Indices : - À alpha fixé, l’échelle alpha/r décroît comme 1/r : doubler le rang divise la mise à jour par deux ; alpha/sqrt(r) ne la divise que par √2. C’est l’hypothèse à confronter à la mesure. - Fixer toutes les graines (torch.manual_seed) pour que la seule différence entre deux runs soit l’échelle.

Critère de réussite : à r = 64, une perte finale nettement plus basse avec alpha/sqrt(r) qu’avec alpha/r, et des pertes proches des deux échelles à r = 4.

# Exercice 4 : échelle alpha/r contre alpha/sqrt(r) (rsLoRA) sur la régression de la section 4
# Etape 1 : reprendre LoRALinear avec un parametre d'echelle, alpha fixe (ex. 16), CPU.
# Etape 2 : entrainer r = 4, 16, 64 sur le meme nombre de pas SGD pour les deux echelles.
# Etape 3 : tableau des pertes finales des 6 configurations.
# Indice : graines fixees, seule l'echelle doit differer entre deux runs.

resultats_exo4 = None  # TODO etudiant

print("Exercice a completer : alpha/r vs alpha/sqrt(r) a r = 4, 16, 64")
Exercice a completer : alpha/r vs alpha/sqrt(r) a r = 4, 16, 64

Resume

Aspect LoRA (FT-01) QLoRA (FT-02)
Poids du modèle FP16 (16 bits) NF4 (4 bits)
Adaptateurs LoRA en FP16 LoRA en FP16
Calculs forward FP16 natif Dequantize → FP16 → re-quantize
Economie memoire ~1.1x modèle base ~3-4x vs FP16
Qualite ~98-99% du full FT ~97-99% du full FT
Modèle recommande < 1B params > 1B params

Points cles : - QLoRA = Quantization (NF4) + LoRA : le meilleur des deux mondes - NF4 est optimal pour les poids (distribution normale), pas un simple INT4 - Double quantization compresse les scaling factors pour des gains supplementaires - paged_adamw_8bit : optimiseur en 8-bit pour economiser encore plus de VRAM - Un modèle 7B en QLoRA tient sur un GPU 6 Go — democratise le fine-tuning

Reference : Dettmers et al., “QLoRA: Efficient Finetuning of Quantized LLMs” (2023).

Prochaines étapes (FT-03) : Fine-tuning multi-tâches avec plusieurs adaptateurs LoRA.

Le parcours mesuré se lit dans les outputs : le chargement NF4, le nombre exact de paramètres entraînables, la durée, le pic VRAM et la perte finale sont produits par les cellules précédentes. La grille de la section 4 prédit le compte d’adaptateurs au paramètre près ; les générations de la section 7 montrent ensuite le mémo-styling à l’œuvre. La boucle FT-01 → FT-02 est close : même mécanique LoRA, seule la résidence mémoire des poids change. FT-03 ouvre le multi-tâches : plusieurs adaptateurs, un modèle de base, et la question du partage de rang.

Vérification de l’affirmation « un 7B tient sur 6 Go » : poids 4-bit = 0,5 octet par paramètre, soit 3,5 Go pour 7 Md, contre 14 Go en FP16. Les adaptateurs LoRA et l’optimiseur paginé portent sur une petite fraction des paramètres. La marge dépend ensuite de la longueur de contexte, du batch et des activations : c’est pourquoi le notebook mesure le pic réel au lieu de se contenter de l’arithmétique des poids.

Retour au sommet