Hablemos de su proyecto
Inteligencia artificial

Preentrenar un codificador-decodificador para normalizar fichas

Un catálogo ficticio contiene descripciones heterogéneas de piezas. Compare tres normalizadores sobre las mismas fichas reservadas: reglas, codificador-decodificador ajustado y un modelo preentrenado mediante eliminación de ruido antes del ajuste.

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

Un catálogo ficticio contiene descripciones heterogéneas de piezas. La tarea final transforma una descripción completa, a veces abreviada, en una línea canónica. Antes de esta tarea supervisada, una pequeña red aprende a reconstruir segmentos retirados de las fichas de train.

Compare tres normalizadores sobre las mismas fichas reservadas: reglas, codificador-decodificador aleatorio y después fine-tuné, y codificador-decodificador preentrenado mediante eliminación de ruido y después fine-tuné. Muestre también una reconstrucción de spans.

Descargar el proyecto completo, revisión r001

El recorrido del tutorial, desde el caso concreto hasta la verificación.

Dos tareas y dos señales

La eliminación de ruido retira segmentos contiguos de una ficha. La red recibe los bytes restantes y tokens centinela; su objetivo solo contiene los segmentos retirados, asociados a los mismos centinelas. La autosupervisión procede de la ficha inicial, sin anotador. La tarea posterior utiliza otro objetivo: una línea canónica construida explícitamente para la normalización. Estas dos NLL no tienen el mismo contenido objetivo; sus valores no son directamente una medida común de calidad de negocio.

El corpus original CatalogueSpan-v1 contiene 600 familias de diez formulaciones, es decir, 6000 fichas. Las familias 0–419 son train, 420–509 validation y 510–599 test: 4200/900/900 fichas. Una familia comparte referencia, material y diámetro. Las formulaciones, materiales y patrones sintácticos cortos se comparten entre particiones; las referencias son distintas. Esto prueba una transferencia sobre un catálogo artificial, no el descubrimiento de una gramática totalmente nueva ni la generalización a un catálogo real.

Las formulaciones 0 y 5 dan 840 pares de train y 180 pares por partición reservada. Todos sus campos están presentes. Los 60 casos incompletos, separados, retiran un campo y prueban un rechazo por reglas antes de la generación. No se consulta al modelo para inventar el valor oculto; por tanto, este experimento no demuestra su resistencia a las alucinaciones. Las reglas cubren los diez formatos controlados y ya pueden ser perfectas: si las medidas lo confirman, su sencillez sigue siendo una ventaja real.

Bytes, spans y centinelas

El vocabulario es fijo: bytes UTF-8 0–255, PAD=256, BOS=257, EOS=258 y 32 centinelas 259–290. Un carácter acentuado puede ocupar varios bytes y un span puede cortarlo. La visualización utiliza fragmentos de bytes explícitos; la restauración oracle recompone exactamente los bytes antes de la decodificación, sin reparación Unicode.

El número de bytes retirados es round(0,15 × longitud), limitado entre 1 y longitud−1. El número de spans se aproxima al número retirado dividido por tres, con restricciones de no vacío. Dos composiciones positivas aleatorias reparten las longitudes visibles y retiradas; se alternan visible y retirado, y el último span alcanza el final de la ficha. La media de tres es un objetivo redondeado, no una ley geométrica exacta. Esta elección de borde es un límite explícito. Cada hueco tiene su centinela; el objetivo termina con un centinela adicional y después EOS. Se rechazan más de 31 huecos.

El codificador recibe BOS, la entrada corrompida y EOS. El decodificador recibe BOS y después target[:-1], y predice target, incluidos centinelas y EOS. PAD nunca es un objetivo activo. La CE selecciona las posiciones activas antes de sumar; la acumulación divide por el número exacto de tokens objetivo, sin una media incorrecta de las medias de microbatches. El collator no vuelve a introducir ningún segmento retirado en la entrada del codificador.

Las máscaras de validation/test se fijan por identificador antes del entrenamiento. Las máscaras de train se extraen dinámicamente con un generador explícito, cuyo estado se guarda. Las formulaciones reservadas permanecen físicamente ausentes del servicio learning; solo monta las corrupciones de validation, no las de test.

Tres caminos de atención

La red original contiene tres bloques de codificador y tres bloques de decodificador, anchura 192, seis cabezas y MLP 768 con GELU. Cada capa se asigna e inicializa de manera independiente. El embedding de entrada y la proyección de salida reutilizan el mismo Parameter. Las posiciones aprendidas cubren 256 tokens; un exceso se rechaza, nunca se trunca silenciosamente. Pre-LayerNorm, residuos y dropout 0,1 son visibles. No es la arquitectura T5: solo la inspira la idea de un objetivo mediante spans y centinelas.

