Le service technique fictif Novaforge emploie des codes et formulations qui reviennent dans ses notices. On veut adapter la distribution linguistique du modèle, tout en gardant une voie de consultation de notices révisées pour les faits changeants.
Comparer la vraisemblance de notices avant/après adaptation, puis les réponses courtes à des questions avec ou sans contexte fourni par un index lexical local. Montrer un fait changé dans une notice révisée après entraînement.
Télécharger le projet complet, révision r001
Trois interventions différentes
Le CPT poursuit l’objectif du prochain token depuis des poids préentraînés. Ici, tous les paramètres sont adaptés sur des notices non annotées. Aucune démonstration question/réponse n’entre dans cette loss. Un SFT apprendrait une transformation ou une manière de répondre à partir de cibles explicites ; ce n’est pas l’expérience menée. La recherche augmentée change le contexte fourni lors de l’inférence et permet de consulter un document récent sans modifier les poids.
Le modèle est Qwen/Qwen2.5-0.5B base, révision 060db6499f32faf8b98477b0a26969ef7d8b9987, avec tokenizer inchangé et chargement strict. Il possède 24 blocs, largeur 896, 14 têtes de requête, deux têtes K/V, MLP 4864 et vocabulaire modèle 151936. Embedding et tête de sortie sont liés ; le nombre de paramètres uniques est mesuré. Le modèle de base n’est pas un assistant instruction-tuned. Un échec de format en réponse courte n’annule pas une éventuelle amélioration de NLL et ne doit pas être caché.
Données et conditions réservées
NovaLex-v1 contient 500 familles de huit notices : 350/75/75 familles, soit 2800/600/600 documents. CommonWorkshop-v1 contient 200 familles de cinq textes : 140/30/30 familles, soit 700/150/150 documents. Références, valeurs et combinaisons sont générées avant utilisation ; les variantes d’une famille ne traversent pas les partitions. Les patrons et le vocabulaire restent partagés. Ce corpus artificiel ne représente pas la complexité d’un fonds industriel.
Les 700 textes train de contrôle servent au replay. Les 150 textes test de contrôle n’y entrent jamais. Leur delta de NLL mesure une dérive sur ce périmètre synthétique uniquement, pas un oubli de toutes les connaissances générales. Les 200 questions portent sur les 200 premières notices test, jamais vues par CPT. L’évaluation fermée ne fournit aucun contexte : le modèle peut légitimement ignorer ces faits réservés. L’évaluation ouverte recherche dans les 4000 notices sources, y compris celles de test ; cette consultation est déclarée et distincte de l’entraînement.
Quarante notices test ont une version 2, créée séparément et marquée inference_only. Leur nombre d’impulsions augmente de deux. Les valeurs précises figurent dans les fichiers générés ; l’exemple 3→5 décrit le mécanisme sans imposer que chaque cas ait ces mêmes nombres. Les v2 ne sont jamais montées dans learning.
Cibles, frontières et budget exact
Chaque texte est tokenisé sans chat template ni ajout implicite de tokens. L’identifiant 151643 joue explicitement le rôle d’ancre au début et d’EOS à la fin. Le même ID sert au padding, mais attention_mask et labels sont construits par position : un vrai EOS reste une cible ; un PAD est ignoré. Les fenêtres de 512 tokens au maximum ont un pas de 511. Chaque prochain token, y compris EOS, apparaît exactement une fois dans le parcours d’un document, sans concaténation entre documents. Le contexte est réinitialisé par fenêtre.
La proposition initiale de 2048 cibles par mise à jour ne se divise pas exactement en 80/20 entiers. Avant toute exécution, le protocole fixe 2050 cibles × 512 mises à jour = 1049600 cibles par branche, soit 1024 de plus que la proposition. Domaine seul : 2050 cibles métier par pas. Replay : exactement 1640 métier et 410 contrôle, donc 839680 et 209920 au total. Le replay reçoit moins de tokens métier, malgré le même budget total : c’est une partie de l’intervention, pas un détail à omettre.
Un sampler parcourt des permutations de fenêtres. Lorsque le quota coupe une fenêtre, seules les positions cibles choisies participent à la loss ; la fenêtre complète reste disponible comme contexte causal. La prochaine mise à jour reprend au premier token cible non consommé. Les tokens futurs présents dans le tenseur ne peuvent pas influencer les logits antérieurs grâce au masque causal. Le journal garde fenêtre, début/fin de cibles, compteurs et type de flux. Le ratio ne dépend donc pas de la longueur des documents.
Les microbatches contiennent une sélection documentaire chacun. Le réseau effectue le forward en autocast BF16 ; poids maîtres, gradients et états AdamW sont FP32. Les logits sélectionnés sont convertis en FP32 pour la CE. BF16 est vérifié sur le GPU réel ; aucun GradScaler n’est utilisé. L’attention eager et les algorithmes déterministes sont fixés, TF32 désactivé. Le coût d’un forward dépend aussi du contexte relu lorsqu’un quota coupe une fenêtre : égalité de tokens supervisés n’implique pas égalité parfaite de FLOPs.
AdamW utilise lr maximal 0,00001, weight_decay 0,01, warm-up 51 mises à jour puis décroissance linéaire vers 10 % du taux maximal ; clipping de norme à 1. Le checkpoint final à 512 est fixé. La validation tous les 128 pas est rapportée sans sélection. L’interruption à 200 et la reprise doivent retrouver exactement modèle, optimiseur, scheduler, états aléatoires, permutations, curseurs documentaires et offsets de tokens. Les fichiers .pt sont des états produits localement, à ne pas remplacer par du contenu non fiable. Les hashes par tenseur et Safetensors relus strictement contrôlent les poids finaux.
NLL et réponses ne mesurent pas la même chose
La NLL réservée utilise les mêmes tokens et dénominateurs pour base, CPT domaine et CPT replay : 600 notices métier et 150 textes de contrôle. Les pertes unitaires et des logits complets de référence sont conservés. Le test compare la somme des pertes actives, une accumulation streaming et la référence Transformers sur le même batch. Une NLL qui baisse indique que ces textes deviennent plus probables ; elle ne prouve ni une réponse correcte ni une mémorisation de faits non vus.
Les trois états de poids répondent chacun aux mêmes 200 questions avec contexte vide puis top-3 lexical : six conditions explicites. Le prompt reste identique à la présence du contexte près et n’est pas ajusté sur les résultats test. La génération greedy autorise 32 nouveaux tokens, sans contrainte de grammaire ni sélection du premier nombre dans une phrase. Une réponse doit être entièrement numérique après retrait des espaces périphériques et terminer par EOS. Les UNKNOWN, textes parasites et troncatures sont des échecs d’exact match. Les sorties brutes et raisons restent présentes.
Index lexical et provenance
Le TF-IDF est codé avec tf=1+log(compte), idf=log((N+1)/(df+1))+1, normalisation L2 et cosinus. Il indexe uniquement le texte des 4000 notices, jamais les labels de réponse. Les requêtes sont tokenisées de la même manière ; les égalités de score sont départagées par identifiant. Les trois notices retenues apparaissent dans le prompt avec leur ID et version. Le rappel du document attendu dans le top-3 est calculé séparément de l’exactitude générative : retrouver le bon texte ne garantit pas qu’il soit utilisé correctement.
L’index v1 reste immuable. La mise à jour produit un nouvel index v2 contenant exactement 40 remplacements et leurs nouveaux hashes, puis recalcule la recherche. Il n’y a pas de cache de contexte susceptible de masquer cette révision. L’assemblage refuse un couple résultat/version incohérent ; un prompt trop long est rejeté sans troncature silencieuse. Le prompt ouvert ne dépasse pas 1024 tokens.
Exercice de révision
Après CPT, les poids et résultats principaux sont protégés par SHA. L’exercice exécute réellement 40 réponses pour chacune des six conditions avec index v2. Il compare les réponses v1 aux anciens et aux nouveaux faits, puis les réponses v2 aux nouveaux faits. Les prompts fermés doivent rester identiques ; leur stabilité de sortie est mesurée. Le nombre de nouveaux pas d’apprentissage est zéro.
Une meilleure disponibilité du fait récent ne garantit pas une meilleure réponse. Les résultats de la recherche, de la génération et les hashes de poids sont donc exposés séparément. Le replay peut limiter la dérive de NLL du contrôle, sans garantie et avec moins de tokens métier. Une seule graine d’apprentissage est testée ; il faudrait répéter et élargir les données avant de conclure à une stratégie d’adaptation utile en production.
Préparer des notices et un contrôle séparé
Partition documentaire et versions
Invariant : Comptes et familles séparés ; 40 révisions v2 marquées comme ajout d'inférence.
Erreur fréquente : Une notice et sa paraphrase traversent les splits : partitionner avant les variantes.
Fichier 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
Acquérir le modèle et inspecter sa NLL
Modèle de base, tokens et cibles
Invariant : SHA résolu, téléchargement reproductible, comptes exacts de cibles.
Erreur fréquente : EOS supprimé comme s'il était PAD coupe l'apprentissage des fins : IDs et attention_mask distincts.
Fichier 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"]
Fixer la baseline et l'index ouvert
Poids vs contexte de recherche
Invariant : Les requêtes s'exécutent sans lire target ; chaque contexte cite une notice existante.
Erreur fréquente : Le champ réponse est indexé par inadvertance : n'indexer que les documents sources.
Fichier 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,
)
Adapter avec et sans replay
Budget en tokens et mélange explicite
Invariant : Les poids changent et les compteurs correspondent au protocole ; base immuable.
Erreur fréquente : Un mélange 80/20 annoncé devient 95/5 à cause des longueurs : piloter le budget par tokens.
Fichier 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",
},
)
Mesurer NLL et réponses dans des conditions nommées
Évaluation fermée et ouverte
Invariant : 200 mêmes questions par condition, tous les échecs de format comptés.
Erreur fréquente : Comparer un CPT sans contexte à une base avec contexte sans le dire mélange deux interventions.
Fichier 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,
},
)
Mettre à jour un fait sans mettre à jour les poids
Test contrefactuel de version
Invariant : L'index référence v2 et les poids gardent leur hash.
Erreur fréquente : L'ancien cache de contexte survit à la révision : inclure version documentaire dans sa clé.
Fichier 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,
},
)
Sources
Don’t Stop Pretraining fournit le contexte de l’adaptation de domaine ; ses résultats ne sont pas ceux de ce TP causal. Carte Qwen2.5-0.5B et objectif causal Transformers ont été consultés. Le chargement et les versions exécutées sont verrouillés localement. Docker GPU et NVIDIA Container Toolkit documentent les prérequis.
Résultats observés lors du développement GPU
Le parcours complet a réussi avec 14 tests en 13 673,51 secondes. Les deux branches effectuent chacune 512 mises à jour et 1 049 600 cibles supervisées. Le replay remplace 20 % des cibles métier par 209 920 cibles de contrôle ; il conserve donc 839 680 cibles métier. Il ne voit pas autant de texte métier que la branche dédiée. Les reprises après 200 pas retrouvent exactement les poids, l'optimiseur, les flux de tokens, les RNG et les historiques des chemins continus. La reproduction depuis le ZIP public reste une vérification distincte.
Sur les 600 notices métier test, soit 25 275 cibles, la NLL passe de 5,197560 à 0,429035 après CPT métier et à 0,417514 avec replay. Sur les 150 textes de contrôle test, soit 3 840 cibles, elle passe de 5,193446 à 6,220325 après CPT métier, contre 1,255818 avec replay. Cela montre ici une dérive sur ce contrôle fictif restreint et son atténuation par le mélange. Ce contrôle ne mesure pas l'ensemble des compétences générales du modèle. Les textes de validation sont distincts et leurs comptes de tokens ne doivent pas être substitués à ceux du test.
La perte et la qualité des réponses libres ne racontent pas la même chose. Sans contexte, le modèle de base répond exactement à 13/200 questions, contre 21/200 pour chacune des branches entraînées. Avec les trois documents retrouvés, il atteint 200/200, contre 168/200 après CPT métier et 178/200 avec replay. Le rappel documentaire top-3 vaut 200/200 : les erreurs des branches entraînées subsistent alors même que la bonne notice est fournie. Aucun avantage du CPT sur la lecture documentaire du modèle de base n'est observé. Les 41 sorties tronquées du modèle de base sans contexte restent dans le dénominateur ; aucune réponse partielle n'est réparée pour améliorer le score.
L'exercice révise 40 faits dans les documents consultables, sans entraîner à nouveau les modèles. Sur les nouvelles valeurs, la consultation de l'index v2 donne 39/40 réponses exactes avec le modèle de base, 34/40 après CPT métier et 36/40 avec replay. Sans documents, les entrées et les sorties restent identiques sur les 40 cas ; aucune réponse n'est exacte pour les valeurs révisées. Le rappel top-3 de l'index révisé est 199/200 sur l'ensemble des questions. La mise à jour documentaire permet donc d'exposer un fait nouveau, sans garantir une lecture correcte de chaque cas.
Le fil rouge réellement conservé est la notice N0425-0 : la valeur « 6 pulses » devient « 8 pulses ». Les trois variantes répondent 6 avec l'ancien document et 8 avec sa révision, avec arrêt EOS. Cet exemple est un cas réussi parmi les populations complètes, pas un substitut à leur évaluation. Les notices test consultées n'ont jamais servi à l'apprentissage CPT : il s'agit explicitement d'un test avec documents fournis.
Les chemins continus prennent 3029.10 s (métier) et 3412.02 s (replay), avec validation et sauvegarde. Les pics d'allocation PyTorch correspondants sont 9990552576 et 9984970752 octets. Ils ne représentent pas toute la VRAM vue par Linux. Les exécutions interrompues et reprises, l'acquisition, les tests, l'inférence et les audits expliquent le coût supérieur du parcours complet. Une préparation CPU d'autres tutoriels a eu lieu pendant ce parcours : aucune mesure sur un hôte entièrement inactif n'est revendiquée.
Une seule graine, un corpus synthétique fermé et un format de réponse numérique borné ne suffisent pas à établir un bénéfice métier général. Le replay a ici un intérêt mesuré sur le contrôle de langue ; le CPT n'est ni une base documentaire actualisable, ni une garantie d'exactitude factuelle.
Observations de développement
Valeurs recalculées depuis les sorties brutes de ce parcours. Elles ne sont pas des seuils de gain ; le parcours depuis le ZIP les recalcule sur les mêmes populations.
{
"status": "PASS",
"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"
}