Parliamo del progetto
Intelligenza artificiale

Proseguire il preaddestramento di un LLM su un vocabolario aziendale senza confondere adattamento e RAG

Il servizio tecnico fittizio Novaforge usa codici e formulazioni ricorrenti nelle sue schede. Confrontate le schede prima e dopo l'adattamento, poi le risposte brevi con o senza il contesto di un indice lessicale locale.

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

Il servizio tecnico fittizio Novaforge usa codici e formulazioni che ricorrono nelle sue schede. Vogliamo adattare la distribuzione linguistica del modello, mantenendo al tempo stesso un modo per consultare schede aggiornate quando i fatti cambiano.

Confrontate la probabilità delle schede prima e dopo l'adattamento, poi le risposte brevi a domande con o senza il contesto fornito da un indice lessicale locale. Mostrate un fatto modificato in una scheda aggiornata dopo l'addestramento.

Scaricare il progetto completo, revisione r001

Il percorso del tutorial, dal caso concreto alla verifica.

Tre interventi diversi

Il CPT prosegue l'obiettivo del token successivo a partire da pesi preaddestrati. Qui tutti i parametri vengono adattati su schede non annotate. Nessun esempio domanda/risposta entra in questa loss. Un SFT imparerebbe una trasformazione o un modo di rispondere da obiettivi espliciti; non è l'esperimento svolto. La ricerca aumentata modifica il contesto fornito durante l'inferenza e permette di consultare un documento recente senza cambiare i pesi.

Il modello è Qwen/Qwen2.5-0.5B base, revisione 060db6499f32faf8b98477b0a26969ef7d8b9987, con tokenizer invariato e caricamento rigoroso. Ha 24 blocchi, larghezza 896, 14 teste di query, due teste K/V, MLP 4864 e vocabolario del modello di 151936 elementi. L'embedding e la testa di output sono condivisi; viene misurato il numero di parametri unici. Il modello di base non è un assistente instruction-tuned. Un fallimento di formato in una risposta breve non annulla un eventuale miglioramento della NLL e non deve essere nascosto.

Dati e condizioni riservati

NovaLex-v1 contiene 500 famiglie di otto schede: 350/75/75 famiglie, cioè 2800/600/600 documenti. CommonWorkshop-v1 contiene 200 famiglie di cinque testi: 140/30/30 famiglie, cioè 700/150/150 documenti. Riferimenti, valori e combinazioni sono generati prima dell'uso; le varianti di una famiglia non attraversano le partizioni. I modelli di frase e il vocabolario restano condivisi. Questo corpus artificiale non rappresenta la complessità di un archivio industriale.

I 700 testi di controllo train servono per il replay. I 150 testi di controllo test non vi entrano mai. La loro variazione di NLL misura una deriva solo su questo perimetro sintetico, non la perdita di tutte le conoscenze generali. Le 200 domande riguardano le prime 200 schede test, mai viste dal CPT. La valutazione chiusa non fornisce alcun contesto: il modello può legittimamente ignorare questi fatti riservati. La valutazione aperta cerca nelle 4000 schede sorgenti, comprese quelle test; questa consultazione è dichiarata e distinta dall'addestramento.

Quaranta schede test hanno una versione 2, creata separatamente e contrassegnata inference_only. Il loro numero di impulsi aumenta di due. I valori precisi sono nei file generati; l'esempio 3→5 descrive il meccanismo senza imporre che ogni caso abbia gli stessi numeri. Le v2 non vengono mai caricate in learning.

Obiettivi, confini e budget esatto

Ogni testo viene tokenizzato senza chat template né aggiunta implicita di token. L'identificativo 151643 svolge esplicitamente il ruolo di ancora all'inizio e di EOS alla fine. Lo stesso ID serve per il padding, ma attention_mask e labels sono costruiti per posizione: un vero EOS resta un obiettivo, mentre un PAD viene ignorato. Le finestre di massimo 512 token hanno un passo di 511. Ogni token successivo, incluso EOS, appare esattamente una volta nel percorso di un documento, senza concatenazione tra documenti. Il contesto viene reimpostato a ogni finestra.

