Skip to main content

Cyber AI România

Marți, 27 octombrie 2026

Extinderea tokenizerului unui LLM: adaugi tokeni biomedicali fără să degradezi modelul

Un termen precum „mitochondrial” poate ocupa mai multe poziții în contextul unui model generalist, deși apare de sute de ori într-un corpus biomedical. Proiectul de mai jos selectează automat 64 de termeni costisitori din PubMedQA, îi adaugă tokenizerului GPT-2 și calibrează exclusiv noile rânduri din matricea de embeddings.

Rezultatul este un director Hugging Face încărcabil cu from_pretrained(), un raport care compară modelul original cu cel extins și un API FastAPI pentru inferență locală. Modificarea este acceptată numai dacă reduce numărul de tokeni pe datele biomedicale, păstrează intacte embeddings-urile vechi și nu mărește cu mai mult de 2% pierderea măsurată în biți per byte pe WikiText-2.

Acesta nu este un proiect de adaptare completă a cunoștințelor modelului. Tokenii noi comprimă reprezentarea termenilor și primesc embeddings calibrate, dar nu transformă GPT-2 într-un sistem medical. Pentru adaptarea tuturor greutăților la un corpus specializat, traseul corect este continued pretraining cu replay și evaluarea uitării.

Fixăm experimentul la GPT-2, PubMedQA și WikiText-2

Laboratorul folosește Ubuntu 24.04 LTS, Python 3.11, PyTorch 2.14.0 și Transformers 4.57.1. Codul rulează și pe CPU, dar un GPU NVIDIA cu minimum 8 GB VRAM scurtează calibrarea. Sunt necesari aproximativ 8 GB de spațiu liber și 16 GB RAM.

Pe un GPU de această clasă, pregătirea și cele șase epoci ar trebui bugetate la aproximativ 20–45 de minute. Pe CPU, operația poate dura câteva ore; timpul este o estimare, nu un benchmark garantat.

Verifică mediul înainte de instalare:

cat /etc/os-release
python3 --version
uname -m
free -h
df -h .
nvidia-smi || true

Un sistem compatibil va raporta Ubuntu 24.04, Python 3.11 și arhitectură x86_64. nvidia-smi poate lipsi pe un sistem CPU-only; scriptul selectează automat cpu.

Datele sunt:

  • qiaojin/PubMedQA, configurația pqa_labeled: 1.000 de întrebări biomedicale etichetate de experți, distribuite sub licență MIT conform cardului setului;
  • Salesforce/wikitext, configurația wikitext-2-raw-v1: corpus general în limba engleză, cu aproximativ două milioane de tokeni, sub CC BY-SA 3.0;
  • openai-community/gpt2: checkpointul GPT-2 de bază folosit atât pentru baseline, cât și pentru varianta extinsă.

PubMedQA este împărțit determinist, cu seed 42, în 800 de exemple pentru calibrare, 100 pentru validare și 100 pentru test. Din WikiText-2 sunt păstrate 800 de fragmente pentru replay negativ și 500 pentru testul general.

Instalăm un mediu izolat și păstrăm toate artefactele local

Creează proiectul și mediul virtual:

mkdir -p tokenizer-domain-lab
cd tokenizer-domain-lab
python3 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip

Fișierul requirements.txt:

torch==2.14.0
transformers==4.57.1
tokenizers==0.22.1
datasets==4.2.0
safetensors==0.6.2
fastapi==0.119.0
uvicorn==0.38.0

Instalează dependențele și confirmă versiunile:

python -m pip install -r requirements.txt
python -c "import torch, transformers, datasets, tokenizers; print(torch.__version__, transformers.__version__, datasets.__version__, tokenizers.__version__)"

Outputul așteptat este:

2.14.0 4.57.1 4.2.0 0.22.1

Dacă PyTorch raportează o versiune fără suport CUDA, codul rămâne funcțional pe CPU. Pentru un wheel CUDA trebuie urmat selectorul oficial PyTorch corespunzător driverului instalat, fără schimbarea celorlalte versiuni.

Proiectul va avea această structură:

tokenizer-domain-lab/
├── requirements.txt          # versiunile Python fixate
├── pipeline.py               # date, tokeni, calibrare și evaluare
├── api.py                    # inferență HTTP locală
├── data/                     # spliturile JSONL materializate
├── artifacts/
│   ├── tokenizer/            # tokenizerul cu 64 de tokeni noi
│   ├── expanded-gpt2/        # modelul și tokenizerul finale
│   ├── added_tokens.json     # termenii aleși și statisticile lor
│   └── dataset_manifest.json # fingerprints și numărul de exemple
└── reports/
    ├── train_history.json
    ├── metrics.json
    └── metrics.md

