Parlons de votre projet
Intelligence artificielle

Préentraîner un encodeur-décodeur par débruitage avant de normaliser des fiches

Un catalogue fictif contient des descriptions hétérogènes de pièces. Comparer trois normaliseurs sur les mêmes fiches réservées : règles, encodeur-décodeur aléatoire puis fine-tuné, et encodeur-décodeur préentraîné par débruitage puis fine-tuné.

Parcours pédagogique : comprendre le cas, construire, mesurer puis vérifier.

Un catalogue fictif contient des descriptions hétérogènes de pièces. La tâche finale transforme une description complète, parfois abrégée, en une ligne canonique. Avant cette tâche supervisée, un petit réseau apprend à reconstruire des segments retirés des fiches train.

Comparer trois normaliseurs sur les mêmes fiches réservées : règles, encodeur-décodeur aléatoire puis fine-tuné, et encodeur-décodeur préentraîné par débruitage puis fine-tuné. Afficher aussi une reconstruction de spans.

Télécharger le projet complet, révision r001

Le parcours du tutoriel, du cas concret à la vérification.

Deux tâches et deux signaux

Le débruitage retire des segments contigus d’une fiche. Le réseau reçoit les octets restants et des tokens sentinelles ; sa cible contient seulement les segments retirés, associés aux mêmes sentinelles. L’auto-supervision vient de la fiche initiale, sans annotateur. La tâche aval utilise une autre cible : une ligne canonique explicitement construite pour la normalisation. Ces deux NLL n’ont pas le même contenu cible ; leurs valeurs ne sont pas directement une mesure commune de qualité métier.

Le corpus original CatalogueSpan-v1 contient 600 familles de dix formulations, soit 6000 fiches. Les familles 0–419 sont train, 420–509 validation, 510–599 test : 4200/900/900 fiches. Une famille partage référence, matériau et diamètre. Les formulations, matériaux et courts patrons syntaxiques sont partagés entre partitions ; les références sont distinctes. Cela teste un transfert sur un catalogue artificiel, pas la découverte d’une grammaire entièrement nouvelle ni une généralisation à un catalogue réel.

Les formulations 0 et 5 donnent 840 paires train et 180 paires par partition réservée. Tous leurs champs sont présents. Les 60 cas incomplets, séparés, retirent un champ et testent un refus par règles avant génération. Le modèle n’est pas interrogé pour inventer la valeur cachée ; cette expérience ne prouve donc pas sa résistance aux hallucinations. Les règles couvrent les dix formats contrôlés et peuvent déjà être parfaites : si les mesures le confirment, leur simplicité reste un avantage réel.

Octets, spans et sentinelles

Le vocabulaire est fixe : octets UTF-8 0–255, PAD=256, BOS=257, EOS=258 et 32 sentinelles 259–290. Un caractère accentué peut occuper plusieurs octets et un span peut le couper. L’affichage utilise des fragments bytes explicites ; la restauration oracle reconstitue exactement les octets avant décodage, sans réparation Unicode.

Le nombre d’octets retirés est round(0,15 × longueur), borné entre 1 et longueur−1. Le nombre de spans est proche du nombre retiré divisé par trois, avec contraintes de non-vacuité. Deux compositions positives aléatoires répartissent les longueurs visibles et retirées ; on alterne visible puis retiré, et le dernier span atteint la fin de la fiche. La moyenne de trois est un objectif arrondi, pas une loi géométrique exacte. Ce choix de bord est une limite explicite. Chaque trou a sa sentinelle ; la cible termine par une sentinelle supplémentaire puis EOS. Plus de 31 trous est refusé.

L’encodeur reçoit BOS, l’entrée corrompue, EOS. Le décodeur reçoit BOS puis target[:-1] et prédit target, y compris sentinelles et EOS. PAD n’est jamais une cible active. La CE sélectionne les positions actives avant de sommer ; l’accumulation divise par le nombre exact de tokens cibles, sans moyenne incorrecte des moyennes de microbatches. Aucun segment retiré n’est remis dans l’entrée encodeur par le collator.

Les masques de validation/test sont figés par identifiant avant entraînement. Les masques train sont tirés dynamiquement avec un générateur explicite, dont l’état est sauvegardé. Les formulations réservées restent physiquement absentes du service learning ; il ne monte que les corruptions de validation, pas celles de test.

Trois chemins d’attention

Le réseau original contient trois blocs encodeur et trois blocs décodeur, largeur 192, six têtes et MLP 768 avec GELU. Chaque couche est allouée et initialisée indépendamment. Embedding d’entrée et projection de sortie réutilisent le même Parameter. Les positions apprises couvrent 256 tokens ; un dépassement est rejeté, jamais tronqué silencieusement. Pré-LayerNorm, résidus et dropout 0,1 sont visibles. Ce n’est pas l’architecture T5 : seule l’idée d’objectif par spans et sentinelles l’inspire.

L’auto-attention encodeur est bidirectionnelle et masque uniquement les clés PAD. L’auto-attention décodeur masque le futur et les clés PAD. La cross-attention consulte tout l’encodage visible, en masquant les PAD encodeur. Les batches utilisent un padding à droite, avec BOS présent. Changer une cible future ne doit pas changer les logits antérieurs ; changer une entrée encodeur future peut changer ses représentations antérieures. Ajouter du padding doit préserver loss et gradients à la tolérance FP32 annoncée.

Protocole d’apprentissage

Les graines 42 et 43 produisent deux chaînes indépendantes. Pour chacune, une initialisation est sauvegardée. La branche directe commence à ces poids ; la branche transférée reçoit les poids après 600 mises à jour de débruitage sur les seules 4200 fiches train. Les deux branches effectuent ensuite 300 mises à jour sur les mêmes 840 paires, dans le même ordre supervisé. Le checkpoint final est fixé ; la validation tous les 100 pas ne sélectionne pas une version meilleure.

AdamW met à jour tous les paramètres en FP32 CUDA, TF32 désactivé, weight_decay 0,01, norme des gradients bornée à 1. Le taux maximal vaut 0,0003 en préentraînement et 0,0001 en normalisation ; warm-up sur 10 % des pas puis décroissance linéaire vers 10 % du taux maximal. Une mise à jour traite 16 exemples, en deux microbatches de huit avec dénominateur token exact. Le sampler parcourt des permutations et peut traverser une frontière d’époque pour compléter un batch.

Chaque phase est réellement exécutée interrompue, continue puis reprise. Le préentraînement s’interrompt à 240 pas sur 600 ; le SFT à 120 sur 300. Modèle, AdamW, scheduler, curseur/permutation, RNG de corruption et RNG Python/NumPy/PyTorch CPU/CUDA doivent être identiques au résultat continu. Ces répétitions coûtent du calcul ; les durées des chemins continus sont séparées du temps total lecteur. Les fichiers .pt sont créés localement ; les poids JSON avec dimensions, taille et SHA permettent un audit sans exécuter de contenu pickle externe.

Évaluer librement

La reconstruction est évaluée avant/après préentraînement sur les mêmes 900 fiches test et masques figés, avec NLL par token cible et douze premières reconstructions libres illustratives. La normalisation est évaluée sur 180 sorties libres par branche et graine. Le décodage greedy autorise jusqu’à 128 nouveaux tokens ; seuls PAD et BOS sont supprimés du support. Les sentinelles ne sont pas supprimées pour forcer une ligne canonique : leur apparition est un échec visible de format. Les octets invalides, mauvais formats et troncatures restent au dénominateur.

La validité de complétion exige EOS et le format complet. L’exact match brut compare la ligne entière ; l’exactitude par champ compare les valeurs parsées, dont le diamètre entier. Une ligne numériquement correcte mais formatée avec des zéros initiaux peut avoir des champs corrects sans exact match brut. Une sortie atteignant 128 tokens échoue même si son préfixe paraît complet. La NLL aval utilise le teacher forcing et les mêmes cibles ; elle ne remplace pas l’évaluation libre.

