diff --git a/MyIA.AI.Notebooks/SymbolicAI/SymbolicLearning/README.md b/MyIA.AI.Notebooks/SymbolicAI/SymbolicLearning/README.md
index 561f7d6819..dadf09ea7e 100644
--- a/MyIA.AI.Notebooks/SymbolicAI/SymbolicLearning/README.md
+++ b/MyIA.AI.Notebooks/SymbolicAI/SymbolicLearning/README.md
@@ -4,9 +4,9 @@
## Présentation
@@ -17,7 +17,7 @@ Le premier notebook pose les bases : représentation d'hypothèses comme conjonc
Les notebooks SL-7 à SL-9 ouvrent ensuite vers des méthodes contemporaines : SL-7 introduit le neuro-symbolique (T-norms différentiables, Logic Tensor Networks, DeepProbLog) ; SL-8 outille la découverte de règles sur knowledge graphs réels avec rdflib et AMIE rule mining ; SL-9 boucle LLM et vérification symbolique pour fiabiliser le raisonnement formel guidé par modèles de langage.
-Enfin, deux notebooks concluent la série : SL-10 change de paradigme avec l'apprentissage *actif* (l'algorithme L* d'Angluin interroge un oracle au lieu de subir un échantillon, et apprend des automates finis avec garanties de minimalité) ; SL-11 est le capstone qui assemble toute la série en un pipeline neuro-symbolique de bout en bout — du texte brut aux faits découverts, avec un LLM réel (Gemini 3.5 Flash) aux deux extrémités et le symbolique comme colonne vertébrale. SL-12, ajouté à la série, explore un autre registre du neuro-symbolique : les *réseaux de portes logiques differentiables* (difflogic, Petersen NeurIPS 2022) — un modèle qui apprend des combinaisons de portes logiques par descente de gradient, puis se discretise en un circuit 100% booleen, interpretable-par-construction et ultra-rapide a l'inference. SL-12b prolonge SL-12 par l'autre versant du registre discret : la **synthèse logique spectrale** (Pavlov, arXiv 2601.13953, digestion EPIC #14366 grain G2) — Fourier booléen exact (FWHT), poids ternaires de PTF, routage Sinkhorn sans surclaim, quantification puis recherche discrète (Metropolis, parallel tempering) avec oracle exact et vérité terrain exhaustive.
+Enfin, deux notebooks concluent la série : SL-10 change de paradigme avec l'apprentissage *actif* (l'algorithme L* d'Angluin interroge un oracle au lieu de subir un échantillon, et apprend des automates finis avec garanties de minimalité) ; SL-11 est le capstone qui assemble toute la série en un pipeline neuro-symbolique de bout en bout — du texte brut aux faits découverts, avec un LLM réel (Gemini 3.5 Flash) aux deux extrémités et le symbolique comme colonne vertébrale. SL-12, ajouté à la série, explore un autre registre du neuro-symbolique : les *réseaux de portes logiques differentiables* (difflogic, Petersen NeurIPS 2022) — un modèle qui apprend des combinaisons de portes logiques par descente de gradient, puis se discretise en un circuit 100% booleen, interpretable-par-construction et ultra-rapide a l'inference. SL-12b prolonge SL-12 par l'autre versant du registre discret : la **synthèse logique spectrale** (Pavlov, arXiv 2601.13953, digestion EPIC #14366 grain G2) — Fourier booléen exact (FWHT), poids ternaires de PTF, routage Sinkhorn sans surclaim, quantification puis recherche discrète (Metropolis, parallel tempering) avec oracle exact et vérité terrain exhaustive. SL-13 ferme la phase 5 par un **diagnostic DISCOVER léger** (McCoy et al. arXiv:2608.29530, digestion EPIC #14366 grain G6) — un GRU/Transformer 1-2 couches entraîné sur copy/reverse/interleave est-il porteur d'une structure *Tensor Product Representation* (TPR) approximative ? Factorisation role × filler par moindres carrés alternés, réinjection dans le décodeur, constituent surgery, contrôle *white-box TPR vs embeddings atomiques*, balayage de capacité et régularisation L2,1 — le tout CPU-only, avec une conclusion bornée (la structure est *approximative*, pas une implémentation symbolique exacte). SL-13 ferme la phase 5 par un **diagnostic DISCOVER léger** (McCoy et al. arXiv:2608.29530, digestion EPIC #14366 grain G6) — un GRU/Transformer 1-2 couches entraîné sur copy/reverse/interleave est-il porteur d'une structure *Tensor Product Representation* (TPR) approximative ? Factorisation role × filler par moindres carrés alternés, réinjection dans le décodeur, constituent surgery, contrôle *white-box TPR vs embeddings atomiques*, balayage de capacité et régularisation L2,1 — le tout CPU-only, avec une conclusion bornée (la structure est *approximative*, pas une implémentation symbolique exacte).
## À qui s'adresse cette série
@@ -27,7 +27,7 @@ Enfin, deux notebooks concluent la série : SL-10 change de paradigme avec l'app
Les notebooks (~20h05 total — 13 Python + 8 jumeaux C# marathon parité #4956 + 1 compagnon Lean natif) se répartissent ainsi :
-- **Track Python** : Python 3.10+ standard library suffit, sauf SL-3 (scikit-learn + numpy pour la comparaison RBL / information mutuelle), SL-4 (Popper + `janus_swi` + SWI-Prolog, kernel Linux/WSL), SL-6 (moteurs ILP réels : SWI-Prolog, Popper, Lernd), SL-7 (`torch` + `LTNtorch` pour les Logic Tensor Networks), SL-8 (`rdflib` + `clingo` pour les knowledge graphs et l'ASP), SL-12 (difflogic + torch) et SL-12b (numpy + matplotlib pour la synthèse spectrale) ; SL-9 et SL-11 acceptent une clé OpenRouter optionnelle (fichier `.env`) pour des appels LLM réels, avec un simulateur déterministe en repli.
+- **Track Python** : Python 3.10+ standard library suffit, sauf SL-3 (scikit-learn + numpy pour la comparaison RBL / information mutuelle), SL-4 (Popper + `janus_swi` + SWI-Prolog, kernel Linux/WSL), SL-6 (moteurs ILP réels : SWI-Prolog, Popper, Lernd), SL-7 (`torch` + `LTNtorch` pour les Logic Tensor Networks), SL-8 (`rdflib` + `clingo` pour les knowledge graphs et l'ASP), SL-12 (difflogic + torch), SL-12b (numpy + matplotlib pour la synthèse spectrale) et SL-13 (`torch` CPU + numpy pour le diagnostic DISCOVER) ; SL-9 et SL-11 acceptent une clé OpenRouter optionnelle (fichier `.env`) pour des appels LLM réels, avec un simulateur déterministe en repli.
- **Compagnon Lean** : SL-1b s'exécute sur le kernel Lean 4 `lean4-wsl` (lake `learning_theory_lean`, Mathlib).
- **Jumeaux C#** : les 8 jumeaux (.NET Interactive 1.4+, `Microsoft.dotnet-interactive`) sont des ré-implémentations from-scratch en C# pur des mêmes algorithmes, sans dépendance externe ML.
- **Niveau requis** : une familiarité avec la logique propositionnelle suffit pour SL-1 à SL-6 et SL-10 ; SL-7, SL-9 et SL-11 supposent une intuition des réseaux de neurones et des LLMs.
@@ -74,16 +74,17 @@ Cette série montre que les deux approches ne s'opposent pas — elles se **comp
10. **Implémenter** l'algorithme L* d'Angluin : table d'observation, requêtes d'appartenance et d'équivalence, apprentissage actif d'automates minimaux
11. **Assembler** un pipeline neuro-symbolique complet : extraction LLM, oracle de validation type, mining de règles, chaînage avant avec provenance, et confrontation LLM vs KG
12. **Calculer** le spectre de Fourier exact d'une fonction booléenne (FWHT), le distinguer des poids ternaires d'un PTF, et conduire une recherche discrète bornée (Metropolis, parallel tempering) contre un oracle exact
+13. **Diagnostiquer** la structure interne d'un GRU 1-2 couches entraîné sur tâches symboliques de séquence via DISCOVER (TPR par ALS, R² sur l'approximation, réinjection decoder, constituent surgery, contrôle *white-box TPR vs embeddings atomiques*), et **conclure de façon bornée** sur la nature approximative vs exacte de la structure TPR observée
## Vue d'ensemble
| Statistique | Valeur |
|-------------|--------|
-| Notebooks | 22 (13 Python canoniques + 8 jumeaux C# marathon parité #4956 + 1 compagnon Lean natif) |
-| Exercices (table de pioche) | 52 |
+| Notebooks | 23 (14 Python canoniques + 8 jumeaux C# marathon parité #4956 + 1 compagnon Lean natif) |
+| Exercices (table de pioche) | 55 |
| Kernel | Python 3 + .NET Interactive (jumeaux C#) + lean4-wsl (compagnon Lean) |
-| Durée estimée | ~1205 min (~20 h 05 : Python 12 h 35 + compagnon Lean 40 min + jumeaux C# 6 h 50) |
-| Prérequis | Python 3.10+ (standard library + sklearn + numpy pour SL-3 seulement ; SL-4 relève de SWI-Prolog/Popper via kernel Linux/WSL ; rdflib+clingo pour SL-8 ; torch+LTNtorch pour SL-7 ; difflogic+torch pour SL-12 ; numpy+matplotlib pour SL-12b ; clé OpenRouter optionnelle pour SL-9/SL-11) + .NET Interactive 1.4+ pour les 8 jumeaux C# + kernel lean4-wsl (lake `learning_theory_lean`) pour SL-1b |
+| Durée estimée | ~1230 min (~20 h 30 : Python 13 h 00 + compagnon Lean 40 min + jumeaux C# 6 h 50) |
+| Prérequis | Python 3.10+ (standard library + sklearn + numpy pour SL-3 seulement ; SL-4 relève de SWI-Prolog/Popper via kernel Linux/WSL ; rdflib+clingo pour SL-8 ; torch+LTNtorch pour SL-7 ; difflogic+torch pour SL-12 ; numpy+matplotlib pour SL-12b ; torch CPU + numpy pour SL-13 ; clé OpenRouter optionnelle pour SL-9/SL-11) + .NET Interactive 1.4+ pour les 8 jumeaux C# + kernel lean4-wsl (lake `learning_theory_lean`) pour SL-1b |
## Parcours d'apprentissage
@@ -95,7 +96,7 @@ flowchart TD
P2["Phase 2 · Guidé par la connaissance
SL-2/3 · EBL · RBL · déterminations
la connaissance accélère"]
P3["Phase 3 · Programmes logiques
SL-4/5 · FOIL · résolution inverse · Progol
clauses Horn + récursion"]
P4["Phase 4 · Moteurs ILP réels
SL-6 · Aleph · Metagol · Popper · ∂ILP
4 machineries comparées"]
- P5["Phase 5 · Neuro-symbolique
SL-7/8/9 · T-norms · KG mining · boucle LLM
différentiable + vérifiable"]
+ P5["Phase 5 · Neuro-symbolique
SL-7/8/9/13 · T-norms · KG mining · boucle LLM · diagnostic TPR
différentiable + vérifiable + structurelle"]
P6(("Phase 6 · Capstone
SL-10/11/12/12b · L* actif · pipeline 6 étages · portes differentiables · synthèse spectrale
LLM ↔ logique en boucle"))
P1 -->|"bruit + disjonction
non représentables"| P2
P2 -->|"passer des attributs
aux programmes"| P3
@@ -122,9 +123,9 @@ Après avoir construit FOIL et Progol *de zéro*, SL-6 met quatre moteurs ILP **
*Les quatre moteurs ILP (Aleph, Metagol, Popper, ∂ILP) sont comparés textuellement dans la cellule 21 du notebook — chacun y apprend le même concept `ancestor/2` par une machinerie distincte (recherche symbolique exacte, métarègles avec invention de prédicats, contraintes ASP, descente de gradient), et leurs clauses apprises, scores et temps sont tabulés. Aucune figure illustrative n'est embarquée ici : la comparaison vit dans le notebook, où chaque moteur peut être inspecté dans son contexte d'exécution.*
-### Phase 5 : Intégration neuro-symbolique (SL-7 à SL-9, ~160 min)
+### Phase 5 : Intégration neuro-symbolique (SL-7 à SL-9, et SL-13, ~185 min)
-Cette phase explore les méthodes contemporaines à l'intersection du symbolique et du connexionniste. SL-7 introduit les T-norms différentiables, les prédicats neuronaux et les Logics Tensor Networks qui rendent la logique opérationnelle dans un gradient descent. SL-8 passe à l'échelle avec le rule mining réel sur des knowledge graphs construits avec rdflib (AMIE, complétion de graphes). SL-9 ferme la boucle avec LLMs : extraction de règles depuis du texte naturel, vérification symbolique des sorties, et boucles de rétroaction pour fiabiliser le raisonnement.
+Cette phase explore les méthodes contemporaines à l'intersection du symbolique et du connexionniste. SL-7 introduit les T-norms différentiables, les prédicats neuronaux et les Logics Tensor Networks qui rendent la logique opérationnelle dans un gradient descent. SL-8 passe à l'échelle avec le rule mining réel sur des knowledge graphs construits avec rdflib (AMIE, complétion de graphes). SL-9 ferme la boucle avec LLMs : extraction de règles depuis du texte naturel, vérification symbolique des sorties, et boucles de rétroaction pour fiabiliser le raisonnement. **SL-13 complète la phase 5** en se demandant si la structure TPR (Tensor Product Representation, `sum_t role_pos(t) ⊗ filler_symbole(x_t)`) émerge *organiquement* dans un GRU 1-2 couches entraîné sur des tâches symboliques de séquence. Le diagnostic DISCOVER (McCoy et al., arXiv:2608.29530) procède par factorisation role × filler (ALS), réinjection dans le décodeur, constituent surgery et contrôle white-box TPR versus embeddings atomiques — sur architectures CPU petites, avec une conclusion *bornée* : la structure est approximative et fonctionnellement exploitée, mais pas une implémentation symbolique exacte.
### Phase 6 : Apprentissage actif et capstone (SL-10 à SL-12b, ~270 min)
@@ -219,6 +220,9 @@ Modalite de la séance : chaque groupe choisit **un exercice** dans la table ci-
| 50 | [SL-12b](SL-12b-SpectralLogicSynthesis.ipynb) | Ex. 1 — Spectre de EXACT2 (poids de Hamming) | Degré Fourier contre degré PTF : lequel ment sur la complexité ? |
| 51 | [SL-12b](SL-12b-SpectralLogicSynthesis.ipynb) | Ex. 2 — Sinkhorn sur ligne uniforme | Une permutation unique au coût minimal prouve-t-elle que le routage « a choisi » ? |
| 52 | [SL-12b](SL-12b-SpectralLogicSynthesis.ipynb) | Ex. 3 — Instance séparant SA et PT | Qu'est-ce qui rend une instance « dure » pour une seule température ? |
+| 53 | [SL-13](SL-13-Discover-TPR.ipynb) | Ex. 1 — Tâche à rôles sémantiques (argmax_pos) | Quand les rôles ne sont plus des positions mais des étiquettes, la TPR émerge-t-elle encore ? |
+| 54 | [SL-13](SL-13-Discover-TPR.ipynb) | Ex. 2 — Constituent surgery sémantique | La permutation des rôles 0↔1 dans la TPR produit-elle la sortie attendue pour la séquence aux rôles permutés ? |
+| 55 | [SL-13](SL-13-Discover-TPR.ipynb) | Ex. 3 — Seuil de capacité et TPR | Quel d_model marque le point d'inflexion entre compression forcée et TPR non-nécessaire ? |
Note : dans SL-7, le premier exercice de la numérotation interne est un exemple guide ; les exercices à piocher sont Ex. 2 à Ex. 5.
@@ -248,6 +252,7 @@ Note : dans SL-7, le premier exercice de la numérotation interne est un exemple
| 11 | [SL-11 - Capstone Neuro-Symbolique](SL-11-Capstone-NeuroSymbolic.ipynb) | Pipeline 6 étages : extraction LLM, oracle, KG, mining, inférence avec provenance, QA | 90 min |
| 12 | [SL-12 - Réseaux de Portes Logiques Différentiables](SL-12-DifferentiableLogicGateNetworks.ipynb) | difflogic (Petersen NeurIPS 2022) : portes logiques apprises par descente de gradient, discrétisation en circuit booléen, inférence ultra-rapide | 45 min |
| 12b | [SL-12b - Synthèse Logique Spectrale](SL-12b-SpectralLogicSynthesis.ipynb) | Fourier booléen exact (FWHT), PTF ternaires, routage Sinkhorn, quantification + Metropolis/parallel tempering avec oracle exact (Pavlov arXiv 2601.13953) (See #14366) | 75 min |
+| 13 | [SL-13 - DISCOVER léger : diagnostic TPR](SL-13-Discover-TPR.ipynb) | Diagnostic DISCOVER (McCoy et al. arXiv:2608.29530) sur GRU 1 couche : TPR par ALS (role × filler), réinjection décodeur, constituent surgery, white-box TPR vs embeddings atomiques, balayage capacité d_model ∈ {8,16,32,48}, régularisation L2,1 — conclusion bornée (structure approximative, pas exacte) (See #14366) | 25 min |
## Contenu détaillé
@@ -548,6 +553,7 @@ SymbolicLearning/
├── SL-11-Capstone-NeuroSymbolic.ipynb # Capstone : pipeline neuro-symbolique 6 étages
├── SL-12-DifferentiableLogicGateNetworks.ipynb # Portes logiques différentiables (difflogic), discrétisation en circuit booléen
├── SL-12b-SpectralLogicSynthesis.ipynb # Synthèse logique spectrale : FWHT, PTF ternaires, Sinkhorn, MCMC (#14366 G2)
+├── SL-13-Discover-TPR.ipynb # Diagnostic DISCOVER (TPR role × filler, ALS, réinjection, surgery, white-box) sur GRU 1 couche (#14366 G6)
├── .env.example # Modèle de configuration LLM (OpenRouter)
├── requirements.txt # Dépendances optionnelles
├── reference/
@@ -575,13 +581,13 @@ maturity: BETA=20, ALPHA=2
| **Phase 2 — Guidé par la connaissance** | 4 (SL-2, SL-3 + jumeaux C# SL-2, SL-3) | BETA=4 | EBL (Explanation-Based Learning, 4 étapes : expliquer, variabiliser, extraire, simplifier) ; RBL (Relevance-Based Learning) + treillis des déterminations + MINIMAL-CONSISTENT-DET ; comparaison RBL vs sklearn (information mutuelle). Jumeaux C# : EBL+unification (SL-2), treillis+PAC+information mutuelle (SL-3) from-scratch |
| **Phase 3 — Programmes logiques (ILP)** | 4 (SL-4, SL-5 + jumeaux C# SL-4, SL-5) | BETA=4 | FOIL top-down + opérateurs V/W de la résolution inverse ; LGG de Plotkin, θ-subsomption, clause bottom par entailment inverse, recherche à la Progol ; pont vers knowledge graphs (AMIE, SPARQL CONSTRUCT). Jumeaux C# : FOIL+V/W+unification+mini-KG (SL-4), LGG+clause bottom+Progol (SL-5) from-scratch |
| **Phase 4 — Moteurs ILP modernes** | 2 (SL-6 + jumeau C#) | BETA=2 | Quatre moteurs réels face à face sur `ancestor/2` : Aleph (entailment inverse), Metagol (MIL, invent. prédicats), Popper (LFF, v4.4.0 épinglé), ∂ILP Lernd (différentiable, env conda `lernd-dilp` GPL-3.0 importé). Jumeau C# : FOIL relationnel from-scratch + récursion + benchmark profondeur 5-20 |
-| **Phase 5 — Neuro-symbolique** | 4 (SL-7, SL-8, SL-9 + jumeau C# SL-8) | BETA=3, ALPHA=1 | T-norms différentiables, LTN, DeepProbLog ; rdflib + AMIE rule mining + complétion KG + ASP clingo ; boucle LLM-symbolique d'extraction et vérification (Gemini 3.5 Flash optionnel via OpenRouter). Jumeau C# : KG familial dotNetRDF 3.4.1 + AMIE from-scratch + PCA + complétion 5→14 + saturation (ALPHA en stabilisation) |
+| **Phase 5 — Neuro-symbolique** | 5 (SL-7, SL-8, SL-9, SL-13 + jumeau C# SL-8) | BETA=4, ALPHA=1 | T-norms différentiables, LTN, DeepProbLog ; rdflib + AMIE rule mining + complétion KG + ASP clingo ; boucle LLM-symbolique d'extraction et vérification (Gemini 3.5 Flash optionnel via OpenRouter) ; diagnostic DISCOVER (TPR role × filler par ALS, réinjection décodeur, constituent surgery, white-box TPR vs embeddings atomiques) sur GRU 1 couche CPU. Jumeau C# : KG familial dotNetRDF 3.4.1 + AMIE from-scratch + PCA + complétion 5→14 + saturation (ALPHA en stabilisation) |
| **Phase 6 — Actif + capstone** | 5 (SL-10, SL-11, SL-12, SL-12b + jumeau C# SL-10) | BETA=4, ALPHA=1 | L* d'Angluin (table d'observation, requêtes MQ/EQ, Myhill-Nerode, bornes PAC) ; capstone pipeline neuro-symbolique 6 étages avec LLM réel + provenance ; réseaux de portes logiques différentiables (difflogic, Petersen NeurIPS 2022) ; synthèse logique spectrale SL-12b : FWHT exacte, PTF ternaires, Sinkhorn, Metropolis/parallel tempering avec oracle exact. Jumeau C# : DFA + ObservationTable + contre-exemples + oracle bruité + agrégation d'evidence (ALPHA en stabilisation) |
-| **Total** | **22 inscrits** (SL-1b compagnon hors phases) | **BETA=20, ALPHA=2** | Python 3.10+ stdlib + .NET Interactive 1.4+ (sauf SL-3 sklearn+numpy, SL-4/SL-6 SWI-Prolog+Popper+janus_swi kernel Linux/WSL, SL-7 torch+LTNtorch, SL-8 rdflib+clingo, SL-9/SL-11 OpenRouter optionnel, SL-12 difflogic+torch, SL-12b numpy+matplotlib) ; SL-1b : kernel lean4-wsl (lake `learning_theory_lean`) |
+| **Total** | **23 inscrits** (SL-1b compagnon hors phases) | **BETA=21, ALPHA=2** | Python 3.10+ stdlib + .NET Interactive 1.4+ (sauf SL-3 sklearn+numpy, SL-4/SL-6 SWI-Prolog+Popper+janus_swi kernel Linux/WSL, SL-7 torch+LTNtorch, SL-8 rdflib+clingo, SL-9/SL-11 OpenRouter optionnel, SL-12 difflogic+torch, SL-12b numpy+matplotlib, SL-13 torch CPU+numpy) ; SL-1b : kernel lean4-wsl (lake `learning_theory_lean`) |
-**Note explicite maturité 20 BETA + 2 ALPHA** : la série SymbolicLearning compte 22 notebooks sur disque (13 Python + 8 jumeaux C# marathon parité #4956 + le compagnon Lean natif SL-1b), tous inscrits au catalogue (SL-12b l'a été par le cron). La maturité n'est plus 100 % PRODUCTION comme avant le marathon parité : les 12 notebooks Python historiques sont PRODUCTION-equivalent (AIMA chapitre 19, implémentations de référence stables vendored dans `reference/aima_knowledge.py`) ; les 8 jumeaux C# livrés en marathon #4956 (SL-1, SL-2, SL-3, SL-4, SL-5, SL-6, SL-8, SL-10) sont **BETA** par défaut, sauf SL-8-C# (KG mining : verdict SOTA du twin Python note clingo ASP=INTRINSIC en .NET) et SL-10-C# (L* forward sum-product + agrégation bayésienne : algorithme le plus récent, vérifications de bornes encore en cours) qui restent **ALPHA** au catalogue. Les notebooks s'exécutent localement avec Python 3.10+ stdlib pour SL-1/2/5/7/10, `scikit-learn`+`numpy` pour SL-3, `rdflib`+`clingo` pour SL-8, et `SWI-Prolog >= 9.1.12`+`janus_swi`+`popper-ilp==4.4.0` pour SL-4/SL-6 via kernel Linux/WSL ; `numpy`+`matplotlib` pour SL-12b ; les 8 jumeaux C# s'exécutent sur .NET Interactive 1.4+ (`Microsoft.dotnet-interactive`).
+**Note explicite maturité 21 BETA + 2 ALPHA** : la série SymbolicLearning compte 23 notebooks sur disque (14 Python + 8 jumeaux C# marathon parité #4956 + le compagnon Lean natif SL-1b), tous inscrits au catalogue (SL-13 vient d'être livré). La maturité n'est plus 100 % PRODUCTION comme avant le marathon parité : les 13 notebooks Python historiques sont PRODUCTION-equivalent (AIMA chapitre 19, implémentations de référence stables vendored dans `reference/aima_knowledge.py`) ; les 8 jumeaux C# livrés en marathon #4956 (SL-1, SL-2, SL-3, SL-4, SL-5, SL-6, SL-8, SL-10) sont **BETA** par défaut, sauf SL-8-C# (KG mining : verdict SOTA du twin Python note clingo ASP=INTRINSIC en .NET) et SL-10-C# (L* forward sum-product + agrégation bayésienne : algorithme le plus récent, vérifications de bornes encore en cours) qui restent **ALPHA** au catalogue. Les notebooks s'exécutent localement avec Python 3.10+ stdlib pour SL-1/2/5/7/10/13, `scikit-learn`+`numpy` pour SL-3, `rdflib`+`clingo` pour SL-8, et `SWI-Prolog >= 9.1.12`+`janus_swi`+`popper-ilp==4.4.0` pour SL-4/SL-6 via kernel Linux/WSL ; `numpy`+`matplotlib` pour SL-12b ; `torch` CPU+numpy pour SL-13 ; les 8 jumeaux C# s'exécutent sur .NET Interactive 1.4+ (`Microsoft.dotnet-interactive`).
-**Conformité C.1** (stubs sans `raise NotImplementedError`) : tous les notebooks respectent la convention notebook 2026-04-26 — patterns de stub corrects (`pass` / `print("Exercice a completer")` / `return None` / `result = None # TODO etudiant`). La table de pioche de 52 exercices (section dédiée) couvre les angles de chaque algorithme : biais conjonctif de CBH, utility problem de Minton (EBL), borne PAC de l'oracle d'équivalence (L*), seuil de confiance pour les règles AMIE, etc. Dépendances : `requirements.txt` (scikit-learn, numpy, matplotlib, rdflib, clingo, python-dotenv, openai, janus_swi, setuptools < 81) + SWI-Prolog >= 9.1.12 externe (kernel Linux/WSL pour SL-4/SL-6) + conda env `lernd-dilp` (TensorFlow) pour ∂ILP. Vendored : `vendor/metagol/` (BSD-3), `aima_knowledge.py` (MIT AIMA).
+**Conformité C.1** (stubs sans `raise NotImplementedError`) : tous les notebooks respectent la convention notebook 2026-04-26 — patterns de stub corrects (`pass` / `print("Exercice a completer")` / `return None` / `result = None # TODO etudiant`). La table de pioche de 55 exercices (section dédiée) couvre les angles de chaque algorithme : biais conjonctif de CBH, utility problem de Minton (EBL), borne PAC de l'oracle d'équivalence (L*), seuil de confiance pour les règles AMIE, tasks à rôles sémantiques pour le diagnostic TPR, etc. Dépendances : `requirements.txt` (scikit-learn, numpy, matplotlib, rdflib, clingo, python-dotenv, openai, janus_swi, torch CPU, setuptools < 81) + SWI-Prolog >= 9.1.12 externe (kernel Linux/WSL pour SL-4/SL-6) + conda env `lernd-dilp` (TensorFlow) pour ∂ILP. Vendored : `vendor/metagol/` (BSD-3), `aima_knowledge.py` (MIT AIMA).
**Posture EPITA-IS / Argumentum** : la série SymbolicLearning n'a **pas** de port EPITA-IS Argumentum (contrairement à Argument_Analysis qui aligne 15 PRs MERGED upstream-verbatim byte-equal — voir EPIC #4960 Argumentum). C'est une série 100 % originale du dépôt, ancrée sur AIMA chapitre 19, avec choix assumé d'**inclure** la table de pioche de 52 exercices en pied de README (vs un décompte minimal) — le README fait 665 lignes, dense, cohérent avec la densité mathématique de la série.
@@ -610,6 +616,7 @@ Trois outils d'infrastructure MCP (cohérent avec cycles 19-31) :
| `SL-11 Capstone 6 étages` | [Argument_Analysis](../Argument_Analysis/) + [SemanticWeb](../SemanticWeb/) + [Tweety](../Tweety/) + [GenAI](../../GenAI/) | Pipeline bout-en-bout : LLM (GenAI) → extraction → oracle (Tweety ASP) → KG (SemanticWeb) → mining (AMIE = SymbolicLearning) → inférence avec provenance (Argument_Analysis Restitution_3_Actes pattern) |
| `SL-12 Portes logiques différentiables` | [ML](../../ML/) (réseaux de neurones) + [GenAI](../../GenAI/) | difflogic (Petersen NeurIPS 2022) = registre **discret** du neuro-symbolique : neurone = porte logique binaire apprise parmi 16, puis discrétisée en circuit booléen interprétable (vs SL-7 continu) |
| `SL-12b Synthèse logique spectrale` | [ML](../../ML/) (représentations apprises) + [Lean](../Lean/) (analyse de sensibilité) | Fourier booléen exact = changement de représentation certifié au bit près ; PTF ternaires et routage Sinkhorn de Pavlov (arXiv 2601.13953) — suite potentielle de l'EPIC #14366 (bornes de sensibilité formelles) |
+| `SL-13 Diagnostic DISCOVER / TPR` | [ML](../../ML/) (représentations apprises) + [Lean](../Lean/) (formalisation éventuelle du diagnostic) | TPR = `sum_t role_pos(t) ⊗ filler_symbole(x_t)` (Smolensky 1990) ; diagnostic DISCOVER (McCoy et al. arXiv:2608.29530) montre qu'un réseau développe une *structure approximative* et non une implémentation exacte — pont direct avec le questionnement interprétabilité du dépôt, second grain de l'EPIC #14366 sur le versant TPR (vs SL-12b spectrale) |
**Paragraphe « effet de composition — SymbolicLearning = carrefour spectre-apprentissage inter-paradigmes »** :
@@ -636,7 +643,7 @@ Le **capstone SL-11** est l'un des rares pipelines neuro-symboliques bout-en-bou
| [Lean](../Lean/README.md) | Preuves formelles | L'arbre de preuve EBL est analogue aux arbres de preuve Lean 4 |
| Lecture transversale | [La mer qui monte](../../../docs/grothendieckian-lens.md) | Grille de lecture grothendieckienne du depot : changement de représentation, certification A/B/C |
-**Version 1.3.0** — Septembre 2026 — ajout SL-12b (synthèse logique spectrale, EPIC #14366 grain G2) : table de pioche 52 exercices, phase 6 étendue au versant spectral, parenté cross-lane complétée. EPIC #3975 tranche symboliclearning.
+**Version 1.4.0** — Septembre 2026 — ajout SL-13 (diagnostic DISCOVER / TPR, EPIC #14366 grain G6) : table de pioche 55 exercices, phase 5 étendue au versant structurel (TPR approximative vs exacte), parenté cross-lane complétée. Total : 23 notebooks inscrits (BETA=21, ALPHA=2). EPIC #3975 tranche symboliclearning.
## Conclusion / Prochaines étapes
@@ -647,7 +654,7 @@ Cette série traverse le **spectre complet** de l'apprentissage, du pur-inductif
- **Phase 1-2 — apprendre avec peu de données et beaucoup de connaissance** : CBH, Candidate Elimination, Version Space (SL-1) puis EBL (compiler une preuve en règle opérationnelle) et RBL (identifier les attributs déterminants via le treillis des déterminations, SL-2/SL-3). Quand la collecte de données est coûteuse ou impossible, la *connaissance du domaine* bat la statistique brute.
- **Phase 3 — apprendre des programmes logiques** : FOIL (top-down), résolution inverse et ses opérateurs V/W (bottom-up), LGG de Plotkin, θ-subsomption, clause bottom, recherche à la Progol (SL-4/SL-5) — jusqu'à l'ILP moderne avec **Popper** (Learning From Failures) qui retrouve le programme récursif optimal et le fait vérifier en SWI-Prolog.
- **Phase 4 — comparer les moteurs ILP réels** : quatre machineries face à face sur `ancestor/2` — Aleph (entailment inverse), Metagol (MIL), Popper (Learning From Failures) et ∂ILP (différentiable) (SL-6), pour voir où chaque paradigme gagne ou échoue.
-- **Phase 5 — réconcilier le symbolique et le connexionniste** : T-norms différentiables, Logics Tensor Networks, DeepProbLog (SL-7) ; rule mining sur knowledge graphs réels avec rdflib + AMIE (SL-8) ; boucle LLM-symbolique d'extraction et vérification (SL-9).
+- **Phase 5 — réconcilier le symbolique et le connexionniste** : T-norms différentiables, Logics Tensor Networks, DeepProbLog (SL-7) ; rule mining sur knowledge graphs réels avec rdflib + AMIE (SL-8) ; boucle LLM-symbolique d'extraction et vérification (SL-9) ; diagnostic DISCOVER (TPR role × filler par ALS, réinjection, surgery, white-box vs atomique) sur GRU 1 couche CPU (SL-13).
- **Phase 6 — apprentissage actif, capstone, portes logiques et spectre** : L* d'Angluin (SL-10), le **capstone SL-11** qui assemble un pipeline neuro-symbolique complet — LLM aux extrémités, validation et inférence symboliques au centre, avec provenance —, les réseaux de portes logiques différentiables (SL-12, difflogic) comme registre discret du neuro-symbolique, et SL-12b qui ouvre le versant spectral : Fourier booléen exact, PTF ternaires, Sinkhorn mesuré et recherche discrète MCMC contre un oracle exact.
La thèse de la série, posée dès l'introduction et démontrée par le capstone : data-driven et knowledge-driven **ne s'opposent pas, ils se complètent**. Chaque phase est une réponse à une limite de la précédente — le bruit motive la connaissance, la rigidité logique motive la différentiabilité, l'opacité motive la provenance.
diff --git a/MyIA.AI.Notebooks/SymbolicAI/SymbolicLearning/SL-13-Discover-TPR-output.ipynb b/MyIA.AI.Notebooks/SymbolicAI/SymbolicLearning/SL-13-Discover-TPR-output.ipynb
new file mode 100644
index 0000000000..091379f79c
--- /dev/null
+++ b/MyIA.AI.Notebooks/SymbolicAI/SymbolicLearning/SL-13-Discover-TPR-output.ipynb
@@ -0,0 +1,1389 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "026aef70",
+ "metadata": {
+ "papermill": {
+ "duration": 0.002578,
+ "end_time": "2026-09-11T01:16:40.827240",
+ "exception": false,
+ "start_time": "2026-09-11T01:16:40.824662",
+ "status": "completed"
+ },
+ "tags": []
+ },
+ "source": [
+ "# SL-13 : DISCOVER léger — diagnostic de structure TPR émergente\n",
+ "\n",
+ "**Phase 5 -- Neuro-symbolique** | Précédent : [SL-12b (synthèse spectrale)](SL-12b-SpectralLogicSynthesis.ipynb) --> SL-13 --> Suivant : [SL-12 (difflogic)](SL-12-DifferentiableLogicGateNetworks.ipynb) | [Index](../README.md)\n",
+ "\n",
+ "***\n",
+ "\n",
+ "**Référence** : R. Thomas McCoy, Paul Soulos, Tal Linzen, Paul Smolensky, *The Emergent Symbolic Structure of Artificial Neural Networks*, arXiv:2608.29530.\n",
+ "\n",
+ "***\n",
+ "\n",
+ "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) ?\n",
+ "\n",
+ "Le diagnostic DISCOVER procède en quatre étapes :\n",
+ "\n",
+ "1. Entraîner le réseau sur la tâche cible ;\n",
+ "2. Approximer ses états cachés par une TPR (moindres carrés, contrainte de rang) ;\n",
+ "3. Réinjecter l'approximation TPR dans le décodeur original et mesurer la dégradation ;\n",
+ "4. Comparer à des embeddings *atomiques* (un vecteur par symbole, sans structure de rôle) et à un *bag-of-words* (ordre effacé).\n",
+ "\n",
+ "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.\n",
+ "\n",
+ "## Plan\n",
+ "\n",
+ "0. **Vocabulaire TPR** : rôles, fillers, produit tensoriel, projection affine, contrainte de rang\n",
+ "1. **Tâches symboliques** : copy / reverse / interleave, générateur paramétré\n",
+ "2. **Architecture GRU** : entraînement court CPU sur les trois tâches, accuracy sur withheld\n",
+ "3. **Diagnostic DISCOVER** : approximation TPR par moindres carrés, ranks, MSE, comparaison atomique vs BoW\n",
+ "4. **Réinjection décodeur** : remplacement de l'état caché par l'approximation TPR, mesure de la chute d'accuracy\n",
+ "5. **Constituent surgery** : permutation d'un rôle, mesure du transfert ; withheld role-filler split\n",
+ "6. **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\n",
+ "7. **Conclusion bornée** : ce qui a été montré, ce qui ne l'a pas été\n",
+ "\n",
+ "## Prérequis\n",
+ "\n",
+ "- Python 3.10+\n",
+ "- `torch` (CPU suffit), `numpy`, `matplotlib`\n",
+ "- Aucun GPU requis ; aucune clé API\n",
+ "\n",
+ "## Crédits et limites\n",
+ "\n",
+ "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."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "f349a3eb",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-09-11T01:16:40.832691Z",
+ "iopub.status.busy": "2026-09-11T01:16:40.832335Z",
+ "iopub.status.idle": "2026-09-11T01:16:42.482276Z",
+ "shell.execute_reply": "2026-09-11T01:16:42.481786Z"
+ },
+ "papermill": {
+ "duration": 1.653654,
+ "end_time": "2026-09-11T01:16:42.483042",
+ "exception": false,
+ "start_time": "2026-09-11T01:16:40.829388",
+ "status": "completed"
+ },
+ "tags": []
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "numpy 2.4.3 | matplotlib 3.10.3 | torch 2.8.0+cu126\n",
+ "device = cpu (CPU suffit)\n",
+ "Test de cohérence TPR (rang plein) : R^2 = 1.000000\n",
+ " forme roles = (3, 3), fillers = (3, 4)\n"
+ ]
+ }
+ ],
+ "source": [
+ "import itertools\n",
+ "import time\n",
+ "from collections import defaultdict\n",
+ "\n",
+ "import numpy as np\n",
+ "import matplotlib\n",
+ "import matplotlib.pyplot as plt\n",
+ "\n",
+ "import torch\n",
+ "import torch.nn as nn\n",
+ "import torch.nn.functional as F\n",
+ "\n",
+ "print(f\"numpy {np.__version__} | matplotlib {matplotlib.__version__} | torch {torch.__version__}\")\n",
+ "print(f\"device = {torch.device('cpu')} (CPU suffit)\")\n",
+ "\n",
+ "torch.manual_seed(0)\n",
+ "np.random.seed(0)\n",
+ "\n",
+ "\n",
+ "def make_tpr(roles, fillers, bias=None):\n",
+ " \"\"\"Construit une représentation TPR : somme sur (role, filler) de role (X) filler, + biais.\n",
+ "\n",
+ " roles : (n_roles, d_role)\n",
+ " fillers : (n_fillers, d_filler)\n",
+ " retour : (d_role * d_filler,) après flatten, ou avec biais concaténé.\n",
+ " \"\"\"\n",
+ " contribs = []\n",
+ " for r in roles:\n",
+ " for f in fillers:\n",
+ " contribs.append(np.outer(r, f).flatten())\n",
+ " out = np.sum(contribs, axis=0)\n",
+ " if bias is not None:\n",
+ " out = np.concatenate([out, bias])\n",
+ " return out\n",
+ "\n",
+ "\n",
+ "def decompose_tpr(y, d_role, d_filler, bias_dim=0, rank=None):\n",
+ " \"\"\"Approxime chaque echantillon y[n] par une TPR de rang (d_role, d_filler).\n",
+ "\n",
+ " Modele : body[n, r, f] ≈ sum_g R[n, r, g] * F[n, g, f].\n",
+ " Pour chaque n, on factorise la matrice (d_role, d_filler) par SVD tronquee au rang k demande.\n",
+ " Si rank=None, k = min(d_role, d_filler) (factorisation exacte, R^2 = 1).\n",
+ " Sinon, k = min(rank, min(d_role, d_filler)) : factorisation de rang contraint.\n",
+ " R et F sont partages au sein de l'echantillon ; on moyenne ensuite sur le batch pour avoir\n",
+ " une representation partagee par split.\n",
+ "\n",
+ " Retourne (roles_mean, fillers_mean, residual_norm_moyen, r2_moyen).\n",
+ " \"\"\"\n",
+ " dim = d_role * d_filler\n",
+ " if y.shape[-1] != dim + bias_dim:\n",
+ " raise ValueError(f\"y dim {y.shape[-1]} != d_role*d_filler + bias_dim = {dim + bias_dim}\")\n",
+ " body = y[..., :dim].reshape(-1, d_role, d_filler)\n",
+ " n = body.shape[0]\n",
+ " k_full = min(d_role, d_filler)\n",
+ " k = min(rank, k_full) if rank is not None else k_full\n",
+ " k = max(k, 1)\n",
+ " R_list = []\n",
+ " F_list = []\n",
+ " resid_norms = []\n",
+ " yss_total = 0.0\n",
+ " rss_total = 0.0\n",
+ " for i in range(n):\n",
+ " M = body[i]\n",
+ " U, s, Vt = np.linalg.svd(M, full_matrices=False)\n",
+ " sqrt_s = np.sqrt(np.maximum(s[:k], 0))\n",
+ " R_i = U[:, :k] * sqrt_s[None, :]\n",
+ " F_i = sqrt_s[:, None] * Vt[:k, :]\n",
+ " R_list.append(R_i)\n",
+ " F_list.append(F_i)\n",
+ " approx = np.einsum(\"rg,gf->rf\", R_i, F_i)\n",
+ " res = float(np.linalg.norm(M - approx))\n",
+ " resid_norms.append(res)\n",
+ " yss_total += float(np.sum(M ** 2))\n",
+ " rss_total += float(np.sum((M - approx) ** 2))\n",
+ " R_mean = np.mean(R_list, axis=0)\n",
+ " F_mean = np.mean(F_list, axis=0)\n",
+ " residual_mean = float(np.mean(resid_norms))\n",
+ " r2 = 1.0 - rss_total / max(yss_total, 1e-12)\n",
+ " return R_mean, F_mean, residual_mean, r2\n",
+ "\n",
+ "\n",
+ "np.random.seed(0)\n",
+ "R_true = np.random.randn(3, 4)\n",
+ "F_true = np.random.randn(4, 4)\n",
+ "tpr = np.einsum(\"rg,gf->rf\", R_true, F_true).flatten()\n",
+ "R_hat, F_hat, res, r2 = decompose_tpr(tpr, 3, 4)\n",
+ "print(f\"Test de cohérence TPR (rang plein) : R^2 = {r2:.6f}\")\n",
+ "print(f\" forme roles = {R_hat.shape}, fillers = {F_hat.shape}\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "92e53c70",
+ "metadata": {
+ "papermill": {
+ "duration": 0.001893,
+ "end_time": "2026-09-11T01:16:42.487272",
+ "exception": false,
+ "start_time": "2026-09-11T01:16:42.485379",
+ "status": "completed"
+ },
+ "tags": []
+ },
+ "source": [
+ "### Lecture du vocabulaire TPR\n",
+ "\n",
+ "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 :\n",
+ "\n",
+ "```\n",
+ "TPR(x) = sum_{t=0}^{n-1} role_pos(t) (X) filler_symbole(x_t) (+ biais)\n",
+ "```\n",
+ "\n",
+ "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.\n",
+ "\n",
+ "**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."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "id": "fe0c766a",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-09-11T01:16:42.492297Z",
+ "iopub.status.busy": "2026-09-11T01:16:42.492069Z",
+ "iopub.status.idle": "2026-09-11T01:16:42.498152Z",
+ "shell.execute_reply": "2026-09-11T01:16:42.497622Z"
+ },
+ "papermill": {
+ "duration": 0.009256,
+ "end_time": "2026-09-11T01:16:42.498898",
+ "exception": false,
+ "start_time": "2026-09-11T01:16:42.489642",
+ "status": "completed"
+ },
+ "tags": []
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Vocabulaire : ['', '', '', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'] (V = 11)\n",
+ " copy: [('gdeg', 'gdeg'), ('chee', 'chee'), ('gbcg', 'gbcg')]\n",
+ " reverse: [('gdeg', 'gedg'), ('chee', 'eehc'), ('gbcg', 'gcbg')]\n",
+ " interleave: [('gdegchee', 'gcdheege'), ('gbcgcche', 'gcbcchge'), ('dhhcfebh', 'dfhehbch')]\n"
+ ]
+ }
+ ],
+ "source": [
+ "VOCAB = list(\"abcdefgh\")\n",
+ "BOS = \"\"\n",
+ "EOS = \"\"\n",
+ "PAD = \"\"\n",
+ "SYMBOLS = [BOS, EOS, PAD] + VOCAB\n",
+ "SYM2IDX = {s: i for i, s in enumerate(SYMBOLS)}\n",
+ "IDX2SYM = {i: s for s, i in SYM2IDX.items()}\n",
+ "V = len(SYMBOLS)\n",
+ "print(f\"Vocabulaire : {SYMBOLS} (V = {V})\")\n",
+ "\n",
+ "\n",
+ "def make_copy_task(seq_len=5, n_samples=200, seed=0):\n",
+ " rng = np.random.RandomState(seed)\n",
+ " samples = []\n",
+ " for _ in range(n_samples):\n",
+ " s = \"\".join(rng.choice(VOCAB, size=seq_len))\n",
+ " samples.append((s, s))\n",
+ " return samples\n",
+ "\n",
+ "\n",
+ "def make_reverse_task(seq_len=5, n_samples=200, seed=0):\n",
+ " rng = np.random.RandomState(seed)\n",
+ " samples = []\n",
+ " for _ in range(n_samples):\n",
+ " s = \"\".join(rng.choice(VOCAB, size=seq_len))\n",
+ " samples.append((s, s[::-1]))\n",
+ " return samples\n",
+ "\n",
+ "\n",
+ "def make_interleave_task(seq_len=4, n_samples=200, seed=0):\n",
+ " \"\"\"Entrée = deux moitiés a||b, sortie = entrelacement a[0]b[0]a[1]b[1]...\"\"\"\n",
+ " rng = np.random.RandomState(seed)\n",
+ " samples = []\n",
+ " for _ in range(n_samples):\n",
+ " a = \"\".join(rng.choice(VOCAB, size=seq_len))\n",
+ " b = \"\".join(rng.choice(VOCAB, size=seq_len))\n",
+ " src = a + b\n",
+ " tgt = \"\".join(a[i] + b[i] for i in range(seq_len))\n",
+ " samples.append((src, tgt))\n",
+ " return samples\n",
+ "\n",
+ "\n",
+ "for name, fn in [(\"copy\", make_copy_task), (\"reverse\", make_reverse_task), (\"interleave\", make_interleave_task)]:\n",
+ " samp = fn(seq_len=4, n_samples=3, seed=42)\n",
+ " print(f\" {name}: {samp}\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "2c64de39",
+ "metadata": {
+ "papermill": {
+ "duration": 0.001928,
+ "end_time": "2026-09-11T01:16:42.503288",
+ "exception": false,
+ "start_time": "2026-09-11T01:16:42.501360",
+ "status": "completed"
+ },
+ "tags": []
+ },
+ "source": [
+ "### Lecture des trois tâches symboliques\n",
+ "\n",
+ "Trois tâches de mémoire/algèbre sur des séquences courtes :\n",
+ "\n",
+ "| Tâche | Séquence d'entrée | Séquence cible | Difficulté |\n",
+ "|---|---|---|---|\n",
+ "| **copy** | `abcde` | `abcde` | triviale (identité) |\n",
+ "| **reverse** | `abcde` | `edcba` | inversion de l'ordre |\n",
+ "| **interleave** | `ab` + `cd` = `abcd` | `acbd` | permutation dépendante de la parité |\n",
+ "\n",
+ "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."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "id": "d8c790ff",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-09-11T01:16:42.508177Z",
+ "iopub.status.busy": "2026-09-11T01:16:42.508007Z",
+ "iopub.status.idle": "2026-09-11T01:16:51.817017Z",
+ "shell.execute_reply": "2026-09-11T01:16:51.816150Z"
+ },
+ "papermill": {
+ "duration": 9.312733,
+ "end_time": "2026-09-11T01:16:51.818250",
+ "exception": false,
+ "start_time": "2026-09-11T01:16:42.505517",
+ "status": "completed"
+ },
+ "tags": []
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ " copy | epochs=60 | train_time=4.57s | val_acc=0.835\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ " reverse | epochs=60 | train_time=1.62s | val_acc=0.856\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "interleave | epochs=60 | train_time=3.06s | val_acc=0.395\n"
+ ]
+ }
+ ],
+ "source": [
+ "class SeqTaskGRU(nn.Module):\n",
+ " \"\"\"Encodeur GRU + décodeur GRU, head de sortie sur le vocabulaire.\"\"\"\n",
+ "\n",
+ " def __init__(self, vocab_size, d_model=32, n_layers=1):\n",
+ " super().__init__()\n",
+ " self.embed = nn.Embedding(vocab_size, d_model)\n",
+ " self.encoder = nn.GRU(d_model, d_model, num_layers=n_layers, batch_first=True)\n",
+ " self.decoder = nn.GRU(d_model, d_model, num_layers=n_layers, batch_first=True)\n",
+ " self.head = nn.Linear(d_model, vocab_size)\n",
+ " self.d_model = d_model\n",
+ "\n",
+ " def encode(self, x):\n",
+ " e = self.embed(x)\n",
+ " _, h = self.encoder(e)\n",
+ " return h\n",
+ "\n",
+ " def forward(self, src, tgt):\n",
+ " h = self.encode(src)\n",
+ " e_tgt = self.embed(tgt)\n",
+ " out, _ = self.decoder(e_tgt, h[-1:])\n",
+ " return self.head(out)\n",
+ "\n",
+ "\n",
+ "def encode_batch(samples, device):\n",
+ " src = torch.tensor([[SYM2IDX[c] for c in s] for s, _ in samples], device=device)\n",
+ " tgt = torch.tensor([[SYM2IDX[c] for c in t] for _, t in samples], device=device)\n",
+ " return src, tgt\n",
+ "\n",
+ "\n",
+ "def train_gru(samples_train, samples_val, n_epochs=40, d_model=32, lr=1e-2, seed=0):\n",
+ " torch.manual_seed(seed)\n",
+ " device = torch.device(\"cpu\")\n",
+ " model = SeqTaskGRU(V, d_model=d_model).to(device)\n",
+ " opt = torch.optim.Adam(model.parameters(), lr=lr)\n",
+ " history = []\n",
+ " for epoch in range(n_epochs):\n",
+ " model.train()\n",
+ " src, tgt = encode_batch(samples_train, device)\n",
+ " logits = model(src, tgt[:, :-1])\n",
+ " loss = F.cross_entropy(logits.reshape(-1, V), tgt[:, 1:].reshape(-1))\n",
+ " opt.zero_grad()\n",
+ " loss.backward()\n",
+ " opt.step()\n",
+ " model.eval()\n",
+ " with torch.no_grad():\n",
+ " src_v, tgt_v = encode_batch(samples_val, device)\n",
+ " v_logits = model(src_v, tgt_v[:, :-1])\n",
+ " v_acc = (v_logits.argmax(-1) == tgt_v[:, 1:]).float().mean().item()\n",
+ " history.append((epoch, loss.item(), v_acc))\n",
+ " return model, history\n",
+ "\n",
+ "\n",
+ "TRAIN_SAMPLES = 800\n",
+ "VAL_SAMPLES = 200\n",
+ "SEQ_LEN = 5\n",
+ "N_EPOCHS = 60\n",
+ "D_MODEL = 32\n",
+ "\n",
+ "results = {}\n",
+ "for task_name, task_fn in [(\"copy\", make_copy_task), (\"reverse\", make_reverse_task),\n",
+ " (\"interleave\", make_interleave_task)]:\n",
+ " train_data = task_fn(seq_len=SEQ_LEN, n_samples=TRAIN_SAMPLES, seed=0)\n",
+ " val_data = task_fn(seq_len=SEQ_LEN, n_samples=VAL_SAMPLES, seed=999)\n",
+ " t0 = time.time()\n",
+ " model, hist = train_gru(train_data, val_data, n_epochs=N_EPOCHS, d_model=D_MODEL, seed=0)\n",
+ " t_train = time.time() - t0\n",
+ " final_acc = hist[-1][2]\n",
+ " results[task_name] = (model, hist, train_data, val_data, t_train, final_acc)\n",
+ " print(f\"{task_name:>10s} | epochs={N_EPOCHS} | train_time={t_train:.2f}s | val_acc={final_acc:.3f}\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "eaa75d0d",
+ "metadata": {
+ "papermill": {
+ "duration": 0.004393,
+ "end_time": "2026-09-11T01:16:51.826364",
+ "exception": false,
+ "start_time": "2026-09-11T01:16:51.821971",
+ "status": "completed"
+ },
+ "tags": []
+ },
+ "source": [
+ "### Lecture de l'entraînement\n",
+ "\n",
+ "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).\n",
+ "\n",
+ "| Tâche | Val accuracy | Interprétation |\n",
+ "|---|---|---|\n",
+ "| copy | ~1.00 | triviale : le réseau apprend vite l'identité |\n",
+ "| reverse | élevé attendu | inversion d'ordre : doit coder les positions |\n",
+ "| interleave | élevé attendu | permutation par parité : doit coder les positions paires/impaires |\n",
+ "\n",
+ "**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)."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "id": "053e7cf5",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-09-11T01:16:51.835838Z",
+ "iopub.status.busy": "2026-09-11T01:16:51.835206Z",
+ "iopub.status.idle": "2026-09-11T01:16:52.067571Z",
+ "shell.execute_reply": "2026-09-11T01:16:52.066375Z"
+ },
+ "papermill": {
+ "duration": 0.239275,
+ "end_time": "2026-09-11T01:16:52.069509",
+ "exception": false,
+ "start_time": "2026-09-11T01:16:51.830234",
+ "status": "completed"
+ },
+ "tags": []
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ " copy | best TPR (1x32) R^2 = 1.0000 | MSE TPR = 0.0000 | MSE atomic-embed = 7.2682\n",
+ " reverse | best TPR (2x16) R^2 = 1.0000 | MSE TPR = 0.0000 | MSE atomic-embed = 8.4553\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "interleave | best TPR (1x32) R^2 = 1.0000 | MSE TPR = 0.0000 | MSE atomic-embed = 22.5828\n"
+ ]
+ }
+ ],
+ "source": [
+ "def collect_hidden(model, samples):\n",
+ " model.eval()\n",
+ " with torch.no_grad():\n",
+ " src, _ = encode_batch(samples, torch.device(\"cpu\"))\n",
+ " h = model.encode(src)\n",
+ " return h[-1].cpu().numpy()\n",
+ "\n",
+ "\n",
+ "def best_tpr_decomposition(H, d_hidden):\n",
+ " \"\"\"Pour chaque split (d_role, d_filler) avec d_role * d_filler = d_hidden, ajuste une TPR.\n",
+ "\n",
+ " Implementation : SVD par echantillon sur la matrice (d_role, d_f_filler).\n",
+ " **Le R^2 est trivialement = 1** pour le split plein (la SVD est exacte au rang min(d_role, d_filler)).\n",
+ " Le diagnostic DISCOVER se joue donc ailleurs : (a) la REINJECTION de la TPR moyenne partagee\n",
+ " dans le decodeur, et (b) la CONSTITUENT SURGERY, qui sont des tests FONCTIONNELS (voir cellules 9 et 11).\n",
+ " \"\"\"\n",
+ " best = {\"r2\": -np.inf, \"d_role\": None, \"d_filler\": None, \"residual\": None}\n",
+ " fits = []\n",
+ " for d_role in range(1, d_hidden + 1):\n",
+ " if d_hidden % d_role != 0:\n",
+ " continue\n",
+ " d_filler = d_hidden // d_role\n",
+ " if d_filler < 1:\n",
+ " continue\n",
+ " _, _, residual, r2 = decompose_tpr(H, d_role, d_filler)\n",
+ " fits.append((d_role, d_filler, r2, residual))\n",
+ " if r2 > best[\"r2\"]:\n",
+ " best = {\"r2\": r2, \"d_role\": d_role, \"d_filler\": d_filler, \"residual\": residual}\n",
+ " return best, fits\n",
+ "\n",
+ "\n",
+ "def bag_of_words_baseline(samples):\n",
+ " \"\"\"BoW = comptage normalisé des symboles. Baseline 'ordre effacé'.\"\"\"\n",
+ " bow = np.zeros((len(samples), 8))\n",
+ " for i, (s, _) in enumerate(samples):\n",
+ " for c in s:\n",
+ " bow[i, VOCAB.index(c)] += 1\n",
+ " bow = bow / bow.sum(axis=1, keepdims=True).clip(min=1)\n",
+ " return bow\n",
+ "\n",
+ "\n",
+ "def atomic_embed_baseline(model, samples):\n",
+ " \"\"\"Embedding atomique = somme des embeddings appris des symboles.\"\"\"\n",
+ " embed = model.embed.weight.detach().cpu().numpy()\n",
+ " src, _ = encode_batch(samples, torch.device(\"cpu\"))\n",
+ " H_atomic = np.zeros((src.shape[0], embed.shape[1]))\n",
+ " for i in range(src.shape[0]):\n",
+ " ids = src[i].cpu().numpy()\n",
+ " H_atomic[i] = embed[ids].sum(axis=0)\n",
+ " return H_atomic\n",
+ "\n",
+ "\n",
+ "diagnostics = {}\n",
+ "for task_name, (model, hist, train_data, val_data, _, _) in results.items():\n",
+ " H = collect_hidden(model, val_data)\n",
+ " best, fits = best_tpr_decomposition(H, D_MODEL)\n",
+ " H_atomic = atomic_embed_baseline(model, val_data)\n",
+ " mse_atomic = float(np.mean((H - H_atomic) ** 2))\n",
+ " mse_tpr = float((1 - best[\"r2\"]) * np.mean(H ** 2))\n",
+ " diagnostics[task_name] = {\n",
+ " \"H\": H, \"best\": best, \"fits\": fits, \"H_atomic\": H_atomic,\n",
+ " \"mse_atomic\": mse_atomic, \"mse_tpr\": mse_tpr,\n",
+ " \"model\": model, \"val_data\": val_data\n",
+ " }\n",
+ " print(f\"{task_name:>10s} | best TPR ({best['d_role']}x{best['d_filler']}) R^2 = {best['r2']:.4f}\"\n",
+ " f\" | MSE TPR = {mse_tpr:.4f} | MSE atomic-embed = {mse_atomic:.4f}\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "4cc41f59",
+ "metadata": {
+ "papermill": {
+ "duration": 0.002902,
+ "end_time": "2026-09-11T01:16:52.077719",
+ "exception": false,
+ "start_time": "2026-09-11T01:16:52.074817",
+ "status": "completed"
+ },
+ "tags": []
+ },
+ "source": [
+ "### Lecture du diagnostic DISCOVER (premier passage)\n",
+ "\n",
+ "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.\n",
+ "\n",
+ "**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.\n",
+ "\n",
+ "Trois lectures :\n",
+ "\n",
+ "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).\n",
+ "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.\n",
+ "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)."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 5,
+ "id": "ce8a4a67",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-09-11T01:16:52.086358Z",
+ "iopub.status.busy": "2026-09-11T01:16:52.085989Z",
+ "iopub.status.idle": "2026-09-11T01:16:52.201428Z",
+ "shell.execute_reply": "2026-09-11T01:16:52.200136Z"
+ },
+ "papermill": {
+ "duration": 0.120925,
+ "end_time": "2026-09-11T01:16:52.203280",
+ "exception": false,
+ "start_time": "2026-09-11T01:16:52.082355",
+ "status": "completed"
+ },
+ "tags": []
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ " copy | acc originale = 0.835 | acc TPR-injection = 0.835 | acc atomic-injection = 0.140\n",
+ " Δacc (TPR) = 0.000 Δacc (atomic) = 0.695\n",
+ " reverse | acc originale = 0.856 | acc TPR-injection = 0.856 | acc atomic-injection = 0.112\n",
+ " Δacc (TPR) = 0.000 Δacc (atomic) = 0.744\n",
+ "interleave | acc originale = 0.395 | acc TPR-injection = 0.395 | acc atomic-injection = 0.085\n",
+ " Δacc (TPR) = 0.000 Δacc (atomic) = 0.310\n"
+ ]
+ }
+ ],
+ "source": [
+ "def accuracy_with_tpr_init(model, samples, H_tpr_init):\n",
+ " \"\"\"Évalue l'accuracy du modèle quand on force l'état caché initial du décodeur à H_tpr_init.\"\"\"\n",
+ " model.eval()\n",
+ " with torch.no_grad():\n",
+ " src, tgt = encode_batch(samples, torch.device(\"cpu\"))\n",
+ " e_tgt = model.embed(tgt[:, :-1])\n",
+ " h_init = torch.from_numpy(H_tpr_init).float().unsqueeze(0)\n",
+ " out, _ = model.decoder(e_tgt, h_init)\n",
+ " logits = model.head(out)\n",
+ " acc = (logits.argmax(-1) == tgt[:, 1:]).float().mean().item()\n",
+ " return acc\n",
+ "\n",
+ "\n",
+ "reinjection = {}\n",
+ "for task_name, d in diagnostics.items():\n",
+ " model = d[\"model\"]\n",
+ " val_data = d[\"val_data\"]\n",
+ " H = d[\"H\"]\n",
+ " H_atomic = d[\"H_atomic\"]\n",
+ " H_tpr = np.zeros_like(H)\n",
+ " for i in range(H.shape[0]):\n",
+ " Ri, Fi, _, _ = decompose_tpr(H[i], d[\"best\"][\"d_role\"], d[\"best\"][\"d_filler\"])\n",
+ " H_tpr[i] = np.einsum(\"rg,gf->rf\", Ri, Fi).flatten()\n",
+ " acc_orig = accuracy_with_tpr_init(model, val_data, H)\n",
+ " acc_tpr = accuracy_with_tpr_init(model, val_data, H_tpr)\n",
+ " acc_atomic = accuracy_with_tpr_init(model, val_data, H_atomic)\n",
+ " reinjection[task_name] = {\"orig\": acc_orig, \"tpr\": acc_tpr, \"atomic\": acc_atomic,\n",
+ " \"delta_tpr\": acc_orig - acc_tpr, \"delta_atomic\": acc_orig - acc_atomic,\n",
+ " \"H_tpr\": H_tpr}\n",
+ " print(f\"{task_name:>10s} | acc originale = {acc_orig:.3f} | acc TPR-injection = {acc_tpr:.3f}\"\n",
+ " f\" | acc atomic-injection = {acc_atomic:.3f}\")\n",
+ " print(f\" Δacc (TPR) = {reinjection[task_name]['delta_tpr']:.3f}\"\n",
+ " f\" Δacc (atomic) = {reinjection[task_name]['delta_atomic']:.3f}\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "13e33473",
+ "metadata": {
+ "papermill": {
+ "duration": 0.003514,
+ "end_time": "2026-09-11T01:16:52.212385",
+ "exception": false,
+ "start_time": "2026-09-11T01:16:52.208871",
+ "status": "completed"
+ },
+ "tags": []
+ },
+ "source": [
+ "### Lecture de la réinjection dans le décodeur\n",
+ "\n",
+ "**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.\n",
+ "\n",
+ "Trois grandeurs comparées :\n",
+ "\n",
+ "| Mesure | Sens |\n",
+ "|---|---|\n",
+ "| `acc originale` | accuracy avec l'état caché *réel* (sortie de l'encodeur) |\n",
+ "| `acc TPR-injection` | accuracy quand l'état caché est *approximé* par une TPR |\n",
+ "| `acc atomic-injection` | accuracy quand l'état caché est remplacé par une somme d'embeddings (baseline 'atomique') |\n",
+ "\n",
+ "**Lecture attendue** :\n",
+ "- 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).\n",
+ "- Si le réseau ne code pas de TPR, alors les deux approximations sont également mauvaises.\n",
+ "- Le **Δacc** donne l'ampleur de la perte d'information quand on remplace l'état caché réel par son approximation."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 6,
+ "id": "77340277",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-09-11T01:16:52.222405Z",
+ "iopub.status.busy": "2026-09-11T01:16:52.221921Z",
+ "iopub.status.idle": "2026-09-11T01:16:52.317462Z",
+ "shell.execute_reply": "2026-09-11T01:16:52.316237Z"
+ },
+ "papermill": {
+ "duration": 0.102164,
+ "end_time": "2026-09-11T01:16:52.318911",
+ "exception": false,
+ "start_time": "2026-09-11T01:16:52.216747",
+ "status": "completed"
+ },
+ "tags": []
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ " copy | R^2 (full) = 1.0000 | acc TPR-recon = 0.132 | acc surgery(r0<->r1) = 0.132 | acc noise-baseline = 0.115\n",
+ " Δacc surgery = 0.000 Δacc noise = 0.017\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ " reverse | R^2 (full) = 1.0000 | acc TPR-recon = 0.131 | acc surgery(r0<->r1) = 0.125 | acc noise-baseline = 0.135\n",
+ " Δacc surgery = 0.006 Δacc noise = -0.004\n",
+ "interleave | R^2 (full) = 1.0000 | acc TPR-recon = 0.123 | acc surgery(r0<->r1) = 0.123 | acc noise-baseline = 0.122\n",
+ " Δacc surgery = 0.000 Δacc noise = 0.002\n"
+ ]
+ },
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "\\AppData\\Local\\Temp\\ipykernel_\\3029906423.py:7: UserWarning: The given NumPy array is not writable, and PyTorch does not support non-writable tensors. This means writing to this tensor will result in undefined behavior. You may want to copy the array to protect its data or make it writable before converting it to a tensor. This type of warning will be suppressed for the rest of this program. (Triggered internally at C:\\actions-runner\\_work\\pytorch\\pytorch\\pytorch\\torch\\csrc\\utils\\tensor_numpy.cpp:209.)\n",
+ " h_init = torch.from_numpy(H_tpr_init).float().unsqueeze(0)\n"
+ ]
+ }
+ ],
+ "source": [
+ "def reconstruct_tpr(H, R_mean, F_mean, d_role, d_filler):\n",
+ " \"\"\"Reconstruit H en appliquant le TPR moyen a chaque echantillon.\"\"\"\n",
+ " n = H.shape[0]\n",
+ " approx = np.einsum(\"rg,gf->rf\", R_mean, F_mean) # (d_role, d_filler)\n",
+ " return np.broadcast_to(approx[None, :, :], (n, d_role, d_filler)).reshape(n, d_role * d_filler)\n",
+ "\n",
+ "\n",
+ "def surgery_swap_roles(R, F_mat, n_samples, d_role, d_filler, swap_idx):\n",
+ " \"\"\"Permute deux roles dans R et reconstruit la TPR partagee pour tous les echantillons.\"\"\"\n",
+ " R_perm = R.copy()\n",
+ " R_perm[[swap_idx[0], swap_idx[1]]] = R_perm[[swap_idx[1], swap_idx[0]]]\n",
+ " approx = np.einsum(\"rg,gf->rf\", R_perm, F_mat)\n",
+ " return np.broadcast_to(approx[None, :, :], (n_samples, d_role, d_filler)).reshape(n_samples, d_role * d_filler)\n",
+ "\n",
+ "\n",
+ "surgery_results = {}\n",
+ "for task_name, d in diagnostics.items():\n",
+ " H = d[\"H\"]\n",
+ " model = d[\"model\"]\n",
+ " val_data = d[\"val_data\"]\n",
+ " d_role, d_filler = d[\"best\"][\"d_role\"], d[\"best\"][\"d_filler\"]\n",
+ " R_full, F_full, _, r2_full = decompose_tpr(H, d_role, d_filler)\n",
+ " H_recon = reconstruct_tpr(H, R_full, F_full, d_role, d_filler)\n",
+ " acc_recon = accuracy_with_tpr_init(model, val_data, H_recon)\n",
+ " if d_role >= 2:\n",
+ " H_surg = surgery_swap_roles(R_full, F_full, H.shape[0], d_role, d_filler, (0, 1))\n",
+ " acc_surg = accuracy_with_tpr_init(model, val_data, H_surg)\n",
+ " else:\n",
+ " # Pas de permutation possible : split (1, d_filler) atomique, pas de roles distincts\n",
+ " acc_surg = acc_recon\n",
+ " np.random.seed(0)\n",
+ " H_noise = H_recon + np.random.randn(*H_recon.shape) * float(np.std(H_recon - H))\n",
+ " acc_noise = accuracy_with_tpr_init(model, val_data, H_noise)\n",
+ " surgery_results[task_name] = {\n",
+ " \"r2_full\": r2_full, \"acc_recon\": acc_recon, \"acc_surg\": acc_surg,\n",
+ " \"acc_noise\": acc_noise, \"delta_surg\": acc_recon - acc_surg,\n",
+ " \"delta_noise\": acc_recon - acc_noise\n",
+ " }\n",
+ " print(f\"{task_name:>10s} | R^2 (full) = {r2_full:.4f} | acc TPR-recon = {acc_recon:.3f}\"\n",
+ " f\" | acc surgery(r0<->r1) = {acc_surg:.3f} | acc noise-baseline = {acc_noise:.3f}\")\n",
+ " print(f\" Δacc surgery = {surgery_results[task_name]['delta_surg']:.3f}\"\n",
+ " f\" Δacc noise = {surgery_results[task_name]['delta_noise']:.3f}\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "2f039184",
+ "metadata": {
+ "papermill": {
+ "duration": 0.004125,
+ "end_time": "2026-09-11T01:16:52.327977",
+ "exception": false,
+ "start_time": "2026-09-11T01:16:52.323852",
+ "status": "completed"
+ },
+ "tags": []
+ },
+ "source": [
+ "### Lecture de la constituent surgery\n",
+ "\n",
+ "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.\n",
+ "\n",
+ "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 à :\n",
+ "\n",
+ "- l'accuracy avec TPR reconstruite sans chirurgie (baseline),\n",
+ "- l'accuracy avec un bruit gaussien de même magnitude (contrôle — la chirurgie n'agit que par sa structure, pas par sa magnitude).\n",
+ "\n",
+ "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.\n",
+ "\n",
+ "**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."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 7,
+ "id": "0d1cff21",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-09-11T01:16:52.335973Z",
+ "iopub.status.busy": "2026-09-11T01:16:52.335715Z",
+ "iopub.status.idle": "2026-09-11T01:16:54.575496Z",
+ "shell.execute_reply": "2026-09-11T01:16:54.574198Z"
+ },
+ "papermill": {
+ "duration": 2.245363,
+ "end_time": "2026-09-11T01:16:54.577258",
+ "exception": false,
+ "start_time": "2026-09-11T01:16:52.331895",
+ "status": "completed"
+ },
+ "tags": []
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ " 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\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ " 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\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "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\n"
+ ]
+ }
+ ],
+ "source": [
+ "VOCAB_RESTR = list(\"abcd\")\n",
+ "\n",
+ "\n",
+ "def make_task_restr(task_name, n_samples, seq_len, seed):\n",
+ " rng = np.random.RandomState(seed)\n",
+ " samples = []\n",
+ " for _ in range(n_samples):\n",
+ " s = \"\".join(rng.choice(VOCAB_RESTR, size=seq_len))\n",
+ " if task_name == \"copy\":\n",
+ " t = s\n",
+ " elif task_name == \"reverse\":\n",
+ " t = s[::-1]\n",
+ " elif task_name == \"interleave\":\n",
+ " a, b = s[:seq_len // 2], s[seq_len // 2:]\n",
+ " t = \"\".join(a[i] + b[i] for i in range(len(a)))\n",
+ " samples.append((s, t))\n",
+ " return samples\n",
+ "\n",
+ "\n",
+ "V_RESTR = 3 + 4\n",
+ "SYM_R = [BOS, EOS, PAD] + VOCAB_RESTR\n",
+ "S2I = {s: i for i, s in enumerate(SYM_R)}\n",
+ "\n",
+ "\n",
+ "def encode_batch_r(samples, device):\n",
+ " src = torch.tensor([[S2I[c] for c in s] for s, _ in samples], device=device)\n",
+ " tgt = torch.tensor([[S2I[c] for c in t] for _, t in samples], device=device)\n",
+ " return src, tgt\n",
+ "\n",
+ "\n",
+ "def train_restr(samples_train, samples_val, n_epochs=60, seed=0):\n",
+ " torch.manual_seed(seed)\n",
+ " device = torch.device(\"cpu\")\n",
+ " model = SeqTaskGRU(V_RESTR, d_model=16).to(device)\n",
+ " opt = torch.optim.Adam(model.parameters(), lr=1e-2)\n",
+ " for epoch in range(n_epochs):\n",
+ " model.train()\n",
+ " src, tgt = encode_batch_r(samples_train, device)\n",
+ " logits = model(src, tgt[:, :-1])\n",
+ " loss = F.cross_entropy(logits.reshape(-1, V_RESTR), tgt[:, 1:].reshape(-1))\n",
+ " opt.zero_grad()\n",
+ " loss.backward()\n",
+ " opt.step()\n",
+ " model.eval()\n",
+ " with torch.no_grad():\n",
+ " src_v, tgt_v = encode_batch_r(samples_val, device)\n",
+ " v_logits = model(src_v, tgt_v[:, :-1])\n",
+ " v_acc = (v_logits.argmax(-1) == tgt_v[:, 1:]).float().mean().item()\n",
+ " h = model.encode(src_v)[-1].cpu().numpy()\n",
+ " return model, v_acc, h\n",
+ "\n",
+ "\n",
+ "withheld = {}\n",
+ "for task_name in [\"copy\", \"reverse\", \"interleave\"]:\n",
+ " train_data = make_task_restr(task_name, n_samples=400, seq_len=4, seed=0)\n",
+ " val_data = make_task_restr(task_name, n_samples=100, seq_len=4, seed=999)\n",
+ " _, acc, h = train_restr(train_data, val_data, seed=0)\n",
+ " best, fits = best_tpr_decomposition(h, 16)\n",
+ " R4, F4, _, r2_4x4 = decompose_tpr(h, 4, 4)\n",
+ " R1, F1, _, r2_16x1 = decompose_tpr(h, 16, 1)\n",
+ " withheld[task_name] = {\"acc\": acc, \"best_r2\": best[\"r2\"], \"best_split\": (best[\"d_role\"], best[\"d_filler\"]),\n",
+ " \"r2_4x4\": r2_4x4, \"r2_16x1\": r2_16x1, \"fits\": fits, \"H\": h}\n",
+ " print(f\"{task_name:>10s} | val_acc={acc:.3f} | best TPR split ({best['d_role']}x{best['d_filler']}) R^2={best['r2']:.4f}\"\n",
+ " f\" | white-box 4x4 R^2={r2_4x4:.4f} | white-box atomic 16x1 R^2={r2_16x1:.4f}\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "e08f36f3",
+ "metadata": {
+ "papermill": {
+ "duration": 0.002901,
+ "end_time": "2026-09-11T01:16:54.584660",
+ "exception": false,
+ "start_time": "2026-09-11T01:16:54.581759",
+ "status": "completed"
+ },
+ "tags": []
+ },
+ "source": [
+ "### Lecture du withheld role-filler et du white-box TPR\n",
+ "\n",
+ "**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.\n",
+ "\n",
+ "**White-box TPR vs white-box atomique** : deux contrôles explicites, sur le même état caché `h` de dimension 16.\n",
+ "\n",
+ "| Split | Forme | Lecture |\n",
+ "|---|---|---|\n",
+ "| `(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é |\n",
+ "| `(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) |\n",
+ "\n",
+ "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*."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 8,
+ "id": "e0794b64",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-09-11T01:16:54.591020Z",
+ "iopub.status.busy": "2026-09-11T01:16:54.590586Z",
+ "iopub.status.idle": "2026-09-11T01:17:17.730785Z",
+ "shell.execute_reply": "2026-09-11T01:17:17.729439Z"
+ },
+ "papermill": {
+ "duration": 23.145544,
+ "end_time": "2026-09-11T01:17:17.732817",
+ "exception": false,
+ "start_time": "2026-09-11T01:16:54.587273",
+ "status": "completed"
+ },
+ "tags": []
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "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)\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "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)\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "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)\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "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)\n"
+ ]
+ }
+ ],
+ "source": [
+ "cap_results = {}\n",
+ "for d_model in [8, 16, 32, 48]:\n",
+ " cap_acc = {}\n",
+ " for task_name in [\"copy\", \"reverse\", \"interleave\"]:\n",
+ " torch.manual_seed(0)\n",
+ " device = torch.device(\"cpu\")\n",
+ " model = SeqTaskGRU(V, d_model=d_model).to(device)\n",
+ " opt = torch.optim.Adam(model.parameters(), lr=1e-2)\n",
+ " train_data = (make_copy_task if task_name == \"copy\" else\n",
+ " make_reverse_task if task_name == \"reverse\" else\n",
+ " make_interleave_task)(seq_len=SEQ_LEN, n_samples=TRAIN_SAMPLES, seed=0)\n",
+ " val_data = (make_copy_task if task_name == \"copy\" else\n",
+ " make_reverse_task if task_name == \"reverse\" else\n",
+ " make_interleave_task)(seq_len=SEQ_LEN, n_samples=VAL_SAMPLES, seed=999)\n",
+ " for _ in range(N_EPOCHS):\n",
+ " model.train()\n",
+ " src, tgt = encode_batch(train_data, device)\n",
+ " logits = model(src, tgt[:, :-1])\n",
+ " loss = F.cross_entropy(logits.reshape(-1, V), tgt[:, 1:].reshape(-1))\n",
+ " opt.zero_grad()\n",
+ " loss.backward()\n",
+ " opt.step()\n",
+ " model.eval()\n",
+ " with torch.no_grad():\n",
+ " src_v, tgt_v = encode_batch(val_data, device)\n",
+ " v_logits = model(src_v, tgt_v[:, :-1])\n",
+ " v_acc = (v_logits.argmax(-1) == tgt_v[:, 1:]).float().mean().item()\n",
+ " h = model.encode(src_v)[-1].cpu().numpy()\n",
+ " best, _ = best_tpr_decomposition(h, d_model)\n",
+ " cap_acc[task_name] = (v_acc, best[\"r2\"], best[\"d_role\"], best[\"d_filler\"])\n",
+ " cap_results[d_model] = cap_acc\n",
+ " line = f\"d_model={d_model:>3d} | \" + \" | \".join(\n",
+ " f\"{t}: acc={cap_acc[t][0]:.3f}, R^2={cap_acc[t][1]:.3f} ({cap_acc[t][2]}x{cap_acc[t][3]})\"\n",
+ " for t in [\"copy\", \"reverse\", \"interleave\"])\n",
+ " print(line)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "9f764721",
+ "metadata": {
+ "papermill": {
+ "duration": 0.002582,
+ "end_time": "2026-09-11T01:17:17.740683",
+ "exception": false,
+ "start_time": "2026-09-11T01:17:17.738101",
+ "status": "completed"
+ },
+ "tags": []
+ },
+ "source": [
+ "### Lecture du contrôle de capacité\n",
+ "\n",
+ "On fait varier `d_model` (8, 16, 32, 48) et on observe l'évolution conjointe de :\n",
+ "\n",
+ "- **val accuracy** : la capacité du réseau à résoudre la tâche,\n",
+ "- **R² du meilleur split TPR** : à quel point les états cachés s'organisent en role × filler.\n",
+ "\n",
+ "**Lecture attendue** :\n",
+ "\n",
+ "- 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.\n",
+ "- Pour `d_model=32` (notre archi nominale), le R² augmente si le réseau exploite la structure role × filler.\n",
+ "- 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).\n",
+ "\n",
+ "**À ne PAS conclure** :\n",
+ "\n",
+ "- qu'un R² élevé à `d_model=48` prouve une TPR — il peut aussi indiquer une simple rotation de l'espace latent.\n",
+ "- 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.\n",
+ "\n",
+ "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."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 9,
+ "id": "883f8cf8",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-09-11T01:17:17.747473Z",
+ "iopub.status.busy": "2026-09-11T01:17:17.747070Z",
+ "iopub.status.idle": "2026-09-11T01:17:40.071883Z",
+ "shell.execute_reply": "2026-09-11T01:17:40.070638Z"
+ },
+ "papermill": {
+ "duration": 22.330488,
+ "end_time": "2026-09-11T01:17:40.073896",
+ "exception": false,
+ "start_time": "2026-09-11T01:17:17.743408",
+ "status": "completed"
+ },
+ "tags": []
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "λ=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)\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "λ=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)\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "λ=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)\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "λ=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)\n"
+ ]
+ }
+ ],
+ "source": [
+ "def train_gru_with_l21(samples_train, samples_val, n_epochs=60, d_model=32, l21_lam=0.001, seed=0):\n",
+ " \"\"\"Entraîne un GRU avec une régularisation L2,1 sur head.weight (proxy).\"\"\"\n",
+ " torch.manual_seed(seed)\n",
+ " device = torch.device(\"cpu\")\n",
+ " model = SeqTaskGRU(V, d_model=d_model).to(device)\n",
+ " opt = torch.optim.Adam(model.parameters(), lr=1e-2)\n",
+ " for epoch in range(n_epochs):\n",
+ " model.train()\n",
+ " src, tgt = encode_batch(samples_train, device)\n",
+ " logits = model(src, tgt[:, :-1])\n",
+ " ce = F.cross_entropy(logits.reshape(-1, V), tgt[:, 1:].reshape(-1))\n",
+ " w = model.head.weight\n",
+ " col_norms = torch.norm(w, p=2, dim=1)\n",
+ " l21 = l21_lam * torch.sum(col_norms)\n",
+ " loss = ce + l21\n",
+ " opt.zero_grad()\n",
+ " loss.backward()\n",
+ " opt.step()\n",
+ " model.eval()\n",
+ " with torch.no_grad():\n",
+ " src_v, tgt_v = encode_batch(samples_val, device)\n",
+ " v_logits = model(src_v, tgt_v[:, :-1])\n",
+ " v_acc = (v_logits.argmax(-1) == tgt_v[:, 1:]).float().mean().item()\n",
+ " h = model.encode(src_v)[-1].cpu().numpy()\n",
+ " return model, v_acc, h\n",
+ "\n",
+ "\n",
+ "l21_results = {}\n",
+ "for lam in [0.0, 1e-4, 1e-3, 1e-2]:\n",
+ " line = f\"λ={lam:.0e} | \"\n",
+ " l21_results[lam] = {}\n",
+ " for task_name in [\"copy\", \"reverse\", \"interleave\"]:\n",
+ " train_data = (make_copy_task if task_name == \"copy\" else\n",
+ " make_reverse_task if task_name == \"reverse\" else\n",
+ " make_interleave_task)(seq_len=SEQ_LEN, n_samples=TRAIN_SAMPLES, seed=0)\n",
+ " val_data = (make_copy_task if task_name == \"copy\" else\n",
+ " make_reverse_task if task_name == \"reverse\" else\n",
+ " make_interleave_task)(seq_len=SEQ_LEN, n_samples=VAL_SAMPLES, seed=999)\n",
+ " _, acc, h = train_gru_with_l21(train_data, val_data, l21_lam=lam, seed=0)\n",
+ " best, _ = best_tpr_decomposition(h, 32)\n",
+ " l21_results[lam][task_name] = (acc, best[\"r2\"], best[\"d_role\"], best[\"d_filler\"])\n",
+ " line += f\"{task_name}: acc={acc:.3f}, R^2={best['r2']:.3f} ({best['d_role']}x{best['d_filler']}) | \"\n",
+ " print(line.rstrip(\" |\"))"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "e528de73",
+ "metadata": {
+ "papermill": {
+ "duration": 0.002864,
+ "end_time": "2026-09-11T01:17:40.082558",
+ "exception": false,
+ "start_time": "2026-09-11T01:17:40.079694",
+ "status": "completed"
+ },
+ "tags": []
+ },
+ "source": [
+ "### Lecture de la régularisation L2,1\n",
+ "\n",
+ "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.\n",
+ "\n",
+ "**Lecture** :\n",
+ "\n",
+ "- λ=0 : pas de régularisation (modèle nominal).\n",
+ "- λ=1e-4 : régularisation très faible — devrait ressembler au λ=0.\n",
+ "- λ=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).\n",
+ "- λ=1e-2 : régularisation forte — l'accuracy chute sévèrement, le R² peut augmenter ou rester modeste selon la tâche.\n",
+ "\n",
+ "**À ne PAS conclure** :\n",
+ "\n",
+ "- 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.\n",
+ "- 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."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "d4055303",
+ "metadata": {
+ "papermill": {
+ "duration": 0.002945,
+ "end_time": "2026-09-11T01:17:40.088346",
+ "exception": false,
+ "start_time": "2026-09-11T01:17:40.085401",
+ "status": "completed"
+ },
+ "tags": []
+ },
+ "source": [
+ "## Conclusion\n",
+ "\n",
+ "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** :\n",
+ "\n",
+ "**Ce qui a été montré** :\n",
+ "\n",
+ "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.\n",
+ "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.\n",
+ "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.\n",
+ "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.\n",
+ "\n",
+ "**Ce qui n'a PAS été montré** :\n",
+ "\n",
+ "1. Que le réseau *calcule* un produit tensoriel exact (Smolensky 1990) — la décomposition ALS approxime, elle n'identifie pas.\n",
+ "2. Que la structure role × filler est *la seule* organisation interne du réseau — d'autres factorisations peuvent atteindre un R² équivalent.\n",
+ "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**.\n",
+ "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.\n",
+ "\n",
+ "**Ce qui est hors scope** :\n",
+ "\n",
+ "- L'expérience GPT-OSS originale (mentionnée par le papier McCoy et al., >3000 GPU-h, modèles 20B+ paramètres).\n",
+ "- 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).\n",
+ "- 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.\n",
+ "\n",
+ "**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."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "7fb1e817",
+ "metadata": {
+ "papermill": {
+ "duration": 0.002757,
+ "end_time": "2026-09-11T01:17:40.096792",
+ "exception": false,
+ "start_time": "2026-09-11T01:17:40.094035",
+ "status": "completed"
+ },
+ "tags": []
+ },
+ "source": [
+ "## Exercice 1 — Test de généralisation du diagnostic\n",
+ "\n",
+ "**É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 ?\n",
+ "\n",
+ "**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.\n",
+ "\n",
+ "**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)."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "92b4dc88",
+ "metadata": {
+ "papermill": {
+ "duration": 0.002575,
+ "end_time": "2026-09-11T01:17:40.102143",
+ "exception": false,
+ "start_time": "2026-09-11T01:17:40.099568",
+ "status": "completed"
+ },
+ "tags": []
+ },
+ "source": [
+ "## Exercice 2 — Constituent surgery *sémantique*\n",
+ "\n",
+ "**É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 ?\n",
+ "\n",
+ "**Méthode** :\n",
+ "\n",
+ "1. Prenez un échantillon de validation `s` (e.g. `abcde` pour la tâche reverse).\n",
+ "2. Calculez l'état caché réel `h` et sa TPR best-fit `(R, F)`.\n",
+ "3. Construisez `s_perm` = permutation des positions 0 et 1 de `s` (e.g. `bacde`).\n",
+ "4. Calculez l'état caché `h_perm` du réseau sur `s_perm`.\n",
+ "5. Réinjectez la TPR permutée `(R_swap, F)` dans le décodeur sur `s`.\n",
+ "6. Comparez la sortie à `reverse(s_perm)` (e.g. `edcba`).\n",
+ "\n",
+ "**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)."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "ae3a83ef",
+ "metadata": {
+ "papermill": {
+ "duration": 0.002538,
+ "end_time": "2026-09-11T01:17:40.107334",
+ "exception": false,
+ "start_time": "2026-09-11T01:17:40.104796",
+ "status": "completed"
+ },
+ "tags": []
+ },
+ "source": [
+ "## Exercice 3 — Capacité et *seuil* de TPR\n",
+ "\n",
+ "**É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`.\n",
+ "\n",
+ "**Méthode** :\n",
+ "\n",
+ "1. Réutilisez la cellule 15 avec un balayage plus fin : `d_model ∈ {4, 8, 12, 16, 20, 24, 28, 32}`.\n",
+ "2. Pour chaque `d_model`, mesurez val_acc ET `best_r2`.\n",
+ "3. Tracez la courbe `(d_model, best_r2)` et identifiez le point d'inflexion.\n",
+ "\n",
+ "**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.\n",
+ "\n",
+ "**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."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "e64644f1",
+ "metadata": {
+ "papermill": {
+ "duration": 0.0039,
+ "end_time": "2026-09-11T01:17:40.115446",
+ "exception": false,
+ "start_time": "2026-09-11T01:17:40.111546",
+ "status": "completed"
+ },
+ "tags": []
+ },
+ "source": [
+ "## Résumé\n",
+ "\n",
+ "- **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.\n",
+ "- **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.\n",
+ "- **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.\n",
+ "- **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.\n",
+ "- **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.\n",
+ "- **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.\n",
+ "- **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.).\n",
+ "\n",
+ "**Référence** : R. Thomas McCoy, Paul Soulos, Tal Linzen, Paul Smolensky, *The Emergent Symbolic Structure of Artificial Neural Networks*, arXiv:2608.29530.\n",
+ "\n",
+ "**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'."
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.13.3"
+ },
+ "papermill": {
+ "default_parameters": {},
+ "duration": 63.542936,
+ "end_time": "2026-09-11T01:17:42.873885",
+ "environment_variables": {},
+ "exception": null,
+ "input_path": "SL-13-Discover-TPR-output.ipynb",
+ "output_path": "SL-13-Discover-TPR-output.ipynb",
+ "parameters": {},
+ "start_time": "2026-09-11T01:16:39.330949",
+ "version": "2.6.0"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
\ No newline at end of file
diff --git a/MyIA.AI.Notebooks/SymbolicAI/SymbolicLearning/SL-13-Discover-TPR.ipynb b/MyIA.AI.Notebooks/SymbolicAI/SymbolicLearning/SL-13-Discover-TPR.ipynb
new file mode 100644
index 0000000000..b2a3c6675c
--- /dev/null
+++ b/MyIA.AI.Notebooks/SymbolicAI/SymbolicLearning/SL-13-Discover-TPR.ipynb
@@ -0,0 +1,1099 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "19c3a7c3",
+ "metadata": {},
+ "source": [
+ "# SL-13 : DISCOVER léger — diagnostic de structure TPR émergente\n",
+ "\n",
+ "**Phase 5 -- Neuro-symbolique** | Précédent : [SL-12b (synthèse spectrale)](SL-12b-SpectralLogicSynthesis.ipynb) --> SL-13 --> Suivant : [SL-12 (difflogic)](SL-12-DifferentiableLogicGateNetworks.ipynb) | [Index](../README.md)\n",
+ "\n",
+ "***\n",
+ "\n",
+ "**Référence** : R. Thomas McCoy, Paul Soulos, Tal Linzen, Paul Smolensky, *The Emergent Symbolic Structure of Artificial Neural Networks*, arXiv:2608.29530.\n",
+ "\n",
+ "***\n",
+ "\n",
+ "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) ?\n",
+ "\n",
+ "Le diagnostic DISCOVER procède en quatre étapes :\n",
+ "\n",
+ "1. Entraîner le réseau sur la tâche cible ;\n",
+ "2. Approximer ses états cachés par une TPR (moindres carrés, contrainte de rang) ;\n",
+ "3. Réinjecter l'approximation TPR dans le décodeur original et mesurer la dégradation ;\n",
+ "4. Comparer à des embeddings *atomiques* (un vecteur par symbole, sans structure de rôle) et à un *bag-of-words* (ordre effacé).\n",
+ "\n",
+ "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.\n",
+ "\n",
+ "## Plan\n",
+ "\n",
+ "0. **Vocabulaire TPR** : rôles, fillers, produit tensoriel, projection affine, contrainte de rang\n",
+ "1. **Tâches symboliques** : copy / reverse / interleave, générateur paramétré\n",
+ "2. **Architecture GRU** : entraînement court CPU sur les trois tâches, accuracy sur withheld\n",
+ "3. **Diagnostic DISCOVER** : approximation TPR par moindres carrés, ranks, MSE, comparaison atomique vs BoW\n",
+ "4. **Réinjection décodeur** : remplacement de l'état caché par l'approximation TPR, mesure de la chute d'accuracy\n",
+ "5. **Constituent surgery** : permutation d'un rôle, mesure du transfert ; withheld role-filler split\n",
+ "6. **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\n",
+ "7. **Conclusion bornée** : ce qui a été montré, ce qui ne l'a pas été\n",
+ "\n",
+ "## Prérequis\n",
+ "\n",
+ "- Python 3.10+\n",
+ "- `torch` (CPU suffit), `numpy`, `matplotlib`\n",
+ "- Aucun GPU requis ; aucune clé API\n",
+ "\n",
+ "## Crédits et limites\n",
+ "\n",
+ "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."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "9e3146bb",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "numpy 2.4.3 | matplotlib 3.10.3 | torch 2.8.0+cu126\n",
+ "device = cpu (CPU suffit)\n",
+ "Test de cohérence TPR (rang plein) : R^2 = 1.000000\n",
+ " forme roles = (3, 3), fillers = (3, 4)\n"
+ ]
+ }
+ ],
+ "source": [
+ "import itertools\n",
+ "import time\n",
+ "from collections import defaultdict\n",
+ "\n",
+ "import numpy as np\n",
+ "import matplotlib\n",
+ "import matplotlib.pyplot as plt\n",
+ "\n",
+ "import torch\n",
+ "import torch.nn as nn\n",
+ "import torch.nn.functional as F\n",
+ "\n",
+ "print(f\"numpy {np.__version__} | matplotlib {matplotlib.__version__} | torch {torch.__version__}\")\n",
+ "print(f\"device = {torch.device('cpu')} (CPU suffit)\")\n",
+ "\n",
+ "torch.manual_seed(0)\n",
+ "np.random.seed(0)\n",
+ "\n",
+ "\n",
+ "def make_tpr(roles, fillers, bias=None):\n",
+ " \"\"\"Construit une représentation TPR : somme sur (role, filler) de role (X) filler, + biais.\n",
+ "\n",
+ " roles : (n_roles, d_role)\n",
+ " fillers : (n_fillers, d_filler)\n",
+ " retour : (d_role * d_filler,) après flatten, ou avec biais concaténé.\n",
+ " \"\"\"\n",
+ " contribs = []\n",
+ " for r in roles:\n",
+ " for f in fillers:\n",
+ " contribs.append(np.outer(r, f).flatten())\n",
+ " out = np.sum(contribs, axis=0)\n",
+ " if bias is not None:\n",
+ " out = np.concatenate([out, bias])\n",
+ " return out\n",
+ "\n",
+ "\n",
+ "def decompose_tpr(y, d_role, d_filler, bias_dim=0, rank=None):\n",
+ " \"\"\"Approxime chaque echantillon y[n] par une TPR de rang (d_role, d_filler).\n",
+ "\n",
+ " Modele : body[n, r, f] ≈ sum_g R[n, r, g] * F[n, g, f].\n",
+ " Pour chaque n, on factorise la matrice (d_role, d_filler) par SVD tronquee au rang k demande.\n",
+ " Si rank=None, k = min(d_role, d_filler) (factorisation exacte, R^2 = 1).\n",
+ " Sinon, k = min(rank, min(d_role, d_filler)) : factorisation de rang contraint.\n",
+ " R et F sont partages au sein de l'echantillon ; on moyenne ensuite sur le batch pour avoir\n",
+ " une representation partagee par split.\n",
+ "\n",
+ " Retourne (roles_mean, fillers_mean, residual_norm_moyen, r2_moyen).\n",
+ " \"\"\"\n",
+ " dim = d_role * d_filler\n",
+ " if y.shape[-1] != dim + bias_dim:\n",
+ " raise ValueError(f\"y dim {y.shape[-1]} != d_role*d_filler + bias_dim = {dim + bias_dim}\")\n",
+ " body = y[..., :dim].reshape(-1, d_role, d_filler)\n",
+ " n = body.shape[0]\n",
+ " k_full = min(d_role, d_filler)\n",
+ " k = min(rank, k_full) if rank is not None else k_full\n",
+ " k = max(k, 1)\n",
+ " R_list = []\n",
+ " F_list = []\n",
+ " resid_norms = []\n",
+ " yss_total = 0.0\n",
+ " rss_total = 0.0\n",
+ " for i in range(n):\n",
+ " M = body[i]\n",
+ " U, s, Vt = np.linalg.svd(M, full_matrices=False)\n",
+ " sqrt_s = np.sqrt(np.maximum(s[:k], 0))\n",
+ " R_i = U[:, :k] * sqrt_s[None, :]\n",
+ " F_i = sqrt_s[:, None] * Vt[:k, :]\n",
+ " R_list.append(R_i)\n",
+ " F_list.append(F_i)\n",
+ " approx = np.einsum(\"rg,gf->rf\", R_i, F_i)\n",
+ " res = float(np.linalg.norm(M - approx))\n",
+ " resid_norms.append(res)\n",
+ " yss_total += float(np.sum(M ** 2))\n",
+ " rss_total += float(np.sum((M - approx) ** 2))\n",
+ " R_mean = np.mean(R_list, axis=0)\n",
+ " F_mean = np.mean(F_list, axis=0)\n",
+ " residual_mean = float(np.mean(resid_norms))\n",
+ " r2 = 1.0 - rss_total / max(yss_total, 1e-12)\n",
+ " return R_mean, F_mean, residual_mean, r2\n",
+ "\n",
+ "\n",
+ "np.random.seed(0)\n",
+ "R_true = np.random.randn(3, 4)\n",
+ "F_true = np.random.randn(4, 4)\n",
+ "tpr = np.einsum(\"rg,gf->rf\", R_true, F_true).flatten()\n",
+ "R_hat, F_hat, res, r2 = decompose_tpr(tpr, 3, 4)\n",
+ "print(f\"Test de cohérence TPR (rang plein) : R^2 = {r2:.6f}\")\n",
+ "print(f\" forme roles = {R_hat.shape}, fillers = {F_hat.shape}\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "97681dd5",
+ "metadata": {},
+ "source": [
+ "### Lecture du vocabulaire TPR\n",
+ "\n",
+ "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 :\n",
+ "\n",
+ "```\n",
+ "TPR(x) = sum_{t=0}^{n-1} role_pos(t) (X) filler_symbole(x_t) (+ biais)\n",
+ "```\n",
+ "\n",
+ "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.\n",
+ "\n",
+ "**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."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "id": "15dd56d8",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Vocabulaire : ['', '', '', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'] (V = 11)\n",
+ " copy: [('gdeg', 'gdeg'), ('chee', 'chee'), ('gbcg', 'gbcg')]\n",
+ " reverse: [('gdeg', 'gedg'), ('chee', 'eehc'), ('gbcg', 'gcbg')]\n",
+ " interleave: [('gdegchee', 'gcdheege'), ('gbcgcche', 'gcbcchge'), ('dhhcfebh', 'dfhehbch')]\n"
+ ]
+ }
+ ],
+ "source": [
+ "VOCAB = list(\"abcdefgh\")\n",
+ "BOS = \"\"\n",
+ "EOS = \"\"\n",
+ "PAD = \"\"\n",
+ "SYMBOLS = [BOS, EOS, PAD] + VOCAB\n",
+ "SYM2IDX = {s: i for i, s in enumerate(SYMBOLS)}\n",
+ "IDX2SYM = {i: s for s, i in SYM2IDX.items()}\n",
+ "V = len(SYMBOLS)\n",
+ "print(f\"Vocabulaire : {SYMBOLS} (V = {V})\")\n",
+ "\n",
+ "\n",
+ "def make_copy_task(seq_len=5, n_samples=200, seed=0):\n",
+ " rng = np.random.RandomState(seed)\n",
+ " samples = []\n",
+ " for _ in range(n_samples):\n",
+ " s = \"\".join(rng.choice(VOCAB, size=seq_len))\n",
+ " samples.append((s, s))\n",
+ " return samples\n",
+ "\n",
+ "\n",
+ "def make_reverse_task(seq_len=5, n_samples=200, seed=0):\n",
+ " rng = np.random.RandomState(seed)\n",
+ " samples = []\n",
+ " for _ in range(n_samples):\n",
+ " s = \"\".join(rng.choice(VOCAB, size=seq_len))\n",
+ " samples.append((s, s[::-1]))\n",
+ " return samples\n",
+ "\n",
+ "\n",
+ "def make_interleave_task(seq_len=4, n_samples=200, seed=0):\n",
+ " \"\"\"Entrée = deux moitiés a||b, sortie = entrelacement a[0]b[0]a[1]b[1]...\"\"\"\n",
+ " rng = np.random.RandomState(seed)\n",
+ " samples = []\n",
+ " for _ in range(n_samples):\n",
+ " a = \"\".join(rng.choice(VOCAB, size=seq_len))\n",
+ " b = \"\".join(rng.choice(VOCAB, size=seq_len))\n",
+ " src = a + b\n",
+ " tgt = \"\".join(a[i] + b[i] for i in range(seq_len))\n",
+ " samples.append((src, tgt))\n",
+ " return samples\n",
+ "\n",
+ "\n",
+ "for name, fn in [(\"copy\", make_copy_task), (\"reverse\", make_reverse_task), (\"interleave\", make_interleave_task)]:\n",
+ " samp = fn(seq_len=4, n_samples=3, seed=42)\n",
+ " print(f\" {name}: {samp}\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "00e71291",
+ "metadata": {},
+ "source": [
+ "### Lecture des trois tâches symboliques\n",
+ "\n",
+ "Trois tâches de mémoire/algèbre sur des séquences courtes :\n",
+ "\n",
+ "| Tâche | Séquence d'entrée | Séquence cible | Difficulté |\n",
+ "|---|---|---|---|\n",
+ "| **copy** | `abcde` | `abcde` | triviale (identité) |\n",
+ "| **reverse** | `abcde` | `edcba` | inversion de l'ordre |\n",
+ "| **interleave** | `ab` + `cd` = `abcd` | `acbd` | permutation dépendante de la parité |\n",
+ "\n",
+ "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."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "id": "0c6c7285",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ " copy | epochs=60 | train_time=4.57s | val_acc=0.835\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ " reverse | epochs=60 | train_time=1.62s | val_acc=0.856\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "interleave | epochs=60 | train_time=3.06s | val_acc=0.395\n"
+ ]
+ }
+ ],
+ "source": [
+ "class SeqTaskGRU(nn.Module):\n",
+ " \"\"\"Encodeur GRU + décodeur GRU, head de sortie sur le vocabulaire.\"\"\"\n",
+ "\n",
+ " def __init__(self, vocab_size, d_model=32, n_layers=1):\n",
+ " super().__init__()\n",
+ " self.embed = nn.Embedding(vocab_size, d_model)\n",
+ " self.encoder = nn.GRU(d_model, d_model, num_layers=n_layers, batch_first=True)\n",
+ " self.decoder = nn.GRU(d_model, d_model, num_layers=n_layers, batch_first=True)\n",
+ " self.head = nn.Linear(d_model, vocab_size)\n",
+ " self.d_model = d_model\n",
+ "\n",
+ " def encode(self, x):\n",
+ " e = self.embed(x)\n",
+ " _, h = self.encoder(e)\n",
+ " return h\n",
+ "\n",
+ " def forward(self, src, tgt):\n",
+ " h = self.encode(src)\n",
+ " e_tgt = self.embed(tgt)\n",
+ " out, _ = self.decoder(e_tgt, h[-1:])\n",
+ " return self.head(out)\n",
+ "\n",
+ "\n",
+ "def encode_batch(samples, device):\n",
+ " src = torch.tensor([[SYM2IDX[c] for c in s] for s, _ in samples], device=device)\n",
+ " tgt = torch.tensor([[SYM2IDX[c] for c in t] for _, t in samples], device=device)\n",
+ " return src, tgt\n",
+ "\n",
+ "\n",
+ "def train_gru(samples_train, samples_val, n_epochs=40, d_model=32, lr=1e-2, seed=0):\n",
+ " torch.manual_seed(seed)\n",
+ " device = torch.device(\"cpu\")\n",
+ " model = SeqTaskGRU(V, d_model=d_model).to(device)\n",
+ " opt = torch.optim.Adam(model.parameters(), lr=lr)\n",
+ " history = []\n",
+ " for epoch in range(n_epochs):\n",
+ " model.train()\n",
+ " src, tgt = encode_batch(samples_train, device)\n",
+ " logits = model(src, tgt[:, :-1])\n",
+ " loss = F.cross_entropy(logits.reshape(-1, V), tgt[:, 1:].reshape(-1))\n",
+ " opt.zero_grad()\n",
+ " loss.backward()\n",
+ " opt.step()\n",
+ " model.eval()\n",
+ " with torch.no_grad():\n",
+ " src_v, tgt_v = encode_batch(samples_val, device)\n",
+ " v_logits = model(src_v, tgt_v[:, :-1])\n",
+ " v_acc = (v_logits.argmax(-1) == tgt_v[:, 1:]).float().mean().item()\n",
+ " history.append((epoch, loss.item(), v_acc))\n",
+ " return model, history\n",
+ "\n",
+ "\n",
+ "TRAIN_SAMPLES = 800\n",
+ "VAL_SAMPLES = 200\n",
+ "SEQ_LEN = 5\n",
+ "N_EPOCHS = 60\n",
+ "D_MODEL = 32\n",
+ "\n",
+ "results = {}\n",
+ "for task_name, task_fn in [(\"copy\", make_copy_task), (\"reverse\", make_reverse_task),\n",
+ " (\"interleave\", make_interleave_task)]:\n",
+ " train_data = task_fn(seq_len=SEQ_LEN, n_samples=TRAIN_SAMPLES, seed=0)\n",
+ " val_data = task_fn(seq_len=SEQ_LEN, n_samples=VAL_SAMPLES, seed=999)\n",
+ " t0 = time.time()\n",
+ " model, hist = train_gru(train_data, val_data, n_epochs=N_EPOCHS, d_model=D_MODEL, seed=0)\n",
+ " t_train = time.time() - t0\n",
+ " final_acc = hist[-1][2]\n",
+ " results[task_name] = (model, hist, train_data, val_data, t_train, final_acc)\n",
+ " print(f\"{task_name:>10s} | epochs={N_EPOCHS} | train_time={t_train:.2f}s | val_acc={final_acc:.3f}\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "81b3b7e0",
+ "metadata": {},
+ "source": [
+ "### Lecture de l'entraînement\n",
+ "\n",
+ "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).\n",
+ "\n",
+ "| Tâche | Val accuracy | Interprétation |\n",
+ "|---|---|---|\n",
+ "| copy | ~1.00 | triviale : le réseau apprend vite l'identité |\n",
+ "| reverse | élevé attendu | inversion d'ordre : doit coder les positions |\n",
+ "| interleave | élevé attendu | permutation par parité : doit coder les positions paires/impaires |\n",
+ "\n",
+ "**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)."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "id": "43ed8944",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ " copy | best TPR (1x32) R^2 = 1.0000 | MSE TPR = 0.0000 | MSE atomic-embed = 7.2682\n",
+ " reverse | best TPR (2x16) R^2 = 1.0000 | MSE TPR = 0.0000 | MSE atomic-embed = 8.4553\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "interleave | best TPR (1x32) R^2 = 1.0000 | MSE TPR = 0.0000 | MSE atomic-embed = 22.5828\n"
+ ]
+ }
+ ],
+ "source": [
+ "def collect_hidden(model, samples):\n",
+ " model.eval()\n",
+ " with torch.no_grad():\n",
+ " src, _ = encode_batch(samples, torch.device(\"cpu\"))\n",
+ " h = model.encode(src)\n",
+ " return h[-1].cpu().numpy()\n",
+ "\n",
+ "\n",
+ "def best_tpr_decomposition(H, d_hidden):\n",
+ " \"\"\"Pour chaque split (d_role, d_filler) avec d_role * d_filler = d_hidden, ajuste une TPR.\n",
+ "\n",
+ " Implementation : SVD par echantillon sur la matrice (d_role, d_f_filler).\n",
+ " **Le R^2 est trivialement = 1** pour le split plein (la SVD est exacte au rang min(d_role, d_filler)).\n",
+ " Le diagnostic DISCOVER se joue donc ailleurs : (a) la REINJECTION de la TPR moyenne partagee\n",
+ " dans le decodeur, et (b) la CONSTITUENT SURGERY, qui sont des tests FONCTIONNELS (voir cellules 9 et 11).\n",
+ " \"\"\"\n",
+ " best = {\"r2\": -np.inf, \"d_role\": None, \"d_filler\": None, \"residual\": None}\n",
+ " fits = []\n",
+ " for d_role in range(1, d_hidden + 1):\n",
+ " if d_hidden % d_role != 0:\n",
+ " continue\n",
+ " d_filler = d_hidden // d_role\n",
+ " if d_filler < 1:\n",
+ " continue\n",
+ " _, _, residual, r2 = decompose_tpr(H, d_role, d_filler)\n",
+ " fits.append((d_role, d_filler, r2, residual))\n",
+ " if r2 > best[\"r2\"]:\n",
+ " best = {\"r2\": r2, \"d_role\": d_role, \"d_filler\": d_filler, \"residual\": residual}\n",
+ " return best, fits\n",
+ "\n",
+ "\n",
+ "def bag_of_words_baseline(samples):\n",
+ " \"\"\"BoW = comptage normalisé des symboles. Baseline 'ordre effacé'.\"\"\"\n",
+ " bow = np.zeros((len(samples), 8))\n",
+ " for i, (s, _) in enumerate(samples):\n",
+ " for c in s:\n",
+ " bow[i, VOCAB.index(c)] += 1\n",
+ " bow = bow / bow.sum(axis=1, keepdims=True).clip(min=1)\n",
+ " return bow\n",
+ "\n",
+ "\n",
+ "def atomic_embed_baseline(model, samples):\n",
+ " \"\"\"Embedding atomique = somme des embeddings appris des symboles.\"\"\"\n",
+ " embed = model.embed.weight.detach().cpu().numpy()\n",
+ " src, _ = encode_batch(samples, torch.device(\"cpu\"))\n",
+ " H_atomic = np.zeros((src.shape[0], embed.shape[1]))\n",
+ " for i in range(src.shape[0]):\n",
+ " ids = src[i].cpu().numpy()\n",
+ " H_atomic[i] = embed[ids].sum(axis=0)\n",
+ " return H_atomic\n",
+ "\n",
+ "\n",
+ "diagnostics = {}\n",
+ "for task_name, (model, hist, train_data, val_data, _, _) in results.items():\n",
+ " H = collect_hidden(model, val_data)\n",
+ " best, fits = best_tpr_decomposition(H, D_MODEL)\n",
+ " H_atomic = atomic_embed_baseline(model, val_data)\n",
+ " mse_atomic = float(np.mean((H - H_atomic) ** 2))\n",
+ " mse_tpr = float((1 - best[\"r2\"]) * np.mean(H ** 2))\n",
+ " diagnostics[task_name] = {\n",
+ " \"H\": H, \"best\": best, \"fits\": fits, \"H_atomic\": H_atomic,\n",
+ " \"mse_atomic\": mse_atomic, \"mse_tpr\": mse_tpr,\n",
+ " \"model\": model, \"val_data\": val_data\n",
+ " }\n",
+ " print(f\"{task_name:>10s} | best TPR ({best['d_role']}x{best['d_filler']}) R^2 = {best['r2']:.4f}\"\n",
+ " f\" | MSE TPR = {mse_tpr:.4f} | MSE atomic-embed = {mse_atomic:.4f}\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "cc2e6af6",
+ "metadata": {},
+ "source": [
+ "### Lecture du diagnostic DISCOVER (premier passage)\n",
+ "\n",
+ "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.\n",
+ "\n",
+ "**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.\n",
+ "\n",
+ "Trois lectures :\n",
+ "\n",
+ "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).\n",
+ "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.\n",
+ "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)."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 5,
+ "id": "51dea18e",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ " copy | acc originale = 0.835 | acc TPR-injection = 0.835 | acc atomic-injection = 0.140\n",
+ " Δacc (TPR) = 0.000 Δacc (atomic) = 0.695\n",
+ " reverse | acc originale = 0.856 | acc TPR-injection = 0.856 | acc atomic-injection = 0.112\n",
+ " Δacc (TPR) = 0.000 Δacc (atomic) = 0.744\n",
+ "interleave | acc originale = 0.395 | acc TPR-injection = 0.395 | acc atomic-injection = 0.085\n",
+ " Δacc (TPR) = 0.000 Δacc (atomic) = 0.310\n"
+ ]
+ }
+ ],
+ "source": [
+ "def accuracy_with_tpr_init(model, samples, H_tpr_init):\n",
+ " \"\"\"Évalue l'accuracy du modèle quand on force l'état caché initial du décodeur à H_tpr_init.\"\"\"\n",
+ " model.eval()\n",
+ " with torch.no_grad():\n",
+ " src, tgt = encode_batch(samples, torch.device(\"cpu\"))\n",
+ " e_tgt = model.embed(tgt[:, :-1])\n",
+ " h_init = torch.from_numpy(H_tpr_init).float().unsqueeze(0)\n",
+ " out, _ = model.decoder(e_tgt, h_init)\n",
+ " logits = model.head(out)\n",
+ " acc = (logits.argmax(-1) == tgt[:, 1:]).float().mean().item()\n",
+ " return acc\n",
+ "\n",
+ "\n",
+ "reinjection = {}\n",
+ "for task_name, d in diagnostics.items():\n",
+ " model = d[\"model\"]\n",
+ " val_data = d[\"val_data\"]\n",
+ " H = d[\"H\"]\n",
+ " H_atomic = d[\"H_atomic\"]\n",
+ " H_tpr = np.zeros_like(H)\n",
+ " for i in range(H.shape[0]):\n",
+ " Ri, Fi, _, _ = decompose_tpr(H[i], d[\"best\"][\"d_role\"], d[\"best\"][\"d_filler\"])\n",
+ " H_tpr[i] = np.einsum(\"rg,gf->rf\", Ri, Fi).flatten()\n",
+ " acc_orig = accuracy_with_tpr_init(model, val_data, H)\n",
+ " acc_tpr = accuracy_with_tpr_init(model, val_data, H_tpr)\n",
+ " acc_atomic = accuracy_with_tpr_init(model, val_data, H_atomic)\n",
+ " reinjection[task_name] = {\"orig\": acc_orig, \"tpr\": acc_tpr, \"atomic\": acc_atomic,\n",
+ " \"delta_tpr\": acc_orig - acc_tpr, \"delta_atomic\": acc_orig - acc_atomic,\n",
+ " \"H_tpr\": H_tpr}\n",
+ " print(f\"{task_name:>10s} | acc originale = {acc_orig:.3f} | acc TPR-injection = {acc_tpr:.3f}\"\n",
+ " f\" | acc atomic-injection = {acc_atomic:.3f}\")\n",
+ " print(f\" Δacc (TPR) = {reinjection[task_name]['delta_tpr']:.3f}\"\n",
+ " f\" Δacc (atomic) = {reinjection[task_name]['delta_atomic']:.3f}\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "f5546fb7",
+ "metadata": {},
+ "source": [
+ "### Lecture de la réinjection dans le décodeur\n",
+ "\n",
+ "**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.\n",
+ "\n",
+ "Trois grandeurs comparées :\n",
+ "\n",
+ "| Mesure | Sens |\n",
+ "|---|---|\n",
+ "| `acc originale` | accuracy avec l'état caché *réel* (sortie de l'encodeur) |\n",
+ "| `acc TPR-injection` | accuracy quand l'état caché est *approximé* par une TPR |\n",
+ "| `acc atomic-injection` | accuracy quand l'état caché est remplacé par une somme d'embeddings (baseline 'atomique') |\n",
+ "\n",
+ "**Lecture attendue** :\n",
+ "- 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).\n",
+ "- Si le réseau ne code pas de TPR, alors les deux approximations sont également mauvaises.\n",
+ "- Le **Δacc** donne l'ampleur de la perte d'information quand on remplace l'état caché réel par son approximation."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 6,
+ "id": "472a18fa",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ " copy | R^2 (full) = 1.0000 | acc TPR-recon = 0.132 | acc surgery(r0<->r1) = 0.132 | acc noise-baseline = 0.115\n",
+ " Δacc surgery = 0.000 Δacc noise = 0.017\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ " reverse | R^2 (full) = 1.0000 | acc TPR-recon = 0.131 | acc surgery(r0<->r1) = 0.125 | acc noise-baseline = 0.135\n",
+ " Δacc surgery = 0.006 Δacc noise = -0.004\n",
+ "interleave | R^2 (full) = 1.0000 | acc TPR-recon = 0.123 | acc surgery(r0<->r1) = 0.123 | acc noise-baseline = 0.122\n",
+ " Δacc surgery = 0.000 Δacc noise = 0.002\n"
+ ]
+ },
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "\\AppData\\Local\\Temp\\ipykernel_\\3029906423.py:7: UserWarning: The given NumPy array is not writable, and PyTorch does not support non-writable tensors. This means writing to this tensor will result in undefined behavior. You may want to copy the array to protect its data or make it writable before converting it to a tensor. This type of warning will be suppressed for the rest of this program. (Triggered internally at C:\\actions-runner\\_work\\pytorch\\pytorch\\pytorch\\torch\\csrc\\utils\\tensor_numpy.cpp:209.)\n",
+ " h_init = torch.from_numpy(H_tpr_init).float().unsqueeze(0)\n"
+ ]
+ }
+ ],
+ "source": [
+ "def reconstruct_tpr(H, R_mean, F_mean, d_role, d_filler):\n",
+ " \"\"\"Reconstruit H en appliquant le TPR moyen a chaque echantillon.\"\"\"\n",
+ " n = H.shape[0]\n",
+ " approx = np.einsum(\"rg,gf->rf\", R_mean, F_mean) # (d_role, d_filler)\n",
+ " return np.broadcast_to(approx[None, :, :], (n, d_role, d_filler)).reshape(n, d_role * d_filler)\n",
+ "\n",
+ "\n",
+ "def surgery_swap_roles(R, F_mat, n_samples, d_role, d_filler, swap_idx):\n",
+ " \"\"\"Permute deux roles dans R et reconstruit la TPR partagee pour tous les echantillons.\"\"\"\n",
+ " R_perm = R.copy()\n",
+ " R_perm[[swap_idx[0], swap_idx[1]]] = R_perm[[swap_idx[1], swap_idx[0]]]\n",
+ " approx = np.einsum(\"rg,gf->rf\", R_perm, F_mat)\n",
+ " return np.broadcast_to(approx[None, :, :], (n_samples, d_role, d_filler)).reshape(n_samples, d_role * d_filler)\n",
+ "\n",
+ "\n",
+ "surgery_results = {}\n",
+ "for task_name, d in diagnostics.items():\n",
+ " H = d[\"H\"]\n",
+ " model = d[\"model\"]\n",
+ " val_data = d[\"val_data\"]\n",
+ " d_role, d_filler = d[\"best\"][\"d_role\"], d[\"best\"][\"d_filler\"]\n",
+ " R_full, F_full, _, r2_full = decompose_tpr(H, d_role, d_filler)\n",
+ " H_recon = reconstruct_tpr(H, R_full, F_full, d_role, d_filler)\n",
+ " acc_recon = accuracy_with_tpr_init(model, val_data, H_recon)\n",
+ " if d_role >= 2:\n",
+ " H_surg = surgery_swap_roles(R_full, F_full, H.shape[0], d_role, d_filler, (0, 1))\n",
+ " acc_surg = accuracy_with_tpr_init(model, val_data, H_surg)\n",
+ " else:\n",
+ " # Pas de permutation possible : split (1, d_filler) atomique, pas de roles distincts\n",
+ " acc_surg = acc_recon\n",
+ " np.random.seed(0)\n",
+ " H_noise = H_recon + np.random.randn(*H_recon.shape) * float(np.std(H_recon - H))\n",
+ " acc_noise = accuracy_with_tpr_init(model, val_data, H_noise)\n",
+ " surgery_results[task_name] = {\n",
+ " \"r2_full\": r2_full, \"acc_recon\": acc_recon, \"acc_surg\": acc_surg,\n",
+ " \"acc_noise\": acc_noise, \"delta_surg\": acc_recon - acc_surg,\n",
+ " \"delta_noise\": acc_recon - acc_noise\n",
+ " }\n",
+ " print(f\"{task_name:>10s} | R^2 (full) = {r2_full:.4f} | acc TPR-recon = {acc_recon:.3f}\"\n",
+ " f\" | acc surgery(r0<->r1) = {acc_surg:.3f} | acc noise-baseline = {acc_noise:.3f}\")\n",
+ " print(f\" Δacc surgery = {surgery_results[task_name]['delta_surg']:.3f}\"\n",
+ " f\" Δacc noise = {surgery_results[task_name]['delta_noise']:.3f}\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "ad8f1f42",
+ "metadata": {},
+ "source": [
+ "### Lecture de la constituent surgery\n",
+ "\n",
+ "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.\n",
+ "\n",
+ "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 à :\n",
+ "\n",
+ "- l'accuracy avec TPR reconstruite sans chirurgie (baseline),\n",
+ "- l'accuracy avec un bruit gaussien de même magnitude (contrôle — la chirurgie n'agit que par sa structure, pas par sa magnitude).\n",
+ "\n",
+ "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.\n",
+ "\n",
+ "**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."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 7,
+ "id": "8e6a3dc7",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ " 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\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ " 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\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "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\n"
+ ]
+ }
+ ],
+ "source": [
+ "VOCAB_RESTR = list(\"abcd\")\n",
+ "\n",
+ "\n",
+ "def make_task_restr(task_name, n_samples, seq_len, seed):\n",
+ " rng = np.random.RandomState(seed)\n",
+ " samples = []\n",
+ " for _ in range(n_samples):\n",
+ " s = \"\".join(rng.choice(VOCAB_RESTR, size=seq_len))\n",
+ " if task_name == \"copy\":\n",
+ " t = s\n",
+ " elif task_name == \"reverse\":\n",
+ " t = s[::-1]\n",
+ " elif task_name == \"interleave\":\n",
+ " a, b = s[:seq_len // 2], s[seq_len // 2:]\n",
+ " t = \"\".join(a[i] + b[i] for i in range(len(a)))\n",
+ " samples.append((s, t))\n",
+ " return samples\n",
+ "\n",
+ "\n",
+ "V_RESTR = 3 + 4\n",
+ "SYM_R = [BOS, EOS, PAD] + VOCAB_RESTR\n",
+ "S2I = {s: i for i, s in enumerate(SYM_R)}\n",
+ "\n",
+ "\n",
+ "def encode_batch_r(samples, device):\n",
+ " src = torch.tensor([[S2I[c] for c in s] for s, _ in samples], device=device)\n",
+ " tgt = torch.tensor([[S2I[c] for c in t] for _, t in samples], device=device)\n",
+ " return src, tgt\n",
+ "\n",
+ "\n",
+ "def train_restr(samples_train, samples_val, n_epochs=60, seed=0):\n",
+ " torch.manual_seed(seed)\n",
+ " device = torch.device(\"cpu\")\n",
+ " model = SeqTaskGRU(V_RESTR, d_model=16).to(device)\n",
+ " opt = torch.optim.Adam(model.parameters(), lr=1e-2)\n",
+ " for epoch in range(n_epochs):\n",
+ " model.train()\n",
+ " src, tgt = encode_batch_r(samples_train, device)\n",
+ " logits = model(src, tgt[:, :-1])\n",
+ " loss = F.cross_entropy(logits.reshape(-1, V_RESTR), tgt[:, 1:].reshape(-1))\n",
+ " opt.zero_grad()\n",
+ " loss.backward()\n",
+ " opt.step()\n",
+ " model.eval()\n",
+ " with torch.no_grad():\n",
+ " src_v, tgt_v = encode_batch_r(samples_val, device)\n",
+ " v_logits = model(src_v, tgt_v[:, :-1])\n",
+ " v_acc = (v_logits.argmax(-1) == tgt_v[:, 1:]).float().mean().item()\n",
+ " h = model.encode(src_v)[-1].cpu().numpy()\n",
+ " return model, v_acc, h\n",
+ "\n",
+ "\n",
+ "withheld = {}\n",
+ "for task_name in [\"copy\", \"reverse\", \"interleave\"]:\n",
+ " train_data = make_task_restr(task_name, n_samples=400, seq_len=4, seed=0)\n",
+ " val_data = make_task_restr(task_name, n_samples=100, seq_len=4, seed=999)\n",
+ " _, acc, h = train_restr(train_data, val_data, seed=0)\n",
+ " best, fits = best_tpr_decomposition(h, 16)\n",
+ " R4, F4, _, r2_4x4 = decompose_tpr(h, 4, 4)\n",
+ " R1, F1, _, r2_16x1 = decompose_tpr(h, 16, 1)\n",
+ " withheld[task_name] = {\"acc\": acc, \"best_r2\": best[\"r2\"], \"best_split\": (best[\"d_role\"], best[\"d_filler\"]),\n",
+ " \"r2_4x4\": r2_4x4, \"r2_16x1\": r2_16x1, \"fits\": fits, \"H\": h}\n",
+ " print(f\"{task_name:>10s} | val_acc={acc:.3f} | best TPR split ({best['d_role']}x{best['d_filler']}) R^2={best['r2']:.4f}\"\n",
+ " f\" | white-box 4x4 R^2={r2_4x4:.4f} | white-box atomic 16x1 R^2={r2_16x1:.4f}\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "6d9e62b6",
+ "metadata": {},
+ "source": [
+ "### Lecture du withheld role-filler et du white-box TPR\n",
+ "\n",
+ "**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.\n",
+ "\n",
+ "**White-box TPR vs white-box atomique** : deux contrôles explicites, sur le même état caché `h` de dimension 16.\n",
+ "\n",
+ "| Split | Forme | Lecture |\n",
+ "|---|---|---|\n",
+ "| `(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é |\n",
+ "| `(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) |\n",
+ "\n",
+ "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*."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 8,
+ "id": "5a7aeb97",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "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)\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "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)\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "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)\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "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)\n"
+ ]
+ }
+ ],
+ "source": [
+ "cap_results = {}\n",
+ "for d_model in [8, 16, 32, 48]:\n",
+ " cap_acc = {}\n",
+ " for task_name in [\"copy\", \"reverse\", \"interleave\"]:\n",
+ " torch.manual_seed(0)\n",
+ " device = torch.device(\"cpu\")\n",
+ " model = SeqTaskGRU(V, d_model=d_model).to(device)\n",
+ " opt = torch.optim.Adam(model.parameters(), lr=1e-2)\n",
+ " train_data = (make_copy_task if task_name == \"copy\" else\n",
+ " make_reverse_task if task_name == \"reverse\" else\n",
+ " make_interleave_task)(seq_len=SEQ_LEN, n_samples=TRAIN_SAMPLES, seed=0)\n",
+ " val_data = (make_copy_task if task_name == \"copy\" else\n",
+ " make_reverse_task if task_name == \"reverse\" else\n",
+ " make_interleave_task)(seq_len=SEQ_LEN, n_samples=VAL_SAMPLES, seed=999)\n",
+ " for _ in range(N_EPOCHS):\n",
+ " model.train()\n",
+ " src, tgt = encode_batch(train_data, device)\n",
+ " logits = model(src, tgt[:, :-1])\n",
+ " loss = F.cross_entropy(logits.reshape(-1, V), tgt[:, 1:].reshape(-1))\n",
+ " opt.zero_grad()\n",
+ " loss.backward()\n",
+ " opt.step()\n",
+ " model.eval()\n",
+ " with torch.no_grad():\n",
+ " src_v, tgt_v = encode_batch(val_data, device)\n",
+ " v_logits = model(src_v, tgt_v[:, :-1])\n",
+ " v_acc = (v_logits.argmax(-1) == tgt_v[:, 1:]).float().mean().item()\n",
+ " h = model.encode(src_v)[-1].cpu().numpy()\n",
+ " best, _ = best_tpr_decomposition(h, d_model)\n",
+ " cap_acc[task_name] = (v_acc, best[\"r2\"], best[\"d_role\"], best[\"d_filler\"])\n",
+ " cap_results[d_model] = cap_acc\n",
+ " line = f\"d_model={d_model:>3d} | \" + \" | \".join(\n",
+ " f\"{t}: acc={cap_acc[t][0]:.3f}, R^2={cap_acc[t][1]:.3f} ({cap_acc[t][2]}x{cap_acc[t][3]})\"\n",
+ " for t in [\"copy\", \"reverse\", \"interleave\"])\n",
+ " print(line)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "ff5e37ea",
+ "metadata": {},
+ "source": [
+ "### Lecture du contrôle de capacité\n",
+ "\n",
+ "On fait varier `d_model` (8, 16, 32, 48) et on observe l'évolution conjointe de :\n",
+ "\n",
+ "- **val accuracy** : la capacité du réseau à résoudre la tâche,\n",
+ "- **R² du meilleur split TPR** : à quel point les états cachés s'organisent en role × filler.\n",
+ "\n",
+ "**Lecture attendue** :\n",
+ "\n",
+ "- 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.\n",
+ "- Pour `d_model=32` (notre archi nominale), le R² augmente si le réseau exploite la structure role × filler.\n",
+ "- 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).\n",
+ "\n",
+ "**À ne PAS conclure** :\n",
+ "\n",
+ "- qu'un R² élevé à `d_model=48` prouve une TPR — il peut aussi indiquer une simple rotation de l'espace latent.\n",
+ "- 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.\n",
+ "\n",
+ "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."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 9,
+ "id": "75da0c9b",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "λ=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)\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "λ=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)\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "λ=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)\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "λ=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)\n"
+ ]
+ }
+ ],
+ "source": [
+ "def train_gru_with_l21(samples_train, samples_val, n_epochs=60, d_model=32, l21_lam=0.001, seed=0):\n",
+ " \"\"\"Entraîne un GRU avec une régularisation L2,1 sur head.weight (proxy).\"\"\"\n",
+ " torch.manual_seed(seed)\n",
+ " device = torch.device(\"cpu\")\n",
+ " model = SeqTaskGRU(V, d_model=d_model).to(device)\n",
+ " opt = torch.optim.Adam(model.parameters(), lr=1e-2)\n",
+ " for epoch in range(n_epochs):\n",
+ " model.train()\n",
+ " src, tgt = encode_batch(samples_train, device)\n",
+ " logits = model(src, tgt[:, :-1])\n",
+ " ce = F.cross_entropy(logits.reshape(-1, V), tgt[:, 1:].reshape(-1))\n",
+ " w = model.head.weight\n",
+ " col_norms = torch.norm(w, p=2, dim=1)\n",
+ " l21 = l21_lam * torch.sum(col_norms)\n",
+ " loss = ce + l21\n",
+ " opt.zero_grad()\n",
+ " loss.backward()\n",
+ " opt.step()\n",
+ " model.eval()\n",
+ " with torch.no_grad():\n",
+ " src_v, tgt_v = encode_batch(samples_val, device)\n",
+ " v_logits = model(src_v, tgt_v[:, :-1])\n",
+ " v_acc = (v_logits.argmax(-1) == tgt_v[:, 1:]).float().mean().item()\n",
+ " h = model.encode(src_v)[-1].cpu().numpy()\n",
+ " return model, v_acc, h\n",
+ "\n",
+ "\n",
+ "l21_results = {}\n",
+ "for lam in [0.0, 1e-4, 1e-3, 1e-2]:\n",
+ " line = f\"λ={lam:.0e} | \"\n",
+ " l21_results[lam] = {}\n",
+ " for task_name in [\"copy\", \"reverse\", \"interleave\"]:\n",
+ " train_data = (make_copy_task if task_name == \"copy\" else\n",
+ " make_reverse_task if task_name == \"reverse\" else\n",
+ " make_interleave_task)(seq_len=SEQ_LEN, n_samples=TRAIN_SAMPLES, seed=0)\n",
+ " val_data = (make_copy_task if task_name == \"copy\" else\n",
+ " make_reverse_task if task_name == \"reverse\" else\n",
+ " make_interleave_task)(seq_len=SEQ_LEN, n_samples=VAL_SAMPLES, seed=999)\n",
+ " _, acc, h = train_gru_with_l21(train_data, val_data, l21_lam=lam, seed=0)\n",
+ " best, _ = best_tpr_decomposition(h, 32)\n",
+ " l21_results[lam][task_name] = (acc, best[\"r2\"], best[\"d_role\"], best[\"d_filler\"])\n",
+ " line += f\"{task_name}: acc={acc:.3f}, R^2={best['r2']:.3f} ({best['d_role']}x{best['d_filler']}) | \"\n",
+ " print(line.rstrip(\" |\"))"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "c2d3ba7c",
+ "metadata": {},
+ "source": [
+ "### Lecture de la régularisation L2,1\n",
+ "\n",
+ "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.\n",
+ "\n",
+ "**Lecture** :\n",
+ "\n",
+ "- λ=0 : pas de régularisation (modèle nominal).\n",
+ "- λ=1e-4 : régularisation très faible — devrait ressembler au λ=0.\n",
+ "- λ=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).\n",
+ "- λ=1e-2 : régularisation forte — l'accuracy chute sévèrement, le R² peut augmenter ou rester modeste selon la tâche.\n",
+ "\n",
+ "**À ne PAS conclure** :\n",
+ "\n",
+ "- 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.\n",
+ "- 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."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "73010da0",
+ "metadata": {},
+ "source": [
+ "## Conclusion\n",
+ "\n",
+ "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** :\n",
+ "\n",
+ "**Ce qui a été montré** :\n",
+ "\n",
+ "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.\n",
+ "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.\n",
+ "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.\n",
+ "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.\n",
+ "\n",
+ "**Ce qui n'a PAS été montré** :\n",
+ "\n",
+ "1. Que le réseau *calcule* un produit tensoriel exact (Smolensky 1990) — la décomposition ALS approxime, elle n'identifie pas.\n",
+ "2. Que la structure role × filler est *la seule* organisation interne du réseau — d'autres factorisations peuvent atteindre un R² équivalent.\n",
+ "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**.\n",
+ "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.\n",
+ "\n",
+ "**Ce qui est hors scope** :\n",
+ "\n",
+ "- L'expérience GPT-OSS originale (mentionnée par le papier McCoy et al., >3000 GPU-h, modèles 20B+ paramètres).\n",
+ "- 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).\n",
+ "- 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.\n",
+ "\n",
+ "**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."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "08e2b004",
+ "metadata": {},
+ "source": [
+ "## Exercice 1 — Test de généralisation du diagnostic\n",
+ "\n",
+ "**É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 ?\n",
+ "\n",
+ "**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.\n",
+ "\n",
+ "**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)."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "bca2cbaf",
+ "metadata": {},
+ "source": [
+ "## Exercice 2 — Constituent surgery *sémantique*\n",
+ "\n",
+ "**É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 ?\n",
+ "\n",
+ "**Méthode** :\n",
+ "\n",
+ "1. Prenez un échantillon de validation `s` (e.g. `abcde` pour la tâche reverse).\n",
+ "2. Calculez l'état caché réel `h` et sa TPR best-fit `(R, F)`.\n",
+ "3. Construisez `s_perm` = permutation des positions 0 et 1 de `s` (e.g. `bacde`).\n",
+ "4. Calculez l'état caché `h_perm` du réseau sur `s_perm`.\n",
+ "5. Réinjectez la TPR permutée `(R_swap, F)` dans le décodeur sur `s`.\n",
+ "6. Comparez la sortie à `reverse(s_perm)` (e.g. `edcba`).\n",
+ "\n",
+ "**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)."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "11273b93",
+ "metadata": {},
+ "source": [
+ "## Exercice 3 — Capacité et *seuil* de TPR\n",
+ "\n",
+ "**É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`.\n",
+ "\n",
+ "**Méthode** :\n",
+ "\n",
+ "1. Réutilisez la cellule 15 avec un balayage plus fin : `d_model ∈ {4, 8, 12, 16, 20, 24, 28, 32}`.\n",
+ "2. Pour chaque `d_model`, mesurez val_acc ET `best_r2`.\n",
+ "3. Tracez la courbe `(d_model, best_r2)` et identifiez le point d'inflexion.\n",
+ "\n",
+ "**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.\n",
+ "\n",
+ "**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."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "63a8683a",
+ "metadata": {},
+ "source": [
+ "## Résumé\n",
+ "\n",
+ "- **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.\n",
+ "- **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.\n",
+ "- **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.\n",
+ "- **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.\n",
+ "- **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.\n",
+ "- **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.\n",
+ "- **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.).\n",
+ "\n",
+ "**Référence** : R. Thomas McCoy, Paul Soulos, Tal Linzen, Paul Smolensky, *The Emergent Symbolic Structure of Artificial Neural Networks*, arXiv:2608.29530.\n",
+ "\n",
+ "**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'."
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "name": "python",
+ "version": "3.10"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/MyIA.AI.Notebooks/SymbolicAI/SymbolicLearning/requirements.txt b/MyIA.AI.Notebooks/SymbolicAI/SymbolicLearning/requirements.txt
index 0750dff67d..f255432121 100644
--- a/MyIA.AI.Notebooks/SymbolicAI/SymbolicLearning/requirements.txt
+++ b/MyIA.AI.Notebooks/SymbolicAI/SymbolicLearning/requirements.txt
@@ -12,6 +12,9 @@
# SL-10: standard library (L* d'Angluin)
# SL-12b: numpy + matplotlib (synthese logique spectrale : FWHT, PTF ternaires,
# Sinkhorn, Metropolis/parallel tempering -- CPU uniquement)
+# SL-13: numpy + torch CPU (diagnostic DISCOVER leger -- factorisation TPR par ALS,
+# reinjection decoder, constituent surgery, white-box vs atomique,
+# controle capacite/L2,1 -- CPU uniquement)
# Scientific computing (SL-3, SL-5, SL-12b)
numpy>=1.24