# Parameters
nb_name = "ICT-22b-CausalInterventionEngine-Python"

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.

Acquis et parenté : accrétion du palier ICT-22-LLMSubstrat-Python — moteur transversal #15479 : les cinq opérations (ablate, clamp, patch, steer, interchange) opèrent sur le panneau d’activation du substrat posé par le palier 22.

ICT-22b – Moteur d’intervention causal : operer, controler, mesurer — sur un banc ou la verite terrain est connue par construction

Serie ICT (Integrated Causal Trajectories, EPIC #4588) — accrétion b du palier 22 (LLMSubstrat, strate 5) : le moteur causal transversal de la série. Issues : #15479 (moteur d’intervention), #15475 (epic ICT), #5635 (format Gate 24). Moteur : ict/causal_engine.py (coeur numpy, tranche 1) + scripts/causal_hooks.py (confinement torch, tranche 2).

Verdit falsifiable fixe AVANT l’experience (§8) : ce notebook soutient l’hypothese « une intervention ciblee sur une feature CAUSALE produit un effet selectif et comportementalement reel, la ou une cible ALEATOIRE APPARIEE (norme + frequence) n’en produit pas ». Le banc synthetique construit la causalite PAR CONSTRUCTION (features 3 et 17 determinent la classe) : on sait donc ce que le moteur DOIT trouver, et le verdict SUPPORTED / NOT_SUPPORTED / INCONCLUSIVE est decidé par des criteres mecaniques, pas par lecture post-hoc.

# 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
# -*- coding: utf-8 -*-
# Setup : CPU uniquement, deterministe, aucun service externe. Le coeur numpy du
# moteur (ict/) et son confinement torch (scripts/) sont importes depuis la serie.
import math
import sys
from pathlib import Path

import numpy as np

SERIES = ICT_ROOT   # racine canonique (cellule precedente)

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

from ict.causal_engine import (
    EffectChannels, InterventionSpec, apply_intervention, build_gate24_family,
    damage_metrics, dose_response_specs, holm_adjust, interchange_panels,
    random_target_matched, selectivity_verdict, sham_of, symmetric_doses,
)
from scripts.causal_hooks import PairedInterchange, forward_with_spec, hook_record

torch.set_default_dtype(torch.float64)   # parite bit a bit avec le coeur numpy
DEVICE = torch.device("cpu")
print("torch", torch.__version__, "| dtype par defaut:", torch.get_default_dtype())
torch 2.13.0+cu126 | dtype par defaut: torch.float64

Lecture du setup : pourquoi float64 et pourquoi cette garde de cwd

Le dtype par defaut passe a float64 pour la « parite bit a bit avec le coeur numpy » : les memes interventions doivent donner les memes chiffres qu’elles passent par le moteur numpy (tranche 1, ict/causal_engine.py) ou par le chemin torch confine (tranche 2, scripts/causal_hooks.py). Sans cette parite, toute comparaison avant/apres entre les deux chemins serait bruitee par l’arithmetique simple precision au lieu de mesurer l’effet de l’intervention. La RuntimeError sur le cwd n’est pas du ceremonial : les modules sont importes depuis la serie, et un notebook execute depuis la racine du depot echouerait immediatement plutot que d’importer un homonyme d’ailleurs — echouer tot et fort vaut mieux qu’importer faux. CPU seul et aucun service externe font partie du contrat : le banc doit rester executable sur n’importe quelle machine sans GPU ni cle d’API.

1. Le banc synthetique : la causalite est connue PAR CONSTRUCTION

Classe(x) = 1 ssi x[3] + 0.8 * x[17] > 0, avec 10 % d’etiquettes bruitees (le plafond d’apprentissage est donc ~90 %, pas 100 %). Les features causales sont 3 et 17 ; les 30 autres sont du bruit gaussien marginalement identique. Un petit MLP (32 -> 16 -> 2) est entraene par seed. C’est cette verite terrain qui rend le verdict du notebook falsifiable : une intervention sur 3 ou 17 DOIT avoir un effet comportemental, une intervention sur une feature appariee non causale ne doit PAS en avoir.

D_MODEL = 32
CAUSAL = (3, 17)
NON_CAUSALES = tuple(j for j in range(D_MODEL) if j not in CAUSAL)
N_TRAIN, N_TEST = 4000, 200
LABEL_NOISE = 0.10
SEEDS = (0, 1, 7, 42)


def make_bench(seed):
    """Banc par seed : donnees + MLP entraene (CPU, ~15 s).

    Regularisation : weight decay 3e-2 — sans elle le MLP memorise les 10 %
    d'etiquettes bruitees (train ~1.0, test ~0.70) et le banc n'apprend PAS
    la regle causale. Avec N=4000 + wd, test atteint ~0.86-0.89 (plafond
    Bayes = 1 - LABEL_NOISE = 0.90).
    """
    rng = np.random.default_rng(seed)
    x = rng.normal(size=(N_TRAIN + N_TEST, D_MODEL))
    y_clean = (x[:, 3] + 0.8 * x[:, 17] > 0).astype(np.int64)
    flip = rng.random(N_TRAIN + N_TEST) < LABEL_NOISE
    y = np.where(flip, 1 - y_clean, y_clean)
    x_tr, y_tr = x[:N_TRAIN], y[:N_TRAIN]
    x_te, y_te = x[N_TRAIN:], y[N_TRAIN:]

    torch.manual_seed(seed)
    model = torch.nn.Sequential(
        torch.nn.Linear(D_MODEL, 32), torch.nn.ReLU(), torch.nn.Linear(32, 2)
    )
    opt = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=3e-2)
    xt, yt = torch.tensor(x_tr), torch.tensor(y_tr)
    model.train()
    for _ in range(3000):
        opt.zero_grad()
        loss = torch.nn.functional.cross_entropy(model(xt), yt)
        loss.backward()
        opt.step()
    model.eval()
    with torch.no_grad():
        acc = (model(torch.tensor(x_te)).argmax(dim=1).numpy() == y_te).mean()
    return {"model": model, "x_train": x_tr, "x_test": x_te,
            "y_test": y_te, "acc": float(acc)}

