Skip to main content

Cyber AI România

Vineri, 6 noiembrie 2026

Bootstrap Confidence Intervals pentru benchmark-uri LLM: când un plus de 1% este real

Două configurații LLM pot termina benchmark-ul la 41% și 42%, dar diferența observată nu spune singură dacă a doua configurație este mai bună sau doar a beneficiat de un seed favorabil. În acest proiect vom păstra predicțiile pe fiecare întrebare, vom repeta fine-tuning-ul cu opt seed-uri și vom calcula intervalul de încredere BCa pentru diferențele pereche.

Rezultatul final este un laborator reproductibil pentru EleutherAI/pythia-70m-deduped și splitul ARC-Challenge din AI2 Reasoning Challenge. Laboratorul descarcă și fixează reviziile surselor, construiește un baseline fără adaptare, antrenează opt adaptoare LoRA, selectează fiecare checkpoint pe validation, evaluează o singură dată pe test și produce artifacts/report.md.

Raportul răspunde separat la două întrebări:

  • există dovezi că adaptarea îmbunătățește scorul, adică limita inferioară a intervalului este peste zero;
  • există dovezi că îmbunătățirea este de cel puțin un punct procentual, adică limita inferioară este peste 1.0 punct procentual.

Această distincție împiedică transformarea automată a unui +1% observat într-o afirmație despre un progres real.

Fixăm experimentul la opt seed-uri și același set ARC-Challenge

Mediul folosit este Ubuntu 24.04 LTS, Python 3.12 și PyTorch 2.14.0. Proiectul necesită aproximativ 16 GB RAM, 6 GB spațiu liber și un GPU NVIDIA cu minimum 8 GB VRAM pentru etapa de antrenare. Pe un GPU de nivelul RTX 3060, bugetul orientativ este de 45–90 de minute pentru cele opt rulări; pe CPU, proiectul poate dura mai multe ore. Timpul real se măsoară cu time, nu se deduce din această estimare.

Verifică sistemul înainte de instalare:

cat /etc/os-release
python3 --version
nvidia-smi
free -h
df -h .

Ieșirea trebuie să confirme Ubuntu 24.04, Python 3.12, accesul la GPU și resursele disponibile. Versiunea driverului, memoria exactă și identificatorul plăcii vor diferi între sisteme.

AI2 ARC conține 7.787 de întrebări de științe cu variante multiple și este împărțit în ARC-Easy și ARC-Challenge. Folosim numai configurația dificilă, cu spliturile oficiale train, validation și test. Licența indicată de cardul datasetului este CC BY-SA 4.0.

Modelul Pythia este disponibil sub licența Apache 2.0. Este suficient de mic pentru un laborator local, dar rămâne un model cauzal real: fiecare variantă este evaluată prin log-probabilitatea medie condiționată de întrebare și de toate opțiunile. Nu evaluăm text generat liber și nu folosim un evaluator LLM extern.

Pentru benchmark-uri contaminate, nici cel mai riguros interval statistic nu repară datele. Înaintea unui experiment important, traseul de aici trebuie completat cu o verificare a contaminării benchmark-ului.

Instalează mediul într-un director nou:

mkdir -p ~/llm-bootstrap-ci
cd ~/llm-bootstrap-ci
python3 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
cat > requirements.txt <<'EOF'
torch==2.14.0
transformers==5.0.0
datasets==4.4.1
peft==0.18.0
huggingface-hub==0.35.3
numpy==2.5.0
scipy==1.17.0
EOF
python -m pip install -r requirements.txt
python -m pip check

Arborele rezultat va fi:

llm-bootstrap-ci/
├── requirements.txt          # versiunile fixate ale bibliotecilor
├── benchmark.py              # pregătire, training, evaluare, analiză și inferență
├── data/
│   ├── source_lock.json      # reviziile exacte ale datasetului și modelului
│   ├── train.jsonl           # întrebările folosite la fine-tuning
│   ├── validation.jsonl      # selecția checkpointului
│   └── test.jsonl            # evaluarea finală, neatinsă în timpul selecției
├── adapters/                 # cele opt adaptoare LoRA selectate
├── results/                  # predicții pereche pe fiecare item
└── artifacts/
    ├── report.json           # rezultate ușor de prelucrat
    └── report.md             # tabelul și decizia experimentului

Pasul 1 — Materializăm ARC și blocăm reviziile Hugging Face

