465 lines
15 KiB
Python
465 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,
|
|
device: str = "cuda",
|
|
) -> 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=device, dtype=torch.long)
|
|
attention_mask = torch.tensor(masks, device=device, 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],
|
|
device: str = "cuda",
|
|
dtype: str = "bfloat16",
|
|
):
|
|
print(f"Loading model from {param_path} ...")
|
|
model = AutoModel.from_pretrained(param_path)
|
|
tokenizer = AutoTokenizer.from_pretrained(param_path)
|
|
torch_dtype = getattr(torch, dtype)
|
|
model.to(device=device, dtype=torch_dtype)
|
|
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,
|
|
device=device,
|
|
)
|
|
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.",
|
|
)
|
|
parser.add_argument(
|
|
"--device",
|
|
type=str,
|
|
default="cuda" if torch.cuda.is_available() else "cpu",
|
|
help="Device for model inference.",
|
|
)
|
|
parser.add_argument(
|
|
"--dtype",
|
|
type=str,
|
|
default="bfloat16" if torch.cuda.is_available() else "float32",
|
|
help="Torch dtype for model weights.",
|
|
)
|
|
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,
|
|
device=args.device,
|
|
dtype=args.dtype,
|
|
)
|