Hablemos de su proyecto
Inteligencia artificial

Adaptar un LLM al vocabulario de negocio sin confundirlo con RAG

El servicio técnico ficticio Novaforge usa códigos y formulaciones repetidos en sus avisos. Compare la verosimilitud antes y después de la adaptación, y respuestas con o sin contexto de un índice léxico local.

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

El servicio técnico ficticio Novaforge utiliza códigos y formulaciones que se repiten en sus avisos. Se quiere adaptar la distribución lingüística del modelo, manteniendo a la vez una vía para consultar avisos revisados ante hechos cambiantes.

Compare la verosimilitud de avisos antes/después de la adaptación y después las respuestas cortas a preguntas con o sin contexto proporcionado por un índice léxico local. Muestre un hecho modificado en un aviso revisado después del entrenamiento.

Descargar el proyecto completo, revisión r001

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

Tres intervenciones distintas

El CPT continúa el objetivo del token siguiente a partir de pesos preentrenados. Aquí se adaptan todos los parámetros sobre avisos no anotados. Ninguna demostración de pregunta/respuesta entra en esta loss. Un SFT aprendería una transformación o una forma de responder a partir de objetivos explícitos; no es el experimento realizado. La búsqueda aumentada cambia el contexto proporcionado durante la inferencia y permite consultar un documento reciente sin modificar los pesos.

El modelo es Qwen/Qwen2.5-0.5B base, revisión 060db6499f32faf8b98477b0a26969ef7d8b9987, con tokenizer sin cambios y carga estricta. Tiene 24 bloques, anchura 896, 14 cabezas de consulta, dos cabezas K/V, MLP 4864 y vocabulario de modelo 151936. El embedding y la cabeza de salida están vinculados; se mide el número de parámetros únicos. El modelo base no es un asistente instruction-tuned. Un fallo de formato en una respuesta corta no anula una posible mejora de NLL y no debe ocultarse.

Datos y condiciones reservadas

NovaLex-v1 contiene 500 familias de ocho avisos: 350/75/75 familias, es decir, 2800/600/600 documentos. CommonWorkshop-v1 contiene 200 familias de cinco textos: 140/30/30 familias, es decir, 700/150/150 documentos. Las referencias, valores y combinaciones se generan antes de usarse; las variantes de una familia no atraviesan las particiones. Los patrones y el vocabulario siguen compartidos. Este corpus artificial no representa la complejidad de un fondo industrial.

Los 700 textos de train de control sirven para el replay. Los 150 textos de test de control nunca entran en él. Su delta de NLL mide una deriva únicamente en este perímetro sintético, no un olvido de todos los conocimientos generales. Las 200 preguntas se refieren a los 200 primeros avisos de test, nunca vistos por CPT. La evaluación cerrada no proporciona contexto: el modelo puede legítimamente ignorar estos hechos reservados. La evaluación abierta busca entre los 4000 avisos fuente, incluidos los de test; esta consulta se declara y es distinta del entrenamiento.

Cuarenta avisos de test tienen una versión 2, creada por separado y marcada inference_only. Su número de pulsos aumenta en dos. Los valores precisos figuran en los archivos generados; el ejemplo 3→5 describe el mecanismo sin imponer que todos los casos tengan estos mismos números. Las v2 nunca se montan en learning.

Objetivos, fronteras y presupuesto exacto

Cada texto se tokeniza sin chat template ni adición implícita de tokens. El identificador 151643 desempeña explícitamente el papel de ancla al inicio y de EOS al final. El mismo ID sirve para padding, pero attention_mask y labels se construyen por posición: un EOS real sigue siendo un objetivo; un PAD se ignora. Las ventanas de como máximo 512 tokens tienen un paso de 511. Cada token siguiente, incluido EOS, aparece exactamente una vez en el recorrido de un documento, sin concatenación entre documentos. El contexto se reinicia por ventana.

La propuesta inicial de 2048 objetivos por actualización no se divide exactamente en 80/20 enteros. Antes de toda ejecución, el protocolo fija 2050 objetivos × 512 actualizaciones = 1049600 objetivos por rama, es decir, 1024 más que la propuesta. Solo dominio: 2050 objetivos de negocio por paso. Replay: exactamente 1640 de negocio y 410 de control, es decir, 839680 y 209920 en total. El replay recibe menos tokens de negocio, pese al mismo presupuesto total: es parte de la intervención, no un detalle que omitir.

Un sampler recorre permutaciones de ventanas. Cuando la cuota corta una ventana, solo las posiciones objetivo seleccionadas participan en la loss; la ventana completa sigue disponible como contexto causal. La actualización siguiente se reanuda en el primer token objetivo no consumido. Los tokens futuros presentes en el tensor no pueden influir en los logits anteriores gracias a la máscara causal. El registro conserva ventana, inicio/fin de objetivos, contadores y tipo de flujo. Por tanto, la proporción no depende de la longitud de los documentos.

Los microbatches contienen una selección documental cada uno. La red realiza el forward en autocast BF16; los pesos maestros, gradientes y estados AdamW son FP32. Los logits seleccionados se convierten a FP32 para la CE. BF16 se verifica en la GPU real; no se utiliza GradScaler. La atención eager y los algoritmos deterministas están fijados, con TF32 desactivado. El coste de un forward también depende del contexto releído cuando una cuota corta una ventana: igualdad de tokens supervisados no implica igualdad perfecta de FLOPs.

AdamW utiliza lr máximo 0,00001, weight_decay 0,01, warm-up de 51 actualizaciones y después descenso lineal hacia el 10 % de la tasa máxima; clipping de norma a 1. El checkpoint final en 512 está fijado. La validación cada 128 pasos se informa sin selección. La interrupción en 200 y la reanudación deben recuperar exactamente modelo, optimizador, scheduler, estados aleatorios, permutaciones, cursores documentales y offsets de tokens. Los archivos .pt son estados producidos localmente y no deben sustituirse por contenido no fiable. Los hashes por tensor y Safetensors releídos estrictamente controlan los pesos finales.

La NLL y las respuestas no miden lo mismo

La NLL reservada utiliza los mismos tokens y denominadores para base, CPT de dominio y CPT con replay: 600 avisos de negocio y 150 textos de control. Se conservan las pérdidas unitarias y logits completos de referencia. La prueba compara la suma de pérdidas activas, una acumulación streaming y la referencia Transformers sobre el mismo batch. Una NLL que baja indica que estos textos se vuelven más probables; no prueba ni una respuesta correcta ni la memorización de hechos no vistos.

Los tres estados de pesos responden cada uno a las mismas 200 preguntas con contexto vacío y después top-3 léxico: seis condiciones explícitas. El prompt se mantiene idéntico salvo por la presencia del contexto y no se ajusta a los resultados de test. La generación greedy permite 32 tokens nuevos, sin restricción de gramática ni selección del primer número de una frase. Una respuesta debe ser completamente numérica tras retirar los espacios periféricos y terminar con EOS. UNKNOWN, textos parásitos y truncamientos son fallos de exact match. Las salidas brutas y los motivos permanecen presentes.

