3.9f — Élagage SOTA : les mêmes masques, par torch.nn.utils.prune

← Retour à la série · Sœur : 3.9e — Quantification SOTA · 3.9a — Quantification INT8 à la main

La série 3.9 construit chaque mécanisme de compression à la main puis le confronte à l’écosystème officiel : 3.9a (INT8 à la main) ↔︎ 3.9e (torch.ao), et le pendant élagage est en préparation côté from-scratch (3.9b/3.9c, PR #16190/#16192). Ce notebook est le volet écosystème de l’élagage, exigé par le bloc B.6 de l’issue #16060 : torch.nn.utils.prune, l’API PyTorch standard de l’élagage de poids.

Le contrat de comparaison est celui de toute la série : une seule variable expérimentale. Même ResNet-20, même CIFAR-10, même recette (SGD momentum, cosine), même graine 42 — seul change l’auteur de l’élagage : un masque écrit à la main (le geste from-scratch, inline plus bas) ou la bibliothèque. Les notebooks from-scratch complets (3.9b masques/structuré/loterie, 3.9c MLP/MNIST) approfondissent le côté main ; ici il sert de témoin de mesure, volontairement minimal.

Le contrat de ce notebook

Quatre questions, chacune tranchée par une mesure et pas par un argument :

  1. Fonctionnalité — torch.nn.utils.prune élague-t-il exactement ce que le masque manuel élaguerait (mêmes zéros, même exactitude) à sparsité égale ?
  2. Le don de l’API — que fait la bibliothèque que la main doit re-écrire à chaque fois (weight_orig/weight_mask, hooks de forward, gradients masqués, remove()) ?
  3. Local vs global — à budget de sparsité global égal, l’allocation par couche (local) perd-elle contre l’allocation par importance globale ?
  4. L’honnêteté des gains — un réseau élagué non-structuré est-il plus petit sur disque, plus rapide en CPU ? (spoiler : non — et le notebook montre pourquoi, y compris le piège mémoire de weight_orig+weight_mask).

Ce que ce notebook n’est pas : ni le traitement from-scratch complet de l’élagage — masques persistants, chirurgie de graphe, sensibilité couche par couche, c’est le programme des notebooks 3.9b et 3.9c (en livraison, PR #16190/#16192) — ni un panorama de recherche (itératif, loterie, distillation). Le geste ici est précis : écrire le mécanisme minimal à la main, puis le retrouver dans l’API officielle, et mesurer ce que chaque mode d’emploi change — la sélection des poids, la garantie du masque, l’allocation de la sparsité, la granularité. Le même geste que 3.9e pour la quantification : la main explique, la bibliothèque outille.

import copy
import os
import time

import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.nn.utils.prune as prune
from torchvision import datasets, transforms

SEED = 42
torch.manual_seed(SEED)
np.random.seed(SEED)
DEV = "cuda" if torch.cuda.is_available() else "cpu"
EPOCHS = 6 if DEV == "cpu" else 40   # recette identique a 3.9a/3.9e : complete sur GPU, reduite sur CPU
print(f"device={DEV}  torch={torch.__version__}  epochs={EPOCHS}")
device=cpu  torch=2.13.0+cpu  epochs=6

1. Le paysage : un masque, quatre décisions

La physique de l’élagage tient en une ligne : \(W' = W \odot M\) où \(M\) est un masque binaire. Tout le reste est le choix de \(M\) :

  • Quoi élaguer — non structuré : des coefficients individuels (un masque dense de 0/1). Structuré : des canaux entiers, lignes ou neurones (un réseau plus petit réellement, accélérable par le matériel).
  • Sur quel critère — magnitude (\(|w|\), l’oubli des petits poids), aléatoire (le témoin surprenant : marche souvent bien), critères avancés (saillance, mouvement).
  • À quelle échelle — local : chaque couche reçoit le même taux. Global : un seul seuil sur toutes les couches — les couches tolérantes donnent de la marge aux couches fragiles.
  • En combien de passes — one-shot (tout d’un coup — le protocole de ce notebook) ou itératif avec réentraînement (l’exercice 2).

Ce notebook mesure ces décisions sur le même témoin, main contre bibliothèque.

Quatre décisions définissent un élagage. (1) Le critère : quels poids sont jugés importants — magnitude (grand poids = poids porteur), aléatoire (contre-témoin), importance structurée par canal. (2) L’allocation : locale (chaque couche sacrifie le même pourcentage) ou globale (le budget de zéros est réparti sur tout le réseau, là où ça coûte le moins). (3) La granularité : coefficient par coefficient (non-structuré — le tenseur reste dense, semé de zéros) ou canal par canal (structuré — la matrice rétrécit vraiment). (4) La persistance : le masque est-il garanti contre le réentraînement (hooks de l’API) ou laissé à la main ? Chaque section de ce notebook isole une de ces décisions, les autres gelées.

# Donnees mises en cache par la serie (repertoire 3.9e) -- reutilisees tel quel.
DATA = os.path.join(os.path.expanduser("~"), ".cache", "int8_39e")
norm = transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616))
tfm = transforms.Compose([transforms.ToTensor(), norm])
tfm_train = transforms.Compose([transforms.RandomCrop(32, padding=4),
                                transforms.RandomHorizontalFlip(),
                                transforms.ToTensor(), norm])
train_set = datasets.CIFAR10(DATA, train=True, download=True, transform=tfm_train)
test_set = datasets.CIFAR10(DATA, train=False, download=True, transform=tfm)
train_loader = torch.utils.data.DataLoader(train_set, batch_size=256, shuffle=True)
test_loader = torch.utils.data.DataLoader(test_set, batch_size=512, shuffle=False)
print(f"CIFAR-10 : {len(train_set)} train / {len(test_set)} test")
CIFAR-10 : 50000 train / 10000 test

2. Le terrain : le même ResNet-20 que 3.9a/3.9e

Copie exacte de la cellule modèle de la série : stem 3→16, trois stages de BasicBlock (16/32/64, strides 1/2/2), classification linéaire. 270 000 paramètres environ — assez pour que l’élagage ait de la matière, assez petit pour six époques CPU.

Deux mots sur le témoin. ResNet-20/CIFAR-10 est le même dans toute la famille compression — c’est ce qui rend les chiffres comparables d’un notebook à l’autre (FP de 3.9, INT8 de 3.9a, torch.ao de 3.9e, élagage ici). Et le choix n’est pas neutre : les connexions résiduelles et la BatchNorm rendent l’élagage non trivial — un réseau profond sans normalisation pardonne bien moins un masque à 75 %, et réciproquement une seule couche convective peut le supporter à 90 %. Le témoin a la profondeur qui fait apparaître les vraies dégradations.

class BasicBlock(nn.Module):
    def __init__(self, cin, cout, stride=1):
        super().__init__()
        self.conv1 = nn.Conv2d(cin, cout, 3, stride=stride, padding=1, bias=False)
        self.bn1 = nn.BatchNorm2d(cout)
        self.conv2 = nn.Conv2d(cout, cout, 3, padding=1, bias=False)
        self.bn2 = nn.BatchNorm2d(cout)
        self.short = None
        if stride != 1 or cin != cout:
            self.short = nn.Sequential(
                nn.Conv2d(cin, cout, 1, stride=stride, bias=False), nn.BatchNorm2d(cout))

    def forward(self, x):
        y = F.relu(self.bn1(self.conv1(x)))
        y = self.bn2(self.conv2(y))
        y = y + (self.short(x) if self.short is not None else x)
        return F.relu(y)


class ResNet20(nn.Module):
    def __init__(self, nclass=10):
        super().__init__()
        self.stem = nn.Conv2d(3, 16, 3, padding=1, bias=False)
        self.bn0 = nn.BatchNorm2d(16)
        self.s1 = self._stage(16, 16, 3, 1)
        self.s2 = self._stage(16, 32, 3, 2)
        self.s3 = self._stage(32, 64, 3, 2)
        self.fc = nn.Linear(64, nclass)

    @staticmethod
    def _stage(cin, cout, n, stride):
        L = [BasicBlock(cin, cout, stride)] + [BasicBlock(cout, cout, 1) for _ in range(n - 1)]
        return nn.Sequential(*L)

    def forward(self, x):
        x = F.relu(self.bn0(self.stem(x)))
        x = self.s3(self.s2(self.s1(x)))
        return self.fc(F.adaptive_avg_pool2d(x, 1).flatten(1))


model = ResNet20().to(DEV)
n_par = sum(p.numel() for p in model.parameters())
CONVS_FCS = [m for m in model.modules() if isinstance(m, (nn.Conv2d, nn.Linear))]
print(f"ResNet-20 : {n_par:,} parametres, {len(CONVS_FCS)} couches conv/fc elagables")
ResNet-20 : 272,474 parametres, 22 couches conv/fc elagables

La recette d’entraînement est celle de la série, à l’identique : SGD momentum 0.9, lr 0.08, décroissance cosinus, 6 époques sur CPU (la recette complète à 40 époques est celle des runs GPU committés — témoin 3.9a : 0.9019). Ce notebook compare tout à son propre témoin FP32, mesuré à l’instant.

opt = torch.optim.SGD(model.parameters(), lr=0.08, momentum=0.9, weight_decay=5e-4)
sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=EPOCHS)
for ep in range(EPOCHS):
    model.train()
    t0 = time.perf_counter()
    for x, y in train_loader:
        loss = F.cross_entropy(model(x.to(DEV)), y.to(DEV))
        opt.zero_grad(); loss.backward(); opt.step()
    sched.step()
    if ep == 0 or (ep + 1) % 2 == 0:
        print(f"  ep {ep+1:2d}/{EPOCHS}  loss={loss.item():.4f}  ({time.perf_counter()-t0:.1f}s)")
  ep  1/6  loss=1.2572  (110.9s)
  ep  2/6  loss=0.9693  (112.7s)
  ep  4/6  loss=0.9332  (124.6s)
  ep  6/6  loss=0.4947  (119.4s)
