# Parameters
nb_name = "ICT-35b-HumorCausalPairs-SAE-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.

ICT-35b – HumorCausalPairs-SAE : paires minimales, substrat SAE (#14035, tranche 1)

Le pilote ICT-35 a mesure l’humour sur un substrat HLS lexical (sklearn, 6 dimensions) : verdict INCONCLUSIVE borne. Ce notebook passe au substrat SAE (sparse autoencoder Qwen-Scope) avec un design experimental plus fin : des paires minimales ou seule la punchline varie.

Le design pre-regle (issue #14035, commentaire c.5743321902) :

  • pour chaque blague, trois textes partagent le meme setup token a token : le texte humour commite, sa punchline neutralisee (unfun : continuation coherente qui tue l’incongruite en gardant le registre), et un controle de distance d’edition (ctrl_edit : autre continuation coherente non-humorale de taille comparable) ;
  • la zone mesuree est la zone d’edition (tokens apres le prefixe commun exact) ;
  • trois jambes statistiques independantes : delta_pair (distance L1 humour vs unfun) confrontee a un null croise (les unfun brasses entre paires, 2048 tirages, ecart c.5743498870), delta_ctrl (controle de distance d’edition), et les z de features (flip de signe intra-paire).

L’ecart c.5743512349 est documente avant interpretation : le critere delta_pair > p99 est mal dirige pour une hypothese de shift systematique (une composante constante est invisible au brassage inter-paires). Le verdict repose donc sur l’ensemble des jambes, pas sur ce seul seuil.

# -*- coding: utf-8 -*-
# Setup. Reproduction du corpus humour (meme geste que le pilote ICT-35) :
# les cellules code [26..33] de la section echelle du banc consolide
# (GameTheory-18c, absorption #14032) sont
# executees dans un namespace isole, sans editer le notebook source.
# La reproduction exige OPENROUTER_API_KEY dans l'environnement (les cellules
# endpoint du banc la verificent ; aucune n'appelle l'API ici).
from collections import Counter
from pathlib import Path
import json
import sys

import numpy as np
import pandas as pd

ROOT = Path.cwd()
if str(ROOT) not in sys.path:
    sys.path.insert(0, str(ROOT))

from ict.humor_pairs import (
    build_pairs, build_prompts_json, banc_humour_path, label_distribution,
    load_corpus_dur, measure_humor_differential, validate_pairs,
)

corpus = load_corpus_dur()
dist = label_distribution(corpus)
print(f"banc humour : {banc_humour_path().name}")
print(f"CORPUS_DUR : {len(corpus)} instances")
for lab, n in dist.most_common():
    print(f"  {lab:32s} {n}")
[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] downloaded 558033 bytes -> argumentum_scenarii.csv
[fetch] upstream master @1b4c831bfc : docs(regles): #458 grain 9 -- outil portable, controle de citation mot pour mot,
[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
banc humour : GameTheory-18c-Humour-Banc-Python.ipynb
CORPUS_DUR : 120 instances
  humour_reussi                    48
  rien                             38
  rire_sans_recadrage              14
  recadrage_sans_rire              13
  offensif_compris_non_partage     7

Lecture 1 – Le corpus reproduit : 120 instances, 48 humour_reussi

La reproduction est deterministe (le banc humour (18c) pose random.seed(42) lui-meme) et cwd-independante : le chargeur resout le cache Argumentum (argumentum_scenarii.csv) dans le dossier ICT-Series puis GameTheory, jamais via un fetch reseau silencieux.

Les 30 paires de la tranche 1 se construisent sur les blagues manuelles (blague-manuelle, 30 instances) et deux one-liners edges (edge-case-curated) ; les 60 instances Argumentum du banc sont exclues du pairing : leur champ texte est tronque a ~150 caracteres par le builder du banc humour (18c) – la punchline n’y est jamais commitee (mesure firsthand, tranche 1).

# Paires minimales : 30 entrees (28 blagues manuelles + 2 one-liners edge).
# Chaque paire : setup verbatim | punchline verbatim | punchline neutralisee |
# controle de distance d'edition. Re-verifiees au chargement (span unique,
# suffixe exact, prefixe commun).
pairs = build_pairs(corpus)
validate_pairs(pairs)  # plancher protocolaire : >= 30
payload = build_prompts_json(pairs)
print(f"{len(pairs)} paires validees ; prompts-json : "
      + ", ".join(f"{s}={len(v)}" for s, v in payload.items()))

apercu = pd.DataFrame([
    {"id": p["id"],
     "n_car_setup": len(p["setup"]), "n_car_humour": len(p["humour"]),
     "n_car_unfun": len(p["unfun"]), "n_car_ctrl": len(p["ctrl_edit"])}
    for p in pairs
])
apercu.head(8)
30 paires validees ; prompts-json : humour=30, unfun=30, ctrl_edit=30
id n_car_setup n_car_humour n_car_unfun n_car_ctrl
0 joke-p02 57 84 99 98
1 joke-p03 40 106 102 102
2 joke-p04 57 75 89 92
3 joke-p05 56 90 82 84
4 joke-p06 52 81 81 90
5 joke-p07 45 96 77 79
6 joke-p08 45 73 87 73
7 joke-p09 50 86 88 90

Lecture 2 – Trente paires, trois exclusions documentees

  • joke-p01 et joke-p17 sont exclues : leurs textes committes dans le banc humour (18c) sont malformes (p01 tronquee au milieu du refrain, p17 saut de ligne inline) – la contrainte texte == setup + punchline ne peut pas y etre verifiee ;
  • edge-04 et edge-05 (one-liners Chapman/Cooper, label recadrage_sans_rire) completent a 30 : heterogeneite de label documentee – le critere du pairing est l’incongruite portee par le texte, pas le label comportemental ;
  • les variantes unfun et ctrl_edit different de humour seulement dans la zone punchline : le prefixe commun exact (verifie token a token sur les traces a la cellule de mesure) garantit que la zone d’edition est disjoncte du setup.

Le controle ctrl_edit repond a l’objection la plus forte contre un design minimal-pair : si l’effet humour etait une consequence mecanique de n’importe quelle edition de cette taille (tokens finaux differents, longueur proche), alors delta_ctrl egalerait delta_pair et l’interpretation “perte d’incongruite” serait indue.

Captures GPU – provenance et pre-enregistrement

Les deux traces sont produites par scripts/extract_sae_traces.py (seul composant torch de la strate, le package ict/ reste numpy-only) sur la machine po-2024 (RTX 3070) :

trained control
modele Qwen/Qwen3.5-2B-Base idem
SAE Qwen/SAE-Res-Qwen3.5-2B-Base-W32K-L0_50 (d_sae=32768, k=50) idem
couche 12/24 (layer_frac 0.5217) idem
variant encodage officiel Qwen-Scope (pre = h @ W_enc.T + b_enc ; relu ; topk(50)) permutation seeedee des lignes d’input embeddings (le SAE lit un residu dont l’anatomie des positions est detruite)
date / seed 2026-09-19T16:05Z, seed 42 idem

Le protocole statistique etait pre-registre (c.5743321902) avant toute mesure, avec deux ecarts documentes sur l’issue avant interpretation : la correction du null (degenere pour L1 sous swap de labels, remplace par le null croise, c.5743498870) et la requalification du seuil delta_pair > p99 (c.5743512349).

# Chargement de la trace trained : manifeste, sparsite reelle (L0 mesure,
# pas suppose).
TRACE_T = ROOT / "traces" / "humor35b_qwen35-2b-base_layer12of24_trained.npz"
TRACE_C = ROOT / "traces" / "humor35b_qwen35-2b-base_layer12of24_control.npz"

data_t = np.load(TRACE_T, allow_pickle=False)
meta_t = json.loads(str(data_t["__meta__"]))
n_entries = sum(1 for k in data_t.files if k.endswith("__topk_ids"))
l0 = np.concatenate([
    (data_t[k] > 0).sum(axis=1) for k in data_t.files if k.endswith("__topk_vals")
]).mean()
print(f"trained : {meta_t['model']} x {meta_t['sae_repo']}")
print(f"  layer {meta_t['layer']}/{meta_t['n_layers']} (frac {meta_t['layer_frac']:.4f}), "
      f"d_sae={meta_t['d_sae']}, k={meta_t['k']}, seed={meta_t['seed']}")
print(f"  {n_entries} prompts ({sum(meta_t['prompt_sets'].values())} attends), "
      f"{meta_t['n_tokens_total']} tokens au total")
print(f"  L0 mesure = {l0:.2f} features actives par token (sur k={meta_t['k']})")
trained : Qwen/Qwen3.5-2B-Base x Qwen/SAE-Res-Qwen3.5-2B-Base-W32K-L0_50
  layer 12/24 (frac 0.5217), d_sae=32768, k=50, seed=42
  90 prompts (90 attends), 1868 tokens au total
  L0 mesure = 50.00 features actives par token (sur k=50)

Lecture 3 – 90 prompts, 1868 tokens, L0 = 50.00 mesure

Chaque paire contribue trois prompts (humour, unfun, ctrl_edit) : 30 x 3 = 90 entrees. La sparsite mesuree est exactement k=50 a chaque token : le relu du SAE top-k ne tue aucune des 50 plus grandes activations sur ces prompts, et la densification operee par la couche de mesure est exacte (une feature hors top-50 vaut exactement zero par construction), sans troncature ni approximation.

La mesure pre-registree (measure_humor_differential) s’applique telle quelle : prefixe commun calcule sur les tokens reels (pas sur les caracteres), zone = tokens de punchline, L1 sur les vecteurs de zone moyenne, null croise a 2048 tirages, z de features par flip de signe intra-paire, seed 42.

# Mesure pre-registree -- trace TRAINED.
res_t = measure_humor_differential(TRACE_T, pairs)
for k in ("delta_pair", "delta_ctrl", "null_p99", "ratio_vs_ctrl",
          "n_features_over3", "verdict"):
    if isinstance(res_t[k], float):
        print(f"{k:18s} {res_t[k]:.4f}")
    else:
        print(f"{k:18s} {res_t[k]}")
print("top-5 |z| :", [(f, round(z, 2)) for f, z in res_t["top_features"][:5]])
delta_pair         27.8327
delta_ctrl         28.6169
null_p99           35.8102
ratio_vs_ctrl      0.9726
n_features_over3   0
verdict            INCONCLUSIVE
top-5 |z| : [(6677, 2.62), (12444, 2.59), (23779, -2.58), (18944, 2.54), (2919, -2.52)]

Lecture 4 – Verdict TRAINED : INCONCLUSIVE, jambe par jambe

  • delta_pair = 27.83 contre un null croise a p99 = 35.81 : la distance humour/unfun est sous le 99e centile du null – les paires ne sont pas plus eloignees que des unfun re-brasses entre paires ;
  • ratio_vs_ctrl = 0.97 : la punchline neutralisee n’est pas plus proche du texte humour que ne l’est le controle de distance d’edition (28.62) – aucune specificite de la perte d’incongruite ;
  • 0 features |z| > 3 (max : feature 6677, z = +2.62) : aucun shift systematique de direction constante dans l’espace des 32768 features.

Les trois jambes convergent : a ce couple modele x SAE x couche (Qwen3.5-2B, W32K, residu 12/24), la suppression de l’incongruite humorale ne produit pas de deplacement mesurable de la representation moyenne de zone. C’est un negatif honnete, pas un echec de protocole : le protocole etait falsifiable et reste falsifiable (tranches suivantes).

# Mesure pre-registree -- trace CONTROL (SAE lisant un residu a positions permutees).
res_c = measure_humor_differential(TRACE_C, pairs)
for k in ("delta_pair", "delta_ctrl", "null_p99", "ratio_vs_ctrl",
          "n_features_over3", "verdict"):
    if isinstance(res_c[k], float):
        print(f"{k:18s} {res_c[k]:.4f}")
    else:
        print(f"{k:18s} {res_c[k]}")
print("top-5 |z| :", [(f, round(z, 2)) for f, z in res_c["top_features"][:5]])

top_t = {f for f, _ in res_t["top_features"]}
top_c = {f for f, _ in res_c["top_features"]}
print(f"recouvrement top-10 trained/control : {len(top_t & top_c)} features communes")
delta_pair         22.0069
delta_ctrl         22.8715
null_p99           27.6215
ratio_vs_ctrl      0.9622
n_features_over3   0
verdict            INCONCLUSIVE
top-5 |z| : [(5680, -2.94), (27159, -2.47), (31556, 2.38), (19573, -2.37), (12486, 2.33)]
recouvrement top-10 trained/control : 0 features communes

Lecture 5 – Le controle clot le debat sur les z residuels

Le controle (permutation seeedee des lignes d’input embeddings, qui detruit l’anatomie des positions sans changer la loi des activations) rend lui aussi INCONCLUSIVE : delta_pair 22.01 sous p99 27.62, ratio 0.96, 0 features |z| > 3.

L’observation decisive : le top-5 du trained (6677, 12444, 23779, 18944, 2919 ; |z| <= 2.62) et celui du control (5680, 27159, 31556, 19573, 12486 ; |z| <= 2.94) sont disjoints, et l’amplitude maximale du trained est sous celle du bruit permute. Les z residuels du trained sont donc interchangeables avec ceux qu’on obtient sans aucune structure linguistique residuelle : il n’y a pas de “presque-signal” a sauver.

Ce que ce negatif borne – et ce qu’il ne borne pas

Borne : au couple Qwen3.5-2B-Base x SAE-W32K x residu 12/24, avec un zone-mean sur la punchline et n=30, la perte d’incongruite humorale est invisible dans les deplacements systematiques de features comme dans la distance globale.

Ne borne PAS : (i) les autres couches (l’incongruite peut etre resolue plus tot ou plus tard dans la pile) ; (ii) le couple 9B/W64K (capacite et largeur de dictionnaire x2) ; (iii) les effets token-level non stationnaires qu’un zone-mean moyenne a zero (le token de resolution vs les tokens de setup de la punchline) ; (iv) les differences de trajectoire (l’ordre des activations au fil de la zone), que la distance L1 sur moyenne ne voit pas. Ces quatre axes sont les tranches 2+ de #14035.

# EXERCICE 1 -- delta par paire : la dispersion cachee derriere la moyenne
#
# delta_pair = 27.83 est une MOYENNE sur 30 paires : la dispersion par paire
# est invisible. Certaines paires pourraient porter un signal local que la
# moyenne dilue.
#
# Indice : reproduire la boucle de zones de measure_humor_differential
#          (ict/humor_pairs.py) avec ict.humor_pairs._common_prefix_len et
#          _zone_vec, puis np.abs(H - U).sum(axis=1) donne le delta par paire.
# Etape 1 : charger la trace trained (ict.sae_traces.load_traces) et
#           construire les matrices H et U [30, d_sae] des zones.
# Etape 2 : calculer le delta par paire, afficher les 5 paires au delta max
#           et les 5 au delta min (argsort, ids de paires).
# Etape 3 : tester la correlation delta x longueur de zone (nombre de tokens
#           apres le prefixe commun) : un delta purement surfacique croit
#           avec la longueur ; un delta d'incongruite, pas necessairement.

H = None
U = None
delta_par_paire = None

print("Exercice a completer")
Exercice a completer
# EXERCICE 2 -- zone reduite au DERNIER token de punchline
#
# La mesure pre-registree moyenne la zone ENTIERE (tous les tokens apres le
# prefixe commun). Hypothese tranches 2+ : l'effet, s'il existe, est porte par
# le token de resolution (souvent le dernier), qu'un zone-mean dilue.
#
# Indice : _zone_vec(entry, start, d_sae) accepte n'importe quel start ; pour
#          se limiter au dernier token : start = len(entry["tokens"]) - 1.
# Etape 1 : reconstruire H, U, C en zone dernier-token (meme boucle que
#           l'exercice 1, start different).
# Etape 2 : recalculer delta_pair et delta_ctrl sur ces vecteurs et les
#           comparer aux valeurs zone-entiere (27.83 / 28.62).
# Etape 3 : interpreter : le ratio delta_pair/delta_ctrl bouge-t-il vers une
#           specificite de l'incongruite, ou reste-t-il ~1 ?

H_last = None
U_last = None
delta_pair_last = None
delta_ctrl_last = None

print("Exercice a completer")
Exercice a completer
# EXERCICE 3 -- stabilite des top features trained vs control
#
# Le verdict global est INCONCLUSIVE, mais la question fine reste : les
# features les plus extremees du trained (6677, 12444, ...) sont-elles
# reproductibles, ou sont-elles un tirage de bruit ? Le control permet de
# trancher SANS GPU : comparez les deux tables de top features.
#
# Indice : res_t["top_features"] et res_c["top_features"] donnent chacun le
#          top-10 sous forme (id, z) ; la cellule de mesure control affiche
#          deja le recouvrement des deux top-10.
# Etape 1 : extraire les ids top-10 des deux mesures et leur intersection.
# Etape 2 : pour les 5 premiers ids du trained, retrouver leur z dans le
#           control via un dict {id: z} construit sur res_c["top_features"]
#           -- que constate-t-on ?
# Etape 3 : conclure en une phrase : que suffit-il pour qu'une feature du
#           trained merite une investigation de tranche 2 ?

ids_trained = None
ids_control = None
intersection = None

print("Exercice a completer")
Exercice a completer

Conclusion et voir aussi

Etabli (negatif borne). Sur 30 paires minimales humour/unfun/controle, au couple Qwen3.5-2B-Base x SAE-Res-W32K-L0_50 couche 12/24, la perte d’incongruite humorale ne deplace ni la distance globale (ratio 0.97 vs controle d’edition), ni les features individuelles (0 sur 32768 a |z| > 3, amplitude sous le bruit permute). Le protocole etait pre-registre avec deux ecarts documentes AVANT interpretation ; le negatif est donc interpretable, pas une absence de conclusion.

Tranches suivantes (#14035) : couches early/late, couple 9B/W64K, zone dernier-token (exercice 2), dynamique de trajectoire dans la zone.

  • #14035 – issue de reference : protocole falsifiable, pre-enregistrement (c.5743321902), ecarts c.5743498870 / c.5743512349
  • ICT-35-HumorCausalProbe-Pilot-Python – le pilote HLS lexical (verdict INCONCLUSIVE borne, meme honnetete)
  • ICT-21-SAETrajectoires-Python / ICT-21b-SAECalibration-Python – la strate SAE du pipeline #5101 et ses garde-fous (bf16, top-k)
  • GameTheory-18c-Humour-Banc-Python – le banc humour consolide (#14032) : CORPUS_DUR (120 instances annotees)
  • scripts/extract_sae_traces.py – l’extracteur GPU (seul composant torch) ; ict/humor_pairs.py – corpus, paires, mesure
Retour au sommet