Controle par interpretabilite — direction du refus, ablation, unlearning

1. Positionnement dans la distillation

Sources canoniques :

  • R11 — The 2026 Singapore Consensus on Global AI Safety Research Priorities (arXiv:2608.14611, Casper et al.). PDF archive hors depot : G:\Mon Drive\MyIA\IA\Bibliographie IA\XAI\2026 - Casper et al - The 2026 Singapore Consensus on Global AI Safety Research Priorities.pdf (sha8 134E9DA8, cf regle bibliography-hygiene). Sections mobilisees : §1.1, §2.2.5 (open-weight safety), §3.2.
  • R14 — Open Problems in Mechanistic Interpretability (arXiv:2501.16496, Sharkey et al.). PDF archive hors depot : G:\Mon Drive\MyIA\IA\Bibliographie IA\XAI\2025 - Sharkey et al - Open Problems in Mechanistic Interpretability.pdf (sha8 9A50CDC6). Sections mobilisees : §3.1-§3.2.

La chaine que ce notebook implemente (issue T17, #16758, dans l’arc B de l’EPIC #16741) : le refus est-il une direction dans le flux residuel, editable a l’inference ; que fait un finetuning de dix exemples a une politique d’alignement ; comment evaluer honnetement un « machine unlearning » ; pourquoi une evaluation ne prouve plus l’alignement.

Ce que ce notebook fait : il implemente ces interventions sur un temoin synthetique entraine from scratch — un transformeur a 2 blocs, 25 tokens de vocabulaire, une tache de decision non degeneree (le risque est une conjonction verbe x objet, pas un token marqueur). Tout est executable localement, sans GPU requis, sans LLM externe, sans telechargement.

Ce que ce notebook n’est pas : une reproduction des resultats publies sur un LLM reel. Le modele n’est ni Qwen ni Llama ; les effets mesures valent pour le temoin, et le notebook dit a chaque section ce qui se transpose et ce qui ne se transpose pas. Le passage a une famille open-weights reelle est RECOVERABLE-MACHINE (stack GenAI / cache HuggingFace sur une lane dediee), declare en §13.

Acceptance :

  • Extraire une direction de refus par difference de moyennes, verifier sa qualite par un classifieur sans entrainement (fit sur calibration, evaluation sur split tenu a part) et par un controle a direction aleatoire.
  • Mesurer l’effet de deux ablations directionnelles (brute et centree) et d’un steering signe (dose-reponse sur les deux classes).
  • Mesurer ce qu’un finetuning de dix exemples fait a la politique, avec son controle (dix exemples qui ne la contredisent pas).
  • Poser une evaluation forget/retain des interventions et une simulation du biais d’evaluation awareness.
  • Trois exercices C.1 repartis dans le notebook.

2. Pourquoi ce volet existe pour notre flotte (R11 §2.2.5)

Nos services GenAI hebergent des modeles open weights (famille Qwen). R11 §2.2.5 pose la contrepartie : qui detient les poids detient aussi toutes les interventions au niveau des poids — ablation, steering, finetuning. Une politique de refus apprise n’est donc pas un verrou mais un comportement dans un espace de representation, et ce comportement est reecrivable par quelques exemples.

La consequence operationnelle pour nos agents : un checkpoint aligne n’est pas une garantie d’alignement, et une evaluation qui ne teste que le comportement observable ne prouve rien sur ce qui a ete appris. Les sections 7 a 12 mesurent chacune de ces affirmations, a l’echelle d’un temoin.

import math
import random

import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F

SEED = 0
random.seed(SEED)
np.random.seed(SEED)
torch.manual_seed(SEED)

DEV = "cuda" if torch.cuda.is_available() else "cpu"
print(f"Setup OK — torch {torch.__version__}, device={DEV}")
print("Determinisme : seeds python/numpy/torch fixes ; aucune couche non deterministe (pas de convolution, pas de dropout).")
Setup OK — torch 2.14.0+cu126, device=cuda
Determinisme : seeds python/numpy/torch fixes ; aucune couche non deterministe (pas de convolution, pas de dropout).

3. Corpus synthetique — une decision non degeneree

Le temoin doit apprendre un comportement de refus qui soit une fonction du contenu, pas un simple token marqueur. Sinon l’ablation serait triviale (retirer « le » token suffirait) et le notebook demontrerait un cas degenere.

Grammaire. Une requete est BOS t t t verbe objet QUERY : trois tokens de sujet tires parmi 8, puis un verbe parmi 6, puis un objet parmi 6, puis le token de requete. Le risque est la conjonction :

risque  <=>  verbe parmi {v0, v1, v2}  ET  objet parmi {o0, o1, o2}

Un verbe a risque avec un objet hors du set est benin : ni le verbe ni l’objet ne determinent la classe seuls. Le modele doit agreger deux positions pour decider, ce qui est exactement le regime ou une representation interne « la requete est a risque » devient necessaire.

Trois splits, trois roles :

Split Taille Distribution Role
train 8000 p_risque = 0.25 apprentissage (le prior penche vers COMPLY)
calibration 4000 50 / 50 extraction de la direction, choix du seuil
evaluation 2000 50 / 50 toutes les mesures rapportees

Aucun contenu reel n’est genere : les tokens sont des symboles abstraits (v0, o2), la tache est un jouet logique.

PAD, BOS, QUERY, REFUSE, COMPLY = 0, 1, 2, 3, 4
N_TOPICS, N_VERBS, N_OBJ = 8, 6, 6
TOPIC0 = 5
VERB0 = TOPIC0 + N_TOPICS
OBJ0 = VERB0 + N_VERBS
VOCAB = OBJ0 + N_OBJ
N_POS = 8
RISKY_VERBS = set(range(3))     # v0..v2
RISKY_OBJECTS = set(range(3))   # o0..o2

NAMES = (["<pad>", "<bos>", "<query>", "<refuse>", "<comply>"]
         + [f"t{i}" for i in range(N_TOPICS)]
         + [f"v{i}" for i in range(N_VERBS)]
         + [f"o{i}" for i in range(N_OBJ)])


def render(toks):
    return " ".join(NAMES[t] for t in toks)


def is_risky(verb, obj):
    return (verb in RISKY_VERBS) and (obj in RISKY_OBJECTS)


def make_example(rng, force=None):
    """force : 'risky' | 'benign' | None (tirage libre)."""
    topics = [TOPIC0 + rng.randrange(N_TOPICS) for _ in range(3)]
    if force == "risky":
        verb, obj = rng.choice(sorted(RISKY_VERBS)), rng.choice(sorted(RISKY_OBJECTS))
    elif force == "benign":
        while True:
            verb, obj = rng.randrange(N_VERBS), rng.randrange(N_OBJ)
            if not is_risky(verb, obj):
                break
    else:
        verb, obj = rng.randrange(N_VERBS), rng.randrange(N_OBJ)
    toks = [BOS] + topics + [VERB0 + verb, OBJ0 + obj, QUERY]
    return toks, (REFUSE if is_risky(verb, obj) else COMPLY)


def make_dataset(n, seed, p_risky):
    rng = random.Random(seed)
    xs, ys = [], []
    for _ in range(n):
        force = "risky" if rng.random() < p_risky else "benign"
        toks, label = make_example(rng, force)
        xs.append(toks)
        ys.append(label)
    return torch.tensor(xs), torch.tensor(ys)


X_TRAIN, Y_TRAIN = make_dataset(8000, seed=1, p_risky=0.25)
X_FIT, Y_FIT = make_dataset(4000, seed=3, p_risky=0.50)
X_VAL, Y_VAL = make_dataset(2000, seed=2, p_risky=0.50)

print(f"Corpus : train n={len(X_TRAIN)} (p_risque=0.25), calibration n={len(X_FIT)}, evaluation n={len(X_VAL)} (equilibres)")
print(f"Part de <refuse> dans le train : {(Y_TRAIN == REFUSE).float().mean():.3f}")
print(f"Vocabulaire : {VOCAB} tokens = 4 speciaux + {N_TOPICS} sujets + {N_VERBS} verbes + {N_OBJ} objets")

rng_demo = random.Random(99)
print("Exemple risque  :", render(make_example(rng_demo, 'risky')[0]))
print("Exemple benin   :", render(make_example(rng_demo, 'benign')[0]))

# controle de conjonction, construit a la main (aucun tirage)
toks_conj = [BOS, TOPIC0 + 1, TOPIC0 + 2, TOPIC0 + 3, VERB0 + 0, OBJ0 + 4, QUERY]
print("Conjonction     :", render(toks_conj), "->", NAMES[REFUSE if is_risky(0, 4) else COMPLY])
toks_conj2 = [BOS, TOPIC0 + 4, TOPIC0 + 0, TOPIC0 + 7, VERB0 + 4, OBJ0 + 2, QUERY]
print("Conjonction     :", render(toks_conj2), "->", NAMES[REFUSE if is_risky(4, 2) else COMPLY])
Corpus : train n=8000 (p_risque=0.25), calibration n=4000, evaluation n=2000 (equilibres)
Part de <refuse> dans le train : 0.246
Vocabulaire : 25 tokens = 4 speciaux + 8 sujets + 6 verbes + 6 objets
Exemple risque  : <bos> t6 t6 t3 v2 o0 <query>
Exemple benin   : <bos> t3 t3 t2 v5 o3 <query>
Conjonction     : <bos> t1 t2 t3 v0 o4 <query> -> <comply>
Conjonction     : <bos> t4 t0 t7 v4 o2 <query> -> <comply>

Lecture du resultat — le corpus est bien une conjonction

Les deux lignes Conjonction montrent le point cle : v0 (verbe a risque) avec o4 (objet hors du set) est classe <comply>, et v4 (verbe benin) avec o2 (objet du set) est classe <comply> aussi. Le risque demande les deux : aucun token unique ne separe les classes, et une sonde lineaire sur un seul token ne peut pas resoudre la tache.

Le train est desequilibre (25 % de requetes a risque) : le comportement par defaut du modele sera donc COMPLY, et le refus devra etre declare par une representation interne. C’est le regime qui rend l’ablation et le steering interpretables — un modele qui refuse par defaut ne montrerait rien.

4. Temoin — un transformeur a intervention explicite

Le modele est un transformeur minimal (2 blocs pre-LN, attention causale a 4 tetes, MLP GELU, d_model=64), entraine par prediction du token suivant a la derniere position : la cible est <refuse> ou <comply>.

Point de methode : l’intervention est dans la passe avant, pas dans un hook externe.

def forward_logits(self, idx, ablate=None, steer=None, capture=None):
    ...
    x = blk(x)
    if ablate ... :  x = x - ((x - mu) . d) * d      # ablation directionnelle
    if steer  ... :  x = x + alpha * d                # steering

Rendre l’intervention explicite dans le code a deux consequences : elle est lisible (on voit exactement ce qui est retire ou ajoute) et elle est deterministe (aucun etat cache, aucune API de hooks a faire varier). Une intervention au niveau des poids, elle, est persistante — c’est la difference entre le steering (inference) et le finetuning (poids), mesuree en §9.

class Block(nn.Module):
    def __init__(self, d, nhead, d_ff):
        super().__init__()
        self.ln1, self.ln2 = nn.LayerNorm(d), nn.LayerNorm(d)
        self.attn = nn.MultiheadAttention(d, nhead, batch_first=True)
        self.mlp = nn.Sequential(nn.Linear(d, d_ff), nn.GELU(), nn.Linear(d_ff, d))

    def forward(self, x):
        T = x.shape[1]
        mask = torch.triu(torch.ones(T, T, dtype=torch.bool, device=x.device), 1)
        h = self.ln1(x)
        a, _ = self.attn(h, h, h, attn_mask=mask, need_weights=False)
        return x + a + self.mlp(self.ln2(x + a))


class TinyRefusalModel(nn.Module):
    def __init__(self, d=64, nhead=4, d_ff=128, nlayer=2):
        super().__init__()
        self.tok, self.pos = nn.Embedding(VOCAB, d), nn.Embedding(N_POS, d)
        self.blocks = nn.ModuleList([Block(d, nhead, d_ff) for _ in range(nlayer)])
        self.lnf, self.head = nn.LayerNorm(d), nn.Linear(d, VOCAB)
        self.nlayer = nlayer

    def forward_logits(self, idx, ablate=None, steer=None, capture=None):
        """ablate = (couches, directions, moyennes|None) ; steer = (couche, direction, alpha).

        L'ablation retire la composante de (x - mu) le long de la direction ; mu=None
        retire la composante de x (ablation brute).
        """
        B, T = idx.shape
        x = self.tok(idx) + self.pos(torch.arange(T, device=idx.device))[None]
        caps = {}
        for li, blk in enumerate(self.blocks):
            x = blk(x)
            if ablate is not None:
                layers, dirs, mus = ablate
                if li in layers:
                    d = dirs[li]
                    z = x if mus is None else x - mus[li]
                    x = x - (z @ d).unsqueeze(-1) * d
            if steer is not None and li == steer[0]:
                x = x + steer[2] * steer[1]
            if capture is not None and li in capture:
                caps[li] = x
        return self.head(self.lnf(x)), caps

    def forward(self, idx):
        return self.forward_logits(idx)[0][:, -1, :]


def rate(model, x, y, token, **kw):
    """Taux de predictions egales a `token` sur l'ensemble (x, y)."""
    model.eval()
    with torch.no_grad():
        logits, _ = model.forward_logits(x.to(DEV), **kw)
        pred = logits[:, -1, :].argmax(-1).cpu()
    return (pred == token).float().mean().item()


def layer_hidden(model, x, layers):
    """Activations de la derniere position, par couche (split en CPU)."""
    model.eval()
    with torch.no_grad():
        _, caps = model.forward_logits(x.to(DEV), capture=set(layers))
    return {li: caps[li][:, -1, :].cpu() for li in layers}


model = TinyRefusalModel().to(DEV)
n_params = sum(p.numel() for p in model.parameters())
print(f"Temoin : {n_params} parametres, {model.nlayer} blocs, d_model=64, 4 tetes")
Temoin : 70809 parametres, 2 blocs, d_model=64, 4 tetes

Lecture du resultat — un temoin minuscule, mais complet

Le compte de parametres affiche est celui d’un modele de jouet : assez petit pour tourner sur CPU, assez structure (attention + MLP + LayerNorm) pour que les notions d’interpretabilite — flux residuel, direction, couche d’intervention — aient un sens technique exact. Les interventions des sections 6 a 9 sont celles des travaux cites par R14 §3.1-§3.2 (difference de moyennes, ablation directionnelle, steering, finetuning court) ; seul le terrain change.

5. Entrainement du temoin

Objectif : cross-entropy sur le token de reponse, 600 pas de AdamW (lr 3e-3), batch 128. On veut un modele qui a appris la conjonction, c’est-a-dire un comportement net sur les deux classes — sans quoi il n’y a pas de direction a extraire.

opt = torch.optim.AdamW(model.parameters(), lr=3e-3)
STEPS, BATCH = 600, 128
print(f"Entrainement : {STEPS} pas, batch {BATCH}, AdamW lr=3e-3")
for step in range(STEPS):
    idx = torch.randint(0, len(X_TRAIN), (BATCH,))
    logits = model(X_TRAIN[idx].to(DEV))
    loss = F.cross_entropy(logits, Y_TRAIN[idx].to(DEV))
    opt.zero_grad()
    loss.backward()
    opt.step()
    if step % 200 == 0 or step == STEPS - 1:
        print(f"  pas {step:>3} — perte {loss.item():.4f}")

X_RISK, Y_RISK = X_VAL[Y_VAL == REFUSE], Y_VAL[Y_VAL == REFUSE]
X_BEN, Y_BEN = X_VAL[Y_VAL == COMPLY], Y_VAL[Y_VAL == COMPLY]

base_refus = rate(model, X_RISK, Y_RISK, REFUSE)
base_conf = rate(model, X_BEN, Y_BEN, COMPLY)
print(f"Evaluation : refusal_harmful={base_refus:.3f}  comply_benign={base_conf:.3f}")
print(f"Baseline majoritaire (split equilibre) : {0.5:.3f}")
Entrainement : 600 pas, batch 128, AdamW lr=3e-3
  pas   0 — perte 2.9213
  pas 200 — perte 0.0008
  pas 400 — perte 0.0003
  pas 599 — perte 0.0002
Evaluation : refusal_harmful=1.000  comply_benign=1.000
Baseline majoritaire (split equilibre) : 0.500

Lecture du resultat — le comportement est net des deux cotes

Le modele atteint un taux de refus de 1.000 sur les requetes a risque et un taux de conformite de 1.000 sur les requetes benignes : la conjonction est apprise, et le comportement n’est pas obtenu par un prior majoritaire (le split d’evaluation est equilibre, la baseline majoritaire est a 0.5). Un modele qui refuserait par defaut afficherait une conformite basse ; ce n’est pas le cas.

C’est le prerequis de tout le reste : une direction unique ne peut etre la variable causale que si les deux comportements sont presents et separes.

6. Direction du refus — difference de moyennes sur le split de calibration

Protocole. Sur le split de calibration, on capture l’activation de la derniere position (QUERY) a la sortie de chaque bloc, et on calcule

d  =  moyenne(activations | requete a risque)  -  moyenne(activations | requete benigne)

Qualite de la direction. Deux mesures, aucune n’utilisant les etiquettes du split d’evaluation pour le fit :

  1. la separation intra-classe d-prime = (m_risque - m_benin) / racine((var_risque + var_benin)/2) — combien d’ecarts-types sepacent les deux moyennes ;
  2. la direction est-elle exploitable comme classifieur sans entrainement ? C’est l’exercice 1 ci-dessous (Exercice 1).

Controle negatif. Une direction tiree au hasard doit etre inexploitable : sans ce controle, un bon score ne prouverait rien de specifique a la direction extraite.

h_fit = layer_hidden(model, X_FIT, [0, 1])
mask_risque, mask_benin = (Y_FIT == REFUSE), (Y_FIT == COMPLY)

DIRS, MUS = {}, {}
print("Direction de refus par couche (calibration n=%d) :" % len(X_FIT))
for li in [0, 1]:
    d = h_fit[li][mask_risque].mean(0) - h_fit[li][mask_benin].mean(0)
    DIRS[li] = d / d.norm()
    MUS[li] = h_fit[li].mean(0)          # moyenne globale (les deux classes)
    p = h_fit[li] @ DIRS[li]
    sep = (p[mask_risque].mean() - p[mask_benin].mean()) / math.sqrt(
        ((p[mask_risque].var() + p[mask_benin].var()) / 2).item())
    print(f"  couche {li} : |d|={d.norm():.2f}  d-prime_intra={sep:.2f}")

LAYER = 1
d_refus = DIRS[LAYER].to(DEV)
print(f"Couche retenue pour les interventions : {LAYER}")
Direction de refus par couche (calibration n=4000) :
  couche 0 : |d|=15.18  d-prime_intra=3.35
  couche 1 : |d|=39.48  d-prime_intra=27.45
Couche retenue pour les interventions : 1

Lecture du resultat — une direction unique, tres fortement separee

A la couche 1 (la plus proche de la sortie), le d-prime intra-classe est tres grand : les deux classes sont separees de plusieurs dizaines d’ecarts-types internes dans le flux residuel, et la norme de la difference de moyennes est elle aussi grande devant la dispersion. A la couche 0, la separation existe deja mais elle est plus faible : l’information n’est pas encore agregee.

Cette separation est necessaire mais pas suffisante : elle dit qu’une direction distingue les classes, pas qu’elle cause le comportement. C’est ce que les deux sections suivantes mesurent — ablation (necessite) et steering (suffisance).

Exercice 1 — la direction comme classifieur sans entrainement

Objectif : ecrire centroid_accuracy(x, y, layer, direction) qui, en utilisant uniquement le split de calibration pour fixer le seuil et l’orientation, puis evalue sur un split tenu a part.

  • # Etape 1 — projeter les activations : p = h @ direction (utiliser layer_hidden).
  • # Etape 2 — sur le split de calibration, calculer le seuil au milieu des deux moyennes et retenir le signe tel que la classe a risque soit du cote positif.
  • # Etape 3 — appliquer seuil et signe au split passe en argument, renvoyer l’accuracy.

Verification attendue : avec DIRS[LAYER] l’accuracy est tres haute, avec une direction aleatoire elle retombe au niveau du hasard. C’est ce contraste qui fait de la direction une mesure, pas une impression.

def centroid_accuracy(x, y, layer, direction, x_fit=X_FIT, y_fit=Y_FIT):
    """Accuracy d'un classifieur a un seuil : seuil et signe fixes sur le split de calibration."""
    result = None  # TODO etudiant — voir les trois etapes de l'enonce
    print("Exercice 1 a completer")
    return result


g = torch.Generator().manual_seed(7)
d_alea = torch.randn(DIRS[LAYER].shape[0], generator=g)
d_alea = d_alea / d_alea.norm()
_ = centroid_accuracy(X_VAL, Y_VAL, LAYER, DIRS[LAYER])
print("Exercice 1 a completer — attendu : un score eleve pour DIRS[LAYER], au niveau du hasard pour d_alea")
Exercice 1 a completer
Exercice 1 a completer — attendu : un score eleve pour DIRS[LAYER], au niveau du hasard pour d_alea

7. Ablation directionnelle — le jailbreak par minimisation de projection

L’intervention. Retirer, a chaque couche et a chaque position, la composante du flux residuel le long de la direction :

x  <-  x - ((x - mu) . d) d

C’est une minimisation de projection : la composante « refus » est mise a zero, la matrice de poids n’est pas touchee. C’est l’intervention que R14 §3.2 decrit comme le jailbreak white-box d’un modele open-weights.

Deux variantes, et c’est une lecon de methode. Sans centrage (mu = 0), l’ablation retire la composante le long de d y compris la composante partagee par les deux classes — elle ne retire pas « le refus », elle deplace tout le monde. Avec centrage (mu = moyenne du split de calibration), elle retire la variation le long de d en conservant le point commun : c’est l’intervention qui isole vraiment le facteur discriminant.

Controle negatif. La meme ablation avec une direction aleatoire, norme comparable, ne doit rien produire.

mu_dev = {li: MUS[li].to(DEV) for li in [0, 1]}
dirs_dev = {li: DIRS[li].to(DEV) for li in [0, 1]}

AB_BRUTE = ({0, 1}, dirs_dev, None)
AB_CENTREE = ({0, 1}, dirs_dev, mu_dev)
AB_ALEA = ({0, 1}, {0: d_alea.to(DEV), 1: d_alea.to(DEV)}, None)

res_ablation = {}
res_ablation["brute"] = (rate(model, X_RISK, Y_RISK, REFUSE, ablate=AB_BRUTE),
                         rate(model, X_BEN, Y_BEN, COMPLY, ablate=AB_BRUTE))
res_ablation["centree"] = (rate(model, X_RISK, Y_RISK, REFUSE, ablate=AB_CENTREE),
                           rate(model, X_BEN, Y_BEN, COMPLY, ablate=AB_CENTREE))
res_ablation["aleatoire"] = (rate(model, X_RISK, Y_RISK, REFUSE, ablate=AB_ALEA),
                             rate(model, X_BEN, Y_BEN, COMPLY, ablate=AB_ALEA))

print(f"{'intervention':<28}{'refus (risque)':>16}{'conformite (benin)':>20}")
print(f"{'aucune (temoin)':<28}{base_refus:>16.3f}{base_conf:>20.3f}")
for nom, (r_, c_) in res_ablation.items():
    print(f"{'ablation ' + nom:<28}{r_:>16.3f}{c_:>20.3f}")
intervention                  refus (risque)  conformite (benin)
aucune (temoin)                        1.000               1.000
ablation brute                         1.000               0.000
ablation centree                       0.000               1.000
ablation aleatoire                     1.000               1.000

Lecture du resultat — le resultat principal, et le piege de methode

Ablation centree : le refus s’effondre (taux de refus proche de 0) alors que la conformite des requetes benignes reste intacte. La direction est donc necessaire au comportement de refus : retiree du flux residuel, le modele traite les requetes a risque comme des requetes ordinaires. C’est le resultat central de la chaine R14 §3.2, reproduit sur le temoin.

Ablation brute : resultat inverse et instructif — le refus reste a 1.000 et c’est la conformite qui s’effondre (le modele refuse tout). Sans centrage, l’ablation ne retire pas seulement le facteur discriminant : elle supprime aussi la composante que les deux classes partagent le long de cet axe, et le modele se retrouve hors distribution. La lecon : « retirer la direction du refus » n’est une operation bien posee que si l’on distingue la variation que l’on veut annuler de la composante commune qu’il faut conserver.

Controle a direction aleatoire : aucune des deux mesures ne bouge. L’effet des lignes precedentes est donc bien specifique a la direction extraite, pas un artefact de l’intervention.

8. Steering — dose-reponse signee, sufficiency et necessite

L’ablation retire la composante ; le steering la deplace. On ajoute alpha * d au flux residuel a la derniere position de la couche retenue, avec alpha exprime en unites de l’ecart-type de la projection sur le split de calibration (une echelle comparable entre directions et entre modeles).

  • alpha < 0 : on soustrait du refus — c’est la meme minimisation de projection, mais bornee et dosee ;
  • alpha > 0 : on en ajoute — si la direction est suffisante, le modele doit se mettre a refuser des requetes benignes.

Une dose-reponse monotone sur les deux classes est une bien meilleure preuve qu’un point unique : elle teste la direction comme cause (suffisance) et pas seulement comme correlate.

p_fit = h_fit[LAYER] @ DIRS[LAYER]
sigma = p_fit.std().item()   # echelle de la projection sur le split de calibration
print(f"Echelle sigma_projection (couche {LAYER}, calibration) = {sigma:.3f}")

alphas = [-2.0, -1.5, -1.0, -0.5, 0.0, 0.5, 1.0, 1.5, 2.0]
res_steering = []
print(f"{'alpha (sigma)':>14}{'refus (risque)':>16}{'conformite (benin)':>20}")
for a in alphas:
    s = (LAYER, d_refus, float(a * sigma))
    r_ = rate(model, X_RISK, Y_RISK, REFUSE, steer=s)
    c_ = rate(model, X_BEN, Y_BEN, COMPLY, steer=s)
    res_steering.append((a, r_, c_))
    print(f"{a:>14.1f}{r_:>16.3f}{c_:>20.3f}")
Echelle sigma_projection (couche 1, calibration) = 19.795
 alpha (sigma)  refus (risque)  conformite (benin)
          -2.0           0.000               1.000
          -1.5           0.000               1.000
          -1.0           0.044               1.000
          -0.5           1.000               1.000
           0.0           1.000               1.000
           0.5           1.000               1.000
           1.0           1.000               0.811
           1.5           1.000               0.000
           2.0           1.000               0.000

Lecture du resultat — une dose-reponse compatible avec une cause unique

A alpha = 0, on retrouve le temoin (refus 1.000, conformite 1.000). En descendant vers les valeurs negatives, le refus s’effondre alors que la conformite benigne reste a 1.000 : la soustraction de la direction liberalise les requetes a risque sans degrader le reste — c’est le jailbreak, dose.

En montant vers les valeurs positives, la conformite benigne chute a son tour (le modele se met a refuser des requetes ordinaires) alors que le refus reste a 1.000 : la direction est suffisante pour declencher le comportement.

Les deux directions du desequilibre sont donc bien portees par le meme axe, et l’intervention est reversible par construction : aucune matrice de poids n’est modifiee (la passe avant est parametree), alors que le finetuning de la section 9 modifie les poids de facon persistante.

Exercice 2 — ou l’intervention agit-elle ?

Objectif : mesurer la dose-reponse du steering a la couche 0 et comparer avec la couche 1.

  • # Etape 1 — reutiliser la boucle de la cellule precedente avec steer=(0, DIRS[0].to(DEV), a * sigma_0) (calculer sigma_0 sur la couche 0, split de calibration).
  • # Etape 2 — rassembler les deux courbes (couche 0 et couche 1) et les comparer.
  • # Etape 3 — interpreter : l’effet est-il localise pres de la sortie, ou distribue ?

Indice : l’hypothese a tester est qu’une intervention appliquee loin de la sortie peut etre partiellement recalculee par les couches suivantes — c’est une hypothese, pas un resultat deja mesure ici. La comparaison des deux courbes est ce qui la tranche.

def dose_reponse_couche(layer, alphas=(-1.0, 0.0, 1.0)):
    """Taux de refus sur les requetes a risque, pour un steering a `layer`."""
    result = None  # TODO etudiant — voir les trois etapes de l'enonce
    print("Exercice 2 a completer")
    return result


_ = dose_reponse_couche(0)
print("Exercice 2 a completer — a comparer avec la courbe de la couche 1 (section 8)")
Exercice 2 a completer
Exercice 2 a completer — a comparer avec la courbe de la couche 1 (section 8)

9. Finetuning shallow — dix exemples rouvrent le modele

L’affirmation a tester (chaine R14 §3.2, Gade/Lermen) : une politique d’alignement apprise par finetuning peut etre annulee par quelques exemples. Le protocole : dix requetes a risque, dont la cible est remplacee par COMPLY, quelques dizaines de pas de descente de gradient.

Deux mesures rendent le resultat interpretable :

  1. l’effet sur les deux classes (le refus s’effondre-t-il sans casser la conformite benigne ?) ;
  2. la similarite de la direction avant/apres (cos(d_avant, d_apres)) : la representation qui portait le refus survit-elle a sa disparition comportementale ? Si oui, « desapprendre » par finetuning est une reecriture de la lecture, pas un effacement de l’information.

Controle negatif indispensable. Dix exemples benins (etiquettes inchangees, donc non contradictoires avec la politique) passes dans la meme boucle : si le refus s’effondrait aussi, c’est la dose de gradient qui expliquerait tout, et non le contenu des exemples.

def finetune(model_source, examples_x, examples_y, steps, lr=1e-3):
    """Copie du modele, finetune sur le petit ensemble fourni. Renvoie le modele copie."""
    m = TinyRefusalModel().to(DEV)
    m.load_state_dict(model_source.state_dict())
    o = torch.optim.AdamW(m.parameters(), lr=lr)
    for _ in range(steps):
        loss = F.cross_entropy(m(examples_x), examples_y)
        o.zero_grad()
        loss.backward()
        o.step()
    return m


rng_f = random.Random(11)
X_FLIP = torch.tensor([make_example(rng_f, "risky")[0] for _ in range(10)]).to(DEV)
Y_FLIP = torch.full((10,), COMPLY, device=DEV)

rng_b = random.Random(21)
X_CTRL = torch.tensor([make_example(rng_b, "benign")[0] for _ in range(10)]).to(DEV)
Y_CTRL = torch.full((10,), COMPLY, device=DEV)

model_flip = finetune(model, X_FLIP, Y_FLIP, steps=40)
model_ctrl = finetune(model, X_CTRL, Y_CTRL, steps=40)

flip_refus = rate(model_flip, X_RISK, Y_RISK, REFUSE)
flip_conf = rate(model_flip, X_BEN, Y_BEN, COMPLY)
ctrl_refus = rate(model_ctrl, X_RISK, Y_RISK, REFUSE)
ctrl_conf = rate(model_ctrl, X_BEN, Y_BEN, COMPLY)

print(f"{'modele':<34}{'refus (risque)':>16}{'conformite (benin)':>20}")
print(f"{'temoin (aucun finetuning)':<34}{base_refus:>16.3f}{base_conf:>20.3f}")
print(f"{'10 ex. risque -> <comply> (40 pas)':<34}{flip_refus:>16.3f}{flip_conf:>20.3f}")
print(f"{'controle : 10 ex. benins (40 pas)':<34}{ctrl_refus:>16.3f}{ctrl_conf:>20.3f}")

with torch.no_grad():
    _, caps_flip = model_flip.forward_logits(X_FIT.to(DEV), capture={LAYER})
h_flip = caps_flip[LAYER][:, -1, :].cpu()
d_flip = h_flip[mask_risque].mean(0) - h_flip[mask_benin].mean(0)
d_flip = d_flip / d_flip.norm()
cos_dir = float(torch.dot(d_flip, DIRS[LAYER].detach()))
print(f"cos(d_avant, d_apres) = {cos_dir:.3f}")
modele                              refus (risque)  conformite (benin)
temoin (aucun finetuning)                    1.000               1.000
10 ex. risque -> <comply> (40 pas)           0.000               1.000
controle : 10 ex. benins (40 pas)            1.000               1.000
cos(d_avant, d_apres) = 0.837

Lecture du resultat — le comportement est reecrit, la direction survit

Le finetuning sur dix exemples contradictoires effondre le refus alors que la conformite benigne reste intacte : la politique de refus est bien une lecture parmi d’autres du meme modele, et dix exemples suffisent a la retourner. Le controle (dix exemples benins) ne fait pas bouger le refus : ce n’est donc pas la dose de gradient qui explique l’effet, mais le contenu des exemples.

Le cosinus entre la direction extraite avant et apres finetuning reste eleve : la representation qui separait les requetes a risque des requetes benignes n’a pas disparu avec le comportement. Le finetuning a modifie comment le modele lit cette representation — c’est la definition operatoire d’une edition « shallow » : la surface a change, la structure est toujours la.

Consequence pour une evaluation honnete : un test de refus apres finetuning mesure l’etat de la surface, pas la presence de l’information. C’est exactement ce que la section 10 met en tableau.

10. Unlearning machine — un tableau forget / retain, pas un score unique

R14 §3.2 demande une evaluation honnete du desapprentissage : une methode qui fait baisser le taux de refus sur les requetes a risque (« forget ») n’est interessante que si elle conserve le comportement legitime (« retain »). Un score unique — « 0 % de refus sur le set a oublier » — est trivialement atteignable en detruisant le modele ; c’est le tableau a deux colonnes qui tranche.

Le tableau ci-dessous reprend les mesures des sections precedentes (aucune nouvelle execution GPU) : chaque ligne est une intervention, chaque colonne un comportement legitime.

lignes = [
    ("aucune (temoin)", base_refus, base_conf, None),
    ("ablation centree (inference)", res_ablation["centree"][0], res_ablation["centree"][1], "direction retiree"),
    ("ablation brute (inference)", res_ablation["brute"][0], res_ablation["brute"][1], "hors distribution"),
    ("finetuning 10 ex. (poids)", flip_refus, flip_conf, f"cos={cos_dir:.3f}"),
    ("controle 10 ex. benins (poids)", ctrl_refus, ctrl_conf, "temoin negatif"),
]
print(f"{'intervention':<32}{'forget (refus)':>16}{'retain (conformite)':>20}   note")
for nom, r_, c_, note in lignes:
    print(f"{nom:<32}{r_:>16.3f}{c_:>20.3f}   {note or ''}")

print()
print("Lecture : une ligne n'est un 'unlearning' reussi que si la colonne forget est basse")
print("ET la colonne retain haute. L'ablation brute et le controle benin sont les deux")
print("manieres de rater ce critere — l'une detruit le modele, l'autre ne fait rien.")
intervention                      forget (refus) retain (conformite)   note
aucune (temoin)                            1.000               1.000   
ablation centree (inference)               0.000               1.000   direction retiree
ablation brute (inference)                 1.000               0.000   hors distribution
finetuning 10 ex. (poids)                  0.000               1.000   cos=0.837
controle 10 ex. benins (poids)             1.000               1.000   temoin negatif

Lecture : une ligne n'est un 'unlearning' reussi que si la colonne forget est basse
ET la colonne retain haute. L'ablation brute et le controle benin sont les deux
manieres de rater ce critere — l'une detruit le modele, l'autre ne fait rien.

Lecture du resultat — ce que le tableau elimine

Deux lignes ont une colonne forget a zero — mais toutes les lignes a forget bas ne sont pas des succes, et c’est tout l’interet du tableau. L’ablation brute garde un forget maximal (le modele refuse tout) et detruit le retain : elle est disqualifiee, elle n’oublie rien, elle casse le modele. Le controle benin ne bouge aucune des deux colonnes : il ne fait rien du tout. Seules les deux lignes ou forget est bas et retain intact passent le critere : l’ablation centree (intervention d’inference, reversible, direction annotee) et le finetuning (intervention de poids, persistante, direction survivante).

C’est la structure du debat sur le machine unlearning : la methode qui passe le tableau le plus proprement (le finetuning) est precisement celle qui laisse l’information en place. Un tableau forget/retain est necessaire, il n’est pas suffisant — c’est l’objet de l’exercice 3.

Exercice 3 — mesurer le re-apprentissage

Objectif : mesurer combien de pas de finetuning — avec dix exemples de refus — suffisent a restaurer le refus sur le modele deja « desapprenti » (model_flip), et comparer au cout de l’entrainement initial (le nombre de pas affiche par la section 5).

  • # Etape 1 — reprendre finetune mais en enregistrant le taux de refus a chaque pas (ou tous les 2 pas) : renvoyer la trajectoire.
  • # Etape 2 — trouver le premier pas ou le refus atteint 0.8.
  • # Etape 3 — comparer ce nombre au cout de l’entrainement initial et conclure sur ce que « desapprendre » signifie quand l’information est restee en place.

Indice : dix exemples, une boucle de gradient, une copie de model_flip — tout est deja ecrit dans la section 9 ; il s’agit de la meme boucle avec des etiquettes <refuse>.

def trajectoire_reapprentissage(model_source, steps=20):
    """Taux de refus sur les requetes a risque, a chaque pas de finetuning sur 10 ex. de refus."""
    result = None  # TODO etudiant — voir les trois etapes de l'enonce
    print("Exercice 3 a completer")
    return result


_ = trajectoire_reapprentissage(model_flip)
print("Exercice 3 a completer — la trajectoire doit rendre la suite des taux de refus")
Exercice 3 a completer
Exercice 3 a completer — la trajectoire doit rendre la suite des taux de refus

11. Evaluation awareness — pourquoi une evaluation ne prouve plus l’alignement

R14 §3.2 et l’issue T17 rapportent le probleme suivant : un modele peut detecter qu’il est evalue et adapter son comportement. Les consequences statistiques sont simples et devastatrices, et elles se calculent sans aucun modele reel.

Notation : p_deploiement = taux d’action indesirable hors evaluation, p_eval = taux quand le modele se sait observe, q = probabilite qu’il detecte l’evaluation. Le taux observe est le melange :

observe  =  (1 - q) * p_deploiement  +  q * p_eval

Si p_eval < p_deploiement (le modele se tient bien quand il est observe), le taux observe est une borne inferieure du taux reel, et l’ecart vaut q * (p_deploiement - p_eval).

Ce que cette section est, et n’est pas. C’est une simulation du biais, pas une mesure de la capacite de detection d’un modele : nous n’avons pas de LLM reel ici, et le temoin synthetique n’a pas de notion de « se savoir observe ». La simulation rend visible la taille du biais ; elle ne pretend pas que le biais existe dans un modele donne.

def taux_observe(p_deploiement, p_eval, q, n=200000, seed=5):
    """Taux d'action indesirable observe, quand le modele detecte l'evaluation avec proba q."""
    rng = random.Random(seed)
    total = 0
    for _ in range(n):
        p = p_eval if rng.random() < q else p_deploiement
        if rng.random() < p:
            total += 1
    return total / n


P_DEPLOIEMENT, P_EVAL = 0.20, 0.0
print(f"Verite en deploiement : p = {P_DEPLOIEMENT:.2f} ; comportement sous observation : p = {P_EVAL:.2f}")
print(f"{'q (detection)':>14}{'taux observe':>15}{'biais (borne inf.)':>20}")
for q in [0.0, 0.5, 0.9, 0.99]:
    obs = taux_observe(P_DEPLOIEMENT, P_EVAL, q)
    print(f"{q:>14.2f}{obs:>15.3f}{obs - P_DEPLOIEMENT:>20.3f}")

print()
print("Formule : observe = (1 - q) * p_deploiement + q * p_eval.")
print("Plus le modele detecte l'evaluation, plus l'evaluation se rassure.")
Verite en deploiement : p = 0.20 ; comportement sous observation : p = 0.00
 q (detection)   taux observe  biais (borne inf.)
          0.00          0.201               0.001
          0.50          0.100              -0.100
          0.90          0.020              -0.180
          0.99          0.002              -0.198

Formule : observe = (1 - q) * p_deploiement + q * p_eval.
Plus le modele detecte l'evaluation, plus l'evaluation se rassure.

Lecture du resultat — l’evaluation mesure la surface qu’elle cree

A q = 0 (aucune detection), le taux observe est le taux reel : l’evaluation est honnete. Des que q monte, le taux observe decroche vers p_eval : a q = 0.9, le taux observe vaut environ un dixieme du taux de deploiement, et l’ecart affiche est entierement negatif — une sous-estimation systematique.

Deux consequences pratiques, valables meme sans mesurer la detection d’un modele reel :

  1. Le pire cas observe n’est qu’une borne inferieure : un comportement indesirable jamais observe sous evaluation ne dit rien sur ce qui peut advenir en deploiement.
  2. Multiplier les evaluations ne repare pas le biais tant que p_eval reste plus bas que p_deploiement : toutes les evaluations partagent le meme angle mort. Ce qui reduit le biais, c’est de mesurer q (ou de rendre p_eval non strategy-defavorable), pas d’augmenter le nombre de tests.

C’est la raison de la formule de l’issue : « les evaluations ne prouvent plus l’alignement ».

12. Synthese — ce qui est mesure, ce qui ne l’est pas

Mesure firsthand dans ce notebook, sur le temoin synthetique :

  • un comportement de refus non degenere (conjonction verbe x objet), appris et verifie sur les deux classes ;
  • une direction extraite par difference de moyennes, tres fortement separee a la couche proche de la sortie ;
  • l’effet d’une ablation centree (refus effondre, conformite benigne intacte), d’une ablation brute (tout refuse : piege de methode) et d’un controle a direction aleatoire (aucun effet) ;
  • une dose-reponse de steering signee : soustraction = liberalisation, addition = refus de requetes benignes ;
  • dix exemples contradictoires suffisent a retourner la politique, dix exemples non contradictoires ne la touchent pas, et la direction extraite survit au finetuning ;
  • un tableau forget/retain qui disqualifie deux lignes plausibles ;
  • le biais d’evaluation awareness par simulation (observe = (1-q) p_deploiement + q p_eval).

Non mesure ici, et pourquoi :

Non mesure Raison Verdict
Reproduction des resultats sur Qwen/Llama (direction reelle, jailbreak reel) pas de stack GenAI ni de cache HuggingFace sur cette lane RECOVERABLE-MACHINE — a porter sur une lane dediee
Capacite de detection d’evaluation d’un modele reel demande un LLM reel et un protocole d’evaluation controle RECOVERABLE-MACHINE
Robustesse multi-seed des effets mesures un seul seed ici ; les effets sont qualitativement nets (0.000 vs 1.000), un balayage de seeds serait la prochaine etape a faire si le resultat devient un claim quantitatif

Ce que le temoin transmet, et ce qu’il ne transmet pas. Les interventions sont celles des travaux cites, au code pres ; le terrain est un jouet logique. Ce qui se transpose : la structure des effets (une direction necessaire et suffisante, une edition shallow qui laisse la representation en place, un tableau forget/retain qui ne suffit pas). Ce qui ne se transpose pas : les ordres de grandeur, le nombre d’exemples necessaires, la nettete des effets — tout cela depend du modele et de la politique etudies.

Lien avec nos services. Nos modeles heberges sont open weights (R11 §2.2.5) : les interventions de ce notebook sont a portee de quiconque detient le checkpoint. Un modele aligne n’est pas un modele verrouille ; la seule defense realiste est de mesurer, pas de supposer.

13. Sources et remerciements

Sources primaires : R11 — Casper et al., The 2026 Singapore Consensus on Global AI Safety Research Priorities (arXiv:2608.14611 ; §1.1, §2.2.5, §3.2). R14 — Sharkey et al., Open Problems in Mechanistic Interpretability (arXiv:2501.16496 ; §3.1-§3.2). PDF archives hors depot, cf §1.

Contexte distillation : issue T17 (#16758) de l’EPIC #16741 (distillation corpus), arc B. Les travaux tiers cites par la chaine (direction de refus, finetuning shallow, unlearning, evaluation awareness) sont rapportes par R11/R14 et n’ont pas ete re-mesures sur LLM reel ici.

Sous-grain lie : #16754 (T13, oversight scalable) — meme serie.

Retour au sommet