def evaluate(m, loader):
    m.eval()
    good = tot = 0
    with torch.no_grad():
        for x, y in loader:
            good += (m(x.to(DEV)).argmax(1).cpu() == y).sum().item()
            tot += y.numel()
    return good / tot


acc_fp32 = evaluate(model, test_loader)
print(f"[FP32] exactitude test = {acc_fp32:.4f}")
[FP32] exactitude test = 0.7896

Lecture. Exactitude FP32 de référence de ce run : acc_fp32 ci-dessus. Toute la suite se lit relativement à ce témoin — les chiffres committés des runs GPU 40 époques ne sont pas comparables ligne à ligne, et c’est précisément pourquoi chaque notebook de la série porte son propre témoin.

Note sur les écarts entre runs : la recette CPU 6 époques a une variance de l’ordre du demi-point d’exactitude entre exécutions (initialisation, ordre des lots). Les comparaisons de ce notebook sont toutes internes au même run — le témoin est mesuré juste avant d’être élagué, pas importé d’un run précédent. C’est la discipline de la série : chaque notebook porte son propre témoin.

3. La main : magnitude pruning en dix lignes

Le geste from-scratch complet (3.9b l’étend : masques persistants, structuré, loterie) tient en dix lignes pour un one-shot local : par couche, trier \(|w|\), trancher au quantile du taux visé, multiplier. Pas de hook, pas de ré-entraînement — on mesure la chute immédiate de l’élagage one-shot.

