Parliamo del progetto
Intelligenza artificiale

Fare il fine-tuning di tutti i pesi per convertire ticket in schede di intervento

Un'officina riceve richieste in testo libero. A partire dallo stesso ticket, visualizzate l'output del modello con il solo prompt, dopo SFT completo e dopo un adattamento limitato ai suoi ultimi due blocchi.

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

Un'officina riceve richieste in testo libero. Vogliamo una scheda JSON che copi le informazioni presenti, scelga una categoria tra categorie definite e lasci null le informazioni assenti. Non vi sono né accesso a un sistema di gestione documentale né esecuzione di interventi.

A partire dallo stesso ticket, visualizzate l'output del modello con il solo prompt, dopo SFT completo e dopo un adattamento limitato ai suoi ultimi due blocchi. Analizzate il JSON e verificate i campi senza decodifica vincolata, per isolare l'effetto dell'apprendimento.

Scaricare il progetto completo, revisione r002

Il percorso del tutorial, dal caso concreto alla verifica.

Un buon output descritto dai dati

Il SFT massimizza la probabilità di una risposta attesa condizionata da un prompt. Non si tratta di far rileggere al modello ticket grezzi: le coppie ticket/scheda insegnano qui una trasformazione precisa. Il modello di base Qwen2.5-0.5B possiede già pesi preaddestrati; questo laboratorio non pretende di imparare una lingua da zero.

InterventionPairs contiene 400 famiglie di sei formulazioni, cioè 2400 coppie. Le famiglie 0–279 forniscono 1680 esempi train; 280–339 forniscono 360 validation; 340–399 forniscono 360 test. Le formulazioni vicine di una famiglia restano insieme. Le combinazioni di apertura/chiusura e le identità dell'unità differenziano le famiglie; il vocabolario dei sintomi, luoghi e forme di frase resta condiviso. Gli hash normalizzati verificano i duplicati dopo la normalizzazione degli spazi e delle maiuscole/minuscole, non un'assenza universale di sovrapposizione semantica.

I campi sono esattamente unit, symptom, location, scheduled_date. I loro valori sono stringhe o null; un symptom non null deve appartenere a noise, leak, heat, vibration. Lo schema verifica tipi e chiavi, non la veridicità di una stringa né la sua conformità a un calendario. Una data fittizia errata può quindi rispettare lo schema e fallire la valutazione del campo. L'uguaglianza aziendale riguarda i valori esatti, non l'ordine delle chiavi JSON.

I target non recuperano alcuna informazione assente dal ticket: una clausola di assenza sostituisce il fatto interessato. Le date sono assenti in due formulazioni su sei; altri casi rimuovono un luogo, un'unità o un sintomo. Le proporzioni effettive di ciascuno split vengono registrate e devono superare un quarto. Otto dimostrazioni sono scelte tramite identificativi train fissati nel generatore, prima di ogni osservazione dei risultati test.

Confine della risposta e loss

Il formato locale è un'istruzione, Ticket: ... poi JSON:. Non usiamo un template conversazionale implicito. Il tokenizer veloce proviene dalla base fissata. Il contratto codifica separatamente il prompt e la risposta JSON, concatena le loro liste di token poi aggiunge EOS=151643. Questa costruzione non è presentata come identica alla ritokenizzazione di una stringa concatenata: il confine è esplicito e testato con accenti e JSON vuoto.

Le label del prompt e del padding sono −100. Ogni token della risposta e EOS ha il proprio target. Per predire il token successivo, allineiamo logits[:, :-1] con labels[:, 1:]. Il primo token della sequenza non ha predecessore; non è mai un target di questo calcolo, nemmeno nell'ablazione che supervisiona il prompt.

PAD ed EOS usano lo stesso identificativo. La maschera deve quindi dipendere dalle posizioni reali, non dall'uguaglianza con questo identificativo: ignorare tutti i 151643 eliminerebbe anche il segnale di arresto. Nessuna sequenza viene concatenata a un'altra. I prompt SFT/zero-shot sono limitati a 384 token; le risposte a 96, EOS incluso. Una risposta vuota o un input troppo lungo provoca un errore con l'identificativo del caso; nulla viene troncato silenziosamente.

La CE è codificata come somma sulle posizioni attive, divisa per il loro numero esatto. Per otto esempi accumulati, ogni microbatch usa il denominatore totale di questi otto esempi, anche se le risposte hanno lunghezze diverse. Una media di medie cambierebbe il peso di ogni token. Il decoder e la proiezione verso il vocabolario calcolano l'intera sequenza, poi la maschera seleziona le posizioni supervisionate per la CE. Mantenere la stessa forma di proiezione di Transformers evita uno scarto di arrotondamento FP32 legato al prodotto matriciale su un sottoinsieme di righe. Un test confronta i logits selezionati e la loss con Transformers alle tolleranze FP32 iniziali; prompt e padding restano esclusi dalla supervisione.

Quali parametri possono cambiare?

La base è Qwen/Qwen2.5-0.5B, revisione 060db6499f32faf8b98477b0a26969ef7d8b9987. L'architettura acquisita viene verificata: larghezza 896, 24 blocchi, 14 teste di query, due teste K/V, MLP 4864, vocabolario 151936, contesto nativo 32768. Gli embedding di input e la proiezione di output condividono lo stesso Parameter. Il programma calcola il numero esatto di parametri unici.

Full rende modificabili tutti i parametri. Partial rende modificabili soltanto i blocchi 22 e 23; embedding, output condiviso, normalizzazione finale e altri blocchi restano congelati. La selezione percorre i veri oggetti dell'architettura e verifica nomi, identità e assenza di duplicati nell'ottimizzatore. Gli hash prima/dopo devono conservare tutti i parametri congelati, mentre alcuni parametri autorizzati devono effettivamente cambiare. Rendere modificabile un parametro non garantisce che ogni tensor cambi numericamente a ogni passo.

I due rami partono esattamente dalla stessa base, in FP32 con attenzione eager e TF32 disattivato. Usano gli stessi dati, ordine di estrazione, due epoche e quantità di token di risposta supervisionati. AdamW, weight_decay 0,01, microbatch 1, accumulazione di otto esempi, clipping a 1, warm-up di 42 aggiornamenti poi decrescita lineare fino al 10% del tasso iniziale. Full usa lr=0,00002 e partial lr=0,00005: questo confronto varia anche il tasso di apprendimento, non solo il numero di parametri. Nessun tasso né checkpoint viene scelto in base al test; il checkpoint finale è fissato dopo due epoche.

La validazione della sola risposta viene misurata dopo ogni epoca, senza selezione del checkpoint migliore. La ripresa avviene a un confine di epoca, dopo un'epoca interrotta. Ripristina pesi, AdamW, stato dello scheduler, RNG Python/NumPy/PyTorch CPU/CUDA, sampler e cronologia. Il percorso continuo di due epoche e la ripresa devono produrre esattamente gli stessi stati. Non è una promessa di ripresa nel mezzo di un microbatch o di una scrittura incompleta.

Gli stati .pt sono creati localmente, scritti in un file temporaneo poi rinominati in modo atomico; non devono essere sostituiti da file non affidabili. Il modello finale viene anche salvato in Safetensors gestendo la condivisione embedding/output, poi ricaricato in un'istanza isolata. File e parametri sono verificati tramite SHA-256. I pesi appresi restano fuori dallo ZIP pubblico; il lettore li ricrea.