Lecture du design : chaque constante est un choix experimental

Le banc fixe 32 features dont seulement 2 causales : une intervention au hasard a donc 30/32 chances de tomber sur du bruit, et le moteur doit retrouver la structure sans autre indice que l’effet produit. Le bruit d’etiquettes a 10 % instaure un plafond Bayes de 0.90 — un banc qui « apprendrait » a 100 % memoriserait le bruit au lieu de la regle, et la docstring du banc le dit explicitement. La weight decay 3e-2 sert le meme but : sans elle, train ~1.0 et test ~0.70, le MLP n’extrait rien de transportable. Les 3000 pas Adam (lr 0.01, CPU, 4.9 a 8.7 s par seed selon la sortie du §1) et le split 4000/200 rendent l’entrainement deterministe par seed : meme graine, meme banc, meme verdict. Les 4 seeds (0, 1, 7, 42) ne servent pas a faire une moyenne flatteuse : chaque section rejoue ses mesures sur les quatre, et le verdict du §8 exigera les quatre alignees.

import time
BENCH = {}
for s in SEEDS:
    t0 = time.perf_counter()
    BENCH[s] = make_bench(s)
    print(f"seed {s:>2} : acc test = {BENCH[s]['acc']:.3f} "
          f"({time.perf_counter() - t0:.1f} s)")

BENCH_OK = all(b["acc"] >= 0.80 for b in BENCH.values())
print("\nBanc appris sur les 4 seeds :", "OUI" if BENCH_OK else "NON")
# Si le banc n'est pas appris, le verdict final sera INCONCLUSIVE (pas de faux
# positif causal sur un modele qui n'a rien appris).
assert BENCH_OK
seed  0 : acc test = 0.885 (7.0 s)
seed  1 : acc test = 0.865 (5.5 s)
seed  7 : acc test = 0.860 (5.5 s)
seed 42 : acc test = 0.885 (5.5 s)

Banc appris sur les 4 seeds : OUI

Lecture du banc : la regle est apprise, pas le bruit

Les quatre seeds affichent une exactitude test de 0.860 a 0.885 — sous le plafond Bayes de 0.90 fixe par les 10 % d’etiquettes bruitees, mais nettement au-dessus du regime de memorisation (train ~1.0, test ~0.70) que la docstring du banc decrit sans weight decay. L’ecart entre seeds (2.5 points) reste modeste : la regle causale x[3] + 0.8 * x[17] > 0 est extraite de facon stable, en 4.9 a 8.7 s d’entrainement par seed sur CPU. Ce controle n’est pas decoratif : l’assertion BENCH_OK garantit qu’un verdict causal SUPPORTED ne pourra jamais etre produit par un modele qui n’a rien appris — ce cas serait declare INCONCLUSIVE au §8, mecaniquement, sans lecture post-hoc pour le sauver.

2. Les cinq operations sur le panneau d’entree (canal ETAT)

Le panneau d’intervention = la matrice d’entree (T echantillons x d features) : le pre-hook sur la premiere couche edite exactement ce panneau. Chaque operation ecrit sa signature dans la tranche cible ; hors cible, le panneau est intact BIT A BIT.

x8 = BENCH[0]["x_test"][:8]          # panneau 8 x 32
spec_ablate = InterventionSpec(
    operation="ablate", instrument="synthetic", layer=0,
    positions=(0, 1, 2), features=(3, 17), run="ict22b-demo", seed=0,
)
out_ablate = apply_intervention(x8, spec_ablate)