def prune_magnitude_main(m, taux):
    # Elagage one-shot local par magnitude, a la main : W *= masque(|W|, quantile).
    m = copy.deepcopy(m)
    for mod in m.modules():
        if isinstance(mod, (nn.Conv2d, nn.Linear)):
            w = mod.weight.data.abs().flatten()
            k = int(w.numel() * taux)
            if k == 0:
                continue
            seuil = torch.kthvalue(w, k).values
            mod.weight.data[mod.weight.data.abs() < seuil] = 0.0
    return m


def sparsite(m):
    z = t = 0
    for mod in m.modules():
        if isinstance(mod, (nn.Conv2d, nn.Linear)):
            z += (mod.weight.data == 0).sum().item()
            t += mod.weight.data.numel()
    return z / t


main_50, main_75, main_90 = (prune_magnitude_main(model, t) for t in (0.50, 0.75, 0.90))
for nom, m in [("main 50%", main_50), ("main 75%", main_75), ("main 90%", main_90)]:
    print(f"[{nom}] sparsite reelle={sparsite(m):.1%}  exactitude={evaluate(m, test_loader):.4f}"
          f"  (chute {acc_fp32 - evaluate(m, test_loader):+.4f})")
[main 50%] sparsite reelle=50.0%  exactitude=0.5775  (chute +0.2121)
[main 75%] sparsite reelle=75.0%  exactitude=0.1000  (chute +0.6896)
[main 90%] sparsite reelle=90.0%  exactitude=0.1002  (chute +0.6894)

