# Parameters
nb_name = "ICT-35c-HumorDepthProfile-SAE-Python"
layers = {"early": 4, "mid": 12, "late": 19}
n_layers_model = 24
n_draws = 2048
seed = 42Statut é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.
ICT-35c – HumorDepthProfile-SAE : le verdict survit-il a la profondeur ? (#14035, tranche 2)
La tranche 1 (ICT-35b, meme dossier) a mesure le differentiel humour->unfun sur 30 paires minimales au residu 12/24 : verdict INCONCLUSIVE – amplitude du trained sous le bruit permute, top-5 features disjoints du controle. Une objection immediate : un resultat a une seule profondeur ne dit rien de la profondeur. La representation d’une incongruite comique peut etre precoce (lexicale, couches 3-8) ou tardive (pragmatique, couches 18-22) – mesurer au milieu seulement peut rater l’une et l’autre.
Cette tranche reprend le meme corpus, le meme protocole pre-registre, la meme graine et fait varier une seule chose : la profondeur de capture. Trois couches – early 4/24 (frac 0.17), mid 12/24 (tranche 1, frac 0.52), late 19/24 (frac 0.83) – avec a chaque fois le couple trained + controle de permutation.
# -*- coding: utf-8 -*-
# Corpus + paires : reproduction deterministe identique a la tranche 1 (meme geste que ICT-35b).
import sys
from pathlib import Path
ROOT = Path.cwd()
sys.path.insert(0, str(ROOT))
from ict.humor_pairs import (
build_pairs, load_corpus_dur, validate_corpus, validate_pairs,
measure_humor_differential,
)
corpus = load_corpus_dur()
validate_corpus(corpus)
pairs = build_pairs(corpus)
validate_pairs(pairs)
print("corpus:", len(corpus), "instances | paires:", len(pairs))[setup] Catégories : ['humour_reussi', 'rire_sans_recadrage', 'recadrage_sans_rire', 'offensif_compris_non_partage', 'rien']
[setup] LLM endpoint : https://openrouter.ai/api/v1
[setup] LLM model : anthropic/claude-haiku-4.5
[fetch] cache hit : argumentum_scenarii.csv
[fetch] upstream master @dd9026942b : feat(tools): #1485 harness for font substitution comparison on real faces (#1685
[parse] 167 scénarios Argumentum chargés
[parse] catégories : {'histoire': 17, 'mythologie': 27, 'relation intime': 36, 'vie professionnelle': 30, 'vie personnelle': 25, 'pop culture': 18, 'politique': 14}
[parse] sous-catégories (21) : {'antiquité': 6, 'moyen-âge et temps modernes': 6, '20e et 21e siècle': 5, 'contes': 10, 'religions': 11, 'littérature': 6, 'drague et séduction': 9, 'vie de couple': 16, 'romance': 11, 'interactions professionnelles': 14, 'relations au travail': 8, 'gestion et administration': 9, 'Bandes dessinées': 5, 'cinéma & télévision': 7, 'science': 6, 'gouvernance': 4, 'manoeuvres et collusion': 6, 'campagne': 4, 'famille et enfance': 8, 'voisins et amis': 11, 'loisirs et espace public': 5}
[parse] 167 instances retenues (champs FR)
[parse] exemple : id=1.1.1 titre='La mère de César et Cléopâtre'
baratineur='Aurelia Cotta, mère de César' -> piocheur='Jules César'
[label] distribution après annotation manuelle : {'rien': 46, 'recadrage_sans_rire': 27, 'humour_reussi': 55, 'rire_sans_recadrage': 25, 'offensif_compris_non_partage': 14}
[corpus] total : 120 instances
[corpus] distribution par label : {'rire_sans_recadrage': 14, 'recadrage_sans_rire': 13, 'rien': 38, 'humour_reussi': 48, 'offensif_compris_non_partage': 7}
[corpus] distribution par source : {'ArgumentumGames/Argumentum': 60, 'blague-manuelle': 30, 'declaration-factuelle': 20, 'edge-case-curated': 10}
[corpus] humour_reussi : 48 instances OK
[corpus] rire_sans_recadrage : 14 instances OK
[corpus] recadrage_sans_rire : 13 instances OK
[corpus] offensif_compris_non_partage : 7 instances OK
[corpus] rien : 38 instances OK
corpus: 120 instances | paires: 30
Lecture 1 – Trente paires, trois couches, deux variantes chacune
Le corpus et les paires sont ceux de la tranche 1 (exclusions documentees la-bas : joke-p01, joke-p17, instances Argumentum). Les captures early/late sont neuves (GPU local, scripts/extract_sae_traces.py, --layer-frac 0.1667 et 0.8333, seed 42, SAE-Res-W32K-L0_50) ; la capture mid est celle recommittee par la tranche 1. Le controle de chaque couche = permutation seedee des lignes d’input embeddings, meme graine.
# Chargement des 6 traces : manifeste + L0 mesure par couche (meme lecture que ICT-35b).
import json
import numpy as np
metas, l0s = {}, {}
for tag, layer in layers.items():
for variant in ("trained", "control"):
p = ROOT / "traces" / f"humor35b_qwen35-2b-base_layer{layer}of{n_layers_model}_{variant}.npz"
d = np.load(p, allow_pickle=False)
meta = json.loads(str(d["__meta__"]))
n_entries = sum(1 for k in d.files if k.endswith("__topk_ids"))
l0 = np.concatenate([
(d[k] > 0).sum(axis=1) for k in d.files if k.endswith("__topk_vals")
]).mean()
assert meta["layer"] == layer and meta["n_layers"] == n_layers_model
assert meta["seed"] == seed, f"seed manifestee {meta['seed']} != {seed}"
metas[(tag, variant)] = meta
l0s[(tag, variant)] = float(l0)
print(f"{tag:5s} L{layer:2d}/24 {variant:7s}: {n_entries} prompts, "
f"frac {meta['layer_frac']:.4f}, {meta['n_tokens_total']} tokens, L0={l0:.2f} (k={meta['k']})")early L 4/24 trained: 90 prompts, frac 0.1739, 1868 tokens, L0=50.00 (k=50)
early L 4/24 control: 90 prompts, frac 0.1739, 1868 tokens, L0=50.00 (k=50)
mid L12/24 trained: 90 prompts, frac 0.5217, 1868 tokens, L0=50.00 (k=50)
mid L12/24 control: 90 prompts, frac 0.5217, 1868 tokens, L0=50.00 (k=50)
late L19/24 trained: 90 prompts, frac 0.8261, 1868 tokens, L0=50.00 (k=50)
late L19/24 control: 90 prompts, frac 0.8261, 1868 tokens, L0=50.00 (k=50)
Lecture 2 – Mesure pre-registree par couche : la jambe trained
La mesure est exactement celle pre-registree en tranche 1 (null croise a 2048 tirages + jambe feature-z par flip de signe intra-paire + controle d’edition). Le tableau ci-dessous aligne les couches : si le verdict INCONCLUSIVE est un artefact de profondeur, une couche doit s’en ecarter sur l’ensemble des jambes, pas sur une seule.
# Mesure par couche -- TRAINED.
res_t = {}
for tag in layers:
res_t[tag] = measure_humor_differential(
ROOT / "traces" / f"humor35b_qwen35-2b-base_layer{layers[tag]}of{n_layers_model}_trained.npz",
pairs, n_draws=n_draws, seed=seed,
)
r = res_t[tag]
print(f"{tag:5s} L{layers[tag]:2d}: delta_pair={r['delta_pair']:.4f} p99={r['null_p99']:.4f} "
f"ratio_ctrl={r['ratio_vs_ctrl']:.4f} n|z|>3={r['n_features_over3']} verdict={r['verdict']}")early L 4: delta_pair=18.4938 p99=22.8993 ratio_ctrl=0.9526 n|z|>3=0 verdict=INCONCLUSIVE
mid L12: delta_pair=27.8327 p99=35.8102 ratio_ctrl=0.9726 n|z|>3=0 verdict=INCONCLUSIVE
late L19: delta_pair=90.9880 p99=121.4721 ratio_ctrl=0.9564 n|z|>3=1 verdict=INCONCLUSIVE
# Mesure par couche -- CONTROL (permutation des input embeddings).
res_c = {}
for tag in layers:
res_c[tag] = measure_humor_differential(
ROOT / "traces" / f"humor35b_qwen35-2b-base_layer{layers[tag]}of{n_layers_model}_control.npz",
pairs, n_draws=n_draws, seed=seed,
)
r = res_c[tag]
print(f"{tag:5s} L{layers[tag]:2d}: delta_pair={r['delta_pair']:.4f} p99={r['null_p99']:.4f} "
f"ratio_ctrl={r['ratio_vs_ctrl']:.4f} n|z|>3={r['n_features_over3']} verdict={r['verdict']}")early L 4: delta_pair=18.8027 p99=21.9155 ratio_ctrl=0.9913 n|z|>3=0 verdict=INCONCLUSIVE
mid L12: delta_pair=22.0069 p99=27.6215 ratio_ctrl=0.9622 n|z|>3=0 verdict=INCONCLUSIVE
late L19: delta_pair=70.0960 p99=89.7911 ratio_ctrl=0.9690 n|z|>3=0 verdict=INCONCLUSIVE
# Identite des top features a travers la profondeur : Jaccard top-5 trained entre couches,
# et trained-vs-control par couche (la disjonction constatee en tranche 1 au mid).
def top_ids(r, n=5):
return {fid for fid, _ in r["top_features"][:n]}
def jaccard(a, b):
return len(a & b) / len(a | b) if (a or b) else 0.0
tags = list(layers)
print("Jaccard top-5 trained, entre couches :")
for i in range(len(tags)):
for j in range(i + 1, len(tags)):
print(f" {tags[i]:5s} vs {tags[j]:5s}: {jaccard(top_ids(res_t[tags[i]]), top_ids(res_t[tags[j]])):.2f}")
print("Jaccard top-5 trained vs control, par couche :")
for tag in tags:
print(f" {tag:5s}: {jaccard(top_ids(res_t[tag]), top_ids(res_c[tag])):.2f}")
print("Top-5 trained par couche :")
for tag in tags:
print(f" {tag:5s}: {[fid for fid, _ in res_t[tag]['top_features'][:5]]}")Jaccard top-5 trained, entre couches :
early vs mid : 0.00
early vs late : 0.00
mid vs late : 0.00
Jaccard top-5 trained vs control, par couche :
early: 0.00
mid : 0.00
late : 0.00
Top-5 trained par couche :
early: [27906, 15486, 28565, 8554, 23110]
mid : [6677, 12444, 23779, 18944, 2919]
late : [13173, 32207, 17506, 24480, 14080]
Lecture 3 – Ce que les trois couches disent ensemble
Trois constats, jambe par jambe :
- Amplitude et null grandissent ensemble. delta_pair passe de 18.5 (early) a 27.8 (mid) a 91.0 (late) – l’echelle du residu croit avec la profondeur – mais le null croise fait exactement de meme (22.9 -> 35.8 -> 121.5) : le ratio_vs_ctrl reste dans [0.95, 0.97] a TOUTES les profondeurs. Le differentiel humour->unfun ne se separe jamais du bruit permute, ni tot ni tard.
- Une seule feature depasse |z|>3 sur toute la experience (trained late, 1 feature – seuil de verdict a >=3 non atteint ; le controle de la meme couche en a 0). Isolee, elle ne soutient pas une lecture systematique.
- Top-5 disjoints partout : Jaccard 0.00 entre les trois couches trained, et 0.00 trained-vs-control a chaque couche. Aucune signature ne se deplace avec la profondeur ; a chaque etage, le SAE lit des features differentes, et differentes de ce que lit la permutation.
Verdict etendu : INCONCLUSIVE stable a toutes les profondeurs mesurees. La tranche 1 ne mesurait qu’un etage ; cette tranche borne desormais early/mid/late : aucun etage du residual stream (4, 12, 19 de 24), lu par ce SAE W32K-L0_50, ne montre de shift systematique humour->unfun au-dessus du bruit. La croissance monotone de l’amplitude est un effet d’echelle du residu, pas un signal – c’est precisement ce que le null croise controle.
Note de provenance : les traces portent le manifeste historique (champ instrument inferre au chargement, avertissement de retro-compatibilite du contrat v1) – l’extracteur GPU local ne pose pas encore le champ canonique ; lecture validee par validate_manifest(strict=False).
# EXERCICE 1 -- delta par paire a la couche late (dispersion cachee par la moyenne)
#
# delta_pair est une MOYENNE sur 30 paires. Une minorite de paires tres discriminees
# peut survivre sous une moyenne tiree par la masse. Recalculez le delta par paire
# (norme L1 de la difference des vecteurs de zone moyennes humour vs unfun) pour
# la couche late, et rapportez la fraction des paires au-dessus de la mediane.
#
# Indice : la zone punchline commence apres le prefixe commun -- _zone_vec(entry, start, d_sae)
# fait deja ce travail pour une entree ; il faut la difference humour vs unfun par paire.
# Etape 1 : pour chaque paire, moyenne des vecteurs de zone des prompts humour puis unfun.
# Etape 2 : norme L1 de la difference, collectee sur les 30 paires.
# Etape 3 : mediane, fraction au-dessus, histogramme 10 bins.
late_tr = np.load(ROOT / "traces" / f"humor35b_qwen35-2b-base_layer{layers['late']}of{n_layers_model}_trained.npz", allow_pickle=False)
deltas_pairs = None # TODO etudiant : liste de 30 floats
frac_above = None # TODO etudiant : fraction > mediane
print("Exercice a completer")Exercice a completer
# EXERCICE 2 -- stabilite du classement : top-10 au lieu de top-5
#
# Le Jaccard top-5 est sensible au moindre echange de rang. Refaites la comparaison
# entre couches avec n=10 : le recouvrement grandit-il (classement stable mais etale),
# ou reste-t-il nul (chaque profondeur lit des features differentes) ?
#
# Indice : top_ids(res, n) accepte n ; il suffit de refaire les trois paires de couches.
# Etape 1 : Jaccard top-10 pour les trois paires (early-mid, early-late, mid-late).
# Etape 2 : comparer aux valeurs top-5 ci-dessus et conclure en une phrase.
jac10 = None # TODO etudiant : dict {(tag_a, tag_b): jaccard_top10}
print("Exercice a completer")Exercice a completer
# EXERCICE 3 -- le ratio_vs_ctrl se resserre-t-il avec la profondeur ?
#
# ratio_vs_ctrl compare le delta observe a celui du controle d'edition. En tranche 1
# (mid), il valait ~0.97 trained. Recensez ce ratio par couche (tableau a 3 lignes),
# et repondez : la profondeur rapproche-t-elle l'amplitude de celle d'un banal
# changement lexical, ou l'en eloigne-t-elle ?
#
# Indice : res_t[tag]['ratio_vs_ctrl'] contient deja la valeur par couche.
# Etape 1 : tableau (couche, ratio) pour trained.
# Etape 2 : une phrase d'interpretation -- tendance monotone ou non.
table_ratio = None # TODO etudiant : liste de tuples (tag, ratio)
print("Exercice a completer")Exercice a completer
Conclusion et voir aussi
Ce que cette tranche etablit : la stabilite (ou l’instabilite) du verdict INCONCLUSIVE de la tranche 1 a travers trois profondeurs du meme residu, sur le meme corpus et le meme protocole. Un INCONCLUSIVE stable a toutes les couches borne davantage qu’un INCONCLUSIVE ponctuel : aucune profondeur du residual stream, lue par ce SAE, ne montre de shift systematique humour->unfun au-dessus du bruit permute.
Ce qu’elle n’etablit pas : l’absence de signature humour dans le modele (d’autres lecteurs que le residu – MLP, attention, J-lens – restent hors de portee de cet appareil), et tout effet causal (phase interventionnelle = tranche suivante, cf #14035).
- ICT-35 (pilote) – banc humour sur sorties, hors interne.
- ICT-35b (tranche 1) – protocole, pre-enregistrement, verdict mid 12/24.
ict/humor_pairs.py– corpus, paires, mesure pre-registree (docstrings).- Issue #14035 – protocole complet et ecarts documentes.