La proposta iniziale di 2048 obiettivi per aggiornamento non si divide esattamente in interi 80/20. Prima di ogni esecuzione, il protocollo fissa 2050 obiettivi × 512 aggiornamenti = 1049600 obiettivi per ramo, cioè 1024 in più della proposta. Solo dominio: 2050 obiettivi aziendali per passo. Replay: esattamente 1640 aziendali e 410 di controllo, dunque 839680 e 209920 in totale. Il replay riceve meno token aziendali, pur avendo lo stesso budget totale: è una parte dell'intervento, non un dettaglio da omettere.

Un sampler percorre permutazioni di finestre. Quando la quota taglia una finestra, solo le posizioni obiettivo scelte partecipano alla loss; la finestra completa resta disponibile come contesto causale. L'aggiornamento successivo riprende dal primo token obiettivo non consumato. I token futuri presenti nel tensore non possono influenzare i logits precedenti grazie alla maschera causale. Il registro conserva finestra, inizio/fine degli obiettivi, contatori e tipo di flusso. Il rapporto non dipende quindi dalla lunghezza dei documenti.

I microbatch contengono ciascuno una selezione documentale. La rete esegue il forward in autocast BF16; pesi master, gradienti e stati AdamW sono FP32. I logits selezionati vengono convertiti in FP32 per la CE. BF16 è verificato sulla GPU reale; non viene usato alcun GradScaler. L'attenzione eager e gli algoritmi deterministici sono fissati, TF32 disattivato. Il costo di un forward dipende anche dal contesto riletto quando una quota taglia una finestra: l'uguaglianza dei token supervisionati non implica una perfetta uguaglianza dei FLOPs.

AdamW usa lr massimo 0,00001, weight_decay 0,01, warm-up di 51 aggiornamenti poi decrescita lineare verso il 10% del tasso massimo; clipping della norma a 1. Il checkpoint finale a 512 è fissato. La validazione ogni 128 passi è riportata senza selezione. L'interruzione a 200 e la ripresa devono ritrovare esattamente modello, ottimizzatore, scheduler, stati casuali, permutazioni, cursori documentali e offset dei token. I file .pt sono stati prodotti localmente e non vanno sostituiti con contenuto non affidabile. Gli hash per tensore e Safetensors riletti rigorosamente controllano i pesi finali.

NLL e risposte non misurano la stessa cosa

La NLL riservata usa gli stessi token e denominatori per base, CPT dominio e CPT replay: 600 schede aziendali e 150 testi di controllo. Sono conservate le perdite unitarie e logits completi di riferimento. Il test confronta la somma delle perdite attive, un'accumulazione streaming e il riferimento Transformers sullo stesso batch. Una NLL che scende indica che questi testi diventano più probabili; non dimostra né una risposta corretta né la memorizzazione di fatti non visti.

I tre stati dei pesi rispondono ciascuno alle stesse 200 domande prima con contesto vuoto e poi con top-3 lessicale: sei condizioni esplicite. Il prompt resta identico salvo la presenza del contesto e non viene adattato ai risultati test. La generazione greedy consente 32 nuovi token, senza vincoli di grammatica né selezione del primo numero di una frase. Una risposta deve essere interamente numerica dopo aver rimosso gli spazi ai margini e terminare con EOS. UNKNOWN, testi estranei e troncamenti sono fallimenti di exact match. Le uscite grezze e le ragioni restano disponibili.

Indice lessicale e provenienza

Il TF-IDF è codificato con tf=1+log(count), idf=log((N+1)/(df+1))+1, normalizzazione L2 e coseno. Indicizza solo il testo delle 4000 schede, mai le label di risposta. Le query vengono tokenizzate nello stesso modo; le parità di punteggio sono risolte dall'identificativo. Le tre schede selezionate appaiono nel prompt con ID e versione. Il recupero del documento atteso nel top-3 è calcolato separatamente dall'esattezza generativa: ritrovare il testo giusto non garantisce che venga usato correttamente.

