O echipă care pregătește un asistent intern are 2.560 de perechi de răspunsuri: unul preferat și unul respins pentru aceeași cerere. La finalul acestui proiect, echipa va avea un adaptor LoRA antrenat cu ORPO peste Qwen/Qwen2.5-0.5B-Instruct, un raport comparativ cu modelul de bază, exemplele pe care modelul ajustat încă le ordonează greșit și un API local pentru inferență.
ORPO — Odds Ratio Preference Optimization — combină pierderea de limbaj pentru răspunsul preferat cu un termen care mărește diferența dintre răspunsul chosen și cel rejected. Nu încarcă un model de referință separat și nu cere o fază SFT urmată de o fază distinctă de preference tuning. Aceasta este diferența operațională față de pipeline-ul din ghidul despre alinierea cu DPO și perechi chosen–rejected.
Proiectul folosește un subset fix din argilla/ultrafeedback-binarized-preferences-cleaned. Licența declarată de pagina datasetului este MIT, iar datele sunt în engleză. UltraFeedback conține preferințe obținute prin evaluarea răspunsurilor generate de modele; acestea nu trebuie confundate automat cu preferințe umane universale sau cu cerințele propriei organizații.
Fixăm experimentul la un GPU de 16 GB și la revizii imuabile
Configurația de referință este Ubuntu 24.04 LTS, Python 3.12, un GPU NVIDIA cu minimum 16 GB VRAM și aproximativ 20 GB liberi pe disc pentru mediul Python, cache-ul Hugging Face, checkpointuri și artefacte. Modelul are aproximativ jumătate de miliard de parametri, iar LoRA evită actualizarea tuturor greutăților.
Pe un GPU de clasă workstation, bugetul de planificare pentru pregătire, baseline, o epocă și evaluarea finală este de aproximativ 45–120 de minute. Este o estimare operațională, nu un benchmark: lungimea exemplelor, modelul GPU, driverul și frecvența evaluărilor schimbă timpul. Pe CPU, aceeași rulare poate depăși opt ore și nu este configurația recomandată pentru acest tutorial.
Verifică mediul înainte să creezi proiectul:
grep -E '^(NAME|VERSION_ID)=' /etc/os-release
python3 --version
nvidia-smi --query-gpu=name,memory.total,driver_version --format=csv,noheader
df -h .
Un mediu compatibil va afișa valori de forma următoare; numele GPU-ului, driverul și spațiul disponibil vor diferi:
NAME="Ubuntu"
VERSION_ID="24.04"
Python 3.12.x
NVIDIA ..., 16384 MiB sau mai mult, ...
Filesystem ... Avail ...
Tutorialul fixează modelul la revizia 7ae557604adf67be50417f59c2c2f167def9a775 și datasetul la 91376df778fc031c79bf47bf69df0d611fb46387. Astfel, o actualizare ulterioară din ramura main nu schimbă pe ascuns tokenizerul sau exemplele selectate.
Instalarea se face într-un mediu virtual nou:
mkdir -p "$HOME/orpo-lab"
cd "$HOME/orpo-lab"
python3 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install torch==2.7.1 --index-url https://download.pytorch.org/whl/cu128
Dacă driverul NVIDIA nu suportă buildul CUDA 12.8 folosit aici, nu înlocui aleatoriu pachetele. Alege din selectorul oficial PyTorch combinația potrivită driverului și păstrează restul versiunilor proiectului neschimbate.
Patru fișiere leagă datele, antrenarea, evaluarea și servirea
Laboratorul va avea această structură:
orpo-lab/
├── requirements.txt # bibliotecile Python fixate
├── pipeline.py # pregătire, ORPO și evaluare
├── serve.py # API FastAPI pentru adaptorul rezultat
├── data/
│ └── preference-splits/ # spliturile train, validation și test
├── output/
│ └── orpo-adapter/ # adaptorul LoRA și tokenizerul
└── reports/
├── metrics.json # baseline versus ORPO
└── error_analysis.jsonl # perechile greșite, ordonate după marjă
pipeline.py folosește exact 2.560 de exemple care încap în limitele stabilite: 2.048 pentru antrenare, 256 pentru validare și 256 pentru test. Seed-ul 47 și revizia datasetului fac selecția repetabilă. Splitul de test nu intră în selecția checkpointului.
Pasul 1 — Scriem pipeline-ul care păstrează completările separate de prompt
De ce: ORPO are nevoie de prompt, răspuns preferat și răspuns respins. Scriptul validează că ambele completări aparțin aceluiași dialog, verifică lungimea după aplicarea chat template-ului și salvează spliturile înainte de antrenare.
Fișierul requirements.txt:
transformers==4.55.4
trl==0.22.1
peft==0.17.1
datasets==4.0.0
accelerate==1.10.0
safetensors==0.6.2
fastapi==0.116.1
uvicorn==0.35.0
pydantic==2.11.7
Instalează-l:
cd "$HOME/orpo-lab"
source .venv/bin/activate
python -m pip install -r requirements.txt
Fișierul pipeline.py:
from __future__ import annotations
import argparse
import gc
import json
import math
import random
from pathlib import Path
from typing import Any
import torch
import torch.nn.functional as F
from datasets import Dataset, DatasetDict, load_dataset, load_from_disk
from peft import LoraConfig, PeftModel
from transformers import AutoModelForCausalLM, AutoTokenizer, set_seed
from trl import ORPOConfig, ORPOTrainer
MODEL_ID = "Qwen/Qwen2.5-0.5B-Instruct"
MODEL_REVISION = "7ae557604adf67be50417f59c2c2f167def9a775"
DATASET_ID = "argilla/ultrafeedback-binarized-preferences-cleaned"
DATASET_REVISION = "91376df778fc031c79bf47bf69df0d611fb46387"
DATA_DIR = Path("data/preference-splits")
ADAPTER_DIR = Path("output/orpo-adapter")
REPORT_DIR = Path("reports")
SEED = 47
MAX_LENGTH = 1024
MAX_PROMPT_LENGTH = 512
TRAIN_SIZE = 2048
VALIDATION_SIZE = 256
TEST_SIZE = 256
TOTAL_SIZE = TRAIN_SIZE + VALIDATION_SIZE + TEST_SIZE
def configure_reproducibility() -> None:
random.seed(SEED)
set_seed(SEED)
torch.backends.cuda.matmul.allow_tf32 = False
torch.backends.cudnn.allow_tf32 = False
def load_tokenizer():
tokenizer = AutoTokenizer.from_pretrained(
MODEL_ID,
revision=MODEL_REVISION,
use_fast=True,
)
if tokenizer.pad_token_id is None:
tokenizer.pad_token = tokenizer.eos_token
tokenizer.padding_side = "right"
return tokenizer
def normalize_example(row: dict[str, Any]) -> dict[str, Any] | None:
chosen = row.get("chosen")
rejected = row.get("rejected")
if not isinstance(chosen, list) or not isinstance(rejected, list):
return None
if len(chosen) < 2 or len(rejected) < 2:
return None
if chosen[:-1] != rejected[:-1]:
return None
if chosen[-1].get("role") != "assistant":
return None
if rejected[-1].get("role") != "assistant":
return None
return {
"prompt": chosen[:-1],
"chosen": [chosen[-1]],
"rejected": [rejected[-1]],
}
def rendered_lengths(tokenizer, example: dict[str, Any]) -> tuple[int, int, int]:
prompt_ids = tokenizer.apply_chat_template(
example["prompt"],
tokenize=True,
add_generation_prompt=True,
)
chosen_ids = tokenizer.apply_chat_template(
example["prompt"] + example["chosen"],
tokenize=True,
add_generation_prompt=False,
)
rejected_ids = tokenizer.apply_chat_template(
example["prompt"] + example["rejected"],
tokenize=True,
add_generation_prompt=False,
)
return len(prompt_ids), len(chosen_ids), len(rejected_ids)
def prepare() -> None:
configure_reproducibility()
tokenizer = load_tokenizer()
raw = load_dataset(
DATASET_ID,
split="train",
revision=DATASET_REVISION,
).shuffle(seed=SEED)
accepted: list[dict[str, Any]] = []
for row in raw:
example = normalize_example(row)
if example is None:
continue
prompt_len, chosen_len, rejected_len = rendered_lengths(
tokenizer, example
)
if prompt_len > MAX_PROMPT_LENGTH:
continue
if max(chosen_len, rejected_len) > MAX_LENGTH:
continue
accepted.append(example)
if len(accepted) == TOTAL_SIZE:
break
if len(accepted) != TOTAL_SIZE:
raise ValueError(
f"Nu am colectat {TOTAL_SIZE} exemple valide; "
f"am găsit doar {len(accepted)}."
)
all_rows = Dataset.from_list(accepted)
splits = DatasetDict(
{
"train": all_rows.select(range(0, TRAIN_SIZE)),
"validation": all_rows.select(
range(TRAIN_SIZE, TRAIN_SIZE + VALIDATION_SIZE)
),
"test": all_rows.select(
range(TRAIN_SIZE + VALIDATION_SIZE, TOTAL_SIZE)
),
}
)
DATA_DIR.parent.mkdir(parents=True, exist_ok=True)
splits.save_to_disk(str(DATA_DIR))
manifest = {
"model_id": MODEL_ID,
"model_revision": MODEL_REVISION,
"dataset_id": DATASET_ID,
"dataset_revision": DATASET_REVISION,
"seed": SEED,
"max_length": MAX_LENGTH,
"max_prompt_length": MAX_PROMPT_LENGTH,
"splits": {name: len(split) for name, split in splits.items()},
}
(DATA_DIR / "manifest.json").write_text(
json.dumps(manifest, indent=2, ensure_ascii=False),
encoding="utf-8",
)
print(json.dumps(manifest, indent=2, ensure_ascii=False))
def require_cuda_bf16() -> None:
if not torch.cuda.is_available():
raise RuntimeError("Acest experiment necesită un GPU CUDA.")
if not torch.cuda.is_bf16_supported():
raise RuntimeError("GPU-ul curent nu raportează suport BF16.")
def load_base_model():
return AutoModelForCausalLM.from_pretrained(
MODEL_ID,
revision=MODEL_REVISION,
torch_dtype=torch.bfloat16,
attn_implementation="sdpa",
)
def train() -> None:
configure_reproducibility()
require_cuda_bf16()
if not DATA_DIR.exists():
raise FileNotFoundError(
"Lipsesc spliturile. Rulează mai întâi: python pipeline.py prepare"
)
splits = load_from_disk(str(DATA_DIR))
tokenizer = load_tokenizer()
model = load_base_model()
model.config.use_cache = False
peft_config = LoraConfig(
task_type="CAUSAL_LM",
r=16,
lora_alpha=32,
lora_dropout=0.05,
bias="none",
target_modules=[
"q_proj",
"k_proj",
"v_proj",
"o_proj",
"gate_proj",
"up_proj",
"down_proj",
],
)
training_args = ORPOConfig(
output_dir="output/checkpoints",
num_train_epochs=1.0,
per_device_train_batch_size=2,
per_device_eval_batch_size=2,
gradient_accumulation_steps=8,
learning_rate=8e-6,
lr_scheduler_type="cosine",
warmup_ratio=0.1,
beta=0.1,
max_length=MAX_LENGTH,
max_prompt_length=MAX_PROMPT_LENGTH,
eval_strategy="steps",
eval_steps=32,
save_strategy="steps",
save_steps=32,
logging_steps=8,
save_total_limit=2,
load_best_model_at_end=True,
metric_for_best_model="eval_loss",
greater_is_better=False,
bf16=True,
gradient_checkpointing=True,
gradient_checkpointing_kwargs={"use_reentrant": False},
optim="adamw_torch_fused",
report_to="none",
seed=SEED,
data_seed=SEED,
remove_unused_columns=False,
)
trainer = ORPOTrainer(
model=model,
args=training_args,
train_dataset=splits["train"],
eval_dataset=splits["validation"],
processing_class=tokenizer,
peft_config=peft_config,
)
trainer.train()
ADAPTER_DIR.mkdir(parents=True, exist_ok=True)
trainer.model.save_pretrained(str(ADAPTER_DIR))
tokenizer.save_pretrained(str(ADAPTER_DIR))
print(f"Adaptor salvat în {ADAPTER_DIR}")
def encode_dialog(tokenizer, prompt, completion) -> tuple[list[int], list[int]]:
prompt_ids = tokenizer.apply_chat_template(
prompt,
tokenize=True,
add_generation_prompt=True,
)
full_ids = tokenizer.apply_chat_template(
prompt + completion,
tokenize=True,
add_generation_prompt=False,
)
if full_ids[: len(prompt_ids)] != prompt_ids:
raise ValueError(
"Chat template-ul nu produce un prefix identic pentru prompt."
)
return prompt_ids, full_ids
@torch.inference_mode()
def completion_log_probability(
model,
tokenizer,
prompt,
completion,
) -> float:
prompt_ids, full_ids = encode_dialog(tokenizer, prompt, completion)
device = next(model.parameters()).device
input_ids = torch.tensor([full_ids], dtype=torch.long, device=device)
logits = model(input_ids=input_ids).logits[:, :-1, :].float()
targets = input_ids[:, 1:]
token_logps = F.log_softmax(logits, dim=-1).gather(
dim=-1,
index=targets.unsqueeze(-1),
).squeeze(-1)
first_completion_prediction = len(prompt_ids) - 1
completion_logps = token_logps[:, first_completion_prediction:]
if completion_logps.numel() == 0:
raise ValueError("Completarea nu conține tokeni evaluabili.")
return completion_logps.mean().item()
def model_for_evaluation(kind: str):
base = load_base_model()
if kind == "base":
return base
if not ADAPTER_DIR.exists():
raise FileNotFoundError(
"Adaptorul ORPO lipsește. Rulează: python pipeline.py train"
)
return PeftModel.from_pretrained(base, str(ADAPTER_DIR))
def evaluate(kind: str) -> None:
configure_reproducibility()
require_cuda_bf16()
splits = load_from_disk(str(DATA_DIR))
tokenizer = load_tokenizer()
model = model_for_evaluation(kind).cuda().eval()
margins: list[float] = []
chosen_nll: list[float] = []
failures: list[dict[str, Any]] = []
for index, row in enumerate(splits["test"]):
chosen_score = completion_log_probability(
model, tokenizer, row["prompt"], row["chosen"]
)
rejected_score = completion_log_probability(
model, tokenizer, row["prompt"], row["rejected"]
)
margin = chosen_score - rejected_score
margins.append(margin)
chosen_nll.append(-chosen_score)
if margin <= 0:
failures.append(
{
"index": index,
"margin": margin,
"prompt": row["prompt"],
"chosen": row["chosen"],
"rejected": row["rejected"],
}
)
result = {
"examples": len(margins),
"preference_accuracy": sum(x > 0 for x in margins) / len(margins),
"mean_margin": sum(margins) / len(margins),
"chosen_nll": sum(chosen_nll) / len(chosen_nll),
}
REPORT_DIR.mkdir(parents=True, exist_ok=True)
metrics_path = REPORT_DIR / "metrics.json"
metrics = {}
if metrics_path.exists():
metrics = json.loads(metrics_path.read_text(encoding="utf-8"))
metrics[kind] = result
metrics_path.write_text(
json.dumps(metrics, indent=2, ensure_ascii=False),
encoding="utf-8",
)
if kind == "orpo":
failures.sort(key=lambda item: item["margin"])
with (REPORT_DIR / "error_analysis.jsonl").open(
"w", encoding="utf-8"
) as handle:
for item in failures[:20]:
handle.write(json.dumps(item, ensure_ascii=False) + "n")
print(json.dumps({kind: result}, indent=2, ensure_ascii=False))
del model
gc.collect()
torch.cuda.empty_cache()
def main() -> None:
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest="command", required=True)
subparsers.add_parser("prepare")
subparsers.add_parser("train")
evaluate_parser = subparsers.add_parser("evaluate")
evaluate_parser.add_argument(
"--model",
choices=["base", "orpo"],
required=True,
)
args = parser.parse_args()
if args.command == "prepare":
prepare()
elif args.command == "train":
train()
elif args.command == "evaluate":
evaluate(args.model)
if __name__ == "__main__":
main()
beta=0.1 controlează ponderea termenului odds-ratio adăugat peste pierderea SFT. max_prompt_length=512 limitează promptul, iar max_length=1024 se aplică secvenței prompt plus completare. gradient_accumulation_steps=8, împreună cu batchul fizic de două perechi, produce un batch efectiv de 16 perechi pe actualizare.
Ce trebuie să vezi: instalarea se încheie fără conflicte, iar verificarea importurilor returnează versiunile fixate:
python -c "import torch, transformers, trl, peft, datasets; print(torch.__version__, transformers.__version__, trl.__version__, peft.__version__, datasets.__version__)"
2.7.1+cu128 4.55.4 0.22.1 0.17.1 4.0.0
Sufixul versiunii PyTorch poate include informația buildului CUDA.
Punct de control: dacă importurile nu corespund, verifică dacă .venv este activ și nu continua cu un mediu în care pip a înlocuit automat una dintre versiunile fixate.
Pasul 2 — Înghețăm 2.560 de perechi și izolăm testul
De ce: antrenarea și evaluarea nu trebuie să aleagă din nou exemplele la fiecare comandă. Materializarea spliturilor păstrează testul stabil și permite verificarea manifestului înainte de consumul de GPU.
Rulează:
cd "$HOME/orpo-lab"
source .venv/bin/activate
python pipeline.py prepare
prepare descarcă datasetul de la revizia fixată, îl amestecă folosind seed-ul 47, respinge dialogurile necompatibile și oprește selecția după ce găsește 2.560 de perechi care încap fără trunchiere. Chat template-ul modelului este aplicat înaintea filtrării, nu se estimează lungimea după numărul de caractere.
Ce trebuie să vezi:
{
"model_id": "Qwen/Qwen2.5-0.5B-Instruct",
"model_revision": "7ae557604adf67be50417f59c2c2f167def9a775",
"dataset_id": "argilla/ultrafeedback-binarized-preferences-cleaned",
"dataset_revision": "91376df778fc031c79bf47bf69df0d611fb46387",
"seed": 47,
"max_length": 1024,
"max_prompt_length": 512,
"splits": {
"train": 2048,
"validation": 256,
"test": 256
}
}
Punct de control: dacă numărul exemplelor diferă, deschide data/preference-splits/manifest.json; reviziile și seed-ul trebuie să fie identice cu cele din script.
Pasul 3 — Măsurăm modelul nemodificat înainte de ORPO
De ce: o valoare obținută doar după fine-tuning nu arată dacă adaptorul a schimbat ceva. Baseline-ul folosește exact același test și aceeași funcție de scor ca modelul ORPO.
Rulează evaluarea modelului de bază:
python pipeline.py evaluate --model base
Pentru fiecare pereche, scriptul calculează media log-probabilităților tokenilor din completare, fără tokenii promptului. preference_accuracy este proporția cazurilor în care scorul mediu al răspunsului chosen îl depășește pe cel rejected. mean_margin este diferența medie dintre aceste scoruri, iar chosen_nll este negative log-likelihood pentru completările preferate.
Aceste metrici evaluează ordonarea perechilor din test, nu corectitudinea factuală generală, siguranța sau calitatea în limba română. Pentru un evaluator separat, calibrat, pipeline-ul este diferit de cel prezentat în articolul despre Reward Model pentru perechi chosen–rejected.
Ce trebuie să vezi: valorile depind de hardware și de calculele BF16, dar schema și numărul de exemple trebuie să fie acestea:
{
"base": {
"examples": 256,
"preference_accuracy": <valoare numerică>,
"mean_margin": <valoare numerică>,
"chosen_nll": <valoare numerică>
}
}
Marcajele dintre paranteze unghiulare descriu câmpurile, nu reprezintă output literal. Valorile reale sunt scrise în reports/metrics.json.
Punct de control: dacă examples nu este 256, nu porni antrenarea; verifică existența splitului test și manifestul generat la pasul anterior.
Pasul 4 — Antrenăm simultan semnalul SFT și preferința odds-ratio
De ce: acesta este pasul specific ORPO. ORPOTrainer optimizează răspunsul preferat prin termenul de limbaj și separă perechea prin termenul odds-ratio, fără să păstreze în VRAM un al doilea model de referință.
Rulează:
python pipeline.py train
LoRA este aplicat proiecțiilor de atenție și straturilor MLP. Rankul 16, lora_alpha=32 și dropoutul 0.05 definesc adaptorul, nu o copie completă a modelului. Validarea se execută la fiecare 32 de actualizări, iar load_best_model_at_end=True readuce checkpointul cu cea mai mică eval_loss.
Faptul că ORPO nu are model de referință separat reduce o componentă importantă de memorie, dar nu face antrenarea gratuită: pentru fiecare pereche sunt procesate atât completarea aleasă, cât și cea respinsă. Dacă BF16 este nou în infrastructura echipei, verificările din ghidul BF16 versus FP16 în training ajută la separarea problemelor numerice de cele produse de date.
Ce trebuie să vezi: logul include periodic pierderea de antrenare și evaluare, apoi mesajul controlat de script:
Adaptor salvat în output/orpo-adapter
În director trebuie să existe cel puțin configurația adaptorului și greutățile sale:
find output/orpo-adapter -maxdepth 1 -type f -printf '%fn' | sort
README.md
adapter_config.json
adapter_model.safetensors
chat_template.jinja
special_tokens_map.json
tokenizer.json
tokenizer_config.json
Lista exactă a fișierelor tokenizerului poate varia în interiorul aceleiași versiuni, dar adapter_config.json și adapter_model.safetensors sunt obligatorii.
Punct de control: dacă directorul adaptorului nu apare, citește ultimul checkpoint din output/checkpoints; nu evalua un checkpoint parțial ca rezultat final.
Pasul 5 — Comparăm ambele modele și păstrăm eșecurile, nu doar media
De ce: o creștere a mediei poate ascunde prompturi pentru care modelul continuă să prefere răspunsul respins. Evaluarea finală folosește aceleași 256 de perechi și scrie primele 20 de eșecuri cu marja cea mai negativă.
Rulează:
python pipeline.py evaluate --model orpo
cat reports/metrics.json
head -n 1 reports/error_analysis.jsonl
Tabelul care trebuie completat cu valorile reale din metrics.json este:
| Model | Exemple test | Preference accuracy | Marjă medie | Chosen NLL |
|---|---|---|---|---|
| Qwen2.5-0.5B-Instruct | 256 | base.preference_accuracy |
base.mean_margin |
base.chosen_nll |
| Qwen2.5 + adaptor ORPO | 256 | orpo.preference_accuracy |
orpo.mean_margin |
orpo.chosen_nll |
Un rezultat util presupune că ORPO îmbunătățește preference_accuracy și, de regulă, marja. chosen_nll trebuie urmărit separat: optimizarea exclusivă a distanței dintre cele două răspunsuri nu garantează automat o modelare mai bună a completării preferate.
error_analysis.jsonl conține promptul, cele două completări și marja. Grupează manual eșecurile după cauză: răspuns preferat discutabil, ambele răspunsuri slabe, trimitere factuală dificilă, instrucțiune ambiguă sau diferență de stil. Datasetul este extern și în engleză; înainte de utilizarea într-un produs românesc, repetă evaluarea cu un test propriu, revizuit conform politicilor și limbii produsului.
Ce trebuie să vezi:
{
"base": {
"examples": 256,
"preference_accuracy": <valoare numerică>,
"mean_margin": <valoare numerică>,
"chosen_nll": <valoare numerică>
},
"orpo": {
"examples": 256,
"preference_accuracy": <valoare numerică>,
"mean_margin": <valoare numerică>,
"chosen_nll": <valoare numerică>
}
}
Punct de control: dacă secțiunea orpo lipsește, evaluarea nu s-a terminat; dacă fișierul de erori este gol, confirmă din metrică faptul că toate cele 256 de marje sunt pozitive înainte să presupui că scrierea a eșuat.
Pasul 6 — Servim numai adaptorul validat printr-un API local
De ce: checkpointul trebuie încărcat în același model de bază și la aceeași revizie. API-ul limitează lungimea cererii, folosește decodare deterministă și ascultă implicit doar pe interfața locală.
Fișierul serve.py:
from __future__ import annotations
import torch
from fastapi import FastAPI
from peft import PeftModel
from pydantic import BaseModel, Field
from transformers import AutoModelForCausalLM, AutoTokenizer
MODEL_ID = "Qwen/Qwen2.5-0.5B-Instruct"
MODEL_REVISION = "7ae557604adf67be50417f59c2c2f167def9a775"
ADAPTER_DIR = "output/orpo-adapter"
tokenizer = AutoTokenizer.from_pretrained(
MODEL_ID,
revision=MODEL_REVISION,
use_fast=True,
)
base_model = AutoModelForCausalLM.from_pretrained(
MODEL_ID,
revision=MODEL_REVISION,
torch_dtype=torch.bfloat16,
attn_implementation="sdpa",
)
model = PeftModel.from_pretrained(base_model, ADAPTER_DIR)
model = model.cuda().eval()
app = FastAPI(title="ORPO Qwen2.5 local API", version="1.0.0")
class GenerationRequest(BaseModel):
prompt: str = Field(min_length=1, max_length=2000)
max_new_tokens: int = Field(default=128, ge=1, le=256)
class GenerationResponse(BaseModel):
text: str
input_tokens: int
generated_tokens: int
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok", "model": "Qwen2.5-0.5B-Instruct+ORPO"}
@app.post("/generate", response_model=GenerationResponse)
def generate(request: GenerationRequest) -> GenerationResponse:
messages = [{"role": "user", "content": request.prompt}]
rendered = tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True,
)
inputs = tokenizer(
rendered,
return_tensors="pt",
add_special_tokens=False,
).to(model.device)
with torch.inference_mode():
output = model.generate(
**inputs,
max_new_tokens=request.max_new_tokens,
do_sample=False,
pad_token_id=tokenizer.eos_token_id,
)
input_length = inputs["input_ids"].shape[1]
generated_ids = output[0, input_length:]
text = tokenizer.decode(generated_ids, skip_special_tokens=True)
return GenerationResponse(
text=text,
input_tokens=input_length,
generated_tokens=len(generated_ids),
)
Pornește serviciul:
uvicorn serve:app --host 127.0.0.1 --port 8000
Testează-l din alt terminal al aceleiași mașini:
curl -s http://127.0.0.1:8000/health
curl -s -X POST http://127.0.0.1:8000/generate
-H 'Content-Type: application/json'
-d '{"prompt":"Explain why a validation split must remain separate from the test split.","max_new_tokens":96}'
Ce trebuie să vezi: endpointul de sănătate are rezultat fix:
{"status":"ok","model":"Qwen2.5-0.5B-Instruct+ORPO"}
Al doilea răspuns conține câmpurile text, input_tokens și generated_tokens. Textul exact poate diferi între platforme; validarea verifică structura și faptul că generated_tokens nu depășește 96.
Punct de control: dacă /health răspunde, dar /generate eșuează, citește traceback-ul procesului uvicorn și verifică mai întâi memoria GPU și lungimea promptului.
Șase mesaje care localizează defectul în acest pipeline
RuntimeError: Acest experiment necesită un GPU CUDA.
torch.cuda.is_available() a întors False. Verifică vizibilitatea dispozitivului:
nvidia-smi
python -c "import torch; print(torch.cuda.is_available(), torch.version.cuda)"
Dacă nvidia-smi funcționează, dar PyTorch raportează False, este instalat un build nepotrivit sau driverul nu este compatibil cu runtime-ul ales.
RuntimeError: GPU-ul curent nu raportează suport BF16.
GPU-ul nu poate executa configurația BF16 din tutorial. Nu schimba pur și simplu bf16=True în FP16 fără o nouă verificare numerică. Rulează experimentul pe un GPU cu BF16 sau definește separat un experiment FP16, cu monitorizarea pierderii și a valorilor nefinite.
CUDA out of memory. Tried to allocate ...
Memoria este ocupată de alt proces sau limita de 16 GB nu mai este suficientă în configurația reală. Identifică procesele:
nvidia-smi
Oprește doar procesele proprii care nu mai sunt necesare. Dacă eroarea persistă într-un GPU liber, schimbă per_device_train_batch_size=2 la 1 și gradient_accumulation_steps=8 la 16; batchul efectiv rămâne 16.
ValueError: Chat template-ul nu produce un prefix identic pentru prompt.
Tokenizerul încărcat nu mai corespunde reviziei sau datele au altă structură decât cea validată. Confirmă constantele:
grep -E 'MODEL_REVISION|DATASET_REVISION' pipeline.py
cat data/preference-splits/manifest.json
Nu elimina verificarea de prefix: altfel evaluatorul poate include tokeni ai promptului în scorul completării.
ValueError: Nu am colectat 2560 exemple valide; am găsit doar ...
Revizia datasetului, structura câmpurilor ori limitele de tokeni au fost schimbate. În tutorial, rezolvarea este restaurarea reviziei 91376df778fc031c79bf47bf69df0d611fb46387, nu reducerea tăcută a spliturilor.
rm -rf data/preference-splits
python pipeline.py prepare
FileNotFoundError: Adaptorul ORPO lipsește.
Evaluarea sau API-ul au fost pornite înainte ca antrenarea să salveze rezultatul:
test -f output/orpo-adapter/adapter_config.json
test -f output/orpo-adapter/adapter_model.safetensors
Ambele comenzi trebuie să se termine cu codul 0. Dacă există doar checkpointuri intermediare, reia sau finalizează antrenarea înainte de servire.
Testul de capăt leagă manifestul, adaptorul, metricile și API-ul
Cu serviciul pornit, execută:
python - <<'PY'
import json
from pathlib import Path
from urllib.request import urlopen
manifest = json.loads(
Path("data/preference-splits/manifest.json").read_text(encoding="utf-8")
)
metrics = json.loads(
Path("reports/metrics.json").read_text(encoding="utf-8")
)
health = json.loads(urlopen("http://127.0.0.1:8000/health").read())
assert manifest["splits"] == {
"train": 2048,
"validation": 256,
"test": 256,
}
assert Path("output/orpo-adapter/adapter_model.safetensors").is_file()
assert metrics["base"]["examples"] == 256
assert metrics["orpo"]["examples"] == 256
assert 0.0 <= metrics["base"]["preference_accuracy"] <= 1.0
assert 0.0 <= metrics["orpo"]["preference_accuracy"] <= 1.0
assert health["status"] == "ok"
print("OK: date, adaptor, evaluări și API verificate")
PY
Rezultatul final trebuie să fie:
OK: date, adaptor, evaluări și API verificate
Testul nu impune artificial ca ORPO să câștige. Dacă scorul scade, execuția poate fi corectă, iar rezultatul experimental nefavorabil. În acel caz, analiza perechilor și repetarea cu date proprii sunt mai utile decât ascunderea metricii.
Oprim serviciul și ștergem numai artefactele laboratorului
Oprește uvicorn cu Ctrl+C. Pentru a elimina antrenarea și rapoartele, dar a păstra codul:
cd "$HOME/orpo-lab"
rm -rf output reports data/preference-splits
Pentru a șterge și mediul virtual:
deactivate 2>/dev/null || true
rm -rf "$HOME/orpo-lab/.venv"
Comenzile nu ating cache-ul Hugging Face global. Dacă dorești să recuperezi și acel spațiu, inspectează-l separat înainte de ștergere; poate conține modele folosite de alte proiecte.
Lista de control pentru un rezultat ORPO auditabil
- ☐ Modelul și datasetul folosesc reviziile fixate în manifest.
- ☐ Spliturile conțin 2.048 exemple train, 256 validation și 256 test.
- ☐ Nicio pereche din test nu intră în antrenare sau selecția checkpointului.
- ☐ Baseline-ul este calculat înaintea adaptorului, cu aceeași funcție de scor.
- ☐
output/orpo-adapterconține configurația și greutățile LoRA. - ☐
metrics.jsonconține secțiunilebaseșiorpo, fiecare cu 256 de exemple. - ☐ Eșecurile ORPO sunt păstrate în
error_analysis.jsonl. - ☐ API-ul ascultă pe
127.0.0.1, nu este expus direct internetului. - ☐ Testul de capăt afișează mesajul
OK. - ☐ Decizia de promovare folosește și un set propriu, nu numai UltraFeedback.
Următorul experiment logic este o ablație cu aceleași splituri și trei valori pentru beta, fiecare în alt director, fără alegerea hiperparametrului pe test. A doua direcție este evaluarea în limba română pe perechi revizuite intern. Pentru producție, adaptorul validat poate fi îmbinat sau încărcat dinamic, dar API-ul trebuie completat cu autentificare, limitare de rată, observabilitate și o politică explicită de păstrare a prompturilor.
Întrebări frecvente
ORPO folosește un model de referință separat?
Nu. În configurația prezentată, ORPO combină pierderea pentru răspunsul preferat cu termenul odds-ratio și încarcă un singur model de bază plus adaptorul LoRA.
De ce evaluăm și chosen NLL, nu numai preference accuracy?
Preference accuracy arată ordinea celor două răspunsuri, în timp ce chosen NLL urmărește cât de bine modelează sistemul completarea preferată. O marjă mai mare nu demonstrează singură o modelare mai bună.
Poate fi rulat proiectul numai pe CPU?
Codul verifică explicit existența unui GPU CUDA cu suport BF16. O rulare CPU ar necesita o configurație separată și ar putea depăși opt ore pentru experimentul fixat aici.
Datasetul UltraFeedback este suficient pentru un produs în limba română?
Nu. Experimentul folosește date externe în engleză și trebuie completat cu un test propriu, în limba română, revizuit conform scenariilor și politicilor produsului.

















































