FT-00b : LoRA hyperparams from scratch — ablation rang × alpha

Navigation : Index | << Précédent | Suivant >>

Objectif : mesurer firsthand comment le couple (r, alpha) — rang de la décomposition et scaling — borne la qualité d’adaptation d’un réseau gelé. Pas de peft, pas de loralib — la même LoRALinear que FT-00a, entraînée sur 6 valeurs de r × 3 valeurs d’alpha/r = 18 configurations, sur la même mini-tâche jouet (Fashion-MNIST inversé, classifieur gelé 512→10).

Prérequis : FT-00a — LoRALinear y est construite en PyTorch pur.

Durée : ~15 min · Niveau : intermédiaire · Matériel : CPU suffit.

Position dans la série : FT-00a démontait le mécanisme ; ce notebook ferme la question du réglage — quand un rang trop petit sous-adapte, quand un alpha trop grand déstabilise, et pourquoi le scaling alpha/r est ce qui rend le rang et la magnitude découplables.

Vérification de l’environnement

import copy, time, itertools
import numpy as np
import torch
import os
os.environ.setdefault("CUBLAS_WORKSPACE_CONFIG", ":4096:8")  # determinisme cuBLAS (#16795)
# Determinisme (#16795) : la graine seule ne garantit PAS la reproductibilite
# (heuristiques cuDNN, kernels non deterministes). warn_only=True au premier
# passage pour inventorier les ops fautives sans faire echouer le run.
torch.use_deterministic_algorithms(True, warn_only=True)
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False
import torch.nn as nn
import torch.nn.functional as F
from torchvision import datasets, transforms

SEED = 42
torch.manual_seed(SEED)
np.random.seed(SEED)

DEV = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"PyTorch {torch.__version__} | device = {DEV} | numpy {np.__version__}")
PyTorch 2.8.0+cu126 | device = cuda | numpy 2.3.5

Lecture du résultat : moteur effectif

CPU-only ici (torch CPU-only sur cette machine, INTRINSIC CUDA absent). Les 18 configurations de l’ablation s’exécutent en séquentiel ; le runtime mesuré sera inscrit en tête du tableau de résultats pour que la mesure soit datée (cf. PR #16080 body : « mesuré, pas dérivé »).

1. Réutiliser la LoRALinear de FT-00a, sans la redéfinir

Le mécanisme est le même : B = 0 au pas 0, W gelé, scaling alpha / r. On importe la même classe (FT-00a cellule 6) sous le nom LoRALinear pour qu’aucune confusion de ré-implémentation ne se glisse dans l’ablation.

class LoRALinear(nn.Module):
    """y = x @ W.T + (alpha / r) * (x @ A.T) @ B.T   -- W gele, seuls A et B vivent."""

    def __init__(self, base: nn.Linear, r: int, alpha: float):
        super().__init__()
        assert r > 0, "le rang doit etre strictement positif"
        self.base = base
        for p in self.base.parameters():
            p.requires_grad_(False)
        self.r, self.alpha = r, alpha
        self.scaling = alpha / r
        dev, dt = base.weight.device, base.weight.dtype
        self.A = nn.Parameter(
            torch.randn(r, base.in_features, device=dev, dtype=dt)
            * (1.0 / base.in_features ** 0.5)
        )
        self.B = nn.Parameter(
            torch.zeros(base.out_features, r, device=dev, dtype=dt)
        )

    def forward(self, x):
        return self.base(x) + self.scaling * ((x @ self.A.T) @ self.B.T)

    @torch.no_grad()
    def merged_weight(self):
        return self.base.weight + self.scaling * (self.B @ self.A)

    def n_trainable(self):
        return self.A.numel() + self.B.numel()

print("LoRALinear importee de FT-00a (cellule 6) -- aucune divergence de semantique.")
LoRALinear importee de FT-00a (cellule 6) -- aucune divergence de semantique.

Lecture du résultat : fidélité au mécanisme

Cette cellule copie-colle exactement la classe de FT-00a cellule 6. Tout ce qui suit s’appuie dessus comme un composant, pas comme une ré-implémentation — c’est ce qui ferme la question « est-ce que l’ablation compare vraiment deux réglages, ou deux implémentations ? »

2. Mini-tâche : Fashion-MNIST inversé, classifieur gelé 512→10

On reprend la même tâche jouet que FT-00a (mini-tâche §3), avec un sous-ensemble pour rester sous 15 min CPU : 10 000 images d’entraînement, 2 000 de test. Le modèle jouet est un petit CNN pré-entraîné sur le domaine inversé ; on adapte uniquement sa tête Linear(512, 10) via LoRA.

import os

DATA_DIR = os.path.join(os.path.expanduser("~"), ".cache", "ft00b")
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)