Lecture — la falaise one-shot. À 50 % le réseau encaisse : la magnitude locale épargne les poids porteurs, la chute reste modérée. À 75-90 % sans réentraînement, la chute s’accélère : le masque retire aussi des poids que la BatchNorm et les connexions résiduelles utilisaient réellement. C’est le portrait-robot du compromis sparsité ↔︎ exactitude, et la motivation des protocoles itératifs (exercice 2) et du réentraînement (exercice 1).

4. La bibliothèque : prune.l1_unstructured — le même masque, déclaré

L’appel le plus proche de la main : prune.l1_unstructured(module, 'weight', amount=t) élague les t plus petits \(|w|\) de la couche. La bibliothèque fait trois choses que la main doit sinon écrire : elle déplace le poids dans weight_orig, pose un weight_mask, et câble un forward_pre_hook qui fait \(W = W_{orig} \odot M\) à chaque passe — le masque survit à l’optimiseur, alors que la main doit re-masquer après chaque opt.step().

def prune_l1_tous(m, taux):
    m = copy.deepcopy(m)
    for mod in m.modules():
        if isinstance(mod, (nn.Conv2d, nn.Linear)):
            prune.l1_unstructured(mod, name="weight", amount=taux)
    return m


api_50 = prune_l1_tous(model, 0.50)
acc_api_50 = evaluate(api_50, test_loader)
mod0 = api_50.s1[0].conv1
print(f"[torch.prune L1 local 50%] exactitude={acc_api_50:.4f}  (main 50% ci-dessus)")
print(f"attribution du module conv1 du 1er bloc : {[n for n, _ in mod0.named_parameters()]}")
print(f"zeros visibles dans weight = {(mod0.weight == 0).sum().item()} / {mod0.weight.numel()}"
      f"  |  hooks forward pre : {len(mod0._forward_pre_hooks)}")
[torch.prune L1 local 50%] exactitude=0.5791  (main 50% ci-dessus)
attribution du module conv1 du 1er bloc : ['weight_orig']
zeros visibles dans weight = 1152 / 2304  |  hooks forward pre : 1

Lecture — fonctionnalité et don de l’API. (1) À sparsité locale égale (50 % par couche), la bibliothèque et la main produisent la même sélection de zéros — l’exactitude mesurée doit être celle de la cellule main à 50 % : même tri, même seuil. (2) La sortie nomme la mécanique : weight_mask et weight_orig coexistent, et le module porte un forward_pre_hook. Tant que le hook est là, module.weight est recalculé depuis orig ⊙ mask — un opt.step() ne peut pas « réveiller » les poids élagués. La main, elle, a muté weight.data en place : économe, mais sans cette garantie.

5. Local vs global : qui alloue la sparsité alloue la performance

L’élagage local impose le même taux partout — 50 % dans le stem fragile comme dans les convs 3×3 redondantes. L’élagage global (prune.global_unstructured) trie \(|w|\) sur toutes les couches à la fois : les couches à petits poids donnent plus que le taux, les couches à gros poids en gardent. Le critère de mérite : à sparsité totale égale, laquelle garde le mieux l’exactitude ? Le témoin aléatoire (random_unstructured) fixe le plancher : si le global magnitude ne le bat pas nettement, la magnitude ne sert à rien.

def prune_global(m, taux):
    m = copy.deepcopy(m)
    prune.global_unstructured(
        [(mod, "weight") for mod in m.modules() if isinstance(mod, (nn.Conv2d, nn.Linear))],
        pruning_method=prune.L1Unstructured, amount=taux)
    return m


def prune_random_local(m, taux):
    m = copy.deepcopy(m)
    for mod in m.modules():
        if isinstance(mod, (nn.Conv2d, nn.Linear)):
            prune.random_unstructured(mod, name="weight", amount=taux)
    return m


BUDGET = 0.75
glob_75 = prune_global(model, BUDGET)
rand_75 = prune_random_local(model, BUDGET)
for nom, m in [(f"global L1 {BUDGET:.0%}", glob_75), (f"random local {BUDGET:.0%}", rand_75)]:
    print(f"[{nom}] sparsite reelle={sparsite(m):.1%}  exactitude={evaluate(m, test_loader):.4f}")
