ICT-43 — Calibration multi-échelle, Phase 0 : inventaire des SAE

Navigation : Index | 44 — Geometry of Truth >>

Épique #8236 (SAE + J-lens multi-échelle : forme et dynamique des catastrophes sous perturbations appariées). Cette série calibration précède le notebook d’expérience : elle rend la comparaison cross-échelle valide en contrôlant, avant toute expérience, ce qui doit l’être.

La Phase 0 établit la vérité-terrain des artefacts. Le body de l’issue annonçait des SAE pour « 0.7B, 2B, 4B, 8B, 27B, 35B-A3B » ; rien n’est supposé en amont — on mesure ici ce qui existe réellement sur le Hub, avec quelles largeurs, quelles couches couvertes, quel point d’accrochage. Les écarts mesurés contre l’annonce sont un résultat, pas un incident : ils redessinent le plan d’expérience (quelles tailles sont réellement comparables).

Conventions de la série : sorties committées (C.2), aucune erreur volontaire (C.1), documentation en français.

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.

import json
import os
import re
import urllib.request

import pandas as pd

# Toutes les requetes passent par ce helper : un User-Agent explicite et,
# si present dans l'environnement, le token HF (repos eventuellement gated).
# Le token ne s'affiche jamais (regle secrets : env uniquement).

def hf_get(url, as_json=True):
    headers = {"User-Agent": "ict43-phase0"}
    tok = os.environ.get("HF_TOKEN")
    if tok:
        headers["Authorization"] = f"Bearer {tok}"
    req = urllib.request.Request(url, headers=headers)
    with urllib.request.urlopen(req, timeout=30) as r:
        raw = r.read()
    return json.loads(raw.decode("utf-8")) if as_json else raw

print("helper HF pret")
helper HF pret

Phase 0a — Balayage du Hub : quels SAE existent vraiment ?

Le balayage énumère les dépôts de l’organisation Qwen dont l’identifiant contient sae (insensible à la casse), puis récupère pour chacun son config.json et la liste de ses fichiers. Les dépôts sont nommés SAE-Res-<modèle>-W<largeur>-L0_<cible> : deux régimes de sparsité (L0_50, L0_100) par modèle — chaque dépôt porte un seul régime.

def scan_sae_repos():
    """Enumere les repos SAE de l'org Qwen avec config + couches couvertes."""
    repos = [m["modelId"] for m in hf_get(
        "https://huggingface.co/api/models?author=Qwen&limit=1000")
        if "sae" in m["modelId"].lower()]
    rows = []
    for repo in sorted(repos):
        try:
            cfg = hf_get(f"https://huggingface.co/{repo}/resolve/main/config.json")
            info = hf_get(f"https://huggingface.co/api/models/{repo}")
            layers = sorted(int(m.group(1)) for s in info.get("siblings", [])
                            if (m := re.match(r"layer(\d+)\.sae\.pt$", s["rfilename"])))
            m = re.match(r"Qwen/SAE-Res-(.+)-W(\d+K)-L0_(\d+)$", repo)
            rows.append({
                "modele": m.group(1) if m else repo, "largeur": m.group(2) if m else "?",
                "l0_cible": int(m.group(3)) if m else None,
                "d_sae": cfg.get("d_sae"), "n_layers_cfg": cfg.get("num_layers"),
                "hook": cfg.get("hook_point"),
                "couches": f"{min(layers)}..{max(layers)} ({len(layers)})",
                "toutes_couches": len(layers) == cfg.get("num_layers"),
            })
        except Exception as e:
            rows.append({"modele": repo, "erreur": f"{type(e).__name__}"})
    return pd.DataFrame(rows)

