finllm-foundry / src /services /evaluation.py
finpy1789's picture
Upload folder using huggingface_hub
68c1777 verified
Raw
History Blame Contribute Delete
10.1 kB
"""Evaluation service (spec §6): seeded sampling with fixed item IDs, CIs on
every metric, paired significance tests for baseline-vs-finetuned, item-level
persistence for resume/reproduction, and rule-based diagnostics.
Pure-Python metric implementations (LCS ROUGE-L, corpus-free BLEU-4 per item)
keep the Space light; sacrebleu/bertscore hook in when enabled in limits.yaml.
"""
import math
import random
import re
import statistics
import time
# ---------- sampling ----------
def sample_items(records: list[dict], n: int, seed: int) -> list[dict]:
"""Stable, seeded sample with item ids; same (records, n, seed) => same items."""
rng = random.Random(seed)
idx = list(range(len(records)))
rng.shuffle(idx)
chosen = sorted(idx[: min(n, len(records))])
items = []
for i in chosen:
msgs = records[i]["messages"]
ref = next((m["content"] for m in reversed(msgs) if m["role"] == "assistant"), "")
prompt = [m for m in msgs if m["role"] != "assistant"]
items.append({"item_id": i, "prompt": prompt, "reference": ref})
return items
# ---------- metrics ----------
def _norm(s):
return re.sub(r"\s+", " ", re.sub(r"[^\w\s]", "", s.lower())).strip()
def exact_match(pred, ref):
return float(_norm(pred) == _norm(ref))
def token_f1(pred, ref):
p, r = _norm(pred).split(), _norm(ref).split()
if not p or not r:
return 0.0
common = {}
for t in p:
common[t] = common.get(t, 0)
overlap = 0
rc = {}
for t in r:
rc[t] = rc.get(t, 0) + 1
pc = {}
for t in p:
pc[t] = pc.get(t, 0) + 1
for t, c in pc.items():
overlap += min(c, rc.get(t, 0))
if overlap == 0:
return 0.0
prec, rec = overlap / len(p), overlap / len(r)
return 2 * prec * rec / (prec + rec)
def rouge_l(pred, ref):
a, b = _norm(pred).split(), _norm(ref).split()
if not a or not b:
return 0.0
dp = [0] * (len(b) + 1)
for x in a:
prev = 0
for j, y in enumerate(b, 1):
cur = dp[j]
dp[j] = prev + 1 if x == y else max(dp[j], dp[j - 1])
prev = cur
lcs = dp[-1]
prec, rec = lcs / len(a), lcs / len(b)
return 0.0 if prec + rec == 0 else 2 * prec * rec / (prec + rec)
def bleu4(pred, ref):
p, r = _norm(pred).split(), _norm(ref).split()
if len(p) == 0:
return 0.0
logs = []
for n in range(1, 5):
pn = [tuple(p[i:i + n]) for i in range(len(p) - n + 1)]
rn = [tuple(r[i:i + n]) for i in range(len(r) - n + 1)]
if not pn:
return 0.0
rc = {}
for g in rn:
rc[g] = rc.get(g, 0) + 1
hit = 0
pc = {}
for g in pn:
pc[g] = pc.get(g, 0) + 1
for g, c in pc.items():
hit += min(c, rc.get(g, 0))
logs.append(math.log((hit + 1e-9) / len(pn)))
bp = 1.0 if len(p) > len(r) else math.exp(1 - len(r) / max(len(p), 1))
return bp * math.exp(sum(logs) / 4)
def unsupported_claim_rate(pred, ref):
"""Fraction of predicted sentences with <30% token overlap vs reference —
a component of the hallucination ESTIMATE, not a truth measurement."""
sents = [s for s in re.split(r"(?<=[.!?])\s+", pred) if len(s.split()) >= 4]
if not sents:
return 0.0
ref_toks = set(_norm(ref).split())
bad = sum(1 for s in sents
if len(set(_norm(s).split()) & ref_toks) / max(len(set(_norm(s).split())), 1) < 0.3)
return bad / len(sents)
# ---------- statistics ----------
def mean_ci(values, iters=2000, seed=0):
"""Bootstrap mean + 95% CI."""
if not values:
return {"mean": 0.0, "ci_low": 0.0, "ci_high": 0.0, "n": 0}
rng = random.Random(seed)
n = len(values)
means = sorted(statistics.fmean(rng.choices(values, k=n)) for _ in range(iters))
return {"mean": round(statistics.fmean(values), 4),
"ci_low": round(means[int(0.025 * iters)], 4),
"ci_high": round(means[int(0.975 * iters)], 4), "n": n}
def paired_pvalue(base, post, iters=2000, seed=0):
"""Paired permutation test on mean difference (sign-flip)."""
diffs = [b - a for a, b in zip(base, post)]
if not diffs or all(d == 0 for d in diffs):
return 1.0
obs = abs(statistics.fmean(diffs))
rng = random.Random(seed)
hits = sum(1 for _ in range(iters)
if abs(statistics.fmean([d if rng.random() < 0.5 else -d for d in diffs])) >= obs)
return round(hits / iters, 4)
# ---------- evaluation run ----------
METRICS = {
"accuracy": exact_match,
"token_f1": token_f1,
"bleu": bleu4,
"rougeL": rouge_l,
"unsupported_claim_rate": unsupported_claim_rate,
}
def evaluate_items(generate_fn, items, existing: dict | None = None,
max_new_tokens=192, progress=None):
"""generate_fn(prompt_messages) -> text. Resumable: pass previously
completed per-item results as `existing` (item_id -> record) (spec P8)."""
done = dict(existing or {})
for k, item in enumerate(items):
iid = str(item["item_id"])
if iid in done:
continue
t0 = time.time()
try:
pred = generate_fn(item["prompt"])
except Exception as e: # noqa: BLE001
pred = f"[generation error: {type(e).__name__}]"
latency = time.time() - t0
rec = {"item_id": item["item_id"], "prediction": pred,
"reference": item["reference"], "latency_s": round(latency, 3),
"pred_tokens": len(pred.split())}
for name, fn in METRICS.items():
rec[name] = round(fn(pred, item["reference"]), 4)
done[iid] = rec
if progress:
progress((k + 1) / len(items))
return done
def summarize(item_results: dict, seed: int, level: str) -> dict:
recs = list(item_results.values())
out = {"level": level, "seed": seed, "n_items": len(recs),
"full_benchmark_executed": False, "metrics": {}}
for name in METRICS:
out["metrics"][name] = mean_ci([r[name] for r in recs], seed=seed)
lat = sorted(r["latency_s"] for r in recs)
out["metrics"]["latency_s"] = mean_ci([r["latency_s"] for r in recs], seed=seed)
out["latency_p95_s"] = round(lat[int(0.95 * (len(lat) - 1))], 3) if lat else 0
out["avg_response_tokens"] = round(statistics.fmean([r["pred_tokens"] for r in recs]), 1) if recs else 0
# composite hallucination estimate (spec §6.2) — labeled estimate everywhere
ucr = out["metrics"]["unsupported_claim_rate"]["mean"]
fact = out["metrics"]["token_f1"]["mean"]
out["hallucination_estimate"] = {
"factual_consistency": round(fact, 3),
"unsupported_claim_rate": round(ucr, 3),
"composite_pct": round(100 * (0.6 * ucr + 0.4 * (1 - fact)), 1),
"label": "Estimated hallucination risk — not a direct measurement of truthfulness",
"judge_model": None, "human_verified": False,
}
return out
def compare(baseline: dict, post: dict, base_items: dict, post_items: dict) -> dict:
"""Paired comparison (identical item ids) with significance (spec §6.3)."""
rows, verdicts = [], []
shared = sorted(set(base_items) & set(post_items), key=int)
higher_better = {"accuracy": True, "token_f1": True, "bleu": True, "rougeL": True,
"unsupported_claim_rate": False, "latency_s": False}
primary = {"accuracy", "token_f1", "rougeL", "bleu"}
for name, hb in higher_better.items():
b = [base_items[i][name] for i in shared]
p = [post_items[i][name] for i in shared]
delta = round(statistics.fmean(p) - statistics.fmean(b), 4) if shared else 0.0
pval = paired_pvalue(b, p)
sig = pval < 0.05
improved = (delta > 0) == hb and delta != 0
rows.append({"metric": name, "baseline": round(statistics.fmean(b), 4) if b else 0,
"finetuned": round(statistics.fmean(p), 4) if p else 0,
"change": delta, "p_value": pval, "significant": sig,
"direction": "improved" if improved else ("degraded" if delta != 0 else "unchanged")})
if sig and name in primary:
verdicts.append("improved" if improved else "degraded")
elif sig and not improved and name in ("latency_s",):
verdicts.append("minor-regression")
if "degraded" in verdicts:
overall = "Degraded"
elif "improved" in verdicts:
overall = "Improved"
else:
overall = "Neutral"
return {"rows": rows, "overall": overall, "n_paired_items": len(shared),
"method": "paired permutation test (sign-flip), alpha=0.05"}
def diagnostics(summary_ds: dict, training_log: dict, comparison: dict) -> list[dict]:
"""Trial & Error panel: evidence-backed possible reasons (spec §6.3)."""
out = []
n = summary_ds.get("samples", 0)
losses = training_log.get("losses", [])
if comparison["overall"] != "Improved":
if n and n < 500:
out.append({"reason": "Dataset too small",
"evidence": f"only {n} training samples; <500 rarely shifts a pretrained model"})
if losses and len(losses) > 4 and losses[-1] > losses[0] * 0.9:
out.append({"reason": "Learning rate too low or too few steps",
"evidence": f"loss only moved {losses[0]:.2f}{losses[-1]:.2f}"})
if losses and min(losses) < 0.5 and n < 2000:
out.append({"reason": "Overfitting risk",
"evidence": f"train loss reached {min(losses):.2f} on a small dataset"})
deg = [r for r in comparison["rows"] if r["direction"] == "degraded" and r["significant"]]
if deg:
out.append({"reason": "Catastrophic forgetting possible",
"evidence": f"significant regressions: {', '.join(r['metric'] for r in deg)}"})
if not out:
out.append({"reason": "Neutral result",
"evidence": "no significant movement — consider more epochs or higher-quality data"})
return out