ICT-36 — F-Lens : mode factored-geometry, sous-espaces et additivite

Navigation : << ICT-35 | Index

Module : ICT-Series (sous-ensemble F-Lens, Epic #15475) Niveau : Recherche Type : Grain DEEP/notebook-python CONTENU Duree estimee : 45 minutes (lecture + execution) VRAM : 0 (CPU uniquement, primitives numpy-only)

Ce notebook est un grain F-Lens factored-geometry autonome : il ne depend pas du contrat de trace v1 (#15476) ni du mode belief-state (#15477). Il reimplemente ses primitives en numpy-only, sur donnees synthetiques, et documente la migration future vers le contrat commun.

Question scientifique

Comment les facteurs independants d’un processus predictif occupent-ils le residual stream : sous-espaces additifs, superposes, orthogonaux ou entremeles ?

Le mode factored-geometry etudie l’organisation geometrique des facteurs ; il se distingue du mode belief-state (acces predictif lineaire, voir grain #15477) et de la SAE/J-Lens (features latentes et lecture fonctionnelle).

References

  • arXiv:2602.02385 (factored representations) — bibliotheque canonique CoursIA.
  • #15478 (issue grain) ; Part of #15475 (Epic toolkit multi-instrument) ; Depends on #15476 (contrat de trace v1) ; complements #15477 (F-Lens belief-state).

Statut épistémique — Sans verdict à ce jour : aucune ligne de la matrice de dissociations ne concerne ce notebook ; son statut épistémique sera porté par la matrice le cas échéant.

Sources et substance (reventilation c.1119)

Papier fondateur : Shai et al., Transformers Learn Factored Representations (arXiv:2602.02385). PDF en biblio canonique : G:\Mon Drive\MyIA\IA\Bibliographie IA\MachineLearning\2026 - Shai et al - Transformers Learn Factored Representations.pdf.

Dépôts de référence (non copiés, réimplémentation from-scratch conforme au mandat Epic #15475) :

  • Astera-org/factored-reps : PyTorch/Hydra. fwh_core.analysis.pca.compute_weighted_pca (PCA pondérée + seuils (0.80, 0.90, 0.95, 0.99)), fwh_core.analysis.linear_regression (R²/RMSE/MAE/dist pondérés), fwh_core.generative_processes.factored_generative_process.FactoredGenerativeProcess, structures/ (independent, fully_conditional, sequential_conditional, conditional_transitions).
  • Astera-org/simplexity : JAX/Equinox, portage structurellement isomorphe de factored-reps (mêmes modules analysis/pca.py, analysis/linear_regression.py, generative_processes/factored_generative_process.py, structures/). Le présent notebook reproduit l’API numpy-first offline dans ict/, conforme à la règle Epic #15475 « cœur analytique numpy-first/offline ».

Référençage ICt-37 sibling : ICT-37 (mode belief-state, PR #15662) traite l’autre moitié du papier — la décodabilité linéaire du belief state. Le présent ICT-36 traite la factorisation géométrique des sous-espaces. Les deux notebooks sont complémentaires et non interchangeables : voir la cellule « Distinction belief vs factorisation » plus bas.

Pas de licence détectée sur les deux dépôts Astera (cf. preflight Epic #15475) : la substance LIVRÉE est donc une réimplémentation from-scratch des méthodes publiées, sans copie de code. Le test H.1 — Pondération et centrage explicites et testés honore cette contrainte.

# Parametres du notebook
nb_name = "ICT-36-FLens-FactoredGeometry-Python"
N_SEEDS = 5  # multi-seed : au moins 4 seeds requis (cf .claude/rules/pr-review-discipline.md, critere C)
N_DIM_RESIDUAL = 256  # dimension typique d'un residual stream LLM (proxy)
N_TOKENS = 2048  # nombre de positions capturees (echantillon)
N_FACTORS = 4  # facteurs independants dans le regime factorise
NC_THRESHOLDS = (80, 90, 95, 99)  # seuils de variance cumulee (NC@k)
NOISE_LEVELS = (0.0, 0.1, 0.25, 0.5, 1.0)  # sweep de bruit
RNG_SEEDS = tuple(range(N_SEEDS))  # (0, 1, 2, 3, 4)
print(f"N_SEEDS={N_SEEDS} RNG_SEEDS={RNG_SEEDS}")
print(f"N_DIM_RESIDUAL={N_DIM_RESIDUAL} N_FACTORS={N_FACTORS} N_TOKENS={N_TOKENS}")
N_SEEDS=5 RNG_SEEDS=(0, 1, 2, 3, 4)
N_DIM_RESIDUAL=256 N_FACTORS=4 N_TOKENS=2048
# Racine canonique de la serie : remonte les parents depuis le dossier du
# notebook jusqu'au package ict/, puis en derive les dossiers de donnees.
# La serie reste executable depuis sa racine comme depuis le dossier d'une
# sous-serie (arbitrage #4362, preparation de l'arc A).
import sys
from pathlib import Path

ICT_ROOT = Path.cwd()
while not (ICT_ROOT / "ict" / "__init__.py").exists() and ICT_ROOT != ICT_ROOT.parent:
    ICT_ROOT = ICT_ROOT.parent
assert (ICT_ROOT / "ict" / "__init__.py").exists(), (
    f"package ict/ introuvable en remontant depuis {Path.cwd().name}")
if str(ICT_ROOT) not in sys.path:
    sys.path.insert(0, str(ICT_ROOT))
TRACES_DIR = ICT_ROOT / "traces"
RUNS_DIR = ICT_ROOT / "runs"
SCRIPTS_DIR = ICT_ROOT / "scripts"
print(f"racine ict : {ICT_ROOT.name}")
racine ict : ICT-Series
# Imports : numpy pour le pilotage des primitives (acceptance #15478).
# Depuis l'extraction #15943, les 7 primitives F-Lens vivent dans le paquet ict/
# (le notebook est la lecon, le paquet est l'outil) -- ce notebook les importe,
# il ne les redefinit plus.
import os
import sys

import numpy as np
import json
from pathlib import Path

from ict.factor_geometry import (
    weighted_pca,
    nc_at,
    basis_overlap,
    max_principal_angle,
    make_factor_bases,
    synthesize_activations,
    null_overlap_distribution,
)

print(f"numpy {np.__version__}")
print("Primitives F-Lens importees depuis ict.factor_geometry (extraction #15943)")
numpy 2.4.6
Primitives F-Lens importees depuis ict.factor_geometry (extraction #15943)

Primitives numpy-only (PCA ponderee, NC@p, angles, overlap)

Depuis l’extraction #15943, elles vivent dans le paquet ict/factor_geometry.py (from ict.factor_geometry import ...) : ce notebook les importe et les exerce, il ne les redéfinit plus.

Les primitives sont autonomes : elles n’utilisent que numpy. La motivation est triple :

  1. Reproductibilite : pas de dependance a la version sklearn, resultats byte-identiques entre machines.
  2. Pondération explicite : la ponderation par nombre d’occurrences est necessaire quand certains etats du belief sont sur-representes dans le sample ; sklearn PCA standard ne supporte que sample_weight au fit, pas au centrage.
  3. Migration vers le contrat de trace v1 (#15476) : les primitives consomment un dict {activations, weights} et rendent un dict structure ; un futur contrat remplacera l’entree par un load NPZ sans changer les primitives.
# weighted_pca, nc_at, basis_overlap, max_principal_angle : importees de
# ict/factor_geometry.py (extraction #15943, corps deplaces a l'identique,
# resultats inchanges par rapport a la livraison initiale #15514).
# Smoke-check d'import : chaque primitive repond sur un cas toy.
_rng = np.random.default_rng(0)
_mu, _comps, _sv, _evr = weighted_pca(_rng.standard_normal((50, 6)))
_nc = nc_at(np.array([0.5, 0.3, 0.15, 0.05]))[95]
_B_toy = np.eye(6)[:, :3]
_ov = float(basis_overlap(_B_toy, _B_toy).max())
_ang = max_principal_angle(_B_toy, _B_toy)
print(f"smoke: NC@95={_nc} overlap_diag={_ov:.1f} angle_self={_ang:.1f} deg")
print("Primitives PCA ponderee + NC@p + basis_overlap + max_principal_angle pretes.")
smoke: NC@95=3 overlap_diag=1.0 angle_self=0.0 deg
Primitives PCA ponderee + NC@p + basis_overlap + max_principal_angle pretes.

Generation de donnees synthetiques (trois regimes)

Trois regimes controles, pour tester la discrimination des primitives :

  1. Orthogonal : chaque facteur occupe un sous-espace de dimension 8 dans R^256, disjoint. L’additivite dimensionnelle devrait tenir exactement.
  2. Superpose : les sous-espaces se chevauchent partiellement (overlap ~ 0.4). L’additivite est degradee.
  3. Bruite : orthogonal + bruit additif gaussien de niveaux croissants. La structure factorisee devrait disparaitre progressivement.
# make_factor_bases et synthesize_activations : importees de ict/factor_geometry.py
# (extraction #15943, corps deplaces a l'identique).
_bases_toy, _dim_toy = make_factor_bases(2, 8, mode="orthogonal")
_X_toy, _w_toy = synthesize_activations(_bases_toy, 16, rng=np.random.default_rng(0))
print(f"smoke: {len(_bases_toy)} bases {_bases_toy[0].shape} dim_per_factor={_dim_toy} X={_X_toy.shape}")
print("make_factor_bases et synthesize_activations pretes.")
smoke: 2 bases (8, 4) dim_per_factor=4 X=(16, 8)
make_factor_bases et synthesize_activations pretes.

Exemple guidé 1 — Regime orthogonal : additivite exacte attendue

Avec des sous-espaces factoriels disjoints, l’additivite dimensionnelle devrait etre exacte : la dimension jointe egale la somme des dimensions factorielles. Ce test etablit la ligne de base.

results_orthogonal = {}
for seed in RNG_SEEDS:
    rng = np.random.default_rng(seed)
    bases, dim_per = make_factor_bases(N_FACTORS, N_DIM_RESIDUAL, mode="orthogonal", rng=rng)
    X, w = synthesize_activations(bases, N_TOKENS, signal_var=1.0, noise_var=0.0, rng=rng)
    mu, comps, sv, evr = weighted_pca(X, w)
    nc = nc_at(evr, NC_THRESHOLDS)
    # Bases factorielles individuelles pour comparaison
    factor_dims = []
    for B in bases:
        mu_i, comps_i, sv_i, evr_i = weighted_pca(X @ B @ B.T, w)  # projection sur facteur
        factor_dims.append(nc_at(evr_i, NC_THRESHOLDS)[95])
    sum_factors = sum(factor_dims)
    joint_dim = nc[95]
    # Overlap entre bases factorielles : devrait etre ~0 (orthogonalite parfaite)
    overlaps = []
    for i in range(N_FACTORS):
        for j in range(i + 1, N_FACTORS):
            M = basis_overlap(bases[i], bases[j])
            overlaps.append(float(M.max()))
    results_orthogonal[seed] = {
        "nc": nc,
        "joint_dim_95": joint_dim,
        "sum_factor_dims_95": sum_factors,
        "additivity_gap": abs(joint_dim - sum_factors),
        "max_pairwise_overlap": max(overlaps) if overlaps else 0.0,
    }

print("=== Exemple guide 1 : regime orthogonal ===")
for seed, r in results_orthogonal.items():
    print(f"  seed={seed} NC@95 joint={r['joint_dim_95']:>3} "
          f"sum_factors={r['sum_factor_dims_95']:>3} gap={r['additivity_gap']:>2} "
          f"max_pair_overlap={r['max_pairwise_overlap']:.4f}")
=== Exemple guide 1 : regime orthogonal ===
  seed=0 NC@95 joint=231 sum_factors=240 gap= 9 max_pair_overlap=0.0000
  seed=1 NC@95 joint=231 sum_factors=240 gap= 9 max_pair_overlap=0.0000
  seed=2 NC@95 joint=231 sum_factors=240 gap= 9 max_pair_overlap=0.0000
  seed=3 NC@95 joint=231 sum_factors=240 gap= 9 max_pair_overlap=0.0000
  seed=4 NC@95 joint=231 sum_factors=240 gap= 9 max_pair_overlap=0.0000

Exemple guidé 2 — Regime superpose : orthogonalite degradee

Les sous-espaces se chevauchent partiellement (overlap 0.4). L’additivite devrait etre degradee : la dimension jointe est inferieure a la somme des dimensions factorielles (les facteurs partagent de la variance).

results_superposed = {}
for seed in RNG_SEEDS:
    rng = np.random.default_rng(seed)
    bases, dim_per = make_factor_bases(N_FACTORS, N_DIM_RESIDUAL, mode="superposed", overlap=0.4, rng=rng)
    X, w = synthesize_activations(bases, N_TOKENS, signal_var=1.0, noise_var=0.0, rng=rng)
    mu, comps, sv, evr = weighted_pca(X, w)
    nc = nc_at(evr, NC_THRESHOLDS)
    # Bases factorielles individuelles pour comparaison
    factor_dims = []
    for B in bases:
        mu_i, comps_i, sv_i, evr_i = weighted_pca(X @ B @ B.T, w)
        factor_dims.append(nc_at(evr_i, NC_THRESHOLDS)[95])
    sum_factors = sum(factor_dims)
    joint_dim = nc[95]
    overlaps = []
    for i in range(N_FACTORS):
        for j in range(i + 1, N_FACTORS):
            M = basis_overlap(bases[i], bases[j])
            overlaps.append(float(M.max()))
    results_superposed[seed] = {
        "nc": nc,
        "joint_dim_95": joint_dim,
        "sum_factor_dims_95": sum_factors,
        "additivity_gap": abs(joint_dim - sum_factors),
        "max_pairwise_overlap": max(overlaps) if overlaps else 0.0,
    }

print("=== Exemple guide 2 : regime superpose (overlap=0.4) ===")
for seed, r in results_superposed.items():
    print(f"  seed={seed} NC@95 joint={r['joint_dim_95']:>3} "
          f"sum_factors={r['sum_factor_dims_95']:>3} gap={r['additivity_gap']:>2} "
          f"max_pair_overlap={r['max_pairwise_overlap']:.4f}")
=== Exemple guide 2 : regime superpose (overlap=0.4) ===
  seed=0 NC@95 joint=155 sum_factors=240 gap=85 max_pair_overlap=0.6993
  seed=1 NC@95 joint=162 sum_factors=240 gap=78 max_pair_overlap=0.6941
  seed=2 NC@95 joint=174 sum_factors=240 gap=66 max_pair_overlap=0.8058
  seed=3 NC@95 joint=220 sum_factors=240 gap=20 max_pair_overlap=0.4946
  seed=4 NC@95 joint=216 sum_factors=240 gap=24 max_pair_overlap=0.3077

Exemple guidé 3 — Sweep de bruit : sensibilite et verdicts falsifiables

Bruit additif gaussien de niveaux 0.0, 0.1, 0.25, 0.5, 1.0. La structure factorisee devrait disparaitre progressivement : au-dela d’un niveau de bruit pre-enregistre, les sous-espaces factoriels ne se separent plus de l’hypothese nulle (sous-espaces aleatoires apparies).

Distinction belief-state vs factorisation géométrique

ICT-36 (ce notebook) et ICT-37 (ICT-37-FLens-BeliefState-Python.ipynb, PR #15662 sur lane myia-po-2023:CoursIA-2) sont deux modes complémentaires de la même Factored Geometry Lens (F-Lens). La distinction est non triviale et les résultats ne sont pas interchangeables :

Question scientifique Mode F-Lens Verdict falsifiable
L’état prédictif du processus est-il linéairement accessible dans le residual stream ? belief-state (ICT-37) Probe R²/RMSE held-out ≥ seuil contre baseline shuffle/next-token. Cible le théorème d’arXiv:2405.15943 (Shai et al.)
Les facteurs indépendants occupent-ils des sous-espaces additifs, superposés, orthogonaux ou entremêlés ? factored-geometry (ICT-36) NC@p + angles principaux + overlap + additivité dimensionnelle contre partitions aléatoires. Cible le théorème d’arXiv:2602.02385 (Shai et al.)

Un modèle peut avoir une excellente décodabilité belief (R² > 0.989, cf. ZM) sans factorisation orthogonale propre, et inversement. ICT-36 et ICT-37 rendent deux verdicts distincts sur le même réseau — c’est précisément ce que l’acceptance #15478 demande (« les résultats ne confondent pas décodabilité belief et factorisation géométrique »).

Pour ce notebook : on se concentre sur la géométrie des sous-espaces factoriels. La décodabilité belief n’est pas mesurée ici ; pour cela, voir ICT-37.

# null_overlap_distribution : importee du module (extraction #15943). Cf. l'avertissement
# de sa docstring -- le VRAI test H1 est ci-dessous (paires appariees null_pairs).
results_noise_sweep = {}
for noise_var in NOISE_LEVELS:
    per_seed = {}
    for seed in RNG_SEEDS:
        # Multiplicateur 1000 : avec 100, (seed=0, noise=1.0) et (seed=1, noise=0.0)
        # derivaient de la MEME graine (0*100+100 == 1*100+0) -- les conditions du
        # sweep n'etaient pas independantes. 1000 rend chaque paire (seed, noise) unique.
        rng = np.random.default_rng(seed * 1000 + int(noise_var * 100))
        bases, dim_per = make_factor_bases(N_FACTORS, N_DIM_RESIDUAL, mode="orthogonal", rng=rng)
        X, w = synthesize_activations(bases, N_TOKENS, signal_var=1.0, noise_var=noise_var, rng=rng)
        mu, comps, sv, evr = weighted_pca(X, w)
        # Mesure : NC@95 sur l'activation jointe vs somme des NC@95 factorielles
        nc_joint = nc_at(evr, NC_THRESHOLDS)[95]
        factor_nc = []
        for B in bases:
            mu_i, comps_i, sv_i, evr_i = weighted_pca(X @ B @ B.T, w)
            factor_nc.append(nc_at(evr_i, NC_THRESHOLDS)[95])
        sum_factors = sum(factor_nc)
        # Vrai test H1 : overlap entre les PROJECTIONS sur sous-espaces factoriels reels
        # vs overlap entre projections sur sous-espaces aleatoires apparies (meme dim).
        # Le test precedent comparait bases factorielles a une QR-aleatoire qui couvre
        # tout l'espace -- cela donne null95=1.0 systematiquement (artefact), pas un test.
        max_real = 0.0
        for i in range(N_FACTORS):
            for j in range(i + 1, N_FACTORS):
                # Projections sur les sous-espaces factoriels, puis PCA
                proj_i = X @ bases[i] @ bases[i].T
                proj_j = X @ bases[j] @ bases[j].T
                _, comps_i, _, _ = weighted_pca(proj_i, w)
                _, comps_j, _, _ = weighted_pca(proj_j, w)
                B_i = comps_i[:, :dim_per]
                B_j = comps_j[:, :dim_per]
                M = basis_overlap(B_i, B_j)
                if M.max() > max_real:
                    max_real = float(M.max())
        # Distribution nulle : paires de sous-espaces aleatoires apparies (meme dim_per_factor)
        null_pairs = []
        rng_null = np.random.default_rng(seed * 31 + 17)
        for _ in range(50):
            A = rng_null.standard_normal((N_DIM_RESIDUAL, dim_per))
            Q1, _ = np.linalg.qr(A)
            A = rng_null.standard_normal((N_DIM_RESIDUAL, dim_per))
            Q2, _ = np.linalg.qr(A)
            M = basis_overlap(Q1, Q2)
            null_pairs.append(float(M.max()))
        null_q95 = float(np.quantile(np.array(null_pairs), 0.95))
        per_seed[seed] = {
            "joint_dim_95": nc_joint,
            "sum_factor_dims_95": sum_factors,
            "additivity_gap": abs(nc_joint - sum_factors),
            "max_pair_overlap": max_real,
            "null_q95_overlap": null_q95,
            # Separation = overlap reel SOUS le quantile 95 de la distribution nulle.
            # Sens : sous-espaces factoriels reels ont un overlap plus PETIT que
            # des paires aleatoires apparies -- ils sont plus orthogonaux que le hasard.
            "separates_from_null": max_real < null_q95,
        }
    results_noise_sweep[noise_var] = per_seed

print("=== Exemple guide 3 : sweep de bruit ===")
print(f"{'noise':>6} {'seed':>4} {'joint':>6} {'sumF':>5} {'gap':>4} {'overlap':>7} {'null95':>7} {'sep?':>5}")
print(f"{'note':>6} sep? = overlap < null95 (sous-espaces plus orthogonaux que le hasard)")
for noise_var, per_seed in results_noise_sweep.items():
    for seed, r in per_seed.items():
        print(f"{noise_var:>6.2f} {seed:>4d} {r['joint_dim_95']:>6d} "
              f"{r['sum_factor_dims_95']:>5d} {r['additivity_gap']:>4d} "
              f"{r['max_pair_overlap']:>7.4f} {r['null_q95_overlap']:>7.4f} "
              f"{'YES' if r['separates_from_null'] else 'no':>5}")
=== Exemple guide 3 : sweep de bruit ===
 noise seed  joint  sumF  gap overlap  null95  sep?
  note sep? = overlap < null95 (sous-espaces plus orthogonaux que le hasard)
  0.00    0    231   240    9  0.0000  0.2609   YES
  0.00    1    231   240    9  0.0000  0.2662   YES
  0.00    2    231   240    9  0.0000  0.2628   YES
  0.00    3    231   240    9  0.0000  0.2761   YES
  0.00    4    231   240    9  0.0000  0.2654   YES
  0.10    0    231   240    9  0.0000  0.2609   YES
  0.10    1    231   240    9  0.0000  0.2662   YES
  0.10    2    231   240    9  0.0000  0.2628   YES
  0.10    3    231   240    9  0.0000  0.2761   YES
  0.10    4    231   240    9  0.0000  0.2654   YES
  0.25    0    231   240    9  0.0000  0.2609   YES
  0.25    1    231   240    9  0.0000  0.2662   YES
  0.25    2    231   240    9  0.0000  0.2628   YES
  0.25    3    231   240    9  0.0000  0.2761   YES
  0.25    4    231   240    9  0.0000  0.2654   YES
  0.50    0    231   240    9  0.0000  0.2609   YES
  0.50    1    231   240    9  0.0000  0.2662   YES
  0.50    2    231   240    9  0.0000  0.2628   YES
  0.50    3    231   240    9  0.0000  0.2761   YES
  0.50    4    231   240    9  0.0000  0.2654   YES
  1.00    0    231   240    9  0.0000  0.2609   YES
  1.00    1    231   240    9  0.0000  0.2662   YES
  1.00    2    231   240    9  0.0000  0.2628   YES
  1.00    3    231   240    9  0.0000  0.2761   YES
  1.00    4    231   240    9  0.0000  0.2654   YES

Exercices

Exercice 1 — Sweep d’overlap : où l’additivité se dégrade-t-elle ?

Les trois régimes ci-dessus sont mesurés à paramètres fixes : overlap 0 ou 0,4, quatre facteurs, 2 048 tokens. Les trois exercices suivants font varier chacun un paramètre du protocole et mesurent ce que la fenêtre fixe ne peut pas voir. Chaque énoncé fixe les paramètres exacts à employer : le résultat attendu est vérifié par une exécution de référence (hors notebook).

Les primitives importées plus haut (make_factor_bases, synthesize_activations, weighted_pca, nc_at, basis_overlap) suffisent — ne rien réécrire du module.

Le carnet compare deux points d’overlap : 0 (Exemple guidé 1) et 0,4 (Exemple guidé 2). Un point ne fait pas une courbe : on ne sait pas si la dégradation est progressive, brutale, ni si sa dispersion entre graines change.

Protocole — mode superposed, quatre facteurs, 2 048 tokens, les cinq graines RNG_SEEDS, pour overlap ∈ {0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6} :

  1. mesurer l’écart d’additivité |NC@95 joint − Σ NC@95 factoriels| pour chaque graine ;
  2. tabuler, par overlap, l’écart moyen, min et max ;
  3. qualifier : la moyenne croît-elle de façon monotone ? La dispersion entre graines se comporte-t-elle comme la moyenne ?

Indices

  • Recopier le motif de l’Exemple guidé 2 en paramétrant overlap (make_factor_bases(..., mode="superposed", overlap=..., rng=...)).
  • Le « min » et le « max » sur les graines sont deux nombres distincts de la moyenne : c’est la dispersion qui porte le fait marquant.

Vocabulaire de l’axe x — le paramètre de grille overlap est l’écart-type des coefficients de mélange injectés entre sous-espaces (non borné), pas l’overlap géométrique lui-même. Pour un axe homogène au vocabulaire du carnet, reporter aussi l’overlap mesuré par point de grille : basis_overlap(bases[i], bases[j]).max() sur les paires, comme max_pair_overlap l’imprime dans les Exemples guidés.

Ce que l’exercice fait mesurer — la dégradation de l’additivité avec le paramètre de mélange et la stabilité de sa mesure.

# Exercice 1 — Sweep d'overlap : ou l'additivite se degrade-t-elle ? (a completer)
# TODO etudiant : mesurer l'ecart d'additivite sur une grille d'overlap.
# Etape 1 : OVERLAPS = (0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6) ; quatre facteurs, 2048 tokens, RNG_SEEDS.
# Etape 2 : pour chaque overlap, mesurer l'ecart par graine (motif de l'Exemple guide 2).
# Etape 3 : tabuler ecart moyen, min, max par overlap et qualifier la monotonie de la moyenne.
# Indice : la dispersion entre graines (min / max) est le second fait a lire, pas seulement la moyenne.
# Indice : reporter aussi, par point de grille, l'overlap geometrique mesure (basis_overlap(bases[i], bases[j]).max())
#         pour que l'axe x se lise dans le vocabulaire du carnet (le parametre overlap est un ecart-type de melange).

OVERLAPS = (0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6)

def ecart_additivite(n_factors, n_tokens, overlap, seed):
    """Ecart |NC@95 joint - somme des NC@95 factoriels| pour un tirage donne."""
    # TODO etudiant
    pass

# for ov in OVERLAPS: ...   # decommenter une fois implemente
print("Exercice 1 a completer : sweep d'overlap et stabilite de l'ecart d'additivite.")
Exercice 1 a completer : sweep d'overlap et stabilite de l'ecart d'additivite.

Exercice 2 — Nombre de facteurs : le côté joint sature-t-il ?

Le carnet fixe N_FACTORS = 4. Ce nombre pilote la taille de chaque sous-espace (dim // n_factors : 128, 64 ou 32 sur les 256 dimensions du stream). Que devient l’écart d’additivité quand le nombre de facteurs change, à budget de dimensions constant ?

Protocole — mode orthogonal, 2 048 tokens, les cinq graines RNG_SEEDS, pour n_factors ∈ {2, 4, 8} :

  1. mesurer NC@95 joint et la somme des NC@95 factoriels (moyennes sur les graines) ;
  2. tabuler aussi dim_per_factor (make_factor_bases le renvoie) ;
  3. qualifier : lequel des deux côtés — joint ou somme — bouge avec le nombre de facteurs, et lequel sature ?

Indices

  • Le côté joint sature à une valeur proche du budget total de dimensions ; le côté somme, lui, dépend du découpage.
  • À huit facteurs, dim_per_factor vaut 32 : vérifier que chaque projection factorielle a encore assez de dimensions pour porter son NC@95.

Ce que l’exercice fait mesurer — l’additivité n’est pas symétrique entre ses deux côtés, et la saturation du côté joint est une propriété du budget de dimensions.

# Exercice 2 — Nombre de facteurs : le cote joint sature-t-il ? (a completer)
# TODO etudiant : mesurer les deux cotes de l'additivite en fonction de n_factors.
# Etape 1 : N_FACTORS_GRID = (2, 4, 8) ; mode orthogonal, 2048 tokens, RNG_SEEDS.
# Etape 2 : pour chaque n_factors, moyenne sur graines de NC@95 joint, de la somme des NC@95
#           factoriels et de l'ecart ; recuperer dim_per_factor renvoye par make_factor_bases.
# Etape 3 : tabuler (n_factors, dim_per_factor, joint, somme, ecart) et dire quel cote sature.
# Indice : le cote joint est borne par le budget total des 256 dimensions du stream.

N_FACTORS_GRID = (2, 4, 8)

def deux_cotes_additivite(n_factors, n_tokens, seed):
    """Retourne (joint, somme_factorielle, dim_per_factor) pour un tirage donne."""
    # TODO etudiant
    pass

# for nf in N_FACTORS_GRID: ...   # decommenter une fois implemente
print("Exercice 2 a completer : nombre de facteurs et saturation du cote joint.")
Exercice 2 a completer : nombre de facteurs et saturation du cote joint.

Exercice 3 — Nombre de tokens : le plancher d’échantillonnage

Le carnet fixe N_TOKENS = 2 048 et traite l’écart d’additivité comme une propriété géométrique. Or une PCA sur \(n\) échantillons ne peut pas rendre plus de \(n\) directions (rang de la matrice) : le côté joint a donc un plancher qui n’a rien de géométrique. Le carnet a déjà rencontré ce genre d’artefact avec l’hypothèse nulle (null_overlap_distribution) — cet exercice le mesure pour la dimension.

Protocole — mode orthogonal, quatre facteurs, les cinq graines RNG_SEEDS, pour n_tokens ∈ {64, 256, 1 024, 4 096} :

  1. mesurer NC@95 joint et la somme des NC@95 factoriels (moyennes sur les graines) ;
  2. tabuler l’écart d’additivité correspondant ;
  3. vérifier le plancher au plus petit échantillon, puis dire ce qui reste de l’écart à 4 096 tokens.

Indices

  • À 64 tokens, le côté joint est borné par le rang de la matrice centrée (≤ 63) : le comparer à sa borne.
  • Le côté somme, lui, est mesuré sur des projections de dimension 64 : il ne subit pas la même borne.
  • La conclusion utile : l’écart à petit échantillon mesure surtout le manque de données, pas la géométrie.

Ce que l’exercice fait mesurer — le plancher d’échantillonnage de NC@95 et la part d’écart qui survit à grand échantillon.

# Exercice 3 — Nombre de tokens : le plancher d'echantillonnage (a completer)
# TODO etudiant : mesurer les deux cotes de l'additivite en fonction de n_tokens.
# Etape 1 : N_TOKENS_GRID = (64, 256, 1024, 4096) ; mode orthogonal, 4 facteurs, RNG_SEEDS.
# Etape 2 : pour chaque n_tokens, moyenne sur graines de NC@95 joint, de la somme des NC@95
#           factoriels et de l'ecart.
# Etape 3 : comparer le joint a 64 tokens a sa borne de rang, puis lire ce qui reste a 4096.
# Indice : une PCA sur n echantillons ne rend pas plus de n directions -- borner le joint par n.

N_TOKENS_GRID = (64, 256, 1024, 4096)

def plancher_echantillonnage(n_tokens, n_factors, seed):
    """Retourne (joint, somme_factorielle) pour un tirage donne."""
    # TODO etudiant
    pass

# for nt in N_TOKENS_GRID: ...   # decommenter une fois implemente
print("Exercice 3 a completer : plancher d'echantillonnage de NC@95.")
Exercice 3 a completer : plancher d'echantillonnage de NC@95.
# Protocole freeze-and-vary : on gele UN facteur et on varie l'AUTRE
# (cle d'isolement de la contribution factorielle, cf. arXiv:2602.02385)
# Implementation : pour chaque seed, on genere 2 bases orthogonales via
# make_factor_bases(2, 16) ; on gele bases[0] et on regenere bases[1] a chaque
# realisation. NC@p sur les activations devrait refleter 2 dimensions dominantes
# stables (gele) + une sensibilite au facteur varie.
# NB : on ne boucle PAS sur n_factors en externe -- la complexite du protocole
# est dans la variation du second facteur, pas dans le nombre de facteurs.

FREEZE_AND_VARY_SEEDS = [0, 1, 7, 42, 99]  # 5 seeds conformes a l'AC >=4

frozen_nc95_per_seed = []
varied_nc95_per_seed = []
for seed in FREEZE_AND_VARY_SEEDS:
    rng = np.random.default_rng(seed)
    # Bases orthogonales gelees : meme base pour toutes les realisations du facteur varie
    bases, _ = make_factor_bases(2, dim=16, mode="orthogonal", rng=rng)
    frozen_factor = bases[0]  # (16, 8)
    nc95_real = []
    for r in range(20):
        rng_r = np.random.default_rng(seed * 1000 + r)
        # Nouveau facteur varie : meme dim_per_factor (8) que le gele
        # NB : make_factor_bases retourne (bases, dim_per_factor), donc [0][1] = bases[1]
        varied_factor = make_factor_bases(2, dim=16, mode="orthogonal", rng=rng_r)[0][1]
        # 256 tokens, chacun = projection sur gele + projection sur varie + bruit
        n_tok = 256
        z_frozen = rng_r.standard_normal((n_tok, 8))  # coefficients gele
        z_varied = rng_r.standard_normal((n_tok, 8))  # coefficients varie
        # Activations : (n_tok, dim=16) via (n_tok, 8) @ (8, 16) = (n_tok, 16)
        X = z_frozen @ frozen_factor.T + z_varied @ varied_factor.T + 0.1 * rng_r.standard_normal((n_tok, 16))
        _, _, _, evr = weighted_pca(X)
        nc_dict = nc_at(evr, thresholds=(95,))
        nc95_real.append(nc_dict[95])
    frozen_nc95_per_seed.append(np.mean(nc95_real))
    varied_nc95_per_seed.append(np.std(nc95_real))

freeze_vary_results = {
    "frozen_nc95_mean": float(np.mean(frozen_nc95_per_seed)),
    "frozen_nc95_std": float(np.std(frozen_nc95_per_seed)),
    "varied_sensitivity_mean": float(np.mean(varied_nc95_per_seed)),
}

print("=== Freeze-and-vary : stabilite du sous-espace gele + sensibilite au varie (5 seeds) ===")
print(f"  frozen NC@95 = {freeze_vary_results['frozen_nc95_mean']:.3f} +/- {freeze_vary_results['frozen_nc95_std']:.3f}")
print(f"  varied sensitivity (ecart-type inter-realisations) = {freeze_vary_results['varied_sensitivity_mean']:.3f}")
print()
print("Verdict attendu : frozen NC@95 doit etre proche du nombre de facteurs dominants (2 ici, gele + varie) ; varied sensitivity reflete la variabilite du facteur varie.")
=== Freeze-and-vary : stabilite du sous-espace gele + sensibilite au varie (5 seeds) ===
  frozen NC@95 = 11.490 +/- 0.185
  varied sensitivity (ecart-type inter-realisations) = 0.593

Verdict attendu : frozen NC@95 doit etre proche du nombre de facteurs dominants (2 ici, gele + varie) ; varied sensitivity reflete la variabilite du facteur varie.

Verdict multi-seed et hypothese falsifiable

Les hypotheses du grain #15478 — H1 a H3 a la livraison initiale (#15514), H4 ajoutee par le suivi du 15/09 (criteres « courbe en k » et « axes de graine independants », section dediee ci-dessous) :

  1. H1 (separation) : les sous-espaces factoriels reels sont PLUS orthogonaux que des paires de sous-espaces aleatoires apparies. Le sens implemente depuis la livraison initiale est max_real < null_q95 : l’overlap reel passe SOUS le quantile 95 de la distribution nulle (cf. Exemple guidé 3, « sep? = overlap < null95 »). Une redaction anterieure de cette ligne affichait > a tort : coquille de prose, le code a toujours teste <.
  2. H2 (additivite jointe) : la dimension jointe est compatible avec une organisation additive dans le regime factorise orthogonal (test : gap additivite faible et stable sur les seeds).
  3. H3 (sensibilite au bruit) : la structure survit a un niveau de bruit pre-enregistre (0.1), mais disparait au-dela (0.5).
  4. H4 (courbe overlap-vs-k, suivi du 15/09) : tracee en fonction de k plutot que reduite a un scalaire a k fixe, l’overlap franchit la bande nulle exactement au k_cross = n_privees + 1 pre-enregistre dans le regime superpose, et reste sous la bande a tout k dans le regime orthogonal — data-seed et null-seed sur deux axes d’entropie disjointe (section H4 ci-dessous).
# H1 : separation des sous-espaces factoriels vs hypothese nulle
# Multi-seed 5 seeds conformement a l'AC #15478 (>= 4 seeds avec IC)
# On utilise la metrique corrigee d'Exemple guide 3 (results_noise_sweep[0.0],
# regime orthogonal = bruit nul) qui projette sur les sous-espaces factoriels
# reels et compare a des paires de sous-espaces aleatoires apparies.
# NB : le test archaique null_overlap_distribution(Q aleatoire vs full space)
#      donnait null95=1.0 systematiquement (artefact reconnu dans la cellule 14).

VERDICT_SEEDS = [0, 1, 7, 42, 99]

h1_per_seed = {}
for seed in VERDICT_SEEDS:
    r = results_noise_sweep[0.0].get(seed, None)
    if r is None:
        # Fallback : recalculer pour ce seed
        rng = np.random.default_rng(seed)
        bases, dim_per = make_factor_bases(N_FACTORS, N_DIM_RESIDUAL, mode="orthogonal", rng=rng)
        X, w = synthesize_activations(bases, N_TOKENS, signal_var=1.0, noise_var=0.0, rng=rng)
        # Calcul direct via projection
        max_real = 0.0
        for i in range(N_FACTORS):
            for j in range(i + 1, N_FACTORS):
                proj_i = X @ bases[i] @ bases[i].T
                proj_j = X @ bases[j] @ bases[j].T
                _, comps_i, _, _ = weighted_pca(proj_i, w)
                _, comps_j, _, _ = weighted_pca(proj_j, w)
                B_i = comps_i[:, :dim_per]
                B_j = comps_j[:, :dim_per]
                M = basis_overlap(B_i, B_j)
                if M.max() > max_real:
                    max_real = float(M.max())
        # Null : paires aleatoires appariees
        null_pairs = []
        rng_null = np.random.default_rng(seed * 31 + 17)
        for _ in range(50):
            A = rng_null.standard_normal((N_DIM_RESIDUAL, dim_per))
            Q1, _ = np.linalg.qr(A)
            A = rng_null.standard_normal((N_DIM_RESIDUAL, dim_per))
            Q2, _ = np.linalg.qr(A)
            M = basis_overlap(Q1, Q2)
            null_pairs.append(float(M.max()))
        null_q95 = float(np.quantile(np.array(null_pairs), 0.95))
        h1_per_seed[seed] = {
            "real": max_real,
            "null95": null_q95,
            "separates": max_real < null_q95,
        }
    else:
        h1_per_seed[seed] = {
            "real": r["max_pair_overlap"],
            "null95": r["null_q95_overlap"],
            "separates": r["separates_from_null"],
        }

print("=== Verdict multi-seed H1 (5 seeds) : separation des sous-espaces factoriels ===")
print("Test : overlap reel (sur projections) < q95 distribution nulle (paires aleatoires appariees)")
for seed, v in h1_per_seed.items():
    verdict = "SEPARATES" if v["separates"] else "MERGES-WITH-NULL"
    print(f"  seed={seed}: real_overlap={v['real']:.4f}, null95={v['null95']:.4f}, {verdict}")

real_vals = [v["real"] for v in h1_per_seed.values()]
null_vals = [v["null95"] for v in h1_per_seed.values()]
n_sep = sum(1 for v in h1_per_seed.values() if v["separates"])

print()
print(f"Moyenne real_overlap = {np.mean(real_vals):.4f} +/- {np.std(real_vals):.4f}")
print(f"Moyenne null95      = {np.mean(null_vals):.4f} +/- {np.std(null_vals):.4f}")
print(f"Seeds separating from null : {n_sep} / {len(VERDICT_SEEDS)}")
print()
if n_sep == len(VERDICT_SEEDS):
    print("H1 SUPPORTED cross-seed : tous les 5 seeds separent les sous-espaces mieux que l'aleatoire apparie.")
elif n_sep >= 3:
    print("H1 PARTIALLY SUPPORTED : majorite des seeds separent, mais resultats heterogenes.")
else:
    print("H1 INCONCLUSIVE : la separation n'est pas reproductible cross-seed.")
=== Verdict multi-seed H1 (5 seeds) : separation des sous-espaces factoriels ===
Test : overlap reel (sur projections) < q95 distribution nulle (paires aleatoires appariees)
  seed=0: real_overlap=0.0000, null95=0.2609, SEPARATES
  seed=1: real_overlap=0.0000, null95=0.2662, SEPARATES
  seed=7: real_overlap=0.0000, null95=0.2663, SEPARATES
  seed=42: real_overlap=0.0000, null95=0.2775, SEPARATES
  seed=99: real_overlap=0.0000, null95=0.2628, SEPARATES

Moyenne real_overlap = 0.0000 +/- 0.0000
Moyenne null95      = 0.2667 +/- 0.0057
Seeds separating from null : 5 / 5

H1 SUPPORTED cross-seed : tous les 5 seeds separent les sous-espaces mieux que l'aleatoire apparie.

Hypothese H4 — Courbe overlap-vs-k et axes de graine independants (suivi du 15/09)

Le verdict H1 ci-dessus resume la separation par un scalaire a k fixe (k = dim_per_factor). Le commentaire du 15/09 sur #15478 demande l’overlap rapporte comme courbe en k — « un scalaire depend du choix de k, la courbe dit ou la separation commence et ou elle sature » — et la separation des axes de graine (data-seed vs graine du temoin). H4 porte ces deux exigences au regime synthetique de ce notebook.

Pre-enregistrement — ecrit AVANT la cellule de mesure ci-dessous :

  • Construction : deux facteurs dans R^256, dim_per = 12 directions chacun. Chaque direction privee m porte une variance 12 - m (ordre spectral strict, 12, 11, …) ; n_shared = 5 directions basse-variance (variance 2 par facteur) sont communes aux deux facteurs. Dans chaque projection oracle, une direction partagee ne porte que 2 + 2 = 4 < 6 de variance : l’ordre PCA place donc toutes les directions privees avant les partagees.
  • P1 (regime orthogonal, n_shared = 0) : la courbe reelle overlap(k) reste sous la bande nulle (quantile 95 des paires aleatoires appariees) pour tout k de 1 a 12.
  • P2 (regime superpose, n_shared = 5) : la courbe reste sous la bande pour k <= 7, franchit la bande exactement a k_cross = n_privees + 1 = 8 (premiere direction partagee admise dans le top-k) et reste au-dessus pour tout k >= 8.
  • Regle de decision (pre-enregistree) : H4 SUPPORTED si P1 et P2 tiennent par seed sur au moins 4 des 5 data-seeds, croisement observe egal a 8 exactement (aucune tolerance) ; sinon REFUTED avec diagnostic par seed.
  • Axes independants : la graine de donnees (default_rng([1000, data_seed]), data_seed dans {0, 1, 7, 42, 99}) et la graine du temoin nul (default_rng([2000, null_seed]), 5 null-seeds x 40 tirages = 200 par k) derivent de deux entropies disjointes — la collision du type (seed=0, noise=1.0) = (seed=1, noise=0.0) qui affectait le sweep de bruit (corrigee dans cette meme livraison : multiplicateur 100 -> 1000) ne peut plus se produire.
  • Limitation explicite : le temoin nul de H4 reste le null de sous-espaces aleatoires apparies (meme modele nul que H1). Le critere du 15/09 « temoin nul sur >= 5 initialisations non entrainees du meme modele » exige un banc d’entrainement : il n’est ni couvert ni revendique ici (cf. Limites et bilan honnete).
# H4 — pre-enregistrement : cette cellule FIXE la prediction AVANT toute mesure.
# La cellule de mesure suivante lit H4_PREDICTIONS et ne redefinit rien : le verdict
# est evalue contre ce qui est imprime ici, pas contre un critere repere apres coup.
H4_DIM = 256                    # dimension ambiante (proxy residual stream)
H4_DIM_PER = 12                 # directions par facteur
H4_N_SHARED = 5                 # directions partagees (regime superpose)
H4_N_TOKENS = 2048
H4_DATA_SEEDS = (0, 1, 7, 42, 99)   # >= 5 data-seeds (AC multi-seed)
H4_NULL_SEEDS = (0, 1, 7, 42, 99)   # axe INDEPENDANT : entropies [2000, ...] disjointes de [1000, ...]
H4_NULL_DRAWS_PER_SEED = 40         # 5 x 40 = 200 tirages nuls par k
H4_K_GRID = tuple(range(1, H4_DIM_PER + 1))
H4_VAR_SHARED_PER_FACTOR = 2.0      # var partagee par facteur -> 2+2 = 4 < 6 en projection

H4_N_PRIVATE = H4_DIM_PER - H4_N_SHARED
H4_K_CROSS_PREDICTED = H4_N_PRIVATE + 1   # premiere direction partagee dans le top-k

H4_PREDICTIONS = {
    "p1_orthogonal": "courbe reelle sous la bande nulle (q95) pour TOUT k de 1 a 12",
    "p2_superposed": "courbe sous la bande pour k <= 7, au-dessus pour tout k >= 8",
    "k_cross_predicted": H4_K_CROSS_PREDICTED,
    "decision_rule": ("SUPPORTED si P1 et P2 tiennent par seed sur >= 4/5 data-seeds, "
                      "croisement observe == 8 exactement"),
    "null_model": "paires de sous-espaces aleatoires apparies (QR), 200 tirages/k",
}
print("=== H4 : pre-enregistrement (AVANT mesure) ===")
for key, val in H4_PREDICTIONS.items():
    print(f"  {key} : {val}")
print(f"  construction : var privee m -> {H4_DIM_PER} - m ; var partagee -> {H4_VAR_SHARED_PER_FACTOR} par facteur")
print(f"  consequence analytique : k_cross = n_privees + 1 = {H4_N_PRIVATE} + 1 = {H4_K_CROSS_PREDICTED}")
=== H4 : pre-enregistrement (AVANT mesure) ===
  p1_orthogonal : courbe reelle sous la bande nulle (q95) pour TOUT k de 1 a 12
  p2_superposed : courbe sous la bande pour k <= 7, au-dessus pour tout k >= 8
  k_cross_predicted : 8
  decision_rule : SUPPORTED si P1 et P2 tiennent par seed sur >= 4/5 data-seeds, croisement observe == 8 exactement
  null_model : paires de sous-espaces aleatoires apparies (QR), 200 tirages/k
  construction : var privee m -> 12 - m ; var partagee -> 2.0 par facteur
  consequence analytique : k_cross = n_privees + 1 = 7 + 1 = 8
# H4 — mesure : courbe overlap-vs-k contre bande nulle appariee, par data-seed.
# data-seed : default_rng([1000, ds]) ; null-seed : default_rng([2000, ns]) —
# deux SeedSequence d'entropies disjointes, aucune collision possible entre axes.
def h4_build_factors(rng_data, n_shared):
    """Deux sous-espaces de R^H4_DIM : blocs prives disjoints + bloc partage."""
    Q, _ = np.linalg.qr(rng_data.standard_normal((H4_DIM, H4_DIM)))
    n_priv = H4_DIM_PER - n_shared
    cols_priv1 = list(range(n_priv))                 # directions prives facteur 1
    cols_priv2 = list(range(n_priv, 2 * n_priv))     # directions prives facteur 2
    cols_shared = list(range(100, 100 + n_shared))   # bloc partage basse variance
    B1 = Q[:, cols_priv1 + cols_shared]
    B2 = Q[:, cols_priv2 + cols_shared]
    var_priv = [float(H4_DIM_PER - m) for m in range(n_priv)]
    var = np.array(var_priv + [H4_VAR_SHARED_PER_FACTOR] * n_shared)
    return B1, B2, var

def h4_real_curve(rng_data, n_shared):
    """overlap(k) = max |cos| entre top-k composantes PCA des projections oracle."""
    B1, B2, var = h4_build_factors(rng_data, n_shared)
    z1 = rng_data.standard_normal((H4_N_TOKENS, H4_DIM_PER)) * np.sqrt(var)[None, :]
    z2 = rng_data.standard_normal((H4_N_TOKENS, H4_DIM_PER)) * np.sqrt(var)[None, :]
    X = z1 @ B1.T + z2 @ B2.T
    _, comps1, _, _ = weighted_pca(X @ B1 @ B1.T)
    _, comps2, _, _ = weighted_pca(X @ B2 @ B2.T)
    return [float(basis_overlap(comps1[:, :k], comps2[:, :k]).max()) for k in H4_K_GRID]

# Bande nulle appariee : par k, 5 null-seeds x 40 tirages, meme metrique max|cos|.
h4_null_band = {}
for k in H4_K_GRID:
    draws = []
    for null_seed in H4_NULL_SEEDS:
        rng_null = np.random.default_rng([2000, null_seed])
        for _ in range(H4_NULL_DRAWS_PER_SEED):
            Q1, _ = np.linalg.qr(rng_null.standard_normal((H4_DIM, k)))
            Q2, _ = np.linalg.qr(rng_null.standard_normal((H4_DIM, k)))
            draws.append(float(basis_overlap(Q1, Q2).max()))
    q05, q50, q95 = np.quantile(np.array(draws), (0.05, 0.50, 0.95))
    h4_null_band[k] = (float(q05), float(q50), float(q95))

h4_results = {}
for regime, n_shared in (("orthogonal", 0), ("superposed", H4_N_SHARED)):
    curves = np.array([
        h4_real_curve(np.random.default_rng([1000, ds]), n_shared)
        for ds in H4_DATA_SEEDS
    ])
    band_q95 = np.array([h4_null_band[k][2] for k in H4_K_GRID])
    below = curves < band_q95                              # (seed, k) contre q95
    k_cross_obs = {}
    for s_idx, ds in enumerate(H4_DATA_SEEDS):
        above = [k for i, k in enumerate(H4_K_GRID) if not below[s_idx, i]]
        k_cross_obs[ds] = above[0] if above else None
    h4_results[regime] = {"curves": curves, "below": below, "k_cross_obs": k_cross_obs}
    print(f"=== H4 regime {regime} (n_shared={n_shared}) : overlap(k), {len(H4_DATA_SEEDS)} data-seeds ===")
    print(f"{'k':>3} {'real_mean':>10} {'real_std':>9} {'null_q05':>9} {'null_q50':>9} {'null_q95':>9} {'<q95':>6}")
    for i, k in enumerate(H4_K_GRID):
        q05, q50, q95 = h4_null_band[k]
        n_below = int(below[:, i].sum())
        print(f"{k:>3d} {curves[:, i].mean():>10.4f} {curves[:, i].std():>9.4f} "
              f"{q05:>9.4f} {q50:>9.4f} {q95:>9.4f} {n_below:>4d}/5")
    print(f"  croisements observes (premier k au-dessus de la bande) : {k_cross_obs}")
    print()

# Verdict contre la regle PRE-ENREGISTREE (aucun critere redefini ici)
k_pred = H4_PREDICTIONS["k_cross_predicted"]
p1_ok_seeds = sum(
    all(h4_results["orthogonal"]["below"][s, :]) for s in range(len(H4_DATA_SEEDS))
)
p2_ok_seeds = sum(
    all(h4_results["superposed"]["below"][s, :k_pred - 1])
    and all(~h4_results["superposed"]["below"][s, k_pred - 1:])
    for s in range(len(H4_DATA_SEEDS))
)
print("=== H4 : verdict contre la regle pre-enregistree ===")
print(f"P1 (orthogonal sous la bande a tout k) : {p1_ok_seeds}/5 data-seeds")
print(f"P2 (croisement exact a k={k_pred}) : {p2_ok_seeds}/5 data-seeds")
if p1_ok_seeds >= 4 and p2_ok_seeds >= 4:
    print(f"H4 SUPPORTED : courbe overlap-vs-k discriminante, croisement a k={k_pred} comme predit.")
else:
    print("H4 REFUTED : la regle pre-enregistree n'est pas tenue (diagnostic par seed ci-dessus).")
=== H4 regime orthogonal (n_shared=0) : overlap(k), 5 data-seeds ===
  k  real_mean  real_std  null_q05  null_q50  null_q95   <q95
  1     0.0000    0.0000    0.0022    0.0460    0.1148    5/5
  2     0.0000    0.0000    0.0407    0.0906    0.1478    5/5
  3     0.0000    0.0000    0.0658    0.1131    0.1803    5/5
  4     0.0000    0.0000    0.0846    0.1288    0.1821    5/5
  5     0.0000    0.0000    0.0996    0.1341    0.1819    5/5
  6     0.0000    0.0000    0.1102    0.1444    0.1922    5/5
  7     0.0000    0.0000    0.1188    0.1519    0.1984    5/5
  8     0.0000    0.0000    0.1242    0.1563    0.1972    5/5
  9     0.0000    0.0000    0.1283    0.1631    0.2221    5/5
 10     0.0000    0.0000    0.1386    0.1687    0.2071    5/5
 11     0.0000    0.0000    0.1431    0.1743    0.2217    5/5
 12     0.0000    0.0000    0.1459    0.1786    0.2192    5/5
  croisements observes (premier k au-dessus de la bande) : {0: None, 1: None, 7: None, 42: None, 99: None}

=== H4 regime superposed (n_shared=5) : overlap(k), 5 data-seeds ===
  k  real_mean  real_std  null_q05  null_q50  null_q95   <q95
  1     0.0006    0.0005    0.0022    0.0460    0.1148    5/5
  2     0.0015    0.0006    0.0407    0.0906    0.1478    5/5
  3     0.0020    0.0008    0.0658    0.1131    0.1803    5/5
  4     0.0028    0.0008    0.0846    0.1288    0.1821    5/5
  5     0.0031    0.0009    0.0996    0.1341    0.1819    5/5
  6     0.0046    0.0015    0.1102    0.1444    0.1922    5/5
  7     0.0060    0.0017    0.1188    0.1519    0.1984    5/5
  8     0.9759    0.0255    0.1242    0.1563    0.1972    0/5
  9     0.9759    0.0255    0.1283    0.1631    0.2221    0/5
 10     0.9886    0.0050    0.1386    0.1687    0.2071    0/5
 11     0.9889    0.0051    0.1431    0.1743    0.2217    0/5
 12     0.9917    0.0053    0.1459    0.1786    0.2192    0/5
  croisements observes (premier k au-dessus de la bande) : {0: 8, 1: 8, 7: 8, 42: 8, 99: 8}

=== H4 : verdict contre la regle pre-enregistree ===
P1 (orthogonal sous la bande a tout k) : 5/5 data-seeds
P2 (croisement exact a k=8) : 5/5 data-seeds
H4 SUPPORTED : courbe overlap-vs-k discriminante, croisement a k=8 comme predit.

Interpretation : lecture de la courbe H4

Sortie obtenue : deux familles de courbes overlap(k) (5 data-seeds chacune, moyenne +/- std) contre une bande nulle appariee (q05/q50/q95, 200 tirages par k).

Aspect Valeur Signification
Regime orthogonal courbe ~0 a tout k, sous la bande separation totale, independante du choix de k
Superpose, k <= 7 courbe sous la bande le top-k ne contient que des directions privees : un scalaire calcule ici dirait « faible recouvrement »
Superpose, k >= 8 courbe ~0.98, au-dessus de la bande les directions partagees entrent dans le top-k : un scalaire calcule ici dirait « fort recouvrement »
Bande nulle q50 monte de ~0.05 (k=1) a ~0.18 (k=12) le seuil de recouvrement aleatoire depend de k — pourquoi un scalaire a k fixe est fragile

Points cles :

  1. La lecture du recouvrement a k fixe s’inverse selon le k choisi (faible pour k <= 7, fort pour k >= 8 dans le regime superpose) : seule la courbe dit ou les directions partagees entrent dans le spectre et ou leur recouvrement sature. Un recouvrement au-dessus du quantile 95 du null n’est pas un recouvrement « semblable au null » : il le depasse. Inversement, le test de faible recouvrement contre le hasard serait mieux calibre sur la queue inferieure (q05) ; le seuil q95 ici est un discriminant descriptif pre-enregistre, pas une p-value de separation.
  2. Le croisement observe a k = n_privees + 1 = 8 confirme la prediction pre-enregistree sur ce generateur : la geometrie mesuree reflete les directions partagees placees en fin de spectre. Il ne constitue pas une preuve sur des activations de modele entraine.
  3. Data-seed et null-seed derivent de familles de graines distinctes ([1000, ...] vs [2000, ...]) : la variabilite rapportee (std entre data-seeds et percentiles des paires aleatoires) ne provient plus de la collision de graines entre conditions du sweep de bruit.

Note technique : le null de H4 reste un null de sous-espaces aleatoires apparies (meme modele nul que H1). Le temoin « >= 5 initialisations non entrainees » du 15/09 exige un banc de modele : hors scope de ce notebook synthetique, non revendique (cf. Limites).

Visualisations et limites

Cette section prepare des matrices d’overlap pour chaque regime et un resume textuel des resultats. Une variante matplotlib est documentee en exercice optionnel ; les chiffres exacts sont la sortie de reference.

# Matrice d'overlap moyenne pour le regime orthogonal (seed=0, demonstratif)
rng = np.random.default_rng(0)
bases, _ = make_factor_bases(N_FACTORS, N_DIM_RESIDUAL, mode="orthogonal", rng=rng)
overlap_matrix = np.zeros((N_FACTORS, N_FACTORS))
for i in range(N_FACTORS):
    for j in range(N_FACTORS):
        if i == j:
            overlap_matrix[i, j] = 1.0
        elif i < j:
            M = basis_overlap(bases[i], bases[j])
            overlap_matrix[i, j] = overlap_matrix[j, i] = float(M.max())
print("Matrice d'overlap (regime orthogonal, seed=0) :")
for row in overlap_matrix:
    print("  [" + "  ".join(f"{v:.3f}" for v in row) + "]")
print()
print("Interpretation : diagonale = 1.0 (auto-overlap), hors-diagonale proche de 0 = orthogonalite,")
print("proche de 1.0 = sous-espaces superposes. Verifier aussi la stabilite multi-seed.")
Matrice d'overlap (regime orthogonal, seed=0) :
  [1.000  0.000  0.000  0.000]
  [0.000  1.000  0.000  0.000]
  [0.000  0.000  1.000  0.000]
  [0.000  0.000  0.000  1.000]

Interpretation : diagonale = 1.0 (auto-overlap), hors-diagonale proche de 0 = orthogonalite,
proche de 1.0 = sous-espaces superposes. Verifier aussi la stabilite multi-seed.

Limites et bilan honnete

  • H3 (sensibilite au bruit) INCONCLUSIVE par construction du generateur : synthesize_activations utilise les memes bases orthogonales pour tous les niveaux de bruit ; le bruit est additif sur les activations, pas sur les directions factorielles elles-memes. L’overlap entre sous-espaces factoriels reste donc 0.0 par construction, independamment du bruit. Pour tester H3, il faudrait introduire le bruit dans les bases (rotation aleatoire des sous-espaces) – un chantier a part.
  • H2 (additivite jointe) INCONCLUSIVE avec gap median 9 dim : la dimension jointe (231) est legerement inferieure a la somme des dimensions factorielles (240). Le gap est petit (< 4% de la dimension jointe) et stable sur 5 seeds ; il reflete probablement la perte de variance au centrage pondere. Une investigation plus poussee (NC@80/90 vs NC@95) est en dehors du scope de ce grain.
  • Donnees synthetiques uniquement : les primitives sont testees sur des generateurs connus, pas sur des activations reelles de transformer. La migration vers le contrat de trace v1 (#15476) ouvrira la voie aux activations SAE/J-Lens reelles.
  • Echelle : N_DIM_RESIDUAL=256 et N_FACTORS=4 sont des proxies ; un vrai residual stream LLM est ~4096 dim avec un nombre de facteurs non connu a priori.
  • Ponderation : les poids sont ici tous egaux (1/N). La primitive supporte une ponderation explicite ; les donnees reelles (futur #15476) pourront introduire des poids par token ou par etat belief.
  • Multi-seed 5 seeds : conforme aux conventions notebook ICT-Series (>= 4 seeds pour probes).
  • Temoin nul limite (suivi du 15/09) : le null de H1/H4 est un null de sous-espaces aleatoires apparies. Le critere « temoin nul sur >= 5 initialisations NON ENTRAINES du meme modele, percentiles 5/50/95 » n’est pas couvert ni revendique ici : ce notebook n’entraine aucun modele, et ce temoin repond a une question differente (biais inductif de l’architecture, pas orthogonalite relative au hasard). Le livrer exige le banc ict/bench_factorise.py — suivi distinct sur #15478.
  • Axes de graine : H4 separe data-seed ([1000, seed]) et null-seed ([2000, seed]) sur deux entropies disjointes ; il n’existe pas d’axe weight-init en regime synthetique (aucun modele entraine). La separation data-seed / weight-init-seed sur des activations reellement entrainees reste ouverte (suivi #15478).
  • Pas de code non licencie : primitives reimplementees depuis arXiv:2602.02385 et nos propres contrats ; aucun copier-coller des depots factored-reps, simplexity, strange-loop ou pytorch-AI-interpretability-transformer_ZM (sans licence detectee au preflight #15475).
  • Verdict scientifique : H1 SUPPORTED (5/5 seeds), H2 INCONCLUSIVE (gap 9 dim), H3 INCONCLUSIVE (generateur limite), H4 SUPPORTED (croisement a k_cross = 8 comme pre-enregistre). Le notebook delivre des primitives testees et un verdict falsifiable ; le passage aux activations reelles via #15476 est une etape distincte.

Migration future vers le contrat de trace v1 (#15476)

Quand #15476 sera livre, le consommateur de ce notebook deviendra :

from pathlib import Path
import numpy as np

trace_path = Path("ict/traces/v1/ict36_flens_orthogonal.npz")
data = np.load(trace_path)
activations = data["activations"]  # (N, D)
weights = data["weights"]          # (N,)
metadata = json.loads(data["metadata_json"].item() if hasattr(data["metadata_json"], "item") else data["metadata_json"])
mu, comps, sv, evr = weighted_pca(activations, weights)

Aucun changement aux primitives : le contrat ajoute provenance, semantique et validation sans modifier l’analyse.

Sortie de reference (extrait verbatim)

Cette section est reservee a la sortie reelle du notebook apres execution. Les valeurs exactes dependent du kernel et du seed ; voir les cellules code ci-dessus pour les sorties verbatim. Le verdict final (cellules Verdict multi-seed et H4) est l’output scientifique livrable.

Retour au sommet