Spaces:
Sleeping
Sleeping
File size: 8,397 Bytes
d2a6765 b6beb2d d2a6765 fa6cf50 d2a6765 fa6cf50 d2a6765 fa6cf50 d2a6765 fa6cf50 d2a6765 fa6cf50 d2a6765 fa6cf50 d2a6765 | 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 | """Predict phase: run the core over a dataset slice and cache each result.
This is the **only** phase that calls a model backend and the only phase that
spends API quota. It is deliberately idempotent: an example already present in
the cache is skipped unless ``overwrite`` is set, so a re-run after an
interruption resumes rather than re-billing.
For each example the PIL image is written to a temporary file (``core`` takes a
path, not bytes) with an extension that makes modality detection classify it as
an image; ``process_document`` then runs the full detect -> acquire -> extract
-> validate -> score -> route pipeline. The gold labels, predicted document,
confidence, and validation report are cached; nothing is persisted to the app's
SQLite store (eval is not production ingestion).
"""
from __future__ import annotations
import logging
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from docfield.backends.base import create_backend
from docfield.config import Settings, load_config
from docfield.core import process_document
from eval.cache import DEFAULT_CACHE_BASE, errored_ids, existing_ids, write_entry
from eval.datasets import WIRED_DATASETS, get_adapter
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class PredictStats:
"""Summary of a predict-phase run.
Attributes:
dataset: The dataset name.
requested: The requested slice size (``limit``), or ``None`` for all.
processed: Examples actually sent through the pipeline this run.
skipped: Examples skipped because they were already cached.
accepted: Processed examples the pipeline auto-accepted.
review: Processed examples routed to review.
errors: Processed examples whose pipeline stage raised (routed to review).
failed: Examples that could not be prepared at all (e.g. the image would
not encode); logged and skipped so one bad input never aborts the run.
"""
dataset: str
requested: int | None
processed: int
skipped: int
accepted: int
review: int
errors: int
failed: int
def _process_example(
example: Any,
dataset: str,
labeled_fields: tuple[str, ...],
settings: Settings,
backend: Any,
) -> dict[str, Any]:
"""Save one example's image to a temp file, run the pipeline, build the entry.
The temp file is created first and unlinked in a ``finally`` so it is cleaned
up even if ``image.save`` or the pipeline raises. ``process_document`` itself
never raises (rule 6); only image encoding can, and the caller isolates that.
Args:
example: The :class:`~eval.datasets.base.GoldExample` to process.
dataset: Dataset name (recorded in the entry).
labeled_fields: The dataset's labeled fields (recorded in the entry).
settings: Validated configuration.
backend: The extraction backend to use.
Returns:
A JSON-serializable cache entry for the example.
"""
with tempfile.NamedTemporaryFile(suffix=example.suffix, delete=False) as handle:
temp_path = Path(handle.name)
try:
example.image.save(temp_path)
result = process_document(temp_path, settings=settings, backend=backend)
finally:
temp_path.unlink(missing_ok=True)
return _build_entry(example, result, dataset, labeled_fields)
def _build_entry(
example: Any,
result: Any,
dataset: str,
labeled_fields: tuple[str, ...],
) -> dict[str, Any]:
"""Assemble a JSON-serializable cache entry from an example and its result."""
return {
"id": example.id,
"dataset": dataset,
"gold": example.gold,
"labeled_fields": list(labeled_fields),
"predicted": result.document.model_dump(mode="json"),
"confidence": result.confidence,
"decision": result.decision,
"modality": result.modality,
"backend": result.backend_name,
"validation": result.report.to_dict(),
"error": result.error,
}
def run_predict(
dataset: str,
limit: int | None,
*,
settings: Settings | None = None,
cache_base: Path = DEFAULT_CACHE_BASE,
overwrite: bool = False,
retry_errors: bool = False,
) -> PredictStats:
"""Run the pipeline over a dataset slice and cache each result.
Args:
dataset: Name of a wired dataset adapter (e.g. "sroie").
limit: Number of examples to process (the held-out slice size); ``None``
for the whole split.
settings: Validated configuration; loaded from the environment when
``None`` (must select a backend that can read images).
cache_base: Root cache directory. Defaults to ``eval/cache``.
overwrite: Re-process and overwrite examples already cached. Defaults to
``False`` so re-runs resume without re-billing.
retry_errors: Re-process *only* cached entries that recorded an error,
leaving every successful prediction byte-identical. Use after an
outage. Mutually exclusive with ``overwrite``.
Returns:
A :class:`PredictStats` summary of the run.
Raises:
ValueError: If ``dataset`` is not wired for the predict phase, if both
``overwrite`` and ``retry_errors`` are set, or if ``retry_errors``
is set with no cache to retry from.
"""
if overwrite and retry_errors:
raise ValueError(
"--overwrite and --retry-errors are mutually exclusive: the first "
"re-runs every document, the second only the failed ones. Pick one."
)
if dataset not in WIRED_DATASETS:
wired = ", ".join(sorted(WIRED_DATASETS))
raise ValueError(
f"Dataset {dataset!r} is scaffolded but not wired for prediction. "
f"Wired datasets: {wired}."
)
adapter = get_adapter(dataset)
if retry_errors:
cached = existing_ids(cache_base, dataset)
if not cached:
raise ValueError(
f"--retry-errors needs an existing cache for dataset {dataset!r} "
f"under {cache_base}, but none was found. Run a normal predict first."
)
retry = errored_ids(cache_base, dataset)
# Skip everything already cached that did NOT error, so successful
# predictions are never re-run, re-billed, or rewritten.
already = cached - retry
logger.info(
"eval-predict: retry-errors mode -- %d errored of %d cached will be re-run",
len(retry),
len(cached),
)
else:
already = set() if overwrite else existing_ids(cache_base, dataset)
settings = settings or load_config()
backend = create_backend(settings)
processed = skipped = accepted = review = errors = failed = 0
logger.info("eval-predict: dataset=%s limit=%s backend=%s", dataset, limit, backend.name)
for example in adapter.load(limit):
if example.id in already:
skipped += 1
logger.info("eval-predict: skip cached id=%s", example.id)
continue
# Isolate per-example preparation failures (e.g. an un-encodable image) so
# one bad input logs and is skipped rather than aborting the whole slice.
try:
entry = _process_example(example, dataset, adapter.labeled_fields, settings, backend)
except Exception as exc: # noqa: BLE001 -- never let one document halt the run.
failed += 1
logger.error("eval-predict: could not prepare id=%s -- skipping: %s", example.id, exc)
continue
write_entry(cache_base, dataset, entry)
processed += 1
if entry["error"]:
errors += 1
if entry["decision"] == "accept":
accepted += 1
else:
review += 1
logger.info(
"eval-predict: id=%s decision=%s confidence=%.3f error=%s",
example.id,
entry["decision"],
entry["confidence"],
bool(entry["error"]),
)
stats = PredictStats(
dataset=dataset,
requested=limit,
processed=processed,
skipped=skipped,
accepted=accepted,
review=review,
errors=errors,
failed=failed,
)
logger.info("eval-predict: done %s", stats)
return stats
|