Pasul 1 — Materializăm spliturile și alegem termenii care economisesc cele mai multe poziții

De ce: selecția nu pornește de la o listă scrisă manual. Un termen intră în vocabular numai dacă apare de cel puțin zece ori în cele 800 de exemple de antrenare și tokenizerul original îl descompune în minimum două tokenuri. Scorul este frecvența înmulțită cu numărul de poziții economisite.

Fișierul pipeline.py:

import argparse
import json
import math
import os
import random
import re
from collections import Counter
from pathlib import Path

import torch
from datasets import load_dataset
from transformers import AutoModelForCausalLM, AutoTokenizer

os.environ.setdefault("CUBLAS_WORKSPACE_CONFIG", ":4096:8")

SEED = 42
BASE_MODEL = "openai-community/gpt2"
ROOT = Path(__file__).resolve().parent
DATA = ROOT / "data"
ARTIFACTS = ROOT / "artifacts"
REPORTS = ROOT / "reports"
TOKENIZER_DIR = ARTIFACTS / "tokenizer"
MODEL_DIR = ARTIFACTS / "expanded-gpt2"
MAX_LENGTH = 256
NEW_TOKEN_COUNT = 64


def seed_everything() -> None:
    random.seed(SEED)
    torch.manual_seed(SEED)
    if torch.cuda.is_available():
        torch.cuda.manual_seed_all(SEED)
    torch.use_deterministic_algorithms(True)
    torch.backends.cudnn.benchmark = False
    torch.backends.cuda.matmul.allow_tf32 = False


def write_jsonl(path: Path, texts: list[str]) -> None:
    with path.open("w", encoding="utf-8") as handle:
        for text in texts:
            handle.write(json.dumps({"text": text}, ensure_ascii=False) + "n")


def read_jsonl(path: Path) -> list[str]:
    with path.open(encoding="utf-8") as handle:
        return [json.loads(line)["text"] for line in handle if line.strip()]


def pubmed_text(row: dict) -> str:
    context = row["context"]
    contexts = context.get("contexts", []) if isinstance(context, dict) else []
    parts = [
        str(row.get("question", "")).strip(),
        " ".join(str(item).strip() for item in contexts),
        str(row.get("long_answer", "")).strip(),
    ]
    return "n".join(part for part in parts if part)


def prepare() -> None:
    DATA.mkdir(parents=True, exist_ok=True)
    TOKENIZER_DIR.mkdir(parents=True, exist_ok=True)
    REPORTS.mkdir(parents=True, exist_ok=True)

    pubmed = load_dataset(
        "qiaojin/PubMedQA",
        "pqa_labeled",
        split="train",
    ).shuffle(seed=SEED)
    if len(pubmed) != 1000:
        raise RuntimeError(f"PubMedQA pqa_labeled are {len(pubmed)} exemple, nu 1000.")

    domain = [pubmed_text(row) for row in pubmed]
    domain = [text for text in domain if text.strip()]
    if len(domain) != 1000:
        raise RuntimeError("Unele înregistrări PubMedQA nu au produs text.")

    wiki_train_ds = load_dataset(
        "Salesforce/wikitext",
        "wikitext-2-raw-v1",
        split="train",
    ).shuffle(seed=SEED)
    wiki_test_ds = load_dataset(
        "Salesforce/wikitext",
        "wikitext-2-raw-v1",
        split="test",
    ).shuffle(seed=SEED)

    wiki_train = [
        row["text"].strip()
        for row in wiki_train_ds
        if row["text"].strip()
    ][:800]
    wiki_test = [
        row["text"].strip()
        for row in wiki_test_ds
        if row["text"].strip()
    ][:500]

    if len(wiki_train) != 800 or len(wiki_test) != 500:
        raise RuntimeError("WikiText-2 nu conține suficiente fragmente nevidate.")

    splits = {
        "domain_train.jsonl": domain[:800],
        "domain_valid.jsonl": domain[800:900],
        "domain_test.jsonl": domain[900:],
        "general_train.jsonl": wiki_train,
        "general_test.jsonl": wiki_test,
    }
    for name, texts in splits.items():
        write_jsonl(DATA / name, texts)

    base_tokenizer = AutoTokenizer.from_pretrained(BASE_MODEL, use_fast=True)
    counts = Counter()
    pattern = re.compile(r"b[A-Za-z][A-Za-z-]{7,}b")
    for text in splits["domain_train.jsonl"]:
        counts.update(match.group(0).lower() for match in pattern.finditer(text))

    ranked = []
    for word, frequency in counts.items():
        token = " " + word
        old_ids = base_tokenizer.encode(token, add_special_tokens=False)
        if frequency >= 10 and len(old_ids) >= 2:
            ranked.append(
                {
                    "token": token,
                    "frequency": frequency,
                    "old_piece_count": len(old_ids),
                    "score": frequency * (len(old_ids) - 1),
                }
            )

    ranked.sort(
        key=lambda item: (-item["score"], -item["frequency"], item["token"])
    )
    selected = ranked[:NEW_TOKEN_COUNT]
    if len(selected) != NEW_TOKEN_COUNT:
        raise RuntimeError(
            f"Au fost găsiți numai {len(selected)} candidați eligibili."
        )

    added = base_tokenizer.add_tokens([item["token"] for item in selected])
    if added != NEW_TOKEN_COUNT:
        raise RuntimeError(f"Tokenizerul a adăugat {added} tokeni, nu 64.")

    base_tokenizer.save_pretrained(TOKENIZER_DIR)
    (ARTIFACTS / "added_tokens.json").write_text(
        json.dumps(selected, ensure_ascii=False, indent=2),
        encoding="utf-8",
    )
    manifest = {
        "seed": SEED,
        "pubmed_fingerprint": pubmed._fingerprint,
        "wikitext_train_fingerprint": wiki_train_ds._fingerprint,
        "wikitext_test_fingerprint": wiki_test_ds._fingerprint,
        "counts": {name: len(texts) for name, texts in splits.items()},
        "new_tokens": NEW_TOKEN_COUNT,
    }
    (ARTIFACTS / "dataset_manifest.json").write_text(
        json.dumps(manifest, ensure_ascii=False, indent=2),
        encoding="utf-8",
    )
    print(json.dumps(manifest, ensure_ascii=False, indent=2))