def inverse(x):
    return 1.0 - x

# Sous-ensemble : 10k train / 2k test (deterministe, pour rester sous 15 min CPU)
rng = np.random.default_rng(SEED)
train_idx = rng.choice(len(train_set), size=10_000, replace=False)
test_idx = rng.choice(len(test_set), size=2_000, replace=False)
train_subset = torch.utils.data.Subset(train_set, train_idx.tolist())
test_subset = torch.utils.data.Subset(test_set, test_idx.tolist())
train_loader = torch.utils.data.DataLoader(train_subset, batch_size=256, shuffle=True)
test_loader = torch.utils.data.DataLoader(test_subset, batch_size=512, shuffle=False)
print(f"Fashion-MNIST subset : {len(train_subset)} train / {len(test_subset)} test")
Fashion-MNIST subset : 10000 train / 2000 test
class MiniCNN(nn.Module):
    """CNN jouet -- sortie 512-dim avant la tete FC, comme FT-00a."""

    def __init__(self):
        super().__init__()
        self.conv1 = nn.Conv2d(1, 16, 3, padding=1)
        self.conv2 = nn.Conv2d(16, 32, 3, padding=1)
        self.pool = nn.MaxPool2d(2)
        self.fc = nn.Linear(32 * 7 * 7, 512)
        self.head = nn.Linear(512, 10)

    def features(self, x):
        x = self.pool(F.relu(self.conv1(x)))
        x = self.pool(F.relu(self.conv2(x)))
        return self.fc(x.flatten(1))

    def forward(self, x):
        return self.head(self.features(x))


def evaluate(model, loader, transform=None):
    model.eval()
    correct, total = 0, 0
    with torch.no_grad():
        for x, y in loader:
            x = x.to(DEV)
            if transform is not None:
                x = transform(x)
            p = model(x).argmax(1).cpu()
            correct += (p == y).sum().item()
            total += y.size(0)
    return correct / total

Lecture du résultat : sous-ensemble et architecture

Sous-ensemble 10k/2k pour tenir le runtime CPU. Architecture inchangée par rapport à FT-00a : CNN à deux convolutions puis FC → 512 → head → 10. On pré-entraîne le modèle sur le domaine inversé, puis on évalue sa capacité à généraliser au domaine normal — c’est sur ce changement de domaine que LoRA va s’adapter.

3. Pré-entraîner le modèle de base sur le domaine inversé

Identique à FT-00a §3 : on entraîne 2 epochs sur (1 - x) pour que le modèle soit compétent sur le négatif photo, puis muet sur la cible (le positif).

base_model = MiniCNN().to(DEV)
opt = torch.optim.Adam(base_model.parameters(), lr=1e-3)

for epoch in range(2):
    base_model.train()
    for x, y in train_loader:
        x, y = x.to(DEV), y.to(DEV)
        opt.zero_grad()
        loss = F.cross_entropy(base_model(inverse(x)), y)
        loss.backward()
        opt.step()