Les tableaux indiquent séparément annotation, tokens traités et temps par phase. Les durées observées englobent validation et écriture du checkpoint dans le processus ; elles ne sont pas des mesures de temps de kernels CUDA isolés. Les ressources globales et l’overhead de reprise sont mesurés dans le parcours. Le transfert ajoute du calcul et n’est pas comparé à budget total égal.

Doubler les annotations

L’exercice prend quatre formulations train (0,2,5,7), soit 1680 paires, et réentraîne deux nouvelles branches depuis les mêmes états initiaux/directs ou préentraînés. Les 180 cas test, deux graines et 300 mises à jour restent fixes. Le nombre de passages sur chaque paire diminue : davantage de labels n’est pas davantage de pas. Le corpus non annoté et les poids de préentraînement restent inchangés et leurs SHA sont contrôlés. Une amélioration n’est pas imposée ; un transfert nul ou négatif reste dans les résultats. L’extension à calcul total égal demanderait une autre expérience, explicitement différente de celle-ci.

Créer les fiches et les partitions

Séparer famille et formulation

Invariant : 420/90/90 familles, 840/180/180 paires aval avant exclusions documentées.

Erreur fréquente : Une autre formulation de la même pièce apparaît dans test : partitionner les familles avant génération.

Fichier src/preentrainement_denoising/data.py

import hashlib, json, re
from pathlib import Path
from .config import save_json, sha

MATERIALS = ["aluminium", "acier", "cuivre", "nylon", "laiton", "bronze"]
ALIASES = ["alu", "ac", "cu", "nyl", "lait", "brz"]
FIELDS = ["reference", "materiau", "diametre_mm"]


def split_catalog(family):
    return "train" if family < 420 else "validation" if family < 510 else "test"


def canonical(values):
    return ";".join(key + "=" + str(values[key]) for key in FIELDS)


def render(values, formulation):
    ref = values["reference"]
    material = values["materiau"]
    diameter = values["diametre_mm"]
    alias = ALIASES[MATERIALS.index(material)] if material else "?"
    ref = ref or "?"
    material = material or "?"
    diameter = str(diameter) if diameter is not None else "?"
    patterns = [
        f"Piece {ref}, {material}, diametre {diameter} mm.",
        f"Reference {ref} : matiere {material} ; diametre {diameter} mm.",
        f"{material} / piece {ref} / diam. {diameter}mm",
        f"Diametre {diameter} mm ; reference {ref} ; materiau {material}.",
        f"Fiche piece={ref} ; mat={alias} ; D={diameter} mm",
        f"piece {ref} / {alias} / diam.{diameter}mm",
        f"Mat. {alias}, ref. {ref}, diametre={diameter}mm.",
        f"{ref} | matiere:{material} | diam:{diameter} mm",
        f"D={diameter}mm pour piece {ref} en {material}.",
        f"Catalogue : reference {ref}. Diametre {diameter} mm. Matiere {material}.",
    ]
    return patterns[formulation]


