ICT-40a — Triangulation causale : SAE x J-Lens x F-Lens sur banc factorise
Pilote #15480 : le premier verdict causal multi-instrument du toolkit ICT, rendu sur un banc ou l’etat latent et ses facteurs sont connus, avant de passer a un LLM opaque. Le banc est le processus factorise Mess3 x RRXOR (ICT-34/#15478) : deux generateurs independants emettent en parallele, un petit transformer hookable est entraine en next-token sur leur flux tokenise joint, et les belief states exacts de chaque facteur sont calculables par filtration forward — le sol de verite existe a chaque position.
Trois instruments, trois lectures separees de la meme machine :
Instrument
Ce qu’il lit
Livrable ici
F-Lens belief
ou l’etat est lineairement decodable (R2 held-out par couche)
couches porteuses A et B
SAE
quelles features latentes portent chaque facteur (AUC vs null apparie)
feature A, features B
J-Lens
ce que la couche cause dans la sortie (interchange -> propagation -> logits)
profil d’effet par couche
Hypotheses pre-enregistrees (verbatim de l’issue, aucune ne se re-formule apres observation) :
H1 : l’ablation ciblee degrade davantage le decodage du facteur A que celui de B et que le controle aleatoire apparie.
H2 : l’effet J-Lens apparait aux couches/positions ou F-Lens localise l’etat, sans exiger une forte correlation observationnelle globale.
H3 : le comportement lie a A se degrade selectivement tandis que B et la perplexite generale restent relativement preserves.
H4 : les features associees a A et B montrent une separation geometrique superieure a des partitions aleatoires ; l’echec refute cette lecture sans invalider le SAE.
Chaque hypothese recoit SUPPORTED / NOT_SUPPORTED / INCONCLUSIVE avec taille d’effet (mediane inter-seeds), IC bootstrap percentile et correction de Holm sur la famille {cible vs B, cible vs aleatoire}. Aucun score omnibus : l’accord et la dissociation entre instruments sont rapportes separement (acceptance #15480).
Architecture du pilote (#15475/#15479) : le pipeline analytique est numpy-only et vit dans le paquet ict/ ; la couche torch (modele + capture de panneaux + re-injection) est confinee dans scripts/ict_pilot_transformer.py. Ce notebook consomme les deux par leurs APIs testeess.
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.
# Parametres geles du pilote : protocole pre-enregistre, aucune valeur ne se# re-ajuste apres observation des resultats (acceptance #15480).import jsonSEEDS = (0, 1, 2, 3, 4, 5) # 6 seeds : >= 4 exige, signflip exact atteignableN_BENCH =40_000# pas du banc par seedSEQ_LEN, HOP =64, 16# fenetres du flux tokeniseSTEPS =3000# pas d'entrainement du transformerTRAIN_FRAC =0.7# decoupe SEQUENTIELLE gelee train/evalGAP_WINDOWS = SEQ_LEN // HOP -1# fenetres sautees a la frontiere (hop < seq_len)K_SAE, TOPK_SAE =48, 8# dictionnaire sparse : 48 features, top-k 8SAE_LR =0.02# pas du full-batch : 0.05 diverge sur le residualN_SAE_TRAIN =12_000# lignes (sous-echantillon gele) du corpus SAEN_NULL_SELECT =200# nulls des AUC de selectivite (permutations)TOP_R_GEOM =4# rang des bases factorielles (H4)N_NULL_GEOM =200# nulls de l'overlap geometrique (H4)DOSES = (0.25, 0.50, 0.75, 1.00) # fractions de suppression de la feature latenteN_EVAL_WINDOWS =192# fenetres eval par seed (F-Lens / SAE)N_CAUSAL =64# fenetres eval pour les bras causaux (cout borne)N_JLENS =64# fenetres eval pour le profil J-LensCONF_DONOR =0.80# confiance minimale pour un donneur J-LensJLENS_K =8# dose fixee J-Lens : k coordonnees A, identiques a chaque couche (#16230)JLENS_N_BANDS =4# axe positions : bandes contigues de SEQ_LEN // JLENS_N_BANDSALPHA =0.05LAYER_KEYS = ("L0_pre", "L0_post", "L1_pre", "L1_post")print("Protocole gele :", json.dumps({"seeds": SEEDS, "n_bench": N_BENCH,"seq_len": SEQ_LEN, "hop": HOP, "steps": STEPS, "k_sae": K_SAE,"topk_sae": TOPK_SAE, "doses": DOSES, "jlens_k": JLENS_K,"jlens_n_bands": JLENS_N_BANDS, "n_null_select": N_NULL_SELECT,"n_null_geom": N_NULL_GEOM, "top_r_geom": TOP_R_GEOM}))
# Imports. Le paquet ict/ (numpy-only) se trouve dans le dossier de la serie ;# le harnais torch vit dans scripts/ a la racine du depot (#15475/#15479).import osimport sysimport timeimport jsonfrom pathlib import Pathimport numpy as npsys.path.insert(0, os.path.abspath(".")) # paquet ict/sys.path.insert(0, str(Path(".").resolve().parents[2] /"scripts")) # harnais torchimport torchfrom ict.bench_factorise import FactoredBench, Mess3Canonical, RRXORfrom ict.sae_dictionary import train_sae, factor_selectivityfrom ict.causal_engine import (InterventionSpec, apply_intervention, interchange_panels, random_target_matched, sham_of, dose_response_specs, damage_metrics, selectivity_verdict, holm_adjust, artifact_sha256)from ict.intervention_battery import run_battery, chain_verdict, dose_monotonicityfrom ict_pilot_transformer import (TinyTransformer, TrainConfig, capture_panels, sequences_from_bench, tokenize_joint, train_tiny)DEVICE ="cuda"if torch.cuda.is_available() else"cpu"print("device :", DEVICE, "| torch", torch.__version__,"| numpy", np.__version__)
device : cuda | torch 2.14.0+cu126 | numpy 2.4.2
1. Banc factorise et tokenisation
FactoredBench(Mess3Canonical, RRXOR) — banc conforme #16225/#16675 : le facteur A est le Mess3 canonique (Marzen & Crutchfield 2017 : 3 etats caches en cycle, emissions ternaires discretes non couplees a l’etat, le belief vit dans le 2-simplexe sans s’effondrer en Dirac), le facteur B le RRXOR conforme (Riechers & Crutchfield 2018 : machine Mealy a 5 etats causaux, contrainte de triplet, correlations par paires nulles — le processus que la version iid precedente ne pouvait pas produire). Les deux tournent en parallele, independants : sample fournit les observations jointes ET les etats caches des deux facteurs ; beliefs fournit la filtration forward exacte (produit des marginales, 3 x 5 = 15 dimensions).
Tokenisation jointe : l’alphabet est nativement categoriel — A dans {0, 1, 2} sur 3 bins geles (frontieres a mi-chemin des symboles : -0.5, 0.5, 1.5, 2.5 ; aucun parametre libre, aucune fuite sur l’echantillon), B binaire sur 2 tranches. Token = bin_a * 2 + b, alphabet 6.
2. Transformer hookable : entrainement multi-seed et capture des panneaux
TinyTransformer (2 blocs pre-LN, d_model=64) predit le token suivant. La capture distingue le residual AVANT la LayerNorm d’entree de chaque bloc (pre) et le residual APRES sortie du bloc (post) — la distinction pre/post est exigee par l’acceptance #15480.
Decoupe gelee : les fenetres (hop 16) se chevauchent, donc une permutation ferait fuiter des tokens a travers la frontiere. Le split est donc sequentiel : premieres 70 % de fenetres pour l’ajustement (probes, SAE, normes/frequences de corpus), dernieres 30 % pour la mesure, avec GAP_WINDOWS fenetres sautees a la frontiere — aucune fenetre evaluee ne partage un token avec une fenetre d’ajustement. Aucun aleatoire : le split est une fonction pure de la grille de fenetrage.
3. F-Lens belief : sondes lineaires R2 held-out par couche
Pour chaque couche (pre et post de chaque bloc) et chaque facteur, une sonde ridge multivariee predit le belief exact depuis le panneau residual. Le R2 held-out (fenetres eval, decoupe gelee) localise ou l’etat vit : la couche porteuse A et la couche porteuse B sont les argmax du R2 median inter-seeds. Ce sont les ancrages de H2 (l’effet J-Lens doit s’y concentrer) et le choix de couche du dictionnaire SAE.
Les parametres ajustes sont conserves : les memes sondes resservent, gelees, comme mesureurs du canal etat dans les bras causaux (avant/apres intervention).
def ridge_params(X_tr, Y_tr): Xm, Ym = X_tr.mean(0), Y_tr.mean(0) Xc = X_tr - Xm lam =1e-2* np.trace(Xc.T @ Xc) / Xc.shape[1] W = np.linalg.solve(Xc.T @ Xc + lam * np.eye(Xc.shape[1]), Xc.T @ (Y_tr - Ym))return Xm, Ym, Wdef probe_predict(par, X): Xm, Ym, W = parreturn (X - Xm) @ W + Ymdef r2_channels(Y_true, Y_pred): ss_res = ((Y_true - Y_pred) **2).sum(0) ss_tot = ((Y_true - Y_true.mean(0)) **2).sum(0)return1- ss_res / ss_totprobes = {} # probes[seed][couche][facteur] = (Xm, Ym, W)flens_r2 = {} # flens_r2[seed]["couche/facteur"] = R2 moyenfor seed in SEEDS: d = pilot[seed] tr, ev = d["tr"], d["ev"] probes[seed] = {} flens_r2[seed] = {}for key in LAYER_KEYS: P = d[key] probes[seed][key] = {}for name in ("a", "b"): bl = d["belief_"+ name] X_tr = P[tr].reshape(-1, P.shape[-1]) Y_tr = bl[tr].reshape(-1, bl.shape[-1]) par = ridge_params(X_tr, Y_tr) probes[seed][key][name] = par X_ev = P[ev].reshape(-1, P.shape[-1]) Y_ev = bl[ev].reshape(-1, bl.shape[-1]) r2 = r2_channels(Y_ev, probe_predict(par, X_ev)) flens_r2[seed][f"{key}/{name}"] =float(np.mean(r2))print(f"{'couche/facteur':<16}"+"".join(f"seed{s:<7}"for s in SEEDS) +"median")for key in LAYER_KEYS:for name in ("a", "b"): vals = [flens_r2[s][f"{key}/{name}"] for s in SEEDS]print(f"{key +'/'+ name:<16}"+"".join(f"{v:<9.3f}"for v in vals)+f"{np.median(vals):.3f}")def med_r2(key, name):returnfloat(np.median([flens_r2[s][f"{key}/{name}"] for s in SEEDS]))LAYER_A =max(LAYER_KEYS, key=lambda k: med_r2(k, "a"))LAYER_B =max(LAYER_KEYS, key=lambda k: med_r2(k, "b"))print(f"\nCouche porteuse A : {LAYER_A} (R2 median {med_r2(LAYER_A, 'a'):.3f})")print(f"Couche porteuse B : {LAYER_B} (R2 median {med_r2(LAYER_B, 'b'):.3f})")
4. SAE : dictionnaire sparse et selectivite aux facteurs
Un TopKSae (48 features, k=8) est ajuste par seed sur les panneaux de la couche porteuse A, split train uniquement. La selectivite de chaque feature se mesure sur les codes des fenetres eval : AUC de Mann-Whitney contre le label one-vs-rest etat==0 de chaque facteur (convention de la batterie #15799), avec z-score contre 200 relabelisations appariees en effectifs.
feature A : plus grand ecart AUC_A - AUC_B (portee par A, pas par B) ;
features A/B (pour H4) : les 4 plus grands ecarts de chaque cote.
sae_art = {}for seed in SEEDS: d = pilot[seed] P = d[LAYER_A] X_tr = P[d["tr"]].reshape(-1, P.shape[-1])# corpus SAE borne : sous-echantillon gele (le full-batch sur tout le split# serait ~4 min/seed) ; stats et normalisation gelees dessus rng_sae = np.random.default_rng(seed +4242) sub = rng_sae.choice(len(X_tr), N_SAE_TRAIN, replace=False) X_sub = X_tr[sub]# standardisation gelee (stats du sous-echantillon train) : le residual# stream a une echelle bien plus grande que les panneaux belief pour# lesquels le SAE est calibre ; lr 0.02 (0.05 diverge, overflow mesure) mu_tr = X_sub.mean(axis=0) sd_tr = X_sub.std(axis=0) +1e-8 fit = train_sae((X_sub - mu_tr) / sd_tr, n_features=K_SAE, k=TOPK_SAE, seed=seed, n_steps=400, lr=SAE_LR) sae = fit["sae"] ev = d["ev"] X_ev = P[ev].reshape(-1, P.shape[-1]) z_ev = sae.encode((X_ev - mu_tr) / sd_tr) lab_a = d["states_a"][ev].reshape(-1) ==0 lab_b = d["states_b"][ev].reshape(-1) ==0 sel_a = factor_selectivity(z_ev, lab_a, n_null=N_NULL_SELECT, seed=seed) sel_b = factor_selectivity(z_ev, lab_b, n_null=N_NULL_SELECT, seed=seed) gap = sel_a["auc"] - sel_b["auc"] feats_a = np.argsort(gap)[::-1][:TOP_R_GEOM] feats_b = np.argsort(gap)[:TOP_R_GEOM]# normes / frequences de corpus pour l'appariement du controle aleatoire z_tr = sae.encode((X_sub - mu_tr) / sd_tr) norms = np.linalg.norm(z_tr, axis=0) freqs = (z_tr >0).mean(axis=0) sae_art[seed] = {"sae": sae, "fit": fit, "gap": gap,"sel_a": sel_a, "sel_b": sel_b,"feats_a": feats_a, "feats_b": feats_b,"f_A": int(feats_a[0]),"z_norms": norms, "z_freqs": freqs,"mu": mu_tr, "sd": sd_tr} fA = sae_art[seed]["f_A"]print(f"seed {seed} : fvu {fit['fvu']:.3f} | l0 {fit['l0']:.1f} | "f"feature A #{fA} AUC_A {sel_a['auc'][fA]:.3f} "f"AUC_B {sel_b['auc'][fA]:.3f} "f"(z_A {sel_a['z'][fA]:5.1f})")
Primitives numpy-only recopiees d’ICT-36 (la lib factor_geometry.py n’est pas sur main, See #15943) : PCA ponderee, NC@p, overlap de bases, angle principal, et la distribution nulle d’overlap entre sous-espaces aleatoires apparies en dimension.
Test H4 : les colonnes du decodeur SAE des features A et B (top-4 de chaque cote, orthonormalisees par QR) forment deux bases d’un espace residual de dimension 64. L’overlap maximal entre les deux bases reelles doit etre inferieur au quantile 95 de l’overlap entre paires de sous-espaces aleatoires apparies : plus orthogonaux que le hasard = separation factorielle.
# Primitives recopiees d'ICT-36 (verbatim, renvoi #15943).NC_THRESHOLDS = (80, 90, 95, 99)def weighted_pca(activations, weights=None): X = np.asarray(activations, dtype=np.float64) N, D = X.shapeif weights isNone: w = np.ones(N, dtype=np.float64) / Nelse: w = np.asarray(weights, dtype=np.float64) w = w / w.sum() mu = (w[:, None] * X).sum(axis=0) Xc = X - mu C = (Xc.T * w) @ Xc eigvals, eigvecs = np.linalg.eigh(C) order = np.argsort(eigvals)[::-1] eigvals, eigvecs = eigvals[order], eigvecs[:, order]for j inrange(eigvecs.shape[1]):if eigvecs[0, j] <0: eigvecs[:, j] =-eigvecs[:, j] sv = np.sqrt(np.maximum(eigvals, 0.0)) * np.sqrt(N) total_var = eigvals.sum() evr = eigvals / total_var if total_var >0else np.zeros_like(eigvals)return mu, eigvecs, sv, evrdef nc_at(evr, thresholds=NC_THRESHOLDS): cum = np.cumsum(evr) out = {}for k in thresholds: idx =int(np.searchsorted(cum, k /100.0) +1) out[k] =min(idx, len(cum))return outdef basis_overlap(B1, B2): M = B1.T @ B2return np.abs(M)def max_principal_angle(B1, B2): P = B2 @ B2.T Q = np.eye(B1.shape[0]) - P sin_max = np.linalg.norm(Q @ B1, ord=2) sin_max =min(max(sin_max, 0.0), 1.0)returnfloat(np.degrees(np.arcsin(sin_max)))def null_overlap_distribution(dim, dim_factor, n_nulls=100, seed=0): rng = np.random.default_rng(seed) nulls = []for _ inrange(n_nulls): A = rng.standard_normal((dim, dim_factor)) Q, _ = np.linalg.qr(A) nulls.append(float(np.abs(Q.T @ Q).max()))return np.array(nulls)def null_pair_overlap(dim, dim_factor, n_nulls, seed): rng = np.random.default_rng(seed) out = []for _ inrange(n_nulls): Q1, _ = np.linalg.qr(rng.standard_normal((dim, dim_factor))) Q2, _ = np.linalg.qr(rng.standard_normal((dim, dim_factor))) out.append(float(basis_overlap(Q1, Q2).max()))return np.array(out)print("Primitives geometriques pretes (ICT-36, #15943).")
Primitives geometriques pretes (ICT-36, #15943).
# Test H4 : overlap reel des bases A/B vs distribution nulle appariee.h4_rows = []h4_sep = []for seed in SEEDS: art = sae_art[seed] sae = art["sae"] D = sae.W_dec.shape[0]def qrbasis(feats):return np.linalg.qr(sae.W_dec[:, list(feats)])[0] Qa, Qb = qrbasis(art["feats_a"]), qrbasis(art["feats_b"]) real_max =float(basis_overlap(Qa, Qb).max()) nulls = null_pair_overlap(D, TOP_R_GEOM, N_NULL_GEOM, seed *31+17) q95 =float(np.quantile(nulls, 0.95))# angle principal : lecture complementaire, meme direction de test angle = max_principal_angle(Qa, Qb) h4_sep.append(q95 - real_max) h4_rows.append((seed, real_max, q95, angle))print(f"seed {seed} : overlap reel {real_max:.3f} | null q95 {q95:.3f} | "f"ecart {q95 - real_max:+.3f} | angle principal {angle:5.1f} deg | "f"{'separe'if q95 > real_max else'NON separe'}")h4_verdict = chain_verdict(h4_sep, [0.0] *len(h4_sep), min_seeds=4)print("\nH4 :", h4_verdict)
6. J-Lens : readout local par couche, a dose fixee
Semantique explicite : l’effet J-Lens d’une couche i sur une fenetre w est la divergence KL (position finale, distribution next-token) entre le forward intact et le forward ou une sous-composante de taille fixee de l’etat de la couche i de w – JLENS_K coordonnees du belief A, les memes a chaque couche et a chaque bande de positions – est remplacee par celle d’une fenetre donneuse, via l’operation interchange du moteur causal (#15479) puis propagation jusqu’aux logits par forward_patched. C’est un contre-factuel d’etat, pas une correlation : la question est « que ferait la sortie si cette sous-composante de l’etat a la couche i etait celle d’une autre fenetre ? ».
Pourquoi une dose fixee et non le panneau entier (reparation #16230) : une interchange portant sur tout le panneau remplace l’etat entier par celui du donneur ; le modele recalcule alors, depuis n’importe quel point d’injection, la trajectoire complete du donneur, et les logits finaux ne dependent plus de la couche – le profil par couche est invariant par construction. L’execution precedente affichait quatre lignes identiques a quatre decimales, et son « median_diff » de 1,8e-08 etait le residu du lissage numerique du KL, pas un signal. A dose fixee, ce qui varie d’une cellule du profil a l’autre est le point d’injection – la couche, croisee avec des bandes de positions – et rien d’autre : la couche redevient audible. Deux cles de prelevement restent redondantes par construction (L0_post et L1_pre designent le meme residuel, cf la table F-Lens).
Deux bras de donneurs par couche : donneur A-different (belief A confiant de classe differente) et donneur B-different (configuration du belief B differente). Sur le banc iid precedent, le plafond de confiance du belief RRXOR etait borne a 0,50 a chaque pas – un bit du XOR brut reste toujours incertain – et une porte de confiance y etait structurellement vide pour B, ce que l’execution anterieure compensait en substituant silencieusement 0,0 a ce bras muet. Le RRXOR conforme (#16225, machine Mealy) concentre au contraire la croyance aux aretes deterministes (X_v emet v avec probabilite 1) ; le critere pre-enregistre – donneurs de configuration differente (argmax du belief), porte satisfiable pour les deux facteurs – est neanmoins conserve a l’identique : le protocole ne se re-ajuste pas apres observation des resultats (acceptance #15480). Les effectifs de chaque bras sont imprimes, et un bras vide ne produit plus un nombre.
H2 predit : l’effet A-cf culmine a la couche porteuse A du F-Lens, pas ailleurs ; et a la couche porteuse, l’effet A-cf excede l’effet B-cf (dissociation).
# Profil J-Lens a dose FIXEE (reparation #16230).## Une `interchange` portant sur TOUTES les positions et TOUTES les# dimensions remplace l'etat entier par celui du donneur : `forward_patched`# propage alors, depuis n'importe quel point d'injection, la trajectoire# complete du donneur, et les logits finaux ne dependent plus de la couche# d'injection. Le profil par couche est invariant PAR CONSTRUCTION (sortie# stockee de l'execution precedente : quatre lignes identiques a quatre# decimales, median_diff 1,8e-08). La cible est donc une sous-composante de# taille fixee : JLENS_K coordonnees du belief A, gelees une fois par seed# depuis le probe F-Lens de la couche porteuse, identiques a chaque couche# et a chaque bande, croisees avec des bandes contigues de positions.from ict_jlens_layers import (component_coords, is_full_panel, kl_final, layer_spread)bandes =tuple(tuple(range(b * SEQ_LEN // JLENS_N_BANDS, (b +1) * SEQ_LEN // JLENS_N_BANDS))for b inrange(JLENS_N_BANDS))tout =tuple(range(SEQ_LEN))jlens = {s: {} for s in SEEDS} # jlens[seed][couche] = (A-cf, B-cf)jlens_bandes = {s: {} for s in SEEDS} # idem, par (couche, bande)jlens_n = {s: {} for s in SEEDS} # effectifs de donneurs par brasJLENS_COORDS = {} # dose gelee par seedt0 = time.time()for seed in SEEDS: d = pilot[seed] model = d["model"] ev = d["ev"][:N_JLENS] seqs_ev = d["seqs"][ev] ba, bb = d["belief_a"][ev][:, -1], d["belief_b"][ev][:, -1] JLENS_COORDS[seed] = component_coords( probes[seed][LAYER_A]["a"][2], JLENS_K) coords = JLENS_COORDS[seed]assertnot is_full_panel(tout, coords, (SEQ_LEN, d[LAYER_A].shape[-1])), ("dose pleine : le profil par couche serait invariant (#16230)") refs = [model.forward_patched(torch.from_numpy(s[None]), {})for s in seqs_ev]# Critere de donneur B : le plafond de confiance du belief RRXOR est# 0,50 a chaque pas (mesure sur 6x40 000 tirages) -- la porte# conf > CONF_DONOR etait structuralement vide pour B et l'ancienne# cellule substituait 0,0 a ce bras muet. Le bras B retient des# donneurs de configuration differente (argmax du belief), porte# satisfiable ; le bras A garde sa porte de confiance d'origine. lab_a, conf_a = ba.argmax(-1), ba.max(-1) lab_b = bb.argmax(-1) elig_a = conf_a > CONF_DONOR elig_b = np.ones_like(elig_a)for key in LAYER_KEYS: P = d[key] li =int(key[1]) kl_a, kl_b, n_a, n_b = [], [], 0, 0for bi, band inenumerate(bandes): acc_a, acc_b = [], []for i inrange(len(ev)):for lab, elig, acc in ((lab_a, elig_a, acc_a), (lab_b, elig_b, acc_b)):ifnot elig[i]:continue cand = np.where(elig & (lab != lab[i]))[0]iflen(cand) ==0:continue wd =int(cand[0]) spec = InterventionSpec( operation="interchange", instrument="jlens", layer=li, positions=band, features=coords, tensor_space="residual", run=f"pilot-{seed}", paired_run=f"donor-{wd}", seed=seed) p_cf, _ = interchange_panels(P[ev[i]], P[ev[wd]], spec) cf = model.forward_patched( torch.from_numpy(seqs_ev[i][None]), {key: torch.from_numpy(p_cf[None])}) acc.append(kl_final(refs[i], cf)) jlens_bandes[seed][(key, bi)] = (float(np.mean(acc_a)) if acc_a elsefloat("nan"),float(np.mean(acc_b)) if acc_b elsefloat("nan")) n_a +=len(acc_a) n_b +=len(acc_b) kl_a += acc_a kl_b += acc_b jlens[seed][key] = (float(np.mean(kl_a)) if kl_a elsefloat("nan"),float(np.mean(kl_b)) if kl_b elsefloat("nan")) jlens_n[seed][key] = (n_a, n_b)print(f"profil J-Lens a dose fixee ({JLENS_K} coordonnees A x "f"{JLENS_N_BANDS} bandes) en {time.time() - t0:.0f}s")print("coords A gelees par seed :", JLENS_COORDS)print("effectifs donneurs A/B (seed 0) :", jlens_n[SEEDS[0]])print(f"\n{'couche':<10}"+"".join(f"seed{s:<9}"for s in SEEDS)+"A-cf median")for key in LAYER_KEYS: vals = [jlens[s][key][0] for s in SEEDS]print(f"{key:<10}"+"".join(f"{v:<10.4f}"for v in vals)+f"{np.median(vals):.4f}")# Etendue inter-couches par bande (seed 0) : > 0 des que la couche est# audible ; sous panneau entier elle vaut 0 a la tolerance pres (#16230).spread = {f"bande{bi}": layer_spread({k: jlens_bandes[SEEDS[0]][(k, bi)][0]for k in LAYER_KEYS})for bi inrange(JLENS_N_BANDS)}print("etendue inter-couches par bande (seed 0) :", {k: round(v, 4) for k, v in spread.items()})# H2, maillon couches : effet A-cf a la couche porteuse vs autres couches.others = [k for k in LAYER_KEYS if k != LAYER_A]h2_layer = chain_verdict( [jlens[s][LAYER_A][0] for s in SEEDS], [float(np.mean([jlens[s][k][0] for k in others])) for s in SEEDS], min_seeds=4)# Dissociation : A-cf vs B-cf a la couche porteuse. Un bras vide ne se# lit plus comme un effet nul : la comparaison est declaree non mesuree.ifall(jlens_n[s][LAYER_A][1] >0for s in SEEDS): h2_dissoc = chain_verdict( [jlens[s][LAYER_A][0] for s in SEEDS], [jlens[s][LAYER_A][1] for s in SEEDS], min_seeds=4) h2_p = holm_adjust([h2_layer["p_signflip"], h2_dissoc["p_signflip"]])else: vides = [s for s in SEEDS if jlens_n[s][LAYER_A][1] ==0] h2_dissoc = {"verdict": "NOT_MEASURED", "p_signflip": float("nan"),"bras_b_vide_sur": vides} h2_p = holm_adjust([h2_layer["p_signflip"], 1.0])print("\nH2 couches (porteuse vs autres) :", h2_layer,"| p Holm", round(h2_p[0], 4))print("H2 dissociation (A-cf vs B-cf a la porteuse) :", h2_dissoc,"| p Holm", round(h2_p[1], 4))
7. Interventions SAE latentes sur le transformer — H1, H3
La chaine causale complete, testee (pas racontee) : intervention dans l’espace latent du SAE -> re-decodage du panneau residual -> re-injection torch -> trois canaux mesures separesment (etat / readout / comportement, EffectChannels #15475).
Operation : steer sur le code de la feature A, direction unitaire e_f, dose -lambda * z_barre_f ou lambda parcourt les doses pre-enregistrees (0.25 a 1.0) et z_barre_f est l’activation moyenne de train — a lambda = 1 la feature est moyennee a zero en moyenne.
Controles (moteur #15479) : sham_of (dose 0, meme voie de code) et random_target_matched (feature aleatoire appariee en norme ET frequence de corpus, bande 25 %). Comparaison off-target : le decodage de B sous la meme intervention.
Reference de decode : le panneau reconstruit decode(encode(panel)) SANS intervention — la comparaison etat est cond - recon, jamais cond - brut, pour ne pas mesurer l’erreur de reconstruction du SAE.
Comportement : entropie croisee next-token sur toutes les positions, et accuracies separees de la composante A (token // 2) et B (token % 2) du token predit.
def window_ce_acc(logits, y_tokens):# CE moyenne + accuracy des composantes A (//2) et B (%2) du token.# logits (1, T, V) : la position t predit t+1 -> positions 0..T-2 valides lg = logits[0, :-1] ce = torch.nn.functional.cross_entropy( lg, torch.from_numpy(y_tokens).to(lg.device)) pred = lg.argmax(-1).cpu().numpy() acc_a =float((pred //2== y_tokens //2).mean()) acc_b =float((pred %2== y_tokens %2).mean())returnfloat(ce), acc_a, acc_bdef rmse_belief(par, panel, belief_win): pred = probe_predict(par, panel)returnfloat(np.sqrt(np.mean((belief_win - pred) **2)))causal = {} # causal[seed][(condition, dose_index)] = dict de mesurest0 = time.time()for seed in SEEDS: d, art = pilot[seed], sae_art[seed] model, sae = d["model"], art["sae"] mu, sd = art["mu"], art["sd"] ev = d["ev"][:N_CAUSAL] par_a = probes[seed][LAYER_A]["a"] par_b = probes[seed][LAYER_A]["b"] f_A = art["f_A"] P = d[LAYER_A]def encode_panel(panel):return sae.encode((panel - mu) / sd)def decode_panel(z_codes):# retour a l'espace residual brut (celui des sondes et du forward)return sae.decode(z_codes) * sd + mu# activation moyenne par feature sur le split train (echelle des doses) zmean = encode_panel(P[d["tr"]].reshape(-1, P.shape[-1])).mean(axis=0)# direction dans l'espace des features cibles (len(direction)==len(features)) base = InterventionSpec( operation="steer", instrument="sae", layer=int(LAYER_A[1]), positions=tuple(range(SEQ_LEN)), features=(f_A,), direction=(1.0,), tensor_space="sae_latent", run=f"pilot-{seed}", seed=seed)# controle aleatoire apparie en norme ET frequence de corpus. La feature# A (la plus selective) est souvent un outlier de norme : le moteur refuse# une bande vide — echelle de tolerance explicite, la tolerance atteinte# est imprimee (jamais d'appariement degrade silencieux). z_ref = encode_panel(P[d["tr"][0]])for tol in (0.25, 0.5, 1.0, 2.0):try: rand_spec = random_target_matched( z_ref, base, feature_norms=art["z_norms"], feature_freqs=art["z_freqs"], rel_tol=tol, rng=np.random.default_rng(seed +777))print(f"seed {seed} : controle aleatoire apparie (rel_tol={tol})")breakexceptValueError:continueelse:raise f_R =int(rand_spec.features[0]) rand_base = InterventionSpec( operation="steer", instrument="sae", layer=int(LAYER_A[1]), positions=tuple(range(SEQ_LEN)), features=(f_R,), direction=(1.0,), tensor_space="sae_latent", run=f"pilot-{seed}", seed=seed, control_ref="random-matched") sham_spec = sham_of(base) familles = {"target": dose_response_specs( base, tuple(-lam * zmean[f_A] for lam in DOSES)),"random": dose_response_specs( rand_base, tuple(-lam * zmean[f_R] for lam in DOSES)),"sham": [sham_spec], } causal[seed] = {}for w_i, w inenumerate(ev): panel = d[LAYER_A][w] z = encode_panel(panel) recon = decode_panel(z) tok = torch.from_numpy(d["seqs"][w][None]) y = d["seqs"][w][1:] logits_int = model.forward_patched(tok, {}) logits_rec = model.forward_patched( tok, {LAYER_A: torch.from_numpy(recon[None])}) ce0, aa0, ab0 = window_ce_acc(logits_int, y) ce_r, aa_r, ab_r = window_ce_acc(logits_rec, y) rm_a_r = rmse_belief(par_a, recon, d["belief_a"][w]) rm_b_r = rmse_belief(par_b, recon, d["belief_b"][w])for cond, specs in familles.items():for di, spec inenumerate(specs): z_cond = apply_intervention(z, spec) panel_cond = decode_panel(z_cond) logits_cd = model.forward_patched( tok, {LAYER_A: torch.from_numpy(panel_cond[None])}) ce_c, aa_c, ab_c = window_ce_acc(logits_cd, y) dmg = damage_metrics(z, z_cond, spec) slot = causal[seed].setdefault((cond, di), {"dd_a": [], "dd_b": [], "dce": [], "daa": [], "dab": [],"dz_f": [], "sel": []}) slot["dd_a"].append( rmse_belief(par_a, panel_cond, d["belief_a"][w]) - rm_a_r) slot["dd_b"].append( rmse_belief(par_b, panel_cond, d["belief_b"][w]) - rm_b_r) slot["dce"].append(ce_c - ce_r) slot["daa"].append(aa_c - aa_r) slot["dab"].append(ab_c - ab_r) slot["dz_f"].append(float(np.abs(z_cond[:, f_A] - z[:, f_A]).mean())) slot["sel"].append(selectivity_verdict(dmg))print(f"seed {seed} : bras causaux en {time.time() - t0:.0f}s cumules")# agregation par seed : moyennes sur fenetres pour les metriques numeriques ;# verdict de selectivite agrege par vote majoritaire (ce sont des strings)agg = {s: {k: {m: float(np.mean(v)) for m, v in slot.items() if m !="sel"}for k, slot in causal[s].items()} for s in SEEDS}for s in SEEDS:for k, slot in causal[s].items(): v = slot["sel"] agg[s][k]["sel"] =max(set(v), key=v.count)i_max =len(DOSES) -1print("\nManipulation (dz_f a dose max, cible vs sham) :")print(" cible :", [round(agg[s][("target", i_max)]["dz_f"], 3) for s in SEEDS])print(" sham :", [round(agg[s][("sham", 0)]["dz_f"], 3) for s in SEEDS])print(" verdict selectivite (cible, dose max) :", [agg[s][("target", i_max)]["sel"] for s in SEEDS])
seed 0 : controle aleatoire apparie (rel_tol=0.25)
seed 0 : bras causaux en 4s cumules
seed 1 : controle aleatoire apparie (rel_tol=0.25)
seed 1 : bras causaux en 8s cumules
seed 2 : controle aleatoire apparie (rel_tol=0.25)
seed 2 : bras causaux en 11s cumules
seed 3 : controle aleatoire apparie (rel_tol=0.25)
seed 3 : bras causaux en 15s cumules
seed 4 : controle aleatoire apparie (rel_tol=0.25)
seed 4 : bras causaux en 18s cumules
seed 5 : controle aleatoire apparie (rel_tol=0.25)
seed 5 : bras causaux en 22s cumules
Manipulation (dz_f a dose max, cible vs sham) :
cible : [0.507, 0.655, 0.534, 0.775, 0.605, 0.47]
sham : [0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
verdict selectivite (cible, dose max) : ['selective', 'selective', 'selective', 'selective', 'selective', 'selective']
# --- H1 : degradation du decodage (canal etat), cible vs B vs aleatoire ---h1_target = [agg[s][("target", i_max)]["dd_a"] for s in SEEDS]h1_vs_b = [agg[s][("target", i_max)]["dd_b"] for s in SEEDS]h1_vs_rand = [agg[s][("random", i_max)]["dd_a"] for s in SEEDS]h1_link_b = chain_verdict(h1_target, h1_vs_b, min_seeds=4)h1_link_r = chain_verdict(h1_target, h1_vs_rand, min_seeds=4)h1_holm = holm_adjust([h1_link_b["p_signflip"], h1_link_r["p_signflip"]])print("H1 cible-vs-B :", h1_link_b, "| p Holm", round(h1_holm[0], 4))print("H1 cible-vs-rand:", h1_link_r, "| p Holm", round(h1_holm[1], 4))# --- H3 : selectivite comportementale + preservation de la perplexite ---h3_selective = chain_verdict( [agg[s][("target", i_max)]["dab"] - agg[s][("target", i_max)]["daa"]for s in SEEDS], [0.0] *len(SEEDS), min_seeds=4)h3_ce = chain_verdict( [agg[s][("target", i_max)]["dce"] for s in SEEDS], [agg[s][("random", i_max)]["dce"] for s in SEEDS], min_seeds=4)print("\nH3 selectivite comportementale (dacc_B - dacc_A > 0) :", h3_selective)print("H3 inflation de CE cible vs random (doit rester moderee) :", h3_ce)# --- lien dose : la degradation de A croit avec lambda ---dose_curves = [[agg[s][("target", di)]["dd_a"] for di inrange(len(DOSES))]for s in SEEDS]h3_dose = dose_monotonicity(dose_curves)print("H3 lien dose (dd_a croissant le long des doses) :", h3_dose)
La meme batterie d’interventions (#15799) tourne sur le substrat belief exact (panneau = belief joint 12-dim, sans transformer) : c’est le contraste de substrat de la serie (ICT-31). Les verdicts des deux substrats se rapportent separement — une divergence transformer-vs-belief est un resultat, pas un artefact a resorber.
t0 = time.time()# n=2400 : garde de la batterie (>= 6 features elegibles z>=2, freq>=0.01)# refuse a n=1200 sur le seed 3 (5 eligibles, mesure prealable)battery = run_battery(bench, seeds=SEEDS, n=2400)print(f"batterie belief-panel en {time.time() - t0:.0f}s")for link, verdict in battery.verdicts.items():print(f" {link:<28}{verdict}")
Trois extensions borees, chacune falsifiable. Les stubs sont conformes C.1 : le notebook s’execute de bout en bout meme non complete.
# Exercice 1 : selection des features par ecart d'AUC.# Etape 1 : centrage des AUC autour de 0.5 (chance).# Etape 2 : tri par ecart decroissant A vs B.# Etape 3 : renvoyer les k indices et leurs ecarts.def top_gap_features(auc_a, auc_b, k):# TODO etudiantprint("Exercice a completer : top_gap_features")returnNoneresultat_ex1 = top_gap_features(sae_art[SEEDS[0]]["sel_a"]["auc"], sae_art[SEEDS[0]]["sel_b"]["auc"], 4)
Exercice a completer : top_gap_features
# Exercice 2 : overlap geometrique d'une paire de bases contre le null.# Etape 1 : orthonormaliser les colonnes donnees (np.linalg.qr).# Etape 2 : overlap maximal entre les deux bases.# Etape 3 : quantile 95 du null apparie et verdict de separation.def overlap_vs_null(w_dec, feats_x, feats_y, n_nulls, seed):# TODO etudiantprint("Exercice a completer : overlap_vs_null")returnNoneresultat_ex2 = overlap_vs_null(sae_art[SEEDS[0]]["sae"].W_dec, sae_art[SEEDS[0]]["feats_a"], sae_art[SEEDS[0]]["feats_b"],200, 0)
Exercice a completer : overlap_vs_null
# Exercice 3 : le versant positions de H2 — l'effet J-Lens par bande de# positions doit suivre la confiance F-Lens (entropie du belief) des# positions couvertes par la bande.# Etape 1 : pour chaque bande b, KL entre forward intact et forward# contre-factuel (les JLENS_K coordonnees gelees de la section 6,# couche porteuse, positions = la bande b seule).# Etape 2 : entropie moyenne du belief A sur les positions de chaque bande.# Etape 3 : correlation de Spearman entre les deux profils de bandes.def corr_position_effect(seed):# TODO etudiantprint("Exercice a completer : corr_position_effect")returnNoneresultat_ex3 = corr_position_effect(SEEDS[0])
Exercice a completer : corr_position_effect
Verdict global, accord et dissociation, limites
Aucun score omnibus : chaque hypothese est tranchee par sa famille de comparaisons pre-enregistrees, chaque instrument rapporte son effet dans son canal, et les dissociations restent visibles. Le bras belief-panel (section 8) est un substrat de contraste, pas une repetition.
table = {"H1": {"cible_vs_B": {**h1_link_b, "p_holm": h1_holm[0]},"cible_vs_random": {**h1_link_r, "p_holm": h1_holm[1]}, },"H2": {"couches_porteuse_vs_autres": {**h2_layer, "p_holm": h2_p[0]},"dissociation_A_vs_B": {**h2_dissoc, "p_holm": h2_p[1]}, },"H3": {"selectivite_comportementale": h3_selective,"preservation_perplexite": h3_ce,"lien_dose": h3_dose, },"H4": {"separation_geometrique": h4_verdict},}def fmt_link(v): ci = v.get("ci95") ci_s =f"[{ci[0]:+.4f}, {ci[1]:+.4f}]"if ci else""return (f"{v.get('verdict', '?'):<16}"f"med {v.get('median_diff', float('nan')):+.4f}{ci_s} "f"p {v.get('p_signflip', float('nan')):.4f}"+ (f" (Holm {v['p_holm']:.4f})"if"p_holm"in v else""))print("="*76)for hyp, fam in table.items():print(f"{hyp} :")for name, v in fam.items():if"p_signflip"in v:print(f" {name:<32}{fmt_link(v)}")elif"n_seeds_monotone"in v:print(f" {name:<32}{v['verdict']} "f"({v['n_seeds_monotone']}/{v['n_seeds']} seeds monotones)")else:print(f" {name:<32}{v}")print("="*76)# Accord / dissociation entre instruments, rapportes separesment (acceptance).print("\nAccord et dissociation par instrument :")print(" F-Lens couches porteuses A/B :", LAYER_A, "/", LAYER_B)print(" J-Lens effet A-cf par couche (median) :", {k: round(float(np.median([jlens[s][k][0] for s in SEEDS])), 4)for k in LAYER_KEYS})print(" SAE manipulation dz_f (cible, dose max) vs sham :", [round(agg[s][("target", i_max)]["dz_f"], 3) for s in SEEDS], "/", [round(agg[s][("sham", 0)]["dz_f"], 3) for s in SEEDS])# Artefact rejouable : config gelee + verdicts + empreinte d'un panneau.artifact = {"notebook": "ICT-40-TriangulationCausale","issue": 15480,"config": {"seeds": list(SEEDS), "n_bench": N_BENCH, "seq_len": SEQ_LEN,"hop": HOP, "steps": STEPS, "k_sae": K_SAE,"topk_sae": TOPK_SAE, "doses": list(DOSES), "jlens_k": JLENS_K,"layer_a": LAYER_A, "layer_b": LAYER_B},"vloss": {int(s): round(float(pilot[s]["vloss"]), 4) for s in SEEDS},"verdicts": {h: {n: (v["verdict"] if"verdict"in v else v)for n, v in fam.items()} for h, fam in table.items()},"panel_sha256_seed0": artifact_sha256(pilot[SEEDS[0]][LAYER_A][ pilot[SEEDS[0]]["ev"][0]]),}print("\nSortie de reference (extrait verbatim) :")print(json.dumps(artifact, ensure_ascii=True)[:1200])