def encode_training_examples(tokenizer) -> list[list[int]]:
    domain = read_jsonl(DATA / "domain_train.jsonl")
    general = read_jsonl(DATA / "general_train.jsonl")
    texts = domain * 4 + general
    examples = []
    for text in texts:
        ids = tokenizer.encode(
            text,
            add_special_tokens=False,
            truncation=True,
            max_length=MAX_LENGTH,
        )
        if len(ids) >= 2:
            examples.append(ids)
    return examples


def collate(batch: list[list[int]], pad_id: int, device: torch.device):
    width = max(len(ids) for ids in batch)
    input_ids = torch.full(
        (len(batch), width), pad_id, dtype=torch.long, device=device
    )
    attention_mask = torch.zeros(
        (len(batch), width), dtype=torch.long, device=device
    )
    labels = torch.full(
        (len(batch), width), -100, dtype=torch.long, device=device
    )
    for row, ids in enumerate(batch):
        tensor = torch.tensor(ids, dtype=torch.long, device=device)
        input_ids[row, : len(ids)] = tensor
        attention_mask[row, : len(ids)] = 1
        labels[row, : len(ids)] = tensor
    return input_ids, attention_mask, labels


def train() -> None:
    seed_everything()
    if not (ARTIFACTS / "added_tokens.json").exists():
        raise FileNotFoundError("Rulează mai întâi: python pipeline.py prepare")

    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
    base_tokenizer = AutoTokenizer.from_pretrained(BASE_MODEL, use_fast=True)
    tokenizer = AutoTokenizer.from_pretrained(TOKENIZER_DIR, use_fast=True)
    tokenizer.pad_token = tokenizer.eos_token

    selected = json.loads(
        (ARTIFACTS / "added_tokens.json").read_text(encoding="utf-8")
    )
    terms = [item["token"] for item in selected]
    old_vocab_size = len(base_tokenizer)

    model = AutoModelForCausalLM.from_pretrained(BASE_MODEL)
    old_weights = model.get_input_embeddings().weight.detach().clone()
    model.resize_token_embeddings(len(tokenizer), mean_resizing=False)
    model.tie_weights()

    embeddings = model.get_input_embeddings().weight
    new_ids = [tokenizer.convert_tokens_to_ids(term) for term in terms]
    if len(set(new_ids)) != NEW_TOKEN_COUNT:
        raise RuntimeError("ID-urile tokenilor noi nu sunt unice.")

    with torch.no_grad():
        for term, token_id in zip(terms, new_ids):
            source_ids = base_tokenizer.encode(term, add_special_tokens=False)
            if not source_ids:
                raise RuntimeError(f"Termenul {term!r} nu are tokeni sursă.")
            embeddings[token_id].copy_(old_weights[source_ids].mean(dim=0))

    for parameter in model.parameters():
        parameter.requires_grad = False
    embeddings.requires_grad = True

    gradient_mask = torch.zeros_like(embeddings)
    gradient_mask[new_ids] = 1.0
    embeddings.register_hook(lambda gradient: gradient * gradient_mask)

    model.to(device)
    frozen_old_rows = old_weights.to(device)
    optimizer = torch.optim.AdamW(
        [embeddings],
        lr=5e-4,
        betas=(0.9, 0.999),
        weight_decay=0.0,
    )

    examples = encode_training_examples(tokenizer)
    batch_size = 4
    accumulation_steps = 8
    epochs = 6
    history = []

    model.train()
    optimizer.zero_grad(set_to_none=True)
    for epoch in range(1, epochs + 1):
        random.Random(SEED + epoch).shuffle(examples)
        running_loss = 0.0
        batches = 0

        for start in range(0, len(examples), batch_size):
            batch = examples[start : start + batch_size]
            input_ids, attention_mask, labels = collate(
                batch, tokenizer.pad_token_id, device
            )
            output = model(
                input_ids=input_ids,
                attention_mask=attention_mask,
                labels=labels,
            )
            loss = output.loss / accumulation_steps
            loss.backward()
            running_loss += output.loss.item()
            batches += 1

            should_step = (
                batches % accumulation_steps == 0
                or start + batch_size >= len(examples)
            )
            if should_step:
                torch.nn.utils.clip_grad_norm_([embeddings], 1.0)
                optimizer.step()
                optimizer.zero_grad(set_to_none=True)
                with torch.no_grad():
                    embeddings[:old_vocab_size].copy_(frozen_old_rows)

        average = running_loss / batches
        history.append({"epoch": epoch, "loss": average})
        print(f"epoch={epoch} loss={average:.6f}")

    MODEL_DIR.mkdir(parents=True, exist_ok=True)
    model.save_pretrained(MODEL_DIR, safe_serialization=True)
    tokenizer.save_pretrained(MODEL_DIR)
    (REPORTS / "train_history.json").write_text(
        json.dumps(history, indent=2),
        encoding="utf-8",
    )