La autoatención del codificador es bidireccional y solo enmascara las claves PAD. La autoatención del decodificador enmascara el futuro y las claves PAD. La cross-attention consulta toda la codificación visible y enmascara los PAD del codificador. Los batches usan padding a la derecha, con BOS presente. Cambiar un objetivo futuro no debe cambiar los logits anteriores; cambiar una entrada futura del codificador puede cambiar sus representaciones anteriores. Añadir padding debe preservar loss y gradientes dentro de la tolerancia FP32 anunciada.

Protocolo de aprendizaje

Las semillas 42 y 43 producen dos cadenas independientes. Para cada una se guarda una inicialización. La rama directa comienza con esos pesos; la rama transferida recibe los pesos tras 600 actualizaciones de eliminación de ruido sobre las únicas 4200 fichas de train. Ambas ramas realizan después 300 actualizaciones sobre los mismos 840 pares, en el mismo orden supervisado. El checkpoint final está fijado; la validación cada 100 pasos no selecciona una versión mejor.

AdamW actualiza todos los parámetros en FP32 CUDA, con TF32 desactivado, weight_decay 0,01 y norma de gradientes limitada a 1. La tasa máxima es 0,0003 en preentrenamiento y 0,0001 en normalización; warm-up durante el 10 % de los pasos y después descenso lineal hacia el 10 % de la tasa máxima. Una actualización procesa 16 ejemplos, en dos microbatches de ocho con denominador de tokens exacto. El sampler recorre permutaciones y puede cruzar un límite de época para completar un batch.

Cada fase se ejecuta realmente interrumpida, continua y reanudada. El preentrenamiento se interrumpe en 240 pasos de 600; el SFT en 120 de 300. Modelo, AdamW, scheduler, cursor/permutación, RNG de corrupción y RNG de Python/NumPy/PyTorch CPU/CUDA deben ser idénticos al resultado continuo. Estas repeticiones cuestan cálculo; las duraciones de los caminos continuos se separan del tiempo total del lector. Los archivos .pt se crean localmente; los pesos JSON con dimensiones, tamaño y SHA permiten una auditoría sin ejecutar contenido pickle externo.

Evaluar libremente

La reconstrucción se evalúa antes/después del preentrenamiento sobre las mismas 900 fichas de test y máscaras fijadas, con NLL por token objetivo y doce primeras reconstrucciones libres ilustrativas. La normalización se evalúa sobre 180 salidas libres por rama y semilla. La decodificación greedy permite hasta 128 tokens nuevos; solo PAD y BOS se eliminan del soporte. Los centinelas no se eliminan para forzar una línea canónica: su aparición es un fallo de formato visible. Los bytes no válidos, formatos incorrectos y truncamientos permanecen en el denominador.

La validez de la finalización exige EOS y el formato completo. El exact match bruto compara la línea entera; la exactitud por campo compara los valores analizados, incluido el diámetro entero. Una línea numéricamente correcta pero formateada con ceros iniciales puede tener campos correctos sin exact match bruto. Una salida que alcanza 128 tokens falla aunque su prefijo parezca completo. La NLL posterior usa teacher forcing y los mismos objetivos; no sustituye la evaluación libre.

Las tablas indican por separado anotación, tokens procesados y tiempo por fase. Las duraciones observadas abarcan validación y escritura del checkpoint en el proceso; no son mediciones de tiempo de kernels CUDA aislados. Los recursos globales y el overhead de reanudación se miden en el recorrido. La transferencia añade cálculo y no se compara con un presupuesto total igual.

Duplicar las anotaciones

El ejercicio toma cuatro formulaciones de train (0, 2, 5, 7), es decir, 1680 pares, y reentrena dos nuevas ramas desde los mismos estados iniciales/directos o preentrenados. Los 180 casos de test, dos semillas y 300 actualizaciones permanecen fijos. El número de pasadas sobre cada par disminuye: más etiquetas no significa más pasos. El corpus no anotado y los pesos de preentrenamiento no cambian y sus SHA se controlan. No se impone una mejora; una transferencia nula o negativa permanece en los resultados. La extensión a cálculo total igual requeriría otro experimento, explícitamente distinto de este.

Crear las fichas y las particiones

Separar familia y formulación

Invariante: 420/90/90 familias, 840/180/180 pares posteriores antes de exclusiones documentadas.

Error frecuente: Otra formulación de la misma pieza aparece en test: particionar las familias antes de la generación.

Archivo 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

Codificar la corrupción mediante spans

Centinelas y objetivo autosupervisado

Invariante: La reconstrucción oracle restablece todos los bytes de la ficha.

Error frecuente: Dos huecos comparten el mismo centinela: asignar tokens únicos y comprobar la capacidad.

Archivo 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)

Construir el codificador-decodificador

Tres caminos de atención distintos

Invariante: La prueba causal del decodificador se supera y la cross-attention ignora los PAD de la entrada.

Error frecuente: Se aplica por copia una máscara causal al codificador: mostrar las tres máscaras por separado.