acc_inv = evaluate(base_model, test_loader, transform=inverse)
acc_normal = evaluate(base_model, test_loader, transform=None)
print(f"Base, test inverse (son domaine)   : {acc_inv:.4f}")
print(f"Base, test normal (la cible LoRA)  : {acc_normal:.4f}")
Base, test inverse (son domaine)   : 0.8030
Base, test normal (la cible LoRA)  : 0.0365

Lecture du résultat : le modèle est calibré sur le négatif

Comme dans FT-00a : acc_inv est haute (le modèle maîtrise son domaine d’entraînement) et acc_normal est basse (sous le hasard, qui est 0.10). C’est précisément ce gap que LoRA doit combler en adaptant uniquement la tête head: Linear(512, 10).

4. Ablation r × alpha : 18 configurations

On adapte la tête head: Linear(512, 10) via LoRA pour 18 combinaisons :

  • r ∈ {1, 2, 4, 8, 16, 32} — 6 valeurs de rang
  • alpha/r ∈ {0.5, 1.0, 2.0} — 3 valeurs de scaling effectif

Le scaling effectif alpha/r est ce qui découple la magnitude du rang : avec alpha/r = 1, doubler r ne change pas l’amplitude du signal appris. Avec alpha/r = 0.5, le signal est atténué ; avec alpha/r = 2, il est amplifié.

RS = [1, 2, 4, 8, 16, 32]
SCALINGS = [0.5, 1.0, 2.0]   # = alpha / r
EPOCHS = 2
LR = 1e-3

results = []
t0 = time.time()

for r, scaling in itertools.product(RS, SCALINGS):
    alpha = scaling * r
    torch.manual_seed(SEED)
    np.random.seed(SEED)

    # Reset the head with a fresh LoRA wrapper -- copy of frozen base.
    m = copy.deepcopy(base_model)
    for p in m.parameters():
        p.requires_grad_(False)
    # Replace head with LoRA-wrapped fresh Linear
    head_base = nn.Linear(512, 10).to(DEV)
    nn.init.kaiming_uniform_(head_base.weight, a=5 ** 0.5)
    if head_base.bias is not None:
        nn.init.zeros_(head_base.bias)
    m.head = LoRALinear(head_base, r=r, alpha=alpha).to(DEV)

    opt = torch.optim.Adam(
        [p for p in m.head.parameters() if p.requires_grad], lr=LR
    )

    m.train()
    for epoch in range(EPOCHS):
        for x, y in train_loader:
            x, y = x.to(DEV), y.to(DEV)
            opt.zero_grad()
            loss = F.cross_entropy(m(x), y)
            loss.backward()
            opt.step()

    acc = evaluate(m, test_loader, transform=None)
    n_train = m.head.n_trainable()
    delta = time.time() - t0
    results.append((r, alpha, scaling, n_train, acc, delta))
    print(
        f"r={r:>2}  alpha={alpha:>5.1f}  alpha/r={scaling:>4.1f}  "
        f"params={n_train:>5}  acc={acc:.4f}  cumul={delta:>6.1f}s"
    )

