Idée centrale. La triade fondatrice de la série ICT articule trois facettes d’une même quantité : l’intégration (Φ), la surprise / énergie libre (F) et la compression (K). Le module ict.beauty (PR #7257) attache la jambe K au compression-progress de Schmidhuber : un système « découvre » (beauty event) quand la compressibilité locale chute brutalement — la beauté est la dérivée de la compressibilité.
Ce notebook confronte cette lentille à l’ancre empirique la plus nette du « passage à la compressibilité » : le grokking (Power et al. 2022), cette généralisation soudaine d’un réseau après une longue phase de mémorisation.
Résultat (honnête, falsifiable). Deux constats, l’un robuste, l’autre nuancé. (1) Le compression-progress de Schmidhuber tient : la norme des poids décroît continûment sous weight-decay et le grok émerge de la compression cumulée. (2) Le « pli de Thom » (catastrophe localisée au grok point) est proxy-dépendant : la compression structurelle (‖w‖²) et la transition behaviorale (accuracy de test) plient au grok, mais la sensibilité Fisher ne le fait pas. Il n’y a pas de catastrophe unique partagée par toutes les facets de K — le grokking révèle que K est multidimensionnel, et que l’axe unificateur est la dérivée temporelle (capstone #7259), pas un instantané K statique. Le cadre n’est ni survendu ni enjolivé.
Statut épistémique — Établi : 2/5 proxys dissocient du progrès de généralisation pendant le grokking. Non établi (§5) : le sonar thermodynamique σ(t)/B_work(t) (jonction ICT-18, pré-enregistré #17975) aboutit à un double INCONCLUSIF — le témoin wd=0 grokke lentement (contrôle C2 invalide) et σ ne passe pas son null mélangé à cette échelle de fenêtrage. Portée et détail dans la matrice des dissociations.
§0. Contexte : K comme compression-progress
Rappel de la triade. ICT-Synthese-CrossSubstrat a falsifié l’hypothèse d’un scalaire universel Φ/F/K : Φ et F covarient (Kendall τ ≈ +1.00), mais K diverge (τ ≈ +0.33). La synthèse isole alors un axe temporel orthogonal (Gate 5 : irréversibilité) — suggérant que la jambe K n’est pas un niveau statique mais une dynamique.
Schmidhuber formalise cette dynamique : la beauté/curiosité d’un système = le taux auquel il découvre de la compressibilité (« compression-progress »). Le module ict.beauty opérationalise cela via (i) beauty_events — détecteur de sauts locaux de compressibilité, calibré par un contrôle iid apparié, et (ii) fold_in_compressibility_curve — un overlay de Thom qui cherche un pli (transition nette haut→bas) dans une courbe K continue.
Pourquoi le grokking est l’ancre idéale. Un modèle figé en inférence est un compresseur quasi statique : pas de catastrophe. Le « pli de compressibilité » vit dans l’entraînement. Or le grokking EST cet événement : passage d’une mémorisation (grosse table de lookup, code dominé par les données) à un programme court généralisable (code qui chute brutalement). C’est le banc d’essai le plus propre pour demander : la lentille ICT voit-elle le grok ?
Prédiction falsifiable. Si la lentille ICT capture le grok, un proxy K doit montrer une catastrophe (fold) localisée au grok point. Sinon, la thèse « beauty = catastrophe de compressibilité » est affaiblie en faveur de « beauty = taux de compression-progress ».
import os, sys, math, time, zlibimport numpy as npimport torchimport torch.nn as nnimport torch.nn.functional as Fimport matplotlib.pyplot as plt# Package ict (module beauty + mdl, PR #7257). Le notebook s'execute depuis son# propre dossier (ICT-Series), donc le package est sur le chemin.sys.path.insert(0, os.getcwd())from ict import beauty as BTY # noqa: E402from ict import mdl as MDL # noqa: E402torch.manual_seed(0)np.random.seed(0)RNG = np.random.default_rng(0)print(f"torch {torch.__version__} | numpy {np.__version__}")
torch 2.8.0+cpu | numpy 2.2.6
§1. Démo canonique : multiplication modulaire a*b mod p
On entraîne un petit transformeur 1-couche (l’architecture canonique du grokking) à prédire le produit modulaire a*b mod p pour p = 59, en ne lui montrant que 30 % des couples. Le reste de la grille sert de test. Le phénomène attendu : mémorisation rapide du sous-ensemble d’entraînement (train → 100 %, test ~30 % = hasard sur le complément), puis — après une longue phase plate — généralisation soudaine à toute la grille (test → ~100 %). C’est le grok.
À chaque checkpoint on journalise, en plus des pertes/accuracies, trois proxys de la jambe K : la norme des poids ‖w‖² (compacité du modèle), la trace de Fisher empirique (sensibilité locale de la vraisemblance) et la longueur zlib de la prediction-string (compressibilité de la sortie).
Recette : cross-check avec la littérature
Avant de lire les résultats, ancrons chaque hyperparamètre. La recette ci-dessous n’est pas arbitraire : c’est la recette canonique du grokking, vérifiée contre le papier fondateur (Power et al. 2022) et quatre implémentations de référence indépendantes.
Source
Modèle
Opération
weight-decay
lr
Power et al. 2022 (papier)
transformeur 2 couches, 4 têtes, d128
x·y mod 97
1
1e-3
stockeh/mlx-grokking (MLX)
transf. 2 c., 1 tête, d128
÷ mod 97
1
1e-3
atveit/torch_grokking (PyTorch)
port du précédent
÷ mod 97
1
1e-3
atveit/jax_grokking (JAX)
port du précédent
÷ mod 97
1
1e-3
scienceetonnante (Louapre)
MLP
+ mod p
1
3e-4
Ce notebook
transf. 1 couche, 4 têtes, d128
· mod 59
1
1e-3
Trois précisions honnêtes sur nos écarts au canonique :
weight_decay = 1.0 est le levier, et il est universel. Power et al. (§3.3) montrent que le weight-decay est « particulièrement efficace » — il divise par plus de deux les données nécessaires à la généralisation ; les quatre implémentations le fixent à 1. Notre choix (issu d’un balayage empirique) coïncide donc exactement avec la littérature : ce n’est pas un réglage ad hoc, c’est le réglage canonique.
Profondeur réduite (1 couche vs 2). C’est notre seul écart architectural, et il va dans le sens de la simplicité. Nos 4 têtes correspondent au papier (les trois ports MLX/torch/jax n’en utilisent qu’une) ; nous ne réduisons que la profondeur à 1 couche, pour un grok rapide sur CPU (~4 min). Le grok reste net — la profondeur 2 est la référence, 1 suffit ici.
a·b mod 59 équivaut à a+b mod 58 pour le réseau. Power et al. (§3.2) : tout résidu non nul modulo un premier étant une puissance d’une racine primitive, la multiplication mod p et l’addition mod p−1 sont indistinguables pour le modèle. Choisir · mod 59 (grille 59×59, plus petite que la 97×97 canonique) est donc équivalent au canonique, juste plus rapide.
lr = 1e-3 est également canonique ; le grokking n’est robuste que dans une fenêtre étroite de lr (~1 ordre de grandeur, Power et al. §3.3) — c’est précisément ce que l’exercice 1 fait explorer. Bibliographie complète en §8.
# --- Dataset : a*b mod p (Power et al. 2022) ---p =59_pairs = torch.cartesian_prod(torch.arange(p), torch.arange(p)) # 3481 couples_labels = (_pairs[:, 0] * _pairs[:, 1]) % p_n = _pairs.shape[0]_perm = RNG.permutation(_n)_n_train =int(0.30* _n) # 30 % d'apprentissage_tr = torch.as_tensor(_perm[:_n_train], dtype=torch.long)tr_a, tr_b, tr_y = _pairs[_tr, 0], _pairs[_tr, 1], _labels[_tr]te_a, te_b, te_y = _pairs[:, 0], _pairs[:, 1], _labels # test = grille completeprint(f"p={p} | train={_n_train} couples | test={_n} couples (grille complete)")class GrokTransformer(nn.Module):"""Transformeur 1 couche (architecture canonique du grokking). Entree : 3 tokens [a, b, STOP] ; on lit la representation en position STOP (le jeton STOP appris signe la demande de reponse). """def__init__(self, vocab, d_model=128, n_heads=4, d_ff=512):super().__init__()self.tok = nn.Embedding(vocab +1, d_model) # +1 pour le token STOPself.pos = nn.Embedding(3, d_model) layer = nn.TransformerEncoderLayer( d_model=d_model, nhead=n_heads, dim_feedforward=d_ff, batch_first=True, activation="gelu", norm_first=True)# norm_first=True : le fast-path nested-tensor est inutilisable, on le desactive explicitementself.tr = nn.TransformerEncoder(layer, num_layers=1, enable_nested_tensor=False)self.ln = nn.LayerNorm(d_model)self.head = nn.Linear(d_model, vocab)self.stop_id = vocabself.register_buffer("pos_ids", torch.arange(3))def forward(self, a, b): stop = torch.full_like(a, self.stop_id) x = torch.stack([a, b, stop], dim=1) # (B, 3) h =self.tok(x) +self.pos(self.pos_ids) h =self.tr(h)returnself.head(self.ln(h[:, -1])) # lit la position STOPmodel = GrokTransformer(p)opt = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1.0)def weight_norm_sq(m):"""Somme des carres de tous les parametres (proxy de la codelength du modele)."""returnfloat(sum((pp.detach() **2).sum().item() for pp in m.parameters()))@torch.no_grad()def accuracy(a, b, y, bs=2048): m = model.eval(); tot =0for i inrange(0, a.shape[0], bs): tot += (model(a[i:i + bs], b[i:i + bs]).argmax(-1) == y[i:i + bs]).sum().item() model.train()return tot / a.shape[0]# Batch fixe pour la trace de Fisher empirique (gradient de la log-vraisemblance).fish_a, fish_b, fish_y = tr_a[:128], tr_b[:128], tr_y[:128]def fisher_trace():"""Trace de Fisher empirique = moyenne sur un batch de ||grad log p(y|x)||^2.""" model.zero_grad(set_to_none=True) logits = model(fish_a, fish_b) logp = torch.log_softmax(logits, dim=-1) ll = logp[torch.arange(fish_y.shape[0]), fish_y].sum() ll.backward() tr =0.0for pp in model.parameters():if pp.grad isnotNone: tr += (pp.grad.detach() **2).sum().item() model.zero_grad(set_to_none=True)return tr / fish_y.shape[0]# Set d'eval fixe pour la compressibilite (zlib) de la prediction-string.ev_a, ev_b = te_a[:300], te_b[:300]@torch.no_grad()def pred_zlib_len():"""Longueur zlib de la prediction-string : proxy de la compressibilite de la sortie.""" model.eval() preds = model(ev_a, ev_b).argmax(-1).tolist() s =",".join(str(x) for x in preds) model.train()returnlen(zlib.compress(s.encode()))# --- Boucle d'entrainement + journalisation ---# TOTAL_STEPS = grok (~1600 avec wd=1.0) + marge pour montrer l'aplatissement# post-grok (la compression-progress se poursuit apres le grok).TOTAL_STEPS =4000LOG_EVERY =100BATCH =512rows = []t0 = time.time()for step inrange(1, TOTAL_STEPS +1): idx = torch.randint(0, _n_train, (BATCH,)) loss = F.cross_entropy(model(tr_a[idx], tr_b[idx]), tr_y[idx]) opt.zero_grad(); loss.backward(); opt.step()if step % LOG_EVERY ==0or step ==1: rows.append(dict( step=step, loss=float(loss.item()), tr_acc=accuracy(tr_a, tr_b, tr_y), te_acc=accuracy(te_a, te_b, te_y), wn=weight_norm_sq(model), ftr=fisher_trace(), pzlen=pred_zlib_len(), )) last = rows[-1]print(f"step {step:5d} loss {last['loss']:.4f} tr {last['tr_acc']:.3f} "f"te {last['te_acc']:.3f} ||w||^2 {last['wn']:.0f} Ftr {last['ftr']:.2f}")print(f"\nEntraîné en {time.time() - t0:.0f}s sur CPU.")
p=59 | train=1044 couples | test=3481 couples (grille complete)
step 1 loss 4.2535 tr 0.030 te 0.030 ||w||^2 8955 Ftr 191.41
step 100 loss 3.0701 tr 0.214 te 0.089 ||w||^2 7511 Ftr 298.86
step 200 loss 2.1650 tr 0.551 te 0.191 ||w||^2 6396 Ftr 546.19
step 300 loss 1.3608 tr 0.826 te 0.280 ||w||^2 5497 Ftr 651.07
step 400 loss 0.9304 tr 0.931 te 0.325 ||w||^2 4705 Ftr 804.33
step 500 loss 0.5827 tr 0.977 te 0.359 ||w||^2 4015 Ftr 534.98
step 600 loss 0.5096 tr 0.995 te 0.387 ||w||^2 3424 Ftr 613.05
step 700 loss 0.3595 tr 0.995 te 0.408 ||w||^2 2925 Ftr 497.63
step 800 loss 0.3245 tr 0.999 te 0.434 ||w||^2 2502 Ftr 475.97
step 900 loss 0.2379 tr 1.000 te 0.456 ||w||^2 2149 Ftr 463.56
step 1000 loss 0.2368 tr 1.000 te 0.485 ||w||^2 1853 Ftr 677.26
step 1100 loss 0.1795 tr 1.000 te 0.506 ||w||^2 1610 Ftr 592.47
step 1200 loss 0.1670 tr 1.000 te 0.528 ||w||^2 1409 Ftr 506.84
step 1300 loss 0.1354 tr 1.000 te 0.542 ||w||^2 1244 Ftr 620.23
step 1400 loss 0.1135 tr 1.000 te 0.570 ||w||^2 1109 Ftr 352.40
step 1500 loss 0.0868 tr 1.000 te 0.631 ||w||^2 997 Ftr 807.68
step 1600 loss 0.0831 tr 1.000 te 0.726 ||w||^2 907 Ftr 1169.90
step 1700 loss 0.0848 tr 1.000 te 0.853 ||w||^2 827 Ftr 213.28
step 1800 loss 0.0643 tr 1.000 te 0.939 ||w||^2 761 Ftr 1067.28
step 1900 loss 0.0633 tr 1.000 te 0.981 ||w||^2 703 Ftr 441.79
step 2000 loss 0.0674 tr 1.000 te 0.989 ||w||^2 654 Ftr 4369.18
step 2100 loss 0.0424 tr 1.000 te 0.991 ||w||^2 612 Ftr 1643.73
step 2200 loss 0.0504 tr 1.000 te 0.996 ||w||^2 581 Ftr 1494.28
step 2300 loss 0.0439 tr 1.000 te 0.998 ||w||^2 546 Ftr 4108.57
step 2400 loss 0.0528 tr 1.000 te 0.997 ||w||^2 519 Ftr 294.32
step 2500 loss 0.0471 tr 1.000 te 0.999 ||w||^2 496 Ftr 136.51
step 2600 loss 0.0532 tr 1.000 te 0.999 ||w||^2 472 Ftr 462.68
step 2700 loss 0.0394 tr 1.000 te 0.998 ||w||^2 455 Ftr 482.36
step 2800 loss 0.0464 tr 1.000 te 0.999 ||w||^2 444 Ftr 1152.42
step 2900 loss 0.0400 tr 0.999 te 0.998 ||w||^2 425 Ftr 1733.84
step 3000 loss 0.0348 tr 1.000 te 0.999 ||w||^2 415 Ftr 2064.95
step 3100 loss 0.0358 tr 1.000 te 0.999 ||w||^2 407 Ftr 524.77
step 3200 loss 0.0335 tr 1.000 te 0.999 ||w||^2 398 Ftr 1470.46
step 3300 loss 0.0469 tr 0.999 te 0.999 ||w||^2 393 Ftr 683.21
step 3400 loss 0.0304 tr 1.000 te 0.999 ||w||^2 392 Ftr 58.33
step 3500 loss 0.0364 tr 1.000 te 0.998 ||w||^2 384 Ftr 3527.22
step 3600 loss 0.0296 tr 1.000 te 0.998 ||w||^2 381 Ftr 1329.26
step 3700 loss 0.0334 tr 1.000 te 0.999 ||w||^2 376 Ftr 499.84
step 3800 loss 0.0338 tr 0.999 te 0.999 ||w||^2 373 Ftr 73.82
step 3900 loss 0.0283 tr 1.000 te 0.999 ||w||^2 372 Ftr 3596.96
step 4000 loss 0.0327 tr 1.000 te 0.999 ||w||^2 369 Ftr 571.28
Entraîné en 182s sur CPU.
On observe typiquement deux régimes bien séparés. (1) Mémorisation : la train accuracy atteint ~100 % en quelques centaines de pas, tandis que la test accuracy stagne autour de 30 % (le réseau a stocké les ~1044 couples vus, et répond au hasard sur le complément). (2) Grok : après une longue phase plate, la test accuracy bascule soudain vers ~100 %. Le réseau est passé d’une table de lookup à un algorithme de multiplication modulaire généralisable.
C’est exactement le passage mémorisation (code dominé par les données) → programme court (code qui se compactifie) que la jambe K doit capturer.
steps = np.array([r["step"] for r in rows])tracc = np.array([r["tr_acc"] for r in rows])teacc = np.array([r["te_acc"] for r in rows])losses = np.array([r["loss"] for r in rows])# grok_step = premier checkpoint ou test_acc >= 0.85 apres memorisation (train >= 0.95).grok_idx =next((i for i, r inenumerate(rows) if r["tr_acc"] >=0.95and r["te_acc"] >=0.85), None)grok_step = rows[grok_idx]["step"] if grok_idx isnotNoneelseNoneprint(f"Grok détecté au step : {grok_step}")fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(11, 4))ax1.plot(steps, tracc, label="train"); ax1.plot(steps, teacc, label="test")if grok_step: ax1.axvline(grok_step, color="k", ls="--", alpha=0.5, label=f"grok @ {grok_step}")ax1.set_xlabel("step"); ax1.set_ylabel("accuracy"); ax1.set_ylim(-0.05, 1.05)ax1.legend(); ax1.set_title("Mémorisation puis généralisation (grok)")ax2.plot(steps, losses, color="C2")ax2.set_xlabel("step"); ax2.set_ylabel("train loss"); ax2.set_title("Loss d'entraînement")ax2.set_yscale("log")plt.tight_layout(); plt.show()
Grok détecté au step : 1700
§2. Compression-progress de Schmidhuber — le résultat positif
La thèse de Schmidhuber dit que la beauté/curiosité = le taux de compression-progress. Le candidat le plus direct pour l’observer est la norme des poids ‖w‖² : sous weight-decay, l’optimiseur compresse continûment le modèle (régularisation L2 = pénalité sur la codelength). Si la thèse tient, on doit voir ‖w‖² décroître continûment, et le grok émerger une fois assez de compression accumulée — pas sur un pic local.
La quantité naturelle à tracer est donc la compression cumulée (le déficit total de norme depuis l’initialisation) : c’est l’intégrale du compression-progress, et le grok doit survenir dans sa zone de saturation.
wn = np.array([r["wn"] for r in rows], dtype=float)ftr = np.array([r["ftr"] for r in rows], dtype=float)cumul = np.maximum(0.0, wn[0] - wn)fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(11, 4))ax1.plot(steps, wn, color="C3")if grok_step: ax1.axvline(grok_step, color="k", ls="--", alpha=0.5, label=f"grok @ {grok_step}")ax1.set_xlabel("step"); ax1.set_ylabel(r"$\|w\|^2$"); ax1.legend()ax1.set_title("Norme des poids (décroissance continue)")ax2.plot(steps, cumul, color="C4")if grok_step: ax2.axvline(grok_step, color="k", ls="--", alpha=0.5, label=f"grok @ {grok_step}")ax2.set_xlabel("step"); ax2.set_ylabel("compression cumulée"); ax2.legend()ax2.set_title(r"Cumulative $\|w\|_0^2 - \|w\|^2$")plt.tight_layout(); plt.show()print(f"||w||^2 : {wn[0]:.0f} -> {wn[-1]:.0f} (décroissance continue sous weight-decay).")print("VERDICT POSITIF : le compression-progress de Schmidhuber tient. La norme des")print("poids décroît CONTINUELLEMENT ; le grok survient dans cette phase de compression")print("cumulée (la §3 demande si un fold s'y localise, et pour quels proxys).")
||w||^2 : 8955 -> 369 (décroissance continue sous weight-decay).
VERDICT POSITIF : le compression-progress de Schmidhuber tient. La norme des
poids décroît CONTINUELLEMENT ; le grok survient dans cette phase de compression
cumulée (la §3 demande si un fold s'y localise, et pour quels proxys).
§3. Le test du pli de Thom au grok point — un résultat proxy-dépendant
La prédiction de la §0 est falsifiable : si la lentille ICT capture le grok, un proxy K doit montrer un pli (overlay de Thom : transition nette haut→bas) localisé au grok point. On applique fold_in_compressibility_curve du module ict.beauty à chaque courbe K et on compare le fold_step trouvé au grok_step mesuré ci-dessus.
La question fine n’est pas seulement « y a-t-il un fold au grok », mais « les différents proxys de K sont-ils d’accord entre eux » — car la jambe K a plusieurs facets : la compression structurelle (‖w‖²), la sensibilité locale (trace de Fisher), le code MDL (Fisher-MDL, proxy de Hinton–van Camp 0.5·log2(1 + Ftr·‖w‖²/n)) et la compressibilité de la sortie (longueur zlib de la prédiction). Si toutes co-localisent leur fold au grok, le pli de Thom est robuste ; si elles divergent, la « catastrophe » est proxy-dépendante et la lentille est fragile.
n_params =sum(pp.numel() for pp in model.parameters())fmdl =0.5* np.log2(1.0+ (ftr * wn) /max(n_params, 1))pz = np.array([r["pzlen"] for r in rows], dtype=float)def fold_step_of(name, vals): rep = BTY.fold_in_compressibility_curve(steps, vals, smooth=3) fs = rep.get("fold_step") ecart =f"{abs(fs - grok_step):.0f}"if (fs isnotNoneand grok_step isnotNone) else"?"print(f" {name:24s} fold_step={fs} (grok @ {grok_step}, écart {ecart})")return fsprint("=== fold_in_compressibility_curve sur chaque courbe K ===")f_wn = fold_step_of("||w||^2 (compression)", wn)f_ftr = fold_step_of("Fisher trace", ftr)f_fmdl = fold_step_of("Fisher-MDL proxy", fmdl)f_pz = fold_step_of("pred-zlib len", pz)f_te = fold_step_of("1 - test_acc", 1.0- teacc)# Co-localisation par proxy : le fold tombe-t-il pres du grok ?_proxies = [("||w||^2 (structure)", f_wn), ("Fisher trace (sensibilite)", f_ftr), ("Fisher-MDL (code)", f_fmdl), ("pred-zlib (sortie)", f_pz), ("1 - test_acc (behaviorale)", f_te)]coloc = []; noncoloc = []if grok_step isnotNone:for name, fs in _proxies:if fs isnotNoneandabs(fs - grok_step) <=400: coloc.append(name)elif fs isnotNone: noncoloc.append(name)print(f"\nProxys co-localisés au grok (|fold - grok| <= 400) : {coloc if coloc else'AUCUN'}")print(f"Proxys NON co-localisés : {noncoloc if noncoloc else'AUCUN'}")print(f"\nVerdict : la catastrophe de Thom est PROXY-DÉPENDANTE — "f"{len(coloc)}/{len([fs for _, fs in _proxies if fs isnotNone])} proxys voient le grok.")
=== fold_in_compressibility_curve sur chaque courbe K ===
||w||^2 (compression) fold_step=1700.0 (grok @ 1700, écart 0)
Fisher trace fold_step=3400.0 (grok @ 1700, écart 1700)
Fisher-MDL proxy fold_step=1300.0 (grok @ 1700, écart 400)
pred-zlib len fold_step=1100.0 (grok @ 1700, écart 600)
1 - test_acc fold_step=1800.0 (grok @ 1700, écart 100)
Proxys co-localisés au grok (|fold - grok| <= 400) : ['||w||^2 (structure)', 'Fisher-MDL (code)', '1 - test_acc (behaviorale)']
Proxys NON co-localisés : ['Fisher trace (sensibilite)', 'pred-zlib (sortie)']
Verdict : la catastrophe de Thom est PROXY-DÉPENDANTE — 3/5 proxys voient le grok.
fig, axes = plt.subplots(2, 2, figsize=(11, 7))for ax, vals, name, fs inzip( axes.flat, [wn, ftr, fmdl, pz], [r"$\|w\|^2$", "Fisher trace", "Fisher-MDL proxy", "pred-zlib len"], [f_wn, f_ftr, f_fmdl, f_pz]): ax.plot(steps, vals, color="C0")if grok_step: ax.axvline(grok_step, color="g", ls="--", alpha=0.6, label=f"grok @ {grok_step}")if fs: ax.axvline(fs, color="r", ls=":", alpha=0.6, label=f"fold @ {fs:.0f}") ax.set_title(name); ax.set_xlabel("step"); ax.legend(fontsize=8)plt.tight_layout(); plt.show()print(f"Co-localisation au grok : {len(coloc)}/{len(_proxies)} proxys.")print(f" voient le grok : {coloc if coloc else'AUCUN'}")print(f" ne le voient pas : {noncoloc if noncoloc else'AUCUN'}")print("La compression structurelle (||w||^2) et la transition behaviorale (test_acc)")print("alignent leur pli avec le grok ; la sensibilité (Fisher) non. Pas de catastrophe")print("unique : K est multidimensionnel, le pli de Thom est proxy-dépendant.")
Co-localisation au grok : 3/5 proxys.
voient le grok : ['||w||^2 (structure)', 'Fisher-MDL (code)', '1 - test_acc (behaviorale)']
ne le voient pas : ['Fisher trace (sensibilite)', 'pred-zlib (sortie)']
La compression structurelle (||w||^2) et la transition behaviorale (test_acc)
alignent leur pli avec le grok ; la sensibilité (Fisher) non. Pas de catastrophe
unique : K est multidimensionnel, le pli de Thom est proxy-dépendant.
§4. Interprétation honnête
Trois constats, un cadre nuancé (sans survendre, sans enjoliver).
Positif et robuste — le compression-progress de Schmidhuber tient. La norme des poids décroît continûment (ici ~8955 → ~369) et le grok émerge de la compression cumulée. La thèse fondamentale de la jambe K — la généralisation naît de la compression — est validée sur données réelles, avec un vrai grok (accuracy de test → ~1.0).
Nuancé — le pli de Thom est proxy-dépendant. Le diagnostic de la §3 montre que la compression structurelle (‖w‖²) et la transition behaviorale (1 − test_acc) ont un fold co-localisé avec le grok, mais que la sensibilité locale (trace de Fisher) plie loin du grok, et le code MDL / la sortie zlib ailleurs encore. Il n’y a pas de catastrophe unique partagée par toutes les facets de K : la lentille Thom voit le grok du côté de la structure, pas de la sensibilité.
Pourquoi c’est le résultat intéressant. Cette divergence EST la trouvaille. Elle dit que la jambe K ne se réduit pas à un scalaire : la compression structurelle du modèle et sa sensibilité locale racontent des histoires différentes, et seule la première aligne son pli avec le grok. C’est exactement la leçon du Gate 5 de la synthèse (ICT-Synthese-CrossSubstrat : la jambe K « statique » ne capture pas le grok de façon unifiée) et de la dissociation emergence/ignition d’ICT-24 (≈ 17 % des cas crédités) : les différentes lentilles de « transition » ne s’alignent pas toutes.
Le cadre, affiné. Schmidhuber (compression-progress = beauté) est confirmé de façon robuste. Thom (catastrophe ponctuelle) est partiel : la structure voit le grok, la sensibilité non. Plutôt qu’une réfutation ou une confirmation nette, le grokking révèle que K est multidimensionnel — et que l’axe unificateur (capstone #7259) est la dérivée temporelle, pas un instantané K statique.
Note de reproductibilité. Un grok net nécessite un weight-decay élevé (ici 1.0) et assez d’étapes ; avec un weight-decay trop faible, le modèle mémorise sans généraliser et il n’y a plus de transition à localiser — ce qui rendrait le diagnostic vide. C’est pourquoi le banc utilise wd=1.0 (cf. exercice 1 pour le balayage).
Pied de page honnête. Ce strand est produit par po-2025 (modèle GLM, Zhipu AI — un petit modèle Open-Source, accessible à tous, pas un modèle SOTA). L’attribution exacte est conservée : pas de fausse provenance. L’esprit « open AI » est honoré sans maquiller la nature du moteur.
§5. Jonction ICT-18 : σ(t) et B(t) comme sonars du grokking
La question. La transition de grokking de la §1 est-elle signalée par la production d’entropie σ(t) et le budget de réversibilité B(t) de la dynamique d’entraînement — plus tôt, plus tard, ou pas du tout, comparé aux cinq proxys de compression de la §3 ? ICT-18 et ICT-18b ont établi σ et B sur quatre substrats non-LLM (tri auto-organisé, paysage bistable, réplicateur d’Axelrod, Gray-Scott) ; la §3 ci-dessus a établi que deux des cinq proxys dissocient autour du grokking. Aucun croisement n’existait : cette section est la jonction (issue #17975, Epic #4588).
Pré-enregistrement. Prédictions, panel, contrôles et seuils ont été verrouillés et publiés sur l’issue avant toute mesure (commentaire du 2026-09-26T22:38Z) :
P-σ — le maximum de σ se situe dans la fenêtre du saut de généralisation (celle contenant le plus grand accroissement consécutif de test_acc, tolérance ±1 fenêtre), puis décroît vers le témoin mélangé. P-B — le budget B_work culmine dans cette même fenêtre puis décroît après le saut (τ de Kendall < 0 post-saut) ; son épuisement avant le saut réfuterait cette lecture.
Élément verrouillé
Choix
Panel (4 grandeurs de §1)
te_acc, wn = ‖w‖², ftr = trace de Fisher, pzlen = longueur zlib des prédictions
Binarisation
bit = 1 si la grandeur s’améliore depuis le checkpoint précédent (te_acc monte ; les trois autres baissent) → 16 états
Fenêtres
W = 20 checkpoints (1000 étapes), stride 2 ; checkpoints tous les 50 pas (80 par run)
Contrôle 1 (mélange)
20 permutations de l’ordre des checkpoints par fenêtre ; σ doit dépasser moyenne + 2 écarts-types
Contrôle 2 (témoin)
σ au maximum > max σ d’un run sans weight decay — si le témoin ne grokke pas
Graines
{0, 1} en weight_decay=1.0 ; témoin graine 0 en weight_decay=0.0
Avertissement de dépendance (pré-enregistré). Le panel contient te_acc, ce qui crée une dépendance possible au critère de saut : σ ne peut pas ici établir une détection prédictive indépendante du grokking — au mieux un sonar coïncident. P-σ et P-B sont rapportés séparément ; le verdict de l’un ne valide pas l’autre.
Trois runs instrumentés : deux graines avec weight decay et un témoin sans weight decay
La §1 journalise ses grandeurs tous les 100 pas — trop grossièrement pour des fenêtres de 20 checkpoints ; la jonction requiert sa propre trajectoire. On rejoue la recette §1 à l’identique (même architecture, mêmes données appariées, mêmes lr/batch) avec un checkpoint tous les 50 pas : deux graines {0, 1} en weight_decay=1.0 (le levier canonique) et un témoin graine 0 en weight_decay=0.0. Chaque run tient en quelques minutes sur CPU.
Le témoin opérationnalise la clause du pré-enregistrement : σ ne sera crédité au saut que s’il dépasse aussi le maximum du run sans weight decay — à condition que ce témoin ne grokke effectivement pas, ce qui est vérifié sur sa trajectoire te_acc observée avant d’appliquer le contrôle. La sortie ci-dessous montre que cette condition n’est finalement pas remplie.
Graine 1. La §3 et la littérature montrent que l’instant du grok varie avec le seed : la prédiction P-σ s’évalue donc par graine — chaque graine a son propre saut (max accroissement de te_acc) et son propre maximum σ, et c’est l’accord des deux graines, mesuré sur leurs fenêtres respectives, qui distingue un sonar robuste d’un artefact de trajectoire.
Témoin sans weight decay. Le run wd=0 teste l’hypothèse selon laquelle retirer le levier canonique empêche la généralisation sur l’horizon de 4000 pas ; cette hypothèse n’est pas supposée vraie. Si le témoin grokke malgré tout, le contrôle C2 est invalide (clause pré-enregistrée) et le verdict σ ne peut être ni PASS ni FAIL — il est rapporté INCONCLUSIVE avec cette cause, sans repliage. La cellule du témoin mesure ce comportement plus bas.
Guide de lecture de la figure
Quatre panneaux : (a) les trajectoires te_acc des trois runs — le saut se lit en trait pointillé rouge ; (b) σ(t) des deux graines contre leur seuil C1 (null mélangé + 2 SD) ; (c) le budget B_work(t) des deux graines ; (d) le σ du témoin sans weight decay contre les σ des graines en pâle. La question que chaque panneau pose à l’œil : le signal culmine-t-il au pointillé rouge, avant, ou après ?
Verdict P-B — rapporté séparément
Le budget n’était pas soumis aux contrôles C1/C2 (ils créditent σ) ; sa prédiction s’évalue sur la position de son maximum et sa décroissance post-saut (τ de Kendall < 0). Aucune contamination croisée : le verdict P-B ne valide ni n’invalide P-σ, et réciproquement — la clause est pré-enregistrée.
# --- §5. Instruments thermodynamiques sur la trajectoire de checkpoints ---# Organes réutilisés (aucun nouvel organe) : ict/time_arrow.py + ict/reversibility_budget.py.import numpy as npfrom ict.time_arrow import transition_matrix, stationary_distribution, entropy_productionfrom ict.reversibility_budget import work_budgetfrom scipy.stats import kendalltauPANEL = ("te_acc", "wn", "ftr", "pzlen") # panel verrouillé (pré-enregistrement)N_STATES =2**len(PANEL) # 16 états binarisésW_WIN, STRIDE =20, 2# fenêtre 20 checkpoints, stride 2N_PERM =20# permutations du contrôle 1rng_null = np.random.default_rng(12345) # RNG du null, seedé (reproductible)def panel_states(rows_i):"""Binarise le panel : bit_q(k) = 1 si q 'améliorée' au checkpoint k vs k-1. Convention verrouillée : te_acc monte ; wn, ftr et pzlen baissent = amélioration. Le premier checkpoint n'a pas de prédécesseur -> n-1 états pour n checkpoints. """ states = []for k inrange(1, len(rows_i)): bits =0 bits |=int(rows_i[k]["te_acc"] > rows_i[k -1]["te_acc"]) <<0 bits |=int(rows_i[k]["wn"] < rows_i[k -1]["wn"]) <<1 bits |=int(rows_i[k]["ftr"] < rows_i[k -1]["ftr"]) <<2 bits |=int(rows_i[k]["pzlen"] < rows_i[k -1]["pzlen"]) <<3 states.append(bits)return np.array(states, dtype=int)def sigma_budget_window(states):"""sigma et B_work d'une fenêtre, via les organes ICT-18 (smoothing par défaut).""" P = transition_matrix(states, N_STATES) pi = stationary_distribution(P)return entropy_production(P, pi), work_budget(P, pi)def windowed_curves(rows_i):"""Courbes fenêtrées sigma(t), B(t) + null C1 (20 permutations par fenêtre).""" states = panel_states(rows_i) steps = np.array([r["step"] for r in rows_i])[1:] # l'état k décrit la transition k-1 -> k out = []for i inrange(0, len(states) - W_WIN +1, STRIDE): win = states[i:i + W_WIN] sig, bud = sigma_budget_window(win) nulls = np.array([sigma_budget_window(rng_null.permutation(win))[0]for _ inrange(N_PERM)]) out.append(dict( i=i, center_step=float(steps[i + W_WIN //2]), sigma=sig, B=bud, null_mean=float(nulls.mean()), null_sd=float(nulls.std(ddof=1)), c1=bool(sig > nulls.mean() +2.0* nulls.std(ddof=1)), ))return outdef jump_window(curves_i, rows_i):"""Fenêtre du saut : checkpoint du max accroissement consécutif de te_acc, puis fenêtre au centre le plus proche de ce checkpoint -- tie-break déterministe choisi avant mesure (plusieurs fenêtres contiennent le saut au stride 2).""" te = np.array([r["te_acc"] for r in rows_i]) c_star =int(np.argmax(te[1:] - te[:-1])) +1# index de checkpoint 1..n-1 centers = np.array([c["i"] + W_WIN //2for c in curves_i])returnint(np.argmin(np.abs(centers - c_star))), c_starprint(f"Instruments §5 chargés : panel={PANEL} | {N_STATES} états | "f"fenêtre={W_WIN} checkpoints | stride={STRIDE} | perms C1={N_PERM}")
Pour chaque run : 80 états binarisés (le premier checkpoint n’a pas de prédécesseur), des fenêtres de 20 checkpoints glissant de 2 en 2, σ et B_work par fenêtre, et le null du contrôle 1 (mélange de l’ordre des checkpoints de la fenêtre, 20 tirages). Les impressions donnent les quantités brutes par graine — checkpoint du saut, fenêtre du maximum σ, écart en fenêtres, passage des contrôles — pour la falsifiabilité : tout recalcul indépendant depuis les sorties doit retomber sur ces nombres.
# --- Courbes fenêtrées sigma/B + null C1, lecture brute par graine ---curves = {s: windowed_curves(runs[s]) for s in (0, 1)}witness_curves = windowed_curves(witness_rows)jump = {s: jump_window(curves[s], runs[s]) for s in (0, 1)}witness_sigma_max =max(c["sigma"] for c in witness_curves)for s in (0, 1): jw, c_star = jump[s] peak =int(np.argmax([c["sigma"] for c in curves[s]])) cs = curves[s]print(f"graine {s} : saut au checkpoint {c_star} (step {runs[s][c_star]['step']}) | "f"fenêtre-saut {jw} (centre step {cs[jw]['center_step']:.0f}) | "f"max sigma fenêtre {peak} (centre step {cs[peak]['center_step']:.0f}) | "f"écart {abs(peak - jw)} fenêtre(s)")print(f" sigma(saut)={cs[jw]['sigma']:.3f} [null {cs[jw]['null_mean']:.3f} "f"+ 2*{cs[jw]['null_sd']:.3f}] C1={cs[jw]['c1']} | "f"sigma(max)={cs[peak]['sigma']:.3f} C1={cs[peak]['c1']} | "f"C2 = sigma(max) > témoin max ({witness_sigma_max:.3f}) : "f"{cs[peak]['sigma'] > witness_sigma_max}")print(f"témoin wd=0 : max sigma sur fenêtres = {witness_sigma_max:.3f} | "f"grokke = {witness_groks} (te_acc max {witness_te_max:.3f})")
# --- Verdict P-sigma : implémentation littérale des seuils pré-enregistrés ---# Ordre de décision (verrouillé) : témoin qui grokke -> INCONCLUSIVE (C2 invalide) ;# aucun passage de C1 nulle part -> INCONCLUSIVE (instrument sans pouvoir sur ce substrat) ;# sinon PASS / FAIL / INCONCLUSIVE selon les écarts, par graine puis globalement.# Le cas non listé (écart exactement 2 fenêtres) est rapporté INCONCLUSIVE, sans repliage.det = {}for s in (0, 1): jw, _ = jump[s] peak =int(np.argmax([c["sigma"] for c in curves[s]])) det[s] =dict( jump_win=jw, peak_win=peak, dist=abs(peak - jw), c1_peak=curves[s][peak]["c1"], c2=curves[s][peak]["sigma"] > witness_sigma_max, sigma_peak=curves[s][peak]["sigma"], )print(f"graine {s} : écart max-sigma / saut = {det[s]['dist']} fenêtre(s) | "f"C1 au max = {det[s]['c1_peak']} | C2 = {det[s]['c2']}")any_c1 =any(c["c1"] for cs in curves.values() for c in cs)dists = [det[s]["dist"] for s in (0, 1)]if witness_groks: VERDICT_SIGMA, reason_sigma ="INCONCLUSIVE", ("le témoin wd=0 grokke : contrôle C2 invalide (clause pré-enregistrée)")elifnot any_c1: VERDICT_SIGMA, reason_sigma ="INCONCLUSIVE", ("sigma n'excède jamais le seuil du contrôle mélangé (instrument sans pouvoir ici)")elifall(d <=1for d in dists) andall(det[s]["c1_peak"] and det[s]["c2"] for s in (0, 1)): VERDICT_SIGMA, reason_sigma ="PASS", ("les deux graines placent max sigma à ±1 fenêtre du saut, au-dessus des deux contrôles")elifall(d >=3for d in dists): VERDICT_SIGMA, reason_sigma ="FAIL", ("contrôles avec pouvoir mais maxima à ≥3 fenêtres du saut sur les deux graines")elifall(det[s]["sigma_peak"] <= witness_sigma_max for s in (0, 1)): VERDICT_SIGMA, reason_sigma ="FAIL", ("le témoin sans grok présente un pic sigma comparable (sigma non spécifique au grokking)")else: VERDICT_SIGMA ="INCONCLUSIVE" reason_sigma = (f"cas non listé ou graines discordantes (écarts = "f"{dists[0]} et {dists[1]} fenêtres) — rapporté sans repliage")print(f"P-sigma VERDICT : {VERDICT_SIGMA} -- {reason_sigma}")
# --- Verdict P-B (séparé) : max B_work à ±1 fenêtre du saut + décroissance post-saut ---detB = {}for s in (0, 1): jw, _ = jump[s] peakB =int(np.argmax([c["B"] for c in curves[s]])) post = curves[s][jw +1:]iflen(post) >=3: tau = kendalltau([c["center_step"] for c in post], [c["B"] for c in post]).statisticelse: tau =float("nan") detB[s] =dict(peak_win=peakB, dist=abs(peakB - jw), before=bool(peakB < jw), tau_post=float(tau))print(f"graine {s} : max B_work fenêtre {peakB} (écart au saut {abs(peakB - jw)}) | "f"tau Kendall post-saut = {tau:+.3f}")ifall(detB[s]["dist"] <=1and detB[s]["tau_post"] <0for s in (0, 1)): VERDICT_B, reason_b ="PASS", ("max B_work à ±1 fenêtre du saut et décroissance post-saut (tau < 0) sur les deux graines")elifall(detB[s]["dist"] >=3and detB[s]["before"] for s in (0, 1)): VERDICT_B, reason_b ="FAIL", ("B_work culmine ≥3 fenêtres AVANT le saut : budget épuisé avant la transition ""(réfute la lecture P-B pré-enregistrée)")else: VERDICT_B ="INCONCLUSIVE" reason_b = (f"graines discordantes ou cas non listé (écarts = {detB[0]['dist']} et "f"{detB[1]['dist']} fenêtres) — rapporté sans repliage")print(f"P-B VERDICT : {VERDICT_B} -- {reason_b}")# --- Synthèse §5 pour la matrice des dissociations (docs/ict/dissociations-matrix.md) ---print("\nSynthèse §5 (à reporter dans la matrice des dissociations) :")print(f" P-sigma : {VERDICT_SIGMA} ({reason_sigma})")print(f" P-B : {VERDICT_B} ({reason_b})")
graine 0 : max B_work fenêtre 30 (écart au saut 18) | tau Kendall post-saut = +0.446
graine 1 : max B_work fenêtre 29 (écart au saut 16) | tau Kendall post-saut = +0.559
P-B VERDICT : INCONCLUSIVE -- graines discordantes ou cas non listé (écarts = 18 et 16 fenêtres) — rapporté sans repliage
Synthèse §5 (à reporter dans la matrice des dissociations) :
P-sigma : INCONCLUSIVE (le témoin wd=0 grokke : contrôle C2 invalide (clause pré-enregistrée))
P-B : INCONCLUSIVE (graines discordantes ou cas non listé (écarts = 18 et 16 fenêtres) — rapporté sans repliage)
# --- Figure §5 : te_acc, sigma(t) vs null, B_work(t), témoin ---fig, axes = plt.subplots(2, 2, figsize=(12, 8))ax = axes[0, 0]for s, c in [(0, "tab:blue"), (1, "tab:orange")]: ax.plot([r["step"] for r in runs[s]], [r["te_acc"] for r in runs[s]], color=c, label=f"grok seed {s}")ax.plot([r["step"] for r in witness_rows], [r["te_acc"] for r in witness_rows], color="gray", alpha=0.8, label="témoin wd=0")for s in (0, 1): ax.axvline(runs[s][jump[s][1]]["step"], color="tab:red", ls=":", lw=1)ax.set_xlabel("step"); ax.set_ylabel("te_acc")ax.set_title("(a) Généralisation — sauts marqués en pointillé rouge")ax.legend(fontsize=8)ax = axes[0, 1]for s, c in [(0, "tab:blue"), (1, "tab:orange")]: ax.plot([cur["center_step"] for cur in curves[s]], [cur["sigma"] for cur in curves[s]], color=c, label=f"sigma seed {s}") ax.plot([cur["center_step"] for cur in curves[s]], [cur["null_mean"] +2* cur["null_sd"] for cur in curves[s]], color=c, ls=":", lw=1) ax.axvline(runs[s][jump[s][1]]["step"], color="tab:red", ls=":", lw=1)ax.set_xlabel("step (centre de fenêtre)"); ax.set_ylabel("sigma (nats)")ax.set_title("(b) sigma(t) — pointillés : seuil C1 (null + 2 SD)")ax.legend(fontsize=8)ax = axes[1, 0]for s, c in [(0, "tab:blue"), (1, "tab:orange")]: ax.plot([cur["center_step"] for cur in curves[s]], [cur["B"] for cur in curves[s]], color=c, label=f"B_work seed {s}") ax.axvline(runs[s][jump[s][1]]["step"], color="tab:red", ls=":", lw=1)ax.set_xlabel("step (centre de fenêtre)"); ax.set_ylabel("B_work (L1/2)")ax.set_title("(c) Budget de réversibilité B_work(t)")ax.legend(fontsize=8)ax = axes[1, 1]ax.plot([cur["center_step"] for cur in witness_curves], [cur["sigma"] for cur in witness_curves], color="gray", label="sigma témoin wd=0")ax.plot([cur["center_step"] for cur in witness_curves], [cur["null_mean"] +2* cur["null_sd"] for cur in witness_curves], color="gray", ls=":", lw=1, label="seuil C1 témoin")for s, c in [(0, "tab:blue"), (1, "tab:orange")]: ax.plot([cur["center_step"] for cur in curves[s]], [cur["sigma"] for cur in curves[s]], color=c, alpha=0.35, lw=1)ax.set_xlabel("step (centre de fenêtre)"); ax.set_ylabel("sigma (nats)")ax.set_title("(d) Témoin sans grok vs graines (pâles)")ax.legend(fontsize=8)fig.suptitle("§5 — Sonar thermodynamique du grokking : sigma(t) et B_work(t)", y=0.995)fig.tight_layout()plt.show()
Lecture de la figure
Les courbes illustrent les mesures numériques, sans lever le statut INCONCLUSIVE des deux prédictions. (b) Sur les deux graines, σ ne dépasse pas son seuil C1 aux fenêtres qui comptent — au saut (σ = 1.974 et 5.342) comme au maximum (σ = 12.756 et 12.253, C1 = False) — et les deux pics sont discordants : tardif pour la graine 0 (centre step 3550), précoce pour la graine 1 (centre step 650), sans structure cohérente autour des sauts (pointillés rouges, steps 1750 et 1800). (d) Le témoin sans weight decay culmine à σ = 12.777, au niveau des deux autres graines ; il grokke aussi (te_acc max = 0.519), donc il ne constitue pas un contrôle non-grok valide. (c)B_work ne culmine pas au saut non plus : ses maxima sont 18 et 16 fenêtres après le saut, et les courbes montent encore après (τ de Kendall post-saut positif sur les deux graines).
Ce que la jonction établit — et ce qu’elle ne peut pas établir
Le résultat mesuré est un double INCONCLUSIF — avec trois observations distinctes :
La prémisse du contrôle témoin échoue. Sans weight decay, la recette généralise aussi, plus lentement : te_acc culmine à 0.519 sur l’horizon de 4000 pas. Le contrôle C2 pré-enregistré (σ au saut supérieur au maximum d’un run qui ne grokke pas) est donc invalide — le verdict P-σ reste INCONCLUSIVE par la clause pré-enregistrée, sans repliage. Le levier wd=1.0 reste visible (grok complet aux sauts step 1750 et 1800 des deux graines, te_acc finale 0.998 chacune, contre 0.519 pour le témoin), mais « sans wd, pas de généralisation » est faux à cette fraction d’entraînement (30 %).
Le critère C1 ne soutient pas P-σ à cette échelle. Aux fenêtres du saut et du maximum, σ reste sous son seuil mélangé (C1 = False sur les deux graines), et ses maxima sont éloignés et de part et d’autre du saut (centre step 3550 pour la graine 0, 650 pour la graine 1). Le témoin culmine au même ordre de grandeur (σ = 12.777 contre 12.756 et 12.253), mais sa comparaison C2 n’est pas interprétable puisqu’il grokke. Des fenêtres de 20 checkpoints sur 16 états peuvent produire des matrices creuses ; cette explication reste une hypothèse sur le mécanisme du signal, non un diagnostic établi par le seul C1.
La direction observée de P-B est opposée à la prédiction.B_work ne culmine pas au saut mais 18 et 16 fenêtres après, et sa tendance post-saut est positive (τ de Kendall = +0.446 et +0.559). La lettre du pré-enregistrement ne code pas ce cas (le FAIL exigeait un pic avant le saut) : le verdict formel reste INCONCLUSIVE sans repliage. Cette discordance directionnelle, mesurée sur deux graines, motive un test ultérieur pré-enregistré ; elle ne constitue pas à elle seule une réfutation formelle.
Portée et bornes. Recette canonique CPU (a·b mod 59, transformeur 1 couche, wd=1.0), 2 graines, panel contenant te_acc (aucun claim prédictif indépendant — avertissement pré-enregistré avec la prédiction). Ces observations ne démontrent ni une détection du saut ni une absence générale de discrimination ; la ligne consolidée vit dans la matrice des dissociations.
§6. Exercices
Les exercices ci-dessous sont des stubs à compléter. Ils conservent les conventions du notebook (aucune erreur volontaire : le notebook s’exécute de bout en bout même non complété). Chacun prolonge une branche de l’analyse.
Exercice 1 — Sensibilité au taux d’apprentissage et au weight-decay
Le grokking est très sensible au weight-decay (c’est le levier principal de la généralisation). Rejouez l’entraînement en balayant weight_decay dans {1e-4, 1e-3, 1e-2, 1e-1} et tracez le grok_step obtenu. Indice : un weight-decay trop faible repousse le grok au-delà du budget ; trop élevé peut empêcher la mémorisation initiale. Concluez sur la fenêtre « grokkable ».
# Exercice 1 : balayage du weight-decay. Stub à compléter.# TODO étudiant : pour wd dans [1e-4, 1e-3, 1e-2, 1e-1], ré-entraîner GrokTransformer(p)# pendant TOTAL_STEPS, mesurer grok_step, et collecter (wd, grok_step).results_wd = [] # liste de tuples (weight_decay, grok_step_or_None)# Indice : factoriser l'entraînement dans une fonction renvoyant la liste rows, puis# réutiliser la règle grok_idx de la cellule §1 (tr_acc>=0.95 et te_acc>=0.70).# Etape 1 : écrire def entrainer(weight_decay, steps=TOTAL_STEPS) -> list[dict].# Etape 2 : boucler sur les wd, collecter grok_step.# Etape 3 : tracer wd (log) vs grok_step.print("Exercice à compléter : balayage du weight-decay et courbe grok_step(wd).")
Exercice à compléter : balayage du weight-decay et courbe grok_step(wd).
Exercice 2 — Un autre proxy K : le rang effectif des poids
Tous nos proxys (norme L2, Fisher) sont -scalaires et restent monotones. Une piste plus structurale : le rang effectif de la matrice de poids head (spectre des valeurs singulières). Mesurez-le à chaque checkpoint et cherchez un fold. Indice : np.linalg.svd(model.head.weight.detach().numpy(), compute_uv=False) donne le spectre ; le rang effectif peut se définir comme exp(H(s²)) (entropie de la distribution normalisée des valeurs singulières au carré). Le rang discrimine-t-il mieux le grok que la norme ?
# Exercice 2 : rang effectif de la couche de sortie. Stub à compléter.# TODO étudiant : à chaque checkpoint, calculer le rang effectif de model.head.weight# et chercher un fold avec fold_in_compressibility_curve.# Indice : spectre s = svd(W, compute_uv=False) ; pi = s**2 / sum(s**2) ;# rang_eff = exp(-sum(pi * log(pi))) (entropie => nombre effectif de directions).# Etape 1 : boucler sur rows (rejouer l'entraînement en journalisant head_weight).# Etape 2 : fold_in_compressibility_curve(steps, rang_eff, smooth=3).# Etape 3 : comparer fold_step au grok_step — meilleur que la norme L2 ?print("Exercice à compléter : rang effectif de head_weight vs grok_step.")
Exercice à compléter : rang effectif de head_weight vs grok_step.
Exercice 3 — Le loss de test saute-t-il au grok ?
Le seul signal qui bascule brutalement au grok est l’accuracy de test. Mais la loss de test (cross-entropy sur la grille complète) saute-t-elle elle aussi, et son saut est-il localisé au grok point ? C’est le test le plus direct du « pli » vu du côté des données. Indice : journaliser test_loss à chaque checkpoint, lui appliquer fold_in_compressibility_curve (sur la courbe décroissante), et comparer. Si la test_loss a un fold net au grok alors que ‖w‖² n’en a pas, c’est que la transition est dans les données (le programme court réduit soudain le résidu), pas dans le modèle.
# Exercice 3 : fold de la loss de test au grok point. Stub à compléter.# TODO étudiant : journaliser test_loss (cross-entropy sur la grille complète) à chaque# checkpoint, puis fold_in_compressibility_curve sur cette courbe.# Indice : loss_te = F.cross_entropy(model(te_a, te_b), te_y).item() (no_grad).# Etape 1 : modifier la boucle d'entraînement pour logger test_loss.# Etape 2 : BTY.fold_in_compressibility_curve(steps, test_loss, smooth=3).# Etape 3 : le fold_step co-localise-t-il avec grok_step ? Comparer à ||w||^2.print("Exercice à compléter : fold de la loss de test vs grok_step.")
Exercice à compléter : fold de la loss de test vs grok_step.
§7. Conclusion et pont vers les LLMs
Ce que le notebook établît. Sur l’ancre empirique du grokking, la jambe K se comporte comme le prédit Schmidhuber : la généralisation émerge d’une compression cumulative (‖w‖² décroît continûment, le grok suit). Le reframing « pli de Thom » est partiellement validé : la compression structurelle voit le grok (fold co-localisé), la sensibilité Fisher non. Le grokking révèle donc que K est multidimensionnel — la trouvaille la plus utile du banc.
Jonction ICT-18 (§5). Le croisement pré-enregistré du grokking avec la production d’entropie et le budget de réversibilité aboutit à un double INCONCLUSIF honnête : à cette échelle de fenêtrage, σ n’a pas de pouvoir discriminateur et B_work croît après le saut au lieu de s’y épuiser — le témoin sans weight decay ayant de surcroît falsifié sa propre prémisse en généralisant lentement. La leçon rejoint celle de la §3 : un seul instrument ne voit pas la transition ; ici, c’est l’échelle de gros grain qui manque.
Reconnexion. Cette divergence entre facets de K est cohérente avec la dissociation emergence/ignition d’ICT-24 (≈ 17 % des cas crédités) et avec l’axe temporel orthogonal du Gate 5 de la synthèse : la jambe K statique ne capture pas le grok de façon unifiée, il faut la dérivée temporelle.
Pont vers le cluster LLM (ICT-21/22). Le grokking est un phénomène précoce dans la stratification de complexité — d’où le placement de ce strand avant les LLMs. Le grand LLM 9B étudié plus loin est un système déjà grokké : son entraînement fut un immense compression- progress event, et les ignitions du workspace observées en inférence (ICT-24) en sont des micro-analogues. Le capstone #7259 testera si la même géométrie de pli se retrouve à deux échelles (micro-ignition / macro-grokking), en s’appuyant sur la dérivée temporelle plutôt que sur un proxy K statique — prenant au sérieux la leçon de multidimensionalité de ce notebook.
Track expérimental (gate marqué). Au-delà du banc CPU, le test le plus propre du capstone est un grokking transformer avec logging périodique des activations/workspace pendant l’entraînement : capturer dans la même trajectoire le macro-grok (ce notebook) et les micro-ignitions (ICT-24). Ce track GPU-2 est routé vers une lane GPU (issue #7258 pièce 3) ; il n’est pas bloquant — le banc CPU ci-dessus porte déjà la falsifiabilité.
§8. Références
Cadre théorique
Power, A., Burda, Y., Edwards, H., Babuschkin, I., & Misra, V. (2022).Grokking: Generalization Beyond Overfitting on Small Algorithmic Datasets. 1st Mathematical Reasoning in General AI Workshop, ICLR 2022 ; arXiv:2201.02177. — Papier fondateur : définit le grokking, la recette AdamW weight_decay=1 sur transformeur 2 couches (largeur 128, 4 têtes), et l’équivalence x·y mod p ≡ x+y mod (p−1) pour le réseau (§3.2).
Schmidhuber, J. (2010).Formal Theory of Creativity, Fun, and Intrinsic Motivation (1990–2010). IEEE Trans. on Autonomous Mental Development, 2(3). — La beauté/curiosité comme taux de compression-progress : fondement de la jambe K et du module ict.beauty.
Thom, R. (1972).Stabilité structurelle et morphogenèse. — Théorie des catastrophes ; le « pli » sert d’overlay de transition dans fold_in_compressibility_curve.
Mécanismes du grokking — lentilles complémentaires
Nanda, N., Chan, L., Lieberum, T., Smith, J., & Steinhardt, J. (2023).Progress Measures for Grokking via Mechanistic Interpretability. ICLR 2023 ; arXiv:2301.05217. — Le « programme court » que le grok découvre est identifié mécaniquement (features de Fourier, circuits de trigonométrie modulaire) : la version interprétabilité du compression-progress que ce notebook mesure par proxys.
Humayun, A. I., Balestriero, R., & Baraniuk, R. (2024).Deep Networks Always Grok and Here is Why. ICML 2024 ; arXiv:2402.15555. — Lentille alternative : le grok comme effondrement de la complexité locale (densité de frontières de décision), assimilé à l’émergence de robustesse adversariale. Thèse de recherche (pas un consensus) — citée comme perspective, non comme définition canonique.
Implémentations de référence — cross-check de la recette CPU (§1)
stockeh/mlx-grokking (MLX / Apple Silicon), atveit/torch_grokking (PyTorch), atveit/jax_grokking (JAX) — trois implémentations indépendantes du grokking, toutes à weight_decay=1, lr=1e-3, transformeur 2 couches, division modulaire mod 97.
Louapre, D. (2023).« Grokking » : les modèles d’IA sont-ils capables de piger ? Science Étonnante (dépôt scienceetonnante/grokking). — Référence pédagogique francophone : MLP sur l’addition modulaire (weight_decay=1), avec la visualisation PCA « horloge » des embeddings.