De ce: main este o țintă mobilă. Scriptul citește commiturile curente ale datasetului și modelului, le salvează, apoi descarcă datele folosind SHA-ul datasetului. Rulările următoare refuză să înlocuiască implicit acest lock.

Creează fișierul complet benchmark.py:

import argparse
import itertools
import json
import os
import random
from pathlib import Path

import numpy as np
import torch
from datasets import load_dataset
from huggingface_hub import HfApi
from peft import LoraConfig, PeftModel, TaskType, get_peft_model
from scipy.stats import bootstrap
from torch.nn.utils.rnn import pad_sequence
from torch.utils.data import DataLoader
from transformers import AutoModelForCausalLM, AutoTokenizer

DATASET_ID = "allenai/ai2_arc"
DATASET_CONFIG = "ARC-Challenge"
MODEL_ID = "EleutherAI/pythia-70m-deduped"
SEEDS = [101, 211, 307, 401, 503, 601, 701, 809]
MAX_LENGTH = 512
ROOT = Path(__file__).resolve().parent
DATA = ROOT / "data"
ADAPTERS = ROOT / "adapters"
RESULTS = ROOT / "results"
ARTIFACTS = ROOT / "artifacts"
LOCK = DATA / "source_lock.json"


def write_json(path, value):
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(
        json.dumps(value, ensure_ascii=False, indent=2) + "n",
        encoding="utf-8",
    )


def read_jsonl(path):
    if not path.exists():
        raise FileNotFoundError(f"Fișier lipsă: {path}; rulează prepare.")
    with path.open(encoding="utf-8") as handle:
        return [json.loads(line) for line in handle]


def write_jsonl(path, rows):
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("w", encoding="utf-8") as handle:
        for row in rows:
            handle.write(json.dumps(row, ensure_ascii=False) + "n")


def load_lock():
    if not LOCK.exists():
        raise FileNotFoundError(f"Fișier lipsă: {LOCK}; rulează prepare.")
    return json.loads(LOCK.read_text(encoding="utf-8"))


def normalize(example):
    labels = [str(value) for value in example["choices"]["label"]]
    texts = [str(value) for value in example["choices"]["text"]]
    answer = str(example["answerKey"])
    if answer not in labels:
        raise ValueError(f"answerKey={answer!r} lipsește din labels pentru {example['id']}")
    return {
        "id": str(example["id"]),
        "question": str(example["question"]),
        "labels": labels,
        "choices": texts,
        "answer_index": labels.index(answer),
    }


def prepare():
    DATA.mkdir(parents=True, exist_ok=True)
    if LOCK.exists():
        lock = load_lock()
    else:
        api = HfApi()
        lock = {
            "dataset_id": DATASET_ID,
            "dataset_config": DATASET_CONFIG,
            "dataset_revision": api.dataset_info(DATASET_ID).sha,
            "model_id": MODEL_ID,
            "model_revision": api.model_info(MODEL_ID).sha,
        }
        write_json(LOCK, lock)

    dataset = load_dataset(
        lock["dataset_id"],
        lock["dataset_config"],
        revision=lock["dataset_revision"],
    )
    for split in ("train", "validation", "test"):
        rows = [normalize(example) for example in dataset[split]]
        write_jsonl(DATA / f"{split}.jsonl", rows)
        print(f"prepared split={split} rows={len(rows)}")
    print(f"lock={LOCK}")


def prompt_for(row):
    options = "n".join(
        f"{label}) {text}" for label, text in zip(row["labels"], row["choices"])
    )
    return f"Question: {row['question']}n{options}nAnswer:"


def tokenized_training_row(tokenizer, row):
    answer = " " + row["choices"][row["answer_index"]]
    answer_ids = tokenizer(answer, add_special_tokens=False)["input_ids"]
    if not answer_ids:
        raise ValueError(f"Tokenized answer is empty pentru {row['id']}")
    room = MAX_LENGTH - len(answer_ids) - 1
    prompt_ids = tokenizer(
        prompt_for(row),
        add_special_tokens=False,
        truncation=True,
        max_length=room,
    )["input_ids"]
    input_ids = prompt_ids + answer_ids + [tokenizer.eos_token_id]
    labels = [-100] * len(prompt_ids) + answer_ids + [tokenizer.eos_token_id]
    return {
        "input_ids": torch.tensor(input_ids, dtype=torch.long),
        "labels": torch.tensor(labels, dtype=torch.long),
    }