inventaire = scan_sae_repos()
print(f"{len(inventaire)} repos SAE mesures sur le Hub")
inventaire
14 repos SAE mesures sur le Hub
modele largeur l0_cible d_sae n_layers_cfg hook couches toutes_couches
0 Qwen3-1.7B-Base 32K 100 32768 28 resid_post 0..27 (28) True
1 Qwen3-1.7B-Base 32K 50 32768 28 resid_post 0..27 (28) True
2 Qwen3-30B-A3B-Base 128K 100 131072 48 resid_post 0..47 (48) True
3 Qwen3-30B-A3B-Base 32K 50 32768 48 resid_post 0..47 (48) True
4 Qwen3-8B-Base 64K 100 65536 36 resid_post 0..35 (36) True
5 Qwen3-8B-Base 64K 50 65536 36 resid_post 0..35 (36) True
6 Qwen3.5-27B 80K 100 81920 64 resid_post 0..63 (64) True
7 Qwen3.5-27B 80K 50 81920 64 resid_post 0..63 (64) True
8 Qwen3.5-2B-Base 32K 100 32768 24 resid_post 0..23 (24) True
9 Qwen3.5-2B-Base 32K 50 32768 24 resid_post 0..23 (24) True
10 Qwen3.5-35B-A3B-Base 128K 100 131072 40 resid_post 0..39 (40) True
11 Qwen3.5-35B-A3B-Base 32K 50 32768 40 resid_post 0..39 (40) True
12 Qwen3.5-9B-Base 64K 100 65536 32 resid_post 0..31 (32) True
13 Qwen3.5-9B-Base 64K 50 65536 32 resid_post 0..31 (32) True

Lecture du balayage — quatre écarts mesurés contre l’annonce de l’issue :

  1. Pas de SAE 0.7B ni 4B : la liste annoncée (0.7B, 2B, 4B, 8B, 27B, 35B-A3B) ne correspond pas au terrain. Les tailles réellement couvertes forment un ensemble différent mais comparable : 1.7B et 8B (Qwen3) ; 2B, 9B, 27B, 35B-A3B (Qwen3.5) ; 30B-A3B (Qwen3). Soit 7 tailles effectives au lieu de 6 annoncées.
  2. Deux régimes de sparsité (L0_50 et L0_100) existent pour chaque dépôt — l’issue ne les mentionnait pas ; le choix du régime devient un paramètre d’expérience à documenter.
  3. Le 27B n’existe qu’en version instruct (Qwen3.5-27B, sans suffixe -Base) : un mismatch base/instruct à inscrire dans les limites de la comparaison.
  4. En régime L0_100, les deux A3B (30B et 35B-A3B) n’existent qu’en W128K — pattern croisé {L0_50→W32K, L0_100→W128K} strictement identique sur les deux tailles : une structure de publication du Hub, pas un retrait spécifique. Le fait mesuré derrière ce point : le dépôt W32K-L0_100 du 30B-A3B est absent de l’énumération des 14 dépôts du Hub (jamais publié ou retiré) — la ligne W32K de la table ci-dessus est son régime L0_50, bien vivant.

Point positif inattendu : chaque dépôt couvre toutes les couches du modèle (layer0.sae.pt … layer{N-1}.sae.pt), pas seulement le mi-réseau — l’appariement de profondeur n’est pas contraint par la disponibilité.

# d_model des modeles de base : les configs Qwen3.5 sont omni (champ text_config imbrique),
# les configs Qwen3 planes. On mesure les deux formes sans rien supposer.
BASES = {
    "Qwen3-1.7B-Base": "Qwen/Qwen3-1.7B-Base",
    "Qwen3-8B-Base": "Qwen/Qwen3-8B-Base",
    "Qwen3-30B-A3B-Base": "Qwen/Qwen3-30B-A3B-Base",
    "Qwen3.5-2B-Base": "Qwen/Qwen3.5-2B-Base",
    "Qwen3.5-9B-Base": "Qwen/Qwen3.5-9B-Base",
    "Qwen3.5-27B": "Qwen/Qwen3.5-27B",
    "Qwen3.5-35B-A3B-Base": "Qwen/Qwen3.5-35B-A3B-Base",
}

def d_model_de(repo):
    cfg = hf_get(f"https://huggingface.co/{repo}/resolve/main/config.json")
    t = cfg.get("text_config", cfg)          # omni -> text_config ; classique -> plat
    return t["hidden_size"], t["num_hidden_layers"]