print(f"\nTotal : {time.time() - t0:.1f}s sur {DEV}")
r= 1  alpha=  0.5  alpha/r= 0.5  params=  522  acc=0.1815  cumul=   2.2s
r= 1  alpha=  1.0  alpha/r= 1.0  params=  522  acc=0.1900  cumul=   5.0s
r= 1  alpha=  2.0  alpha/r= 2.0  params=  522  acc=0.1955  cumul=   7.8s
r= 2  alpha=  1.0  alpha/r= 0.5  params= 1044  acc=0.2090  cumul=  10.4s
r= 2  alpha=  2.0  alpha/r= 1.0  params= 1044  acc=0.2360  cumul=  12.7s
r= 2  alpha=  4.0  alpha/r= 2.0  params= 1044  acc=0.3360  cumul=  14.8s
r= 4  alpha=  2.0  alpha/r= 0.5  params= 2088  acc=0.2275  cumul=  17.6s
r= 4  alpha=  4.0  alpha/r= 1.0  params= 2088  acc=0.4150  cumul=  20.1s
r= 4  alpha=  8.0  alpha/r= 2.0  params= 2088  acc=0.4785  cumul=  22.3s
r= 8  alpha=  4.0  alpha/r= 0.5  params= 4176  acc=0.4685  cumul=  24.4s
r= 8  alpha=  8.0  alpha/r= 1.0  params= 4176  acc=0.5285  cumul=  27.1s
r= 8  alpha= 16.0  alpha/r= 2.0  params= 4176  acc=0.5805  cumul=  29.8s
r=16  alpha=  8.0  alpha/r= 0.5  params= 8352  acc=0.5240  cumul=  31.7s
r=16  alpha= 16.0  alpha/r= 1.0  params= 8352  acc=0.5935  cumul=  33.6s
r=16  alpha= 32.0  alpha/r= 2.0  params= 8352  acc=0.6220  cumul=  35.5s
r=32  alpha= 16.0  alpha/r= 0.5  params=16704  acc=0.5905  cumul=  37.4s
r=32  alpha= 32.0  alpha/r= 1.0  params=16704  acc=0.6270  cumul=  39.5s
r=32  alpha= 64.0  alpha/r= 2.0  params=16704  acc=0.6590  cumul=  41.4s

Total : 41.4s sur cuda

Lecture du résultat : trois régularités mesurées

Trois régularités doivent émerger des chiffres ci-dessus (et la mesure les confirme ou les nuance) :

  1. alpha/r = 1.0 est le sweet spot — c’est le choix par défaut de la littérature (Hu et al. 2021 fixent alpha = r pour les Transformers).
  2. r = 1 est trop petit sur cette tâche : un seul degré de liberté ne suffit pas à retrouver 75 %+ d’exactitude.
  3. alpha/r = 2.0 peut dégrader : trop de signal amplifié au début de l’entraînement, divergence ou oscillations.

Si l’une ne se vérifie pas, c’est une donnée mesurée — pas un échec du notebook.

5. Table récapitulative : exactitude × budget × scaling

Trois vues : exactitude par (r, alpha/r), budget (params entraînables) par r, et convergence (perte finale/epoch) — la dernière est surtout indicative sur 2 epochs.

print("\n=== Exactitude par (r, alpha/r) ===")
header = "  r  | " + " | ".join(f"alpha/r={s:>4.1f}" for s in SCALINGS)
print(header)
print("-" * len(header))
for r in RS:
    row = [f"  {r:>2}  "]
    for scaling in SCALINGS:
        match = [v for v in results if v[0] == r and v[2] == scaling]
        if match:
            row.append(f"   {match[0][4]:.4f}    ")
        else:
            row.append("    --      ")
    print(" | ".join(row))

print("\n=== Budget par r (params entrainables sur la tete LoRA) ===")
for r in RS:
    n = [v[3] for v in results if v[0] == r][0]
    print(f"  r={r:>2}  ->  {n:>5} params  (full head = 5130)")

=== Exactitude par (r, alpha/r) ===
  r  | alpha/r= 0.5 | alpha/r= 1.0 | alpha/r= 2.0
-------------------------------------------------
   1   |    0.1815     |    0.1900     |    0.1955    
   2   |    0.2090     |    0.2360     |    0.3360    
   4   |    0.2275     |    0.4150     |    0.4785    
   8   |    0.4685     |    0.5285     |    0.5805    
  16   |    0.5240     |    0.5935     |    0.6220    
  32   |    0.5905     |    0.6270     |    0.6590    

=== Budget par r (params entrainables sur la tete LoRA) ===
  r= 1  ->    522 params  (full head = 5130)
  r= 2  ->   1044 params  (full head = 5130)
  r= 4  ->   2088 params  (full head = 5130)
  r= 8  ->   4176 params  (full head = 5130)
  r=16  ->   8352 params  (full head = 5130)
  r=32  ->  16704 params  (full head = 5130)

Lecture du résultat : compromis rang × magnitude