class Collator:
    def __init__(self, pad_id):
        self.pad_id = pad_id

    def __call__(self, batch):
        ids = pad_sequence(
            [item["input_ids"] for item in batch],
            batch_first=True,
            padding_value=self.pad_id,
        )
        labels = pad_sequence(
            [item["labels"] for item in batch],
            batch_first=True,
            padding_value=-100,
        )
        return {
            "input_ids": ids,
            "attention_mask": ids.ne(self.pad_id).long(),
            "labels": labels,
        }


@torch.inference_mode()
def choice_score(model, tokenizer, row, choice_index, device):
    prompt_ids = tokenizer(
        prompt_for(row),
        add_special_tokens=False,
    )["input_ids"]
    answer_ids = tokenizer(
        " " + row["choices"][choice_index],
        add_special_tokens=False,
    )["input_ids"]
    if not answer_ids:
        raise ValueError(f"Tokenized answer is empty pentru {row['id']}")

    room = MAX_LENGTH - len(answer_ids)
    prompt_ids = prompt_ids[:room]
    ids = prompt_ids + answer_ids
    tensor = torch.tensor([ids], dtype=torch.long, device=device)
    logits = model(input_ids=tensor).logits
    log_probs = logits.log_softmax(dim=-1)

    start = len(prompt_ids)
    positions = log_probs[0, start - 1 : len(ids) - 1]
    targets = tensor[0, start:]
    token_scores = positions.gather(1, targets.unsqueeze(1)).squeeze(1)
    return float(token_scores.mean().cpu())


@torch.inference_mode()
def predict_rows(model, tokenizer, rows, device):
    model.eval()
    output = []
    for row in rows:
        scores = [
            choice_score(model, tokenizer, row, index, device)
            for index in range(len(row["choices"]))
        ]
        prediction = int(np.argmax(scores))
        output.append(
            {
                "id": row["id"],
                "gold": row["answer_index"],
                "prediction": prediction,
                "correct": int(prediction == row["answer_index"]),
                "scores": scores,
            }
        )
    return output


def load_base(device):
    lock = load_lock()
    tokenizer = AutoTokenizer.from_pretrained(
        lock["model_id"],
        revision=lock["model_revision"],
    )
    tokenizer.pad_token = tokenizer.eos_token
    model = AutoModelForCausalLM.from_pretrained(
        lock["model_id"],
        revision=lock["model_revision"],
    )
    model.config.use_cache = False
    return model.to(device), tokenizer


def train_one(seed, device):
    random.seed(seed)
    np.random.seed(seed)
    torch.manual_seed(seed)
    torch.cuda.manual_seed_all(seed)
    torch.use_deterministic_algorithms(True)

    train_rows = read_jsonl(DATA / "train.jsonl")
    validation_rows = read_jsonl(DATA / "validation.jsonl")
    base, tokenizer = load_base(device)
    config = LoraConfig(
        task_type=TaskType.CAUSAL_LM,
        r=8,
        lora_alpha=16,
        lora_dropout=0.05,
        target_modules=["query_key_value"],
    )
    model = get_peft_model(base, config)
    examples = [tokenized_training_row(tokenizer, row) for row in train_rows]
    generator = torch.Generator().manual_seed(seed)
    loader = DataLoader(
        examples,
        batch_size=8,
        shuffle=True,
        generator=generator,
        collate_fn=Collator(tokenizer.pad_token_id),
    )
    optimizer = torch.optim.AdamW(
        (parameter for parameter in model.parameters() if parameter.requires_grad),
        lr=2e-4,
        weight_decay=0.01,
    )

    target = ADAPTERS / f"seed-{seed}"
    best_accuracy = -1.0
    for epoch in range(1, 4):
        model.train()
        total_loss = 0.0
        for batch in loader:
            batch = {key: value.to(device) for key, value in batch.items()}
            optimizer.zero_grad(set_to_none=True)
            loss = model(**batch).loss
            loss.backward()
            torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
            optimizer.step()
            total_loss += float(loss.detach().cpu())

        validation = predict_rows(model, tokenizer, validation_rows, device)
        accuracy = float(np.mean([row["correct"] for row in validation]))
        print(
            f"seed={seed} epoch={epoch} "
            f"loss={total_loss / len(loader):.6f} validation_accuracy={accuracy:.6f}"
        )
        if accuracy > best_accuracy:
            best_accuracy = accuracy
            target.mkdir(parents=True, exist_ok=True)
            model.save_pretrained(target)
            write_json(
                target / "selection.json",
                {"seed": seed, "epoch": epoch, "validation_accuracy": accuracy},
            )

    del model
    if device.startswith("cuda"):
        torch.cuda.empty_cache()