Índice léxico y procedencia

El TF-IDF se codifica con tf=1+log(recuento), idf=log((N+1)/(df+1))+1, normalización L2 y coseno. Solo indexa el texto de los 4000 avisos, nunca las etiquetas de respuesta. Las consultas se tokenizan del mismo modo; los empates de puntuación se resuelven por identificador. Los tres avisos retenidos aparecen en el prompt con su ID y versión. El recuerdo del documento esperado en el top-3 se calcula por separado de la exactitud generativa: recuperar el texto correcto no garantiza que se utilice correctamente.

El índice v1 permanece inmutable. La actualización produce un nuevo índice v2 que contiene exactamente 40 sustituciones y sus nuevos hashes, y después recalcula la búsqueda. No hay caché de contexto que pueda ocultar esta revisión. El ensamblaje rechaza una pareja resultado/versión incoherente; un prompt demasiado largo se rechaza sin truncamiento silencioso. El prompt abierto no supera 1024 tokens.

Ejercicio de revisión

Después de CPT, los pesos y resultados principales están protegidos mediante SHA. El ejercicio ejecuta de verdad 40 respuestas para cada una de las seis condiciones con el índice v2. Compara las respuestas v1 con los hechos antiguos y nuevos, y después las respuestas v2 con los hechos nuevos. Los prompts cerrados deben permanecer idénticos; se mide su estabilidad de salida. El número de nuevos pasos de aprendizaje es cero.

Una mejor disponibilidad del hecho reciente no garantiza una respuesta mejor. Por ello, los resultados de la búsqueda, de la generación y los hashes de pesos se exponen por separado. El replay puede limitar la deriva de NLL del control, sin garantía y con menos tokens de negocio. Solo se prueba una semilla de aprendizaje; habría que repetir y ampliar los datos antes de concluir que existe una estrategia de adaptación útil en producción.

Preparar avisos y un control separado

Partición documental y versiones

Invariante: Recuentos y familias separados; 40 revisiones v2 marcadas como añadido de inferencia.

Error frecuente: Un aviso y su paráfrasis atraviesan las particiones: particionar antes de las variantes.

Archivo src/preentrainement_continu/data.py

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

MODES = ["azur", "brume", "cuivre", "delta", "ambre", "givre", "orbe", "silex"]


def split_families(family, kind):
    a, b = (350, 425) if kind == "domain" else (140, 170)
    return "train" if family < a else "validation" if family < b else "test"


def notice_text(family, j, pulses, version=1):
    alias = f"N{family:04d}"
    mode = MODES[j]
    lead = f"Novaforge {alias}, notice {mode}, revision {version}. "
    variants = [
        f"The {mode} mode waits for {pulses} pulses before its cycle.",
        f"Before starting a cycle, {mode} mode expects {pulses} pulses.",
        f"The pulse count before a cycle in {mode} mode is {pulses}.",
        f"A cycle under {mode} mode begins after {pulses} pulses.",
        f"For {mode} mode, wait for {pulses} pulses before the cycle.",
        f"The {mode} cycle has a waiting count of {pulses} pulses.",
        f"The required wait before the {mode} cycle is {pulses} pulses.",
        f"In {mode} mode, {pulses} pulses precede the cycle.",
    ]
    return (
        lead
        + variants[j]
        + " This is a fictional language example, not an operating instruction."
    )


def generate_notices():
    pages = []
    facts = []
    for family in range(500):
        for j in range(8):
            value = 1 + (family * 7 + j * 3) % 9
            text = notice_text(family, j, value)
            identifier = f"N{family:04d}-{j}"
            pages.append(
                {
                    "document_id": identifier,
                    "kind": "domain",
                    "family": family,
                    "formulation": j,
                    "split": split_families(family, "domain"),
                    "version": 1,
                    "text": text,
                    "content_sha256": hashlib.sha256(text.encode()).hexdigest(),
                }
            )
            facts.append({"document_id": identifier, "pulses": value})
    for family in range(200):
        alias = f"W{family:04d}"
        count = 4 + family % 17
        place = ["north", "south", "east", "west"][family % 4]
        phrases = [
            f"Workshop {alias} keeps {count} notebooks on the {place} shelf. The inventory is a written record.",
            f"The {place} storage room of workshop {alias} contains {count} empty boxes. A clerk records their presence.",
            f"A team at workshop {alias} reads {count} short reports near the {place} desk. The reports describe routine observations.",
            f"Workshop {alias} has {count} labelled drawers in its {place} cabinet. A list records each drawer.",
            f"The {place} noticeboard at workshop {alias} holds {count} paper cards. The cards remain available for the next shift.",
        ]
        for j, text in enumerate(phrases):
            pages.append(
                {
                    "document_id": f"W{family:04d}-{j}",
                    "kind": "control",
                    "family": family,
                    "formulation": j,
                    "split": split_families(family, "control"),
                    "version": 1,
                    "text": text,
                    "content_sha256": hashlib.sha256(text.encode()).hexdigest(),
                }
            )
    if len({p["text"].casefold() for p in pages}) != 5000:
        raise ValueError("Duplicate normalized source")
    return pages, facts


def build_revision_set(pages, facts):
    lookup = {r["document_id"]: r["pulses"] for r in facts}
    reserved = [p for p in pages if p["kind"] == "domain" and p["split"] == "test"][
        :200
    ]
    questions = []
    gold = []
    revisions = []
    for index, p in enumerate(reserved):
        alias = f"N{p['family']:04d}"
        mode = MODES[p["formulation"]]
        qid = f"Q{index:03d}"
        question = (
            f"How many pulses precede the cycle in {mode} mode for Novaforge {alias}?"
        )
        questions.append({"question_id": qid, "question": question})
        value = lookup[p["document_id"]]
        gold.append(
            {
                "question_id": qid,
                "document_id": p["document_id"],
                "v1_answer": str(value),
                "v2_answer": str(value + 2) if index < 40 else str(value),
                "revised": index < 40,
            }
        )
        if index < 40:
            text = notice_text(p["family"], p["formulation"], value + 2, 2)
            revisions.append(
                {
                    **p,
                    "version": 2,
                    "text": text,
                    "content_sha256": hashlib.sha256(text.encode()).hexdigest(),
                    "inference_only": True,
                }
            )
    return questions, gold, revisions