bases = pd.DataFrame(
    [{"modele": k, "d_model": d_model_de(v)[0], "n_layers": d_model_de(v)[1]}
     for k, v in BASES.items()])
bases
modele d_model n_layers
0 Qwen3-1.7B-Base 2048 28
1 Qwen3-8B-Base 4096 36
2 Qwen3-30B-A3B-Base 2048 48
3 Qwen3.5-2B-Base 2048 24
4 Qwen3.5-9B-Base 4096 32
5 Qwen3.5-27B 5120 64
6 Qwen3.5-35B-A3B-Base 2048 40

Phase 0b — Appariement des échelles : facteur d’expansion et profondeur relative

Deux quantités conditionnent la validité de la comparaison cross-échelle :

  • le facteur d’expansion d_sae / d_model : s’il diffère d’une taille à l’autre, les dictionnaires n’ont pas la même résolution relative et les ordres-paramètres ne sont pas directement comparables ;
  • la profondeur relative d’ancrage round(0.5 · n_layers) : l’issue impose un ancrage à mi-réseau apparié (le pilote ICT-42 utilisait déjà layer12of24 au 2B et layer14of28 au 1.7B — la même règle, avant la lettre).
# Jointure inventaire (regime L0_100, une ligne par taille) x d_model.
# Les lignes d'erreur du balayage n'ont pas de l0_cible : le filtre les ecarte.
l0100 = inventaire[inventaire["l0_cible"] == 100]

appariement = (
    l0100.drop_duplicates(subset=["modele"])
    .merge(bases, on="modele", how="inner")
    .assign(expansion=lambda d: d["d_sae"] // d["d_model"],
            couche_ancre=lambda d: (0.5 * d["n_layers"]).round().astype(int))
    [["modele", "d_model", "n_layers", "d_sae", "expansion",
      "couche_ancre", "couches", "hook"]]
    .reset_index(drop=True)
)
appariement
modele d_model n_layers d_sae expansion couche_ancre couches hook
0 Qwen3-1.7B-Base 2048 28 32768 16 14 0..27 (28) resid_post
1 Qwen3-30B-A3B-Base 2048 48 131072 64 24 0..47 (48) resid_post
2 Qwen3-8B-Base 4096 36 65536 16 18 0..35 (36) resid_post
3 Qwen3.5-27B 5120 64 81920 16 32 0..63 (64) resid_post
4 Qwen3.5-2B-Base 2048 24 32768 16 12 0..23 (24) resid_post
5 Qwen3.5-35B-A3B-Base 2048 40 131072 64 20 0..39 (40) resid_post
6 Qwen3.5-9B-Base 4096 32 65536 16 16 0..31 (32) resid_post

Lecture de l’appariement. Trois familles se dégagent :

  • expansion 16× partout sauf 64× pour les deux A3B en W128K (30B-A3B et 35B-A3B) — les deux seules tailles MoE à grande largeur portent un dictionnaire 4× plus résolu par dimension. Une comparaison qui mélange 16× et 64× doit le déclarer (le pilote 2-tailles était homogène 16×).
  • l’ancrage mi-réseau tombe toujours sur un entier (12, 14, 16, 18, 20, 24, 32) et chaque dépôt couvre toutes les couches : la règle round(0.5 · n_layers) est applicable partout sans exception.
  • l’architecture hybride des Qwen3.5 (attention linéaire : pleine = 3:1 sur toutes leurs couches, lu dans layer_types des configs publiées — a priori d’architecture, non mesuré dans ce notebook) diffère structurellement des Qwen3 denses — la profondeur relative contrôle la position dans le réseau, pas l’homogénéité du mécanisme d’attention traversé. À inscrire dans les limites.

Le pilote suivant (Phase 1) fittera la J-lens par taille à cette couche appariée ; la fidélité de reconstruction des SAE (MSE, variance expliquée — l’autre moitié de la Phase 0) exige des passes avant GPU et fait l’objet de la Phase 0c.

Exercices

