"""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