L'indice v1 resta immutabile. L'aggiornamento produce un nuovo indice v2 contenente esattamente 40 sostituzioni e i loro nuovi hash, poi ricalcola la ricerca. Non esiste una cache di contesto che possa nascondere questa revisione. L'assemblaggio rifiuta una coppia risultato/versione incoerente; un prompt troppo lungo viene respinto senza troncamento silenzioso. Il prompt aperto non supera 1024 token.

Esercizio di revisione

Dopo il CPT, i pesi e i risultati principali sono protetti da SHA. L'esercizio esegue realmente 40 risposte per ciascuna delle sei condizioni con l'indice v2. Confronta le risposte v1 con i fatti vecchi e nuovi, poi le risposte v2 con i fatti nuovi. I prompt chiusi devono restare identici; viene misurata la stabilità delle loro uscite. Il numero di nuovi passi di apprendimento è zero.

Una migliore disponibilità di un fatto recente non garantisce una risposta migliore. I risultati della ricerca, della generazione e gli hash dei pesi sono quindi esposti separatamente. Il replay può limitare la deriva della NLL di controllo, senza garanzia e con meno token aziendali. Viene testato un solo seme di apprendimento; occorrerebbe ripetere e ampliare i dati prima di concludere che una strategia di adattamento sia utile in produzione.

Preparare schede e un controllo separato

Partizione documentale e versioni

Invariante: conteggi e famiglie separati; 40 revisioni v2 contrassegnate come aggiunta per l'inferenza.

Errore frequente: una scheda e la sua parafrasi attraversano gli split; effettuate la partizione prima delle varianti.

File 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

Acquisire il modello e ispezionarne la NLL

Modello di base, token e obiettivi

Invariante: SHA risolto, download riproducibile, conteggi esatti degli obiettivi.

Errore frequente: rimuovere EOS come se fosse PAD interrompe l'apprendimento delle finali; ID e attention_mask distinti.

File 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"]

Fissare la baseline e l'indice aperto

Pesi rispetto al contesto di ricerca

Invariante: le query vengono eseguite senza leggere target; ogni contesto cita una scheda esistente.

Errore frequente: il campo di risposta viene indicizzato inavvertitamente; indicizzate solo i documenti sorgenti.

File 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,
    )

Adattare con e senza replay

Budget in token e miscela esplicita

Invariante: i pesi cambiano e i contatori corrispondono al protocollo; la base resta immutabile.

Errore frequente: una miscela 80/20 annunciata diventa 95/5 a causa delle lunghezze; guidate il budget tramite i token.

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

Misurare NLL e risposte in condizioni denominate

Valutazione chiusa e aperta

Invariante: le stesse 200 domande per condizione, contando tutti gli errori di formato.

Errore frequente: confrontare un CPT senza contesto con una base con contesto senza dichiararlo mescola due interventi.

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

Aggiornare un fatto senza aggiornare i pesi

Test controfattuale di versione

Invariante: l'indice fa riferimento alla v2 e i pesi conservano il loro hash.

Errore frequente: la vecchia cache di contesto sopravvive alla revisione; includete la versione del documento nella sua chiave.

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

Fonti

Don’t Stop Pretraining fornisce il contesto dell'adattamento di dominio; i suoi risultati non sono quelli di questo laboratorio causale. Sono stati consultati la scheda Qwen2.5-0.5B e l'obiettivo causale di Transformers. Il caricamento e le versioni eseguite sono bloccati localmente. Docker GPU e NVIDIA Container Toolkit documentano i prerequisiti.

Risultati osservati durante lo sviluppo su GPU