Trois exercices préparent la Phase 1. Ils se complètent sans dépendre l’un de l’autre ; les squelettes s’exécutent (C.1).

# Exercice 1 : URL d'artefact verifiee.
# Ecrire couche_url(modele, couche) qui construit l'URL du .sae.pt de la couche
# demandee pour le depot L0_100, et verifie sa disponibilite par une requete HEAD
# (code 200). Retourner (url, disponible). Attention : le 30B-A3B n'existe qu'en W128K.
# Indice : appariement contient d_sae par modele, le nom du depot suit
# SAE-Res-<modele>-W<largeur>-L0_100 avec largeur = d_sae en K (32768 -> 32K, ...).

def couche_url(modele, couche):
    # TODO etudiant
    print("Exercice a completer")
    return None

# TEMOIN attendu : couche_url("Qwen3.5-2B-Base", 12) doit rendre une URL disponible.
resultat_ex1 = None  # TODO etudiant
resultat_ex1
# Exercice 2 : grille de comparaison homogene en expansion.
# Construire grille_16x() : le sous-ensemble d'appariement ou toutes les tailles
# portent expansion == 16 (comparaison homogene), trie par n_layers croissant,
# avec la colonne couche_ancre. Verifier que la grille compte 5 tailles.
# Etape 1 : filtrer sur expansion. Etape 2 : trier. Etape 3 : verifier le compte.

def grille_16x(tableau):
    # TODO etudiant
    print("Exercice a completer")
    return None

resultat_ex2 = None  # TODO etudiant
resultat_ex2
# Exercice 3 : cout de telechargement d'une experience.
# Pour la grille 16x, estimer le nombre d'artefacts SAE a telecharger pour la
# Phase 1 (J-lens) si l'on veut, par taille, la couche d'ancrage ET ses deux
# voisines (ancre-1, ancre, ancre+1). Retourner un DataFrame (modele, couches_voulues,
# n_artefacts). bornes : la couche 0 et la couche n_layers-1 existent, jamais hors bornes.
# Indice : appariement["n_layers"] donne la borne haute par modele.

def cout_phase1(tableau):
    # TODO etudiant
    print("Exercice a completer")
    return None

resultat_ex3 = None  # TODO etudiant
resultat_ex3

Limites de la Phase 0

  1. Fidélité de reconstruction non mesurée ici : l’inventaire établit l’existence et la géométrie des dictionnaires (largeurs, couches, hook), pas leur qualité (MSE, variance expliquée). Cette mesure exige des passes avant sur GPU-2 (garde-fous #8236 : CUDA_VISIBLE_DEVICES=2, un job sérialisé, abort thermique > 85 °C) — c’est l’objet de la Phase 0c, à livrer avant toute expérience.
  2. 27B en instruct seulement : le dépôt Qwen3.5-27B n’a pas de jumeau -Base. Toute comparaison impliquant le 27B mélange un modèle post-entraîné avec des modèles de base — à déclarer dans le plan d’expérience ou à exclure cette taille des comparaisons sensibles au post-training.
  3. Hétérogénéité d’expansion : 16× (5 tailles denses) contre 64× (2 tailles A3B en W128K). La comparaison stricte se fait en expansion homogène ; l’analyse d’échelle A3B est un régime séparé.
  4. Qwen3.5 hybrides (attention linéaire 3:1) : la profondeur relative n’égalise pas le mécanisme traversé. Le facteur de structure (dense vs MoE vs hybride) est un covariant à tracer, pas à ignorer. Le ratio 3:1 est lui-même un a priori (config layer_types), pas une mesure de ce notebook.
  5. Inventaire daté : mesuré au Hub tel qu’il existe au moment de l’exécution — le dépôt W32K-L0_100 du 30B-A3B est ainsi absent de l’énumération des 14 dépôts (jamais publié ou retiré), son W32K-L0_50 restant la ligne 3 de la table. Toute re-exécution re-mesure.

Suite (Phase 1) : fit J-lens par taille à couche_ancre, report qualité de sonde (accuracy/AUC control vs trained). See #8236.

Retour au sommet