Files
OBLITERATUS/obliteratus/evaluation/evaluator.py
T

211 lines
6.9 KiB
Python

"""Evaluator: runs a model on a dataset and computes metrics."""
from __future__ import annotations
import torch
from tqdm import tqdm
from obliteratus.models.loader import ModelHandle
class Evaluator:
"""Evaluate a model handle on a dataset, returning metric results.
Supports two modes:
- **perplexity** (default for causal_lm): feeds tokenized text and computes PPL.
- **classification**: runs forward pass, takes argmax, computes accuracy/F1.
"""
def __init__(
self,
handle: ModelHandle,
dataset,
metrics: list[str] | None = None,
batch_size: int = 8,
max_length: int = 512,
max_samples: int | None = None,
text_column: str = "text",
label_column: str = "label",
):
self.handle = handle
self.dataset = dataset
self.metrics = metrics or (
["perplexity"] if handle.task == "causal_lm" else ["accuracy", "f1"]
)
self.batch_size = batch_size
self.max_length = max_length
self.max_samples = max_samples
self.text_column = text_column
self.label_column = label_column
@torch.no_grad()
def evaluate(self) -> dict[str, float]:
"""Run evaluation and return a dict of metric_name -> score."""
if self.handle.task == "causal_lm":
return self._evaluate_causal_lm()
elif self.handle.task == "classification":
return self._evaluate_classification()
else:
raise ValueError(f"Unsupported task: {self.handle.task}")
def _evaluate_causal_lm(self) -> dict[str, float]:
model = self.handle.model
tokenizer = self.handle.tokenizer
device = next(model.parameters()).device
ds = self.dataset
if self.max_samples is not None and self.max_samples <= 0:
raise ValueError("max_samples must be a positive integer or None.")
# WikiText and similar corpora contain empty separator rows. Walk the
# map-style dataset row by row so a bounded evaluation stops as soon as
# it has enough usable texts instead of materializing the whole column.
valid_indices = []
for index in range(len(ds)):
text = ds[index][self.text_column]
if not isinstance(text, str) or not text.strip():
continue
valid_indices.append(index)
if self.max_samples is not None and len(valid_indices) >= self.max_samples:
break
if not valid_indices:
raise ValueError(
f"Dataset contains no non-empty text in column "
f"{self.text_column!r}."
)
ds = ds.select(valid_indices)
total_loss = 0.0
total_tokens = 0
skipped_batches = 0
for i in tqdm(
range(0, len(ds), self.batch_size),
desc="Evaluating PPL",
):
batch_texts = ds[i : i + self.batch_size][self.text_column]
# Defensive filtering in case a custom dataset returns unexpected
# values after selection or transformation.
batch_texts = [
text
for text in batch_texts
if isinstance(text, str) and text.strip()
]
if not batch_texts:
skipped_batches += 1
continue
encodings = tokenizer(
batch_texts,
return_tensors="pt",
truncation=True,
max_length=self.max_length,
padding=True,
).to(device)
input_ids = encodings["input_ids"]
attention_mask = encodings["attention_mask"]
# A causal-LM loss requires at least one prediction target after
# shifting. A sequence length below two cannot contribute.
if (
input_ids.ndim != 2
or input_ids.numel() == 0
or input_ids.shape[1] < 2
):
skipped_batches += 1
continue
labels = input_ids.clone()
# Hugging Face causal-LM losses ignore labels set to -100.
# This prevents padded positions from affecting perplexity.
labels[attention_mask == 0] = -100
num_tokens = labels[:, 1:].ne(-100).sum().item()
if num_tokens <= 0:
skipped_batches += 1
continue
outputs = model(
input_ids=input_ids,
attention_mask=attention_mask,
labels=labels,
)
if outputs.loss is None:
raise RuntimeError(
"The causal language model returned no loss value."
)
if not torch.isfinite(outputs.loss):
raise RuntimeError(
f"Non-finite evaluation loss encountered at batch "
f"offset {i}: {outputs.loss.item()}"
)
total_loss += outputs.loss.item() * num_tokens
total_tokens += num_tokens
if total_tokens <= 0:
raise RuntimeError(
"Perplexity evaluation produced zero valid prediction tokens. "
"Check the dataset text column and tokenizer configuration."
)
if skipped_batches:
tqdm.write(
f"Skipped {skipped_batches} empty or too-short batch(es)."
)
import math
avg_loss = total_loss / total_tokens
return {"perplexity": math.exp(avg_loss)}
def _evaluate_classification(self) -> dict[str, float]:
from obliteratus.evaluation.metrics import accuracy as acc_fn
from obliteratus.evaluation.metrics import f1_score_metric as f1_fn
model = self.handle.model
tokenizer = self.handle.tokenizer
device = next(model.parameters()).device
ds = self.dataset
if self.max_samples is not None:
ds = ds.select(range(min(self.max_samples, len(ds))))
all_preds = []
all_labels = []
for i in tqdm(range(0, len(ds), self.batch_size), desc="Evaluating"):
batch = ds[i : i + self.batch_size]
texts = batch[self.text_column]
labels = batch[self.label_column]
encodings = tokenizer(
texts,
return_tensors="pt",
truncation=True,
max_length=self.max_length,
padding=True,
).to(device)
outputs = model(**encodings)
preds = outputs.logits.argmax(dim=-1).cpu().tolist()
all_preds.extend(preds)
all_labels.extend(labels)
results = {}
if "accuracy" in self.metrics:
results["accuracy"] = acc_fn(all_preds, all_labels)
if "f1" in self.metrics:
results["f1"] = f1_fn(all_preds, all_labels)
return results