Baseline e generazione senza vincoli

Il modello di base è valutato in zero-shot e con otto dimostrazioni fisse. Questi otto esempi richiedono un budget di contesto separato di 2048 token: non sono forzati artificialmente nei 384 token degli altri prompt. Il loro costo reale viene registrato. La generazione riserva 96 nuovi token nel contesto nativo, senza troncamento del prompt.

Tutti i rami usano generazione greedy, una sola beam e arresto EOS=151643, con un tetto di 96 nuovi token. Nessuno schema viene imposto durante la decodifica. ID di input/output, testo grezzo, ragione di arresto, numeri di token e durata sono conservati per ciascuno dei 360 casi validation e 360 test. I pesi non vengono adattati da questa fase.

Il parser distingue tre proprietà: testo JSON analizzabile, schema valido, poi risposta completa accettata. Una risposta fermata al tetto può contenere un JSON ben formato; resta rifiutata come risposta completa. Non viene applicata alcuna rimozione di tag Markdown, aggiunta di campo, riparazione o arricchimento. Chiavi duplicate e costanti non finite vengono rifiutate.

L'exact match globale e l'esattezza di ogni campo usano tutti i 360 casi test. Un output non valido o troncato fallisce su tutti i campi. Per le informazioni assenti, il rapporto distingue null corretto, valore non null inventato in una risposta completa valida e output non valido. Il tasso di valori inventati usa tutte le occasioni in cui manca il campo; il tasso di fallimento nel fornire null include anche gli output non validi. Un modello che non produce alcun JSON non può così sembrare affidabile grazie a un tasso di invenzione artificialmente nullo.

La NLL della sola risposta su test viene ricalcolata con lo stesso prompt zero-shot per base, full, partial e ablazione. Include EOS e gli stessi target. La generazione few-shot è un confronto distinto con più contesto; la sua qualità e i suoi token sono riportati, senza confondere questo costo con un effetto di apprendimento.

Esercizio: includere il prompt nella loss

Un terzo ramo completo riparte realmente dalla base per due epoche, con lo stesso ordine dei documenti e la stessa configurazione full, ma supervisiona anche i token del prompt. Conta quindi più token attivi. Una diminuzione della sua loss di apprendimento non può essere confrontata direttamente con la media della sola risposta.

Ricalcoliamo la sua NLL sulle stesse risposte test e sulle sue 720 generazioni validation/test, poi confrontiamo exact match e campi assenti. Non viene imposto alcun guadagno né regressione. Modelli e risultati principali sono protetti da SHA prima dell'esercizio. La maschera risposta resta la configurazione di riferimento; l'ablazione ha un nome distinto.

Limiti

Questi ticket sono sintetici e regolari. Le loro label riflettono un generatore, non la varietà delle richieste reali. Un JSON conforme può contenere un'informazione falsa. Un solo seme di apprendimento non consente di stimare la variabilità di un addestramento. Le regole che producono i target sono un oracolo pedagogico, non una scoperta del modello. Non viene creato né eseguito alcun fascicolo documentale o atto di intervento.

Definire compito, schema e famiglie

Dimostrazione supervisionata

Errore frequente: il generatore scrive una data target assente dal ticket; testate ogni campo rispetto ai fatti resi.

Invariante: 1 680/360/360 coppie e proporzioni di assenza sottoposte ad audit prima di eventuali esclusioni.

File src/sft_complet/data.py

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

FIELDS = ["unit", "symptom", "location", "scheduled_date"]
SYMPTOMS = ["noise", "leak", "heat", "vibration"]
LOCATIONS = [
    "inlet",
    "outlet",
    "motor bay",
    "upper housing",
    "control panel",
    "rear cover",
    "lower bracket",
    "supply line",
    "return line",
    "service hatch",
]
OPENINGS = [
    "A technician submitted this observation.",
    "The operator left a short report.",
    "The shift log contains this request.",
    "A maintenance note describes the following.",
    "The service team received this message.",
    "An internal ticket records these details.",
    "The workshop inbox received a report.",
    "A routine handover included this note.",
    "The operator asks for a review.",
    "The inspection desk received this request.",
    "The shift coordinator recorded a concern.",
    "A service request includes these facts.",
    "The team copied this observation into a ticket.",
    "The workshop clerk transcribed this report.",
    "A technician added the following account.",
    "The afternoon handover contains this message.",
    "The maintenance inbox lists this observation.",
    "The service desk forwarded this written request.",
    "An operator documented the following concern.",
    "A new workshop note contains these details.",
]
CLOSINGS = [
    "Please retain the original observations.",
    "The note contains no other confirmed details.",
    "This is a request for review only.",
    "No repair has been performed by this note.",
    "The text is an observation, not an instruction to run equipment.",
    "The report remains available for the next shift.",
    "Keep unreported details unspecified.",
    "The service desk can review the record.",
    "This message does not confirm a diagnosis.",
    "The observation is awaiting a technical review.",
    "Only explicitly reported facts should be copied.",
    "The ticket has not authorized any intervention.",
    "Retain uncertainty when details are missing.",
    "No additional findings are supplied here.",
    "The report is ready for manual review.",
    "This entry is a written observation only.",
    "The workshop has supplied no supplementary facts.",
    "The request can be filed for later inspection.",
    "The maintenance team should read the original note.",
    "The text does not provide a cause for the concern.",
]
ORDERS = [
    ["unit", "symptom", "location", "scheduled_date"],
    ["location", "unit", "scheduled_date", "symptom"],
    ["scheduled_date", "symptom", "unit", "location"],
    ["symptom", "location", "unit", "scheduled_date"],
    ["unit", "scheduled_date", "location", "symptom"],
    ["location", "symptom", "scheduled_date", "unit"],
]
MISSING = {
    "unit": "The unit identifier was not provided.",
    "symptom": "The symptom was not specified.",
    "location": "The location was not stated.",
    "scheduled_date": "No inspection date has been scheduled.",
}


def split_scenarios(family):
    return "train" if family < 280 else "validation" if family < 340 else "test"


def normalized(text):
    return " ".join(text.casefold().split())


def render_target(values):
    return json.dumps(
        {key: values[key] for key in FIELDS}, ensure_ascii=False, separators=(",", ":")
    )


def render_fact(key, value, j):
    if value is None:
        return MISSING[key]
    alternatives = {
        "unit": [
            f"The unit is {value}.",
            f"Unit {value} is named in the report.",
            f"The reported unit identifier is {value}.",
            f"This observation concerns unit {value}.",
            f"The unit was identified as {value}.",
            f"The record names unit {value}.",
        ],
        "symptom": [
            f"The reported symptom is {value}.",
            f"The operator reports {value}.",
            f"The symptom is described as {value}.",
            f"The note explicitly reports {value}.",
            f"The observed symptom is {value}.",
            f"The report describes {value}.",
        ],
        "location": [
            f"The location is {value}.",
            f"The issue was reported near the {value}.",
            f"The stated location is the {value}.",
            f"The observation was made at the {value}.",
            f"The report points to the {value}.",
            f"The location named is the {value}.",
        ],
        "scheduled_date": [
            f"The inspection is scheduled for {value}.",
            f"The scheduled inspection date is {value}.",
            f"An inspection has been scheduled on {value}.",
            f"The planned inspection date is {value}.",
            f"The inspection appointment is set for {value}.",
            f"The record gives {value} as the inspection date.",
        ],
    }
    return alternatives[key][j]