def train_all(device):
    if device.startswith("cuda") and not torch.cuda.is_available():
        raise RuntimeError("CUDA este necesar pentru train-all în acest proiect.")
    ADAPTERS.mkdir(parents=True, exist_ok=True)
    for seed in SEEDS:
        train_one(seed, device)


def evaluate(device):
    test_rows = read_jsonl(DATA / "test.jsonl")
    RESULTS.mkdir(parents=True, exist_ok=True)

    base, tokenizer = load_base(device)
    baseline = predict_rows(base, tokenizer, test_rows, device)
    write_jsonl(RESULTS / "baseline.jsonl", baseline)
    print(f"saved={RESULTS / 'baseline.jsonl'}")

    for seed in SEEDS:
        adapter = ADAPTERS / f"seed-{seed}"
        if not adapter.exists():
            raise FileNotFoundError(f"Adaptor lipsă: {adapter}; rulează train-all.")
        model = PeftModel.from_pretrained(base, adapter).to(device)
        predictions = predict_rows(model, tokenizer, test_rows, device)
        path = RESULTS / f"seed-{seed}.jsonl"
        write_jsonl(path, predictions)
        print(f"saved={path}")
        del model


def exact_sign_flip_pvalue(deltas):
    observed = abs(float(np.mean(deltas)))
    statistics = [
        abs(float(np.mean(np.asarray(signs) * deltas)))
        for signs in itertools.product((-1.0, 1.0), repeat=len(deltas))
    ]
    return sum(value >= observed - 1e-12 for value in statistics) / len(statistics)


def analyze():
    baseline_rows = read_jsonl(RESULTS / "baseline.jsonl")
    ids = [row["id"] for row in baseline_rows]
    baseline = np.asarray([row["correct"] for row in baseline_rows], dtype=float)
    seed_accuracies = []
    deltas = []
    treatment_matrix = []

    for seed in SEEDS:
        rows = read_jsonl(RESULTS / f"seed-{seed}.jsonl")
        if [row["id"] for row in rows] != ids:
            raise ValueError(f"Ordinea itemilor diferă pentru seed={seed}.")
        values = np.asarray([row["correct"] for row in rows], dtype=float)
        treatment_matrix.append(values)
        seed_accuracies.append(float(values.mean() * 100))
        deltas.append(float((values - baseline).mean() * 100))

    deltas_array = np.asarray(deltas)
    if np.ptp(deltas_array) == 0:
        raise RuntimeError("BCa CI cannot be calculated from identical seed deltas.")

    interval = bootstrap(
        (deltas_array,),
        np.mean,
        confidence_level=0.95,
        n_resamples=10000,
        method="BCa",
        rng=np.random.default_rng(20260918),
    ).confidence_interval
    mean_delta = float(deltas_array.mean())
    lower = float(interval.low)
    upper = float(interval.high)
    pvalue = exact_sign_flip_pvalue(deltas_array)
    matrix = np.vstack(treatment_matrix)
    item_gain = matrix.mean(axis=0) - baseline
    gained = [ids[index] for index in np.argsort(item_gain)[-10:][::-1]]
    lost = [ids[index] for index in np.argsort(item_gain)[:10]]

    report = {
        "baseline_accuracy_pct": float(baseline.mean() * 100),
        "seed_accuracies_pct": dict(zip(map(str, SEEDS), seed_accuracies)),
        "seed_deltas_pp": dict(zip(map(str, SEEDS), deltas)),
        "mean_delta_pp": mean_delta,
        "bca_95_ci_pp": [lower, upper],
        "exact_sign_flip_pvalue": pvalue,
        "evidence_any_gain": bool(lower > 0 and pvalue < 0.05),
        "evidence_at_least_1pp": bool(lower >= 1.0 and pvalue < 0.05),
        "largest_gains_item_ids": gained,
        "largest_losses_item_ids": lost,
    }
    write_json(ARTIFACTS / "report.json", report)

    lines = [
        "# ARC-Challenge paired evaluation",
        "",
        "| Sistem | Accuracy (%) |",
        "|---|---:|",
        f"| Pythia baseline | {report['baseline_accuracy_pct']:.3f} |",
        f"| LoRA, media a 8 seed-uri | {np.mean(seed_accuracies):.3f} |",
        "",
        f"- Diferență medie: {mean_delta:.3f} pp",
        f"- BCa 95% CI: [{lower:.3f}, {upper:.3f}] pp",
        f"- Exact sign-flip p-value: {pvalue:.6f}",
        f"- Dovezi pentru orice câștig: {report['evidence_any_gain']}",
        f"- Dovezi pentru cel puțin +1 pp: {report['evidence_at_least_1pp']}",
    ]
    path = ARTIFACTS / "report.md"
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text("n".join(lines) + "n", encoding="utf-8")
    print(path.read_text(encoding="utf-8"))