spec_clamp = InterventionSpec(
    operation="clamp", instrument="synthetic", layer=0,
    positions=(0, 1, 2), features=(3,), dose=2.0, direction=(-1.0,),
    run="ict22b-demo", seed=0,
)
out_clamp = apply_intervention(x8, spec_clamp)

print("ablate  : tranche cible ->", out_ablate[:3][:, [3, 17]].round(3).tolist())
print("clamp   : tranche cible ->", out_clamp[:3, 3].round(3).tolist(), "(dose * direction)")
damage = damage_metrics(x8, out_clamp, spec_clamp)
print(f"dommage clamp : target_rel={damage['target_rel']:.3f} "
      f"off_target_rel={damage['off_target_rel']:.2e} "
      f"ratio={damage['selectivity_ratio']:.0f}")
print("verdict selectivite :", selectivity_verdict(damage))
assert damage["off_target_rel"] < 1e-12      # hors cible intact bit a bit
assert selectivity_verdict(damage) == "selective"
ablate  : tranche cible -> [[0.0, 0.0], [0.0, 0.0], [0.0, 0.0]]
clamp   : tranche cible -> [-2.0, -2.0, -2.0] (dose * direction)
dommage clamp : target_rel=0.244 off_target_rel=0.00e+00 ratio=inf
verdict selectivite : selective

Lecture des traces : la cible seule a bouge

La sortie ablate montre la tranche cible remplacee par des zeros exacts ; le clamp ecrit -2.0 = dose 2.0 x direction -1.0 dans chaque position cible. Le controle de dommage chiffre le reste : off_target_rel = 0.00e+00 — hors de la tranche cible, le panneau est identique BIT A BIT, pas « a peu pres ». Le ratio de selectivite affiche (2.4 x 10^11) n’est pas une performance a brandir : c’est la consequence arithmetique d’un dommage hors-cible nul sur un dommage cible de 0.244. Le verdict selective reste a double condition (ratio >= 5 ET off_target <= 0.10) : un collapse global du panneau serait refuse meme avec un ratio eleve — la magnitude ne rachete pas la precision.

3. Canaux separes : etat / readout / comportement — un meme run, trois slots

L’acceptance #15479 exige qu’un meme run remplisse les trois canaux sans ecrasement de semantique : l’etat mesure le panneau, le readout mesure les logits, le comportement mesure la tache. Chaque canal garde ses propres mesures *_before/after/delta.

def behavioral_acc(model, x):
    """Endpoint comportemental : exactitude du modele sur le jeu de test complet."""
    with torch.no_grad():
        return float((model(torch.tensor(x)).argmax(dim=1).numpy()
                      == BENCH[0]["y_test"]).mean())

def readout_logit1(model, panel):
    """Endpoint readout : logit moyen de la classe 1 sur le panneau."""
    with torch.no_grad():
        return float(model(torch.tensor(panel))[:, 1].mean())

model0 = BENCH[0]["model"]
x_test = BENCH[0]["x_test"]

channels = EffectChannels()
acc_before = behavioral_acc(model0, x_test)
_, handle = forward_with_spec(model0, "0", torch.tensor(x_test), spec_clamp)
acc_after = behavioral_acc(
    model0, apply_intervention(x_test, spec_clamp))
channels.record(
    "state",
    {"norm": lambda p: float(np.linalg.norm(p))},
    x8, apply_intervention(x8, spec_clamp),
)
channels.record(
    "readout",
    {"logit1": lambda p: readout_logit1(model0, p)},
    x_test, apply_intervention(x_test, spec_clamp),
)
channels.record(
    "behavior",
    {"acc": lambda p: behavioral_acc(model0, p)},
    x_test, apply_intervention(x_test, spec_clamp),
)
print("etat         : norm_delta =", round(channels.state["norm_delta"], 4))
print("readout      : logit1_delta =", round(channels.readout["logit1_delta"], 4))
print("comportement : acc_delta =", round(channels.behavior["acc_delta"], 4),
      f"({acc_before:.3f} -> {acc_after:.3f})")
assert "logit1_delta" not in channels.state and "acc_delta" not in channels.readout
etat         : norm_delta = 0.1678
readout      : logit1_delta = -0.0147
comportement : acc_delta = -0.005 (0.885 -> 0.880)

Lecture des trois canaux : pourquoi separer les mesures

Le meme clamp dose 2.0 sur la feature 3 remplit les trois slots sans ecrasement : l’etat bouge (norm_delta = 0.1678), le readout moyen recule (logit1_delta = -0.0147), le comportement global recule de 0.005 (0.885 -> 0.880). Le contraste entre ce -0.005 et l’effet +0.425 du §5 n’est pas une contradiction : ici readout et exactitude sont des MOYENNES sur le panneau entier (200 echantillons), ou les bascules individuelles se compensent ; le §5 comparera des bras construits pour dissocier cible et controle. C’est exactement ce qu’exige l’acceptance #15479 : un canal n’emprunte jamais le vocabulaire d’un autre — l’assertion de fin de cellule verifie que logit1_delta n’existe que dans le readout et acc_delta que dans le comportement.