Archivo 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

Preentrenar y después hacer fine-tuning de las dos ramas

Cambio de objetivo, mismos pesos reutilizados

Invariante: Ambas ramas ven los mismos ID supervisados; sus costes por fase quedan registrados.

Error frecuente: Cargar los pesos preentrenados en las dos ramas borra la baseline: comparar sus hashes iniciales.

Archivo 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",
        },
    )

Comparar la normalización, no solo las losses

Métricas posteriores y cobertura

Invariante: 180 casos de test por rama con un estado explícito para cada fallo de formato.

Error frecuente: Una diferencia de formato se cuenta como campo falso sin explicarlo: publicar exact match bruto y campos normalizados.

Archivo 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,
        },
    )

Cambiar el presupuesto de anotación

Beneficio marginal de la transferencia

Invariante: Las nuevas anotaciones pertenecen solo a las familias de train.

Error frecuente: Comparar dos conjuntos de test distintos falsea la diferencia: reutilizar los ID y hashear la lista.

Archivo 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,
        },
    )

Fuentes

El artículo T5 y el repositorio de los autores se consultaron para el contexto del objetivo. No se reutiliza ningún código ni peso T5. Las dependencias realmente utilizadas están bloqueadas en el proyecto. Docker GPU y NVIDIA Container Toolkit documentan los requisitos previos.

Resultados observados durante el desarrollo con GPU

El recorrido completo tuvo éxito con 15 pruebas en 898,97 segundos. Se entrenaron dos semillas (42 y 43), con 600 actualizaciones de eliminación de ruido y después 300 de SFT por rama. Las comparaciones de reanudación abarcan las diez cadenas de aprendizaje, incluido el ejercicio: modelo, optimizador, sampler, RNG e historiales se recuperan exactamente. La reproducción desde el archivo público sigue siendo una comprobación distinta.

De los 180 casos de test de normalización, ninguna de las ocho variantes neuronales produce una línea completamente exacta, y ninguna restituye correctamente la referencia. Las reglas obtienen 180/180. Por tanto, el preentrenamiento mejora algunas señales sin resolver la tarea: con dos formulaciones supervisadas, el formato completo válido pasa de 101/180 a 180/180 (semilla 42) y de 98/180 a 180/180 (semilla 43). El material correcto pasa de 69 a 131 y de 54 a 145; el diámetro sigue siendo correcto en solo seis casos por modelo transferido.

Ejemplo realmente conservado: para «Piece K0510, aluminium, diametre 18 mm.», el modelo transferido de semilla 42 produce reference=K0024;materiau=aluminium;diametre_mm=26. El formato y el material son correctos, pero los otros dos campos son erróneos. Esta salida basta para comprobar por qué la conformidad sintáctica no constituye una extracción fiable.

La NLL de reconstrucción de huecos sobre 900 fichas y 9 150 tokens objetivo baja de 5,6601 a 1,0212 (semilla 42) y de 5,6971 a 1,0314 (semilla 43). La NLL de normalización bajo prefijo objetivo pasa de 0,6420 a 0,3425 y de 0,6785 a 0,3387. Estas pérdidas condicionadas y la calidad de las salidas libres responden a preguntas diferentes; su descenso no contradice los errores observados.

El ejercicio duplica el número de formulaciones supervisadas, de 840 a 1 680 ejemplos disponibles, pero conserva 300 actualizaciones: cada ejemplo se revisita por tanto menos. No aporta una ganancia uniforme. Para la transferencia de semilla 42, el formato válido baja a 170/180 y el material a 109/180; para la semilla 43 alcanzan 180/180 y 146/180. No se ajustó ningún parámetro para imponer una mejora. Los 60 casos con un campo ausente son rechazados por el control previo basado en reglas; no prueban que la red sepa abstenerse.

Se incluye el coste adicional del preentrenamiento: 36,13 s y después 35,26 s en los caminos continuos, a los que se añaden aproximadamente 16–17 s por SFT. Estas duraciones incluyen validación y escritura; las reanudaciones, generaciones y otros controles explican una duración total del recorrido mucho mayor. Los picos internos de asignación observados son 153 128 448 bytes para la eliminación de ruido y 164 146 176 para el SFT; no representan la VRAM total vista por Linux. Durante el recorrido se realizó una preparación CPU de otros trabajos: no se pretende medir un host completamente inactivo.

Este corpus cerrado y sus reglas son un soporte para aprender el mecanismo. El preentrenamiento adicional no justifica aquí sustituir el analizador determinista para normalizar estas fichas. Dos semillas no bastan para establecer una generalidad de negocio.

Observaciones de desarrollo

Valores recalculados a partir de las salidas brutas de este recorrido. No son umbrales de ganancia; el recorrido desde el ZIP los recalcula sobre las mismas poblaciones.

{
  "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"
}

Los resultados observados, su alcance y el experimento propuesto.

Compartir este artículo