@torch.inference_mode()
def score(model, tokenizer, texts: list[str], device: torch.device) -> dict:
    model.eval()
    total_nats = 0.0
    total_bytes = 0
    total_tokens = 0

    for original in texts:
        text = original[:400]
        encoded = tokenizer(
            text,
            return_tensors="pt",
            add_special_tokens=False,
        )
        length = encoded["input_ids"].shape[1]
        if length < 2:
            continue
        if length > 512:
            raise RuntimeError("Fragmentul de evaluare depășește 512 tokeni.")

        input_ids = encoded["input_ids"].to(device)
        attention_mask = encoded["attention_mask"].to(device)
        output = model(
            input_ids=input_ids,
            attention_mask=attention_mask,
            labels=input_ids,
        )
        total_nats += output.loss.item() * (length - 1)
        total_bytes += len(text.encode("utf-8"))
        total_tokens += length

    return {
        "bits_per_byte": total_nats / math.log(2) / total_bytes,
        "tokens_per_1000_bytes": total_tokens * 1000 / total_bytes,
        "evaluated_bytes": total_bytes,
    }


def evaluate() -> None:
    seed_everything()
    if not MODEL_DIR.exists():
        raise FileNotFoundError("Rulează mai întâi: python pipeline.py train")

    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
    domain = read_jsonl(DATA / "domain_test.jsonl")
    general = read_jsonl(DATA / "general_test.jsonl")

    base_tokenizer = AutoTokenizer.from_pretrained(BASE_MODEL, use_fast=True)
    base_model = AutoModelForCausalLM.from_pretrained(BASE_MODEL).to(device)
    expanded_tokenizer = AutoTokenizer.from_pretrained(
        MODEL_DIR, use_fast=True
    )
    expanded_model = AutoModelForCausalLM.from_pretrained(MODEL_DIR).to(device)

    baseline_domain = score(base_model, base_tokenizer, domain, device)
    expanded_domain = score(
        expanded_model, expanded_tokenizer, domain, device
    )
    baseline_general = score(base_model, base_tokenizer, general, device)
    expanded_general = score(
        expanded_model, expanded_tokenizer, general, device
    )

    old_vocab_size = len(base_tokenizer)
    base_rows = base_model.get_input_embeddings().weight.detach().cpu()
    expanded_rows = (
        expanded_model.get_input_embeddings()
        .weight[:old_vocab_size]
        .detach()
        .cpu()
    )
    old_embedding_max_drift = (
        expanded_rows - base_rows
    ).abs().max().item()

    selected = json.loads(
        (ARTIFACTS / "added_tokens.json").read_text(encoding="utf-8")
    )
    roundtrip_ok = all(
        expanded_tokenizer.decode(
            expanded_tokenizer.encode(
                item["token"], add_special_tokens=False
            ),
            clean_up_tokenization_spaces=False,
        )
        == item["token"]
        for item in selected
    )

    compression_gain = 1.0 - (
        expanded_domain["tokens_per_1000_bytes"]
        / baseline_domain["tokens_per_1000_bytes"]
    )
    general_bpb_delta = (
        expanded_general["bits_per_byte"]
        / baseline_general["bits_per_byte"]
        - 1.0
    )
    domain_bpb_delta = (
        expanded_domain["bits_per_byte"]
        / baseline_domain["bits_per_byte"]
        - 1.0
    )

    accepted = (
        compression_gain >= 0.05
        and general_bpb_delta <= 0.02
        and domain_bpb_delta <= 0.02
        and old_embedding_max_drift == 0.0
        and roundtrip_ok
    )

    metrics = {
        "baseline": {
            "domain": baseline_domain,
            "general": baseline_general,
        },
        "expanded": {
            "domain": expanded_domain,
            "general": expanded_general,
        },
        "compression_gain": compression_gain,
        "domain_bpb_delta": domain_bpb_delta,
        "general_bpb_delta": general_bpb_delta,
        "old_embedding_max_drift": old_embedding_max_drift,
        "roundtrip_ok": roundtrip_ok,
        "accepted": accepted,
    }
    REPORTS.mkdir(parents=True, exist_ok=True)
    (REPORTS / "metrics.json").write_text(
        json.dumps(metrics, indent=2),
        encoding="utf-8",
    )

    table = (
        "| Model | Domain BPB | Domain tokeni/1.000 bytes | General BPB |n"
        "|---|---:|---:|---:|n"
        f"| GPT-2 baseline | {baseline_domain['bits_per_byte']:.6f} | "
        f"{baseline_domain['tokens_per_1000_bytes']:.2f} | "
        f"{baseline_general['bits_per_byte']:.6f} |n"
        f"| GPT-2 + 64 tokeni | {expanded_domain['bits_per_byte']:.6f} | "
        f"{expanded_domain['tokens_per_1000_bytes']:.2f} | "
        f"{expanded_general['bits_per_byte']:.6f} |n"
    )
    (REPORTS / "metrics.md").write_text(table, encoding="utf-8")
    print(table)
    print(
        "ACCEPTAT" if accepted else "RESPINS",
        json.dumps(
            {
                "compression_gain": compression_gain,
                "domain_bpb_delta": domain_bpb_delta,
                "general_bpb_delta": general_bpb_delta,
                "old_embedding_max_drift": old_embedding_max_drift,
                "roundtrip_ok": roundtrip_ok,
            },
            indent=2,
        ),
    )
    if not accepted:
        raise SystemExit(2)


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument(
        "command",
        choices=("prepare", "train", "evaluate"),
    )
    args = parser.parse_args()
    {"prepare": prepare, "train": train, "evaluate": evaluate}[args.command]()