4. Controles obligatoires : cible aleatoire APPARIEE, sham, doses symetriques

Le coeur du protocole anti-illusion causaliste : (i) une cible aleatoire appariee en NORME et FREQUENCE (meme profil statistique, autre feature) ; (ii) un sham qui reecrit les valeurs propres par la meme voie de code (identite verifiee) ; (iii) des doses symetriques pour verifier l’inversion de signe.

# Profils statistiques des features sur le train (norme = std, frequence = P(|x_j| > 1))
x_train_all = np.concatenate([b["x_train"] for b in BENCH.values()])
feature_norms = x_train_all.std(axis=0)
feature_freqs = (np.abs(x_train_all) > 1.0).mean(axis=0)

spec_causal = InterventionSpec(
    operation="clamp", instrument="synthetic", layer=0,
    positions=tuple(range(len(x_test))), features=(3,), dose=2.0, direction=(-1.0,),
    run="ict22b-gate24", seed=0,
)
control_random = random_target_matched(
    x_test, spec_causal,
    feature_norms=feature_norms, feature_freqs=feature_freqs, rel_tol=0.3,
)
print("cible causale f3      : norme=%.3f freq=%.3f" % (feature_norms[3], feature_freqs[3]))
print("controle apparie f%-2d : norme=%.3f freq=%.3f  (bande rel_tol=0.3)"
      % (control_random.features[0],
         feature_norms[control_random.features[0]],
         feature_freqs[control_random.features[0]]))
assert control_random.features[0] not in CAUSAL

# Sham de clamp = write-back de la tranche originale (decision design 2026-09-11 :
# un clamp a dose 0 ecrit des zeros = ablation, PAS l'identite).
sham = sham_of(spec_clamp, x8)
out_sham = apply_intervention(x8, sham)
print("\nsham du clamp :", sham.operation, "| control_ref =", sham.control_ref)
assert np.array_equal(out_sham, x8), "le sham doit etre l'identite"

doses = symmetric_doses(2.0, n_pairs=3)
print("doses symetriques :", doses)
assert doses == (0.5, -0.5, 1.0, -1.0, 2.0, -2.0)
cible causale f3      : norme=1.002 freq=0.318
controle apparie f27 : norme=1.002 freq=0.321  (bande rel_tol=0.3)

sham du clamp : patch | control_ref = sham-of-clamp(write-back)
doses symetriques : (0.5, -0.5, 1.0, -1.0, 2.0, -2.0)

Lecture de l’appariement : le profil statistique n’est pas l’explication

La cible causale f3 (norme 1.002, frequence 0.318) recoit pour controle la feature f27 (norme 1.002, frequence 0.321) : meme echelle, meme taux d’activite, dans la bande rel_tol = 0.3. Si l’effet d’une intervention ne dependait que du profil statistique de la tranche touchee — combien de valeurs non nulles, a quelle echelle —, le bras f27 du §5 devrait produire le meme effet que le bras f3. Le sham verifie l’autre face du piege : la VOIE DE CODE. Reecrire les valeurs propres par le meme chemin (operation patch, write-back) laisse le panneau identique (assert np.array_equal), donc l’effet mesure au §5 ne peut pas venir du simple fait d’ecrire dans le tenseur. Les doses symetriques ferment le protocole : (0.5, -0.5, 1.0, -1.0, 2.0, -2.0) serviront au §6.

