Un outil fictif transforme des faits d'incident en un compte rendu bref qui sépare observation et hypothèse. Des paires illustrent une synthèse fidèle et une synthèse qui ajoute une cause non établie. La tâche n'est ni médicale ni un contrôle d'équipement.
Comparer SFT chosen-only et ORPO depuis les mêmes poids instruct. Afficher NLL choisie, log-odds et qualité des résumés, avec un exemple où une formule numériquement naïve échoue.
Télécharger le projet complet, révision r003
D’abord le fait, ensuite le compte rendu
IncidentNotesPrefs-v1 contient 300 familles de quatre paires, soit 1200 exemples. Les familles 0–209 donnent 840 paires train ; 210–254 donnent 180 validation ; 255–299 donnent 180 test. Chaque dossier nomme une unité fictive, son état stopped/running, une pression connue ou non mesurée et une cause confirmée ou inconnue. Une ancienne note donne un état contradictoire et une cause suggérée ; le relevé courant la remplace explicitement. Aucune action sur un équipement réel n’est proposée.
La réponse choisie exprime les faits courants. Le rejet inverse une seule information : cause dans deux formulations, pression dans une, état dans une. Certaines causes sont confirmées : écrire systématiquement « inconnue » est alors faux. Une réponse polie peut néanmoins inventer une cause. Les formules de courtoisie sont alternées pour produire choisi plus court, de même longueur en mots ou plus long. Les mots et ces régimes sont audités ; les strates finales utilisent les tokens réels.
Toutes les variantes d’une famille restent dans une partition. Les identifiants sont distincts, mais les faits élémentaires et formes de langage sont partagés : cette séparation ne prouve pas une généralisation à des incidents ou domaines réels. Les annotations sont simulées, sans retour humain revendiqué. Un audit par mot ne démontre pas l’absence de tout raccourci sémantique.
Une distribution sur les tokens
La base Qwen2.5-0.5B-Instruct est acquise à la révision 7ae557604adf67be50417f59c2c2f167def9a775. Ses 494 032 768 paramètres uniques sont tous entraînables en FP32. L’architecture est contrôlée : 24 blocs, largeur 896, 14 têtes Q et deux K/V, MLP 4864, vocabulaire 151936, embeddings/sortie liés. Attention eager et TF32 désactivé ; une seule carte explicitement choisie. Pas de LoRA, quantification ou copie de référence.
Le template du tokenizer assemble système, incident et frontière assistant. La réponse est encodée séparément, avec un seul EOS=151645 final. Ce contrat n’affirme pas une équivalence à la retokenisation de la chaîne entière. Les limites sont 256 tokens de prompt, 128 de réponse EOS compris, total au plus 512. Tout dépassement provoque une erreur identifiée, jamais une troncature de données d’apprentissage.
Les logits en position t−1 prédisent le token t. Après décalage, seuls les labels réponse restent actifs ; le prompt et le padding valent −100. PAD=151643 et EOS sont distingués, et les positions réelles déterminent les masques. Le prompt n’est pas supervisé directement, mais il conditionne chaque score réponse.
Pour une réponse de n tokens actifs, on calcule une somme s de log-probabilités, puis ell=s/n. q=exp(ell) est la moyenne géométrique des probabilités de tokens, pas la probabilité jointe de la réponse. EOS participe au compte n. Une réponse vide est refusée. Contrairement à DPO, l’objectif ORPO retenu utilise ell, pas s.
Des probabilités aux cotes
La cote de q vaut q/(1−q). Sa logarithme vaut ell−log(1−exp(ell)). Pour choisi et rejeté, on soustrait les deux log-cotes ; une différence positive favorise le choisi. La pénalité par paire est −logsigmoid(différence). La perte totale ajoute une supervision : NLL choisie + lambda × moyenne des pénalités de paire.
Deux réductions distinctes sont importantes. La NLL est la somme de toutes les CE choisies divisée par tous leurs tokens actifs. La préférence est une moyenne par paire. Dans une accumulation de huit paires de longueurs différentes, chaque CE choisie partage le dénominateur total de tokens de ces huit paires, tandis que chaque pénalité est divisée par huit. Une moyenne de moyennes de CE changerait l’objectif.
Lambda=0 doit retrouver la perte et les gradients SFT avec ces mêmes masques et réductions. La branche SFT réelle ne calcule pas les rejets ; le test lambda=0 peut les calculer pour vérifier leur gradient nul. Des réponses identiques donnent une pénalité relative log(2), sans signal relatif utile ; la NLL choisie reste présente. Les paires identiques sont rejetées dans le corpus.
Éviter une soustraction imprécise
Calculer directement log(1−exp(ell)) peut donner log(0) près de zéro : exp(ell) s’arrondit à 1. Nous utilisons log1p(−exp(ell)) si ell < −log(2), et log(−expm1(ell)) dans l’autre branche. Seule la branche utile est évaluée pour chaque valeur ; évaluer les deux puis sélectionner par torch.where pourrait laisser un gradient NaN venant de la branche inactive.
Les deux côtés sont empilés avant cette opération commune. Cette symétrie permet aux gradients opposés d’une même entrée de s’annuler exactement au point où ils se rejoignent, sans changer la formule ni relâcher le test.
Le domaine est explicite : FP32 ou FP64, ell fini strictement négatif, avec une magnitude au moins égale à finfo.tiny. Zéro et les magnitudes subnormales sont refusés, car la log-cote ou sa dérivée n’y est pas représentable dans le contrat retenu. Il n’existe aucun clamp silencieux créant un plateau. Les tests incluent −1000, la frontière −log(2), −1e−300 en FP64 et −1e−30 en FP32. Les échecs de la formule naïve sont conservés avec un statut et null pour les nombres non finis ; jamais un faux résultat numérique JSON.
La valeur et les gradients du terme de préférence sont comparés à odds_ratio_loss de TRL v0.23.1, commit 4529a1c8b1813480a85b02c9c8e7f75a29085d65, dans le domaine numérique ordinaire : tolérance absolue 1e−12 en FP64 et 2e−6 en FP32. Cet extrait Apache-2.0 est fourni avec provenance et licence ; le trainer n’est pas importé. La NLL masquée est comparée séparément à CrossEntropyLoss de PyTorch.
Cette comparaison ne reproduit pas tout le trainer TRL : dans la version inspectée, celui-ci reconstruit les labels choisis depuis les IDs et l’attention, incluant le prompt. Le TP emploie explicitement des labels réponse seule. Le dépôt original des auteurs a aussi été inspecté ; ses masques et sa conversion finale BF16 ne sont pas repris. On compare la formule de préférence et les réductions annoncées, sans revendiquer l’identité de toute une infrastructure d’entraînement.
Trois apprentissages et une base commune
Le minimum compare la base acquise, SFT chosen-only et ORPO lambda=0,1. Les deux politiques entraînées repartent indépendamment du même fichier instruct, sans SFT importé ni amorçage d’un autre tutoriel. Chaque politique voit les mêmes 840 prompts pendant deux époques, dans le même ordre déterministe : 1680 présentations et 210 mises à jour, microbatch d’une paire et accumulation de huit.
AdamW utilise lr=0,000002, weight_decay=0,01, clipping à 1, warm-up de 21 mises à jour puis décroissance linéaire jusqu’à 10 % du taux maximal. La graine est 47. Les paramètres complets et gradients FP32 sont contrôlés. La validation est mesurée après chaque époque, sans sélectionner un checkpoint ou lambda sur le test. L’ORPO traite aussi les réponses rejetées ; même exposition de prompts ne signifie pas même budget de tokens, forwards ou temps.
Chaque phase comprend interruption après une époque, entraînement continu de deux époques et reprise de la seconde. Les états finaux du modèle, AdamW, sampler, scheduler, RNG Python/NumPy/PyTorch CPU/CUDA et historiques doivent être exactement égaux. Les checkpoints complets sont sérialisés localement en gzip niveau 1 sans perte, avec tailles réellement consignées, puis publiés par renommage atomique. Leur compression ne réduit pas la VRAM et ne garantit pas un taux de réduction. Ils sont de confiance parce que produits localement ; ne pas charger un objet Torch téléchargé à leur place. Les modèles finaux sont également conservés en Safetensors et rechargés strictement.
L’exercice ajoute une troisième politique entraînée, appelée sum-ablation, avec les mêmes poids initiaux, ordre de prompts, optimiseur et mises à jour. Son terme de cotes reçoit les SOMMES plutôt que les moyennes : c’est une ablation nommée, pas l’ORPO de référence. Les neuf états complets des trois phases restent locaux. Les durées d’optimisation, de validation, des commandes complètes et les pics mesurés sont distingués.
Évaluer des candidats et des textes libres
Chaque état base/SFT/ORPO/sum-ablation score les mêmes 180 paires test. Le rapport publie NLL choisie par token, marge de log-cotes, pénalité et accuracy avec demi-crédit aux égalités. Les résultats sont séparés selon la longueur choisie plus courte, égale ou plus longue en tokens. Pour comparer les politiques, cette évaluation utilise toujours les cotes des moyennes, y compris sur les poids de sum-ablation ; le nom du modèle n’altère pas en cachette la métrique.
Chaque état génère aussi 180 comptes rendus libres, avec le seul incident dans le prompt : glouton, une beam, 128 nouveaux tokens maximum, EOS=151645. IDs, texte brut, raison d’arrêt, longueur et durées restent disponibles. Une réponse arrêtée au plafond est un échec même si son début paraît complet. Aucune sortie n’est envoyée ni appliquée à un système réel.
La rubrique est une grammaire indépendante de la génération. Elle reconnaît les clauses contrôlées d’unité/état, pression et cause. Toute phrase non reconnue, un champ répété, une balise ou un arrêt au plafond rendent la note incomplète. Une paraphrase naturelle correcte hors grammaire peut donc échouer. Le score n’est pas un juge général de fidélité ni une mesure de satisfaction.
L’exactitude demande les quatre champs corrects sur TOUS les 180 cas. Les causes connues et inconnues ont des dénominateurs séparés. Une cause reconnue différente du relevé est comptée comme non étayée. Une abstention reconnue sur une cause connue est comptée séparément ; un champ absent dans une prose incomprise n’est pas automatiquement appelé abstention. Une pression reconnue alors qu’aucune mesure n’existe est signalée. Les phrases non reconnues restent non certifiées, même si ces compteurs partiels valent zéro. Les échecs ne sont jamais retirés des dénominateurs.
Exercice sur la longueur
Les premières lignes de l’exercice sont analytiques : log-probabilités constantes −1 pour les tokens choisis et −1,2 pour les rejetés, avec longueurs 4, 16 et 64. Moyennes, sommes, cotes et gradients sont comparés pour les neuf couples de longueurs. Avec les moyennes, la qualité moyenne constante ne dépend pas de la longueur ; avec les sommes, une réponse choisie longue peut perdre face à une réponse rejetée courte. Ce n’est pas une mesure sur des textes métier.
Ensuite, les scores réels des 180 paires de la politique ORPO principale sont soumis aux deux formules sans modifier ses poids. Enfin, la politique sum-ablation est réellement entraînée puis évaluée en génération libre. Ses résultats sont séparés de l’expérience analytique et du simple recalcul de formule. Les SHA-256 des modèles, résultats principaux et configuration minimum sont protégés ; la version principale conserve les moyennes.
Une perte numériquement stable peut apprendre de mauvaises préférences. Une marge positive sur deux candidats ne garantit pas un bon texte nouveau. Une seule graine et des patrons synthétiques bornent fortement les conclusions ; aucune amélioration d’ORPO sur SFT n’est exigée.
Définir des préférences sans mot révélateur
Observation et causalité
Erreur fréquente : Tous les chosen disent 'unconfirmed' : ajouter causes confirmées et paraphrases sans modifier test après run.
Invariant : 840/180/180 paires, pas de fuite d'incident, diversité des motifs.
Fichier src/orpo_sans_reference/data.py
import hashlib, re
from pathlib import Path
from .config import save_json, sha
CAUSES = ["filter blockage", "power loss", "sensor fault"]
COURTESY = [
"This is the recorded incident note.",
"Further details may be documented separately.",
]
def split_incident_families(family):
return "train" if family < 210 else "validation" if family < 255 else "test"
def facts_for(family):
return {
"unit": f"P{family+1000}",
"state": "stopped" if family % 3 else "running",
"pressure": None if family % 4 < 2 else 10 + (family // 4) % 7,
"cause": None if family % 2 == 0 else CAUSES[(family // 2) % 3],
}
def incident_text(family, j, facts):
previous = "running" if facts["state"] == "stopped" else "stopped"
old_cause = CAUSES[(family // 2 + 1) % 3]
pressure = (
"not measured" if facts["pressure"] is None else str(facts["pressure"]) + " kPa"
)
cause = "not confirmed" if facts["cause"] is None else "confirmed " + facts["cause"]
return f"Incident N{family:03d}-{j}. Superseded note: state {previous}; suggested cause {old_cause}. Current verified record: unit {facts['unit']}; state {facts['state']}; pressure {pressure}; cause {cause}. Use only the current record."
def reply(facts, j, courtesy=False):
first = [
f"{facts['unit']} is {facts['state']}.",
f"Unit {facts['unit']} is {facts['state']}.",
f"The state of {facts['unit']} is {facts['state']}.",
f"The current state of {facts['unit']} is {facts['state']}.",
][j]
pressure = (
f"The pressure is {facts['pressure']} kPa."
if facts["pressure"] is not None
else ("Pressure was not measured." if j % 2 else "The pressure is unknown.")
)
cause = (
f"The confirmed cause is {facts['cause']}."
if facts["cause"] is not None
else (
"The cause remains unconfirmed."
if j % 2
else "The confirmed cause is not known."
)
)
clauses = [first, pressure, cause]
if courtesy:
clauses = COURTESY[:1] + clauses + COURTESY[1:]
return " ".join(clauses)
def audit_causal_labels(labels):
n = len(labels)
lengths = {"chosen_shorter": 0, "same_words": 0, "chosen_longer": 0}
known = sum(r["expected"]["cause"] is not None for r in labels)
features = []
words = sorted(
{
w
for r in labels
for side in ["chosen", "rejected"]
for w in re.findall("[a-z]+", r[side].lower())
}
)
for row in labels:
a, b = len(row["chosen"].split()), len(row["rejected"].split())
lengths[
"chosen_shorter" if a < b else "chosen_longer" if a > b else "same_words"
] += 1
for word in words:
a, b = [
sum(word in set(re.findall("[a-z]+", r[side].lower())) for r in labels)
for side in ["chosen", "rejected"]
]
accuracy = (a + n - b) / (2 * n)
features.append(
{
"word": word,
"chosen_presence": a,
"rejected_presence": b,
"best_binary_label_accuracy": max(accuracy, 1 - accuracy),
}
)
return {
"pairs": n,
"known_cause": known,
"unknown_cause": n - known,
"unknown_pressure": sum(r["expected"]["pressure"] is None for r in labels),
"word_length_regimes": lengths,
"best_length_preference_accuracy_half_ties": max(
(lengths["chosen_longer"] + 0.5 * lengths["same_words"]) / n,
(lengths["chosen_shorter"] + 0.5 * lengths["same_words"]) / n,
),
"word_features": features,
"maximum_word_accuracy": max(r["best_binary_label_accuracy"] for r in features),
"scope": "Original words, not every possible semantic/BPE shortcut",
}
def generate_incident_pairs():
pages = []
labels = []
for family in range(300):
facts = facts_for(family)
for j in range(4):
identifier = f"N{family:03d}-{j}"
text = incident_text(family, j, facts)
wrong = dict(facts)
reason = ["cause", "pressure", "state", "cause"][j]
if reason == "cause":
wrong["cause"] = (
CAUSES[(family // 2) % 3] if facts["cause"] is None else None
)
elif reason == "pressure":
wrong["pressure"] = (
10 + (family // 4) % 7 if facts["pressure"] is None else None
)
else:
wrong["state"] = "running" if facts["state"] == "stopped" else "stopped"
chosen = reply(facts, j, courtesy=j == 1)
rejected = reply(wrong, j, courtesy=j == 2)
if chosen == rejected:
raise ValueError("Preference pair is identical")
pages.append(
{
"document_id": identifier,
"family": family,
"formulation": j,
"split": split_incident_families(family),
"incident": text,
"record": facts,
"content_sha256": hashlib.sha256(text.encode()).hexdigest(),
}
)
labels.append(
{
"document_id": identifier,
"chosen": chosen,
"rejected": rejected,
"expected": facts,
"preference_reason": reason,
"simulated_preference": True,
}
)
if len({p["incident"] for p in pages}) != 1200:
raise ValueError("Duplicate incident")
return pages, labels
def prepare(config):
root = Path("data/generated")
if (root / "manifest.json").exists():
raise ValueError("Preserve existing corpus; use a new extraction")
pages, labels = generate_incident_pairs()
gold = {r["document_id"]: r for r in labels}
save_json(root / "input/pages.json", pages)
save_json(root / "labels/gold.json", labels)
counts = {}
audits = {}
for split in ["train", "validation", "test"]:
selected = [p for p in pages if p["split"] == split]
chosen = [gold[p["document_id"]] for p in selected]
counts[split] = {
"pairs": len(selected),
"families": len({p["family"] for p in selected}),
}
audits[split] = audit_causal_labels(chosen)
if split != "test":
save_json(root / f"{split}/pages.json", selected)
save_json(root / f"labels/{split}.json", chosen)
save_json(
"outputs/data-audit.json",
{
"dataset": "IncidentNotesPrefs-v1",
"rights": "CC0-1.0",
"counts": counts,
"shortcuts": audits,
"human_preferences": False,
"shared_semantic_and_language_primitives": True,
"current_record_supersedes_previous_note": True,
"reference_model_or_cache": False,
},
)
save_json(
root / "manifest.json",
{
"dataset": "IncidentNotesPrefs-v1",
"seed": config["seed"],
"counts": counts,
"files": {
str(f.relative_to(root)): sha(f) for f in root.rglob("*") if f.is_file()
},
},
)
return counts
Calculer les moyennes de tokens
Masques, longueur et score q
Erreur fréquente : Un batch moyen pondère chaque phrase par erreur : nommer séparément moyenne tokens et moyenne paires.
Invariant : PAD invariants, réponses non vides, réduction contrôlée.
Fichier src/orpo_sans_reference/scores.py
import torch
from .config import save_json
SYSTEM = "Write an incident note using only the current verified record. State the unit and its current state, the pressure if measured, and the confirmed cause if known. Distinguish observations from causal conclusions. A superseded suggestion is not a current fact. Do not add facts, actions or explanations."
def format_prompt(tokenizer, incident, version):
if version != "incident-v1":
raise ValueError("Unknown incident template")
messages = [
{"role": "system", "content": SYSTEM},
{"role": "user", "content": incident},
]
return messages, tokenizer.apply_chat_template(
messages, tokenize=True, add_generation_prompt=True
)
def completion_row(tokenizer, incident, response, config, identifier, version=None):
version = version or config["template_version"]
messages, prompt = format_prompt(tokenizer, incident, version)
target = tokenizer.encode(response, add_special_tokens=False) + [151645]
if (
not prompt
or len(prompt) > config["max_prompt_tokens"]
or not 1 < len(target) <= config["max_response_tokens_including_eos"]
or len(prompt) + len(target) > config["context"]
):
raise ValueError(
identifier + ": declared token budget exceeded; do not truncate"
)
return {
"document_id": identifier,
"messages": messages,
"input_ids": prompt + target,
"labels": [-100] * len(prompt) + target,
"prompt_tokens": len(prompt),
"response_tokens": len(target),
"template_version": version,
}
def paired_completion_batch(tokenizer, page, label, config, version=None):
chosen = completion_row(
tokenizer,
page["incident"],
label["chosen"],
config,
page["document_id"],
version,
)
rejected = completion_row(
tokenizer,
page["incident"],
label["rejected"],
config,
page["document_id"],
version,
)
if (
chosen["input_ids"] == rejected["input_ids"]
and chosen["labels"] == rejected["labels"]
):
raise ValueError("Identical completions are not an informative preference")
return {
"document_id": page["document_id"],
"chosen": chosen,
"rejected": rejected,
"response_length_delta": chosen["response_tokens"]
- rejected["response_tokens"],
}
def collate(rows, device="cuda", pad_to=None):
if not rows:
raise ValueError("Empty completion batch")
width = max(len(r["input_ids"]) for r in rows) if pad_to is None else pad_to
ids = torch.full((len(rows), width), 151643, dtype=torch.long, device=device)
mask = torch.zeros_like(ids, dtype=torch.bool)
labels = torch.full_like(ids, -100)
for i, row in enumerate(rows):
n = len(row["input_ids"])
if n > width:
raise ValueError("Padding width would truncate a response")
ids[i, :n] = torch.tensor(row["input_ids"], device=device)
mask[i, :n] = True
labels[i, :n] = torch.tensor(row["labels"], device=device)
return ids, mask, labels
def sequence_logprob(logits, labels):
if logits.shape[:2] != labels.shape:
raise ValueError("Logits and labels shape mismatch")
active = labels[:, 1:] != -100
counts = active.sum(dim=1)
if (counts == 0).any():
raise ValueError("Every sequence needs an active response target")
selected = logits[:, :-1][active]
targets = labels[:, 1:][active]
values = (
torch.log_softmax(selected, dim=-1)
.gather(-1, targets.unsqueeze(-1))
.squeeze(-1)
)
sums = torch.stack([v.sum() for v in values.split(counts.tolist())])
return sums, counts, values, selected, targets
def model_scores(model, rows):
ids, mask, labels = collate(rows)
logits = model(
input_ids=ids, attention_mask=mask, use_cache=False, return_dict=True
).logits
return sequence_logprob(logits.float(), labels)
def mean_completion_logprob(logits, labels):
sums, counts, values, selected, targets = sequence_logprob(logits, labels)
return sums / counts, counts, values, selected, targets
def chosen_token_nll(logits, labels):
sums, counts, _, _, _ = sequence_logprob(logits, labels)
return -sums.sum() / counts.sum()
def inspect_pair_lengths(config):
from .model import load_tokenizer
from .data import generate_incident_pairs
tokenizer = load_tokenizer()
pages, labels = generate_incident_pairs()
pairs = [
paired_completion_batch(tokenizer, pages[i], labels[i], config)
for i in range(4)
]
save_json(
"outputs/tokenization-mechanism.json",
{
"pairs": pairs,
"preference_reduction": "mean_response_logprob_including_EOS",
"nll_reduction": "all_chosen_active_tokens",
"prompt_and_padding_excluded": True,
},
)
Coder les cotes de façon stable
log1mexp et sigmoid
Erreur fréquente : Un clamp silencieux crée un plateau de gradient : le rendre explicite et tester sa zone d'action.
Invariant : Tests FP64/FP32 et concordance de référence selon tolérances préfixées.
Fichier src/orpo_sans_reference/loss.py
import math
from types import SimpleNamespace
import torch
from .config import save_json
def log1mexp(a):
if a.dtype not in [torch.float32, torch.float64] or not torch.isfinite(a).all():
raise ValueError("Use finite FP32/FP64 log probabilities")
if (a >= 0).any() or (a.abs() < torch.finfo(a.dtype).tiny).any():
raise ValueError(
"Log probability must be strictly negative with a representable finite derivative; no silent clamp"
)
# Compute only the selected branch: torch.where on two evaluated formulas
# could propagate NaN gradients from its inactive near-zero branch.
far = a < -math.log(2)
result = torch.empty_like(a)
result[far] = torch.log1p(-torch.exp(a[far]))
result[~far] = torch.log(-torch.expm1(a[~far]))
return result
def orpo_preference_loss(chosen_logps, rejected_logps):
if chosen_logps.shape != rejected_logps.shape or not chosen_logps.numel():
raise ValueError("Matching nonempty paired score vectors required")
paired = torch.stack([chosen_logps, rejected_logps])
odds = paired - log1mexp(paired)
margin = odds[0] - odds[1]
return -torch.nn.functional.logsigmoid(margin), margin
def orpo_total_loss(
chosen_sums,
rejected_sums,
chosen_counts,
rejected_counts,
coefficient=0.1,
normalization="mean",
):
if (
coefficient < 0
or not math.isfinite(coefficient)
or normalization not in ["mean", "sum"]
):
raise ValueError("Explicit nonnegative lambda and named normalization required")
if (chosen_counts <= 0).any() or (rejected_counts <= 0).any():
raise ValueError("Responses must have active tokens")
chosen = chosen_sums / chosen_counts if normalization == "mean" else chosen_sums
rejected = (
rejected_sums / rejected_counts if normalization == "mean" else rejected_sums
)
nll = -chosen_sums.sum() / chosen_counts.sum()
preference, margins = orpo_preference_loss(chosen, rejected)
return nll + coefficient * preference.mean(), nll, preference, margins
def numeric_mechanism(config):
from .reference_loss import odds_ratio_loss
reference_rows = []
for dtype, tolerance in [(torch.float64, 1e-12), (torch.float32, 2e-6)]:
chosen = torch.tensor([-1.0, -3.0, -0.1], dtype=dtype, requires_grad=True)
rejected = torch.tensor([-2.0, -1.0, -0.5], dtype=dtype, requires_grad=True)
loss, margin = orpo_preference_loss(chosen, rejected)
control = SimpleNamespace(
beta=config["lambda_orpo"], accelerator=SimpleNamespace(device="cpu")
)
reference = -odds_ratio_loss(control, chosen, rejected)[0]
a = torch.autograd.grad(
config["lambda_orpo"] * loss.mean(), (chosen, rejected), retain_graph=True
)
b = torch.autograd.grad(reference.mean(), (chosen, rejected))
checks = [
torch.allclose(
config["lambda_orpo"] * loss, reference, atol=tolerance, rtol=0
),
*(torch.allclose(x, y, atol=tolerance, rtol=0) for x, y in zip(a, b)),
]
if not all(checks):
raise ValueError("Pinned ORPO reference value/gradient comparison failed")
reference_rows.append(
{
"dtype": str(dtype),
"chosen_mean_logps": chosen.tolist(),
"rejected_mean_logps": rejected.tolist(),
"preference_losses": loss.tolist(),
"log_odds_margins": margin.tolist(),
"weighted_reference_losses": reference.tolist(),
"chosen_gradient": a[0].tolist(),
"rejected_gradient": a[1].tolist(),
"reference_chosen_gradient": b[0].tolist(),
"reference_rejected_gradient": b[1].tolist(),
"lambda": config["lambda_orpo"],
"atol": tolerance,
}
)
stability = []
for dtype, values in [
(
torch.float64,
[-1000.0, -100.0, -1.0, -math.log(2), -0.1, -1e-10, -1e-100, -1e-300],
),
(
torch.float32,
[-1000.0, -100.0, -1.0, -math.log(2), -0.1, -1e-5, -1e-10, -1e-30],
),
]:
x = torch.tensor(values, dtype=dtype, requires_grad=True)
stable = log1mexp(x)
gradient = torch.autograd.grad(stable.sum(), x)[0]
naive = torch.log(1 - torch.exp(x))
naive_grad = torch.autograd.grad(naive.sum(), x)[0]
if not torch.isfinite(stable).all() or not torch.isfinite(gradient).all():
raise ValueError("Stable numerical branch is not finite")
for a, s, g, n, ng in zip(
x.tolist(),
stable.tolist(),
gradient.tolist(),
naive.tolist(),
naive_grad.tolist(),
):
expected = (
math.log1p(-math.exp(a))
if a < -math.log(2)
else math.log(-math.expm1(a))
)
stability.append(
{
"dtype": str(dtype),
"log_probability": a,
"stable": s,
"stable_gradient": g,
"analytic_float64_value": expected,
"absolute_error": abs(s - expected),
"naive": n if math.isfinite(n) else None,
"naive_gradient": ng if math.isfinite(ng) else None,
"naive_finite": math.isfinite(n) and math.isfinite(ng),
}
)
save_json(
"outputs/numeric-mechanism.json",
{
"reference": reference_rows,
"stability": stability,
"data_source": "Controlled analytical log probabilities, not incident quality measurements",
"zero_or_subnormal_magnitude_policy": "Explicit rejection, no clamp",
"reference_model_loaded": False,
},
)
Entraîner SFT et ORPO depuis la même base
Deux objectifs comparables
Erreur fréquente : Copier une config DPO importe une ref inutilisée : auditer les objets et allocations.
Invariant : Mises à jour réelles, checkpoints et reprise vérifiés ; aucun modèle ref créé.
Fichier src/orpo_sans_reference/train.py
import math, time
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 .scores import paired_completion_batch, model_scores
from .loss import orpo_preference_loss
from .state import save_resume_state, load_resume_state, equal
BRANCHES = ["sft", "orpo", "sum-ablation"]
def learning_rate(config, step, total=210):
peak = config["learning_rate"]
warmup = max(1, int(total * config["warmup_ratio"]))
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"])
)
def encoded_split(config, tokenizer, split):
pages = read_json(f"data/generated/{split}/pages.json")
gold = {
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("Family partition differs")
return [
paired_completion_batch(tokenizer, p, gold[p["document_id"]], config)
for p in pages
]
@torch.no_grad()
def validate_policy(model, pairs):
model.eval()
rows = []
total_nll = 0.0
total_tokens = 0
wins = 0.0
for pair in pairs:
chosen, nc, *_ = model_scores(model, [pair["chosen"]])
rejected, nr, *_ = model_scores(model, [pair["rejected"]])
a = chosen / nc
b = rejected / nr
preference, margin = orpo_preference_loss(a, b)
total_nll -= float(chosen[0])
total_tokens += int(nc[0])
wins += 1 if float(margin[0]) > 0 else 0.5 if float(margin[0]) == 0 else 0
rows.append(
{
"document_id": pair["document_id"],
"chosen_sum": float(chosen[0]),
"rejected_sum": float(rejected[0]),
"chosen_tokens": int(nc[0]),
"rejected_tokens": int(nr[0]),
"chosen_mean": float(a[0]),
"rejected_mean": float(b[0]),
"mean_log_odds_margin": float(margin[0]),
"mean_odds_preference_loss": float(preference[0]),
}
)
model.train()
return {
"pairs": len(pairs),
"chosen_tokens": total_tokens,
"chosen_nll_nats_per_token": total_nll / total_tokens,
"mean_odds_accuracy_half_ties": wins / len(pairs),
"rows": rows,
"used_for_checkpoint_selection": False,
"evaluation_normalization": "mean_even_for_sum_ablation",
}
def train_policy(config, branch="orpo", mode="continuous"):
if branch not in BRANCHES or mode not in ["interrupt", "continuous", "resume"]:
raise ValueError("Unknown training branch/mode")
normalization = "sum" if branch == "sum-ablation" else "mean"
if config.get("normalization", "mean") != normalization:
raise ValueError("Sum ablation requires its explicit exercise config")
if (
Path("data/generated/input").exists()
or Path("data/generated/labels/gold.json").exists()
or Path("assets/reference").exists()
or Path("outputs/reference-cache").exists()
or Path("outputs/inference").exists()
):
raise ValueError(
"Learning must not mount heldout outputs, labels or any reference model/cache"
)
seed_all(config["seed"])
tokenizer = load_tokenizer()
rows = encoded_split(config, tokenizer, "train")
validation = encoded_split(config, tokenizer, "validation")
if len(rows) != 840 or len(validation) != 180:
raise ValueError("Frozen population differs")
model = load_base().cuda()
parameters = list(model.parameters())
initial = parameter_manifest(model)
initial_digest = manifest_digest(initial)
if (
sum(p.numel() for p in parameters) != 494032768
or any(not p.requires_grad or p.dtype != torch.float32 for p in parameters)
or len({id(p) for p in parameters}) != len(parameters)
):
raise ValueError("All unique FP32 policy parameters must be optimized")
identity = {
"branch": branch,
"normalization": normalization,
"lambda": 0.0 if branch == "sft" else config["lambda_orpo"],
"config": config,
"initial_model_digest": initial_digest,
"model_sha256": sha("assets/model/model.safetensors"),
"tokenizer_sha256": sha("assets/model/tokenizer.json"),
"implementation_sha256": {
name: sha("src/orpo_sans_reference/" + name)
for name in ["model.py", "scores.py", "loss.py", "train.py", "state.py"]
},
"data_sha256": {
name: sha("data/generated/" + name)
for name in [
"train/pages.json",
"labels/train.json",
"validation/pages.json",
"labels/validation.json",
]
},
"reference_models": 0,
"reference_caches": 0,
}
optimizer = torch.optim.AdamW(
parameters, lr=config["learning_rate"], weight_decay=config["weight_decay"]
)
sampler = torch.Generator().manual_seed(config["seed"] + 100)
per_epoch = math.ceil(len(rows) / config["accumulation_pairs"])
total = per_epoch * config["epochs"]
root = Path("assets/checkpoints")
destination = root / f"{branch}-{mode}.pt.gz"
updates = start = 0
history = []
validation_history = []
if destination.exists():
raise ValueError("Preserve existing local checkpoint")
if mode == "resume":
state = load_resume_state(root / f"{branch}-interrupt.pt.gz")
if state["identity"] != identity:
raise ValueError("Resume identity changed, including lambda/normalization")
model.load_state_dict(state["model"], strict=True)
optimizer.load_state_dict(state["optimizer"])
sampler.set_state(state["sampler"])
restore_rng(state["rng"])
updates = state["updates"]
start = state["epoch"]
history = state["history"]
validation_history = state["validation_history"]
if state["scheduler"] != {
"next_update": updates,
"next_lr": learning_rate(config, updates, total),
}:
raise ValueError("Scheduler identity changed")
del state
stop = config["interrupt_epoch"] if mode == "interrupt" else config["epochs"]
model.train()
torch.cuda.reset_peak_memory_stats()
torch.cuda.synchronize()
begun = time.perf_counter()
optimization_seconds = 0.0
for epoch in range(start, stop):
order = torch.randperm(len(rows), generator=sampler).tolist()
torch.cuda.synchronize()
epoch_begun = time.perf_counter()
for offset in range(0, len(rows), config["accumulation_pairs"]):
selected = order[offset : offset + config["accumulation_pairs"]]
chosen_denominator = sum(
rows[i]["chosen"]["response_tokens"] for i in selected
)
lr = learning_rate(config, updates, total)
for group in optimizer.param_groups:
group["lr"] = lr
optimizer.zero_grad(set_to_none=True)
details = []
nll_numerator = preference_numerator = 0.0
policy_tokens = 0
for index in selected:
pair = rows[index]
chosen, nc, *_ = model_scores(model, [pair["chosen"]])
nll = -chosen.sum()
contribution = nll / chosen_denominator
policy_tokens += int(nc[0])
detail = {
"document_id": pair["document_id"],
"chosen_sum": float(chosen.detach()[0]),
"chosen_tokens": int(nc[0]),
"rejected_sum": None,
"rejected_tokens": 0,
"preference_loss": None,
"log_odds_margin": None,
"preference_chosen_logp": None,
"preference_rejected_logp": None,
}
if branch != "sft":
rejected, nr, *_ = model_scores(model, [pair["rejected"]])
a = chosen / nc if normalization == "mean" else chosen
b = rejected / nr if normalization == "mean" else rejected
preference, margin = orpo_preference_loss(a, b)
contribution = contribution + config[
"lambda_orpo"
] * preference.sum() / len(selected)
preference_numerator += float(preference.detach()[0])
policy_tokens += int(nr[0])
detail.update(
rejected_sum=float(rejected.detach()[0]),
rejected_tokens=int(nr[0]),
preference_loss=float(preference.detach()[0]),
log_odds_margin=float(margin.detach()[0]),
preference_chosen_logp=float(a.detach()[0]),
preference_rejected_logp=float(b.detach()[0]),
)
if not torch.isfinite(contribution):
raise ValueError("Nonfinite objective")
contribution.backward()
nll_numerator += float(nll.detach())
details.append(detail)
norm = torch.nn.utils.clip_grad_norm_(
parameters, config["gradient_clip"], error_if_nonfinite=True
)
if any(p.grad is None or p.grad.dtype != torch.float32 for p in parameters):
raise ValueError("All policy gradients must exist in FP32")
optimizer.step()
updates += 1
nll = nll_numerator / chosen_denominator
preference = (
preference_numerator / len(selected) if branch != "sft" else 0.0
)
history.append(
{
"update": updates,
"epoch": epoch + 1,
"document_ids": [rows[i]["document_id"] for i in selected],
"chosen_token_denominator": chosen_denominator,
"preference_pair_denominator": (
len(selected) if branch != "sft" else 0
),
"policy_response_tokens": policy_tokens,
"chosen_nll": nll,
"preference_loss": preference,
"total_loss": nll + identity["lambda"] * preference,
"learning_rate": lr,
"gradient_norm_before_clip": float(norm),
"pairs": details,
}
)
if updates % 21 == 0:
print(
{k: v for k, v in history[-1].items() if k != "pairs"}, flush=True
)
torch.cuda.synchronize()
optimization_seconds += time.perf_counter() - epoch_begun
observed = validate_policy(model, validation)
validation_history.append({"epoch": epoch + 1, **observed})
print(
{
"branch": branch,
"epoch": epoch + 1,
**{k: v for k, v in observed.items() if k != "rows"},
},
flush=True,
)
torch.cuda.synchronize()
elapsed = time.perf_counter() - begun
peak = torch.cuda.max_memory_allocated()
final = parameter_manifest(model)
final_digest = manifest_digest(final)
if final_digest == initial_digest:
raise ValueError("No real learned parameter change")
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, updates, total),
},
"rng": rng_state(),
"history": history,
"validation_history": validation_history,
}
compression = save_resume_state(destination, state)
if mode == "resume":
save_learned(model, root / f"{branch}-resume.safetensors", identity)
save_json(
f"outputs/learning/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,
"initial_model_digest": initial_digest,
"final_model_digest": final_digest,
"trainable_parameters": 494032768,
"checkpoint_sha256": sha(destination),
"checkpoint_bytes": destination.stat().st_size,
"checkpoint_compression": compression,
"optimization_seconds_this_process": optimization_seconds,
"training_and_validation_seconds_this_process": elapsed,
"max_torch_allocated_bytes": peak,
"test_visible": False,
"reference_models_loaded": 0,
"policy_models_loaded": 1,
},
)
def verify_resume(branch):
root = Path("assets/checkpoints")
a = load_resume_state(root / f"{branch}-continuous.pt.gz")
b = load_resume_state(root / f"{branch}-resume.pt.gz")
checks = {k: equal(a[k], b[k]) for k in a}
if a.keys() != b.keys() or not all(checks.values()):
raise ValueError("Continuous/resumed full states differ: " + str(checks))
save_json(
f"outputs/learning/resume-{branch}.json",
{
"status": "PASS",
"all_state_exact": True,
"checks": checks,
"boundary": "Full epoch; model/AdamW/scheduler/sampler/RNG/history compared exactly after lossless decompression",
},
)
Observer les causes inventées et les abstentions
Qualité de génération libre
Erreur fréquente : Évaluer seulement les causes inconnues récompense l'abstention systématique : conserver les deux strates.
Invariant : 180 sorties par branche, causes connues/inconnues présentées séparément.
Fichier src/orpo_sans_reference/evaluate.py
import math, re, 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 .scores import format_prompt, paired_completion_batch, model_scores
from .loss import orpo_preference_loss
from .data import COURTESY
def parse_note(text, finish):
sentences = [
s.strip() for s in re.split(r"(?<=[.!?])\s+", text.strip()) if s.strip()
]
fields = {}
unknown = []
duplicates = []
for sentence in sentences:
if sentence in COURTESY:
continue
value = sentence.rstrip(".!?").strip()
items = None
match = re.fullmatch(r"(?:Unit )?(P\d{4}) is (stopped|running)", value, re.I)
if match:
items = {"unit": match[1].upper(), "state": match[2].lower()}
else:
match = re.fullmatch(
r"The (?:current )?state of (P\d{4}) is (stopped|running)", value, re.I
)
if match:
items = {"unit": match[1].upper(), "state": match[2].lower()}
else:
match = re.fullmatch(r"The pressure is (\d{1,4}) kPa", value, re.I)
if match:
items = {"pressure": int(match[1])}
elif value.lower() in [
"pressure was not measured",
"the pressure is unknown",
]:
items = {"pressure": None}
elif value.lower() in [
"the cause remains unconfirmed",
"no cause has been confirmed",
"the confirmed cause is not known",
]:
items = {"cause": None}
else:
match = re.fullmatch(
r"The confirmed cause is (filter blockage|power loss|sensor fault)",
value,
re.I,
)
if match:
items = {"cause": match[1].lower()}
if items is None:
unknown.append(sentence)
else:
for key, item in items.items():
if key in fields:
duplicates.append(key)
else:
fields[key] = item
covered = bool(sentences) and not unknown and not duplicates
return {
"recognized": fields,
"unrecognized_sentences": unknown,
"duplicate_fields": duplicates,
"all_text_covered": covered,
"complete": finish == "eos"
and covered
and set(fields) == {"unit", "state", "pressure", "cause"},
"truncated": finish != "eos",
}
def score_causal_claims(rows, gold):
metrics = {
"documents": len(rows),
"complete_controlled_notes": 0,
"exact_facts": 0,
"truncated": 0,
"unrecognized_prose": 0,
"known_cause_cases": 0,
"unknown_cause_cases": 0,
"known_cause_correct": 0,
"unknown_cause_correct": 0,
"recognized_unsupported_cause": 0,
"recognized_known_cause_abstention": 0,
"unknown_pressure_cases": 0,
"recognized_invented_pressure": 0,
}
fields = dict.fromkeys(["unit", "state", "pressure", "cause"], 0)
details = []
for row in rows:
expected = gold[row["document_id"]]["expected"]
parsed = parse_note(row["text"], row["finish_reason"])
if row["parsed"] != parsed:
raise ValueError("Stored parser result differs")
actual = parsed["recognized"]
valid = parsed["complete"]
correct = {k: valid and actual.get(k) == v for k, v in expected.items()}
exact = all(correct.values())
metrics["complete_controlled_notes"] += valid
metrics["exact_facts"] += exact
metrics["truncated"] += parsed["truncated"]
metrics["unrecognized_prose"] += bool(parsed["unrecognized_sentences"])
for key, value in correct.items():
fields[key] += value
known = expected["cause"] is not None
metrics["known_cause_cases" if known else "unknown_cause_cases"] += 1
metrics["known_cause_correct" if known else "unknown_cause_correct"] += correct[
"cause"
]
metrics["recognized_unsupported_cause"] += (
actual.get("cause") is not None and actual["cause"] != expected["cause"]
)
metrics["recognized_known_cause_abstention"] += (
known and "cause" in actual and actual["cause"] is None
)
if expected["pressure"] is None:
metrics["unknown_pressure_cases"] += 1
metrics["recognized_invented_pressure"] += (
actual.get("pressure") is not None
)
details.append(
{
"document_id": row["document_id"],
"expected": expected,
"recognized": actual,
"complete": valid,
"fields_correct": correct,
"exact": exact,
"unrecognized_sentences": parsed["unrecognized_sentences"],
}
)
n = len(rows)
metrics.update(
exact_accuracy_over_all_cases=metrics["exact_facts"] / n,
field_correct_over_all_cases=fields,
field_accuracy_over_all_cases={k: v / n for k, v in fields.items()},
known_cause_accuracy=metrics["known_cause_correct"]
/ metrics["known_cause_cases"],
unknown_cause_accuracy=metrics["unknown_cause_correct"]
/ metrics["unknown_cause_cases"],
recognized_known_cause_abstention_rate=metrics[
"recognized_known_cause_abstention"
]
/ metrics["known_cause_cases"],
mean_generated_tokens=math.fsum(r["output_tokens"] for r in rows) / n,
)
return {
"metrics": metrics,
"rows": details,
"unrecognized_prose_is_not_certified_faithful": True,
}
def pair_metrics(rows):
result = {}
for group in ["all", "chosen_shorter", "same_tokens", "chosen_longer"]:
selected = (
rows
if group == "all"
else [
r
for r in rows
if (
"chosen_shorter"
if r["response_length_delta"] < 0
else (
"chosen_longer"
if r["response_length_delta"] > 0
else "same_tokens"
)
)
== group
]
)
n = len(selected)
count = sum(r["scores"]["chosen"]["response_tokens"] for r in selected)
result[group] = {
"pairs": n,
"chosen_response_tokens": count,
"chosen_nll_nats_per_token": (
-math.fsum(r["scores"]["chosen"]["sum_logprob"] for r in selected)
/ count
if count
else None
),
"mean_log_odds_margin": (
math.fsum(r["mean_log_odds_margin"] for r in selected) / n
if n
else None
),
"mean_odds_preference_accuracy_half_ties": (
sum(
(
1
if r["mean_log_odds_margin"] > 0
else 0.5 if r["mean_log_odds_margin"] == 0 else 0
)
for r in selected
)
/ n
if n
else None
),
"mean_odds_preference_loss": (
math.fsum(r["mean_odds_preference_loss"] for r in selected) / n
if n
else None
),
}
return result
def load_policy(branch):
if branch not in ["base", "sft", "orpo", "sum-ablation"]:
raise ValueError("Unknown evaluation branch")
path = Path(
"assets/model/model.safetensors"
if branch == "base"
else f"assets/checkpoints/{branch}-resume.safetensors"
)
model = load_base() if branch == "base" else reload_learned(path)[0]
digest = manifest_digest(parameter_manifest(model))
for parameter in model.parameters():
parameter.requires_grad_(False)
return model.cuda().eval(), {
"branch": branch,
"file_sha256": sha(path),
"model_digest": digest,
"tokenizer_sha256": sha("assets/model/tokenizer.json"),
"reference_models_loaded": 0,
}
@torch.no_grad()
def score_pairs(config, branch):
seed_all(config["seed"])
model, identity = load_policy(branch)
tokenizer = load_tokenizer()
pages = [
p for p in read_json("data/generated/input/pages.json") if p["split"] == "test"
]
gold = {g["document_id"]: g for g in read_json("data/generated/labels/gold.json")}
rows = []
probes = []
torch.cuda.reset_peak_memory_stats()
torch.cuda.synchronize()
begun = time.perf_counter()
for index, page in enumerate(pages):
pair = paired_completion_batch(
tokenizer, page, gold[page["document_id"]], config
)
scores = {}
mean_tensors = {}
for side in ["chosen", "rejected"]:
sums, counts, values, logits, targets = model_scores(model, [pair[side]])
mean_tensors[side] = sums / counts
scores[side] = {
"sum_logprob": float(sums[0]),
"mean_logprob": float(mean_tensors[side][0]),
"response_tokens": int(counts[0]),
"token_logprobs": values.cpu().tolist(),
"targets": targets.cpu().tolist(),
}
if index == 0:
probes.append(
{
"document_id": page["document_id"],
"side": side,
"response_position": 0,
"target": int(targets[0]),
"logits": logits[0].cpu().tolist(),
"logprob": float(values[0]),
}
)
loss, margin = orpo_preference_loss(
mean_tensors["chosen"], mean_tensors["rejected"]
)
rows.append(
{
"document_id": page["document_id"],
"pair": pair,
"scores": scores,
"response_length_delta": pair["response_length_delta"],
"mean_log_odds_margin": float(margin[0]),
"mean_odds_preference_loss": float(loss[0]),
}
)
torch.cuda.synchronize()
save_json(
f"outputs/evaluation/pairs-{branch}.json",
{
"branch": branch,
"identity": identity,
"rows": rows,
"probes": probes,
"metrics": pair_metrics(rows),
"seconds": time.perf_counter() - begun,
"max_torch_allocated_bytes": torch.cuda.max_memory_allocated(),
"evaluation_normalization": "mean_even_for_sum_ablation",
"test_selection": False,
},
)
@torch.no_grad()
def generate_notes(config, branch):
if (
Path("data/generated/labels").exists()
or Path("assets/reference").exists()
or Path("outputs/reference-cache").exists()
):
raise ValueError("Blind generation must not mount labels or a reference")
seed_all(config["seed"])
model, identity = load_policy(branch)
tokenizer = load_tokenizer()
pages = [
p for p in read_json("data/generated/input/pages.json") if p["split"] == "test"
]
generation = GenerationConfig(
do_sample=False,
num_beams=1,
max_new_tokens=config["max_new_tokens"],
use_cache=True,
eos_token_id=151645,
pad_token_id=151643,
bos_token_id=151643,
)
rows = []
torch.cuda.reset_peak_memory_stats()
torch.cuda.synchronize()
begun = time.perf_counter()
for index, page in enumerate(pages):
messages, ids = format_prompt(
tokenizer, page["incident"], config["template_version"]
)
if not 0 < len(ids) <= config["max_prompt_tokens"]:
raise ValueError("No silent prompt truncation")
tensor = torch.tensor([ids], device="cuda")
torch.cuda.synchronize()
started = time.perf_counter()
output = (
model.generate(
input_ids=tensor,
attention_mask=torch.ones_like(tensor),
generation_config=generation,
)[0, len(ids) :]
.cpu()
.tolist()
)
torch.cuda.synchronize()
finish = "eos" if output and output[-1] == 151645 else "length"
text = tokenizer.decode(
output[:-1] if finish == "eos" else output,
skip_special_tokens=False,
clean_up_tokenization_spaces=False,
)
rows.append(
{
"document_id": page["document_id"],
"messages": messages,
"input_ids": ids,
"output_ids": output,
"text": text,
"finish_reason": finish,
"parsed": parse_note(text, finish),
"input_tokens": len(ids),
"output_tokens": len(output),
"seconds": time.perf_counter() - started,
}
)
if (index + 1) % 30 == 0:
print({"branch": branch, "generated_notes": index + 1}, flush=True)
torch.cuda.synchronize()
save_json(
f"outputs/inference/predictions-{branch}.json",
{
"branch": branch,
"identity": identity,
"generation": generation.to_dict(),
"input_sha256": sha("data/generated/input/pages.json"),
"rows": rows,
"seconds": time.perf_counter() - begun,
"max_torch_allocated_bytes": torch.cuda.max_memory_allocated(),
"gold_visible": False,
"reference_models_loaded": 0,
"training": False,
},
)
def evaluate_notes(config, exercise=False):
gold = {g["document_id"]: g for g in read_json("data/generated/labels/gold.json")}
test_ids = [
p["document_id"]
for p in read_json("data/generated/input/pages.json")
if p["split"] == "test"
]
results = {}
for branch in ["sum-ablation"] if exercise else ["base", "sft", "orpo"]:
predictions = read_json(f"outputs/inference/predictions-{branch}.json")
pairs = read_json(f"outputs/evaluation/pairs-{branch}.json")
if (
[r["document_id"] for r in predictions["rows"]] != test_ids
or [r["document_id"] for r in pairs["rows"]] != test_ids
or len(test_ids) != 180
or predictions["identity"] != pairs["identity"]
):
raise ValueError("Same180testcases and model weights required")
results[branch] = {
"free_notes": score_causal_claims(predictions["rows"], gold),
"pairs": pair_metrics(pairs["rows"]),
}
print(
{
"branch": branch,
"free_notes": results[branch]["free_notes"]["metrics"],
"pairs": results[branch]["pairs"],
},
flush=True,
)
save_json(
(
"outputs/evaluation/exercise-comparison.json"
if exercise
else "outputs/evaluation/comparison.json"
),
{
"results": results,
"lambda_selected_before_training": config["lambda_orpo"],
"human_preferences": False,
"quality_gain_required": False,
"checkpoint_selection": False,
"reference_models_loaded": 0,
"same_prompt_presentations_not_same_compute": True,
"exercise_sum_is_not_reference_ORPO": exercise,
"no_real_incident_action": True,
},
)
Isoler l'effet de la longueur
Ablation de normalisation
Erreur fréquente : Mélanger données analytiques et résultats entraînés dans un même graphe sans légende : séparer sources.
Invariant : Les deux variantes sont nommées et l'ORPO final garde la moyenne.
Fichier src/orpo_sans_reference/exercise.py
from pathlib import Path
import torch
from .config import read_json, save_json, sha
from .loss import orpo_preference_loss
def main_artifacts():
names = [
"configs/minimum.yaml",
"outputs/evaluation/comparison.json",
"outputs/numeric-mechanism.json",
]
for branch in ["base", "sft", "orpo"]:
names += [
f"outputs/evaluation/pairs-{branch}.json",
f"outputs/inference/predictions-{branch}.json",
]
if branch != "base":
names += [
f"assets/checkpoints/{branch}-resume.safetensors",
f"outputs/learning/resume-{branch}.json",
]
return names
def preserve_main():
path = Path("outputs/exercise/protected.json")
if path.exists():
raise ValueError("Preserve existing exercise snapshot")
save_json(path, {name: sha(name) for name in main_artifacts()})
def compare_sum_mean_odds():
rows = []
for chosen_length in [4, 16, 64]:
for rejected_length in [4, 16, 64]:
for normalization in ["mean", "sum"]:
a = torch.full(
(chosen_length,), -1.0, dtype=torch.float64, requires_grad=True
)
b = torch.full(
(rejected_length,), -1.2, dtype=torch.float64, requires_grad=True
)
pa = a.mean() if normalization == "mean" else a.sum()
pb = b.mean() if normalization == "mean" else b.sum()
loss, margin = orpo_preference_loss(pa.reshape(1), pb.reshape(1))
ga, gb = torch.autograd.grad(loss.sum(), (a, b))
rows.append(
{
"normalization": normalization,
"chosen_length": chosen_length,
"rejected_length": rejected_length,
"chosen_token_logprob": -1.0,
"rejected_token_logprob": -1.2,
"chosen_score": float(pa.detach()),
"rejected_score": float(pb.detach()),
"log_odds_margin": float(margin.detach()[0]),
"preference_loss": float(loss.detach()[0]),
"chosen_token_gradients": ga.tolist(),
"rejected_token_gradients": gb.tolist(),
}
)
return rows
def run_length_ablation(config):
if config.get("normalization") != "sum":
raise ValueError("Exercise must identify the sum ablation")
protected = read_json("outputs/exercise/protected.json")
if protected != {name: sha(name) for name in main_artifacts()}:
raise ValueError("Exercise changed main weights/results/config")
actual = read_json("outputs/evaluation/pairs-orpo.json")
rescored = []
for row in actual["rows"]:
a = torch.tensor(
[row["scores"]["chosen"]["sum_logprob"]], dtype=torch.float32, device="cuda"
)
b = torch.tensor(
[row["scores"]["rejected"]["sum_logprob"]],
dtype=torch.float32,
device="cuda",
)
na = row["scores"]["chosen"]["response_tokens"]
nb = row["scores"]["rejected"]["response_tokens"]
mean, mean_margin = orpo_preference_loss(a / na, b / nb)
summed, sum_margin = orpo_preference_loss(a, b)
if (
float(mean[0]) != row["mean_odds_preference_loss"]
or float(mean_margin[0]) != row["mean_log_odds_margin"]
):
raise ValueError("Mean scoring changed in the exercise")
rescored.append(
{
"document_id": row["document_id"],
"chosen_tokens": na,
"rejected_tokens": nb,
"mean_log_odds_margin": float(mean_margin[0]),
"sum_log_odds_margin": float(sum_margin[0]),
"mean_preference_loss": float(mean[0]),
"sum_preference_loss": float(summed[0]),
"scope": "Same already scored main ORPO policy, formula ablation only",
}
)
branches = {
b: read_json(f"outputs/learning/train-{b}-continuous.json")
for b in ["sft", "orpo", "sum-ablation"]
}
initial = {r["initial_model_digest"] for r in branches.values()}
schedules = [
[
(r["document_ids"], r["chosen_token_denominator"], r["learning_rate"])
for r in run["history"]
]
for run in branches.values()
]
if (
len(initial) != 1
or not all(schedule == schedules[0] for schedule in schedules)
or any(run["updates"] != 210 for run in branches.values())
):
raise ValueError("Ablation initial model/example budgets differ")
result = read_json("outputs/evaluation/exercise-comparison.json")
if not result["exercise_sum_is_not_reference_ORPO"]:
raise ValueError("Sum variant must not be labelled reference ORPO")
save_json(
"outputs/exercise/odds-length.json",
{
"analytic_rows": compare_sum_mean_odds(),
"analytic_source": "Constructed constant token log probabilities, not generated incident notes",
"actual_main_policy_rescoring": rescored,
"actual_fresh_sum_training": True,
"same_base_and_prompt_order": True,
"updates_per_branch": 210,
"main_normalization_restored": "mean",
"main_artifacts_unchanged": protected
== {name: sha(name) for name in main_artifacts()},
"protected_sha256": protected,
"sum_variant_is_not_reference_ORPO": True,
"quality_gain_required": False,
},
)
Sources
ORPO, article original, dépôt des auteurs figé, fonction de contrôle TRL figée, Qwen2.5-0.5B-Instruct. Les données, masques et budgets locaux ne reproduisent pas les résultats publiés de ces auteurs.
Référence mesurée
Consulter les résultats de développement r2 (RESULTATS_DEVELOPPEMENT.md, inclus dans le ZIP) et leur provenance explicite (OBSERVATIONS.json, inclus dans le ZIP). Le code scientifique reste identique en r3 ; seule une légende est corrigée.