def generate_tickets():
    pages = []
    gold = []
    templates = []
    for family in range(400):
        opening = OPENINGS[family // 20]
        closing = CLOSINGS[family % 20]
        templates.append(
            {
                "family": family,
                "split": split_scenarios(family),
                "opening": opening,
                "closing": closing,
            }
        )
        for j in range(6):
            values = {
                "unit": f"U{family+1000}",
                "symptom": SYMPTOMS[family % 4],
                "location": LOCATIONS[(family // 4) % 10],
                "scheduled_date": f"2026-{family%12+1:02d}-{family//12%27+1:02d}",
            }
            if j in [0, 3]:
                values["scheduled_date"] = None
            if j == 4 and family % 3 == 0:
                values["location"] = None
            if j == 5 and family % 4 == 0:
                values["unit"] = None
            if j == 2 and family % 7 == 0:
                values["symptom"] = None
            clauses = [render_fact(key, values[key], j) for key in ORDERS[j]]
            ticket = " ".join([opening, *clauses, closing])
            identifier = f"I{family:03d}-{j}"
            row = {
                "document_id": identifier,
                "family": family,
                "formulation": j,
                "split": split_scenarios(family),
                "ticket": ticket,
                "content_sha256": hashlib.sha256(ticket.encode()).hexdigest(),
                "normalized_sha256": hashlib.sha256(
                    normalized(ticket).encode()
                ).hexdigest(),
            }
            pages.append(row)
            gold.append(
                {
                    "document_id": identifier,
                    "target": values,
                    "target_json": render_target(values),
                    "absent_fields": [key for key in FIELDS if values[key] is None],
                }
            )
            # Every non-null label appears in a rendered clause; absent facts are
            # represented only by their missingness statement, never hidden metadata.
            for key in FIELDS:
                if values[key] is not None:
                    assert str(values[key]) in clauses[ORDERS[j].index(key)]
    assert len({r["normalized_sha256"] for r in pages}) == 2400
    return pages, gold, templates


def prepare(config):
    root = Path("data/generated")
    root.mkdir(parents=True, exist_ok=True)
    if (root / "manifest.json").exists():
        raise ValueError("Existing frozen corpus; extract into a new directory")
    pages, gold, templates = generate_tickets()
    byid = {r["document_id"]: r for r in gold}
    save_json(root / "input/pages.json", pages)
    save_json(root / "input/templates.json", templates)
    save_json(root / "labels/gold.json", gold)
    audit = {}
    for split in ["train", "validation", "test"]:
        selected = [r for r in pages if r["split"] == split]
        labels = [byid[r["document_id"]] for r in selected]
        save_json(root / f"{split}/pages.json", selected)
        if split != "test":
            save_json(root / f"labels/{split}.json", labels)
        absent = sum(bool(r["absent_fields"]) for r in labels)
        assert absent / len(labels) >= 0.25
        audit[split] = {
            "documents": len(selected),
            "families": len({r["family"] for r in selected}),
            "with_absent_field": absent,
            "with_absent_field_rate": absent / len(labels),
        }
    demo_ids = [
        f"I{family:03d}-{j}"
        for family, j in [
            (0, 0),
            (3, 1),
            (8, 2),
            (13, 3),
            (21, 4),
            (34, 5),
            (55, 0),
            (89, 1),
        ]
    ]
    lookup = {r["document_id"]: r for r in pages}
    demonstrations = [
        {
            "document_id": identifier,
            "ticket": lookup[identifier]["ticket"],
            "target_json": byid[identifier]["target_json"],
        }
        for identifier in demo_ids
    ]
    assert all(lookup[i]["split"] == "train" for i in demo_ids)
    save_json(root / "input/demonstrations.json", demonstrations)
    save_json(
        root / "manifest.json",
        {
            "dataset": "InterventionPairs-v1",
            "rights": "CC0-1.0",
            "seed": config["seed"],
            "split_audit": audit,
            "normalized_duplicates": 0,
            "demonstration_ids": demo_ids,
            "files": {str(f.relative_to(root)): sha(f) for f in root.rglob("*.json")},
        },
    )
    save_json(
        "outputs/data-audit.json",
        {"splits": audit, "normalized_duplicates": 0, "demonstration_ids": demo_ids},
    )
    return audit

Codificare il collator delle risposte

Confine dei token ed EOS

Errore frequente: un troncamento a destra elimina EOS e la risposta; rilevatelo prima dell'addestramento.

Invariante: tutti i token della risposta attesa hanno un target e il prompt viene ignorato.

File src/sft_complet/collator.py

import torch
import torch.nn.functional as F

INSTRUCTION = "Extract exactly unit, symptom, location, scheduled_date as a JSON object. Missing information must be null. A reported symptom is noise, leak, heat, or vibration. Return only the JSON object."


def prompt_text(ticket):
    return INSTRUCTION + "\nTicket: " + ticket + "\nJSON:"


def encode_prompt_response(
    tokenizer, ticket, response, config, identifier, supervise_prompt=False
):
    # Explicit concatenation of two separately encoded token streams. We do not
    # pretend it equals re-tokenizing the concatenated character string.
    prompt = tokenizer.encode(prompt_text(ticket), add_special_tokens=False)
    completion = tokenizer.encode(response, add_special_tokens=False) + [151643]
    if not prompt or len(prompt) > config["max_prompt_tokens"]:
        raise ValueError(
            identifier + ": prompt exceeds the declared token budget; no truncation"
        )
    if (
        len(completion) <= 1
        or len(completion) > config["max_response_tokens_including_eos"]
    ):
        raise ValueError(
            identifier + ": missing or overlength response including EOS; no truncation"
        )
    ids = prompt + completion
    labels = list(ids) if supervise_prompt else [-100] * len(prompt) + completion
    return {
        "document_id": identifier,
        "ids": ids,
        "labels": labels,
        "prompt_tokens": len(prompt),
        "response_tokens": len(completion),
        "supervise_prompt": supervise_prompt,
    }


def completion_labels(row):
    return row["labels"]


def collate(rows, device="cuda", pad_to=None):
    if not rows:
        raise ValueError("Empty batch")
    length = max(len(r["ids"]) for r in rows) if pad_to is None else pad_to
    if length < max(len(r["ids"]) for r in rows):
        raise ValueError("Silent truncation prohibited")
    ids = torch.full((len(rows), length), 151643, dtype=torch.long, device=device)
    labels = torch.full_like(ids, -100)
    mask = torch.zeros_like(ids, dtype=torch.bool)
    for i, row in enumerate(rows):
        n = len(row["ids"])
        ids[i, :n] = torch.tensor(row["ids"], device=device)
        labels[i, :n] = torch.tensor(row["labels"], device=device)
        mask[i, :n] = True
    return ids, mask, labels


def completion_nll(logits, labels):
    selected = labels[:, 1:] != -100
    if not selected.any():
        raise ValueError("No supervised next token")
    values = F.cross_entropy(
        logits[:, :-1][selected].float(), labels[:, 1:][selected], reduction="none"
    )
    return values.sum(), int(selected.sum()), values

Misurare il solo prompt e scegliere i pesi modificabili

Inizializzazione e spazio di adattamento

Errore frequente: embedding collegati a lm_head sbloccati per errore; verificare identità di archiviazione e lista dell'ottimizzatore.

Invariante: gli ultimi due blocchi sono identificati e nessun altro parametro partial è nell'ottimizzatore.

File src/sft_complet/baseline.py

from .model import load_base, select_trainables, parameter_manifest, manifest_digest
from .config import save_json


def report_trainable_parameters():
    model = load_base()
    result = {}
    for branch in ["full", "partial"]:
        parameters = select_trainables(model, branch)
        rows = parameter_manifest(model)
        result[branch] = {
            "parameters": rows,
            "total_parameters": sum(r["numel"] for r in rows),
            "trainable_parameters": sum(r["numel"] for r in rows if r["requires_grad"]),
            "optimizer_parameter_objects": len(parameters),
            "unique_optimizer_parameter_objects": len({id(p) for p in parameters}),
            "model_digest": manifest_digest(rows),
            "tied_embedding_output": model.lm_head.weight
            is model.model.embed_tokens.weight,
        }
    if result["full"]["model_digest"] != result["partial"]["model_digest"]:
        raise ValueError("Selecting trainable parameters changed weights")
    save_json("outputs/parameter-audit.json", result)
    import shutil

    shutil.copyfile("assets/model/tokenizer.json", "outputs/tokenizer-reference.json")


def select_last_blocks(model):
    return select_trainables(model, "partial")


def evaluate_prompt_only(config, variant):
    from .evaluate import predict

    if variant not in ["zeroshot", "fewshot"]:
        raise ValueError("Expected frozen-base baseline")
    predict(config, variant)

Eseguire il SFT e la sua ripresa

CE condizionale e stato di ottimizzazione

Errore frequente: accumulare la media di risposte brevi produce un gradiente errato; sommate NLL e contatori.

Invariante: pesi full e partial attesi modificati; checkpoint separati, stati del sampler e RNG ripristinati.

File src/sft_complet/train.py

import copy, hashlib, math, os, time
from pathlib import Path
import numpy as np
import torch
from .config import read_json, save_json, sha, seed_all, rng_state, restore_rng
from .model import (
    load_base,
    load_tokenizer,
    select_trainables,
    parameter_manifest,
    manifest_digest,
    save_learned,
    active_response_logits,
)
from .collator import encode_prompt_response, collate


def equal(a, b):
    if isinstance(a, torch.Tensor):
        return isinstance(b, torch.Tensor) and torch.equal(a, b)
    if isinstance(a, np.ndarray):
        return isinstance(b, np.ndarray) and np.array_equal(a, b)
    if isinstance(a, dict):
        return (
            isinstance(b, dict)
            and a.keys() == b.keys()
            and all(equal(a[k], b[k]) for k in a)
        )
    if isinstance(a, (list, tuple)):
        return (
            type(a) == type(b)
            and len(a) == len(b)
            and all(equal(x, y) for x, y in zip(a, b))
        )
    return a == b


def save_resume_state(path, state):
    path = Path(path)
    path.parent.mkdir(parents=True, exist_ok=True)
    if path.exists():
        raise ValueError("Existing training state is preserved")
    temporary = path.with_suffix(".pending")
    torch.save(state, temporary)
    os.replace(temporary, path)


def encode_split(config, tokenizer, split, supervise_prompt=False):
    pages = read_json(f"data/generated/{split}/pages.json")
    labels = {
        r["document_id"]: r for r in read_json(f"data/generated/labels/{split}.json")
    }
    if any(p["split"] != split for p in pages):
        raise ValueError("Wrong training/validation partition")
    return [
        encode_prompt_response(
            tokenizer,
            p["ticket"],
            labels[p["document_id"]]["target_json"],
            config,
            p["document_id"],
            supervise_prompt,
        )
        for p in pages
    ]


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


@torch.no_grad()
def validate_response(model, rows):
    model.eval()
    total = 0.0
    count = 0
    for row in rows:
        ids, mask, labels = collate([row])
        _, n, values, _, _ = active_response_logits(model, ids, mask, labels)
        total += float(values.double().sum())
        count += n
    model.train()
    return {
        "response_nll_nats_per_token": total / count,
        "response_tokens": count,
        "documents": len(rows),
    }


def train_sft(config, branch="full", mode="continuous"):
    if (
        Path("data/generated/test").exists()
        or Path("data/generated/labels/gold.json").exists()
    ):
        raise ValueError("Learning service must not mount test inputs or gold")
    if mode not in ["interrupt", "continuous", "resume"]:
        raise ValueError("Unknown training mode")
    seed_all(config["seed"])
    tokenizer = load_tokenizer()
    rows = encode_split(config, tokenizer, "train", branch == "prompt_loss")
    validation = encode_split(config, tokenizer, "validation", False)
    if len(rows) != 1680 or len(validation) != 360:
        raise ValueError("Expected frozen1680/360 population")
    model = load_base()
    parameters = select_trainables(model, branch)
    initial = parameter_manifest(model)
    initial_digest = manifest_digest(initial)
    model = model.cuda()
    parameters = [p for p in model.parameters() if p.requires_grad]
    optimizer = torch.optim.AdamW(
        parameters, lr=config["learning_rate_full"], weight_decay=config["weight_decay"]
    )
    sampler = torch.Generator().manual_seed(config["seed"] + 100)
    updates_per_epoch = math.ceil(len(rows) / config["accumulation_examples"])
    total_updates = updates_per_epoch * config["epochs"]
    start = updates = 0
    history = []
    validation_history = []
    identity = {
        "branch": branch,
        "config": config,
        "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"),
        "implementation_sha256": {
            name: sha("src/sft_complet/" + name) for name in ["model.py", "train.py"]
        },
        "train_input_sha256": sha("data/generated/train/pages.json"),
        "train_labels_sha256": sha("data/generated/labels/train.json"),
        "validation_input_sha256": sha("data/generated/validation/pages.json"),
        "validation_labels_sha256": sha("data/generated/labels/validation.json"),
        "collator_sha256": sha("src/sft_complet/collator.py"),
        "initial_model_digest": initial_digest,
        "trainable_names": [r["name"] for r in initial if r["requires_grad"]],
    }
    root = Path("assets/checkpoints")
    destination = root / f"{branch}-{mode}.pt"
    if destination.exists():
        raise ValueError("Preserve existing checkpoint")
    if mode == "resume":
        state = torch.load(
            root / f"{branch}-interrupt.pt", map_location="cpu", weights_only=False
        )
        if state["identity"] != identity:
            raise ValueError("Resume identity changed")
        model.load_state_dict(state["model"], strict=True)
        optimizer.load_state_dict(state["optimizer"])
        sampler.set_state(state["sampler"])
        restore_rng(state["rng"])
        start = state["epoch"]
        updates = state["updates"]
        history = state["history"]
        validation_history = state["validation_history"]
        if state["scheduler"] != {
            "next_update": updates,
            "next_lr": learning_rate(config, branch, updates, total_updates),
        }:
            raise ValueError("Resume scheduler changed")
        del state
    stop = config["interrupt_epoch"] if mode == "interrupt" else config["epochs"]
    begun = time.perf_counter()
    torch.cuda.reset_peak_memory_stats()
    model.train()
    for epoch in range(start, stop):
        permutation = torch.randperm(len(rows), generator=sampler).tolist()
        epoch_loss = 0.0
        epoch_tokens = 0
        for offset in range(0, len(rows), config["accumulation_examples"]):
            selected = permutation[offset : offset + config["accumulation_examples"]]
            denominator = sum(
                sum(v != -100 for v in rows[i]["labels"][1:]) for i in selected
            )
            lr = learning_rate(config, branch, updates, total_updates)
            for group in optimizer.param_groups:
                group["lr"] = lr
            optimizer.zero_grad(set_to_none=True)
            summed = 0.0
            for i in selected:
                ids, mask, labels = collate([rows[i]])
                loss_sum, n, values, _, _ = active_response_logits(
                    model, ids, mask, labels
                )
                if not torch.isfinite(loss_sum) or n != sum(
                    v != -100 for v in rows[i]["labels"][1:]
                ):
                    raise ValueError("Nonfinite or miscounted active-token loss")
                (loss_sum / denominator).backward()
                summed += float(values.detach().double().sum())
            norm = torch.nn.utils.clip_grad_norm_(
                parameters, config["gradient_clip"], error_if_nonfinite=True
            )
            optimizer.step()
            updates += 1
            epoch_loss += summed
            epoch_tokens += denominator
            history.append(
                {
                    "update": updates,
                    "epoch": epoch + 1,
                    "document_ids": [rows[i]["document_id"] for i in selected],
                    "active_tokens": denominator,
                    "nll_nats_per_active_token": summed / denominator,
                    "learning_rate": lr,
                    "gradient_norm_before_clip": float(norm),
                }
            )
            if updates % 42 == 0:
                print(history[-1], flush=True)
        observed = validate_response(model, validation)
        validation_history.append({"epoch": epoch + 1, **observed})
        print(
            {
                "epoch": epoch + 1,
                "train_nll_nats_per_active_token": epoch_loss / epoch_tokens,
                "train_active_tokens": epoch_tokens,
                "validation": observed,
            },
            flush=True,
        )
    torch.cuda.synchronize()
    final = parameter_manifest(model)
    before = {r["name"]: r for r in initial}
    changed = [r["name"] for r in final if r["sha256"] != before[r["name"]]["sha256"]]
    frozen_changed = [
        r["name"]
        for r in final
        if not before[r["name"]]["requires_grad"]
        and r["sha256"] != before[r["name"]]["sha256"]
    ]
    if not changed or frozen_changed:
        raise ValueError("No learning or frozen parameters changed")
    state = {
        "identity": identity,
        "epoch": stop,
        "updates": updates,
        "model": model.state_dict(),
        "optimizer": optimizer.state_dict(),
        "sampler": sampler.get_state(),
        "scheduler": {
            "next_update": updates,
            "next_lr": learning_rate(config, branch, updates, total_updates),
        },
        "rng": rng_state(),
        "history": history,
        "validation_history": validation_history,
    }
    save_resume_state(destination, state)
    if mode == "resume" or (branch == "prompt_loss" and mode == "continuous"):
        save_learned(model, root / f"{branch}-{mode}.safetensors", identity)
    save_json(
        f"outputs/train-{branch}-{mode}.json",
        {
            "branch": branch,
            "mode": mode,
            "identity": identity,
            "epochs": stop,
            "updates": updates,
            "history": history,
            "validation_history": validation_history,
            "tokenization": rows,
            "initial_parameters": initial,
            "final_parameters": final,
            "changed_parameters": changed,
            "frozen_changed": frozen_changed,
            "trainable_parameters": sum(
                r["numel"] for r in initial if r["requires_grad"]
            ),
            "total_parameters": sum(r["numel"] for r in initial),
            "initial_model_digest": initial_digest,
            "final_model_digest": manifest_digest(final),
            "checkpoint_sha256": sha(destination),
            "checkpoint_bytes": destination.stat().st_size,
            "max_torch_allocated_bytes": torch.cuda.max_memory_allocated(),
            "seconds": time.perf_counter() - begun,
            "test_visible": False,
        },
    )


def verify_resume(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("Full resume differs: " + str(checks))
    save_json(
        f"outputs/resume-{branch}.json",
        {
            "status": "PASS",
            "all_state_exact": True,
            "checks": checks,
            "boundary": "complete epoch; not a mid-microbatch recovery claim",
        },
    )

Valutare senza vincoli di decodifica

Formato e semantica separati

Errore frequente: un parser permissivo riempie campi assenti; vietate ogni arricchimento silenzioso.

Invariante: 360 ID per ramo, senza eliminare i JSON non validi.

File src/sft_complet/evaluate.py

import json, math, 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,
    active_response_logits,
    parameter_manifest,
    manifest_digest,
)
from .collator import prompt_text, encode_prompt_response, collate
from .data import FIELDS, SYMPTOMS


def parse_generated_record(text, finish_reason):
    def pairs(items):
        result = {}
        for key, value in items:
            if key in result:
                raise ValueError("Duplicate key")
            result[key] = value
        return result

    def constant(value):
        raise ValueError("Nonfinite JSON constant")

    try:
        record = json.loads(text, object_pairs_hook=pairs, parse_constant=constant)
    except (ValueError, TypeError):
        return {
            "json_parsable": False,
            "schema_valid": False,
            "completion_valid": False,
            "record": None,
            "reason": "TRUNCATED" if finish_reason != "eos" else "INVALID_JSON",
        }
    valid = (
        isinstance(record, dict)
        and set(record) == set(FIELDS)
        and all(v is None or type(v) == str for v in record.values())
    )
    if valid:
        valid = record["symptom"] is None or record["symptom"] in SYMPTOMS
    return {
        "json_parsable": True,
        "schema_valid": valid,
        "completion_valid": valid and finish_reason == "eos",
        "record": record if valid else None,
        "reason": (
            "TRUNCATED"
            if finish_reason != "eos"
            else "VALID_SCHEMA" if valid else "INVALID_SCHEMA"
        ),
    }


def generation_prompt(tokenizer, ticket, demonstrations, config):
    text = "\n\n".join(
        prompt_text(d["ticket"]) + d["target_json"] for d in demonstrations
    )
    if demonstrations:
        text += "\n\n"
    text += prompt_text(ticket)
    ids = tokenizer.encode(text, add_special_tokens=False)
    limit = (
        config["max_fewshot_prompt_tokens"]
        if demonstrations
        else config["max_prompt_tokens"]
    )
    if len(ids) > limit:
        raise ValueError("Generation prompt exceeds declared token cap; no truncation")
    return text, ids


@torch.no_grad()
def predict(config, variant):
    if Path("data/generated/labels").exists():
        raise ValueError("Free generation must not mount gold labels")
    seed_all(config["seed"])
    tokenizer = load_tokenizer()
    learned = variant in ["full", "partial", "prompt_loss"]
    path = Path("assets/checkpoints") / (
        variant
        + (
            "-continuous.safetensors"
            if variant == "prompt_loss"
            else "-resume.safetensors"
        )
    )
    if learned:
        model, metadata = reload_learned(path)
        identity = metadata["model_digest"]
    else:
        model = load_base()
        identity = manifest_digest(parameter_manifest(model))
    model = model.cuda().eval()
    pages = read_json("data/generated/input/pages.json")
    selected = [r for r in pages if r["split"] in ["validation", "test"]]
    demonstrations = (
        read_json("data/generated/input/demonstrations.json")
        if variant == "fewshot"
        else []
    )
    if len(selected) != 720 or (variant == "fewshot" and len(demonstrations) != 8):
        raise ValueError("Expected fixed720heldout cases and8train demos")
    generation = GenerationConfig(
        do_sample=False,
        num_beams=1,
        max_new_tokens=config["max_new_tokens"],
        use_cache=True,
        eos_token_id=151643,
        pad_token_id=151643,
        bos_token_id=151643,
    )
    rows = []
    begun = time.perf_counter()
    torch.cuda.reset_peak_memory_stats()
    for index, page in enumerate(selected):
        text, ids = generation_prompt(tokenizer, page["ticket"], demonstrations, config)
        input_ids = torch.tensor([ids], device="cuda")
        mask = torch.ones_like(input_ids)
        started = time.perf_counter()
        output = (
            model.generate(
                input_ids=input_ids, attention_mask=mask, generation_config=generation
            )[0, len(ids) :]
            .cpu()
            .tolist()
        )
        torch.cuda.synchronize()
        finish = "eos" if output and output[-1] == 151643 else "length"
        content = output[:-1] if finish == "eos" else output
        decoded = tokenizer.decode(
            content, skip_special_tokens=False, clean_up_tokenization_spaces=False
        )
        parsed = parse_generated_record(decoded, finish)
        rows.append(
            {
                "document_id": page["document_id"],
                "split": page["split"],
                "variant": variant,
                "input_ids": ids,
                "input_text": text,
                "output_ids": output,
                "text": decoded,
                "finish_reason": finish,
                "parsed": parsed,
                "input_tokens": len(ids),
                "output_tokens": len(output),
                "seconds": time.perf_counter() - started,
            }
        )
        if (index + 1) % 60 == 0:
            print(
                {"variant": variant, "generated": index + 1, "total": len(selected)},
                flush=True,
            )
    save_json(
        f"outputs/predictions-{variant}.json",
        {
            "variant": variant,
            "model_digest": identity,
            "model_file_sha256": (
                sha(path) if learned else sha("assets/model/model.safetensors")
            ),
            "tokenizer_sha256": sha("assets/model/tokenizer.json"),
            "input_sha256": sha("data/generated/input/pages.json"),
            "demonstration_ids": [d["document_id"] for d in demonstrations],
            "generation": generation.to_dict(),
            "rows": rows,
            "seconds": time.perf_counter() - begun,
            "max_torch_allocated_bytes": torch.cuda.max_memory_allocated(),
            "gold_visible": False,
        },
    )


def score_missing_fields(rows, gold):
    n = len(rows)
    correct = {k: 0 for k in FIELDS}
    absent = {k: 0 for k in FIELDS}
    invented = {k: 0 for k in FIELDS}
    null_correct = {k: 0 for k in FIELDS}
    invalid_absent = {k: 0 for k in FIELDS}
    exact = parsable = schema = truncated = 0
    details = []
    for row in rows:
        expected = gold[row["document_id"]]["target"]
        parsed = parse_generated_record(row["text"], row["finish_reason"])
        assert parsed == row["parsed"]
        valid = parsed["completion_valid"]
        record = parsed["record"]
        fields = {key: valid and record[key] == expected[key] for key in FIELDS}
        row_exact = all(fields.values())
        exact += row_exact
        parsable += parsed["json_parsable"]
        schema += parsed["schema_valid"]
        truncated += row["finish_reason"] == "length"
        for key in FIELDS:
            correct[key] += fields[key]
            if expected[key] is None:
                absent[key] += 1
                null_correct[key] += valid and record[key] is None
                invented[key] += valid and record[key] is not None
                invalid_absent[key] += not valid
        details.append(
            {
                "document_id": row["document_id"],
                "fields_correct": fields,
                "exact_match": row_exact,
                "absent_fields": [k for k in FIELDS if expected[k] is None],
            }
        )
    return {
        "documents": n,
        "json_parsable": parsable,
        "json_parsable_rate": parsable / n,
        "schema_valid": schema,
        "schema_valid_rate": schema / n,
        "exact_match": exact,
        "exact_match_rate": exact / n,
        "field_correct": correct,
        "field_accuracy": {k: v / n for k, v in correct.items()},
        "absent_opportunities": absent,
        "correct_null": null_correct,
        "invented_nonnull_in_valid_record": invented,
        "invalid_record_with_absent_field": invalid_absent,
        "invented_rate_over_all_absent_opportunities": {
            k: invented[k] / absent[k] if absent[k] else None for k in FIELDS
        },
        "null_failure_rate_over_all_absent_opportunities": {
            k: (absent[k] - null_correct[k]) / absent[k] if absent[k] else None
            for k in FIELDS
        },
        "truncated": truncated,
        "rows": details,
    }


def evaluate_branches(variants):
    gold = {r["document_id"]: r for r in read_json("data/generated/labels/gold.json")}
    result = {}
    for variant in variants:
        predictions = read_json(f"outputs/predictions-{variant}.json")
        result[variant] = {}
        for split in ["validation", "test"]:
            rows = [r for r in predictions["rows"] if r["split"] == split]
            if len(rows) != 360 or len({r["document_id"] for r in rows}) != 360:
                raise ValueError("Do not omit invalid heldout outputs")
            result[variant][split] = score_missing_fields(rows, gold)
    name = (
        "exercise-evaluation.json" if variants == ["prompt_loss"] else "evaluation.json"
    )
    save_json(
        "outputs/" + name,
        {
            "results": result,
            "quality_gain_required": False,
            "decoding_constraints": False,
            "invalid_outputs_discarded": False,
        },
    )
    print(
        {
            v: {k: value for k, value in splits["test"].items() if k != "rows"}
            for v, splits in result.items()
        },
        flush=True,
    )


@torch.no_grad()
def response_nll(config, variant):
    tokenizer = load_tokenizer()
    learned = variant in ["full", "partial", "prompt_loss"]
    path = Path("assets/checkpoints") / (
        variant
        + (
            "-continuous.safetensors"
            if variant == "prompt_loss"
            else "-resume.safetensors"
        )
    )
    model = reload_learned(path)[0] if learned else load_base()
    model = model.cuda().eval()
    pages = [
        r for r in read_json("data/generated/input/pages.json") if r["split"] == "test"
    ]
    gold = {r["document_id"]: r for r in read_json("data/generated/labels/gold.json")}
    records = []
    probes = []
    # Always the same zero-shot response-only conditioning contract, including
    # the prompt-loss ablation. Few-shot cost/quality is evaluated separately.
    for index, page in enumerate(pages):
        row = encode_prompt_response(
            tokenizer,
            page["ticket"],
            gold[page["document_id"]]["target_json"],
            config,
            page["document_id"],
            False,
        )
        ids, mask, labels = collate([row])
        _, n, losses, logits, targets = active_response_logits(model, ids, mask, labels)
        records.append(
            {
                "document_id": page["document_id"],
                "input_ids": row["ids"],
                "prompt_tokens": row["prompt_tokens"],
                "active_response_tokens": n,
                "response_targets": targets.cpu().tolist(),
                "response_nll": losses.cpu().tolist(),
            }
        )
        if index == 0:
            for pos in [0, n - 1]:
                probes.append(
                    {
                        "document_id": page["document_id"],
                        "response_position": pos,
                        "target": int(targets[pos]),
                        "logits": logits[pos].cpu().tolist(),
                        "nll": float(losses[pos]),
                    }
                )
    total = math.fsum(v for row in records for v in row["response_nll"])
    count = sum(r["active_response_tokens"] for r in records)
    save_json(
        f"outputs/response-nll-{variant}.json",
        {
            "variant": variant,
            "conditioning": "zero-shot completion-only for every compared weight state",
            "documents": len(records),
            "response_tokens": count,
            "response_nll_nats_per_token": total / count,
            "rows": records,
            "probes": probes,
        },
    )

Modificare la maschera per comprendere il segnale

Ablazione della supervisione

Errore frequente: riutilizzare i risultati full come quelli dell'ablazione; addestrate un nuovo ramo e conservatene l'identità.

Invariante: tabella dei token attivi, del costo e dei campi assenti per ciascuna maschera.

File src/sft_complet/exercise.py

from .config import read_json, save_json, sha


def preserve_main():
    paths = [
        "outputs/evaluation.json",
        "outputs/response-nll-full.json",
        "outputs/response-nll-partial.json",
        "outputs/resume-full.json",
        "outputs/resume-partial.json",
        "assets/checkpoints/full-resume.safetensors",
        "assets/checkpoints/partial-resume.safetensors",
    ]
    save_json("outputs/exercise-before.json", {name: sha(name) for name in paths})


def compare_response_losses():
    before = read_json("outputs/exercise-before.json")
    if any(sha(name) != digest for name, digest in before.items()):
        raise ValueError("Exercise changed main artifacts")
    main = read_json("outputs/evaluation.json")
    exercise = read_json("outputs/exercise-evaluation.json")
    rows = {}
    for branch in ["full", "partial", "prompt_loss"]:
        training = read_json(
            f"outputs/train-{branch}-"
            + ("continuous" if branch == "prompt_loss" else "resume")
            + ".json"
        )
        nll = read_json(f"outputs/response-nll-{branch}.json")
        metrics = (exercise if branch == "prompt_loss" else main)["results"][branch][
            "test"
        ]
        rows[branch] = {
            "training_active_tokens": sum(
                r["active_tokens"] for r in training["history"]
            ),
            "updates": training["updates"],
            "trainable_parameters": training["trainable_parameters"],
            "test_response_tokens": nll["response_tokens"],
            "test_response_nll_nats_per_token": nll["response_nll_nats_per_token"],
            "test_metrics": {k: v for k, v in metrics.items() if k != "rows"},
            "model_digest": training["final_model_digest"],
            "initial_model_digest": training["initial_model_digest"],
        }
    if (
        len({r["test_response_tokens"] for r in rows.values()}) != 1
        or len({r["initial_model_digest"] for r in rows.values()}) != 1
    ):
        raise ValueError("Ablation initial state or evaluation population changed")
    if (
        rows["prompt_loss"]["training_active_tokens"]
        <= rows["full"]["training_active_tokens"]
    ):
        raise ValueError("Prompt-loss branch must actually supervise additional tokens")
    save_json(
        "outputs/exercise/loss-masks.json",
        {
            "rows": rows,
            "protected_sha256": before,
            "main_artifacts_unchanged": True,
            "fresh_prompt_loss_training": True,
            "quality_gain_required": False,
            "training_denominators_differ": True,
        },
    )

Fonti

Sono stati consultati il modello di base Qwen2.5-0.5B, l'implementazione Qwen2 di Transformers 4.56.2 e la gestione dei tensor condivisi in Safetensors 0.6.2 per verificare i meccanismi utilizzati. Il modello è una base preaddestrata, non la variante Instruct. Le versioni eseguibili e gli hash sono in locks e requirements.lock.txt.

Interpretare i risultati realmente ottenuti

Durante lo sviluppo, i rami full e partial raggiungono ciascuno 360 risposte esattamente corrette su 360 ticket test. La base senza esempi ne ottiene 145; il contesto con otto dimostrazioni non ne ottiene nessuna secondo il contratto di risposta completa. Le sue 360 uscite test raggiungono il limite di generazione. Un esempio grezzo inizia con un oggetto JSON poi prosegue con una nuova istruzione e un altro ticket: la base continua la sequenza di dimostrazioni. Non ripariamo questo output conservando solo il suo primo oggetto. Questa osservazione appartiene a questo modello di base e a questa serializzazione; non è una regola generale contro il few-shot.

Il corpus regolare rende qui sufficienti entrambi gli adattamenti. Il confronto full/partial include due learning rate distinti; non isola il solo effetto del numero di parametri. I tempi continui di apprendimento+validazione osservati sono 372,91 s (full) e 152,13 s (partial), con picchi di allocazione PyTorch di 9 964 563 968 e 2 611 971 584 byte. Queste misure dipendono dalla postazione e dal suo carico; non includono né acquisizione, né generazione, né ripetizioni della ripresa, né serializzazione finale. Non rappresentano tutta la VRAM riservata da Docker.

L'esercizio che supervisiona anche il prompt raggiunge anch'esso 360/360. Attiva 430238 token contro 105052 nella loss della sola risposta per gli stessi 420 update. La sua NLL test della risposta è 1,06265e−5, contro 6,89464e−7 per full. In questo esercizio non si osserva alcun degrado delle risposte finali; ciò non cancella la differenza di segnale e denominatore. I valori dettagliati e i limiti sono forniti qui sotto, e il percorso per il lettore li ricalcola integralmente.

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",
  "documents": 2400,
  "test_documents": 360,
  "parameters": 494032768,
  "partial_trainable_parameters": 29824768,
  "metrics": {
    "zeroshot": {
      "documents": 360,
      "json_parsable": 329,
      "json_parsable_rate": 0.9138888888888889,
      "schema_valid": 167,
      "schema_valid_rate": 0.4638888888888889,
      "exact_match": 145,
      "exact_match_rate": 0.4027777777777778,
      "field_correct": {
        "unit": 167,
        "symptom": 161,
        "location": 160,
        "scheduled_date": 158
      },
      "field_accuracy": {
        "unit": 0.4638888888888889,
        "symptom": 0.44722222222222224,
        "location": 0.4444444444444444,
        "scheduled_date": 0.4388888888888889
      },
      "absent_opportunities": {
        "unit": 15,
        "symptom": 9,
        "location": 20,
        "scheduled_date": 120
      },
      "correct_null": {
        "unit": 0,
        "symptom": 0,
        "location": 6,
        "scheduled_date": 71
      },
      "invented_nonnull_in_valid_record": {
        "unit": 0,
        "symptom": 6,
        "location": 7,
        "scheduled_date": 9
      },
      "invalid_record_with_absent_field": {
        "unit": 15,
        "symptom": 3,
        "location": 7,
        "scheduled_date": 40
      },
      "invented_rate_over_all_absent_opportunities": {
        "unit": 0.0,
        "symptom": 0.6666666666666666,
        "location": 0.35,
        "scheduled_date": 0.075
      },
      "null_failure_rate_over_all_absent_opportunities": {
        "unit": 1.0,
        "symptom": 1.0,
        "location": 0.7,
        "scheduled_date": 0.4083333333333333
      },
      "truncated": 31
    },
    "fewshot": {
      "documents": 360,
      "json_parsable": 0,
      "json_parsable_rate": 0.0,
      "schema_valid": 0,
      "schema_valid_rate": 0.0,
      "exact_match": 0,
      "exact_match_rate": 0.0,
      "field_correct": {
        "unit": 0,
        "symptom": 0,
        "location": 0,
        "scheduled_date": 0
      },
      "field_accuracy": {
        "unit": 0.0,
        "symptom": 0.0,
        "location": 0.0,
        "scheduled_date": 0.0
      },
      "absent_opportunities": {
        "unit": 15,
        "symptom": 9,
        "location": 20,
        "scheduled_date": 120
      },
      "correct_null": {
        "unit": 0,
        "symptom": 0,
        "location": 0,
        "scheduled_date": 0
      },
      "invented_nonnull_in_valid_record": {
        "unit": 0,
        "symptom": 0,
        "location": 0,
        "scheduled_date": 0
      },
      "invalid_record_with_absent_field": {
        "unit": 15,
        "symptom": 9,
        "location": 20,
        "scheduled_date": 120
      },
      "invented_rate_over_all_absent_opportunities": {
        "unit": 0.0,
        "symptom": 0.0,
        "location": 0.0,
        "scheduled_date": 0.0
      },
      "null_failure_rate_over_all_absent_opportunities": {
        "unit": 1.0,
        "symptom": 1.0,
        "location": 1.0,
        "scheduled_date": 1.0
      },
      "truncated": 360
    },
    "full": {
      "documents": 360,
      "json_parsable": 360,
      "json_parsable_rate": 1.0,
      "schema_valid": 360,
      "schema_valid_rate": 1.0,
      "exact_match": 360,
      "exact_match_rate": 1.0,
      "field_correct": {
        "unit": 360,
        "symptom": 360,
        "location": 360,
        "scheduled_date": 360
      },
      "field_accuracy": {
        "unit": 1.0,
        "symptom": 1.0,
        "location": 1.0,
        "scheduled_date": 1.0
      },
      "absent_opportunities": {
        "unit": 15,
        "symptom": 9,
        "location": 20,
        "scheduled_date": 120
      },
      "correct_null": {
        "unit": 15,
        "symptom": 9,
        "location": 20,
        "scheduled_date": 120
      },
      "invented_nonnull_in_valid_record": {
        "unit": 0,
        "symptom": 0,
        "location": 0,
        "scheduled_date": 0
      },
      "invalid_record_with_absent_field": {
        "unit": 0,
        "symptom": 0,
        "location": 0,
        "scheduled_date": 0
      },
      "invented_rate_over_all_absent_opportunities": {
        "unit": 0.0,
        "symptom": 0.0,
        "location": 0.0,
        "scheduled_date": 0.0
      },
      "null_failure_rate_over_all_absent_opportunities": {
        "unit": 0.0,
        "symptom": 0.0,
        "location": 0.0,
        "scheduled_date": 0.0
      },
      "truncated": 0
    },
    "partial": {
      "documents": 360,
      "json_parsable": 360,
      "json_parsable_rate": 1.0,
      "schema_valid": 360,
      "schema_valid_rate": 1.0,
      "exact_match": 360,
      "exact_match_rate": 1.0,
      "field_correct": {
        "unit": 360,
        "symptom": 360,
        "location": 360,
        "scheduled_date": 360
      },
      "field_accuracy": {
        "unit": 1.0,
        "symptom": 1.0,
        "location": 1.0,
        "scheduled_date": 1.0
      },
      "absent_opportunities": {
        "unit": 15,
        "symptom": 9,
        "location": 20,
        "scheduled_date": 120
      },
      "correct_null": {
        "unit": 15,
        "symptom": 9,
        "location": 20,
        "scheduled_date": 120
      },
      "invented_nonnull_in_valid_record": {
        "unit": 0,
        "symptom": 0,
        "location": 0,
        "scheduled_date": 0
      },
      "invalid_record_with_absent_field": {
        "unit": 0,
        "symptom": 0,
        "location": 0,
        "scheduled_date": 0
      },
      "invented_rate_over_all_absent_opportunities": {
        "unit": 0.0,
        "symptom": 0.0,
        "location": 0.0,
        "scheduled_date": 0.0
      },
      "null_failure_rate_over_all_absent_opportunities": {
        "unit": 0.0,
        "symptom": 0.0,
        "location": 0.0,
        "scheduled_date": 0.0
      },
      "truncated": 0
    },
    "prompt_loss": {
      "documents": 360,
      "json_parsable": 360,
      "json_parsable_rate": 1.0,
      "schema_valid": 360,
      "schema_valid_rate": 1.0,
      "exact_match": 360,
      "exact_match_rate": 1.0,
      "field_correct": {
        "unit": 360,
        "symptom": 360,
        "location": 360,
        "scheduled_date": 360
      },
      "field_accuracy": {
        "unit": 1.0,
        "symptom": 1.0,
        "location": 1.0,
        "scheduled_date": 1.0
      },
      "absent_opportunities": {
        "unit": 15,
        "symptom": 9,
        "location": 20,
        "scheduled_date": 120
      },
      "correct_null": {
        "unit": 15,
        "symptom": 9,
        "location": 20,
        "scheduled_date": 120
      },
      "invented_nonnull_in_valid_record": {
        "unit": 0,
        "symptom": 0,
        "location": 0,
        "scheduled_date": 0
      },
      "invalid_record_with_absent_field": {
        "unit": 0,
        "symptom": 0,
        "location": 0,
        "scheduled_date": 0
      },
      "invented_rate_over_all_absent_opportunities": {
        "unit": 0.0,
        "symptom": 0.0,
        "location": 0.0,
        "scheduled_date": 0.0
      },
      "null_failure_rate_over_all_absent_opportunities": {
        "unit": 0.0,
        "symptom": 0.0,
        "location": 0.0,
        "scheduled_date": 0.0
      },
      "truncated": 0
    }
  },
  "response_nll": {
    "zeroshot": 0.3579458403824175,
    "full": 6.894639002962341e-07,
    "partial": 1.5182218123592618e-05,
    "prompt_loss": 1.0626541892029729e-05
  },
  "quality_gain_required": false,
  "scope": "Data-only tokenizer decoding, label formulas, per-tensor hashes, exact active-token budgets, resume evidence, full-vocabulary CE probes and all-case metrics; not BPE re-encoding or scalar reimplementation of the 0.5B Transformer"
}

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

Condividi questo articolo