if __name__ == "__main__":
    main()

Rulează materializarea:

python pipeline.py prepare

load_dataset() descarcă seturile în cache-ul Hugging Face, iar scriptul salvează exact fragmentele folosite în fișierele JSONL. Fingerprints-urile sunt păstrate pentru audit; dacă depozitul setului se schimbă ulterior, manifestul arată că nu mai lucrezi cu aceeași revizie materializată.

Outputul trebuie să conțină aceste numere:

"counts": {
  "domain_train.jsonl": 800,
  "domain_valid.jsonl": 100,
  "domain_test.jsonl": 100,
  "general_train.jsonl": 800,
  "general_test.jsonl": 500
},
"new_tokens": 64

old_piece_count indică în câte fragmente era împărțit termenul înainte, iar score favorizează cuvintele frecvente care eliberează cele mai multe poziții de context.

Punct de control: dacă nu apar exact 64 de tokeni, inspectează artifacts/dataset_manifest.json și versiunea setului; nu reduce pragul până nu confirmi că ai configurația pqa_labeled.

Pasul 2 — Inițializăm fiecare embedding din fragmentele pe care le înlocuiește

De ce: resize_token_embeddings() mărește matricea modelului, dar un rând nou nu cunoaște sensul termenului. Scriptul pornește embeddingul fiecărui token din media embeddings-urilor vechi care reprezentau același șir.

În același timp, sunt înghețați toți parametrii cu excepția matricei de embeddings. Un gradient mask permite actualizarea numai a celor 64 de rânduri noi. După fiecare pas, rândurile originale sunt copiate din snapshot, astfel încât nici weight decay, nici o schimbare accidentală a optimizatorului să nu le poată modifica.

Rulează:

python pipeline.py train

Outputul are câte o linie pentru fiecare epocă:

epoch=1 loss=<valoare_calculată>
epoch=2 loss=<valoare_calculată>
epoch=3 loss=<valoare_calculată>
epoch=4 loss=<valoare_calculată>
epoch=5 loss=<valoare_calculată>
epoch=6 loss=<valoare_calculată>

Valorile sunt măsurate local și nu trebuie copiate dintr-un articol. Ele pot diferi între CPU și GPU, între drivere și între platforme, chiar dacă seed-ul este fix; documentația PyTorch avertizează că reproductibilitatea absolută nu este garantată între versiuni și platforme diferite.

Amestecul de antrenare conține de patru ori splitul biomedical și o dată cele 800 de fragmente generale. Fragmentele generale nu actualizează embeddings-urile vechi, dar oferă exemple în care tokenii biomedicali noi nu trebuie prezentați ca următoarea predicție probabilă.

Batch-ul efectiv este 32: patru exemple fizice și opt pași de acumulare. weight_decay=0.0 evită modificarea rândurilor mascate, iar clip_grad_norm_() limitează norma gradientului noilor embeddings la 1.0.

La final trebuie să existe:

artifacts/expanded-gpt2/config.json
artifacts/expanded-gpt2/model.safetensors
artifacts/expanded-gpt2/tokenizer.json
reports/train_history.json

Punct de control: dacă procesul se oprește înainte de prima epocă, verifică mai întâi că artifacts/added_tokens.json și toate cele cinci fișiere JSONL există; reluarea începe de la inițializarea deterministă, nu dintr-un checkpoint parțial.

Pasul 3 — Comparăm tokenizarea și pierderea în unități compatibile

De ce: perplexitatea per token nu poate fi comparată direct între două tokenizatoare diferite. După extindere, numărul și semnificația tokenilor s-au schimbat. Proiectul folosește bits per byte, calculat din negative log-likelihood și numărul de bytes UTF-8 ai aceluiași text.

Rulează evaluarea:

python pipeline.py evaluate

Scriptul produce un tabel real în reports/metrics.md:

| Model | Domain BPB | Domain tokeni/1.000 bytes | General BPB |
|---|---:|---:|---:|
| GPT-2 baseline | valoare_măsurată | valoare_măsurată | valoare_măsurată |
| GPT-2 + 64 tokeni | valoare_măsurată | valoare_măsurată | valoare_măsurată |

Acceptarea cere simultan:

  • reducere de minimum 5% a tokenilor per 1.000 bytes pe PubMedQA;
  • creștere de maximum 2% a BPB pe testul biomedical;
  • creștere de maximum 2% a BPB pe WikiText-2;
  • drift maxim exact 0.0 pentru rândurile vechi;
  • encode/decode corect pentru toți cei 64 de tokeni adăugați.

Finalul comenzii trebuie să înceapă cu:

ACCEPTAT

Dacă apare RESPINS, procesul se încheie cu codul 2. Modelul rămâne disponibil pentru analiză, dar nu trece poarta de calitate.

Separarea train/test este esențială: termenii sunt selectați numai din cele 800 de exemple de train, iar pragurile sunt evaluate pe cele 100 de exemple PubMedQA păstrate. Splitul de validare rămâne disponibil pentru reglaje ulterioare, fără contaminarea testului.

Această evaluare măsoară compresia și stabilitatea modelării limbajului, nu corectitudinea medicală. Un BPB mai mic nu dovedește că răspunsurile generate sunt adevărate.

Pasul 4 — Încărcăm artefactul printr-un API care limitează cererea și generarea

De ce: salvarea nu este suficientă. Modelul și tokenizerul trebuie reîncărcate împreună, iar o cerere reală trebuie să demonstreze că dimensiunea vocabularului rămâne sincronizată.

Fișierul api.py:

from pathlib import Path

import torch
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
from transformers import AutoModelForCausalLM, AutoTokenizer

ROOT = Path(__file__).resolve().parent
MODEL_DIR = ROOT / "artifacts" / "expanded-gpt2"

if not MODEL_DIR.exists():
    raise RuntimeError("Lipsește artifacts/expanded-gpt2.")

DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
TOKENIZER = AutoTokenizer.from_pretrained(MODEL_DIR, use_fast=True)
MODEL = AutoModelForCausalLM.from_pretrained(MODEL_DIR).to(DEVICE)
MODEL.eval()

app = FastAPI(title="Expanded GPT-2 tokenizer", version="1.0.0")


class GenerationRequest(BaseModel):
    prompt: str = Field(min_length=1, max_length=1000)
    max_new_tokens: int = Field(default=32, ge=1, le=64)