Il percorso completo è riuscito con 14 test in 13 673,51 secondi. I due rami eseguono ciascuno 512 aggiornamenti e 1 049 600 obiettivi supervisionati. Il replay sostituisce il 20% degli obiettivi aziendali con 209 920 obiettivi di controllo; conserva quindi 839 680 obiettivi aziendali. Non vede tanto testo aziendale quanto il ramo dedicato. Le riprese dopo 200 passi ritrovano esattamente pesi, ottimizzatore, flussi di token, RNG e cronologia dei percorsi continui. La riproduzione dallo ZIP pubblico resta una verifica distinta.

Sulle 600 schede aziendali test, cioè 25 275 obiettivi, la NLL passa da 5,197560 a 0,429035 dopo CPT aziendale e a 0,417514 con replay. Sui 150 testi di controllo test, cioè 3 840 obiettivi, passa da 5,193446 a 6,220325 dopo CPT aziendale, contro 1,255818 con replay. Questo mostra qui una deriva su questo controllo fittizio ristretto e la sua attenuazione tramite la miscela. Questo controllo non misura l'insieme delle competenze generali del modello. I testi di validazione sono distinti e i loro conteggi di token non devono sostituire quelli del test.

La perdita e la qualità delle risposte libere non raccontano la stessa cosa. Senza contesto, il modello di base risponde esattamente a 13/200 domande, contro 21/200 per ciascuno dei rami addestrati. Con i tre documenti recuperati, raggiunge 200/200, contro 168/200 dopo CPT aziendale e 178/200 con replay. Il richiamo documentale top-3 è 200/200: gli errori dei rami addestrati restano anche quando viene fornita la scheda corretta. Non si osserva alcun vantaggio del CPT sulla lettura documentale del modello di base. Le 41 uscite troncate del modello di base senza contesto restano nel denominatore; nessuna risposta parziale viene riparata per migliorare il punteggio.

L'esercizio rivede 40 fatti nei documenti consultabili, senza addestrare nuovamente i modelli. Sui nuovi valori, la consultazione dell'indice v2 produce 39/40 risposte esatte con il modello di base, 34/40 dopo CPT aziendale e 36/40 con replay. Senza documenti, gli input e gli output restano identici nei 40 casi; nessuna risposta è esatta per i valori rivisti. Il richiamo top-3 dell'indice rivisto è 199/200 sull'insieme delle domande. L'aggiornamento documentale consente quindi di esporre un fatto nuovo, senza garantire una lettura corretta di ogni caso.

Il filo conduttore realmente mantenuto è la scheda N0425-0: il valore « 6 pulses » diventa « 8 pulses ». Le tre varianti rispondono 6 con il vecchio documento e 8 con la sua revisione, con arresto EOS. Questo esempio è un caso riuscito fra le popolazioni complete, non un sostituto della loro valutazione. Le schede test consultate non sono mai servite all'apprendimento CPT: si tratta esplicitamente di un test con documenti forniti.

I percorsi continui richiedono 3029.10 s (aziendale) e 3412.02 s (replay), con validazione e salvataggio. I corrispondenti picchi di allocazione PyTorch sono 9990552576 e 9984970752 byte. Non rappresentano tutta la VRAM rilevata da Linux. Le esecuzioni interrotte e riprese, l'acquisizione, i test, l'inferenza e gli audit spiegano il costo superiore del percorso completo. Durante questo percorso si è svolta una preparazione CPU di altri tutorial: non viene dichiarata alcuna misura su un host del tutto inattivo.

Un solo seme, un corpus sintetico chiuso e un formato di risposta numerica limitato non bastano a stabilire un beneficio aziendale generale. Il replay mostra qui un interesse misurato sul controllo linguistico; il CPT non è né una base documentale aggiornabile né una garanzia di esattezza fattuale.

Osservazioni di sviluppo

Valori ricalcolati dalle uscite grezze di questo percorso. Non sono soglie di miglioramento; il percorso dallo ZIP li ricalcola sulle stesse popolazioni.

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

I risultati osservati, la loro portata e l'esperimento proposto.

Condividi questo articolo