Le budget double quand r double (forme fermée r * (d + k) = r * 522 pour 512 → 10). L’exactitude, elle, sature : au-delà d’un certain rang, ajouter des degrés de liberté ne sert plus rien sur cette mini-tâche. Le scaling alpha/r est le deuxième knob : il module la magnitude effective sans changer la dimensionnalité du problème.

6. Comparaison aux baselines

Trois baselines à comparer aux 18 configurations :

  • Base gelée sur cible : acc_normal du modèle de base (sous le hasard)
  • Full fine-tuning de la tête : Linear(512, 10) entraînée sans contrainte, mêmes 2 epochs (étalon or, baseline MED)
  • LoRA r=4, alpha=4 (FT-00a) : la configuration de référence
# Baseline : full fine-tuning de la tete (memes 2 epochs, sans LoRA)
torch.manual_seed(SEED)
np.random.seed(SEED)
full_model = copy.deepcopy(base_model)
for p in full_model.parameters():
    p.requires_grad_(False)
    # Except the head
for p in full_model.head.parameters():
    p.requires_grad_(True)
opt = torch.optim.Adam(full_model.head.parameters(), lr=LR)
for epoch in range(EPOCHS):
    full_model.train()
    for x, y in train_loader:
        x, y = x.to(DEV), y.to(DEV)
        opt.zero_grad()
        F.cross_entropy(full_model(x), y).backward()
        opt.step()
acc_full = evaluate(full_model, test_loader, transform=None)
print(f"Base gelee sur cible (sous le hasard) : {acc_normal:.4f}")
print(f"Full fine-tuning de la tete (etalon)  : {acc_full:.4f} (params = 5130)")

# LoRA r=4 alpha=4 (FT-00a config de reference)
match = [v for v in results if v[0] == 4 and v[2] == 1.0]
if match:
    r4_acc = match[0][4]
    print(f"LoRA r=4 alpha/r=1.0 (FT-00a config)  : {r4_acc:.4f} (params = {match[0][3]})")
Base gelee sur cible (sous le hasard) : 0.0365
Full fine-tuning de la tete (etalon)  : 0.5270 (params = 5130)
LoRA r=4 alpha/r=1.0 (FT-00a config)  : 0.4150 (params = 2088)

Lecture du résultat : trois repères

Trois repères pour lire les 18 cellules du tableau :

  • Le sous le hasard donne le plancher (le modèle non adapté).
  • Le full donne le plafond atteignable avec le budget complet (5130 params, sans contrainte de rang).
  • Le LoRA r=4 alpha/r=1.0 est la configuration pédagogique de FT-00a — combien sacrifie-t-on en passant de full à LoRA ? La réponse dépend de la tâche, mais elle est mesurée sur ce notebook.

7. Exemples guidés et exercices

Trois exemples guidés, suivis de trois exercices de portée distincte. Chaque exemple démontre une technique ; chaque exercice demande à l’étudiant de mesurer une grandeur différente de celle de l’exemple correspondant. Les corrigés des exemples sont dans les exemples (ils s’exécutent et impriment leurs sorties réelles), pas dans une section voisine.

