SL-13 : DISCOVER léger — diagnostic de structure TPR émergente

Phase 5 – Neuro-symbolique | Précédent : SL-12b (synthèse spectrale) –> SL-13 –> Suivant : SL-12 (difflogic) · SL-13b (TPR x SAE) → | Index


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 :

  1. Entraîner le réseau sur la tâche cible ;
  2. Approximer ses états cachés par une TPR (moindres carrés, contrainte de rang) ;
  3. Réinjecter l’approximation TPR dans le décodeur original et mesurer la dégradation ;
  4. 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

  1. Vocabulaire TPR : rôles, fillers, produit tensoriel, projection affine, contrainte de rang
  2. Tâches symboliques : copy / reverse / interleave, générateur paramétré
  3. Architecture GRU : entraînement court CPU sur les trois tâches, accuracy sur withheld
  4. Diagnostic DISCOVER : approximation TPR par moindres carrés, ranks, MSE, comparaison atomique vs BoW
  5. Réinjection décodeur : remplacement de l’état caché par l’approximation TPR, mesure de la chute d’accuracy
  6. Constituent surgery : permutation d’un rôle, mesure du transfert ; withheld role-filler split
  7. 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
  8. 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 itertools
import time
from collections import defaultdict

import numpy as np
import matplotlib
import matplotlib.pyplot as plt

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

print(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 is not None:
        out = np.concatenate([out, bias])
    return out


def 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_filler
    if y.shape[-1] != dim + bias_dim:
        raise ValueError(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 is not None else k_full
    k = max(k, 1)
    R_list = []
    F_list = []
    resid_norms = []
    yss_total = 0.0
    rss_total = 0.0
    for i in range(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, r2


np.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 :

TPR(x) = sum_{t=0}^{n-1} role_pos(t) (X) filler_symbole(x_t)  (+ biais)

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] + VOCAB
SYM2IDX = {s: i for i, s in enumerate(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 _ in range(n_samples):
        s = "".join(rng.choice(VOCAB, size=seq_len))
        samples.append((s, s))
    return samples


def make_reverse_task(seq_len=5, n_samples=200, seed=0):
    rng = np.random.RandomState(seed)
    samples = []
    for _ in range(n_samples):
        s = "".join(rng.choice(VOCAB, size=seq_len))
        samples.append((s, s[::-1]))
    return samples


def 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 _ in range(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 in range(seq_len))
        samples.append((src, tgt))
    return samples


for 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}")
Vocabulaire : ['<bos>', '<eos>', '<pad>', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'] (V = 11)
  copy: [('gdeg', 'gdeg'), ('chee', 'chee'), ('gbcg', 'gbcg')]
  reverse: [('gdeg', 'gedg'), ('chee', 'eehc'), ('gbcg', 'gcbg')]
  interleave: [('gdegchee', 'gcdheege'), ('gbcgcche', 'gcbcchge'), ('dhhcfebh', 'dfhehbch')]

Lecture des trois tâches symboliques

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_model

    def encode(self, x):
        e = self.embed(x)
        _, h = self.encoder(e)
        return h

    def forward(self, src, tgt):
        h = self.encode(src)
        e_tgt = self.embed(tgt)
        out, _ = self.decoder(e_tgt, h[-1:])
        return self.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, tgt


def 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 in range(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, history


TRAIN_SAMPLES = 800
VAL_SAMPLES = 200
SEQ_LEN = 5
N_EPOCHS = 60
D_MODEL = 32

results = {}
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}")
      copy | epochs=60 | train_time=3.22s | val_acc=0.835
   reverse | epochs=60 | train_time=1.41s | val_acc=0.856
interleave | epochs=60 | train_time=2.25s | val_acc=0.395

Lecture de l’entraînement

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 in range(1, d_hidden + 1):
        if d_hidden % d_role != 0:
            continue
        d_filler = d_hidden // d_role
        if 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, fits


def bag_of_words_baseline(samples):
    """BoW = comptage normalisé des symboles. Baseline 'ordre effacé'."""
    bow = np.zeros((len(samples), 8))
    for i, (s, _) in enumerate(samples):
        for c in s:
            bow[i, VOCAB.index(c)] += 1
    bow = bow / bow.sum(axis=1, keepdims=True).clip(min=1)
    return bow


def 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 in range(src.shape[0]):
        ids = src[i].cpu().numpy()
        H_atomic[i] = embed[ids].sum(axis=0)
    return H_atomic


diagnostics = {}
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}")
      copy | best TPR (1x32) R^2 = 1.0000 | MSE TPR = 0.0000 | MSE atomic-embed = 7.2682
   reverse | best TPR (2x16) R^2 = 1.0000 | MSE TPR = 0.0000 | MSE atomic-embed = 8.4553
interleave | best TPR (1x32) R^2 = 1.0000 | MSE TPR = 0.0000 | MSE atomic-embed = 22.5828

Lecture du diagnostic DISCOVER (premier passage)

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 :

  1. 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).
  2. 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.
  3. 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 acc


reinjection = {}
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 in range(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}")
      copy | acc originale = 0.835 | acc TPR-injection = 0.835 | acc atomic-injection = 0.140
           Δacc (TPR) = 0.000   Δacc (atomic) = 0.695
   reverse | acc originale = 0.856 | acc TPR-injection = 0.856 | acc atomic-injection = 0.112
           Δacc (TPR) = 0.000   Δacc (atomic) = 0.744
interleave | acc originale = 0.395 | acc TPR-injection = 0.395 | acc atomic-injection = 0.085
           Δacc (TPR) = 0.000   Δacc (atomic) = 0.310

Lecture de la réinjection dans le décodeur

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}")
      copy | R^2 (full) = 1.0000 | acc TPR-recon = 0.132 | acc surgery(r0<->r1) = 0.132 | acc noise-baseline = 0.115
           Δacc surgery = 0.000   Δacc noise = 0.017
   reverse | R^2 (full) = 1.0000 | acc TPR-recon = 0.131 | acc surgery(r0<->r1) = 0.125 | acc noise-baseline = 0.135
           Δacc surgery = 0.006   Δacc noise = -0.004
interleave | R^2 (full) = 1.0000 | acc TPR-recon = 0.123 | acc surgery(r0<->r1) = 0.123 | acc noise-baseline = 0.122
           Δacc surgery = 0.000   Δacc noise = 0.002

Lecture de la constituent surgery

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 _ in range(n_samples):
        s = "".join(rng.choice(VOCAB_RESTR, size=seq_len))
        if task_name == "copy":
            t = s
        elif 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 in range(len(a)))
        samples.append((s, t))
    return samples