def predict(adapter, device, question, choices):
    if len(choices) < 2:
        raise ValueError("Sunt necesare cel puțin două variante.")
    base, tokenizer = load_base(device)
    model = PeftModel.from_pretrained(base, adapter).to(device)
    row = {
        "id": "cli",
        "question": question,
        "labels": [chr(65 + index) for index in range(len(choices))],
        "choices": choices,
        "answer_index": 0,
    }
    scores = [
        choice_score(model, tokenizer, row, index, device)
        for index in range(len(choices))
    ]
    selected = int(np.argmax(scores))
    print(json.dumps(
        {"selected_index": selected, "selected_text": choices[selected], "scores": scores},
        ensure_ascii=False,
        indent=2,
    ))


def main():
    parser = argparse.ArgumentParser()
    sub = parser.add_subparsers(dest="command", required=True)
    sub.add_parser("prepare")

    train_parser = sub.add_parser("train-all")
    train_parser.add_argument("--device", default="cuda")

    eval_parser = sub.add_parser("evaluate")
    eval_parser.add_argument("--device", default="cuda")

    sub.add_parser("analyze")

    predict_parser = sub.add_parser("predict")
    predict_parser.add_argument("--adapter", required=True)
    predict_parser.add_argument("--device", default="cuda")
    predict_parser.add_argument("--question", required=True)
    predict_parser.add_argument("--choice", action="append", required=True)
    args = parser.parse_args()

    if args.command == "prepare":
        prepare()
    elif args.command == "train-all":
        train_all(args.device)
    elif args.command == "evaluate":
        evaluate(args.device)
    elif args.command == "analyze":
        analyze()
    elif args.command == "predict":
        predict(args.adapter, args.device, args.question, args.choice)


if __name__ == "__main__":
    main()

Rulează materializarea:

source .venv/bin/activate
python benchmark.py prepare
cat data/source_lock.json
wc -l data/train.jsonl data/validation.jsonl data/test.jsonl

Ce trebuie să vezi, cu SHA-uri și numere de rânduri furnizate de descărcarea ta:

prepared split=train rows=...
prepared split=validation rows=...
prepared split=test rows=...
lock=.../data/source_lock.json

source_lock.json trebuie păstrat împreună cu rezultatele. Commiturile Hugging Face sunt mai precise decât simpla menționare a numelui modelului sau datasetului.

Punct de control: dacă lipsește un split, inspectează source_lock.json și verifică accesul la paginile Hugging Face înainte de antrenare.

Pasul 2 — Antrenăm adaptoarele fără să consultăm testul

De ce: fiecare seed modifică inițializarea LoRA, ordinea batch-urilor și dropout-ul. Validation decide ce epocă se păstrează; testul rămâne în afara selecției. Astfel evităm alegerea checkpointului care arată accidental mai bine pe rezultatul final.

Pornește cele opt rulări:

export CUBLAS_WORKSPACE_CONFIG=:4096:8
time python benchmark.py train-all --device cuda 2>&1 | tee train.log

torch.use_deterministic_algorithms(True) și CUBLAS_WORKSPACE_CONFIG reduc variația pe aceeași combinație hardware–software. Documentația PyTorch avertizează însă că reproductibilitatea completă nu este garantată între versiuni, platforme sau dispozitive diferite. De aceea păstrăm atât requirements.txt, cât și lock-ul surselor.

LoRA folosește rangul 8, factorul 16, dropout 0.05 și modulele query_key_value din GPT-NeoX. Optimizatorul actualizează numai parametrii antrenabili ai adaptorului. Gradientul este limitat la norma 1.0.

Ieșirea va avea câte trei linii pentru fiecare seed:

seed=101 epoch=1 loss=... validation_accuracy=...
seed=101 epoch=2 loss=... validation_accuracy=...
seed=101 epoch=3 loss=... validation_accuracy=...

Valorile nu sunt furnizate anticipat: ele sunt rezultate ale execuției și pot varia pe alt hardware. Pentru fiecare seed trebuie să existe adapter_model.safetensors, configurația PEFT și selection.json.

Punct de control: dacă validation accuracy nu este finită sau loss-ul devine nan, oprește rularea; nu continua către test și nu interpreta adaptoarele incomplete.

Pasul 3 — Salvăm predicții pereche, nu doar opt scoruri finale

De ce: comparația corectă folosește aceleași întrebări pentru baseline și tratament. Fișierele păstrează ID-ul, eticheta corectă, predicția și indicatorul correct, astfel încât ordinea itemilor să poată fi verificată înaintea bootstrap-ului.

Rulează evaluarea finală o singură dată:

time python benchmark.py evaluate --device cuda 2>&1 | tee evaluate.log
ls -lh results

Baseline-ul este checkpointul Pythia nemodificat. Varianta evaluată este adaptorul LoRA selectat pe validation pentru fiecare seed. Pentru fiecare opțiune, scriptul calculează media log-probabilităților tokenilor răspunsului; normalizarea împiedică favorizarea automată a unui răspuns doar pentru că este mai scurt.

Trebuie să apară:

saved=.../results/baseline.jsonl
saved=.../results/seed-101.jsonl
saved=.../results/seed-211.jsonl

Lista continuă până la seed-809.jsonl. Fiecare fișier trebuie să aibă același număr de rânduri și aceeași ordine a ID-urilor.

Această disciplină este utilă și în evaluarea sistemelor mai complexe. Pentru agenți, unitatea pereche poate fi sarcina completă, nu un răspuns izolat; articolul despre evaluări pentru agenți AI cu tool-uri explică de ce succesul trebuie legat de un rezultat verificabil.

Punct de control: dacă numărul rândurilor diferă, nu concatena fișierele și nu calcula CI; refă evaluarea din revizia fixată.

Pasul 4 — Calculăm BCa pe diferențele dintre seed-uri

De ce: accuracy medie ascunde dispersia rulărilor. Pentru fiecare seed calculăm accuracy_LoRA − accuracy_baseline, în puncte procentuale. scipy.stats.bootstrap construiește apoi un interval BCa de 95% pentru media celor opt diferențe.

Rulează analiza:

python benchmark.py analyze
cat artifacts/report.md

Metoda BCa corectează bias-ul și asimetria distribuției bootstrap. Sunt folosite 10.000 de reeșantionări și seed-ul statistic 20260918. Separat, testul exact sign-flip verifică dacă semnul diferențelor este compatibil cu ipoteza nulă. Cu opt seed-uri există 2^8 combinații de semne, toate enumerate de script.

Raportul are această structură:

# ARC-Challenge paired evaluation

| Sistem | Accuracy (%) |
|---|---:|
| Pythia baseline | ... |
| LoRA, media a 8 seed-uri | ... |

- Diferență medie: ... pp
- BCa 95% CI: [..., ...] pp
- Exact sign-flip p-value: ...
- Dovezi pentru orice câștig: ...
- Dovezi pentru cel puțin +1 pp: ...

Nu înlocui aceste valori cu cifre copiate din altă rulare. Rezultatul propriu este acceptat numai după ce scriptul l-a calculat din cele nouă fișiere de predicții.

Un +1.0 pp punctual are patru interpretări posibile:

  • intervalul include zero: datele nu susțin nici măcar direcția câștigului;
  • intervalul este complet peste zero, dar începe sub 1.0: există dovezi de îmbunătățire, nu de un câștig minim de un punct;
  • intervalul începe peste 1.0, dar testul sign-flip nu trece pragul 0.05: seed-urile nu oferă confirmarea conservatoare cerută aici;
  • limita inferioară este cel puțin 1.0, iar p < 0.05: protocolul susține afirmația „minimum un punct procentual” pentru populația de seed-uri și benchmark-ul fixate.

Intervalul nu demonstrează că modelul va câștiga un punct pe alte domenii, alte limbi sau date din producție. Nici nu măsoară calibrarea probabilităților; pentru aceasta este necesar un experiment separat cu ECE și temperature scaling.

Punct de control: dacă raportul spune False, concluzia corectă este că pragul nu a fost demonstrat, nu că metoda statistică „a eșuat”.