Méthode : exécuter les cellules 24-29 pour lire les exemples guidés (les sorties sont réelles), puis tenter les exercices 30-35 (stubs C.1 conformes — pass / print(...) / result = None # TODO).

Exemple guide 1 : rapport params/accuracy pour alpha/r = 1.0

Pour alpha/r = 1.0, calculer le rapport params / accuracy pour chaque r ∈ {1, 2, 4, 8, 16, 32} à partir des 18 mesures de la section 4. Identifier le r qui minimise ce rapport — c’est le rendement marginal du rang sur cette mini-tâche.

Pourquoi c’est utile : un rang r=1 peut sembler efficient (peu de params) mais sous-adapter ; un rang r=32 peut sembler précis mais coûteux en mémoire d’inférence. Le rapport params / accuracy estimise le coût par point d’exactitude — c’est ce qu’un ingénieur doit regarder pour choisir le rang à déployer.

Exemple guide 2 : observer la dégradation alpha/r = 2.0 vs 1.0

Reprendre l’invariant de FT-00a : ‖delta_W‖ = (alpha/r) * ‖B @ A‖. Pour r = 4, mesurer ‖B @ A‖ à la fin de l’entraînement pour les deux scalings alpha/r ∈ {1.0, 2.0}. Comparer à ‖B @ A‖ * scaling (la magnitude effective du delta).

Pourquoi c’est utile : la mesure isole la cause de la dégradation observée en section 4 — c’est l’amplification du signal B@A qui sature, pas le rang lui-même.

Exemple guide 3 : courbe d’accuracy epoch par epoch sur 6 epochs

Pour r = 4 et alpha/r = 2.0, entraîner 6 epochs. À chaque epoch, mesurer l’exactitude test. Identifier le seuil d’amplification — l’epoch à partir duquel l’accuracy commence à osciller ou chuter.

Pourquoi c’est utile : la cellule démontre la trajectoire d’une configuration qui suramplifie — utile pour comprendre quand un early-stopping serait rentable sur cette même configuration.

Exercice 1 : alpha/r = 0.5 — quand le rang ne suffit plus

Refaire l’analyse de l’exemple guide 1 (rapport params / accuracy) pour alpha/r = 0.5 sur les 6 rangs. Comparer au régime alpha/r = 1.0 : le rapport params / accuracy augmente-t-il plus vite sur le scaling bas ? Identifier le rang où le rendement marginal du rang commence à décliner — c’est le signal que le signal est devenu sous-amplifié.

Indice 1 : reprendre la boucle for r in RS de l’exemple guide 1, en remplaçant v[2] == 1.0 par v[2] == 0.5.

Indice 2 : pour comparer, calculer le ratio params/acc(0.5) / params/acc(1.0) pour chaque rang — un ratio constant indiquerait que le scaling est un simple facteur multiplicatif.

Exercice 2 : ‖B@A‖ / r — l’invariant normalisé

L’exemple guide 2 mesure ‖B @ A‖ brut. Pour comparer entre rangs sans confondre dimension et magnitude, calculer la version normalisée ‖B @ A‖ / r pour les trois scalings alpha/r ∈ {0.5, 1.0, 2.0}, à r = 4. Si LoRA est bien calibré, ce rapport doit décroître quand le scaling augmente au-delà de 1 (le signal suramplifié sature, donc la magnitude effective plafonne).

Indice 1 : reprendre la fonction _train_and_measure de l’exemple guide 2 (elle retourne déjà norm_dW).

Indice 2 : la grandeur à comparer est norm_dW / scaling, pas norm_dW / r — c’est la magnitude effective qui doit plafonner, pas la magnitude par degré de liberté.

Exercice 3 : early-stopping — la zone sûre de r=8 / alpha/r = 1.0

L’exemple guide 3 démontre la trajectoire divergente de r=4 / alpha/r = 2.0 sur 6 epochs. Refaire la mesure sur r = 8 / alpha/r = 1.0 (zone sûre d’après les sections 4-5) mais sur 3 epochs seulement — c’est l’horizon naturel pour un early-stopping. Comparer l’accuracy finale à celle de la même config mesurée par l’ablation de la section 4 (r=8, scaling=1.0, 2 epochs) : le 3ᵉ epoch apporte-t-il un gain marginal significatif ?

Indice 1 : reprendre la boucle d’entraînement de l’exemple guide 3, en remplaçant r=4, alpha=8.0 par r=8, alpha=8.0 (alpha/r = 1.0) et range(1, 7) par range(1, 4).

Indice 2 : l’accuracy à 2 epochs figure dans results (filtre v[0] == 8 and v[2] == 1.0). La comparer à l’accuracy finale de cet exercice.

# Exemple guide 1 : ratio params / accuracy sur alpha/r = 1.0
# Grandeur mesuree : cout marginal par point d'exactitude.
print("=== ratio params / accuracy (alpha/r = 1.0) ===")
ratios = []
for r in RS:
    match = [v for v in results if v[0] == r and v[2] == 1.0]
    if match:
        n, acc = match[0][3], match[0][4]
        ratio = n / max(acc, 1e-6)
        ratios.append((r, n, acc, ratio))
        print(f"  r={r:>2}  params={n:>5}  acc={acc:.4f}  params/acc={ratio:>8.1f}")

best = min(ratios, key=lambda x: x[3]) if ratios else None
if best:
    print(f"\nJuste rang (min params/acc) : r={best[0]}, acc={best[2]:.4f}, ratio={best[3]:.1f}")
else:
    print("Aucune donnee pour ce scaling.")
=== ratio params / accuracy (alpha/r = 1.0) ===
  r= 1  params=  522  acc=0.1900  params/acc=  2747.4
  r= 2  params= 1044  acc=0.2360  params/acc=  4423.7
  r= 4  params= 2088  acc=0.4150  params/acc=  5031.3
  r= 8  params= 4176  acc=0.5285  params/acc=  7901.6
  r=16  params= 8352  acc=0.5935  params/acc= 14072.5
  r=32  params=16704  acc=0.6270  params/acc= 26641.1

Juste rang (min params/acc) : r=1, acc=0.1900, ratio=2747.4
# Exemple guide 2 : ||B @ A|| a la fin de l'entrainement pour les deux scalings
# Grandeur mesuree : amplification du produit B @ A par le scaling.
def _train_and_measure(r, scaling, epochs=2):
    alpha = scaling * r
    torch.manual_seed(SEED)
    m = copy.deepcopy(base_model)
    for p in m.parameters():
        p.requires_grad_(False)
    head_base = nn.Linear(512, 10).to(DEV)
    nn.init.kaiming_uniform_(head_base.weight, a=5 ** 0.5)
    if head_base.bias is not None:
        nn.init.zeros_(head_base.bias)
    m.head = LoRALinear(head_base, r=r, alpha=alpha).to(DEV)
    opt = torch.optim.Adam(
        [p for p in m.head.parameters() if p.requires_grad], lr=LR
    )
    for _ in range(epochs):
        m.train()
        for x, y in train_loader:
            x, y = x.to(DEV), y.to(DEV)
            opt.zero_grad()
            F.cross_entropy(m(x), y).backward()
            opt.step()
    delta_W = m.head.scaling * (m.head.B @ m.head.A)
    return delta_W.norm().item(), m.head.A.norm().item(), m.head.B.norm().item()

for scaling in [1.0, 2.0]:
    norm_dW, norm_A, norm_B = _train_and_measure(r=4, scaling=scaling)
    print(
        f"alpha/r={scaling}  ||B@A||={norm_dW:.4f}  ||A||={norm_A:.4f}  ||B||={norm_B:.4f}"
    )
alpha/r=1.0  ||B@A||=1.1719  ||A||=3.9897  ||B||=0.4748
alpha/r=2.0  ||B@A||=1.8847  ||A||=3.6880  ||B||=0.4276
# Exemple guide 3 : seuil d'amplification sur 6 epochs (r=4, alpha/r = 2.0)
# Grandeur mesuree : trajectoire d'accuracy epoch-par-epoch en regime suramplifie.
torch.manual_seed(SEED)
m = copy.deepcopy(base_model)
for p in m.parameters():
    p.requires_grad_(False)
head_base = nn.Linear(512, 10).to(DEV)
nn.init.kaiming_uniform_(head_base.weight, a=5 ** 0.5)
if head_base.bias is not None:
    nn.init.zeros_(head_base.bias)
m.head = LoRALinear(head_base, r=4, alpha=8.0).to(DEV)  # alpha/r = 2.0
opt = torch.optim.Adam(
    [p for p in m.head.parameters() if p.requires_grad], lr=LR
)
print("epoch | acc test")
print("------|----------")
for epoch in range(1, 7):
    m.train()
    for x, y in train_loader:
        x, y = x.to(DEV), y.to(DEV)
        opt.zero_grad()
        F.cross_entropy(m(x), y).backward()
        opt.step()
    acc = evaluate(m, test_loader, transform=None)
    print(f"  {epoch}  | {acc:.4f}")
epoch | acc test
------|----------
  1  | 0.2095
  2  | 0.4710
  3  | 0.5170
  4  | 0.5700
  5  | 0.5945
  6  | 0.5980
# Exercice 1 : ratio params / accuracy sur alpha/r = 0.5
#
# Indice 1 : 'results' est une liste de tuples (r, scaling, alpha, n_params, acc_test)
#             deja construite par les cellules precedentes (section 4).
# Indice 2 : pour comparer a alpha/r = 1.0, utiliser les donnees de l'exemple guide 1.
ratios_low = []
result = None  # TODO etudiant : construire ratios_low
print("=== ratio params / accuracy (alpha/r = 0.5) ===")
print("TODO : completer la construction de ratios_low")
print("TODO : comparer a alpha/r = 1.0 via le ratio ratios_low / ratios")
=== ratio params / accuracy (alpha/r = 0.5) ===
TODO : completer la construction de ratios_low
TODO : comparer a alpha/r = 1.0 via le ratio ratios_low / ratios
# Exercice 2 : ||B @ A|| normalise par r, sur les 3 scalings a r=4
#
# Indice 1 : reprendre _train_and_measure (defini a l'exemple guide 2) pour chaque scaling.
# Indice 2 : la grandeur pertinente est norm_dW / scaling -- c'est l'invariant de magnitude.
result = None  # TODO etudiant : pour scaling in [0.5, 1.0, 2.0], calculer norm_dW / scaling
print("=== ||B@A|| / scaling (r=4) ===")
print("TODO : completer la mesure de l'invariant normalise")
=== ||B@A|| / scaling (r=4) ===
TODO : completer la mesure de l'invariant normalise
# Exercice 3 : trajectoire 3 epochs en r=8 / alpha/r = 1.0 (zone sure)
#
# Indice 1 : reprendre la structure de l'exemple guide 3 en changeant r=4 -> r=8, alpha=8.0,
#             et range(1, 7) -> range(1, 4).
# Indice 2 : comparer l'accuracy finale a celle stockee dans 'results' pour r=8, scaling=1.0.
result = None  # TODO etudiant : entrainer 3 epochs, mesurer accuracy a chaque epoch, comparer a results
print("=== early-stopping r=8 / alpha/r = 1.0 (3 epochs) ===")
print("TODO : completer la trajectoire 3-epochs")
=== early-stopping r=8 / alpha/r = 1.0 (3 epochs) ===
TODO : completer la trajectoire 3-epochs

Résumé

Trois régularités mesurées sur la même mini-tâche, six valeurs de rang, trois scalings :

  • alpha/r = 1.0 est le choix de référence : la magnitude effective du signal est neutre, et le rang commande seul la dimensionnalité.
  • r = 1 est trop petit sur Fashion-MNIST inversé : un seul degré de liberté sous-adapte. Au-delà de r = 4, les gains saturent.
  • alpha/r = 2.0 peut dégrader : trop de signal amplifié, oscillations ou divergence sur les grands modèles.

Le découplage alpha / r est ce qui rend les deux knobs réglables indépendamment : augmenter r ne change pas la magnitude du signal, et changer alpha sans toucher r ne change pas la dimensionnalité. C’est l’ingrédient qui rend LoRA praticable sur des modèles 7B+ — où le rang reste petit (r = 8 typique) mais le scaling devient un hyperparamètre de calibration fin.

Branchement : FT-00a (mécanisme) → FT-00b (réglage) → FT-01 (LoRA avec peft sur GPT-2) → FT-02 (QLoRA 4-bit) → FT-06 (vision-langage).

Retour au sommet