V_RESTR = 3 + 4
SYM_R = [BOS, EOS, PAD] + VOCAB_RESTR
S2I = {s: i for i, s in enumerate(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, tgt


def 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 in range(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, h


withheld = {}
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}")
      copy | val_acc=0.870 | best TPR split (1x16) R^2=1.0000 | white-box 4x4 R^2=1.0000 | white-box atomic 16x1 R^2=1.0000
   reverse | val_acc=0.917 | best TPR split (1x16) R^2=1.0000 | white-box 4x4 R^2=1.0000 | white-box atomic 16x1 R^2=1.0000
interleave | val_acc=0.817 | best TPR split (1x16) R^2=1.0000 | white-box 4x4 R^2=1.0000 | white-box atomic 16x1 R^2=1.0000

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.

cap_results = {}
for d_model in [8, 16, 32, 48]:
    cap_acc = {}
    for task_name in ["copy", "reverse", "interleave"]:
        torch.manual_seed(0)
        device = torch.device("cpu")
        model = SeqTaskGRU(V, d_model=d_model).to(device)
        opt = torch.optim.Adam(model.parameters(), lr=1e-2)
        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)
        for _ in range(N_EPOCHS):
            model.train()
            src, tgt = encode_batch(train_data, 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(val_data, 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()
        best, _ = best_tpr_decomposition(h, d_model)
        cap_acc[task_name] = (v_acc, best["r2"], best["d_role"], best["d_filler"])
    cap_results[d_model] = cap_acc
    line = f"d_model={d_model:>3d} | " + " | ".join(
        f"{t}: acc={cap_acc[t][0]:.3f}, R^2={cap_acc[t][1]:.3f} ({cap_acc[t][2]}x{cap_acc[t][3]})"
        for t in ["copy", "reverse", "interleave"])
    print(line)
d_model=  8 | copy: acc=0.385, R^2=1.000 (1x8) | reverse: acc=0.369, R^2=1.000 (1x8) | interleave: acc=0.216, R^2=1.000 (1x8)
d_model= 16 | copy: acc=0.509, R^2=1.000 (1x16) | reverse: acc=0.604, R^2=1.000 (1x16) | interleave: acc=0.302, R^2=1.000 (1x16)
d_model= 32 | copy: acc=0.835, R^2=1.000 (1x32) | reverse: acc=0.856, R^2=1.000 (2x16) | interleave: acc=0.395, R^2=1.000 (1x32)
d_model= 48 | copy: acc=0.850, R^2=1.000 (1x48) | reverse: acc=0.886, R^2=1.000 (1x48) | interleave: acc=0.443, R^2=1.000 (2x24)

Lecture du contrôle de capacité

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=8 exclut 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 in range(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, h


l21_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(" |"))
λ=0e+00 | copy: acc=0.835, R^2=1.000 (1x32) | reverse: acc=0.856, R^2=1.000 (2x16) | interleave: acc=0.395, R^2=1.000 (1x32)
λ=1e-04 | copy: acc=0.836, R^2=1.000 (1x32) | reverse: acc=0.854, R^2=1.000 (16x2) | interleave: acc=0.394, R^2=1.000 (1x32)
λ=1e-03 | copy: acc=0.844, R^2=1.000 (1x32) | reverse: acc=0.861, R^2=1.000 (16x2) | interleave: acc=0.389, R^2=1.000 (1x32)
λ=1e-02 | copy: acc=0.816, R^2=1.000 (2x16) | reverse: acc=0.841, R^2=1.000 (16x2) | interleave: acc=0.355, R^2=1.000 (2x16)

Lecture de la régularisation L2,1

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é :

  1. 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.
  2. 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.
  3. 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.
  4. 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é :

  1. Que le réseau calcule un produit tensoriel exact (Smolensky 1990) — la décomposition ALS approxime, elle n’identifie pas.
  2. Que la structure role × filler est la seule organisation interne du réseau — d’autres factorisations peuvent atteindre un R² équivalent.
  3. 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.
  4. 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 :

  1. Prenez un échantillon de validation s (e.g. abcde pour la tâche reverse).
  2. Calculez l’état caché réel h et sa TPR best-fit (R, F).
  3. Construisez s_perm = permutation des positions 0 et 1 de s (e.g. bacde).
  4. Calculez l’état caché h_perm du réseau sur s_perm.
  5. Réinjectez la TPR permutée (R_swap, F) dans le décodeur sur s.
  6. 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 :

  1. Réutilisez la cellule 15 avec un balayage plus fin : d_model ∈ {4, 8, 12, 16, 20, 24, 28, 32}.
  2. Pour chaque d_model, mesurez val_acc ET best_r2.
  3. 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’.

Retour au sommet