diffusion_baseline / evaluation /generative_perplexity.py
jyliuAI's picture
Add files using upload-large-folder tool
784f572 verified
Raw History Blame Contribute Delete
12.2 kB
"""Corpus-token-weighted generative perplexity with an offline HF scorer."""
from __future__ import annotations
from dataclasses import dataclass
import json
import math
import os
import re
from pathlib import Path
from typing import Iterable, Sequence
import yaml
@dataclass(frozen=True)
class PPLResult:
perplexity: float
total_nll: float
valid_token_count: int
sample_count: int = 0
batch_size: int = 0
context_length: int = 1024
truncation: str = "right"
model_id: str = "gpt2-large"
model_revision: str = ""
tokenizer_id: str = "gpt2"
tokenizer_revision: str = ""
aggregation: str = "sum_nll_over_sum_valid_next_tokens"
@dataclass(frozen=True)
class GPT2Assets:
model_id: str
model_revision: str
model_path: Path
tokenizer_id: str
tokenizer_revision: str
tokenizer_path: Path
def _unique_object(pairs: list[tuple[str, object]]) -> dict[str, object]:
result: dict[str, object] = {}
for key, value in pairs:
if key in result:
raise ValueError(f"duplicate JSON key {key!r}")
result[key] = value
return result
def _safe_snapshot(root: Path, relative: object, revision: str, name: str) -> Path:
if type(relative) is not str or not relative:
raise ValueError(f"{name} snapshot_path is invalid")
relative_path = Path(relative)
if relative_path.is_absolute() or ".." in relative_path.parts:
raise ValueError(f"{name} snapshot_path must be repository-relative")
raw_candidate = root / relative_path
cursor = root
for part in relative_path.parts:
cursor = cursor / part
if cursor.is_symlink():
raise ValueError(f"{name} snapshot traverses a symlink: {cursor}")
candidate = raw_candidate.resolve()
try:
candidate.relative_to(root)
except ValueError as error:
raise ValueError(f"{name} snapshot escapes repository root") from error
if not candidate.is_dir():
raise FileNotFoundError(f"{name} local snapshot is missing: {candidate}")
if candidate.name != revision:
raise ValueError(f"{name} snapshot directory does not match pinned revision")
return candidate
def resolve_gpt2_assets(root: Path) -> GPT2Assets:
"""Resolve GPT-2 tokenizer/model snapshots without any Hub fallback."""
root = root.resolve()
configuration_path = root / "artifacts" / "data.yaml"
downloads_path = root / "data" / "manifests" / "downloads.json"
configuration = yaml.safe_load(configuration_path.read_text(encoding="utf-8"))
downloads = json.loads(
downloads_path.read_text(encoding="utf-8"), object_pairs_hook=_unique_object
)
if not isinstance(downloads, dict) or downloads.get("schema_version") != 1:
raise ValueError("downloads manifest schema_version must be 1")
try:
pinned = configuration["models"]
downloaded = downloads["models"]
except (KeyError, TypeError) as error:
raise ValueError("data/download manifests do not contain model records") from error
resolved: dict[str, tuple[str, Path]] = {}
for name in ("gpt2-large", "gpt2"):
try:
revision = pinned[name]
record = downloaded[name]
except (KeyError, TypeError) as error:
raise ValueError(f"missing pinned {name} snapshot record") from error
if type(revision) is not str or re.fullmatch(r"[0-9a-f]{40}", revision) is None:
raise ValueError(
f"{name} pinned revision must be 40 lowercase hexadecimal characters"
)
if not isinstance(record, dict):
raise ValueError(f"{name} download record is invalid")
if record.get("repo_id") != name:
raise ValueError(f"{name} repo_id does not match pinned model")
if record.get("revision") != revision:
raise ValueError(f"{name} revision does not match pinned configuration")
resolved[name] = (
revision,
_safe_snapshot(root, record.get("snapshot_path"), revision, name),
)
return GPT2Assets(
model_id="gpt2-large",
model_revision=resolved["gpt2-large"][0],
model_path=resolved["gpt2-large"][1],
tokenizer_id="gpt2",
tokenizer_revision=resolved["gpt2"][0],
tokenizer_path=resolved["gpt2"][1],
)
def load_offline_gpt2_large(
assets: GPT2Assets, *, device: str = "cuda"
) -> tuple[object, object]:
"""Load only the already-resolved snapshots, with network access disabled."""
os.environ["HF_HUB_OFFLINE"] = "1"
os.environ["TRANSFORMERS_OFFLINE"] = "1"
from transformers import AutoModelForCausalLM, AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained(
str(assets.tokenizer_path), local_files_only=True
)
tokenizer.padding_side = "right"
tokenizer.truncation_side = "right"
if tokenizer.pad_token_id is None:
if tokenizer.eos_token_id is None:
raise ValueError("GPT-2 tokenizer has neither padding nor EOS token")
tokenizer.pad_token = tokenizer.eos_token
model = AutoModelForCausalLM.from_pretrained(
str(assets.model_path), local_files_only=True
)
model = model.to(device).eval()
return model, tokenizer
def aggregate_nll(parts: Iterable[tuple[float, int]]) -> PPLResult:
"""Exponentiate once after summing finite NLL/token-count parts."""
total_nll = 0.0
total_tokens = 0
found = False
for nll, count in parts:
found = True
if type(count) is not int or count <= 0:
raise ValueError("valid next-token count must be a positive integer")
if type(nll) not in (int, float) or not math.isfinite(float(nll)) or nll < 0:
raise ValueError("NLL must be finite and non-negative")
total_nll += float(nll)
total_tokens += count
if not found or total_tokens == 0:
raise ValueError("no valid next-token targets were evaluated")
if not math.isfinite(total_nll):
raise ValueError("total NLL is not finite")
try:
perplexity = math.exp(total_nll / total_tokens)
except OverflowError as error:
raise ValueError("perplexity is not finite") from error
if not math.isfinite(perplexity):
raise ValueError("perplexity is not finite")
return PPLResult(
perplexity=perplexity,
total_nll=total_nll,
valid_token_count=total_tokens,
)
class TorchCausalLMScorer:
"""Efficiently reduce causal-LM logits without transferring them to the CPU."""
def __init__(self, model: object, *, device: str | None = None) -> None:
self.model = model
self.device = device
def eval(self) -> "TorchCausalLMScorer":
self.model.eval() # type: ignore[attr-defined]
return self
def score_batch(
self, input_ids: Sequence[Sequence[int]], attention_mask: Sequence[Sequence[int]]
) -> tuple[float, int]:
import torch
import torch.nn.functional as functional
device = self.device
if device is None:
try:
device = str(next(self.model.parameters()).device) # type: ignore[attr-defined]
except StopIteration:
device = "cpu"
ids = torch.as_tensor(input_ids, dtype=torch.long, device=device)
mask = torch.as_tensor(attention_mask, dtype=torch.bool, device=device)
with torch.inference_mode():
outputs = self.model(input_ids=ids, attention_mask=mask) # type: ignore[operator]
logits = outputs.logits if hasattr(outputs, "logits") else outputs[0]
shifted_logits = logits[:, :-1, :].contiguous()
shifted_labels = ids[:, 1:].clone()
valid = mask[:, 1:]
shifted_labels.masked_fill_(~valid, -100)
nll = functional.cross_entropy(
shifted_logits.reshape(-1, shifted_logits.size(-1)),
shifted_labels.reshape(-1),
ignore_index=-100,
reduction="sum",
)
return float(nll.item()), int(valid.sum().item())
def _rows(value: object, key: str) -> list[list[int]]:
try:
rows = value[key] # type: ignore[index]
except (KeyError, TypeError) as error:
raise ValueError(f"tokenizer result is missing {key}") from error
if not isinstance(rows, (list, tuple)):
raise ValueError(f"tokenizer {key} must be a batch of rows")
normalized: list[list[int]] = []
for index, row in enumerate(rows):
if not isinstance(row, (list, tuple)) or not all(type(item) is int for item in row):
raise ValueError(f"tokenizer {key} row {index} is invalid")
normalized.append(list(row))
return normalized
def compute_gen_ppl(
texts: Sequence[str],
model: object,
tokenizer: object,
*,
batch_size: int = 8,
max_length: int = 1024,
model_revision: str = "",
tokenizer_revision: str = "",
device: str | None = None,
) -> PPLResult:
"""Retokenize texts and compute corpus-level GPT-2 causal perplexity."""
if not texts or any(type(text) is not str or not text.strip() for text in texts):
raise ValueError("texts must contain non-empty strings")
if type(batch_size) is not int or batch_size <= 0:
raise ValueError("batch_size must be positive")
if type(max_length) is not int or max_length < 2:
raise ValueError("max_length must be at least two")
if max_length > 1024:
raise ValueError("GPT-2 context length must not exceed 1024")
scorer = (
model
if callable(getattr(model, "score_batch", None))
else TorchCausalLMScorer(model, device=device)
)
scorer.eval() # type: ignore[attr-defined]
parts: list[tuple[float, int]] = []
for start in range(0, len(texts), batch_size):
batch = list(texts[start : start + batch_size])
encoded = tokenizer( # type: ignore[operator]
batch,
add_special_tokens=True,
return_attention_mask=True,
return_token_type_ids=False,
truncation=True,
padding=True,
max_length=max_length,
)
input_ids = _rows(encoded, "input_ids")
attention_mask = _rows(encoded, "attention_mask")
if len(input_ids) != len(batch) or len(attention_mask) != len(batch):
raise ValueError("tokenizer silently changed the batch size")
if any(len(ids) != len(mask) for ids, mask in zip(input_ids, attention_mask, strict=True)):
raise ValueError("tokenizer input and attention-mask shapes differ")
if any(value not in (0, 1) for row in attention_mask for value in row):
raise ValueError("tokenizer attention mask must contain only zero or one")
expected_count = 0
for batch_index, row in enumerate(attention_mask):
valid_length = sum(row)
if row != [1] * valid_length + [0] * (len(row) - valid_length):
raise ValueError("tokenizer attention mask must be right-padded")
if valid_length < 2:
raise ValueError(
f"sample {start + batch_index} has no valid next-token target"
)
expected_count += valid_length - 1
nll, count = scorer.score_batch(input_ids, attention_mask) # type: ignore[attr-defined]
if count != expected_count:
raise ValueError(
"scorer valid-token count differs from attention mask: "
f"expected {expected_count}, reported {count}"
)
if count:
parts.append((nll, count))
elif nll != 0:
raise ValueError("scorer returned NLL without valid next-token targets")
aggregated = aggregate_nll(parts)
return PPLResult(
perplexity=aggregated.perplexity,
total_nll=aggregated.total_nll,
valid_token_count=aggregated.valid_token_count,
sample_count=len(texts),
batch_size=batch_size,
context_length=max_length,
model_revision=model_revision,
tokenizer_revision=tokenizer_revision,
)