class GenerationResponse(BaseModel):
    generated_text: str
    input_tokens: int
    vocabulary_size: int
    device: str
    notice: str


@app.get("/health")
def health() -> dict:
    return {
        "status": "ok",
        "model": str(MODEL_DIR),
        "vocabulary_size": len(TOKENIZER),
        "device": DEVICE.type,
    }


@app.post("/generate", response_model=GenerationResponse)
@torch.inference_mode()
def generate(request: GenerationRequest) -> GenerationResponse:
    encoded = TOKENIZER(
        request.prompt,
        return_tensors="pt",
        truncation=True,
        max_length=256,
    )
    input_tokens = encoded["input_ids"].shape[1]
    if input_tokens == 0:
        raise HTTPException(status_code=400, detail="Promptul este gol.")

    encoded = {name: value.to(DEVICE) for name, value in encoded.items()}
    output = MODEL.generate(
        **encoded,
        max_new_tokens=request.max_new_tokens,
        do_sample=False,
        pad_token_id=TOKENIZER.eos_token_id,
        eos_token_id=TOKENIZER.eos_token_id,
    )
    text = TOKENIZER.decode(
        output[0],
        skip_special_tokens=True,
        clean_up_tokenization_spaces=False,
    )
    return GenerationResponse(
        generated_text=text,
        input_tokens=input_tokens,
        vocabulary_size=len(TOKENIZER),
        device=DEVICE.type,
        notice="Demonstrație de cercetare; textul nu este recomandare medicală.",
    )

Pornește serviciul numai pe interfața locală:

uvicorn api:app --host 127.0.0.1 --port 8000

În alt terminal, verifică sănătatea și trimite o cerere:

curl --fail --silent http://127.0.0.1:8000/health
curl --fail --silent 
  -H 'Content-Type: application/json' 
  -d '{"prompt":"Mitochondrial dysfunction is associated with","max_new_tokens":24}' 
  http://127.0.0.1:8000/generate

Primul răspuns trebuie să conțină status: "ok", iar al doilea câmpurile generated_text, input_tokens, vocabulary_size, device și notice. Textul generat diferă în funcție de prompt și nu trebuie interpretat ca informație medicală validată.

do_sample=False elimină eșantionarea aleatorie din această verificare. Limitele Pydantic refuză prompturile de peste 1.000 de caractere și cererile de peste 64 de tokeni noi.

Punct de control: dacă dimensiunea vocabularului din /health nu coincide cu tokenizerul salvat, oprește API-ul și confirmă că config.json, model.safetensors și tokenizer.json provin din același director.

Testul de capăt leagă datele, modelul și serviciul

Execută întregul traseu într-un mediu nou:

rm -rf data artifacts reports
python pipeline.py prepare
python pipeline.py train
python pipeline.py evaluate
test -f artifacts/expanded-gpt2/model.safetensors
test -f artifacts/expanded-gpt2/tokenizer.json
test -f reports/metrics.json
grep -q '"accepted": true' reports/metrics.json
python -c "from transformers import AutoModelForCausalLM, AutoTokenizer; p='artifacts/expanded-gpt2'; t=AutoTokenizer.from_pretrained(p); m=AutoModelForCausalLM.from_pretrained(p); assert len(t) == m.get_input_embeddings().num_embeddings; print('MODEL_OK', len(t))"

Rezultatul final acceptat este:

ACCEPTAT
MODEL_OK <dimensiunea_vocabularului>

Numărul exact este vocabularul checkpointului original plus 64. El este citit din artefact, nu introdus manual în test.

Fișierul metrics.json reprezintă dovada principală. metrics.md este doar forma lizibilă a acelorași rezultate. Dacă proiectul va fi mutat ulterior la Transformers 5, trebuie refăcut auditul apelurilor și al artefactelor; ghidul de migrare la Transformers v5 tratează separat această schimbare.

Cinci defecțiuni care indică unde s-a rupt extinderea

PubMedQA pqa_labeled are ... exemple, nu 1000.

Cauza este schimbarea configurației, a reviziei descărcate sau încărcarea altui subset. Nu continua cu alte praguri.

Șterge numai cache-ul builderului și repetă descărcarea:

rm -rf ~/.cache/huggingface/datasets/qiaojin___pub_med_qa
python pipeline.py prepare

Compară noul fingerprint cu artifacts/dataset_manifest.json dacă analizezi o rulare anterioară.

Au fost găsiți numai ... candidați eligibili.

Textul materializat nu mai produce 64 de cuvinte care respectă frecvența și fragmentarea cerute. Confirmă configurația și numărul spliturilor:

python -c "import json; print(json.load(open('artifacts/dataset_manifest.json', encoding='utf-8')))"
wc -l data/domain_train.jsonl