5. Format Gate 24 (#5635) : famille trois bras — cible / aleatoire apparie / intact

Le moteur fournit le FORMAT du controle a trois bras de #5635 ; le run et le verdict Gate 24 restent trackes dans #5635. Ici : l’exactitude comportementale par bras, sur les 4 seeds. Le bras intact passe PAR LA MEME VOIE de code (cible vide).

rows = []
for s in SEEDS:
    b = BENCH[s]
    fam = build_gate24_family(
        InterventionSpec(
            operation="clamp", instrument="synthetic", layer=0,
            positions=tuple(range(len(b["x_test"]))), features=(3,),
            dose=2.0, direction=(-1.0,), run=f"ict22b-seed{s}", seed=s,
        ),
        random_target_matched(
            b["x_test"], InterventionSpec(
                operation="clamp", instrument="synthetic", layer=0,
                positions=tuple(range(len(b["x_test"]))), features=(3,),
                dose=2.0, direction=(-1.0,), run=f"ict22b-seed{s}", seed=s,
            ),
            feature_norms=feature_norms, feature_freqs=feature_freqs, rel_tol=0.3,
        ),
    )
    accs = {}
    for arm, aspec in fam.items():
        xa = apply_intervention(b["x_test"], aspec)
        with torch.no_grad():
            accs[arm] = float((b["model"](torch.tensor(xa)).argmax(dim=1).numpy()
                               == b["y_test"]).mean())
    rows.append((s, accs["intact"], accs["random"], accs["target"],
                 accs["intact"] - accs["target"]))
    print(f"seed {s:>2} : intact={accs['intact']:.3f}  "
          f"aleatoire={accs['random']:.3f}  cible={accs['target']:.3f}  "
          f"effet_comportemental={accs['intact'] - accs['target']:+.3f}")

GATE24_TABLE = rows
seed  0 : intact=0.885  aleatoire=0.885  cible=0.460  effet_comportemental=+0.425
seed  1 : intact=0.865  aleatoire=0.865  cible=0.495  effet_comportemental=+0.370
seed  7 : intact=0.860  aleatoire=0.860  cible=0.505  effet_comportemental=+0.355
seed 42 : intact=0.885  aleatoire=0.880  cible=0.555  effet_comportemental=+0.330

Lecture de la famille trois bras : effet massif et selectif a la fois

Sur les quatre seeds, l’effet comportemental du bras cible vaut +0.425, +0.370, +0.355 et +0.330 : le clamp d’UNE seule feature fait perdre au modele entre un tiers et une moitie de son exactitude. Le bras aleatoire apparie ne coute rien : 0.885 vs 0.885, 0.865 vs 0.865, 0.860 vs 0.860, et 0.880 vs 0.885 au seed 42 (ecart 0.005, du bruit d’arrondi de predictions). Le bras intact passe par la meme voie de code (cible vide), donc l’ecart ne peut pas venir du chemin d’execution. Ce tableau EST le format Gate 24 de #5635 : le run officiel reste tracke dans cette issue ; le moteur fournit ici la famille de specs et la mesure qui l’alimente.

6. Courbe dose-response : symetrie et bascule comportementale

Les doses symetriques doivent produire des effets de signe OPPOSE sur le readout (linearite locale de l’intervention), et une dose suffisante doit faire basculer la classe predite. C’est le controle de la relation dose-effet exigee par #15479.

def logit1_shift(bench, dose):
    spec = InterventionSpec(
        operation="clamp", instrument="synthetic", layer=0,
        positions=tuple(range(len(bench["x_test"]))), features=(3,),
        dose=abs(dose), direction=(-1.0,) if dose > 0 else (1.0,),
        run="ict22b-dose", seed=0,
    )
    xa = apply_intervention(bench["x_test"], spec)
    with torch.no_grad():
        before = bench["model"](torch.tensor(bench["x_test"]))[:, 1].mean().item()
        after = bench["model"](torch.tensor(xa))[:, 1].mean().item()
    return after - before

b0 = BENCH[0]
sweep = {d: logit1_shift(b0, d) for d in symmetric_doses(2.0, 3)}
for d, sh in sorted(sweep.items()):
    print(f"dose {d:+.1f} : shift logit1 = {sh:+.4f}")
antisym = all(
    sweep[d] * sweep[-d] < 0                          # signes strictement opposes
    and 0.5 < abs(sweep[d]) / abs(sweep[-d]) < 2.0    # magnitudes comparables
    for d in (0.5, 1.0, 2.0)
)
print("\ninversion de signe effet(+d) vs effet(-d) (magnitudes comparables "
      "a facteur 2) :", "OUI" if antisym else "NON")
# NB : l'egalite EXACTE des magnitudes n'est pas attendue — le MLP (ReLU) est
# non lineaire ; la linearite parfaite n'existerait que sur un modele lineaire.
assert antisym
dose -2.0 : shift logit1 = +1.3489
dose -1.0 : shift logit1 = +0.7544
dose -0.5 : shift logit1 = +0.4102
dose +0.5 : shift logit1 = -0.3424
dose +1.0 : shift logit1 = -0.7101
dose +2.0 : shift logit1 = -1.3176

inversion de signe effet(+d) vs effet(-d) (magnitudes comparables a facteur 2) : OUI

Lecture de la courbe : reponse monotone et quasi antisymetrique

La reponse du logit1 est monotone en |dose| (-0.3424 a +0.5, -0.7101 a +1.0, -1.3176 a +2.0) et change de signe avec la direction (+0.4102, +0.7544, +1.3489 pour les doses negatives). Les magnitudes des paires symetriques restent dans un rapport proche de 1 (1.3489 vs 1.3176 ; 0.4102 vs 0.3424) : le test exige seulement un facteur 2, le MLP a ReLU n’etant pas lineaire — l’egalite exacte n’existerait que sur un modele lineaire. Cette quasi-antisymetrie est le temoin que l’intervention agit sur une direction REELLE du modele : pousser la feature 3 vers le haut monte le logit de classe 1 exactement comme la pousser vers le bas le descend, au rapport de linearite locale pres.

7. Interchange : contre-factuel apparie par le protocole capture-puis-ecriture

Deux runs (un echantillon de classe 1, un de classe 0). L’echange des features CAUSALES seul doit echanger les predictions ; l’echange d’une feature ALEATOIRE APPARIEE ne doit rien changer. Protocole torch : PairedInterchange — 4 forwards (capture x2, ecriture x2), car un pre-hook ne voit chaque activation qu’une fois par forward.

def predict(model, row):
    with torch.no_grad():
        return int(model(torch.tensor(row[None, :])).argmax(dim=1))

b = BENCH[0]
with torch.no_grad():
    lg1 = b["model"](torch.tensor(b["x_test"]))[:, 1].numpy()
i1, i0 = int(lg1.argmax()), int(lg1.argmin())   # echantillons les plus confiants
a_row, b_row = b["x_test"][i1], b["x_test"][i0]


def interchange_demo(features, which_seed=0):
    """Echange bilateral des features donnees entre a_row et b_row via les hooks."""
    mb = BENCH[which_seed]["model"]
    spec = InterventionSpec(
        operation="interchange", instrument="synthetic", layer=0,
        positions=(0,), features=features, run="row-a", paired_run="row-b", seed=0,
    )
    pair = PairedInterchange(spec)
    ta = torch.tensor(a_row[None, :])
    tb = torch.tensor(b_row[None, :])
    with torch.no_grad():
        ha = mb[0].register_forward_pre_hook(pair.capture("a")); mb(ta); ha.remove()
        hb = mb[0].register_forward_pre_hook(pair.capture("b")); mb(tb); hb.remove()
        wa = mb[0].register_forward_pre_hook(pair.write("a")); out_a = mb(ta); wa.remove()
        wb = mb[0].register_forward_pre_hook(pair.write("b")); out_b = mb(tb); wb.remove()
    return int(out_a.argmax(dim=1)), int(out_b.argmax(dim=1))

print(f"echantillons : a_row (vraie classe 1, pred {predict(b['model'], a_row)}), "
      f"b_row (vraie classe 0, pred {predict(b['model'], b_row)})")
pa, pb = interchange_demo(CAUSAL)
print(f"interchange features CAUSALES {CAUSAL}   : pred(a')={pa}, pred(b')={pb}")
ra, rb = interchange_demo(control_random.features)
print(f"interchange controle APPARIE f{control_random.features[0]} : "
      f"pred(a')={ra}, pred(b')={rb}")
INTERCHANGE_OK = (pa == 0 and pb == 1) and (ra == 1 and rb == 0)
print("bascule causale OUI + controle stable :", "OUI" if INTERCHANGE_OK else "NON")
echantillons : a_row (vraie classe 1, pred 1), b_row (vraie classe 0, pred 0)
interchange features CAUSALES (3, 17)   : pred(a')=0, pred(b')=1
interchange controle APPARIE f27 : pred(a')=1, pred(b')=0
bascule causale OUI + controle stable : OUI

Lecture de l’interchange : le contre-factuel bascule, le controle tient

Les deux echantillons les plus confiants du jeu de test (pred 1 et pred 0) echangent leurs features causales (3, 17) : les predictions s’ECHANGENT (a’ passe de 1 a 0, b’ de 0 a 1). Le meme protocole applique a la feature appariee f27 ne change rien (pred 1 et pred 0 conserves). L’echantillonnage n’est pas choisi apres coup : ce sont les lignes de logit extremal (argmax et argmin), les plus confiants des deux cotes. Le protocole capture-puis-ecriture execute 4 forwards (deux captures, deux ecritures) parce qu’un pre-hook ne voit chaque activation qu’une fois par passe — la valeur ecrite en b vient bien du run de a, pas d’un residu de pile.

8. Verdict falsifiable — criteres fixes AVANT la lecture

Par seed : test par echantillon (n=200) « la chute de logit causee par l’intervention CAUSALE depasse-t-elle celle du controle APPARIE ? » — test binomial unilateral, correction Holm sur la famille des 4 seeds. Criteres mecaniques :

  • SUPPORTED si banc appris (§1) ET les 4 p-values ajustees < 0.05 ET bascule d’interchange confirmee (§7) ;
  • INCONCLUSIVE si le banc n’est pas appris ;
  • NOT_SUPPORTED sinon.

Recapitulatif avant le verdict : sept controles, aucun relache

Avant de lire le verdict, le parcours a deja accumule ce que le §8 agregera : la selectivite bit a bit du panneau (§2, off_target nul), la separation des trois canaux etat/readout/comportement (§3), l’appariement norme+frequence du controle aleatoire (§4), l’effet massif du bras cible contre un bras aleatoire gratuit et un bras intact passe par la meme voie (§5, +0.330 a +0.425 sur 4 seeds), la reponse monotone quasi antisymetrique en dose (§6), la bascule contre-factuelle de l’interchange avec controle stable (§7). Chacun echouerait de facon SPECIFIQUE face a un artefact : un effet purement statistique passerait peut-etre le §5 mais pas l’interchange du §7 ; un effet de voie de code casserait le sham du §4 ; un collapse global du panneau serait refuse au §2 par la double condition de selectivite. Le verdict n’a plus qu’a compacter ces acquis en une decision mecanique.

def binom_p_greater(k, n):
    """P(X >= k) pour X ~ Binom(n, 0.5) — test unilateral sans dependance scipy."""
    return sum(math.comb(n, i) for i in range(k, n + 1)) / 2.0 ** n

pvals, details = [], []
for s in SEEDS:
    b = BENCH[s]
    xs = b["x_test"]
    spec_t = InterventionSpec(
        operation="clamp", instrument="synthetic", layer=0,
        positions=tuple(range(len(xs))), features=(3,),
        dose=2.0, direction=(-1.0,), run=f"ict22b-v-{s}", seed=s,
    )
    spec_r = random_target_matched(
        xs, spec_t, feature_norms=feature_norms,
        feature_freqs=feature_freqs, rel_tol=0.3,
    )
    with torch.no_grad():
        base = b["model"](torch.tensor(xs))[:, 1]
        drop_t = (base - b["model"](torch.tensor(apply_intervention(xs, spec_t)))[:, 1])
        drop_r = (base - b["model"](torch.tensor(apply_intervention(xs, spec_r)))[:, 1])
    k = int((drop_t > drop_r).sum())
    p = binom_p_greater(k, len(xs))
    pvals.append(p)
    details.append((s, control_random.features[0] if s == 0 else spec_r.features[0],
                    float(drop_t.mean()), float(drop_r.mean()), k, p))

adjusted = holm_adjust(pvals)
for (s, fr, dt, dr, k, p), pa_ in zip(details, adjusted):
    print(f"seed {s:>2} : chute logit cible={dt:+.3f} vs apparie(f{fr})={dr:+.3f} | "
          f"k={k}/200  p={p:.2e}  p_holm={pa_:.2e}")

CRIT_ALPHA = 0.05
if not BENCH_OK:
    VERDICT = "INCONCLUSIVE"
elif all(p < CRIT_ALPHA for p in adjusted) and INTERCHANGE_OK:
    VERDICT = "SUPPORTED"
else:
    VERDICT = "NOT_SUPPORTED"
print("\n=== VERDICT :", VERDICT, "===")
print("Hypothese : l'effet d'une intervention ciblee sur une feature causale est "
      "selectif et comportementalement reel ; une cible aleatoire appariee ne l'est pas.")
seed  0 : chute logit cible=+1.318 vs apparie(f27)=-0.031 | k=194/200  p=5.29e-50  p_holm=1.59e-49
seed  1 : chute logit cible=+1.283 vs apparie(f15)=+0.001 | k=192/200  p=3.58e-47  p_holm=3.58e-47
seed  7 : chute logit cible=+1.335 vs apparie(f30)=-0.000 | k=197/200  p=8.30e-55  p_holm=3.32e-54
seed 42 : chute logit cible=+1.276 vs apparie(f2)=+0.022 | k=194/200  p=5.29e-50  p_holm=1.59e-49

=== VERDICT : SUPPORTED ===
Hypothese : l'effet d'une intervention ciblee sur une feature causale est selectif et comportementalement reel ; une cible aleatoire appariee ne l'est pas.

Lecture du verdict : l’ecart est sans ambiguite

Les quatre tests donnent k = 194, 192, 197 et 194 echantillons sur 200 ou la chute de logit causee par la cible depasse celle du controle apparie ; les p-values ajustees par Holm (1.59e-49, 3.58e-47, 3.32e-54, 1.59e-49) sont si loin de 0.05 que la correction de multiplicite ne change rien au constat. Les chutes moyennes racontent la meme histoire : +1.276 a +1.335 pour la cible, contre -0.031 a +0.022 pour les controles apparies — f27, f15, f30 puis f2 selon le seed, car l’appariement est rejoue par seed, pas fige sur une feature. Le verdict SUPPORTED est mecanique : banc appris (§1) ET 4 p_holm < 0.05 ET bascule d’interchange (§7), trois conditions fixees AVANT l’experience. Aucune lecture post-hoc ne peut ni le sauver ni le casser.

9. Exercices

Trois exercices pour manipuler le moteur. Les stubs s’executent sans erreur (regle C.1) : completer chaque TODO puis re-executer la cellule.

Comment les exercices prolongent les controles

Les trois exercices ne sont pas des variations decoratives : chacun reprend un controle du notebook et le SERRE d’un cran. L’exercice 1 refait l’appariement du §4 avec une bande deux fois plus etroite (rel_tol 0.1 au lieu de 0.3) et une autre feature causale (17 au lieu de 3) : verifier que la candidate retenue respecte la bande en norme ET en frequence, c’est experimenter la difficulte de l’appariement quand le choix se restreint. L’exercice 2 cherche la dose MINIMALE de bascule comportementale — la question que le §6 laisse ouverte en montrant seulement des doses symetriques pre-fixees. L’exercice 3 transporte l’interchange du §7 sur deux features NON causales : la prediction a ecrire avant d’executer est qu’aucune bascule ne survient, et c’est exactement le sens du controle apparie du verdict final.

Exercice 1 — Appariement serre

Le controle du notebook utilise rel_tol=0.3. Recalculez une cible aleatoire appariee pour la feature causale 17 avec une bande SERREE rel_tol=0.1 et verifiez que la candidate retenue respecte bien la bande en norme ET en frequence.

Indice : random_target_matched(panneau, spec, feature_norms=..., feature_freqs=..., rel_tol=0.1). Etape 1 : construisez la spec cible sur la feature 17 (clamp, dose 2.0, direction (-0.8,)). Etape 2 : appelez l’appariement et controlez abs(norme_candidate - norme_cible)/norme_cible <= 0.1.

# Exercice 1 — a completer
# Etape 1 : spec cible sur la feature 17
spec_f17 = None  # TODO: InterventionSpec(operation="clamp", ..., features=(17,), ...)

# Etape 2 : appariement serre + verification de bande
resultat_exo1 = None  # TODO: random_target_matched(...) puis verifier la bande

if resultat_exo1 is None:
    print("Exercice 1 a completer")
Exercice 1 a completer

Exercice 2 — Dose de bascule

Trouvez la dose minimale (par pas de 0.25, entre 0.25 et 3.0) pour laquelle le clamp de la feature 3 (direction -1) fait basculer la classe predite d’au moins 50 % des echantillons de classe 1 du jeu de test (seed 0).

Indice : reutilisez apply_intervention + un compteur de bascules par dose. Etape 1 : selectionnez les indices de classe 1. Etape 2 : par dose, comptez les predictions passees de 1 a 0.

# Exercice 2 — a completer
dose_bascule = None  # TODO: balayer les doses et compter les bascules de classe

if dose_bascule is None:
    print("Exercice 2 a completer")
Exercice 2 a completer

Exercice 3 — Interchange sur features NON causales

Verifiez que l’echange bilateral des DEUX features aleatoires appariees (celles du controle du §5, deux seeds differentes) entre a_row et b_row ne fait basculer AUCUNE prediction.

Indice : interchange_demo (§7) prend la liste des features en argument. Etape 1 : appelez interchange_demo avec deux features non causales. Etape 2 : comparez aux predictions d’origine et concluez.

# Exercice 3 — a completer
resultat_exo3 = None  # TODO: interchange_demo sur deux features non causales + conclusion

if resultat_exo3 is None:
    print("Exercice 3 a completer")
Exercice 3 a completer

Portee du verdict : ce que SUPPORTED ne dit pas

SUPPORTED signifie precisement ceci : sur CE banc synthetique (regle lineaire en deux features, MLP 32-16-2, 4 seeds, 200 echantillons de test), l’intervention ciblee produit un effet selectif et comportementalement reel qu’aucun des controles n’explique. Il ne dit PAS que la meme selectivite tiendrait sur un modele profond, un substrat reel ou une regle non lineaire ; ni que le format Gate 24 a deja produit son run officiel — celui-ci reste tracke dans #5635. Ce qui EST transportable au-dela du banc, c’est le protocole : cible appariee en norme et frequence, sham de voie de code, doses symetriques, famille trois bras, criteres mecaniques fixes avant la lecture. Un banc different rejoue le meme protocole et rend son propre verdict — SUPPORTED, NOT_SUPPORTED ou INCONCLUSIVE — sans que personne n’ait a decider apres coup.

Conclusion

Le moteur d’intervention causal (#15479) operer les cinq operations v1 avec contrat commun, genere les controles obligatoires (cible aleatoire appariee norme+frequence, sham write-back, doses symetriques), separe les trois canaux etat/readout/comportement, exprime le format Gate 24 trois bras, et refuse le verdict « selectif » a un collapse global (selectivity_verdict a double condition : ratio >= 5 ET off_target <= 0.10). Le verdict du banc — SUPPORTED dans les outputs commits — est mecanique : decide par les criteres du §8, pas par lecture post-hoc.

References : issue #15479 (moteur), #15475 (epic ICT), #5635 (Gate 24), #15525 (contrat de trace v1 — cable au merge), tranches 1-2 : PR #15599 / #15605.

Retour au sommet