Référence : R. Thomas McCoy, Paul Soulos, Tal Linzen, Paul Smolensky, The Emergent Symbolic Structure of Artificial Neural Networks, arXiv:2608.29530.
Ce notebook explore, sur des architectures petites et CPU-only, le diagnostic proposé par McCoy et al. : un réseau de séquence (GRU ou Transformer 2 couches) qui résout une tâche symbolique de copie / renversement / entrelacement développe-t-il dans ses représentations internes une structure de produit tensoriel (TPR = Tensor Product Representation, fillers liés à des rôles par produit tensoriel, somme, puis projection affine) ?
Le diagnostic DISCOVER procède en quatre étapes :
Entraîner le réseau sur la tâche cible ;
Approximer ses états cachés par une TPR (moindres carrés, contrainte de rang) ;
Réinjecter l’approximation TPR dans le décodeur original et mesurer la dégradation ;
Comparer à des embeddings atomiques (un vecteur par symbole, sans structure de rôle) et à un bag-of-words (ordre effacé).
L’acceptation de ce notebook est strictement bornée — comme l’impose le corps de l’EPIC #14366 (grain G6) : on montre une structure TPR approximative quand le réseau en possède une, on ne montre pas qu’il calcule un produit tensoriel exact, et la distinction est tenue jusqu’au bout.
Plan
Vocabulaire TPR : rôles, fillers, produit tensoriel, projection affine, contrainte de rang
Architecture GRU : entraînement court CPU sur les trois tâches, accuracy sur withheld
Diagnostic DISCOVER : approximation TPR par moindres carrés, ranks, MSE, comparaison atomique vs BoW
Réinjection décodeur : remplacement de l’état caché par l’approximation TPR, mesure de la chute d’accuracy
Constituent surgery : permutation d’un rôle, mesure du transfert ; withheld role-filler split
White-box TPR vs embeddings atomiques : contrôle de capacité (d_hidden vs d_role × d_filler) et régularisation L2,1 sur la décomposition
Conclusion bornée : ce qui a été montré, ce qui ne l’a pas été
Prérequis
Python 3.10+
torch (CPU suffit), numpy, matplotlib
Aucun GPU requis ; aucune clé API
Crédits et limites
Recherche de premier plan menée par myia-po-2023 (lane CoursIA-2) sur le scope G6 de l’EPIC #14366. Le dépôt officiel tommccoy1/discover n’est pas exécuté ici — on en reproduit l’esprit (le diagnostic DISCOVER) à petite échelle pour servir d’outil pédagogique, sans prétendre être une ré-implémentation. Les expériences GPT-OSS à >3000 GPU-h (mentionnées dans le papier) sont hors scope CPU. Limites explicites : architectures petites (GRU 1 couche cachée 32, Transformer 2 couches d_model 32), vocabulaire réduit (8 symboles), seeds 0/1/2/7/42 ; pas de barre batch-size ; pas d’ablation learning rate.
import itertoolsimport timefrom collections import defaultdictimport numpy as npimport matplotlibimport matplotlib.pyplot as pltimport torchimport osos.environ.setdefault("CUBLAS_WORKSPACE_CONFIG", ":4096:8") # determinisme cuBLAS (#16795)# Determinisme (#16795) : la graine seule ne garantit PAS la reproductibilite# (heuristiques cuDNN, kernels non deterministes). warn_only=True au premier# passage pour inventorier les ops fautives sans faire echouer le run.torch.use_deterministic_algorithms(True, warn_only=True)torch.backends.cudnn.deterministic =Truetorch.backends.cudnn.benchmark =Falseimport torch.nn as nnimport torch.nn.functional as Fprint(f"numpy {np.__version__} | matplotlib {matplotlib.__version__} | torch {torch.__version__}")print(f"device = {torch.device('cpu')} (CPU suffit)")torch.manual_seed(0)np.random.seed(0)def make_tpr(roles, fillers, bias=None):"""Construit une représentation TPR : somme sur (role, filler) de role (X) filler, + biais. roles : (n_roles, d_role) fillers : (n_fillers, d_filler) retour : (d_role * d_filler,) après flatten, ou avec biais concaténé. """ contribs = []for r in roles:for f in fillers: contribs.append(np.outer(r, f).flatten()) out = np.sum(contribs, axis=0)if bias isnotNone: out = np.concatenate([out, bias])return outdef decompose_tpr(y, d_role, d_filler, bias_dim=0, rank=None):"""Approxime chaque echantillon y[n] par une TPR de rang (d_role, d_filler). Modele : body[n, r, f] ≈ sum_g R[n, r, g] * F[n, g, f]. Pour chaque n, on factorise la matrice (d_role, d_filler) par SVD tronquee au rang k demande. Si rank=None, k = min(d_role, d_filler) (factorisation exacte, R^2 = 1). Sinon, k = min(rank, min(d_role, d_filler)) : factorisation de rang contraint. R et F sont partages au sein de l'echantillon ; on moyenne ensuite sur le batch pour avoir une representation partagee par split. Retourne (roles_mean, fillers_mean, residual_norm_moyen, r2_moyen). """ dim = d_role * d_fillerif y.shape[-1] != dim + bias_dim:raiseValueError(f"y dim {y.shape[-1]} != d_role*d_filler + bias_dim = {dim + bias_dim}") body = y[..., :dim].reshape(-1, d_role, d_filler) n = body.shape[0] k_full =min(d_role, d_filler) k =min(rank, k_full) if rank isnotNoneelse k_full k =max(k, 1) R_list = [] F_list = [] resid_norms = [] yss_total =0.0 rss_total =0.0for i inrange(n): M = body[i] U, s, Vt = np.linalg.svd(M, full_matrices=False) sqrt_s = np.sqrt(np.maximum(s[:k], 0)) R_i = U[:, :k] * sqrt_s[None, :] F_i = sqrt_s[:, None] * Vt[:k, :] R_list.append(R_i) F_list.append(F_i) approx = np.einsum("rg,gf->rf", R_i, F_i) res =float(np.linalg.norm(M - approx)) resid_norms.append(res) yss_total +=float(np.sum(M **2)) rss_total +=float(np.sum((M - approx) **2)) R_mean = np.mean(R_list, axis=0) F_mean = np.mean(F_list, axis=0) residual_mean =float(np.mean(resid_norms)) r2 =1.0- rss_total /max(yss_total, 1e-12)return R_mean, F_mean, residual_mean, r2np.random.seed(0)R_true = np.random.randn(3, 4)F_true = np.random.randn(4, 4)tpr = np.einsum("rg,gf->rf", R_true, F_true).flatten()R_hat, F_hat, res, r2 = decompose_tpr(tpr, 3, 4)print(f"Test de cohérence TPR (rang plein) : R^2 = {r2:.6f}")print(f" forme roles = {R_hat.shape}, fillers = {F_hat.shape}")
numpy 2.3.5 | matplotlib 3.10.3 | torch 2.8.0+cu126
device = cpu (CPU suffit)
Test de cohérence TPR (rang plein) : R^2 = 1.000000
forme roles = (3, 3), fillers = (3, 4)
Lecture du vocabulaire TPR
Trois objets primitifs — rôles (vecteurs par position dans la séquence, e.g. pos_0, pos_1, pos_2), fillers (vecteurs par symbole, e.g. a, b, c, d, e), biais optionnel — combinés par produit tensoriel r (X) f puis sommés :
Le diagnostic DISCOVER essaie d’inverser cette construction : étant donné un état caché y du réseau entraîné, peut-on trouver des rôles et fillers (de dimensions choisies) qui approximent y ? Si oui, le réseau a vraisemblablement internalisé une structure role × filler. Si non (résidu élevé, ou rang forcé trop petit), il a probablement une autre organisation.
Note sur la portée : cette factorisation est non-unique (rotation conique role × filler reste valide tant qu’on transforme simultanément R et F). On ne cherche donc pas à identifier les rôles et les fillers — on cherche à mesurer la qualité d’ajustement (R², résiduel) en fonction des dimensions.
VOCAB =list("abcdefgh")BOS ="<bos>"EOS ="<eos>"PAD ="<pad>"SYMBOLS = [BOS, EOS, PAD] + VOCABSYM2IDX = {s: i for i, s inenumerate(SYMBOLS)}IDX2SYM = {i: s for s, i in SYM2IDX.items()}V =len(SYMBOLS)print(f"Vocabulaire : {SYMBOLS} (V = {V})")def make_copy_task(seq_len=5, n_samples=200, seed=0): rng = np.random.RandomState(seed) samples = []for _ inrange(n_samples): s ="".join(rng.choice(VOCAB, size=seq_len)) samples.append((s, s))return samplesdef make_reverse_task(seq_len=5, n_samples=200, seed=0): rng = np.random.RandomState(seed) samples = []for _ inrange(n_samples): s ="".join(rng.choice(VOCAB, size=seq_len)) samples.append((s, s[::-1]))return samplesdef make_interleave_task(seq_len=4, n_samples=200, seed=0):"""Entrée = deux moitiés a||b, sortie = entrelacement a[0]b[0]a[1]b[1]...""" rng = np.random.RandomState(seed) samples = []for _ inrange(n_samples): a ="".join(rng.choice(VOCAB, size=seq_len)) b ="".join(rng.choice(VOCAB, size=seq_len)) src = a + b tgt ="".join(a[i] + b[i] for i inrange(seq_len)) samples.append((src, tgt))return samplesfor name, fn in [("copy", make_copy_task), ("reverse", make_reverse_task), ("interleave", make_interleave_task)]: samp = fn(seq_len=4, n_samples=3, seed=42)print(f" {name}: {samp}")
Trois tâches de mémoire/algèbre sur des séquences courtes :
Tâche
Séquence d’entrée
Séquence cible
Difficulté
copy
abcde
abcde
triviale (identité)
reverse
abcde
edcba
inversion de l’ordre
interleave
ab + cd = abcd
acbd
permutation dépendante de la parité
Vocabulaire = 8 symboles (a..h). Séquences courtes (4-5) pour que l’entraînement CPU converge vite et que la décomposition TPR reste numériquement traitable.
class SeqTaskGRU(nn.Module):"""Encodeur GRU + décodeur GRU, head de sortie sur le vocabulaire."""def__init__(self, vocab_size, d_model=32, n_layers=1):super().__init__()self.embed = nn.Embedding(vocab_size, d_model)self.encoder = nn.GRU(d_model, d_model, num_layers=n_layers, batch_first=True)self.decoder = nn.GRU(d_model, d_model, num_layers=n_layers, batch_first=True)self.head = nn.Linear(d_model, vocab_size)self.d_model = d_modeldef encode(self, x): e =self.embed(x) _, h =self.encoder(e)return hdef forward(self, src, tgt): h =self.encode(src) e_tgt =self.embed(tgt) out, _ =self.decoder(e_tgt, h[-1:])returnself.head(out)def encode_batch(samples, device): src = torch.tensor([[SYM2IDX[c] for c in s] for s, _ in samples], device=device) tgt = torch.tensor([[SYM2IDX[c] for c in t] for _, t in samples], device=device)return src, tgtdef train_gru(samples_train, samples_val, n_epochs=40, d_model=32, lr=1e-2, seed=0): torch.manual_seed(seed) device = torch.device("cpu") model = SeqTaskGRU(V, d_model=d_model).to(device) opt = torch.optim.Adam(model.parameters(), lr=lr) history = []for epoch inrange(n_epochs): model.train() src, tgt = encode_batch(samples_train, device) logits = model(src, tgt[:, :-1]) loss = F.cross_entropy(logits.reshape(-1, V), tgt[:, 1:].reshape(-1)) opt.zero_grad() loss.backward() opt.step() model.eval()with torch.no_grad(): src_v, tgt_v = encode_batch(samples_val, device) v_logits = model(src_v, tgt_v[:, :-1]) v_acc = (v_logits.argmax(-1) == tgt_v[:, 1:]).float().mean().item() history.append((epoch, loss.item(), v_acc))return model, historyTRAIN_SAMPLES =800VAL_SAMPLES =200SEQ_LEN =5N_EPOCHS =60D_MODEL =32results = {}for task_name, task_fn in [("copy", make_copy_task), ("reverse", make_reverse_task), ("interleave", make_interleave_task)]: train_data = task_fn(seq_len=SEQ_LEN, n_samples=TRAIN_SAMPLES, seed=0) val_data = task_fn(seq_len=SEQ_LEN, n_samples=VAL_SAMPLES, seed=999) t0 = time.time() model, hist = train_gru(train_data, val_data, n_epochs=N_EPOCHS, d_model=D_MODEL, seed=0) t_train = time.time() - t0 final_acc = hist[-1][2] results[task_name] = (model, hist, train_data, val_data, t_train, final_acc)print(f"{task_name:>10s} | epochs={N_EPOCHS} | train_time={t_train:.2f}s | val_acc={final_acc:.3f}")
Trois modèles GRU entraînés sur les trois tâches, même architecture (1 couche cachée, d_model=32), même budget (60 époques, Adam lr=1e-2, 800 exemples d’entraînement).
Tâche
Val accuracy
Interprétation
copy
~1.00
triviale : le réseau apprend vite l’identité
reverse
élevé attendu
inversion d’ordre : doit coder les positions
interleave
élevé attendu
permutation par parité : doit coder les positions paires/impaires
Note de portée : on ne prétend pas que les val accuracies soient suffisantes pour parler d’un réseau de production — l’objectif est de disposer d’un état caché entraîné à analyser via DISCOVER, pas d’établir un SOTA sur les trois tâches (les val accuracies seront discutées plus bas).
def collect_hidden(model, samples): model.eval()with torch.no_grad(): src, _ = encode_batch(samples, torch.device("cpu")) h = model.encode(src)return h[-1].cpu().numpy()def best_tpr_decomposition(H, d_hidden):"""Pour chaque split (d_role, d_filler) avec d_role * d_filler = d_hidden, ajuste une TPR. Implementation : SVD par echantillon sur la matrice (d_role, d_f_filler). **Le R^2 est trivialement = 1** pour le split plein (la SVD est exacte au rang min(d_role, d_filler)). Le diagnostic DISCOVER se joue donc ailleurs : (a) la REINJECTION de la TPR moyenne partagee dans le decodeur, et (b) la CONSTITUENT SURGERY, qui sont des tests FONCTIONNELS (voir cellules 9 et 11). """ best = {"r2": -np.inf, "d_role": None, "d_filler": None, "residual": None} fits = []for d_role inrange(1, d_hidden +1):if d_hidden % d_role !=0:continue d_filler = d_hidden // d_roleif d_filler <1:continue _, _, residual, r2 = decompose_tpr(H, d_role, d_filler) fits.append((d_role, d_filler, r2, residual))if r2 > best["r2"]: best = {"r2": r2, "d_role": d_role, "d_filler": d_filler, "residual": residual}return best, fitsdef bag_of_words_baseline(samples):"""BoW = comptage normalisé des symboles. Baseline 'ordre effacé'.""" bow = np.zeros((len(samples), 8))for i, (s, _) inenumerate(samples):for c in s: bow[i, VOCAB.index(c)] +=1 bow = bow / bow.sum(axis=1, keepdims=True).clip(min=1)return bowdef atomic_embed_baseline(model, samples):"""Embedding atomique = somme des embeddings appris des symboles.""" embed = model.embed.weight.detach().cpu().numpy() src, _ = encode_batch(samples, torch.device("cpu")) H_atomic = np.zeros((src.shape[0], embed.shape[1]))for i inrange(src.shape[0]): ids = src[i].cpu().numpy() H_atomic[i] = embed[ids].sum(axis=0)return H_atomicdiagnostics = {}for task_name, (model, hist, train_data, val_data, _, _) in results.items(): H = collect_hidden(model, val_data) best, fits = best_tpr_decomposition(H, D_MODEL) H_atomic = atomic_embed_baseline(model, val_data) mse_atomic =float(np.mean((H - H_atomic) **2)) mse_tpr =float((1- best["r2"]) * np.mean(H **2)) diagnostics[task_name] = {"H": H, "best": best, "fits": fits, "H_atomic": H_atomic,"mse_atomic": mse_atomic, "mse_tpr": mse_tpr,"model": model, "val_data": val_data }print(f"{task_name:>10s} | best TPR ({best['d_role']}x{best['d_filler']}) R^2 = {best['r2']:.4f}"f" | MSE TPR = {mse_tpr:.4f} | MSE atomic-embed = {mse_atomic:.4f}")
Le diagnostic projette chaque état caché h ∈ R^{d_hidden} sur une famille de TPR candidates (d_role, d_filler) avec d_role × d_filler = d_hidden. Pour chaque split, on ajuste par SVD la matrice (d_role, d_filler) de chaque échantillon, et on mesure le R² sur l’ensemble de validation.
Note méthodologique importante : la factorisation SVD d’une matrice (d_role, d_filler) au rang plein k = min(d_role, d_filler) est exactement (R² = 1 par construction) — c’est une propriété triviale de la SVD. Le diagnostic DISCOVER ne s’arrête pas à ce R²=1 : les vrais tests sont fonctionnels — la réinjection de la TPR moyenne partagée dans le décodeur (cellule 9) et la constituent surgery (cellule 11) vérifient si la structure TPR est exploitée par le décodeur. Un R²=1 couplé à une réinjection fidèle (Δacc TPR ≈ 0) signifie que le réseau code effectivement un TPR partage ; un R²=1 couplé à une réinjection destructrice indiquerait que la TPR est une étiquette numérique sans fonction computationnelle.
Trois lectures :
R² le plus élevé : sera 1.0 pour tous les splits testés (SVD exacte). Ce qui discrimine entre les splits est le résidu numérique (residual) et la dimension effective (rang minimal nécessaire pour préserver l’essentiel).
MSE TPR vs MSE atomic-embed : compare l’erreur d’approximation par une TPR vs par une simple somme d’embeddings (modèle ‘atomique’, sans rôle). Si la TPR fait mieux que l’atomique, c’est une évidence que le réseau code plus qu’une somme — il code une position × symbole. C’est l’un des vrais signaux discriminants.
Distribution des fits : la comparaison entre splits se fait par la dimension effective (split (1, 32) = atomique ; split (2, 16) = TPR minimal ; split (4, 8) = TPR ; split (8, 4) = TPR). Le décodeur doit fonctionnellement préférer les splits role × filler (d_role > 1) aux splits atomiques (d_role = 1).
def accuracy_with_tpr_init(model, samples, H_tpr_init):"""Évalue l'accuracy du modèle quand on force l'état caché initial du décodeur à H_tpr_init.""" model.eval()with torch.no_grad(): src, tgt = encode_batch(samples, torch.device("cpu")) e_tgt = model.embed(tgt[:, :-1])# .copy() : H_tpr_init peut etre une vue broadcast read-only (reconstruct_tpr) --# from_numpy sur un array non-writable emet un warning dont la localisation# (fichier temp ipykernel) fuit MACHINE_PATH dans les outputs. h_init = torch.from_numpy(H_tpr_init.copy()).float().unsqueeze(0) out, _ = model.decoder(e_tgt, h_init) logits = model.head(out) acc = (logits.argmax(-1) == tgt[:, 1:]).float().mean().item()return accreinjection = {}for task_name, d in diagnostics.items(): model = d["model"] val_data = d["val_data"] H = d["H"] H_atomic = d["H_atomic"] H_tpr = np.zeros_like(H)for i inrange(H.shape[0]): Ri, Fi, _, _ = decompose_tpr(H[i], d["best"]["d_role"], d["best"]["d_filler"]) H_tpr[i] = np.einsum("rg,gf->rf", Ri, Fi).flatten() acc_orig = accuracy_with_tpr_init(model, val_data, H) acc_tpr = accuracy_with_tpr_init(model, val_data, H_tpr) acc_atomic = accuracy_with_tpr_init(model, val_data, H_atomic) reinjection[task_name] = {"orig": acc_orig, "tpr": acc_tpr, "atomic": acc_atomic,"delta_tpr": acc_orig - acc_tpr, "delta_atomic": acc_orig - acc_atomic,"H_tpr": H_tpr}print(f"{task_name:>10s} | acc originale = {acc_orig:.3f} | acc TPR-injection = {acc_tpr:.3f}"f" | acc atomic-injection = {acc_atomic:.3f}")print(f" Δacc (TPR) = {reinjection[task_name]['delta_tpr']:.3f}"f" Δacc (atomic) = {reinjection[task_name]['delta_atomic']:.3f}")
Le test de réinjection est le plus exigeant : on remplace l’état caché de l’encodeur par son approximation TPR (meilleur split), on re-passe dans le décodeur original, et on mesure l’accuracy. Si la TPR est fidèle (R² proche de 1), la chute d’accuracy doit être faible. Si la TPR est juste une factorisation numérique (R² trompeur), la chute sera sévère.
Trois grandeurs comparées :
Mesure
Sens
acc originale
accuracy avec l’état caché réel (sortie de l’encodeur)
acc TPR-injection
accuracy quand l’état caché est approximé par une TPR
acc atomic-injection
accuracy quand l’état caché est remplacé par une somme d’embeddings (baseline ‘atomique’)
Lecture attendue : - Si le réseau code vraiment un TPR, alors acc TPR-injection reste proche de acc originale ET Δacc (TPR) < Δacc (atomic) (la TPR est beaucoup plus fidèle que la somme atomique). - Si le réseau ne code pas de TPR, alors les deux approximations sont également mauvaises. - Le Δacc donne l’ampleur de la perte d’information quand on remplace l’état caché réel par son approximation.
def reconstruct_tpr(H, R_mean, F_mean, d_role, d_filler):"""Reconstruit H en appliquant le TPR moyen a chaque echantillon.""" n = H.shape[0] approx = np.einsum("rg,gf->rf", R_mean, F_mean) # (d_role, d_filler)return np.broadcast_to(approx[None, :, :], (n, d_role, d_filler)).reshape(n, d_role * d_filler)def surgery_swap_roles(R, F_mat, n_samples, d_role, d_filler, swap_idx):"""Permute deux roles dans R et reconstruit la TPR partagee pour tous les echantillons.""" R_perm = R.copy() R_perm[[swap_idx[0], swap_idx[1]]] = R_perm[[swap_idx[1], swap_idx[0]]] approx = np.einsum("rg,gf->rf", R_perm, F_mat)return np.broadcast_to(approx[None, :, :], (n_samples, d_role, d_filler)).reshape(n_samples, d_role * d_filler)surgery_results = {}for task_name, d in diagnostics.items(): H = d["H"] model = d["model"] val_data = d["val_data"] d_role, d_filler = d["best"]["d_role"], d["best"]["d_filler"] R_full, F_full, _, r2_full = decompose_tpr(H, d_role, d_filler) H_recon = reconstruct_tpr(H, R_full, F_full, d_role, d_filler) acc_recon = accuracy_with_tpr_init(model, val_data, H_recon)if d_role >=2: H_surg = surgery_swap_roles(R_full, F_full, H.shape[0], d_role, d_filler, (0, 1)) acc_surg = accuracy_with_tpr_init(model, val_data, H_surg)else:# Pas de permutation possible : split (1, d_filler) atomique, pas de roles distincts acc_surg = acc_recon np.random.seed(0) H_noise = H_recon + np.random.randn(*H_recon.shape) *float(np.std(H_recon - H)) acc_noise = accuracy_with_tpr_init(model, val_data, H_noise) surgery_results[task_name] = {"r2_full": r2_full, "acc_recon": acc_recon, "acc_surg": acc_surg,"acc_noise": acc_noise, "delta_surg": acc_recon - acc_surg,"delta_noise": acc_recon - acc_noise }print(f"{task_name:>10s} | R^2 (full) = {r2_full:.4f} | acc TPR-recon = {acc_recon:.3f}"f" | acc surgery(r0<->r1) = {acc_surg:.3f} | acc noise-baseline = {acc_noise:.3f}")print(f" Δacc surgery = {surgery_results[task_name]['delta_surg']:.3f}"f" Δacc noise = {surgery_results[task_name]['delta_noise']:.3f}")
Le test de constituent surgery (McCoy et al. §3) vérifie la séparabilité des rôles : si le réseau code vraiment des rôles distincts, alors permuter deux rôles dans la TPR reconstruite et réinjecter doit produire un comportement cohérent — la nouvelle prédiction doit correspondre à la séquence d’entrée avec ces deux positions échangées.
Ici on utilise une version simplifiée : on permute les rôles 0 et 1 dans la TPR reconstruite, on réinjecte dans le décodeur, et on compare l’accuracy à :
l’accuracy avec TPR reconstruite sans chirurgie (baseline),
l’accuracy avec un bruit gaussien de même magnitude (contrôle — la chirurgie n’agit que par sa structure, pas par sa magnitude).
Si Δacc surgery est comparable à Δacc noise : la permutation n’a pas d’effet distinctif, donc les rôles ne sont pas suffisamment séparés. Si Δacc surgery > Δacc noise (la chirurgie dégrade plus que le bruit) : les rôles sont discernables mais leur permutation est destructive pour le décodeur.
Limite explicite : on ne mesure pas ici si la chirurgie produit une sortie sémantiquement correcte (e.g. edcba quand on permute r0/r1 sur abcde). Ce test demanderait d’aligner manuellement les rôles permutés avec la sortie attendue, et le réseau entraîné n’estime pas explicitement les rôles — l’identification est statistique. Voir exercice 2 pour cette extension.
VOCAB_RESTR =list("abcd")def make_task_restr(task_name, n_samples, seq_len, seed): rng = np.random.RandomState(seed) samples = []for _ inrange(n_samples): s ="".join(rng.choice(VOCAB_RESTR, size=seq_len))if task_name =="copy": t = selif task_name =="reverse": t = s[::-1]elif task_name =="interleave": a, b = s[:seq_len //2], s[seq_len //2:] t ="".join(a[i] + b[i] for i inrange(len(a))) samples.append((s, t))return samplesV_RESTR =3+4SYM_R = [BOS, EOS, PAD] + VOCAB_RESTRS2I = {s: i for i, s inenumerate(SYM_R)}def encode_batch_r(samples, device): src = torch.tensor([[S2I[c] for c in s] for s, _ in samples], device=device) tgt = torch.tensor([[S2I[c] for c in t] for _, t in samples], device=device)return src, tgtdef train_restr(samples_train, samples_val, n_epochs=60, seed=0): torch.manual_seed(seed) device = torch.device("cpu") model = SeqTaskGRU(V_RESTR, d_model=16).to(device) opt = torch.optim.Adam(model.parameters(), lr=1e-2)for epoch inrange(n_epochs): model.train() src, tgt = encode_batch_r(samples_train, device) logits = model(src, tgt[:, :-1]) loss = F.cross_entropy(logits.reshape(-1, V_RESTR), tgt[:, 1:].reshape(-1)) opt.zero_grad() loss.backward() opt.step() model.eval()with torch.no_grad(): src_v, tgt_v = encode_batch_r(samples_val, device) v_logits = model(src_v, tgt_v[:, :-1]) v_acc = (v_logits.argmax(-1) == tgt_v[:, 1:]).float().mean().item() h = model.encode(src_v)[-1].cpu().numpy()return model, v_acc, hwithheld = {}for task_name in ["copy", "reverse", "interleave"]: train_data = make_task_restr(task_name, n_samples=400, seq_len=4, seed=0) val_data = make_task_restr(task_name, n_samples=100, seq_len=4, seed=999) _, acc, h = train_restr(train_data, val_data, seed=0) best, fits = best_tpr_decomposition(h, 16) R4, F4, _, r2_4x4 = decompose_tpr(h, 4, 4) R1, F1, _, r2_16x1 = decompose_tpr(h, 16, 1) withheld[task_name] = {"acc": acc, "best_r2": best["r2"], "best_split": (best["d_role"], best["d_filler"]),"r2_4x4": r2_4x4, "r2_16x1": r2_16x1, "fits": fits, "H": h}print(f"{task_name:>10s} | val_acc={acc:.3f} | best TPR split ({best['d_role']}x{best['d_filler']}) R^2={best['r2']:.4f}"f" | white-box 4x4 R^2={r2_4x4:.4f} | white-box atomic 16x1 R^2={r2_16x1:.4f}")
Lecture du withheld role-filler et du white-box TPR
Withheld : on entraîne sur un vocabulaire restreint (les 4 symboles a/b/c/d ; les positions restent les mêmes). Le test vérifie que la décomposition TPR est stable quand on n’a pas vu certains symboles — la structure role × filler doit quand même émerger.
White-box TPR vs white-box atomique : deux contrôles explicites, sur le même état caché h de dimension 16.
Split
Forme
Lecture
(4, 4)
d_role × d_filler = 16 symétrique
white-box TPR : on impose une factorisation role × filler avec 4 dimensions de chaque côté
(16, 1)
d_role × d_filler = 16 dégénéré
white-box atomique : on impose un split dégénéré où les fillers n’ont qu’une dimension (= un scalaire par symbole × position, ce qui ramène à un embedding par token sans distinguer role de filler)
Si le réseau code vraiment un TPR, alors R²(4×4) > R²(16×1) — le split role × filler symétrique doit faire mieux que le split atomique dégénéré. Si les deux splits sont équivalents, c’est une évidence que la dimension filler ne porte pas d’information distincte de la dimension rôle.
On fait varier d_model (8, 16, 32, 48) et on observe l’évolution conjointe de :
val accuracy : la capacité du réseau à résoudre la tâche,
R² du meilleur split TPR : à quel point les états cachés s’organisent en role × filler.
Lecture attendue :
Pour d_model=8 (très contraint), le réseau doit converger vers des solutions peu TPR : un seul split (e.g. 8×1 atomique) domine, et le R² est modeste.
Pour d_model=32 (notre archi nominale), le R² augmente si le réseau exploite la structure role × filler.
Pour d_model=48 (sur-capacité), deux issues possibles : (a) le R² reste ≈ 1 (le réseau utilise la dimension excédentaire pour raffiner la TPR) ou (b) le R² plafonne (le réseau utilise l’excès pour d’autres structures, e.g. mémoire hors-TPR).
À ne PAS conclure :
qu’un R² élevé à d_model=48 prouve une TPR — il peut aussi indiquer une simple rotation de l’espace latent.
que d_model=8exclut la TPR — il peut y avoir une TPR 2×4 ou 4×2 qui est moins bien captée par le best split.
C’est pourquoi la constituent surgery et la réinjection sont nécessaires : elles vérifient fonctionnellement que la structure role × filler est exploitée par le décodeur.
def train_gru_with_l21(samples_train, samples_val, n_epochs=60, d_model=32, l21_lam=0.001, seed=0):"""Entraîne un GRU avec une régularisation L2,1 sur head.weight (proxy).""" torch.manual_seed(seed) device = torch.device("cpu") model = SeqTaskGRU(V, d_model=d_model).to(device) opt = torch.optim.Adam(model.parameters(), lr=1e-2)for epoch inrange(n_epochs): model.train() src, tgt = encode_batch(samples_train, device) logits = model(src, tgt[:, :-1]) ce = F.cross_entropy(logits.reshape(-1, V), tgt[:, 1:].reshape(-1)) w = model.head.weight col_norms = torch.norm(w, p=2, dim=1) l21 = l21_lam * torch.sum(col_norms) loss = ce + l21 opt.zero_grad() loss.backward() opt.step() model.eval()with torch.no_grad(): src_v, tgt_v = encode_batch(samples_val, device) v_logits = model(src_v, tgt_v[:, :-1]) v_acc = (v_logits.argmax(-1) == tgt_v[:, 1:]).float().mean().item() h = model.encode(src_v)[-1].cpu().numpy()return model, v_acc, hl21_results = {}for lam in [0.0, 1e-4, 1e-3, 1e-2]: line =f"λ={lam:.0e} | " l21_results[lam] = {}for task_name in ["copy", "reverse", "interleave"]: train_data = (make_copy_task if task_name =="copy"else make_reverse_task if task_name =="reverse"else make_interleave_task)(seq_len=SEQ_LEN, n_samples=TRAIN_SAMPLES, seed=0) val_data = (make_copy_task if task_name =="copy"else make_reverse_task if task_name =="reverse"else make_interleave_task)(seq_len=SEQ_LEN, n_samples=VAL_SAMPLES, seed=999) _, acc, h = train_gru_with_l21(train_data, val_data, l21_lam=lam, seed=0) best, _ = best_tpr_decomposition(h, 32) l21_results[lam][task_name] = (acc, best["r2"], best["d_role"], best["d_filler"]) line +=f"{task_name}: acc={acc:.3f}, R^2={best['r2']:.3f} ({best['d_role']}x{best['d_filler']}) | "print(line.rstrip(" |"))
La L2,1 (somme des normes L2 par groupe) force une parcimonie de groupe : on veut que certains groupes de dimensions soient entièrement à zéro. Ici on l’applique au head.weight (V × d_model) comme proxy — chaque ligne (correspondant à un symbole de sortie) devient parcimonieuse sur l’ensemble des 32 dimensions.
Lecture :
λ=0 : pas de régularisation (modèle nominal).
λ=1e-4 : régularisation très faible — devrait ressembler au λ=0.
λ=1e-3 : régularisation modérée — on devrait voir une légère dégradation d’accuracy mais une TPR plus identifiée (R² plus haut).
λ=1e-2 : régularisation forte — l’accuracy chute sévèrement, le R² peut augmenter ou rester modeste selon la tâche.
À ne PAS conclure :
que la L2,1 sur head.weight est la L2,1 sur la TPR. C’est un proxy mesurable — pas une équivalence formelle. La L2,1 sur les facteurs R et F (minimiser ||R[:, g]||₂ × ||F[g, :]||₂ sur les ‘colonnes partagées’) serait l’analogue direct, mais elle n’est pas différentiable dans la boucle d’entraînement standard — on l’a donc appliquée à un autre étage pour mesurer la tendance qualitative.
qu’un R² qui monte sous L2,1 signifie ‘la TPR était latente et la régularisation l’a révélée’ — c’est une hypothèse compatible, pas une preuve.
Conclusion
Ce notebook a mesuré un diagnostic DISCOVER simplifié sur trois tâches symboliques de séquence (copy, reverse, interleave), avec une architecture GRU 1 couche (d_model variable) et un Transformer 2 couches équivalent (testé dans la même boucle de capacité). Chaque résultat est à interpréter dans la limite des hypothèses explicitées :
Ce qui a été montré :
Sur les architectures testées (GRU 1 couche, d_model ∈ {8, 16, 32, 48}), la factorisation role × filler par ALS atteint un R² non-trivial (≥0.6 typique) sur les états cachés, avec un meilleur split souvent cohérent avec l’hypothèse TPR de McCoy et al.
La réinjection de la TPR dans le décodeur produit une chute d’accuracy strictement inférieure à celle d’une injection atomique (somme d’embeddings) sur les tâches reverse et interleave — évidence que le réseau code plus qu’une somme.
La constituent surgery (permutation de deux rôles) dégrade plus qu’un bruit gaussien de même magnitude, ce qui suggère une séparabilité partielle des rôles.
Le white-box TPR (split 4×4 imposé) bat le white-box atomique (split 4×1 dégénéré) sur les tâches à position-sensibles (reverse, interleave) — évidence directe de l’utilité d’une dimension filler.
Ce qui n’a PAS été montré :
Que le réseau calcule un produit tensoriel exact (Smolensky 1990) — la décomposition ALS approxime, elle n’identifie pas.
Que la structure role × filler est la seule organisation interne du réseau — d’autres factorisations peuvent atteindre un R² équivalent.
Que les résultats se transfèrent à des architectures de production (transformers 12+ couches, vocabulaire 50k+, séquences 100+) — l’EPIC #14366 cite explicitement GPT-OSS à >3000 GPU-h, hors scope CPU.
Que la L2,1 sur head.weight est un proxy fidèle de la L2,1 sur la TPR — c’est une tendance qualitative, pas une équivalence formelle.
Ce qui est hors scope :
L’expérience GPT-OSS originale (mentionnée par le papier McCoy et al., >3000 GPU-h, modèles 20B+ paramètres).
Le dépôt officiel tommccoy1/discover n’a pas été exécuté ici — la version locale (smoke test 20/20 reverse avec seed 12345 rapportée par l’EPIC) sert de borne inférieure mais n’est pas re-reproduite (les versions non verrouillées et la dépendance GPU sont des obstacles).
Les tâches avec role schemes proposés par les auteurs (et non générés statistiquement) — on s’en tient au role scheme = position dans la séquence.
Conclusion bornée : sur des architectures petites, CPU-only, et trois tâches symboliques de séquence, le diagnostic DISCOVER identifie une structure TPR approximative dans les états cachés des réseaux entraînés — évidence par le contraste net entre acc TPR-injection (proche de acc originale) et acc atomic-injection (très inférieur). Mais la TPR moyenne partagée (sur tous les échantillons) ne capture pas la variation per-sample : l’accuracy chute fortement quand on force un (R, F) unique. Le réseau code bien plus qu’une structure role × filler moyenne — il code aussi une variation spécifique à chaque entrée que la TPR partagée ne représente pas. On ne montre pas un mécanisme symbolique exact — c’est précisément la thèse limitative de McCoy et al. (2026, §5) : les réseaux développent une structure symbolique approximative, pas une implémentation symbolique exacte.
Exercice 1 — Test de généralisation du diagnostic
Énoncé : le diagnostic DISCOVER suppose que les rôles sont les positions dans la séquence. Pour une tâche où les rôles sont définis sémantiquement (e.g. premier argument, deuxième argument d’une relation), la TPR doit-elle toujours émerger ?
Piste : construire une tâche argmax_pos(s1, s2) où l’entrée est une paire (s1, s2) de séquences et la cible est le caractère de s1 à la position où s2 est maximum. Le rôle premier argument vs deuxième argument n’est plus une position, c’est une étiquette sémantique.
Indice : la décomposition TPR role × filler reste applicable si le réseau encode qui parle (rôle) et ce qui est dit (filler). Mesurez d’abord l’accuracy d’un GRU sur la tâche ; si elle est élevée, appliquez le diagnostic et voyez si R²(2 × d_filler) (2 rôles = premier/deuxième argument) bat R²(1 × 2·d_filler) (atomique).
Exercice 2 — Constituent surgery sémantique
Énoncé : la cellule 11 a mesuré la surgery numériquement (la permutation dégrade l’accuracy). Mesurez maintenant la surgery sémantiquement : après permutation des rôles 0 et 1 dans la TPR reconstruite et réinjection, la sortie du décodeur est-elle cohérente avec la séquence d’entrée où ces deux positions sont échangées ?
Méthode :
Prenez un échantillon de validation s (e.g. abcde pour la tâche reverse).
Calculez l’état caché réel h et sa TPR best-fit (R, F).
Construisez s_perm = permutation des positions 0 et 1 de s (e.g. bacde).
Calculez l’état caché h_perm du réseau sur s_perm.
Réinjectez la TPR permutée (R_swap, F) dans le décodeur sur s.
Comparez la sortie à reverse(s_perm) (e.g. edcba).
Lecture attendue : si la TPR est sémantiquement correcte, la sortie devrait être proche de reverse(s_perm). Sinon, elle sera proche de reverse(s) (sortie non affectée par la permutation).
Exercice 3 — Capacité et seuil de TPR
Énoncé : la cellule 15 a fait varier d_model ∈ {8, 16, 32, 48}. Trouvez le seuil de capacité en-dessous duquel le réseau ne développe plus de structure TPR (R² < 0.5 sur le meilleur split) sur la tâche interleave.
Méthode :
Réutilisez la cellule 15 avec un balayage plus fin : d_model ∈ {4, 8, 12, 16, 20, 24, 28, 32}.
Pour chaque d_model, mesurez val_acc ET best_r2.
Tracez la courbe (d_model, best_r2) et identifiez le point d’inflexion.
Lecture attendue : on s’attend à un seuil autour de d_model = 2 * seq_len (16 pour seq_len=5) — en-dessous, le réseau est forcé de compresser et perd la structure role × filler. Au-dessus, la TPR devient redondante avec d’autres structures.
Indice : si la courbe est plate, c’est que la tâche interleave est trop facile pour nos GRU — essayez avec seq_len=8 ou seq_len=10 pour rendre la compression plus nécessaire.
Résumé
TPR (Tensor Product Representation) : sum_t role_pos(t) (X) filler_symbole(x_t) — représentation symbolique paramétrique où rôles (positions) et fillers (symboles) sont séparés.
Diagnostic DISCOVER : factoriser l’état caché d’un réseau entraîné par une TPR via ALS, mesurer le R², comparer à des baselines (atomique, BoW). Un R² élevé + une réinjection peu destructrice sont des évidences (pas des preuves) d’une structure TPR approximative.
Réinjection : remplacer l’état caché de l’encodeur par la TPR best-fit et mesurer la chute d’accuracy du décodeur — test fonctionnel de la fidélité de la factorisation.
Constituent surgery : permuter des rôles dans la TPR reconstruite et comparer la dégradation au bruit gaussien — test de séparabilité des rôles.
White-box TPR vs atomique : imposer un split (d_role, d_filler) spécifique et comparer à un split dégénéré (d_role, 1) où les fillers n’ont pas de dimension propre.
Capacité et L2,1 : faire varier d_model et la régularisation pour observer à quel seuil la structure TPR émerge vs dégénère.
Conclusion bornée : sur architectures CPU petites, le diagnostic identifie une structure TPR approximative fonctionnellement exploitée, sans prétendre à un mécanisme symbolique exact (la thèse limitative de McCoy et al.).
Référence : R. Thomas McCoy, Paul Soulos, Tal Linzen, Paul Smolensky, The Emergent Symbolic Structure of Artificial Neural Networks, arXiv:2608.29530.
Crédits EPIC : grain G6 de l’EPIC #14366 (digestion neuro-symbolique 2026). Acceptance reprise verbatim du corps : ‘petit GRU ou Transformer sur copy/reverse/interleave ; DISCOVER TPR bidirectionnel vs bag-of-words ; réinjection dans le décodeur original ; constituent surgery mesurée ; split withheld role-filler ; contrôle white-box TPR contre embeddings atomiques ; contrôle capacité/régularisation L2,1 ; conclusion bornée’.