Fișierul trebuie să aibă 800 de linii. Nu adăuga termeni manual doar pentru a ajunge la 64.

IndexError: index out of range in self

Modelul a fost încărcat cu vocabularul original, dar inputul a fost produs de tokenizerul extins. Refă modelul și tokenizerul împreună:

rm -rf artifacts/expanded-gpt2 reports/train_history.json
python pipeline.py train
python pipeline.py evaluate

Aplicația trebuie să încarce ambele obiecte din artifacts/expanded-gpt2, nu tokenizerul de acolo și modelul direct din Hub.

RuntimeError: Deterministic behavior was enabled ...

O operație de pe combinația locală PyTorch–CUDA nu oferă implementare deterministă. Nu dezactiva silențios verificarea dacă urmărești comparabilitatea.

Rulează experimentul pe CPU:

CUDA_VISIBLE_DEVICES="" python pipeline.py train
CUDA_VISIBLE_DEVICES="" python pipeline.py evaluate

Procesul va fi mai lent, dar păstrează aceeași politică deterministă.

Evaluarea se termină cu RESPINS

Deschide valorile măsurate:

cat reports/metrics.json
cat reports/metrics.md

old_embedding_max_drift diferit de zero indică o eroare în mascarea sau restaurarea rândurilor vechi. roundtrip_ok: false indică un token care nu supraviețuiește ciclului encode/decode. Un general_bpb_delta peste 0.02 arată degradare pe WikiText-2. În toate cele trei cazuri, artefactul nu trebuie promovat; codul 2 este o oprire intenționată, nu o eroare de infrastructură.

Ștergem extensia fără să atingem cache-ul comun al modelelor

Oprește Uvicorn cu Ctrl+C, apoi elimină mediul și artefactele proiectului:

cd tokenizer-domain-lab
rm -rf data artifacts reports .venv

Această comandă nu șterge cache-ul Hugging Face din directorul utilizatorului. Dacă vrei să eliberezi și acel spațiu, inspectează-l înainte:

du -sh ~/.cache/huggingface

Nu elimina cache-ul pe o mașină partajată fără să verifici dacă alte proiecte folosesc aceleași modele și seturi de date.

Modelul original nu a fost suprascris: extensia se află exclusiv în artifacts/expanded-gpt2. Revenirea aplicației la baseline înseamnă încărcarea explicită a openai-community/gpt2.

Lista de acceptare înainte ca tokenizerul să intre într-un pipeline mai mare

  • ☐ dataset_manifest.json arată 800/100/100 exemple PubMedQA.
  • ☐ Lista din added_tokens.json conține exact 64 de intrări.
  • ☐ Fiecare token nou avea minimum două fragmente în GPT-2 original.
  • ☐ Modelul și tokenizerul raportează aceeași dimensiune a vocabularului.
  • ☐ compression_gain este cel puțin 0.05.
  • ☐ domain_bpb_delta nu depășește 0.02.
  • ☐ general_bpb_delta nu depășește 0.02.
  • ☐ old_embedding_max_drift este exact 0.0.
  • ☐ roundtrip_ok și accepted sunt true.
  • ☐ API-ul răspunde local și limitează generarea la 64 de tokeni.

Următorul experiment logic este o ablație cu 32, 64 și 128 de tokeni, fiecare selectat numai pe train și comparat pe același test. A doua direcție este măsurarea latenței și a memoriei la secvențe cu același număr de bytes. Abia după aceste două verificări are sens combinarea tokenizerului extins cu un antrenament de domeniu asupra mai multor parametri.

Întrebări frecvente

De ce nu comparăm direct perplexitatea celor două modele?

Tokenizatoarele produc secvențe de lungimi diferite, deci perplexitatea per token nu mai are aceeași unitate. Proiectul folosește bits per byte pe exact aceleași texte.

Sunt antrenate toate greutățile GPT-2?

Nu. Gradientul ajunge numai la cele 64 de rânduri noi, iar rândurile originale ale matricei sunt restaurate după fiecare pas al optimizatorului.

Tokenii biomedicali fac modelul sigur pentru recomandări medicale?

Nu. Experimentul măsoară compresia și modelarea limbajului, nu adevărul clinic; API-ul este o demonstrație de cercetare.

Poate fi rulat proiectul fără placă video?

Da. Scriptul selectează automat CPU-ul, însă antrenarea durează mai mult. Pentru reproducibilitate, evaluarea trebuie făcută cu aceleași versiuni și aceeași platformă.

Surse

Facebook
X
WhatsApp
Extinderea tokenizerului unui LLM: cum adaugi tokeni de domeniu fără să strici modelul

Te-ar putea interesa si: