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 :
Fonctionnalité — torch.nn.utils.prune élague-t-il exactement ce que le masque manuel élaguerait (mêmes zéros, même exactitude) à sparsité égale ?
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()) ?
Local vs global — à budget de sparsité global égal, l’allocation par couche (local) perd-elle contre l’allocation par importance globale ?
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 copyimport osimport timeimport numpy as npimport torchimport torch.nn as nnimport torch.nn.functional as Fimport torch.nn.utils.prune as prunefrom torchvision import datasets, transformsSEED =42torch.manual_seed(SEED)np.random.seed(SEED)DEV ="cuda"if torch.cuda.is_available() else"cpu"EPOCHS =6if DEV =="cpu"else40# recette identique a 3.9a/3.9e : complete sur GPU, reduite sur CPUprint(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.
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 =Noneif stride !=1or 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) ifself.short isnotNoneelse 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)@staticmethoddef _stage(cin, cout, n, stride): L = [BasicBlock(cin, cout, stride)] + [BasicBlock(cout, cout, 1) for _ inrange(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)))returnself.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() ifisinstance(m, (nn.Conv2d, nn.Linear))]print(f"ResNet-20 : {n_par:,} parametres, {len(CONVS_FCS)} 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 inrange(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 ==0or (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 =0with torch.no_grad():for x, y in loader: good += (m(x.to(DEV)).argmax(1).cpu() == y).sum().item() tot += y.numel()return good / totacc_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():ifisinstance(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.0return mdef sparsite(m): z = t =0for mod in m.modules():ifisinstance(mod, (nn.Conv2d, nn.Linear)): z += (mod.weight.data ==0).sum().item() t += mod.weight.data.numel()return z / tmain_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})")
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():ifisinstance(mod, (nn.Conv2d, nn.Linear)): prune.l1_unstructured(mod, name="weight", amount=taux)return mapi_50 = prune_l1_tous(model, 0.50)acc_api_50 = evaluate(api_50, test_loader)mod0 = api_50.s1[0].conv1print(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() ifisinstance(mod, (nn.Conv2d, nn.Linear))], pruning_method=prune.L1Unstructured, amount=taux)return mdef prune_random_local(m, taux): m = copy.deepcopy(m)for mod in m.modules():ifisinstance(mod, (nn.Conv2d, nn.Linear)): prune.random_unstructured(mod, name="weight", amount=taux)return mBUDGET =0.75glob_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}")
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():ifisinstance(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() ifisinstance(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}")
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_origetweight_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.
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))passprint("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 listepassprint("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 exactitudespassprint("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é
Fonctionnel : à sparsité locale égale, prune.l1_unstructured et le masque manuel sélectionnent les mêmes zéros — l’exactitude mesurée le confirme.
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.
Global > local : à budget total égal, l’allocation par importance domine l’uniforme ; le random local fixe le plancher qui le prouve.
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.
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.