def generate_catalog():
    pages = []
    gold = []
    for family in range(600):
        values = {
            "reference": f"K{family:04d}",
            "materiau": MATERIALS[family % 6],
            "diametre_mm": 8 + 2 * ((family // 6) % 20),
        }
        for j in range(10):
            text = render(values, j)
            identifier = f"C{family:03d}-{j}"
            pages.append(
                {
                    "document_id": identifier,
                    "family": family,
                    "formulation": j,
                    "split": split_catalog(family),
                    "text": text,
                    "content_sha256": hashlib.sha256(text.encode()).hexdigest(),
                }
            )
            gold.append(
                {
                    "document_id": identifier,
                    "target": values.copy(),
                    "target_text": canonical(values),
                }
            )
    if len({r["text"].casefold() for r in pages}) != 6000:
        raise ValueError("Duplicate normalized catalogue record")
    missing = []
    for index in range(60):
        family = 510 + index
        field = FIELDS[index % 3]
        values = {
            "reference": f"K{family:04d}",
            "materiau": MATERIALS[family % 6],
            "diametre_mm": 8 + 2 * ((family // 6) % 20),
        }
        values[field] = None
        missing.append(
            {
                "document_id": f"M{index:03d}",
                "source_family": family,
                "missing_field": field,
                "text": render(values, index % 10),
            }
        )
    return pages, gold, missing


def select_labeled_pairs(pages, gold, split, formulations):
    byid = {r["document_id"]: r for r in gold}
    return [
        {
            **p,
            "target": byid[p["document_id"]]["target"],
            "target_text": byid[p["document_id"]]["target_text"],
        }
        for p in pages
        if p["split"] == split and p["formulation"] in formulations
    ]


def prepare(config):
    root = Path("data/generated")
    root.mkdir(parents=True, exist_ok=True)
    if (root / "manifest.json").exists():
        raise ValueError("Preserve frozen catalogue; use a new extraction")
    pages, gold, missing = generate_catalog()
    save_json(root / "input/pages.json", pages)
    save_json(root / "labels/gold.json", gold)
    save_json(root / "input/invalid.json", missing)
    audit = {}
    for split in ["train", "validation", "test"]:
        selected = [p for p in pages if p["split"] == split]
        save_json(root / f"{split}/pages.json", selected)
        pairs = select_labeled_pairs(pages, gold, split, config["labeled_formulations"])
        audit[split] = {
            "documents": len(selected),
            "families": len({p["family"] for p in selected}),
            "labeled_pairs": len(pairs),
        }
        if split != "test":
            save_json(root / f"labels/{split}.json", pairs)
    exercise = select_labeled_pairs(
        pages, gold, "train", config["exercise_labeled_formulations"]
    )
    save_json(root / "labels/train-pairs.json", exercise)
    from .corruption import corrupt_with_sentinels
    import random

    frozen = [
        {
            "document_id": p["document_id"],
            **corrupt_with_sentinels(
                list(p["text"].encode()),
                random.Random(420000 + i),
                config["noise_density"],
                config["mean_span_length"],
            ),
        }
        for i, p in enumerate(pages)
        if p["split"] != "train"
    ]
    save_json(root / "input/pool.json", frozen)
    validation_ids = {p["document_id"] for p in pages if p["split"] == "validation"}
    save_json(
        root / "validation/corruptions.json",
        [r for r in frozen if r["document_id"] in validation_ids],
    )
    manifest = {
        "dataset": "CatalogueSpan-v1",
        "rights": "CC0-1.0",
        "seed": config["seed"],
        "split_audit": audit,
        "exercise_labeled_pairs": len(exercise),
        "missing_cases": len(missing),
        "fixed_evaluation_corruptions": len(frozen),
        "normalized_duplicates": 0,
        "files": {str(f.relative_to(root)): sha(f) for f in root.rglob("*.json")},
    }
    save_json(root / "manifest.json", manifest)
    save_json("outputs/data-audit.json", manifest)
    return audit

Coder la corruption par spans

Sentinelles et cible auto-supervisée

Invariant : La reconstruction oracle rétablit tous les octets de la fiche.

Erreur fréquente : Deux trous partagent la même sentinelle : allouer des tokens uniques et vérifier la capacité.

Fichier src/preentrainement_denoising/corruption.py

import random

PAD = 256
BOS = 257
EOS = 258
FIRST = 259
SENTINELS = 32
VOCAB = 291


def positive_parts(total, parts, generator):
    if not 1 <= parts <= total:
        raise ValueError("A span must contain at least one token")
    boundaries = [0] + sorted(generator.sample(range(1, total), parts - 1)) + [total]
    return [b - a for a, b in zip(boundaries, boundaries[1:])]


def sample_noise_spans(length, generator, density=0.15, mean_length=3.0):
    if length < 2 or not 0 < density < 1 or mean_length <= 0:
        raise ValueError("Invalid corruption parameters")
    count = max(1, min(length - 1, round(length * density)))
    spans = max(1, min(count, length - count, round(count / mean_length)))
    if spans + 1 > SENTINELS:
        raise ValueError("Not enough unique sentinels including the terminal sentinel")
    noise = positive_parts(count, spans, generator)
    visible = positive_parts(length - count, spans, generator)
    cursor = 0
    ranges = []
    for a, b in zip(visible, noise):
        cursor += a
        ranges.append([cursor, cursor + b])
        cursor += b
    assert cursor == length
    return ranges


def corrupt_with_sentinels(clean, generator, density=0.15, mean_length=3.0):
    if not clean or any(type(v) != int or not 0 <= v < 256 for v in clean):
        raise ValueError("Corrupt only raw byte tokens")
    ranges = sample_noise_spans(len(clean), generator, density, mean_length)
    encoder = []
    target = []
    cursor = 0
    for index, (start, end) in enumerate(ranges):
        encoder.extend(clean[cursor:start])
        encoder.append(FIRST + index)
        target.append(FIRST + index)
        target.extend(clean[start:end])
        cursor = end
    encoder.extend(clean[cursor:])
    target.extend([FIRST + len(ranges), EOS])
    return {
        "clean_ids": clean,
        "encoder_ids": [BOS] + encoder + [EOS],
        "target_ids": target,
        "spans": ranges,
        "removed_tokens": sum(b - a for a, b in ranges),
    }


def restore_from_target(encoder, target):
    if encoder[0] != BOS or encoder[-1] != EOS or target[-1] != EOS:
        raise ValueError("Missing boundary token")
    pieces = {}
    current = None
    for token in target[:-1]:
        if FIRST <= token < FIRST + SENTINELS:
            if token in pieces or token != FIRST + len(pieces):
                raise ValueError("Repeated or out-of-order sentinel")
            pieces[token] = []
            current = token
        elif 0 <= token < 256 and current is not None:
            pieces[current].append(token)
        else:
            raise ValueError("Invalid reconstruction target")
    if (
        len(pieces) < 2
        or pieces[max(pieces)]
        or any(not v for k, v in pieces.items() if k != max(pieces))
    ):
        raise ValueError("Nonempty spans and empty terminal sentinel required")
    seen = []
    result = []
    for token in encoder[1:-1]:
        if FIRST <= token < FIRST + SENTINELS:
            if token not in pieces or token == max(pieces) or token in seen:
                raise ValueError("Unmatched encoder sentinel")
            seen.append(token)
            result.extend(pieces[token])
        elif 0 <= token < 256:
            result.append(token)
        else:
            raise ValueError("Invalid encoder byte")
    if seen != list(range(FIRST, max(pieces))):
        raise ValueError("Missing or reordered span")
    return result


def display(ids):
    # Sentinels can split a UTF-8 character: show raw byte runs using repr,
    # never treat a lossy decoder display as an exact reconstructed string.
    result = []
    buffer = []
    for value in ids:
        if value < 256:
            buffer.append(value)
            continue
        if buffer:
            result.append(repr(bytes(buffer)))
            buffer = []
        result.append(
            {PAD: "<PAD>", BOS: "<BOS>", EOS: "<EOS>"}.get(
                value, f"<extra_{value-FIRST}>"
            )
        )
    if buffer:
        result.append(repr(bytes(buffer)))
    return " ".join(result)

Construire l'encodeur-décodeur

Trois chemins d'attention distincts

Invariant : Le test causal du décodeur passe et la cross-attention ignore les PAD de l'entrée.

Erreur fréquente : Un masque causal est appliqué à l'encodeur par copie : afficher les trois masques séparément.

Fichier src/preentrainement_denoising/model.py

import math, torch
from torch import nn
from .corruption import PAD, BOS, EOS, VOCAB


class Attention(nn.Module):
    def __init__(self, width, heads, dropout):
        super().__init__()
        if width % heads:
            raise ValueError("Width must be divisible by heads")
        self.width = width
        self.heads = heads
        self.query = nn.Linear(width, width)
        self.key = nn.Linear(width, width)
        self.value = nn.Linear(width, width)
        self.output = nn.Linear(width, width)
        self.dropout = nn.Dropout(dropout)

    def forward(self, query, memory, key_mask, causal=False):
        batch, steps, width = query.shape
        length = memory.shape[1]
        size = width // self.heads
        if not key_mask.any(1).all():
            raise ValueError("Attention needs at least one real key")
        q = self.query(query).reshape(batch, steps, self.heads, size).transpose(1, 2)
        k = self.key(memory).reshape(batch, length, self.heads, size).transpose(1, 2)
        v = self.value(memory).reshape(batch, length, self.heads, size).transpose(1, 2)
        allowed = key_mask[:, None, None, :].expand(batch, 1, steps, length)
        if causal:
            if steps != length:
                raise ValueError("Self-attention causal dimensions differ")
            allowed = (
                allowed
                & torch.ones(
                    steps, length, device=query.device, dtype=torch.bool
                ).tril()[None, None]
            )
        if not allowed.any(-1).all():
            raise ValueError(
                "Use right padding with a visible BOS, not an empty causal prefix"
            )
        weights = (
            (q @ k.transpose(-1, -2) / math.sqrt(size))
            .masked_fill(~allowed, float("-inf"))
            .softmax(-1)
        )
        x = (
            (self.dropout(weights) @ v)
            .transpose(1, 2)
            .contiguous()
            .reshape(batch, steps, width)
        )
        return self.dropout(self.output(x))


def mlp(width, hidden, dropout):
    return nn.Sequential(
        nn.Linear(width, hidden),
        nn.GELU(),
        nn.Linear(hidden, width),
        nn.Dropout(dropout),
    )


class EncoderBlock(nn.Module):
    def __init__(self, width, heads, hidden, dropout):
        super().__init__()
        self.norm1 = nn.LayerNorm(width)
        self.attention = Attention(width, heads, dropout)
        self.norm2 = nn.LayerNorm(width)
        self.feedforward = mlp(width, hidden, dropout)

    def forward(self, x, mask):
        normalized = self.norm1(x)
        x = x + self.attention(normalized, normalized, mask, False)
        return x + self.feedforward(self.norm2(x))


class DecoderBlock(nn.Module):
    def __init__(self, width, heads, hidden, dropout):
        super().__init__()
        self.norm1 = nn.LayerNorm(width)
        self.self_attention = Attention(width, heads, dropout)
        self.norm2 = nn.LayerNorm(width)
        self.cross_attention = Attention(width, heads, dropout)
        self.norm3 = nn.LayerNorm(width)
        self.feedforward = mlp(width, hidden, dropout)

    def forward(self, x, mask, memory, memory_mask):
        normalized = self.norm1(x)
        x = x + self.self_attention(normalized, normalized, mask, True)
        x = x + self.cross_attention(self.norm2(x), memory, memory_mask, False)
        return x + self.feedforward(self.norm3(x))


class SpanDenoiser(nn.Module):
    def __init__(self, config):
        super().__init__()
        width = config["width"]
        self.context = config["context"]
        self.embedding = nn.Embedding(VOCAB, width)
        self.encoder_position = nn.Embedding(self.context, width)
        self.decoder_position = nn.Embedding(self.context, width)
        self.dropout = nn.Dropout(config["dropout"])
        args = (width, config["heads"], config["mlp_width"], config["dropout"])
        self.encoders = nn.ModuleList(
            [EncoderBlock(*args) for _ in range(config["encoder_blocks"])]
        )
        self.decoders = nn.ModuleList(
            [DecoderBlock(*args) for _ in range(config["decoder_blocks"])]
        )
        self.encoder_norm = nn.LayerNorm(width)
        self.decoder_norm = nn.LayerNorm(width)
        self.apply(self.initialize)

    @staticmethod
    def initialize(module):
        if isinstance(module, (nn.Linear, nn.Embedding)):
            nn.init.normal_(module.weight, mean=0.0, std=0.02)
            if isinstance(module, nn.Linear) and module.bias is not None:
                nn.init.zeros_(module.bias)

    def embed(self, ids, position):
        if ids.ndim != 2 or ids.shape[1] > self.context:
            raise ValueError("No silent sequence truncation")
        return self.dropout(
            self.embedding(ids)
            + position(torch.arange(ids.shape[1], device=ids.device))[None]
        )

    def encode(self, ids, mask):
        x = self.embed(ids, self.encoder_position)
        for layer in self.encoders:
            x = layer(x, mask)
        return self.encoder_norm(x)

    def decode(self, ids, mask, memory, memory_mask):
        x = self.embed(ids, self.decoder_position)
        for layer in self.decoders:
            x = layer(x, mask, memory, memory_mask)
        return self.decoder_norm(x) @ self.embedding.weight.T

    def forward(self, encoder_ids, encoder_mask, decoder_ids, decoder_mask):
        return self.decode(
            decoder_ids,
            decoder_mask,
            self.encode(encoder_ids, encoder_mask),
            encoder_mask,
        )


def make_batch(rows, device="cuda", pad_encoder=None, pad_target=None):
    if not rows or any(not r["encoder_ids"] or not r["target_ids"] for r in rows):
        raise ValueError("Empty encoder or target")
    ne = max(len(r["encoder_ids"]) for r in rows)
    nt = max(len(r["target_ids"]) for r in rows)
    pe = ne if pad_encoder is None else pad_encoder
    pt = nt if pad_target is None else pad_target
    if pe < ne or pt < nt:
        raise ValueError("Padding must not truncate")
    encoder = torch.full((len(rows), pe), PAD, dtype=torch.long, device=device)
    decoder = torch.full((len(rows), pt), PAD, dtype=torch.long, device=device)
    labels = torch.full((len(rows), pt), -100, dtype=torch.long, device=device)
    em = torch.zeros_like(encoder, dtype=torch.bool)
    dm = torch.zeros_like(decoder, dtype=torch.bool)
    for i, row in enumerate(rows):
        source = row["encoder_ids"]
        target = row["target_ids"]
        encoder[i, : len(source)] = torch.tensor(source, device=device)
        decoder[i, : len(target)] = torch.tensor([BOS] + target[:-1], device=device)
        labels[i, : len(target)] = torch.tensor(target, device=device)
        em[i, : len(source)] = True
        dm[i, : len(target)] = True
    return encoder, em, decoder, dm, labels


def target_loss(logits, labels):
    active = labels != -100
    if not active.any():
        raise ValueError("No active target")
    values = torch.nn.functional.cross_entropy(
        logits[active].float(), labels[active], reduction="none"
    )
    return values.sum(), int(active.sum()), values

Préentraîner puis fine-tuner les deux branches

Changement d'objectif, mêmes poids réutilisés

Invariant : Les deux branches voient les mêmes IDs supervisés ; leurs coûts par phase sont enregistrés.

Erreur fréquente : Charger les poids préentraînés dans les deux branches efface la baseline : comparer leurs hashes initiaux.

Fichier src/preentrainement_denoising/train.py

import math, random, time
from pathlib import Path
import torch
from .config import read_json, save_json, sha, seed_all, rng_state, restore_rng
from .model import SpanDenoiser, make_batch, target_loss
from .corruption import BOS, EOS, corrupt_with_sentinels
from .state import digest, equal, save_training_state, export_weights, load_weights


class ExampleSampler:
    def __init__(self, size, seed):
        self.size = size
        self.generator = random.Random(seed)
        self.order = []
        self.cursor = 0
        self.epoch = 0

    def take(self, count):
        selected = []
        while len(selected) < count:
            if self.cursor == len(self.order):
                self.order = list(range(self.size))
                self.generator.shuffle(self.order)
                self.cursor = 0
                self.epoch += 1
            selected.append(self.order[self.cursor])
            self.cursor += 1
        return selected

    def state_dict(self):
        return {
            "size": self.size,
            "order": self.order,
            "cursor": self.cursor,
            "epoch": self.epoch,
            "rng": self.generator.getstate(),
        }

    def load_state_dict(self, state):
        if (
            state["size"] != self.size
            or sorted(state["order"]) != list(range(self.size))
            or not 0 <= state["cursor"] <= self.size
        ):
            raise ValueError("Invalid sampler checkpoint")
        self.order = list(state["order"])
        self.cursor = state["cursor"]
        self.epoch = state["epoch"]
        self.generator.setstate(state["rng"])


def identity_config(config):
    return {k: v for k, v in config.items() if k != "exercise"}


def initialize(config, seed):
    seed_all(seed)
    model = SpanDenoiser(config)
    root = Path("assets/checkpoints")
    save_training_state(
        root / f"initial-{seed}.pt",
        {
            "model": model.state_dict(),
            "config": identity_config(config),
            "seed": seed,
            "tokenizer": {
                "bytes": [0, 255],
                "pad": 256,
                "bos": 257,
                "eos": 258,
                "sentinels": [259, 290],
            },
        },
    )
    export_weights(f"outputs/model-initial-{seed}.json", model)
    return digest(model.state_dict())


def supervised_row(page):
    return {
        "document_id": page["document_id"],
        "encoder_ids": [BOS] + list(page["text"].encode()) + [EOS],
        "target_ids": list(page["target_text"].encode()) + [EOS],
    }


def phase_rows(phase, config):
    if phase == "pretrain":
        train = read_json("data/generated/train/pages.json")
        validation_pages = read_json("data/generated/validation/pages.json")
        validation_ids = {p["document_id"] for p in validation_pages}
        validation = [
            p
            for p in read_json("data/generated/validation/corruptions.json")
            if p["document_id"] in validation_ids
        ]
    else:
        path = (
            "data/generated/labels/train-pairs.json"
            if phase.startswith("exercise_")
            else "data/generated/labels/train.json"
        )
        train = read_json(path)
        validation = [
            supervised_row(p)
            for p in read_json("data/generated/labels/validation.json")
        ]
    if any(p["split"] != "train" for p in train):
        raise ValueError("Training family leakage")
    expected = (
        4200 if phase == "pretrain" else 1680 if phase.startswith("exercise_") else 840
    )
    if len(train) != expected or len(validation) != (
        900 if phase == "pretrain" else 180
    ):
        raise ValueError("Unexpected phase population")
    return train, validation


def lr_at(config, phase, step, total):
    peak = (
        config["learning_rate_pretrain"]
        if phase == "pretrain"
        else config["learning_rate_finetune"]
    )
    warmup = max(1, round(total * config["warmup_ratio"]))
    return (
        peak * (step + 1) / warmup
        if step < warmup
        else peak
        * (
            1
            - min(1.0, (step - warmup) / max(1, total - warmup - 1))
            * (1 - config["minimum_lr_ratio"])
        )
    )


@torch.no_grad()
def validation_nll(model, rows, batch_size):
    model.eval()
    total = 0.0
    count = 0
    for i in range(0, len(rows), batch_size):
        encoder, em, decoder, dm, labels = make_batch(rows[i : i + batch_size])
        _, n, values = target_loss(model(encoder, em, decoder, dm), labels)
        total += float(values.double().sum())
        count += n
    model.train()
    return {
        "documents": len(rows),
        "target_tokens": count,
        "target_nll_nats_per_token": total / count,
    }


def train_phase(config, seed, phase, mode):
    if (
        Path("data/generated/test").exists()
        or Path("data/generated/labels/gold.json").exists()
    ):
        raise ValueError("Learning container must not mount test or global gold")
    if phase not in [
        "pretrain",
        "random",
        "transferred",
        "exercise_random",
        "exercise_transferred",
    ] or mode not in ["interrupt", "continuous", "resume"]:
        raise ValueError("Unknown phase/mode")
    seed_all(seed)
    train, validation = phase_rows(phase, config)
    source = Path("assets/checkpoints") / (
        f"pretrain-{seed}-resume.pt"
        if phase in ["transferred", "exercise_transferred"]
        else f"initial-{seed}.pt"
    )
    initial = torch.load(source, map_location="cpu", weights_only=False)
    if initial.get(
        "config", initial.get("identity", {}).get("config")
    ) != identity_config(config):
        raise ValueError("Tokenizer/config changed between phases")
    model = SpanDenoiser(config)
    model.load_state_dict(initial["model"], strict=True)
    initial_digest = digest(model.state_dict())
    del initial
    model = model.cuda()
    optimizer = torch.optim.AdamW(
        model.parameters(),
        lr=config["learning_rate_pretrain"],
        weight_decay=config["weight_decay"],
    )
    sampler = ExampleSampler(len(train), seed + 100)
    corruption_rng = random.Random(seed + 200)
    total = (
        config["pretrain_steps"] if phase == "pretrain" else config["finetune_steps"]
    )
    interrupt = (
        config["pretrain_interrupt"]
        if phase == "pretrain"
        else config["finetune_interrupt"]
    )
    stop = interrupt if mode == "interrupt" else total
    identity = {
        "phase": phase,
        "seed": seed,
        "config": identity_config(config),
        "source_checkpoint_sha256": sha(source),
        "initial_model_sha256": initial_digest,
        "training_ids": [p["document_id"] for p in train],
        "training_sha256": (
            sha("data/generated/train/pages.json")
            if phase == "pretrain"
            else sha(
                "data/generated/labels/train-pairs.json"
                if phase.startswith("exercise_")
                else "data/generated/labels/train.json"
            )
        ),
        "implementation_sha256": {
            n: sha("src/preentrainement_denoising/" + n)
            for n in ["model.py", "corruption.py", "train.py", "state.py"]
        },
    }
    start = 0
    history = []
    validation_history = []
    root = Path("assets/checkpoints")
    path = root / f"{phase}-{seed}-{mode}.pt"
    if path.exists():
        raise ValueError("Preserve phase checkpoint")
    if mode == "resume":
        state = torch.load(
            root / f"{phase}-{seed}-interrupt.pt",
            map_location="cpu",
            weights_only=False,
        )
        if state["identity"] != identity or state["scheduler"] != {
            "next_step": state["steps"],
            "next_lr": lr_at(config, phase, state["steps"], total),
        }:
            raise ValueError("Resume identity/scheduler mismatch")
        model.load_state_dict(state["model"], strict=True)
        optimizer.load_state_dict(state["optimizer"])
        sampler.load_state_dict(state["sampler"])
        corruption_rng.setstate(state["corruption_rng"])
        restore_rng(state["rng"])
        start = state["steps"]
        history = state["history"]
        validation_history = state["validation_history"]
        del state
    model.train()
    begun = time.perf_counter()
    torch.cuda.reset_peak_memory_stats()
    for step in range(start, stop):
        selected = sampler.take(config["batch_size"])
        rows = []
        for index in selected:
            page = train[index]
            row = (
                {
                    "document_id": page["document_id"],
                    **corrupt_with_sentinels(
                        list(page["text"].encode()),
                        corruption_rng,
                        config["noise_density"],
                        config["mean_span_length"],
                    ),
                }
                if phase == "pretrain"
                else supervised_row(page)
            )
            rows.append(row)
        denominator = sum(len(r["target_ids"]) for r in rows)
        lr = lr_at(config, phase, step, total)
        for group in optimizer.param_groups:
            group["lr"] = lr
        optimizer.zero_grad(set_to_none=True)
        loss_total = 0.0
        for offset in range(0, len(rows), config["microbatch"]):
            encoder, em, decoder, dm, labels = make_batch(
                rows[offset : offset + config["microbatch"]]
            )
            summed, n, values = target_loss(model(encoder, em, decoder, dm), labels)
            if not torch.isfinite(summed):
                raise ValueError("Nonfinite loss")
            (summed / denominator).backward()
            loss_total += float(values.detach().double().sum())
        norm = torch.nn.utils.clip_grad_norm_(
            model.parameters(), config["gradient_clip"], error_if_nonfinite=True
        )
        optimizer.step()
        history.append(
            {
                "step": step + 1,
                "document_ids": [r["document_id"] for r in rows],
                "spans": [r.get("spans", []) for r in rows],
                "target_tokens": denominator,
                "target_nll_nats_per_token": loss_total / denominator,
                "learning_rate": lr,
                "gradient_norm_before_clip": float(norm),
            }
        )
        if (step + 1) % 100 == 0:
            observed = validation_nll(model, validation, config["batch_size"])
            validation_history.append({"step": step + 1, **observed})
            print(
                {
                    "seed": seed,
                    "phase": phase,
                    "mode": mode,
                    **history[-1],
                    "validation": observed,
                },
                flush=True,
            )
    torch.cuda.synchronize()
    state = {
        "identity": identity,
        "steps": stop,
        "model": model.state_dict(),
        "optimizer": optimizer.state_dict(),
        "scheduler": {"next_step": stop, "next_lr": lr_at(config, phase, stop, total)},
        "sampler": sampler.state_dict(),
        "corruption_rng": corruption_rng.getstate(),
        "rng": rng_state(),
        "history": history,
        "validation_history": validation_history,
    }
    final = digest(model.state_dict())
    if final == initial_digest:
        raise ValueError("No learned parameter change")
    save_training_state(path, state)
    if mode == "resume":
        export_weights(f"outputs/model-{phase}-{seed}.json", model)
    save_json(
        f"outputs/train-{phase}-{seed}-{mode}.json",
        {
            "seed": seed,
            "phase": phase,
            "mode": mode,
            "identity": identity,
            "steps": stop,
            "history": history,
            "validation_history": validation_history,
            "initial_model_sha256": initial_digest,
            "final_model_sha256": final,
            "parameters": sum(p.numel() for p in model.parameters()),
            "checkpoint_sha256": sha(path),
            "checkpoint_bytes": path.stat().st_size,
            "max_torch_allocated_bytes": torch.cuda.max_memory_allocated(),
            "seconds": time.perf_counter() - begun,
            "test_visible": False,
        },
    )


def verify_resume(seed, phase):
    root = Path("assets/checkpoints")
    a = torch.load(
        root / f"{phase}-{seed}-continuous.pt", map_location="cpu", weights_only=False
    )
    b = torch.load(
        root / f"{phase}-{seed}-resume.pt", map_location="cpu", weights_only=False
    )
    checks = {k: equal(a[k], b[k]) for k in a}
    if not all(checks.values()):
        raise ValueError("Resume differs: " + str(checks))
    save_json(
        f"outputs/resume-{phase}-{seed}.json",
        {
            "status": "PASS",
            "all_state_exact": True,
            "checks": checks,
            "boundary": "complete optimizer update, including corruption and permutation RNG",
        },
    )

Comparer la normalisation, pas seulement les losses

Métriques aval et couverture

Invariant : 180 cas test par branche avec un statut explicite pour chaque échec de format.

Erreur fréquente : Une différence de format est comptée comme champ faux sans l'expliquer : publier exact match brut et champs normalisés.

Fichier src/preentrainement_denoising/evaluate.py

import math, time
from pathlib import Path
import torch
from .config import read_json, save_json, sha, seed_all
from .corruption import PAD, BOS, EOS, FIRST, VOCAB, restore_from_target
from .data import FIELDS
from .model import make_batch, target_loss
from .state import load_weights, digest
from .train import supervised_row
from .baseline import normalize_with_rules, parse_canonical, upstream_complete


@torch.no_grad()
def greedy(model, rows, config):
    model.eval()
    maximum = max(len(r["encoder_ids"]) for r in rows)
    encoder = torch.full((len(rows), maximum), PAD, dtype=torch.long, device="cuda")
    mask = torch.zeros_like(encoder, dtype=torch.bool)
    for i, row in enumerate(rows):
        encoder[i, : len(row["encoder_ids"])] = torch.tensor(
            row["encoder_ids"], device="cuda"
        )
        mask[i, : len(row["encoder_ids"])] = True
    memory = model.encode(encoder, mask)
    decoder = torch.full((len(rows), 1), BOS, dtype=torch.long, device="cuda")
    dm = torch.ones_like(decoder, dtype=torch.bool)
    done = [False] * len(rows)
    outputs = [[] for _ in rows]
    probes = []
    for step in range(config["max_new_tokens"]):
        logits = model.decode(decoder, dm, memory, mask)[:, -1].float()
        logits[:, [PAD, BOS]] = float("-inf")
        chosen = logits.argmax(-1).cpu().tolist()
        if step < 2:
            probes.append(
                {
                    "generation_position": step,
                    "logits": [
                        None if not math.isfinite(x) else x
                        for x in logits[0].cpu().tolist()
                    ],
                    "chosen": chosen[0],
                }
            )
        next_ids = []
        next_mask = []
        for i, token in enumerate(chosen):
            if done[i]:
                next_ids.append(PAD)
                next_mask.append(False)
            else:
                outputs[i].append(token)
                next_ids.append(token)
                next_mask.append(True)
                done[i] = token == EOS
        if all(done):
            break
        decoder = torch.cat(
            [decoder, torch.tensor(next_ids, device="cuda")[:, None]], 1
        )
        dm = torch.cat([dm, torch.tensor(next_mask, device="cuda")[:, None]], 1)
    return outputs, probes


def decode_generated(ids):
    finish = "eos" if ids and ids[-1] == EOS else "length"
    content = ids[:-1] if finish == "eos" else ids
    valid_bytes = all(0 <= v < 256 for v in content)
    raw = bytes(content) if valid_bytes else None
    try:
        text = raw.decode("utf-8") if raw is not None else None
        utf8 = raw is not None
    except UnicodeDecodeError:
        text = None
        utf8 = False
    return {
        "finish_reason": finish,
        "content_bytes_hex": raw.hex() if raw is not None else None,
        "byte_only": valid_bytes,
        "utf8_valid": utf8,
        "text": text,
    }


def selected_pages(config):
    return [
        p
        for p in read_json("data/generated/input/pages.json")
        if p["split"] == "test" and p["formulation"] in config["labeled_formulations"]
    ]


@torch.no_grad()
def predict(config, seed, phase):
    if Path("data/generated/labels").exists():
        raise ValueError("Generation service must not mount target labels")
    seed_all(seed)
    path = Path(f"outputs/model-{phase}-{seed}.json")
    model = load_weights(path, config).cuda().eval()
    pages = selected_pages(config)
    if len(pages) != 180:
        raise ValueError("Expected180heldout pairs")
    rows = [
        {
            "document_id": p["document_id"],
            "encoder_ids": [BOS] + list(p["text"].encode()) + [EOS],
        }
        for p in pages
    ]
    result = []
    probes = []
    begun = time.perf_counter()
    torch.cuda.reset_peak_memory_stats()
    for offset in range(0, len(rows), config["generation_batch_size"]):
        batch = rows[offset : offset + config["generation_batch_size"]]
        generated, actual = greedy(model, batch, config)
        if offset == 0:
            probes = actual
        for row, ids in zip(batch, generated):
            decoded = decode_generated(ids)
            parsed = (
                parse_canonical(decoded["text"], decoded["finish_reason"])
                if decoded["text"] is not None
                else {
                    "format_valid": False,
                    "completion_valid": False,
                    "record": None,
                    "reason": (
                        "NON_BYTE_TOKEN" if not decoded["byte_only"] else "INVALID_UTF8"
                    ),
                }
            )
            result.append({**row, "generated_ids": ids, **decoded, "parsed": parsed})
    torch.cuda.synchronize()
    save_json(
        f"outputs/predictions-{phase}-{seed}.json",
        {
            "phase": phase,
            "seed": seed,
            "model_sha256": digest(model.state_dict()),
            "model_json_sha256": sha(path),
            "test_input_sha256": sha("data/generated/input/pages.json"),
            "rows": result,
            "first_case_logit_probes": probes,
            "seconds": time.perf_counter() - begun,
            "max_torch_allocated_bytes": torch.cuda.max_memory_allocated(),
            "gold_visible": False,
            "decoding": "greedy291tokens; PAD/BOS suppressed; sentinels not suppressed",
        },
    )


@torch.no_grad()
def nll_rows(model, rows, batch_size):
    results = []
    probes = []
    for offset in range(0, len(rows), batch_size):
        batch = rows[offset : offset + batch_size]
        encoder, em, decoder, dm, labels = make_batch(batch)
        logits = model(encoder, em, decoder, dm)
        _, _, values = target_loss(logits, labels)
        cursor = 0
        for row in batch:
            n = len(row["target_ids"])
            results.append(
                {**row, "target_nll": values[cursor : cursor + n].cpu().tolist()}
            )
            cursor += n
        if offset == 0:
            for pos in [0, len(batch[0]["target_ids"]) - 1]:
                probes.append(
                    {
                        "document_id": batch[0]["document_id"],
                        "target_position": pos,
                        "target": int(labels[0, pos]),
                        "logits": logits[0, pos].cpu().tolist(),
                        "target_nll": results[0]["target_nll"][pos],
                    }
                )
    values = [v for row in results for v in row["target_nll"]]
    return {
        "documents": len(rows),
        "target_tokens": len(values),
        "target_nll_nats_per_token": math.fsum(values) / len(values),
        "rows": results,
        "probes": probes,
    }


@torch.no_grad()
def evaluate_reconstruction(config, seed, phase):
    if phase not in ["initial", "pretrain"]:
        raise ValueError("Reconstruction comparison is before/after pretraining")
    seed_all(seed)
    model = load_weights(f"outputs/model-{phase}-{seed}.json", config).cuda().eval()
    test_ids = {
        p["document_id"]
        for p in read_json("data/generated/input/pages.json")
        if p["split"] == "test"
    }
    rows = [
        r
        for r in read_json("data/generated/input/pool.json")
        if r["document_id"] in test_ids
    ]
    result = nll_rows(model, rows, config["batch_size"])
    generations, probes = greedy(model, rows[:12], config)
    examples = []
    for row, ids in zip(rows, generations):
        try:
            restored = restore_from_target(row["encoder_ids"], ids)
            valid = True
        except (ValueError, IndexError):
            restored = None
            valid = False
        examples.append(
            {
                "document_id": row["document_id"],
                "encoder_ids": row["encoder_ids"],
                "target_ids": row["target_ids"],
                "generated_ids": ids,
                "finish_reason": "eos" if ids[-1] == EOS else "length",
                "valid_sentinel_structure": valid,
                "restored_ids": restored,
                "exact_reconstruction": valid and restored == row["clean_ids"],
            }
        )
    save_json(
        f"outputs/reconstruction-{phase}-{seed}.json",
        {
            "seed": seed,
            "phase": phase,
            "model_sha256": digest(model.state_dict()),
            **result,
            "fixed_first12_generations": examples,
            "first_case_generation_probes": probes,
        },
    )


def score_fields(predictions, gold):
    correct = dict.fromkeys(FIELDS, 0)
    exact = valid = truncated = 0
    details = []
    for row in predictions:
        expected = gold[row["document_id"]]
        parsed = row["parsed"]
        accepted = parsed["completion_valid"]
        fields = {
            k: accepted and parsed["record"][k] == expected["target"][k] for k in FIELDS
        }
        raw = accepted and row["text"] == expected["target_text"]
        exact += raw
        valid += accepted
        truncated += row["finish_reason"] == "length"
        for key in FIELDS:
            correct[key] += fields[key]
        details.append(
            {
                "document_id": row["document_id"],
                "fields_correct": fields,
                "raw_exact_match": raw,
            }
        )
    n = len(predictions)
    return {
        "documents": n,
        "valid_completions": valid,
        "valid_completion_rate": valid / n,
        "raw_exact_match": exact,
        "raw_exact_match_rate": exact / n,
        "field_correct": correct,
        "field_accuracy": {k: v / n for k, v in correct.items()},
        "truncated": truncated,
        "rows": details,
    }


def score_canonical_line(config, seed, phase):
    gold = {r["document_id"]: r for r in read_json("data/generated/labels/gold.json")}
    prediction = read_json(f"outputs/predictions-{phase}-{seed}.json")
    metrics = score_fields(prediction["rows"], gold)
    model = load_weights(f"outputs/model-{phase}-{seed}.json", config).cuda().eval()
    pairs = [{**p, **gold[p["document_id"]]} for p in selected_pages(config)]
    nll = nll_rows(model, [supervised_row(p) for p in pairs], config["batch_size"])
    save_json(f"outputs/nll-{phase}-{seed}.json", nll)
    save_json(
        f"outputs/evaluation-{phase}-{seed}.json",
        {
            "seed": seed,
            "phase": phase,
            "metrics": metrics,
            "response_nll_nats_per_token": nll["target_nll_nats_per_token"],
            "quality_gain_required": False,
        },
    )
    print(
        {
            "seed": seed,
            "phase": phase,
            "metrics": {k: v for k, v in metrics.items() if k != "rows"},
        },
        flush=True,
    )


def rule_baseline(config):
    gold = {r["document_id"]: r for r in read_json("data/generated/labels/gold.json")}
    rows = []
    for p in selected_pages(config):
        observed = normalize_with_rules(p["text"])
        rows.append(
            {
                "document_id": p["document_id"],
                "text": observed["text"],
                "status": observed["status"],
                "finish_reason": "eos",
                "parsed": {
                    "completion_valid": observed["status"] == "ACCEPT",
                    "record": observed["record"],
                },
            }
        )
    missing = read_json("data/generated/input/invalid.json")
    decisions = [
        {
            "document_id": p["document_id"],
            "missing_field": p["missing_field"],
            "accepted_upstream": upstream_complete(p["text"]),
        }
        for p in missing
    ]
    save_json(
        "outputs/rules.json",
        {
            "rows": rows,
            "metrics": score_fields(rows, gold),
            "coverage": sum(r["status"] == "ACCEPT" for r in rows) / len(rows),
            "abstentions": sum(r["status"] == "ABSTAIN" for r in rows),
            "missing_cases": decisions,
            "missing_rejected": sum(not r["accepted_upstream"] for r in decisions),
            "scope": "Upstream rule over controlled synthetic fields; not a guarantee of factual completeness for real texts",
        },
    )


def compare_branches(config):
    rows = {}
    for seed in config["reference_seeds"]:
        rows[str(seed)] = {}
        for phase in ["random", "transferred"]:
            report = read_json(f"outputs/evaluation-{phase}-{seed}.json")
            training = read_json(f"outputs/train-{phase}-{seed}-resume.json")
            pretraining = read_json(f"outputs/train-pretrain-{seed}-resume.json")
            continuous = read_json(f"outputs/train-{phase}-{seed}-continuous.json")
            precontinuous = read_json(f"outputs/train-pretrain-{seed}-continuous.json")
            rows[str(seed)][phase] = {
                "metrics": {k: v for k, v in report["metrics"].items() if k != "rows"},
                "normalization_target_nll": report["response_nll_nats_per_token"],
                "supervised_examples": len(training["identity"]["training_ids"]),
                "supervised_presentations": sum(
                    len(r["document_ids"]) for r in training["history"]
                ),
                "supervised_target_tokens": sum(
                    r["target_tokens"] for r in training["history"]
                ),
                "continuous_supervised_seconds": continuous["seconds"],
                "additional_pretraining_steps": 600 if phase == "transferred" else 0,
                "additional_pretraining_target_tokens": (
                    sum(r["target_tokens"] for r in pretraining["history"])
                    if phase == "transferred"
                    else 0
                ),
                "continuous_additional_pretraining_seconds": (
                    precontinuous["seconds"] if phase == "transferred" else 0
                ),
            }
        a = read_json(f"outputs/train-random-{seed}-resume.json")
        b = read_json(f"outputs/train-transferred-{seed}-resume.json")
        if [r["document_ids"] for r in a["history"]] != [
            r["document_ids"] for r in b["history"]
        ]:
            raise ValueError("Supervised batches differ")
    save_json(
        "outputs/comparison.json",
        {
            "seeds": rows,
            "rules": {
                k: v
                for k, v in read_json("outputs/rules.json").items()
                if k not in ["rows", "missing_cases"]
            },
            "equal_annotation_budget": True,
            "equal_total_compute": False,
            "continuous_times_include_validation_and_checkpoint_io": True,
            "resume_verification_overhead_not_in_path_cost_but_in_reader_wall_time": True,
            "quality_gain_required": False,
        },
    )

Changer le budget d'annotation

Bénéfice marginal du transfert

Invariant : Les nouvelles annotations appartiennent seulement aux familles train.

Erreur fréquente : Comparer deux test sets différents fausse l'écart : réutiliser les IDs et hacher la liste.

Fichier src/preentrainement_denoising/exercise.py

from pathlib import Path
from .config import read_json, save_json, sha


def preserve_main(config):
    paths = ["outputs/comparison.json", "outputs/rules.json"]
    for seed in config["reference_seeds"]:
        for phase in ["initial", "pretrain", "random", "transferred"]:
            paths.append(f"outputs/model-{phase}-{seed}.json")
        for phase in ["random", "transferred"]:
            paths.extend(
                [
                    f"outputs/evaluation-{phase}-{seed}.json",
                    f"outputs/predictions-{phase}-{seed}.json",
                ]
            )
    save_json("outputs/exercise-before.json", {name: sha(name) for name in paths})


def run_label_budget(config):
    before = read_json("outputs/exercise-before.json")
    if any(sha(name) != h for name, h in before.items()):
        raise ValueError("Exercise changed main artifacts")
    rows = {}
    for seed in config["reference_seeds"]:
        rows[str(seed)] = {}
        for branch in ["random", "transferred"]:
            phase = "exercise_" + branch
            report = read_json(f"outputs/evaluation-{phase}-{seed}.json")
            training = read_json(f"outputs/train-{phase}-{seed}-resume.json")
            main = read_json(f"outputs/train-{branch}-{seed}-resume.json")
            if training["initial_model_sha256"] != main["initial_model_sha256"] or len(
                training["identity"]["training_ids"]
            ) != 2 * len(main["identity"]["training_ids"]):
                raise ValueError("Initial state or doubled labels changed")
            rows[str(seed)][branch] = {
                "main": read_json(f"outputs/evaluation-{branch}-{seed}.json")[
                    "metrics"
                ],
                "double_labels": report["metrics"],
                "labeled_pairs": len(training["identity"]["training_ids"]),
                "optimizer_steps": training["steps"],
                "presentations": sum(
                    len(r["document_ids"]) for r in training["history"]
                ),
                "initial_model_sha256": training["initial_model_sha256"],
                "target_tokens": sum(r["target_tokens"] for r in training["history"]),
            }
    save_json(
        "outputs/exercise/label-budget.json",
        {
            "seeds": rows,
            "protected_sha256": before,
            "main_artifacts_unchanged": True,
            "same_pretraining_corpus_and_weights": True,
            "same180test_cases": True,
            "same300updates_fewer_passes_over_more_labels": True,
            "quality_gain_required": False,
        },
    )

Sources

L’article T5 et le dépôt des auteurs ont été consultés pour le contexte de l’objectif. Aucun code ni poids T5 n’est repris. Les dépendances réellement utilisées sont verrouillées dans le projet. Docker GPU et NVIDIA Container Toolkit documentent les prérequis.

Résultats observés lors du développement GPU

Le parcours complet a réussi avec 15 tests en 898,97 secondes. Deux graines (42 et 43) ont été entraînées, avec 600 mises à jour de débruitage puis 300 de SFT par branche. Les comparaisons de reprise portent sur les dix chaînes d’apprentissage, y compris l’exercice : modèle, optimiseur, sampler, RNG et historiques sont exactement retrouvés. La reproduction depuis l’archive publique reste une vérification distincte.

Sur les 180 cas test de normalisation, aucune des huit variantes neuronales ne produit une ligne entièrement exacte, et aucune ne restitue correctement la référence. Les règles obtiennent 180/180. Le préentraînement améliore donc certains signaux sans résoudre la tâche : avec deux formulations supervisées, le format complet valide passe de 101/180 à 180/180 (graine 42) et de 98/180 à 180/180 (graine 43). Le matériau correct passe de 69 à 131 et de 54 à 145 ; le diamètre reste correct dans seulement six cas par modèle transféré.

Exemple réellement conservé : pour « Piece K0510, aluminium, diametre 18 mm. », le modèle transféré de graine 42 produit reference=K0024;materiau=aluminium;diametre_mm=26. Le format et le matériau sont corrects, mais les deux autres champs sont faux. Cette sortie suffit à voir pourquoi une conformité syntaxique ne constitue pas une extraction fiable.

La NLL de reconstruction des trous sur 900 fiches et 9 150 tokens cibles baisse de 5,6601 à 1,0212 (graine 42) et de 5,6971 à 1,0314 (graine 43). La NLL de normalisation sous préfixe cible passe de 0,6420 à 0,3425 et de 0,6785 à 0,3387. Ces pertes conditionnées et la qualité des sorties libres répondent à des questions différentes ; leur baisse ne contredit pas les erreurs observées.

L’exercice double le nombre de formulations supervisées, de 840 à 1 680 exemples disponibles, mais conserve 300 mises à jour : chaque exemple est donc moins revisité. Il n’apporte pas un gain uniforme. Pour le transfert de graine 42, le format valide descend à 170/180 et le matériau à 109/180 ; pour la graine 43, ils atteignent 180/180 et 146/180. Aucun réglage n’a été ajusté pour imposer une amélioration. Les 60 cas à champ manquant sont refusés par le contrôle amont à règles ; ils ne prouvent pas que le réseau sait s’abstenir.

Le coût supplémentaire du préentraînement est inclus : 36,13 s puis 35,26 s sur les chemins continus, auxquels s’ajoutent environ 16–17 s par SFT. Ces durées incluent validation et écriture ; les reprises, générations et autres contrôles expliquent une durée totale du parcours bien supérieure. Les pics d’allocation internes observés sont 153 128 448 octets pour le débruitage et 164 146 176 pour le SFT ; ils ne sont pas la VRAM totale vue par Linux. Une préparation CPU d’autres travaux a eu lieu pendant le parcours : aucune mesure sur hôte entièrement inactif n’est revendiquée.

Ce corpus fermé et ses règles constituent un support d’apprentissage du mécanisme. Le préentraînement supplémentaire ne justifie pas ici de remplacer le parseur déterministe pour normaliser ces fiches. Deux graines ne suffisent pas à établir une généralité métier.

Observations de développement

Valeurs recalculées depuis les sorties brutes de ce parcours. Elles ne sont pas des seuils de gain ; le parcours depuis le ZIP les recalcule sur les mêmes populations.

{
  "status": "PASS",
  "documents": 6000,
  "test_normalization_cases": 180,
  "seeds": [
    42,
    43
  ],
  "parameters": 3269952,
  "metrics": {
    "random-42": {
      "documents": 180,
      "valid_completions": 101,
      "valid_completion_rate": 0.5611111111111111,
      "raw_exact_match": 0,
      "raw_exact_match_rate": 0.0,
      "field_correct": {
        "reference": 0,
        "materiau": 69,
        "diametre_mm": 3
      },
      "field_accuracy": {
        "reference": 0.0,
        "materiau": 0.38333333333333336,
        "diametre_mm": 0.016666666666666666
      },
      "truncated": 0
    },
    "transferred-42": {
      "documents": 180,
      "valid_completions": 180,
      "valid_completion_rate": 1.0,
      "raw_exact_match": 0,
      "raw_exact_match_rate": 0.0,
      "field_correct": {
        "reference": 0,
        "materiau": 131,
        "diametre_mm": 6
      },
      "field_accuracy": {
        "reference": 0.0,
        "materiau": 0.7277777777777777,
        "diametre_mm": 0.03333333333333333
      },
      "truncated": 0
    },
    "exercise_random-42": {
      "documents": 180,
      "valid_completions": 109,
      "valid_completion_rate": 0.6055555555555555,
      "raw_exact_match": 0,
      "raw_exact_match_rate": 0.0,
      "field_correct": {
        "reference": 0,
        "materiau": 59,
        "diametre_mm": 8
      },
      "field_accuracy": {
        "reference": 0.0,
        "materiau": 0.3277777777777778,
        "diametre_mm": 0.044444444444444446
      },
      "truncated": 0
    },
    "exercise_transferred-42": {
      "documents": 180,
      "valid_completions": 170,
      "valid_completion_rate": 0.9444444444444444,
      "raw_exact_match": 0,
      "raw_exact_match_rate": 0.0,
      "field_correct": {
        "reference": 0,
        "materiau": 109,
        "diametre_mm": 7
      },
      "field_accuracy": {
        "reference": 0.0,
        "materiau": 0.6055555555555555,
        "diametre_mm": 0.03888888888888889
      },
      "truncated": 0
    },
    "random-43": {
      "documents": 180,
      "valid_completions": 98,
      "valid_completion_rate": 0.5444444444444444,
      "raw_exact_match": 0,
      "raw_exact_match_rate": 0.0,
      "field_correct": {
        "reference": 0,
        "materiau": 54,
        "diametre_mm": 0
      },
      "field_accuracy": {
        "reference": 0.0,
        "materiau": 0.3,
        "diametre_mm": 0.0
      },
      "truncated": 0
    },
    "transferred-43": {
      "documents": 180,
      "valid_completions": 180,
      "valid_completion_rate": 1.0,
      "raw_exact_match": 0,
      "raw_exact_match_rate": 0.0,
      "field_correct": {
        "reference": 0,
        "materiau": 145,
        "diametre_mm": 6
      },
      "field_accuracy": {
        "reference": 0.0,
        "materiau": 0.8055555555555556,
        "diametre_mm": 0.03333333333333333
      },
      "truncated": 0
    },
    "exercise_random-43": {
      "documents": 180,
      "valid_completions": 178,
      "valid_completion_rate": 0.9888888888888889,
      "raw_exact_match": 0,
      "raw_exact_match_rate": 0.0,
      "field_correct": {
        "reference": 0,
        "materiau": 60,
        "diametre_mm": 5
      },
      "field_accuracy": {
        "reference": 0.0,
        "materiau": 0.3333333333333333,
        "diametre_mm": 0.027777777777777776
      },
      "truncated": 0
    },
    "exercise_transferred-43": {
      "documents": 180,
      "valid_completions": 180,
      "valid_completion_rate": 1.0,
      "raw_exact_match": 0,
      "raw_exact_match_rate": 0.0,
      "field_correct": {
        "reference": 0,
        "materiau": 146,
        "diametre_mm": 6
      },
      "field_accuracy": {
        "reference": 0.0,
        "materiau": 0.8111111111111111,
        "diametre_mm": 0.03333333333333333
      },
      "truncated": 0
    }
  },
  "rules": {
    "documents": 180,
    "valid_completions": 180,
    "valid_completion_rate": 1.0,
    "raw_exact_match": 180,
    "raw_exact_match_rate": 1.0,
    "field_correct": {
      "reference": 180,
      "materiau": 180,
      "diametre_mm": 180
    },
    "field_accuracy": {
      "reference": 1.0,
      "materiau": 1.0,
      "diametre_mm": 1.0
    },
    "truncated": 0
  },
  "quality_gain_required": false,
  "scope": "Data-only exact sampler/corruption replay, all target budgets, weight hashes, full-state resume evidence, CE probes, raw decoding/metrics and observed phase costs; no scalar reimplementation of the full Transformer"
}

Les résultats observés, leur portée et l'expérience proposée.

Partager cet article