File size: 8,113 Bytes
1e4fb05 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 | """
Comprehensive SLM Evaluation Suite for Fiction/Narrative Models:
1. Perplexity & Cross-Entropy Loss
2. Distinct-N Lexical Diversity (Distinct-1, Distinct-2, Distinct-3)
3. Dialogue & Syntactic Hygiene (Quotation closure, sentence completion)
4. Vocabulary Utilization (Active vocabulary percentage)
5. Zero-Shot Narrative Cloze Choice Accuracy
6. Inference Latency & Throughput Benchmark (Tokens/sec, TTFT)
"""
import os
import time
import math
import torch
import torch.nn.functional as F
import numpy as np
from collections import Counter
from typing import List, Dict, Tuple
# ==========================================
# 1. Perplexity & Loss
# ==========================================
@torch.no_grad()
def evaluate_loss(model, dataloader, device, max_batches: int = 100) -> float:
model.eval()
losses = []
for i, (x, y) in enumerate(dataloader):
if i >= max_batches:
break
x, y = x.to(device, non_blocking=True), y.to(device, non_blocking=True)
with torch.autocast(device_type="cuda" if "cuda" in str(device) else "cpu", dtype=torch.float16):
_, loss = model(x, targets=y)
losses.append(loss.item())
return float(np.mean(losses)) if losses else float("nan")
@torch.no_grad()
def perplexity(model, dataloader, device, max_batches: int = 200) -> float:
avg_loss = evaluate_loss(model, dataloader, device, max_batches=max_batches)
return math.exp(avg_loss)
# ==========================================
# 2. Distinct-N Lexical Diversity
# ==========================================
def distinct_n(texts: List[str], n: int = 2) -> float:
"""
Computes Distinct-N ratio: unique n-grams / total n-grams.
Higher values (0.75 - 0.90) indicate rich, non-repetitive vocabulary.
"""
total_ngrams = 0
unique_ngrams = set()
for text in texts:
tokens = text.strip().split()
if len(tokens) < n:
continue
ngrams = [tuple(tokens[i : i + n]) for i in range(len(tokens) - n + 1)]
total_ngrams += len(ngrams)
unique_ngrams.update(ngrams)
return len(unique_ngrams) / max(1, total_ngrams)
def distinct_1(texts: List[str]) -> float:
"""Convenience helper for Distinct-1 (unigram diversity)."""
return distinct_n(texts, n=1)
def distinct_2(texts: List[str]) -> float:
"""Convenience helper for Distinct-2 (bigram diversity)."""
return distinct_n(texts, n=2)
def distinct_3(texts: List[str]) -> float:
"""Convenience helper for Distinct-3 (trigram diversity)."""
return distinct_n(texts, n=3)
def compute_diversity_report(generated_texts: List[str]) -> Dict[str, float]:
"""Computes Distinct-1, Distinct-2, and Distinct-3 diversity."""
return {
"distinct_1": distinct_1(generated_texts),
"distinct_2": distinct_2(generated_texts),
"distinct_3": distinct_3(generated_texts),
}
# ==========================================
# 3. Dialogue & Syntactic Hygiene
# ==========================================
def dialogue_syntax_hygiene(generated_texts: List[str]) -> Dict[str, float]:
"""
Evaluates whether the model handles dialogue quotes and punctuation properly:
- Quote closure rate: % of opened quotes that are properly closed.
- Dialogue percentage: % of text inside spoken dialogue.
- Average sentence length.
"""
closed_quotes_count = 0
total_quote_pairs = 0
total_chars = 0
dialogue_chars = 0
for text in generated_texts:
total_chars += len(text)
quotes = text.count('"') + text.count('“') + text.count('”')
total_quote_pairs += (quotes // 2)
if quotes % 2 == 0 and quotes > 0:
closed_quotes_count += 1
# Extract text within quotation marks
parts = text.split('"')
for i in range(1, len(parts), 2):
dialogue_chars += len(parts[i])
closure_rate = (closed_quotes_count / max(1, len(generated_texts))) * 100.0
dialogue_ratio = (dialogue_chars / max(1, total_chars)) * 100.0
return {
"closed_quotes_rate_pct": closure_rate,
"dialogue_ratio_pct": dialogue_ratio,
}
# ==========================================
# 4. Active Vocabulary Utilization
# ==========================================
@torch.no_grad()
def vocabulary_utilization(model, sample_prompts: List[str], tok, device, max_tokens: int = 100) -> Dict[str, float]:
"""
Measures the number of unique tokens the model generates across prompts.
Detects if the model suffers from vocabulary mode collapse.
"""
model.eval()
used_token_ids = set()
total_generated = 0
for prompt in sample_prompts:
input_ids = torch.tensor(tok.encode(prompt), dtype=torch.long, device=device).unsqueeze(0)
raw_model = model.module if hasattr(model, "module") else model
out_ids = raw_model.generate(input_ids, max_new_tokens=max_tokens, temperature=0.8, top_k=40)
gen_ids = out_ids[0, input_ids.size(1):].tolist()
used_token_ids.update(gen_ids)
total_generated += len(gen_ids)
return {
"unique_tokens_used": len(used_token_ids),
"total_tokens_generated": total_generated,
"vocab_utilization_ratio": len(used_token_ids) / max(1, total_generated)
}
# ==========================================
# 5. Zero-Shot Narrative Cloze Test
# ==========================================
@torch.no_grad()
def narrative_cloze_accuracy(model, tok, cloze_test_cases: List[Dict], device) -> float:
"""
Presents the model with a prompt and two options: (A) Correct continuation, (B) Nonsense/Contradictory.
Computes log-likelihood of each and checks if the model prefers the coherent continuation.
"""
model.eval()
correct = 0
for test in cloze_test_cases:
prompt = test["prompt"]
option_a = test["correct"]
option_b = test["incorrect"]
def get_sequence_logprob(text):
ids = torch.tensor(tok.encode(prompt + " " + text), dtype=torch.long, device=device).unsqueeze(0)
with torch.autocast(device_type="cuda" if "cuda" in str(device) else "cpu", dtype=torch.float16):
logits, _ = model(ids)
# Compute log probs for the completion tokens
prompt_len = len(tok.encode(prompt))
target_ids = ids[:, prompt_len:]
target_logits = logits[:, prompt_len - 1 : -1, :]
log_probs = F.log_softmax(target_logits, dim=-1)
token_logprobs = log_probs.gather(2, target_ids.unsqueeze(-1)).squeeze(-1)
return token_logprobs.sum().item()
score_a = get_sequence_logprob(option_a)
score_b = get_sequence_logprob(option_b)
if score_a > score_b:
correct += 1
accuracy = (correct / max(1, len(cloze_test_cases))) * 100.0
return accuracy
# ==========================================
# 6. Inference Latency & Throughput Benchmark
# ==========================================
@torch.no_grad()
def benchmark_inference(model, tok, prompt: str = "Once upon a time", max_tokens: int = 128, device="cuda") -> Dict[str, float]:
"""
Measures Time-to-First-Token (TTFT) and token generation throughput (tokens/sec).
"""
model.eval()
input_ids = torch.tensor(tok.encode(prompt), dtype=torch.long, device=device).unsqueeze(0)
raw_model = model.module if hasattr(model, "module") else model
# Warmup
_ = raw_model.generate(input_ids, max_new_tokens=10, temperature=1.0)
if "cuda" in str(device):
torch.cuda.synchronize()
# Benchmark
start = time.perf_counter()
out = raw_model.generate(input_ids, max_new_tokens=max_tokens, temperature=1.0)
if "cuda" in str(device):
torch.cuda.synchronize()
total_time = time.perf_counter() - start
tokens_per_sec = max_tokens / max(1e-5, total_time)
ms_per_token = (total_time / max_tokens) * 1000
return {
"tokens_per_second": tokens_per_sec,
"ms_per_token": ms_per_token,
"total_latency_sec": total_time,
"generated_tokens": max_tokens
}
|