Spaces:
Running on Zero
Running on Zero
File size: 16,547 Bytes
fed954e | 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 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 | """
evaluator.py — Scores model output along three orthogonal axes:
1. SCHEMA VALIDITY: does it parse as JSON, and validate against the Pydantic schema?
2. GROUNDING: is every leaf string traceable to the input caption?
This is the hallucination metric. v0.2: per-slot rule
driven by `groundedness` in registry.SLOT_REGISTRY.
3. COVERAGE: did the model surface the obvious nouns/verbs from the input,
or did it drop information? (cheap recall signal)
Grounding rules (per slot, read from registry):
- must_ground : every leaf MUST trace to input. Otherwise hallucinated.
- may_infer : leaf is allowed regardless of input. Counted as grounded.
Closed-vocab values (e.g. "indoor") are also auto-grounded
because the grammar enforces the value space anyway.
- derived_only : leaf is expected to be inferred. Auto-grounded, never penalized.
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass, field, asdict
from typing import Any, List, Optional, Tuple
from pydantic import BaseModel, ValidationError
from .schema import Caption
from .registry import SLOT_REGISTRY, SubjectValue, all_closed_vocab
# Auto-grounded values — anything in any closed vocab is always counted as
# grounded since the grammar pins the value space.
CLOSED_VOCAB: set[str] = all_closed_vocab()
# ──────────────────────────────────────────────────────────────────────────────
# Parsing — recover JSON from messy model output (markdown fences, prose, etc.)
# ──────────────────────────────────────────────────────────────────────────────
_FENCE_RE = re.compile(r"```(?:json)?\s*(.*?)```", re.DOTALL)
def _strip_fences(text: str) -> str:
"""If the model wrapped output in ```json ... ```, peel it off. First fence wins."""
m = _FENCE_RE.search(text)
return m.group(1).strip() if m else text.strip()
def _extract_first_json_object(text: str) -> Optional[str]:
"""
Walk the text and return the first balanced {...} substring.
Tolerates leading prose. Returns None if no balanced object found.
"""
text = _strip_fences(text)
start = text.find("{")
if start < 0:
return None
depth = 0
in_str = False
esc = False
for i in range(start, len(text)):
c = text[i]
if esc:
esc = False
continue
if c == "\\":
esc = True
continue
if c == '"':
in_str = not in_str
continue
if in_str:
continue
if c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
return text[start:i + 1]
return None
@dataclass
class GenericParseReport:
"""Result of recovering + validating JSON against an arbitrary Pydantic model.
Two repair signals, because not all repair is equal:
`needed_repair` — any recovery was needed (not clean bare JSON).
`needed_structural_repair` — recovery needed MORE than stripping a markdown
code fence (prose prefix, trailing tokens,
runaway generation). Fence-stripping is benign
and 100% deterministic, so the vision
`json_robustness` metric keys off the STRUCTURAL
signal — a model that only wraps clean JSON in
```fences``` is still robust; one that buries it
in prose is not.
"""
parsed: Optional[Any]
schema_valid: bool
error: Optional[str]
needed_repair: bool = False
needed_structural_repair: bool = False
def parse_against(raw_text: str, model: type[BaseModel]) -> GenericParseReport:
"""Recover the first JSON object from raw model output and validate it against
`model`. Never raises. Used by both the caption path (model=Caption) and the
vision metrics (model=per-category schema)."""
obj_str = _extract_first_json_object(raw_text)
if obj_str is None:
return GenericParseReport(None, False, "no JSON object found",
needed_repair=True, needed_structural_repair=True)
stripped = raw_text.strip()
needed_repair = stripped != obj_str
# If the ONLY thing between the raw text and the object was a code fence, the
# fence-stripped text equals the object → benign (fence-only) repair.
fenced_inner = _strip_fences(raw_text).strip()
fence_only = needed_repair and (fenced_inner == obj_str)
needed_structural_repair = needed_repair and not fence_only
try:
as_dict = json.loads(obj_str)
except json.JSONDecodeError as e:
return GenericParseReport(None, False, f"json decode: {e}",
needed_repair=True, needed_structural_repair=True)
try:
parsed = model.model_validate(as_dict)
except ValidationError as e:
return GenericParseReport(None, False, f"schema: {e.errors()[:2]}",
needed_repair=needed_repair,
needed_structural_repair=needed_structural_repair)
return GenericParseReport(parsed, True, None, needed_repair=needed_repair,
needed_structural_repair=needed_structural_repair)
@dataclass
class ParseReport:
parsed: Optional[Caption]
schema_valid: bool
error: Optional[str]
def parse_safely(raw_text: str) -> ParseReport:
"""Try to recover a Caption object from raw model output. Never raises."""
r = parse_against(raw_text, Caption)
return ParseReport(r.parsed, r.schema_valid, r.error)
# ──────────────────────────────────────────────────────────────────────────────
# Grounding — the hallucination metric
# ──────────────────────────────────────────────────────────────────────────────
_TOKEN_RE = re.compile(r"[a-z0-9]+")
def _normalize(s: str) -> str:
return s.lower().strip()
def _tokens(s: str) -> List[str]:
return _TOKEN_RE.findall(s.lower())
def _depluralize(token: str) -> str:
"""Cheap singularization: drop trailing -s, -es, -ies. No NLTK dependency.
LIMITATION: irregular plurals (children, mice, geese, men) are not handled.
Those will surface as false-positive hallucinations. Upgrade to a real
lemmatizer if irregular-plural FPs become a problem in production data.
"""
if len(token) <= 3:
return token
if token.endswith("ies"):
return token[:-3] + "y"
if token.endswith("es"):
return token[:-2]
if token.endswith("s"):
return token[:-1]
return token
def _is_grounded(leaf: str, input_caption: str) -> bool:
"""Does `leaf` trace back to `input_caption`?"""
leaf_norm = _normalize(leaf)
if leaf_norm in CLOSED_VOCAB:
return True
cap_norm = _normalize(input_caption)
# Direct substring (handles multi-word phrases like "blue car")
if leaf_norm in cap_norm:
return True
# Token-level: every token of the leaf (after singularization) must appear in caption
cap_tokens = {_depluralize(t) for t in _tokens(input_caption)}
leaf_tokens = [_depluralize(t) for t in _tokens(leaf)]
if leaf_tokens and all(t in cap_tokens for t in leaf_tokens):
return True
return False
@dataclass
class GroundingReport:
leaves_total: int
leaves_grounded: int
hallucinated: List[Tuple[str, str]] # (field_path, value)
@property
def grounding_rate(self) -> float:
return self.leaves_grounded / self.leaves_total if self.leaves_total else 1.0
def _collect_leaves(caption: Caption) -> List[Tuple[str, str, str]]:
"""Walk the caption and return (path, value, groundedness) for every leaf.
Closed-vocab single-value slots are NOT included — their value space is
grammar-enforced, so they can't hallucinate by definition.
"""
leaves: List[Tuple[str, str, str]] = []
for slot_name, spec in SLOT_REGISTRY.items():
val = getattr(caption, slot_name)
if spec.cardinality == "list":
if spec.nested_model is SubjectValue:
for i, subj in enumerate(val):
leaves.append((f"{slot_name}[{i}].name", subj.name, spec.groundedness))
for j, attr in enumerate(subj.attributes):
leaves.append(
(f"{slot_name}[{i}].attributes[{j}]", attr, spec.groundedness)
)
else:
for i, item in enumerate(val):
leaves.append((f"{slot_name}[{i}]", item, spec.groundedness))
else:
if val is None:
continue
if spec.vocabulary == "closed":
# Value space is grammar-enforced — auto-grounded, not a leaf.
continue
leaves.append((slot_name, val, spec.groundedness))
return leaves
def ground_check(caption: Caption, input_text: str) -> GroundingReport:
"""Walk every leaf in the parsed caption; flag per the slot's groundedness rule.
- must_ground: leaf must trace to input or it's hallucinated
- may_infer: leaf auto-counts as grounded (closed enums + soft slots)
- derived_only: leaf auto-counts as grounded (model is expected to infer)
"""
leaves = _collect_leaves(caption)
grounded = 0
halluc: List[Tuple[str, str]] = []
for path, val, groundedness in leaves:
if groundedness in ("may_infer", "derived_only"):
grounded += 1
continue
# must_ground — strict check
if _is_grounded(val, input_text):
grounded += 1
else:
halluc.append((path, val))
return GroundingReport(
leaves_total=len(leaves),
leaves_grounded=grounded,
hallucinated=halluc,
)
# ──────────────────────────────────────────────────────────────────────────────
# Coverage — did the model surface the obvious nouns from input? (cheap recall)
# ──────────────────────────────────────────────────────────────────────────────
# Common English stop-tokens we don't expect to appear as caption subjects/actions
_STOP = {
"a", "an", "the", "of", "in", "on", "at", "to", "and", "or", "with",
"is", "are", "was", "were", "be", "been", "being",
"this", "that", "these", "those", "it", "its",
"for", "from", "by", "as", "into", "onto", "over", "under",
}
def _content_tokens(text: str) -> set[str]:
return {_depluralize(t) for t in _tokens(text) if t not in _STOP and len(t) > 2}
@dataclass
class CoverageReport:
input_content_tokens: int
output_coverage: int
@property
def coverage_rate(self) -> float:
return self.output_coverage / self.input_content_tokens if self.input_content_tokens else 1.0
def _collect_output_strings(caption: Caption) -> list[str]:
"""All string content the model produced, for coverage / recall scoring.
Iterates the registry so new slots automatically participate in coverage.
Closed-vocab single-value slots are excluded — their values come from the
enum, not from input content, so they're not informative for recall.
"""
out: list[str] = []
for slot_name, spec in SLOT_REGISTRY.items():
val = getattr(caption, slot_name)
if spec.cardinality == "list":
if spec.nested_model is SubjectValue:
for subj in val:
out.append(subj.name)
out.extend(subj.attributes)
else:
out.extend(val)
else:
if val is None:
continue
if spec.vocabulary == "closed":
continue
out.append(val)
return out
def coverage_check(caption: Caption, input_text: str) -> CoverageReport:
in_tokens = _content_tokens(input_text)
out_blob = " ".join(_collect_output_strings(caption))
out_tokens = _content_tokens(out_blob)
overlap = in_tokens & out_tokens
return CoverageReport(len(in_tokens), len(overlap))
# ──────────────────────────────────────────────────────────────────────────────
# Per-sample and per-run aggregation
# ──────────────────────────────────────────────────────────────────────────────
@dataclass
class SampleResult:
input_caption: str
mode: str
raw_output: str
schema_valid: bool
parse_error: Optional[str]
grounding_rate: float
hallucinations: List[Tuple[str, str]]
coverage_rate: float
n_input_tokens: int
n_output_tokens: int
def to_dict(self) -> dict:
return asdict(self)
def score_sample(
input_caption: str,
raw_output: str,
mode: str,
n_input_tokens: int = 0,
n_output_tokens: int = 0,
) -> SampleResult:
parse = parse_safely(raw_output)
if not parse.schema_valid or parse.parsed is None:
return SampleResult(
input_caption=input_caption,
mode=mode,
raw_output=raw_output,
schema_valid=False,
parse_error=parse.error,
grounding_rate=0.0,
hallucinations=[],
coverage_rate=0.0,
n_input_tokens=n_input_tokens,
n_output_tokens=n_output_tokens,
)
g = ground_check(parse.parsed, input_caption)
c = coverage_check(parse.parsed, input_caption)
return SampleResult(
input_caption=input_caption,
mode=mode,
raw_output=raw_output,
schema_valid=True,
parse_error=None,
grounding_rate=g.grounding_rate,
hallucinations=g.hallucinated,
coverage_rate=c.coverage_rate,
n_input_tokens=n_input_tokens,
n_output_tokens=n_output_tokens,
)
@dataclass
class RunMetrics:
mode: str
n_samples: int
schema_valid_rate: float
mean_grounding_rate: float
mean_coverage_rate: float
total_hallucinations: int
samples_with_zero_hallucinations: int
def __str__(self) -> str:
return (
f"[{self.mode}] n={self.n_samples} "
f"schema_valid={self.schema_valid_rate:.1%} "
f"grounding={self.mean_grounding_rate:.1%} "
f"coverage={self.mean_coverage_rate:.1%} "
f"clean_samples={self.samples_with_zero_hallucinations}/{self.n_samples} "
f"halluc_total={self.total_hallucinations}"
)
def score_run(results: List[SampleResult]) -> RunMetrics:
if not results:
return RunMetrics("empty", 0, 0.0, 0.0, 0.0, 0, 0)
mode = results[0].mode
n = len(results)
valid = [r for r in results if r.schema_valid]
return RunMetrics(
mode=mode,
n_samples=n,
schema_valid_rate=len(valid) / n,
mean_grounding_rate=sum(r.grounding_rate for r in valid) / len(valid) if valid else 0.0,
mean_coverage_rate=sum(r.coverage_rate for r in valid) / len(valid) if valid else 0.0,
total_hallucinations=sum(len(r.hallucinations) for r in results),
samples_with_zero_hallucinations=sum(
1 for r in results if r.schema_valid and not r.hallucinations
),
)
|