AstrAI/scripts/eval/evaluate_ppl.py

446 lines
15 KiB
Python

import argparse
import glob
import json
import os
import statistics
from typing import Dict, List, Optional, Tuple
import torch
import torch.nn.functional as F
import tqdm
from astrai.model import AutoModel
from astrai.tokenize import AutoTokenizer
def _collect_input_files(input_path: str) -> List[str]:
"""Resolve *input_path* to a list of JSONL/JSON files."""
if os.path.isdir(input_path):
files = []
for ext in ("*.jsonl", "*.json"):
files.extend(
sorted(glob.glob(os.path.join(input_path, "**", ext), recursive=True))
)
return files
return sorted(glob.glob(input_path))
def _load_items(filepath: str) -> List[dict]:
"""Load JSONL or JSON (array / single dict) into a list of dicts."""
with open(filepath, "r", encoding="utf-8") as f:
if filepath.lower().endswith(".json"):
data = json.load(f)
if isinstance(data, dict):
return [data]
return data
return [json.loads(line) for line in f if line.strip()]
def _encode_batch(
tokenizer: AutoTokenizer, texts: List[str], max_length: int
) -> Tuple[List[List[int]], List[List[int]]]:
"""Encode *texts* and return (token_ids, attention_masks).
Each sequence is left-aligned and padded to the batch max length.
"""
encoded = [tokenizer.encode(t)[:max_length] for t in texts]
if not encoded:
return [], []
max_len = max(len(seq) for seq in encoded)
padded_ids = []
masks = []
for seq in encoded:
pad_len = max_len - len(seq)
padded_ids.append(seq + [tokenizer.pad_id] * pad_len)
masks.append([1] * len(seq) + [0] * pad_len)
return padded_ids, masks
def _compute_batch(
model,
input_ids: torch.Tensor,
attention_mask: torch.Tensor,
) -> Tuple[torch.Tensor, torch.Tensor]:
"""Forward pass and return (log_probs, valid_mask) of shape [B, S-1].
log_probs[i, j] = log P(token j+1 | tokens 0..j)
"""
output = model(input_ids, input_mask=attention_mask)
logits = output["logits"][:, :-1, :] # [B, S-1, V]
targets = input_ids[:, 1:] # [B, S-1]
valid = attention_mask[:, 1:].float() # [B, S-1]
log_probs = F.log_softmax(logits.float(), dim=-1) # [B, S-1, V]
token_log_probs = log_probs.gather(2, targets.unsqueeze(-1)).squeeze(-1) # [B, S-1]
return token_log_probs, valid
def _token_type(token_id: int, stop_ids: frozenset, decode_fn) -> str:
"""Classify a token into a coarse type for analysis.
*stop_ids* is a pre-built set of special token IDs.
*decode_fn* is ``tokenizer.decode`` (or a wrapper) for single-token
decoding.
"""
if token_id in stop_ids:
return "special"
decoded = decode_fn([token_id], skip_special_tokens=True)
if any("\u4e00" <= ch <= "\u9fff" for ch in decoded):
return "cjk"
if any(ord(ch) > 127 for ch in decoded):
return "non_ascii"
return "ascii"
def _percentiles(values: List[float]) -> Dict[str, float]:
"""Compute common percentiles from a list of floats.
Uses linear interpolation between closest ranks (same convention
as NumPy's default).
"""
if not values:
return {}
sorted_vals = sorted(values)
n = len(sorted_vals)
def _pct(p: float) -> float:
if n == 1:
return sorted_vals[0]
k = p * (n - 1)
f = int(k)
c = min(f + 1, n - 1)
return sorted_vals[f] + (sorted_vals[c] - sorted_vals[f]) * (k - f)
return {
"p50": _pct(0.50),
"p90": _pct(0.90),
"p95": _pct(0.95),
"p99": _pct(0.99),
}
class LossAccumulator:
"""Accumulate per-token losses with optional streaming mode.
When *stream* is True (token_level=False), losses are not kept
in memory individually — only a running sum/count and a histogram
(for approximate percentiles) are maintained. When *stream* is
False, all losses are retained for exact statistics and per-record
output.
"""
_HIST_BINS = 1000
_HIST_MAX = 20.0 # clamp losses above this for histogram
def __init__(self, stream: bool):
self.stream = stream
self.losses: List[float] = [] if not stream else []
self.total: float = 0.0
self.count: int = 0
self.hist = torch.zeros(self._HIST_BINS, dtype=torch.long)
# per-type losses (only populated when not streaming)
self.by_type: Dict[str, List[float]] = {}
self.type_total: Dict[str, float] = {}
self.type_count: Dict[str, int] = {}
def add(self, losses: List[float]):
self.total += sum(losses)
self.count += len(losses)
if self.stream:
clamped = [min(max(l, 0.0), self._HIST_MAX) for l in losses]
idx = torch.tensor(clamped) / self._HIST_MAX * (self._HIST_BINS - 1)
self.hist += torch.bincount(
idx.long().clamp(0, self._HIST_BINS - 1),
minlength=self._HIST_BINS,
)
else:
self.losses.extend(losses)
def add_typed(self, ttype: str, losses: List[float]):
if not self.stream:
self.by_type.setdefault(ttype, []).extend(losses)
self.type_total[ttype] = self.type_total.get(ttype, 0.0) + sum(losses)
self.type_count[ttype] = self.type_count.get(ttype, 0) + len(losses)
def stats(self) -> Dict:
result: Dict = {}
if self.count == 0:
return result
mean_loss = self.total / self.count
result["overall"] = {
"num_tokens": self.count,
"mean_loss": mean_loss,
"ppl": float(torch.exp(torch.tensor(mean_loss))),
}
if self.stream:
result["overall"].update(self._hist_percentiles())
else:
result["overall"]["median_loss"] = statistics.median(self.losses)
result["overall"].update(_percentiles(self.losses))
if self.type_count:
result["by_token_type"] = {}
for ttype in sorted(self.type_count.keys()):
cnt = self.type_count[ttype]
tmean = self.type_total[ttype] / cnt
entry: Dict = {
"num_tokens": cnt,
"mean_loss": tmean,
"ppl": float(torch.exp(torch.tensor(tmean))),
}
if not self.stream and ttype in self.by_type:
entry["median_loss"] = statistics.median(self.by_type[ttype])
entry.update(_percentiles(self.by_type[ttype]))
result["by_token_type"][ttype] = entry
return result
def _hist_percentiles(self) -> Dict[str, float]:
"""Approximate percentiles from the histogram."""
total = self.hist.sum().item()
if total == 0:
return {}
cum = torch.cumsum(self.hist.float(), dim=0)
result = {}
for label, p in [("p50", 0.5), ("p90", 0.9), ("p95", 0.95), ("p99", 0.99)]:
target = p * total
idx = int(torch.searchsorted(cum, target).item())
idx = min(idx, self._HIST_BINS - 1)
result[label] = (idx + 0.5) / self._HIST_BINS * self._HIST_MAX
return result
def process_file(
model,
tokenizer: AutoTokenizer,
items: List[dict],
text_key: str,
batch_size: int,
max_length: int,
token_level: bool,
max_samples: Optional[int],
output_file: Optional[str],
label: str,
) -> Dict:
"""Evaluate a single dataset (list of items), return summary stats.
If *token_level* is True and *output_file* is set, per-record token_ids
and log_probs are written as JSONL alongside the summary.
"""
if max_samples and len(items) > max_samples:
import random
items = random.sample(items, max_samples)
texts = [item[text_key] for item in items if text_key in item]
print(f" [{label}] {len(texts)} samples, text_key='{text_key}'")
acc = LossAccumulator(stream=not token_level)
per_sample: List[dict] = []
if token_level:
stop_ids = frozenset(tokenizer.stop_ids)
decode_fn = tokenizer.decode
num_batches = (len(texts) + batch_size - 1) // batch_size
for i in tqdm.tqdm(
range(0, len(texts), batch_size),
total=num_batches,
desc=f" {label}",
leave=False,
):
batch_texts = texts[i : i + batch_size]
padded_ids, masks = _encode_batch(tokenizer, batch_texts, max_length)
input_ids = torch.tensor(padded_ids, device="cuda", dtype=torch.long)
attention_mask = torch.tensor(masks, device="cuda", dtype=torch.bool)
token_log_probs, valid = _compute_batch(model, input_ids, attention_mask)
for b in range(len(batch_texts)):
seq_len = int(valid[b].sum().item())
lps = token_log_probs[b, :seq_len].tolist()
losses = [-lp for lp in lps]
acc.add(losses)
if token_level:
# log_probs correspond to positions 1..seq_len (predicted
# from position 0..seq_len-1), so token_ids must skip BOS
# at position 0 to stay aligned with log_probs.
ids = padded_ids[b][1 : seq_len + 1]
per_sample.append(
{
"text": batch_texts[b][:200],
"token_ids": ids,
"log_probs": [round(lp, 4) for lp in lps],
"ppl": float(torch.exp(torch.tensor(statistics.mean(losses))))
if losses
else None,
}
)
typed_losses: Dict[str, List[float]] = {}
for tid, loss in zip(ids, losses):
ttype = _token_type(tid, stop_ids, decode_fn)
typed_losses.setdefault(ttype, []).append(loss)
for ttype, tl in typed_losses.items():
acc.add_typed(ttype, tl)
stats = acc.stats()
if token_level and output_file:
with open(output_file, "w", encoding="utf-8") as f:
for item in per_sample:
f.write(json.dumps(item, ensure_ascii=False) + "\n")
return stats
def print_stats(label: str, stats: Dict):
"""Pretty-print summary statistics."""
print(f"\n{'=' * 60}")
print(f" {label}")
print(f"{'=' * 60}")
ov = stats.get("overall", {})
if ov:
print(f" tokens: {ov['num_tokens']:,}")
print(f" mean loss: {ov['mean_loss']:.4f}")
if "median_loss" in ov:
print(f" median loss: {ov['median_loss']:.4f}")
print(f" ppl: {ov['ppl']:.2f}")
if "p50" in ov:
print(
f" p50/p90/p95/p99: "
f"{ov['p50']:.2f} / {ov['p90']:.2f} / {ov['p95']:.2f} / {ov['p99']:.2f}"
)
by_type = stats.get("by_token_type", {})
if by_type:
print(f"\n by token type:")
print(f" {'type':<12} {'count':>8} {'mean_loss':>10} {'ppl':>8}")
print(f" {'-' * 12} {'-' * 8} {'-' * 10} {'-' * 8}")
for ttype, s in by_type.items():
print(
f" {ttype:<12} {s['num_tokens']:>8,} "
f"{s['mean_loss']:>10.4f} {s['ppl']:>8.2f}"
)
def main(
param_path: str,
input_path: str,
output_dir: str,
text_key: str,
batch_size: int,
max_length: int,
token_level: bool,
max_samples: Optional[int],
):
print(f"Loading model from {param_path} ...")
model = AutoModel.from_pretrained(param_path)
tokenizer = AutoTokenizer.from_pretrained(param_path)
model.to(device="cuda", dtype=torch.bfloat16)
model.eval()
input_files = _collect_input_files(input_path)
if not input_files:
print(f"No input files found at {input_path}")
return
print(f"Found {len(input_files)} file(s) to evaluate")
os.makedirs(output_dir, exist_ok=True)
all_stats = {}
for filepath in input_files:
label = os.path.splitext(os.path.basename(filepath))[0]
items = _load_items(filepath)
if not items:
print(f" [{label}] empty, skipping")
continue
token_output = (
os.path.join(output_dir, f"{label}_tokens.jsonl") if token_level else None
)
stats = process_file(
model=model,
tokenizer=tokenizer,
items=items,
text_key=text_key,
batch_size=batch_size,
max_length=max_length,
token_level=token_level,
max_samples=max_samples,
output_file=token_output,
label=label,
)
all_stats[label] = stats
print_stats(label, stats)
if token_output:
print(f" token-level output: {token_output}")
summary_path = os.path.join(output_dir, "summary.json")
with open(summary_path, "w", encoding="utf-8") as f:
json.dump(all_stats, f, ensure_ascii=False, indent=2)
print(f"\nSummary saved to {summary_path}")
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Perplexity and token-level loss evaluation on JSONL/JSON data."
)
parser.add_argument(
"--param_path", type=str, required=True, help="Path to the model directory."
)
parser.add_argument(
"--input_path",
type=str,
required=True,
help="Path to input file, glob pattern, or directory.",
)
parser.add_argument(
"--output_dir",
type=str,
required=True,
help="Directory for output files (summary.json + per-file token JSONL).",
)
parser.add_argument(
"--text_key",
type=str,
default="text",
help="Key for the text field in the input data.",
)
parser.add_argument(
"--batch_size", type=int, default=4, help="Batch size for evaluation."
)
parser.add_argument(
"--max_length",
type=int,
default=2048,
help="Maximum sequence length (tokens). Longer sequences are truncated.",
)
parser.add_argument(
"--token_level",
action="store_true",
help="Store per-token log_probs and token type analysis. "
"Default: off (only aggregate stats).",
)
parser.add_argument(
"--max_samples",
type=int,
default=None,
help="Maximum number of samples per file (random subsample). Default: all.",
)
args = parser.parse_args()
with torch.inference_mode():
main(
param_path=args.param_path,
input_path=args.input_path,
output_dir=args.output_dir,
text_key=args.text_key,
batch_size=args.batch_size,
max_length=args.max_length,
token_level=args.token_level,
max_samples=args.max_samples,
)