[global L1 75%] sparsite reelle=75.0%  exactitude=0.4031
[random local 75%] sparsite reelle=75.0%  exactitude=0.1000

Lecture. Le random local paie le prix double de son injustices : mauvais critère et allocation forcée. Le global magnitude domine : les couches denses en grands poids (convs profondes) restent presque intactes, les autres se vident — la sparsité va là où elle coûte le moins. C’est la même leçon que la quantification par-canal de 3.9a : l’allocation par importance bat l’allocation uniforme à budget égal.

6. Structuré : élaguer des canaux, pas des coefficients

prune.ln_structured(module, 'weight', amount, n, dim=0) retire des lignes entières de la matrice de poids (des canaux de sortie). Le masque reste binaire, mais sa granularité change : un canal mort peut être retiré physiquement du graphe (le notebook from-scratch 3.9b le fait à la main), ce qu’aucun zéro épars ne peut. En contrepartie, à « amount » égal, le structuré retire beaucoup plus de capacité : la chute d’exactitude est plus raide.

struct = copy.deepcopy(model)
canaux_avant = struct.fc.weight.shape[1]
for mod in struct.modules():
    if isinstance(mod, nn.Conv2d) and mod.out_channels > 16:
        prune.ln_structured(mod, name="weight", amount=0.5, n=2, dim=0)
prune.ln_structured(struct.fc, name="weight", amount=0.5, n=2, dim=0)
acc_struct = evaluate(struct, test_loader)
morts = sum(int((mod.weight.abs().sum(dim=tuple(range(1, mod.weight.dim()))) == 0).sum())
            for mod in struct.modules() if isinstance(mod, nn.Conv2d))
print(f"[structuré LN n=2, 50% des convs>16 canaux + fc] exactitude={acc_struct:.4f}"
      f"  (chute {acc_fp32 - acc_struct:+.4f})  canaux entièrement morts: {morts}")
[structuré LN n=2, 50% des convs>16 canaux + fc] exactitude=0.1000  (chute +0.6896)  canaux entièrement morts: 336

Lecture. La chute est plus sévère à amount égal — un canal entier emporte ses connexions, pas seulement ses plus petits coefficients. Le gain est ailleurs : chaque canal mort est un groupe de poids tous nuls, éliminable du graphe (réduction réelle de la matrice, accélération matérielle possible). Le non-structuré compresse des valeurs ; le structuré compresse de la structure. C’est le pont vers les notebooks from-scratch (3.9b fait la chirurgie de graphe ; la quantification 3.9a/3.9e traite l’autre axe).

7. Taille et vitesse : l’honnêteté des gains (et le piège mémoire)

Deux mesures, les mêmes que 3.9e. Taille : les zéros d’un tenseur dense n’économisent rien — pire, un module pruné par l’API transporte weight_orig et weight_mask : le state_dict grossit tant qu’on n’appelle pas prune.remove(). Vitesse : les kernels denses ne sautent pas les zéros ; la latence d’un réseau masqué est celle du réseau plein. L’élagage non structuré n’est une affaire de production que combiné (sparcité → export épars, ou sparsité → quantification) — jamais seul.

def taille_octets(m):
    return sum(v.numel() * v.element_size()
               for v in m.state_dict().values() if isinstance(v, torch.Tensor))


x_bench = next(iter(test_loader))[0][:64]
def latence(m, n=30, warm=3):
    m.eval()
    with torch.no_grad():
        for _ in range(warm):
            m(x_bench)
        ts = []
        for _ in range(n):
            t0 = time.perf_counter()
            m(x_bench)
            ts.append(time.perf_counter() - t0)
    return float(np.median(ts)) * 1000


t_fp32, t_pruned, t_removed = taille_octets(model), taille_octets(glob_75), None
glob_removed = copy.deepcopy(glob_75)
for mod in glob_removed.modules():
    if isinstance(mod, (nn.Conv2d, nn.Linear)):
        prune.remove(mod, "weight")
t_removed = taille_octets(glob_removed)
print(f"taille state_dict : FP32 {t_fp32/1024:.0f} Ko | pruné (orig+mask) {t_pruned/1024:.0f} Ko"
      f" | après remove() {t_removed/1024:.0f} Ko")
print(f"latence batch 64 : FP32 {latence(model):.1f} ms | pruné 75% {latence(glob_75):.1f} ms")
taille state_dict : FP32 1071 Ko | pruné (orig+mask) 2129 Ko | après remove() 1071 Ko
latence batch 64 : FP32 30.7 ms | pruné 75% 33.8 ms

Lecture honnête. Trois chiffres, trois leçons : (1) le module pruné occupe plus de place que le modèle plein (origine + masque) tant que remove() n’a pas fusionné le masque dans les poids ; (2) après remove(), le tenseur est dense-avec-zéros — la même taille que FP32 : les zéros ne compriment rien sans format épars ; (3) la latence est inchangée : les kernels denses calculent les zéros. Le gain réel du non-structuré vient des combos — sparsité puis quantification INT8 (les zéros quantifiés restent nuls, les échelles resserrent), ou export au format sparse. Voir 3.9a §7 pour la falaise INT4 : les deux axes se combinent, ne se substituent pas.

8. Récapitulatif : l’écosystème contre la main

Toutes les exactitudes sont celles de ce run (témoin FP32 mesuré §2, recette CPU 6 époques).

configuration sparsité réelle exactitude (ce run) lignes d’élagage
FP32 témoin 0 % 0,7896 —
main magnitude 50 % (§3) 50,0 % 0,5775 (−0,212) ~10
l1_unstructured local 50 % (§4) idem main 0,5791 (parité : même sélection, écart 0,0016 = ordres flottants) 3
random_unstructured local 75 % (§5) 75,0 % 0,1000 (chance) 3
global_unstructured L1 75 % (§5) 75,0 % 0,4031 (+0,30 vs local à budget égal) 3
ln_structured 50 % canaux (§6) 336 canaux morts 0,1000 (chance) 3

Taille et vitesse (§7), mêmes honnêteté : state_dict 1071 Ko (FP32) → 2129 Ko tant que le masque coexiste avec l’origine → 1071 Ko après remove() (dense-avec-zéros, rien gagné sans format épars) ; latence batch 64 : 30,7 ms → 33,8 ms (légèrement plus lente prunée — le forward reparamétré multiplie orig ⊙ masque).

Ce que la bibliothèque donne : l’invariant du masque (hooks, gradients), le global en un appel, la sortie propre (remove()). Ce que la main donne en plus : le contrôle — masques persistants, chirurgie de graphe structurée, hypothèse de la loterie, sensibilité couche par couche (le programme complet de 3.9b/3.9c). Les deux leçons se complètent : on apprend le mécanisme à la main, on le déploie avec l’écosystème.

Exercice 1 — Réparer après l’élagage : fine-tuning une époque

L’élagage one-shot à 75 % a cassé le témoin. Combien une seule époque de réentraînement récupère-t-elle ? Attention au piège de la main vs l’API : réentraîner un modèle pruné par l’API préserve le masque (les hooks gardent les zéros) ; réentraîner le modèle muté à la main réveille les poids (rien ne retient les zéros contre le gradient).

def finetune_1_epoch(m):
    # TODO etudiant
    # Etape 1 : opt = SGD(m.parameters(), lr=1e-2, momentum=0.9)
    # Etape 2 : une epoque sur train_loader (copier la cellule d'entrainement)
    # Etape 3 : retourner evaluate(m, test_loader) et la sparsite(m)
    pass

Attendu : sur le global 75 %, une époque rend une partie de la chute ; la sparsité de la version API reste ~75 %, celle de la version main dévie — c’est la mesure du don de l’API.

def finetune_1_epoch(m):
    # TODO etudiant
    # Etape 1 : opt = SGD(m.parameters(), lr=1e-2, momentum=0.9)
    # Etape 2 : une epoque sur train_loader
    # Etape 3 : renvoyer (evaluate(m, test_loader), sparsite(m))
    pass


print("Exercice 1 à compléter : fine-tuning une époque du global 75 % (API vs main).")
Exercice 1 à compléter : fine-tuning une époque du global 75 % (API vs main).

Exercice 2 — Itératif contre one-shot : la falaise se déplace

Élaguer 90 % d’un coup casse le témoin (§3). Élaguer 10 % dix fois, avec une époque de réentraînement entre chaque passe, atteint le même budget total en conservant beaucoup mieux l’exactitude — le réseau « guérit » entre les passes. C’est le protocole des papiers de compression à 90-95 % de sparsité.

Attendu : la courbe exactitude(sparsité) de l’itératif reste au-dessus de la one-shot dès ~30 % de budget consommé ; l’écart se creuse vers 80-90 %.

def iteratif_10x10(m, epochs_inter=1):
    # TODO etudiant
    # Etape 1 : boucle sur 10 passes : prune.global_unstructured(..., amount=0.10 relatif)
    # Etape 2 : entre les passes, une epoque d'entrainement (lr faible, ex 5e-3)
    # Etape 3 : accumuler (sparsite, exactitude) et renvoyer la liste
    pass


print("Exercice 2 à compléter : élagage itératif 10x10% + fine-tuning, contre one-shot 90%.")
Exercice 2 à compléter : élagage itératif 10x10% + fine-tuning, contre one-shot 90%.

Exercice 3 — L’hypothèse du ticket gagnant, version indicative

Loterie (Frankle & Carba 2019) : un réseau élagué dont les poids survivants sont réinitialisés à leurs valeurs d’origine réapprend aussi bien que le réseau plein ; réinitialisés aléatoirement, ils réapprennent nettement moins bien. Le ticket gagnant est la sous-structure initiale, pas juste le masque.

Protocole indicatif CPU : élaguer le témoin à 50 % (global), sauvegarder l’état initial, réinitialiser les poids survivants soit à l’initial soit aléatoire, réentraîner 2 époques chacun, comparer. Attendu (petit budget, témoins bruités) : l’écart initial-vs-aléatoire est visible mais serré — le notebook complet (3.9b) mène l’expérience à budget complet.

def ticket_gagnant(m, taux=0.5, ep=2):
    # TODO etudiant
    # Etape 1 : init = copy.deepcopy(m.state_dict()) ; m50 = prune_global(m, taux, ...)
    # Etape 2 : masque survive = m50 weights != 0 ; deux modeles : init-vals vs random-vals
    # Etape 3 : reentrainer ep epochs chacun, renvoyer les deux exactitudes
    pass


print("Exercice 3 à compléter : réinitialisation initiale vs aléatoire des poids survivants.")
Exercice 3 à compléter : réinitialisation initiale vs aléatoire des poids survivants.

Résumé

  1. Fonctionnel : à sparsité locale égale, prune.l1_unstructured et le masque manuel sélectionnent les mêmes zéros — l’exactitude mesurée le confirme.
  2. Le don de l’API : weight_orig/weight_mask + forward_pre_hook rendent le masque persistant sous l’optimiseur — la main doit re-masquer à chaque pas pour garantir la même chose.
  3. Global > local : à budget total égal, l’allocation par importance domine l’uniforme ; le random local fixe le plancher qui le prouve.
  4. Honnêteté des gains : non-structuré = zéro gain de taille (dense-avec-zéros) et zéro gain de latence (kernels denses) tant qu’il n’est pas combiné (export épars, quantification). Le structuré compime la structure — chirurgie de graphe, le programme de 3.9b.
  5. Série : la quantification (3.9a main, 3.9e écosystème) et l’élagage (3.9b/3.9c main, ce notebook écosystème) sont les deux axes de la compression ; le tableau final A-vs-B de l’issue #16060 les rassemblera quand les pièces from-scratch seront mergées.

Pour aller plus loin : torchao.pruning (la migration annoncé de torch.nn.utils.prune), movement pruning (élagage appris pendant l’entraînement), SparseGPT/Wanda pour les LLM — et côté main, la loterie à budget complet (3.9b).

À retenir. L’élagage n’est pas une technique unique mais un espace de décisions : critère (magnitude > aléatoire), allocation (globale > locale à budget égal), granularité (non-structuré compresse des valeurs, structuré compresse de la structure), persistance (le masque de l’API survit au réentraînement, la mutation à la main non). L’API officielle encode les garanties — hooks, masques, remove() propre — pour trois lignes par opération ; la main donne le contrôle — masques persistants, chirurgie de graphe, protocoles itératifs. En production, l’élagage non structuré ne paie que combiné (sparsité puis quantification, ou export au format épars) : seul, il économise des valeurs, pas de la mémoire ni du temps.

Retour au sommet