def prepare(config):
    root = Path("data/generated")
    root.mkdir(parents=True, exist_ok=True)
    if (root / "manifest.json").exists():
        raise ValueError("Preserve corpus; use a clean extraction")
    pages, facts = generate_notices()
    save_json(root / "input/pages.json", pages)
    questions, gold, revisions = build_revision_set(pages, facts)
    save_json(root / "input/queries.json", questions)
    save_json(root / "input/revisions.json", revisions)
    save_json(root / "labels/gold.json", gold)
    audit = {}
    for kind in ["domain", "control"]:
        audit[kind] = {}
        for split in ["train", "validation", "test"]:
            selected = [p for p in pages if p["kind"] == kind and p["split"] == split]
            save_json(root / f"{split}/{kind}.json", selected)
            audit[kind][split] = {
                "documents": len(selected),
                "families": len({p["family"] for p in selected}),
            }
    manifest = {
        "datasets": ["NovaLex-v1", "CommonWorkshop-v1"],
        "rights": "CC0-1.0",
        "seed": config["seed"],
        "split_audit": audit,
        "questions": len(questions),
        "revisions": len(revisions),
        "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

Adquirir el modelo e inspeccionar su NLL

Modelo base, tokens y objetivos

Invariante: SHA resuelto, descarga reproducible, recuentos exactos de objetivos.

Error frecuente: EOS eliminado como si fuera PAD corta el aprendizaje de los finales: ID y attention_mask distintos.

Archivo src/preentrainement_continu/batch.py

import random, torch

EOS = 151643


def encode_documents(tokenizer, pages, context=512):
    if context < 2:
        raise ValueError("Context needs an input and a target")
    rows = []
    for page in pages:
        content = tokenizer.encode(page["text"], add_special_tokens=False)
        if not content:
            raise ValueError("Empty document")
        ids = [EOS] + content + [EOS]
        for start in range(0, len(ids) - 1, context - 1):
            rows.append(
                {
                    "document_id": page["document_id"],
                    "kind": page["kind"],
                    "window_start": start,
                    "ids": ids[start : start + context],
                }
            )
    return rows


def collate_documents(selections, rows, device="cuda", pad_to=None):
    if not selections:
        raise ValueError("Empty token budget")
    width = (
        max(len(rows[s["window"]]["ids"]) for s in selections)
        if pad_to is None
        else pad_to
    )
    ids = torch.full((len(selections), width), EOS, dtype=torch.long, device=device)
    mask = torch.zeros_like(ids, dtype=torch.bool)
    labels = torch.full_like(ids, -100)
    for i, selection in enumerate(selections):
        row = rows[selection["window"]]
        tokens = row["ids"]
        start = selection["target_start"]
        end = selection["target_end"]
        if len(tokens) > width or not 1 <= start < end <= len(tokens):
            raise ValueError("Invalid token slice; no silent truncation")
        ids[i, : len(tokens)] = torch.tensor(tokens, device=device)
        mask[i, : len(tokens)] = True
        labels[i, start:end] = torch.tensor(tokens[start:end], device=device)
    return ids, mask, labels


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


class TokenStream:
    def __init__(self, rows, seed):
        if not rows or any(len(r["ids"]) < 2 for r in rows):
            raise ValueError("A stream needs nonempty next-token targets")
        self.rows = rows
        self.generator = random.Random(seed)
        self.order = []
        self.cursor = 0
        self.target_offset = 0
        self.epoch = 0
        self.tokens = 0

    def take(self, quota):
        if quota < 0:
            raise ValueError("Negative quota")
        result = []
        while quota:
            if self.cursor == len(self.order):
                self.order = list(range(len(self.rows)))
                self.generator.shuffle(self.order)
                self.cursor = 0
                self.epoch += 1
            index = self.order[self.cursor]
            row = self.rows[index]
            remaining = len(row["ids"]) - 1 - self.target_offset
            take = min(quota, remaining)
            start = 1 + self.target_offset
            result.append(
                {
                    "window": index,
                    "document_id": row["document_id"],
                    "kind": row["kind"],
                    "window_start": row["window_start"],
                    "target_start": start,
                    "target_end": start + take,
                }
            )
            self.target_offset += take
            self.tokens += take
            quota -= take
            if self.target_offset == len(row["ids"]) - 1:
                self.target_offset = 0
                self.cursor += 1
        return result

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

    def load_state_dict(self, state):
        if sorted(state["order"]) != list(range(len(self.rows))) or not 0 <= state[
            "cursor"
        ] <= len(self.rows):
            raise ValueError("Invalid stream order/cursor")
        if state["cursor"] == len(self.rows):
            if state["target_offset"] != 0:
                raise ValueError("Invalid completed epoch offset")
        elif (
            not 0
            <= state["target_offset"]
            < len(self.rows[state["order"][state["cursor"]]]["ids"]) - 1
        ):
            raise ValueError("Invalid token cursor")
        self.order = list(state["order"])
        self.cursor = state["cursor"]
        self.target_offset = state["target_offset"]
        self.epoch = state["epoch"]
        self.tokens = state["tokens"]
        self.generator.setstate(state["rng"])


class TokenMixer:
    def __init__(self, domain, control, seed, branch):
        if branch not in ["domain", "replay"]:
            raise ValueError("Unknown CPT branch")
        self.branch = branch
        self.domain = TokenStream(domain, seed + 100)
        self.control = TokenStream(control, seed + 200)
        self.updates = 0

    def take(self):
        domain = self.domain.take(1640 if self.branch == "replay" else 2050)
        control = self.control.take(410) if self.branch == "replay" else []
        self.updates += 1
        return domain, control

    def state_dict(self):
        return {
            "branch": self.branch,
            "updates": self.updates,
            "domain": self.domain.state_dict(),
            "control": self.control.state_dict(),
        }

    def load_state_dict(self, state):
        if state["branch"] != self.branch:
            raise ValueError("Changed replay policy")
        self.domain.load_state_dict(state["domain"])
        if self.branch == "replay":
            self.control.load_state_dict(state["control"])
        elif state["control"] != self.control.state_dict():
            raise ValueError("Control stream changed in domain-only branch")
        self.updates = state["updates"]

Fijar la baseline y el índice abierto

Pesos frente a contexto de búsqueda

Invariante: Las consultas se ejecutan sin leer target; cada contexto cita un aviso existente.

Error frecuente: El campo de respuesta se indexa por inadvertencia: indexar solo los documentos fuente.

Archivo src/preentrainement_continu/retrieval.py

import collections, hashlib, json, math, re, time
from pathlib import Path
from .config import read_json, save_json, sha


def terms(text):
    return re.findall(r"\w+", text.casefold())


def vector(text, idf):
    counts = collections.Counter(terms(text))
    weights = {
        word: (1 + math.log(n)) * idf[word]
        for word, n in sorted(counts.items())
        if word in idf
    }
    norm = math.sqrt(math.fsum(v * v for v in weights.values()))
    return {word: value / norm for word, value in weights.items()} if norm else {}


def fit_tfidf_index(documents):
    begun = time.perf_counter()
    if not documents or len({d["document_id"] for d in documents}) != len(documents):
        raise ValueError("Empty or duplicate source ID")
    frequencies = collections.Counter()
    for document in documents:
        if document["kind"] != "domain":
            raise ValueError("The lexical index contains domain source texts only")
        if (
            hashlib.sha256(document["text"].encode()).hexdigest()
            != document["content_sha256"]
        ):
            raise ValueError("Source digest changed")
        frequencies.update(set(terms(document["text"])))
    n = len(documents)
    idf = {
        word: math.log((n + 1) / (count + 1)) + 1
        for word, count in sorted(frequencies.items())
    }
    rows = [
        {
            "document_id": d["document_id"],
            "version": d["version"],
            "text": d["text"],
            "content_sha256": d["content_sha256"],
            "vector": vector(d["text"], idf),
        }
        for d in documents
    ]
    return {
        "documents": rows,
        "idf": idf,
        "document_frequency": dict(sorted(frequencies.items())),
        "document_count": n,
        "build_seconds": time.perf_counter() - begun,
        "formula": "tf=1+log(count), idf=log((N+1)/(df+1))+1, L2 cosine; source text only",
    }


def retrieve_notices(index, question, k=3):
    if k < 1:
        raise ValueError("Positive retrieval count required")
    query = vector(question, index["idf"])
    scores = []
    for document in index["documents"]:
        score = math.fsum(
            query[word] * document["vector"].get(word, 0.0) for word in sorted(query)
        )
        scores.append(
            {
                "document_id": document["document_id"],
                "version": document["version"],
                "score": score,
            }
        )
    return sorted(scores, key=lambda row: (-row["score"], row["document_id"]))[:k]


def build_question_context(question, index, hits, condition):
    if condition not in ["closed", "open"]:
        raise ValueError("Name the evaluation condition")
    lookup = {d["document_id"]: d for d in index["documents"]}
    documents = [lookup[h["document_id"]] for h in hits] if condition == "open" else []
    for d, h in zip(documents, hits):
        if d["version"] != h["version"]:
            raise ValueError("Stale retrieval version")
    context = (
        "\n".join(f"[{d['document_id']}@{d['version']}] {d['text']}" for d in documents)
        if documents
        else "None supplied."
    )
    text = (
        "Answer with digits only. If the answer is unavailable, write UNKNOWN.\nContext:\n"
        + context
        + "\nQuestion: "
        + question
        + "\nAnswer:"
    )
    return text, [
        {
            "document_id": d["document_id"],
            "version": d["version"],
            "content_sha256": d["content_sha256"],
        }
        for d in documents
    ]


def build_index(version=1):
    if Path("data/generated/labels").exists():
        raise ValueError("Indexing service must not mount answer labels")
    pages = [
        p for p in read_json("data/generated/input/pages.json") if p["kind"] == "domain"
    ]
    changed = []
    if version == 2:
        revisions = read_json("data/generated/input/revisions.json")
        lookup = {p["document_id"]: p for p in pages}
        for revision in revisions:
            old = lookup[revision["document_id"]]
            if (
                old["version"] != 1
                or revision["version"] != 2
                or revision["split"] != "test"
                or not revision["inference_only"]
            ):
                raise ValueError("Unexpected revision boundary")
            lookup[revision["document_id"]] = revision
            changed.append(revision["document_id"])
        pages = [lookup[p["document_id"]] for p in pages]
    elif version != 1:
        raise ValueError("Expected index version1or2")
    path = Path(f"outputs/index-v{version}.json")
    if path.exists():
        raise ValueError("Never overwrite an index snapshot")
    index = fit_tfidf_index(pages)
    save_json(path, {**index, "snapshot_version": version, "revised_ids": changed})
    questions = read_json("data/generated/input/queries.json")
    rows = []
    for q in questions:
        begun = time.perf_counter()
        hits = retrieve_notices(index, q["question"], 3)
        rows.append({**q, "hits": hits, "seconds": time.perf_counter() - begun})
    save_json(
        f"outputs/retrieval-v{version}.json",
        {
            "index_sha256": sha(path),
            "snapshot_version": version,
            "rows": rows,
            "answer_labels_visible": False,
        },
    )
    print(
        {
            "snapshot_version": version,
            "documents": len(pages),
            "revised_documents": len(changed),
            "build_seconds": index["build_seconds"],
        },
        flush=True,
    )

Adaptar con y sin replay

Presupuesto de tokens y mezcla explícita

Invariante: Los pesos cambian y los contadores corresponden al protocolo; base inmutable.

Error frecuente: Una mezcla 80/20 anunciada se convierte en 95/5 por las longitudes: controlar el presupuesto por tokens.

Archivo src/preentrainement_continu/train.py

import math, time, shutil
from pathlib import Path
import torch
from .config import read_json, save_json, sha, seed_all, rng_state, restore_rng
from .model import (
    load_base,
    load_tokenizer,
    parameter_manifest,
    manifest_digest,
    save_learned,
)
from .batch import encode_documents, collate_documents, sum_token_nll, TokenMixer
from .state import equal, save_resume_state


def lr_at(config, step):
    warmup = config["warmup_updates"]
    peak = config["learning_rate"]
    total = config["optimizer_steps"]
    return (
        peak * (step + 1) / warmup
        if step < warmup
        else peak
        * (
            1
            - min(1.0, (step - warmup) / (total - warmup - 1))
            * (1 - config["minimum_lr_ratio"])
        )
    )


def training_rows(config, tokenizer):
    result = {}
    for kind, count in [("domain", 2800), ("control", 700)]:
        pages = read_json(f"data/generated/train/{kind}.json")
        if len(pages) != count or any(
            p["split"] != "train" or p["kind"] != kind or p["version"] != 1
            for p in pages
        ):
            raise ValueError("Replay/train/version boundary violated")
        result[kind] = encode_documents(tokenizer, pages, config["context"])
    return result


@torch.no_grad()
def validation(model, rows):
    model.eval()
    total = 0.0
    count = 0
    for index, row in enumerate(rows):
        selection = {"window": index, "target_start": 1, "target_end": len(row["ids"])}
        ids, mask, labels = collate_documents([selection], rows)
        with torch.autocast("cuda", dtype=torch.bfloat16):
            logits = model(input_ids=ids, attention_mask=mask, use_cache=False).logits
        _, n, values = sum_token_nll(logits, labels)
        total += float(values.double().sum())
        count += n
    model.train()
    return {
        "windows": len(rows),
        "active_tokens": count,
        "nll_nats_per_token": total / count,
    }


def train_cpt(config, branch, mode):
    if (
        Path("data/generated/test").exists()
        or Path("data/generated/input").exists()
        or Path("data/generated/labels").exists()
    ):
        raise ValueError("Learning container must only mount train and validation")
    if mode not in ["interrupt", "continuous", "resume"]:
        raise ValueError("Unknown mode")
    if not torch.cuda.is_bf16_supported():
        raise ValueError("This reference profile requires CUDA bfloat16 support")
    expected = (2050, 1049600, 1640, 410, 512)
    if (
        tuple(
            config[k]
            for k in [
                "tokens_per_update",
                "total_active_tokens",
                "replay_domain_tokens_per_update",
                "replay_control_tokens_per_update",
                "optimizer_steps",
            ]
        )
        != expected
    ):
        raise ValueError("Frozen exact80/20token budget changed")
    seed_all(config["seed"])
    tokenizer = load_tokenizer()
    rows = training_rows(config, tokenizer)
    model = load_base()
    initial = parameter_manifest(model)
    initial_digest = manifest_digest(initial)
    model = model.cuda()
    optimizer = torch.optim.AdamW(
        model.parameters(),
        lr=config["learning_rate"],
        weight_decay=config["weight_decay"],
    )
    mixer = TokenMixer(rows["domain"], rows["control"], config["seed"], branch)
    validation_rows = {
        k: encode_documents(
            tokenizer,
            read_json(f"data/generated/validation/{k}.json"),
            config["context"],
        )
        for k in ["domain", "control"]
    }
    identity = {
        "branch": branch,
        "config": {k: v for k, v in config.items() if k != "exercise"},
        "initial_model_digest": initial_digest,
        "base_sha256": sha("assets/model/model.safetensors"),
        "tokenizer_sha256": sha("assets/model/tokenizer.json"),
        "model_config_sha256": sha("assets/model/config.json"),
        "model_lock_sha256": sha("locks/models.lock.json"),
        "training_sha256": {
            k: sha(f"data/generated/train/{k}.json") for k in ["domain", "control"]
        },
        "validation_sha256": {
            k: sha(f"data/generated/validation/{k}.json") for k in ["domain", "control"]
        },
        "implementation_sha256": {
            n: sha("src/preentrainement_continu/" + n)
            for n in ["model.py", "batch.py", "train.py", "state.py"]
        },
    }
    root = Path("assets/checkpoints")
    destination = root / f"{branch}-{mode}.pt"
    history = []
    validation_history = []
    start = 0
    if destination.exists():
        raise ValueError("Preserve recorded checkpoint")
    if mode == "resume":
        state = torch.load(
            root / f"{branch}-interrupt.pt", map_location="cpu", weights_only=False
        )
        if state["identity"] != identity or state["scheduler"] != {
            "next_step": state["steps"],
            "next_lr": lr_at(config, state["steps"]),
        }:
            raise ValueError("Resume identity or scheduler changed")
        model.load_state_dict(state["model"], strict=True)
        optimizer.load_state_dict(state["optimizer"])
        mixer.load_state_dict(state["mixer"])
        restore_rng(state["rng"])
        start = state["steps"]
        history = state["history"]
        validation_history = state["validation_history"]
        del state
    stop = (
        config["interrupt_step"] if mode == "interrupt" else config["optimizer_steps"]
    )
    begun = time.perf_counter()
    torch.cuda.reset_peak_memory_stats()
    model.train()
    logit_dtype = None
    for step in range(start, stop):
        domain, control = mixer.take()
        denominator = sum(r["target_end"] - r["target_start"] for r in domain + control)
        if denominator != 2050:
            raise ValueError("Token budget drift")
        lr = lr_at(config, step)
        for group in optimizer.param_groups:
            group["lr"] = lr
        optimizer.zero_grad(set_to_none=True)
        loss_total = 0.0
        for kind, selections in [("domain", domain), ("control", control)]:
            for selection in selections:
                ids, mask, labels = collate_documents([selection], rows[kind])
                with torch.autocast("cuda", dtype=torch.bfloat16):
                    logits = model(
                        input_ids=ids, attention_mask=mask, use_cache=False
                    ).logits
                logit_dtype = str(logits.dtype)
                summed, n, values = sum_token_nll(logits, labels)
                if (
                    not torch.isfinite(summed)
                    or n != selection["target_end"] - selection["target_start"]
                ):
                    raise ValueError("Nonfinite or miscounted 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
        )
        if any(
            p.dtype != torch.float32 or p.grad is None or p.grad.dtype != torch.float32
            for p in model.parameters()
        ):
            raise ValueError("Expected finite FP32 master gradients")
        optimizer.step()
        history.append(
            {
                "step": step + 1,
                "selections": domain + control,
                "active_tokens": denominator,
                "domain_tokens": sum(
                    r["target_end"] - r["target_start"] for r in domain
                ),
                "control_tokens": sum(
                    r["target_end"] - r["target_start"] for r in control
                ),
                "total_tokens_seen": mixer.domain.tokens + mixer.control.tokens,
                "nll_nats_per_token": loss_total / denominator,
                "learning_rate": lr,
                "gradient_norm_before_clip": float(norm),
            }
        )
        if (step + 1) % config["validation_every"] == 0:
            observed = {k: validation(model, v) for k, v in validation_rows.items()}
            validation_history.append({"step": step + 1, "results": observed})
            print(
                {
                    "branch": branch,
                    "mode": mode,
                    "step": step + 1,
                    "total_tokens_seen": history[-1]["total_tokens_seen"],
                    "training_nll": history[-1]["nll_nats_per_token"],
                    "validation": observed,
                },
                flush=True,
            )
        elif (step + 1) % 32 == 0:
            print(
                {
                    "branch": branch,
                    "mode": mode,
                    "step": step + 1,
                    "total_tokens_seen": history[-1]["total_tokens_seen"],
                    "training_nll": history[-1]["nll_nats_per_token"],
                },
                flush=True,
            )
    torch.cuda.synchronize()
    final = parameter_manifest(model)
    final_digest = manifest_digest(final)
    if final_digest == initial_digest or logit_dtype != "torch.bfloat16":
        raise ValueError("Expected real updates and BF16 forward logits")
    state = {
        "identity": identity,
        "steps": stop,
        "model": model.state_dict(),
        "optimizer": optimizer.state_dict(),
        "scheduler": {"next_step": stop, "next_lr": lr_at(config, stop)},
        "mixer": mixer.state_dict(),
        "rng": rng_state(),
        "history": history,
        "validation_history": validation_history,
    }
    save_resume_state(destination, state)
    if mode == "resume":
        save_learned(model, root / f"{branch}-resume.safetensors", identity)
    save_json(
        f"outputs/train-{branch}-{mode}.json",
        {
            "branch": branch,
            "mode": mode,
            "identity": identity,
            "steps": stop,
            "history": history,
            "validation_history": validation_history,
            "tokenized_train": rows,
            "initial_parameters": initial,
            "final_parameters": final,
            "initial_model_digest": initial_digest,
            "final_model_digest": final_digest,
            "parameters": sum(p.numel() for p in model.parameters()),
            "active_tokens": mixer.domain.tokens + mixer.control.tokens,
            "domain_tokens": mixer.domain.tokens,
            "control_tokens": mixer.control.tokens,
            "checkpoint_sha256": sha(destination),
            "checkpoint_bytes": destination.stat().st_size,
            "precision": {
                "master": "torch.float32",
                "gradients": "torch.float32",
                "logits": logit_dtype,
                "ce": "torch.float32",
                "scaler": False,
            },
            "seconds": time.perf_counter() - begun,
            "max_torch_allocated_bytes": torch.cuda.max_memory_allocated(),
            "test_visible": False,
            "v2_visible": False,
        },
    )


def checkpoint_mixer(branch):
    root = Path("assets/checkpoints")
    a = torch.load(
        root / f"{branch}-continuous.pt", map_location="cpu", weights_only=False
    )
    b = torch.load(root / f"{branch}-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("CPT resume differs: " + str(checks))
    save_json(
        f"outputs/resume-{branch}.json",
        {
            "status": "PASS",
            "all_state_exact": True,
            "checks": checks,
            "boundary": "complete2050targettoken optimizer update; includes stream target offset",
        },
    )

Medir NLL y respuestas en condiciones nombradas

Evaluación cerrada y abierta

Invariante: Las mismas 200 preguntas por condición, con todos los fallos de formato contabilizados.

Error frecuente: Comparar un CPT sin contexto con una base con contexto sin decirlo mezcla dos intervenciones.

Archivo src/preentrainement_continu/evaluate.py

import hashlib, json, math, re, shutil, time
from pathlib import Path
import torch
from transformers import GenerationConfig
from .config import read_json, save_json, sha, seed_all
from .model import (
    load_base,
    load_tokenizer,
    reload_learned,
    parameter_manifest,
    manifest_digest,
)
from .batch import encode_documents, collate_documents, sum_token_nll, EOS
from .retrieval import build_question_context


def weight_state(variant):
    if variant == "base":
        model = load_base()
        path = Path("assets/model/model.safetensors")
        identity = manifest_digest(parameter_manifest(model))
    elif variant in ["domain", "replay"]:
        path = Path(f"assets/checkpoints/{variant}-resume.safetensors")
        model, metadata = reload_learned(path)
        identity = metadata["model_digest"]
    else:
        raise ValueError("Unknown weight state")
    return model, path, identity


def audit_base():
    model = load_base()
    rows = parameter_manifest(model)
    save_json(
        "outputs/base-parameters.json",
        {
            "parameters": rows,
            "unique_parameters": sum(r["numel"] for r in rows),
            "model_digest": manifest_digest(rows),
            "model_sha256": sha("assets/model/model.safetensors"),
            "tokenizer_sha256": sha("assets/model/tokenizer.json"),
            "all_master_fp32": all(
                p.dtype == torch.float32 for p in model.parameters()
            ),
            "tied_embedding_output": model.lm_head.weight
            is model.model.embed_tokens.weight,
        },
    )
    shutil.copyfile("assets/model/tokenizer.json", "outputs/tokenizer-reference.json")


def parse_short_answer(text, finish):
    stripped = text.strip()
    valid = re.fullmatch(r"[0-9]{1,3}", stripped) is not None
    return {
        "numeric_format": valid,
        "completion_valid": valid and finish == "eos",
        "answer": stripped if valid else None,
        "reason": (
            "TRUNCATED"
            if finish != "eos"
            else "VALID_INTEGER" if valid else "UNKNOWN_OR_INVALID_FORMAT"
        ),
    }


@torch.no_grad()
def predict_questions(config, variant, version=1):
    if Path("data/generated/labels").exists():
        raise ValueError("Question generation must not mount answer labels")
    if version not in [1, 2]:
        raise ValueError("Index version must be explicit")
    seed_all(config["seed"])
    model, path, identity = weight_state(variant)
    model = model.cuda().eval()
    tokenizer = load_tokenizer()
    index_path = Path(f"outputs/index-v{version}.json")
    index = read_json(index_path)
    retrieval = read_json(f"outputs/retrieval-v{version}.json")
    if retrieval["index_sha256"] != sha(index_path):
        raise ValueError("Stale retrieval snapshot")
    selected = retrieval["rows"] if version == 1 else retrieval["rows"][:40]
    generation = GenerationConfig(
        do_sample=False,
        num_beams=1,
        use_cache=True,
        max_new_tokens=config["max_new_tokens"],
        eos_token_id=EOS,
        pad_token_id=EOS,
        bos_token_id=EOS,
    )
    result = []
    begun = time.perf_counter()
    torch.cuda.reset_peak_memory_stats()
    for condition in ["closed", "open"]:
        for q in selected:
            text, sources = build_question_context(
                q["question"], index, q["hits"], condition
            )
            ids = tokenizer.encode(text, add_special_tokens=False)
            if not ids or len(ids) > config["generation_max_prompt_tokens"]:
                raise ValueError(
                    "No prompt truncation: declared context budget exceeded"
                )
            input_ids = torch.tensor([ids], device="cuda")
            mask = torch.ones_like(input_ids)
            started = time.perf_counter()
            with torch.autocast("cuda", dtype=torch.bfloat16):
                generated = (
                    model.generate(
                        input_ids=input_ids,
                        attention_mask=mask,
                        generation_config=generation,
                    )[0, len(ids) :]
                    .cpu()
                    .tolist()
                )
            torch.cuda.synchronize()
            finish = "eos" if generated and generated[-1] == EOS else "length"
            content = generated[:-1] if finish == "eos" else generated
            decoded = tokenizer.decode(
                content, skip_special_tokens=False, clean_up_tokenization_spaces=False
            )
            result.append(
                {
                    "question_id": q["question_id"],
                    "question": q["question"],
                    "condition": condition,
                    "input_text": text,
                    "input_ids": ids,
                    "source_documents": sources,
                    "prompt_sha256": hashlib.sha256(text.encode()).hexdigest(),
                    "generated_ids": generated,
                    "text": decoded,
                    "finish_reason": finish,
                    "parsed": parse_short_answer(decoded, finish),
                    "seconds": time.perf_counter() - started,
                }
            )
        print(
            {
                "variant": variant,
                "version": version,
                "condition": condition,
                "questions": len(selected),
            },
            flush=True,
        )
    save_json(
        f"outputs/answers-{variant}-v{version}.json",
        {
            "variant": variant,
            "snapshot_version": version,
            "model_digest": identity,
            "model_file_sha256": sha(path),
            "tokenizer_sha256": sha("assets/model/tokenizer.json"),
            "index_sha256": sha(index_path),
            "generation": generation.to_dict(),
            "rows": result,
            "seconds": time.perf_counter() - begun,
            "max_torch_allocated_bytes": torch.cuda.max_memory_allocated(),
            "answer_labels_visible": False,
            "weights_updated": False,
        },
    )


@torch.no_grad()
def evaluate_nll(config, variant):
    seed_all(config["seed"])
    model, path, identity = weight_state(variant)
    model = model.cuda().eval()
    tokenizer = load_tokenizer()
    pages = read_json("data/generated/input/pages.json")
    results = {}
    for kind, count in [("domain", 600), ("control", 150)]:
        selected = [p for p in pages if p["kind"] == kind and p["split"] == "test"]
        if len(selected) != count:
            raise ValueError("Expected frozen heldout documents")
        rows = encode_documents(tokenizer, selected, config["context"])
        records = []
        probes = []
        for index, row in enumerate(rows):
            selection = {
                "window": index,
                "target_start": 1,
                "target_end": len(row["ids"]),
            }
            ids, mask, labels = collate_documents([selection], rows)
            with torch.autocast("cuda", dtype=torch.bfloat16):
                logits = model(
                    input_ids=ids, attention_mask=mask, use_cache=False
                ).logits
            _, n, values = sum_token_nll(logits, labels)
            records.append(
                {
                    **row,
                    "active_tokens": n,
                    "targets": row["ids"][1:],
                    "token_nll": values.cpu().tolist(),
                }
            )
            if index == 0:
                for pos in [0, n - 1]:
                    probes.append(
                        {
                            "document_id": row["document_id"],
                            "target_position": pos + 1,
                            "target": row["ids"][pos + 1],
                            "logits": logits[0, pos].float().cpu().tolist(),
                            "nll": float(values[pos]),
                        }
                    )
        total = math.fsum(v for row in records for v in row["token_nll"])
        tokens = sum(r["active_tokens"] for r in records)
        results[kind] = {
            "documents": count,
            "active_tokens": tokens,
            "nll_nats_per_token": total / tokens,
            "rows": records,
            "probes": probes,
        }
    save_json(
        f"outputs/nll-{variant}.json",
        {
            "variant": variant,
            "model_digest": identity,
            "model_file_sha256": sha(path),
            "tokenizer_sha256": sha("assets/model/tokenizer.json"),
            "precision": "FP32 master weights, BF16 autocast, FP32 CE",
            "results": results,
        },
    )


def score_short_answers(rows, gold, version):
    details = []
    correct = numeric = valid = truncated = 0
    for row in rows:
        parsed = parse_short_answer(row["text"], row["finish_reason"])
        if parsed != row["parsed"]:
            raise ValueError("Stored parse changed")
        expected = gold[row["question_id"]][f"v{version}_answer"]
        exact = parsed["completion_valid"] and parsed["answer"] == expected
        correct += exact
        numeric += parsed["numeric_format"]
        valid += parsed["completion_valid"]
        truncated += row["finish_reason"] == "length"
        details.append(
            {
                "question_id": row["question_id"],
                "expected": expected,
                "exact_match": exact,
                "revised": gold[row["question_id"]]["revised"],
            }
        )
    n = len(rows)
    return {
        "questions": n,
        "numeric_format": numeric,
        "valid_completions": valid,
        "exact_match": correct,
        "exact_match_rate": correct / n,
        "truncated": truncated,
        "rows": details,
    }


def evaluate_retrieval(version):
    gold = {r["question_id"]: r for r in read_json("data/generated/labels/gold.json")}
    retrieval = read_json(f"outputs/retrieval-v{version}.json")
    details = []
    for q in retrieval["rows"]:
        label = gold[q["question_id"]]
        expected_version = 2 if version == 2 and label["revised"] else 1
        hit = any(
            h["document_id"] == label["document_id"]
            and h["version"] == expected_version
            for h in q["hits"]
        )
        details.append(
            {
                "question_id": q["question_id"],
                "expected_document_id": label["document_id"],
                "expected_version": expected_version,
                "hit_top3": hit,
            }
        )
    return {
        "questions": len(details),
        "hit_top3": sum(r["hit_top3"] for r in details),
        "recall_top3": sum(r["hit_top3"] for r in details) / len(details),
        "rows": details,
    }


def compare_results(config):
    gold = {r["question_id"]: r for r in read_json("data/generated/labels/gold.json")}
    result = {}
    base = read_json("outputs/nll-base.json")
    for variant in ["base", "domain", "replay"]:
        answers = read_json(f"outputs/answers-{variant}-v1.json")
        nll = read_json(f"outputs/nll-{variant}.json")
        result[variant] = {
            "nll": {
                kind: {
                    "documents": r["documents"],
                    "active_tokens": r["active_tokens"],
                    "nll_nats_per_token": r["nll_nats_per_token"],
                    "delta_from_base": r["nll_nats_per_token"]
                    - base["results"][kind]["nll_nats_per_token"],
                }
                for kind, r in nll["results"].items()
            },
            "answers": {
                condition: score_short_answers(
                    [r for r in answers["rows"] if r["condition"] == condition], gold, 1
                )
                for condition in ["closed", "open"]
            },
        }
    save_json(
        "outputs/comparison.json",
        {
            "variants": result,
            "retrieval": evaluate_retrieval(1),
            "quality_gain_required": False,
            "control_scope": "150synthetic heldout workshop texts, not a benchmark of all general capabilities",
            "open_book_test": True,
        },
    )

Actualizar un hecho sin actualizar los pesos

Prueba contrafactual de versión

Invariante: El índice hace referencia a v2 y los pesos conservan su hash.

Error frecuente: La antigua caché de contexto sobrevive a la revisión: incluir la versión documental en su clave.

Archivo src/preentrainement_continu/exercise.py

from pathlib import Path
from .config import read_json, save_json, sha
from .evaluate import score_short_answers, evaluate_retrieval


def preserve_main():
    paths = [
        "assets/model/model.safetensors",
        "assets/checkpoints/domain-resume.safetensors",
        "assets/checkpoints/replay-resume.safetensors",
        "outputs/index-v1.json",
        "outputs/retrieval-v1.json",
        "outputs/comparison.json",
    ]
    for variant in ["base", "domain", "replay"]:
        paths.extend(
            [f"outputs/nll-{variant}.json", f"outputs/answers-{variant}-v1.json"]
        )
    save_json("outputs/exercise-before.json", {name: sha(name) for name in paths})


def compare_revision_answers():
    before = read_json("outputs/exercise-before.json")
    if any(sha(name) != h for name, h in before.items()):
        raise ValueError("Revision exercise changed a weight or main artifact")
    gold = {r["question_id"]: r for r in read_json("data/generated/labels/gold.json")}
    selected = {r["question_id"] for r in gold.values() if r["revised"]}
    result = {}
    if len(selected) != 40:
        raise ValueError("Expected40revised notices")
    for variant in ["base", "domain", "replay"]:
        old = read_json(f"outputs/answers-{variant}-v1.json")
        new = read_json(f"outputs/answers-{variant}-v2.json")
        if (
            old["model_digest"] != new["model_digest"]
            or old["model_file_sha256"] != new["model_file_sha256"]
        ):
            raise ValueError("Weights changed between document revisions")
        result[variant] = {}
        for condition in ["closed", "open"]:
            a = [
                r
                for r in old["rows"]
                if r["condition"] == condition and r["question_id"] in selected
            ]
            b = [r for r in new["rows"] if r["condition"] == condition]
            if (
                len(a) != 40
                or len(b) != 40
                or [r["question_id"] for r in a] != [r["question_id"] for r in b]
            ):
                raise ValueError("Revised question population changed")
            same_inputs = [x["input_ids"] == y["input_ids"] for x, y in zip(a, b)]
            same_outputs = [
                x["generated_ids"] == y["generated_ids"] for x, y in zip(a, b)
            ]
            if condition == "closed" and not all(same_inputs):
                raise ValueError("Closed condition gained context")
            result[variant][condition] = {
                "v1_answers_against_old_facts": score_short_answers(a, gold, 1),
                "v1_answers_against_new_facts": score_short_answers(a, gold, 2),
                "v2_answers_against_new_facts": score_short_answers(b, gold, 2),
                "identical_input_count": sum(same_inputs),
                "identical_output_count": sum(same_outputs),
            }
    save_json(
        "outputs/exercise/revised-notices.json",
        {
            "variants": result,
            "retrieval_v1": evaluate_retrieval(1),
            "retrieval_v2": evaluate_retrieval(2),
            "protected_sha256": before,
            "weights_and_main_artifacts_unchanged": True,
            "new_training_steps": 0,
            "quality_gain_required": False,
        },
    )

Fuentes

Don’t Stop Pretraining aporta el contexto de la adaptación de dominio; sus resultados no son los de este TP causal. Ficha Qwen2.5-0.5B y objetivo causal de Transformers fueron consultados. La carga y las versiones ejecutadas están bloqueadas localmente. Docker GPU y NVIDIA Container Toolkit documentan los requisitos previos.

Resultados observados durante el desarrollo con GPU

El recorrido completo tuvo éxito con 14 pruebas en 13 673,51 segundos. Las dos ramas realizan cada una 512 actualizaciones y 1 049 600 objetivos supervisados. El replay sustituye el 20 % de los objetivos de negocio por 209 920 objetivos de control; conserva por tanto 839 680 objetivos de negocio. No ve tanto texto de negocio como la rama dedicada. Las reanudaciones tras 200 pasos recuperan exactamente los pesos, el optimizador, los flujos de tokens, los RNG y los historiales de los caminos continuos. La reproducción desde el ZIP público sigue siendo una comprobación distinta.

Sobre los 600 avisos de negocio de test, es decir, 25 275 objetivos, la NLL pasa de 5,197560 a 0,429035 tras CPT de negocio y a 0,417514 con replay. Sobre los 150 textos de control de test, es decir, 3 840 objetivos, pasa de 5,193446 a 6,220325 tras CPT de negocio, frente a 1,255818 con replay. Esto muestra aquí una deriva en este control ficticio restringido y su atenuación mediante la mezcla. Este control no mide el conjunto de las competencias generales del modelo. Los textos de validation son distintos y sus recuentos de tokens no deben sustituirse por los de test.

La pérdida y la calidad de las respuestas libres no cuentan lo mismo. Sin contexto, el modelo base responde exactamente a 13/200 preguntas, frente a 21/200 para cada una de las ramas entrenadas. Con los tres documentos recuperados, alcanza 200/200, frente a 168/200 tras CPT de negocio y 178/200 con replay. El recuerdo documental top-3 vale 200/200: los errores de las ramas entrenadas persisten aun cuando se proporciona el aviso correcto. No se observa ninguna ventaja del CPT sobre la lectura documental del modelo base. Las 41 salidas truncadas del modelo base sin contexto permanecen en el denominador; no se repara ninguna respuesta parcial para mejorar la puntuación.

El ejercicio revisa 40 hechos en los documentos consultables, sin entrenar de nuevo los modelos. Sobre los nuevos valores, la consulta del índice v2 da 39/40 respuestas exactas con el modelo base, 34/40 tras CPT de negocio y 36/40 con replay. Sin documentos, las entradas y las salidas permanecen idénticas en los 40 casos; ninguna respuesta es exacta para los valores revisados. El recuerdo top-3 del índice revisado es 199/200 sobre el conjunto de preguntas. La actualización documental permite exponer un hecho nuevo, sin garantizar una lectura correcta de cada caso.

El hilo conductor realmente conservado es el aviso N0425-0: el valor «6 pulses» pasa a ser «8 pulses». Las tres variantes responden 6 con el documento antiguo y 8 con su revisión, con parada EOS. Este ejemplo es un caso exitoso entre las poblaciones completas, no un sustituto de su evaluación. Los avisos de test consultados nunca se usaron para el aprendizaje CPT: se trata explícitamente de una prueba con documentos proporcionados.

Los caminos continuos duran 3029.10 s (negocio) y 3412.02 s (replay), con validación y guardado. Los picos de asignación PyTorch correspondientes son 9990552576 y 9984970752 bytes. No representan toda la VRAM vista por Linux. Las ejecuciones interrumpidas y reanudadas, la adquisición, las pruebas, la inferencia y las auditorías explican el coste superior del recorrido completo. Durante este recorrido se prepararon en CPU otros tutoriales: no se pretende medir un host completamente inactivo.

Una sola semilla, un corpus sintético cerrado y un formato de respuesta numérico limitado no bastan para establecer un beneficio de negocio general. El replay tiene aquí un interés medido sobre el control de lengua; el CPT no es ni una base documental actualizable ni una garantía de exactitud factual.

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",
  "domain_documents": 4000,
  "control_documents": 1000,
  "questions": 200,
  "revised_questions": 40,
  "cpt_tokens_per_branch": 1049600,
  "replay_domain_tokens": 839680,
  "replay_control_tokens": 209920,
  "metrics": {
    "base": {
      "nll": {
        "domain": {
          "documents": 600,
          "active_tokens": 25275,
          "nll_nats_per_token": 5.197560175851901,
          "delta_from_base": 0.0
        },
        "control": {
          "documents": 150,
          "active_tokens": 3840,
          "nll_nats_per_token": 5.19344591589955,
          "delta_from_base": 0.0
        }
      },
      "answers": {
        "closed": {
          "questions": 200,
          "numeric_format": 159,
          "valid_completions": 159,
          "exact_match": 13,
          "exact_match_rate": 0.065,
          "truncated": 41
        },
        "open": {
          "questions": 200,
          "numeric_format": 200,
          "valid_completions": 200,
          "exact_match": 200,
          "exact_match_rate": 1.0,
          "truncated": 0
        }
      }
    },
    "domain": {
      "nll": {
        "domain": {
          "documents": 600,
          "active_tokens": 25275,
          "nll_nats_per_token": 0.42903490273272693,
          "delta_from_base": -4.768525273119174
        },
        "control": {
          "documents": 150,
          "active_tokens": 3840,
          "nll_nats_per_token": 6.220325457319268,
          "delta_from_base": 1.026879541419718
        }
      },
      "answers": {
        "closed": {
          "questions": 200,
          "numeric_format": 200,
          "valid_completions": 200,
          "exact_match": 21,
          "exact_match_rate": 0.105,
          "truncated": 0
        },
        "open": {
          "questions": 200,
          "numeric_format": 197,
          "valid_completions": 197,
          "exact_match": 168,
          "exact_match_rate": 0.84,
          "truncated": 0
        }
      }
    },
    "replay": {
      "nll": {
        "domain": {
          "documents": 600,
          "active_tokens": 25275,
          "nll_nats_per_token": 0.4175135541870015,
          "delta_from_base": -4.7800466216649
        },
        "control": {
          "documents": 150,
          "active_tokens": 3840,
          "nll_nats_per_token": 1.255818447340746,
          "delta_from_base": -3.9376274685588033
        }
      },
      "answers": {
        "closed": {
          "questions": 200,
          "numeric_format": 200,
          "valid_completions": 200,
          "exact_match": 21,
          "exact_match_rate": 0.105,
          "truncated": 0
        },
        "open": {
          "questions": 200,
          "numeric_format": 200,
          "valid_completions": 200,
          "exact_match": 178,
          "exact_match_rate": 0.89,
          "truncated": 0
        }
      }
    }
  },
  "quality_gain_required": false,
  "scope": "Data-only per-token sampler replay, tokenizer decoding, parameter manifests, resume evidence, all TF-IDF/cosines, CE probes, raw answers and revision metrics; no full BPE re-encoding or scalar Transformer reconstruction"
}

Los resultados observados, su alcance y el experimento propuesto.

Compartir este artículo