Pasul 5 — Inspectăm schimburile de erori înaintea deciziei

De ce: aceeași accuracy poate ascunde întrebări câștigate și pierdute. report.json listează ID-urile cu cea mai mare creștere și scădere a ratei de succes între cele opt adaptoare.

Inspectează artefactul fără a modifica testul:

python - <<'PY'
import json
from pathlib import Path

report = json.loads(Path("artifacts/report.json").read_text(encoding="utf-8"))
print("Câștiguri:", report["largest_gains_item_ids"])
print("Pierderi:", report["largest_losses_item_ids"])
print("Delta:", report["mean_delta_pp"])
print("CI:", report["bca_95_ci_pp"])
PY

Ce trebuie să vezi:

Câștiguri: [...]
Pierderi: [...]
Delta: ...
CI: [...]

Aceste ID-uri servesc analizei erorilor, nu unei noi selecții de hiperparametri. Dacă schimbi promptul, rangul LoRA sau epocile după ce le inspectezi, ai folosit indirect setul de test; noua configurație trebuie evaluată pe un test neatins.

Punct de control: verifică dacă listele conțin ID-uri ARC valide și dacă intervalul coincide cu cel din report.md.

Pasul 6 — Încărcăm adaptorul selectat într-o interfață CLI

De ce: evaluarea trebuie legată de artefactul care va fi folosit. Comanda predict încarcă checkpointul de bază din revizia blocată și un adaptor concret, apoi aplică exact aceeași funcție de scor ca benchmark-ul.

Folosește o întrebare proprie, fără date personale:

python benchmark.py predict 
  --adapter adapters/seed-101 
  --device cuda 
  --question "Which state of matter has a fixed volume but takes the shape of its container?" 
  --choice "Solid" 
  --choice "Liquid" 
  --choice "Gas" 
  --choice "Plasma"

Ieșirea este un obiect JSON:

{
  "selected_index": ...,
  "selected_text": "...",
  "scores": [...]
}

Scorurile sunt log-probabilități medii, nu probabilități calibrate și nu procente de încredere. CLI-ul servește verificării traseului model–adaptor–funcție de scor; nu transformă Pythia-70M într-un sistem sigur pentru decizii importante.

Punct de control: selected_index trebuie să indice unul dintre elementele --choice, iar numărul scorurilor trebuie să fie egal cu numărul variantelor.

Testul de capăt acceptă numai artefacte complete

Rulează controlul după analiză:

python - <<'PY'
import json
from pathlib import Path

report = json.loads(Path("artifacts/report.json").read_text(encoding="utf-8"))
assert len(report["seed_accuracies_pct"]) == 8
assert len(report["seed_deltas_pp"]) == 8
assert len(report["bca_95_ci_pp"]) == 2
assert report["bca_95_ci_pp"][0] <= report["mean_delta_pp"]
assert report["mean_delta_pp"] <= report["bca_95_ci_pp"][1]

for seed in (101, 211, 307, 401, 503, 601, 701, 809):
    assert Path(f"adapters/seed-{seed}/adapter_model.safetensors").exists()
    assert Path(f"results/seed-{seed}.jsonl").exists()

assert Path("results/baseline.jsonl").exists()
assert Path("data/source_lock.json").exists()
print("OK: 8 adaptoare, predicții pereche, BCa CI și raport final.")
PY

Rezultatul așteptat este:

OK: 8 adaptoare, predicții pereche, BCa CI și raport final.

Tabelul baseline versus model, intervalul BCa, testul sign-flip și analiza ID-urilor sunt acum legate de aceleași date și revizii. Dacă vrei să compari ulterior modelul cu o variantă obținută prin knowledge distillation, păstrează același protocol pereche, dar declară înainte metrica și pragul practic.

Defecțiuni care invalidează comparația statistică

RuntimeError: CUDA este necesar pentru train-all în acest proiect. Comanda a primit --device cuda, dar PyTorch nu vede un GPU compatibil. Verifică instalarea cu:

python -c "import torch; print(torch.cuda.is_available(), torch.version.cuda)"
nvidia-smi

Nu schimba automat pe CPU în mijlocul unei serii; toate seed-urile trebuie executate în același mediu.

ValueError: Tokenized answer is empty pentru <id> Un item are o variantă goală sau structura locală a datelor a fost alterată. Regenerează spliturile din revizia blocată:

rm data/train.jsonl data/validation.jsonl data/test.jsonl
python benchmark.py prepare

torch.OutOfMemoryError: CUDA out of memory Batch-ul de opt secvențe nu încape în VRAM. Oprește seria, schimbă batch_size=8 în batch_size=4, șterge toate adaptoarele parțiale și reia toate seed-urile; nu combina rezultate obținute cu configurații diferite.

ValueError: Ordinea itemilor diferă pentru seed=<seed>. Un fișier de rezultate provine din alt split sau a fost reordonat. Șterge numai rezultatele și reevaluează toate modelele:

rm -f results/*.jsonl
python benchmark.py evaluate --device cuda

RuntimeError: BCa CI cannot be calculated from identical seed deltas. Toate cele opt diferențe sunt identice, iar corecția BCa devine degenerată. Raportează diferențele identice și nu substitui în tăcere un interval arbitrar; pentru un CI pe itemi trebuie definit și documentat un alt estimand.

OSError: ... is not a local folder and is not a valid model identifier Revizia modelului nu a putut fi descărcată sau cache-ul este incomplet. Confirmă accesul la model și păstrează SHA-ul existent:

python -c "import json; print(json.load(open('data/source_lock.json'))['model_revision'])"

Ștergem experimentul fără să atingem cache-ul global

Adaptoarele și rezultatele pot fi eliminate separat de mediul Python și de cache-ul Hugging Face:

cd ~/llm-bootstrap-ci
rm -rf adapters results artifacts
rm -f train.log evaluate.log

Pentru a elimina și datele materializate, dar a păstra codul:

rm -rf data

Pentru curățarea completă a proiectului:

deactivate 2>/dev/null || true
cd ~
rm -rf ~/llm-bootstrap-ci

Comenzile nu șterg cache-ul comun din ~/.cache/huggingface, care poate fi folosit de alte proiecte.

Lista de acceptare pentru un „+1 pp” publicabil

  • ☐ source_lock.json conține commiturile exacte pentru ARC și Pythia.
  • ☐ Train, validation și test provin din aceeași revizie fixată.
  • ☐ Toate cele opt seed-uri au câte un adaptor și un selection.json.
  • ☐ Checkpointurile au fost selectate numai pe validation.
  • ☐ Baseline-ul și adaptoarele au evaluat aceleași ID-uri în aceeași ordine.
  • ☐ report.md conține media diferențelor, BCa 95% CI și sign-flip p-value.
  • ☐ Afirmația „există un câștig” este făcută numai dacă limita inferioară depășește zero.
  • ☐ Afirmația „câștigul este de minimum +1 pp” este făcută numai dacă limita inferioară atinge 1.0 și p < 0.05.
  • ☐ ID-urile câștigate și pierdute au fost inspectate fără retunarea configurației pe test.
  • ☐ Limitarea la ARC-Challenge, Pythia-70M și mediul fixat apare lângă rezultat.

Următorul proiect logic este un bootstrap stratificat pe categorii de întrebări, cu praguri declarate înainte de rulare. Pentru modele care generează răspunsuri stocastic, extinderea corectă este un design ierarhic: mai multe seed-uri de training, mai multe decodări per item și reeșantionare pe nivelurile care corespund surselor reale de variație.

Întrebări frecvente

De ce nu este suficient să compar două valori de accuracy?

Cele două valori sunt estimări punctuale. Ele nu arată cât variază diferența între seed-uri și nici dacă intervalul compatibil cu datele include zero.

De ce baseline-ul și modelul trebuie evaluate pe aceleași întrebări?

Comparația pereche calculează diferența pe același item, eliminând variația produsă de folosirea unor seturi diferite. Scriptul oprește analiza dacă ordinea ID-urilor nu coincide.

Un interval complet peste zero dovedește automat un câștig de minimum 1 pp?

Nu. Pentru afirmația de minimum un punct procentual, protocolul cere ca limita inferioară BCa să fie cel puțin 1.0, iar testul sign-flip să aibă p < 0.05.

Pot generaliza rezultatul la alte benchmark-uri sau modele?

Nu direct. Intervalul descrie experimentul cu ARC-Challenge, modelul Pythia, adaptoarele LoRA și mediul fixate în proiect.

Surse

Facebook
X
WhatsApp
Bootstrap Confidence Intervals pentru benchmark-uri LLM: când +1% chiar contează

Te-ar putea interesa si: