Spaces:
Sleeping
Sleeping
Download src/captioning/evaluation/dashboard.py from apoorvrajdev/image-captioning-api: direct link, hf CLI and curl.
- Browser
- Download file 15.7 kB
-
https://huggingface.co/spaces/apoorvrajdev/image-captioning-api/resolve/main/src/captioning/evaluation/dashboard.py
- Command line
-
hf download hf://spaces/apoorvrajdev/image-captioning-api/src/captioning/evaluation/dashboard.py
-
curl -L -o dashboard.py https://huggingface.co/spaces/apoorvrajdev/image-captioning-api/resolve/main/src/captioning/evaluation/dashboard.py
15.7 kB
| """Export the Phase 3 dashboard data from committed results. | |
| The SPA's comparison dashboard reads one static JSON file, imported at build | |
| time (ADR-021). This module builds it from two committed sources and nothing | |
| else: | |
| - the cross-run quality summary, ``results/<comparison_id>/comparison.json`` | |
| (TASK-013, ``docs/EVAL_METHODOLOGY.md`` § 8); | |
| - every latency run, ``results/<prefix><model_id>-<decoding>-<device>/latency.json`` | |
| (TASK-015, § 9). | |
| Values are copied verbatim, never recomputed or rounded. The only hand-written | |
| content is each model's display name and the caveat notes, which restate | |
| §§ 8 and 9. The sources are checked before anything is exported: quality and | |
| latency must share the slice, every latency run must use the same protocol, | |
| inputs, settings and timing, every summary must recompute from its raw | |
| samples, and a model's Hub id and revision must agree across all its runs. | |
| Any disagreement raises :class:`DashboardExportError` naming the run. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| from collections.abc import Sequence | |
| from pathlib import Path | |
| from typing import Any, TypeVar | |
| from pydantic import BaseModel, ConfigDict, ValidationError | |
| from captioning.evaluation.comparison import METRIC_KEYS, METRIC_LABELS | |
| from captioning.evaluation.latency import summarize | |
| DASHBOARD_SCHEMA_VERSION = 1 | |
| DEFAULT_COMPARISON_ID = "phase3-comparison" | |
| DEFAULT_LATENCY_PREFIX = "phase3-latency-" | |
| EXPORT_COMMAND = "python -m scripts.export_dashboard_data" | |
| # Names shown in the dashboard, keyed by the § 8.1 model id. | |
| DISPLAY_NAMES = { | |
| "blip-base": "BLIP-base", | |
| "git-base-coco": "GIT-base-coco", | |
| "inceptionv3-transformer-stabilized": "CNN + Transformer (InceptionV3)", | |
| "vit-gpt2": "ViT-GPT2", | |
| } | |
| # How each adapter handles a batch (§ 9.5): one generate() call for the whole | |
| # batch, or one single-image prediction after another. | |
| BATCH_MODES = {"CNNCaptioner": "sequential", "HFCaptioner": "batched"} | |
| QUALITY_NOTES = ( | |
| "Scores use the slice's stored references (about {rpi:.2f} per image), not the " | |
| "five-reference COCO setup, so they can't be compared with published COCO results " | |
| "(docs/EVAL_METHODOLOGY.md § 8.8).", | |
| 'Rows of kind "reference" are committed CNN + Transformer runs made before the comparison ' | |
| "harness, so they record no revision (null). The greedy one is reproduced exactly by a " | |
| "harness run at the pinned revision (docs/EVAL_METHODOLOGY.md § 8.8). The beam-search row " | |
| "is a labelled reference and isn't compared with the greedy baselines " | |
| "(docs/EVAL_METHODOLOGY.md § 8.4).", | |
| "Models are listed by model id. The order is not a ranking.", | |
| ) | |
| LATENCY_NOTES = ( | |
| "Values are seconds per call, and one call captions one whole batch, so a batch-8 value is " | |
| "the time for eight images, not per image (docs/EVAL_METHODOLOGY.md § 9.4).", | |
| 'Runs with batch_mode "sequential" (the CNN + Transformer) caption a batch one image at a ' | |
| "time: their batch-8 values are eight single-image calls in a row, not batched inference, " | |
| "and aren't like-for-like with the batched Hugging Face values (docs/EVAL_METHODOLOGY.md " | |
| "§ 9.5).", | |
| "Each device's runs come from a different host, operating system and framework build (see " | |
| "each run's environment). Figures compare only within one environment and device; together " | |
| "they are not a controlled CPU-versus-GPU comparison (docs/EVAL_METHODOLOGY.md §§ 9.5, 9.10).", | |
| "load_seconds is the cold-start cost on that host: captioner construction plus load(), " | |
| "including framework imports and any download. It is never part of a sample " | |
| "(docs/EVAL_METHODOLOGY.md § 9.1).", | |
| "Latency says nothing about caption quality (docs/EVAL_METHODOLOGY.md § 9.4).", | |
| ) | |
| class DashboardExportError(ValueError): | |
| """The committed results can't be exported: a file is missing, malformed or inconsistent.""" | |
| class _Artefact(BaseModel): | |
| # Strict: a malformed value (a number stored as a string) is refused, not converted. | |
| model_config = ConfigDict(extra="forbid", strict=True, protected_namespaces=()) | |
| # A copied number keeps its JSON type: an int is never widened to a float. | |
| _Number = int | float | |
| class _ComparisonSlice(_Artefact): | |
| fingerprint_sha256: str | |
| images: int | |
| references: int | |
| references_per_image: _Number | |
| class _ComparisonRow(_Artefact): | |
| run_id: str | |
| kind: str | |
| model_id: str | |
| backend: str | None | |
| captioner: str | None | |
| hub_repo: str | None | |
| revision: str | None | |
| weights_path: str | |
| decode_strategy: str | |
| decode_settings: dict[str, Any] | |
| n_samples: int | |
| metrics: dict[str, _Number | None] | |
| metric_errors: dict[str, str] | |
| class _Comparison(_Artefact): | |
| protocol: str | None | |
| normalisation: str | None | |
| slice: _ComparisonSlice | |
| caveat: str | |
| metric_keys: list[str] | |
| rows: list[_ComparisonRow] | |
| class _LatencySlice(_Artefact): | |
| source: str | |
| fingerprint_sha256: str | |
| images: int | |
| references: int | |
| class _LatencyInputs(_Artefact): | |
| slice: _LatencySlice | |
| images: list[str] | |
| class _LatencySettings(_Artefact): | |
| num_images: int | |
| batch_sizes: list[int] | |
| warmup_passes: int | |
| measured_passes: int | |
| class _Timing(_Artefact): | |
| clock: str | |
| sample: str | |
| load: str | |
| class _Summary(_Artefact): | |
| count: int | |
| mean: _Number | |
| median: _Number | |
| min: _Number | |
| max: _Number | |
| class _Batch(_Artefact): | |
| batch_size: int | |
| calls_per_pass: int | |
| samples_seconds: list[_Number] | |
| summary_seconds: _Summary | |
| class _Runtime(_Artefact): | |
| python: str | |
| platform: str | |
| packages: dict[str, str | None] | |
| tensorflow_gpus: list[str] | None | |
| class _Latency(_Artefact): | |
| protocol: str | |
| model_id: str | |
| backend: str | |
| captioner: str | |
| hub_repo: str | None | |
| revision: str | None | |
| decode_strategy: str | |
| decode_settings: dict[str, Any] | |
| device: str | |
| environment: str | |
| inputs: _LatencyInputs | |
| settings: _LatencySettings | |
| timing: _Timing | |
| load_seconds: _Number | |
| batches: list[_Batch] | |
| seed: int | |
| runtime: _Runtime | |
| _ModelT = TypeVar("_ModelT", bound=BaseModel) | |
| def find_latency_runs(results_root: str | Path, prefix: str = DEFAULT_LATENCY_PREFIX) -> list[Path]: | |
| """Return every ``<results_root>/<prefix>*/`` latency run directory, sorted by name.""" | |
| return sorted(p for p in Path(results_root).glob(f"{prefix}*") if p.is_dir()) | |
| def build_dashboard_data( | |
| results_root: str | Path, | |
| *, | |
| comparison_id: str = DEFAULT_COMPARISON_ID, | |
| latency_prefix: str = DEFAULT_LATENCY_PREFIX, | |
| ) -> dict[str, Any]: | |
| """Check the committed Phase 3 results and return the dashboard data. | |
| Args: | |
| results_root: The ``results/`` directory. | |
| comparison_id: The quality summary's directory under ``results_root``. | |
| latency_prefix: Every directory under ``results_root`` whose name starts | |
| with this is read as a latency run. | |
| Raises: | |
| DashboardExportError: If a source is missing, malformed or inconsistent. | |
| """ | |
| results_root = Path(results_root) | |
| comparison = _read(results_root / comparison_id / "comparison.json", _Comparison, comparison_id) | |
| if comparison.metric_keys != list(METRIC_KEYS): | |
| raise DashboardExportError( | |
| f"{comparison_id}: metric_keys {comparison.metric_keys} differ from {list(METRIC_KEYS)}" | |
| ) | |
| latency_dirs = find_latency_runs(results_root, latency_prefix) | |
| if not latency_dirs: | |
| raise DashboardExportError( | |
| f"no latency runs found: no {latency_prefix}* directory under {results_root.as_posix()}" | |
| ) | |
| runs = [(d.name, _read(d / "latency.json", _Latency, d.name)) for d in latency_dirs] | |
| base_id, base = runs[0] | |
| for run_id, run in runs: | |
| _check_latency_run(run_id, run, latency_prefix) | |
| for label, value, expected in ( | |
| ("protocol", run.protocol, base.protocol), | |
| ("inputs", run.inputs, base.inputs), | |
| ("settings", run.settings, base.settings), | |
| ("timing", run.timing, base.timing), | |
| ("seed", run.seed, base.seed), | |
| ): | |
| if value != expected: | |
| raise DashboardExportError(f"{run_id}: {label} doesn't match {base_id}") | |
| latency_slice = base.inputs.slice | |
| for label, value, expected in ( | |
| ( | |
| "slice fingerprint", | |
| latency_slice.fingerprint_sha256, | |
| comparison.slice.fingerprint_sha256, | |
| ), | |
| ("slice image count", latency_slice.images, comparison.slice.images), | |
| ("slice reference count", latency_slice.references, comparison.slice.references), | |
| ): | |
| if value != expected: | |
| raise DashboardExportError( | |
| f"{base_id}: {label} {value} differs from {comparison_id} ({expected})" | |
| ) | |
| for run_id in [row.run_id for row in comparison.rows]: | |
| if not (results_root / run_id).is_dir(): | |
| raise DashboardExportError( | |
| f"{comparison_id}: source run {run_id} has no directory under " | |
| f"{results_root.as_posix()}" | |
| ) | |
| model_ids = sorted({row.model_id for row in comparison.rows} | {r.model_id for _, r in runs}) | |
| unnamed = [model_id for model_id in model_ids if model_id not in DISPLAY_NAMES] | |
| if unnamed: | |
| raise DashboardExportError( | |
| f"no display name for model(s) {unnamed}; add it to DISPLAY_NAMES" | |
| ) | |
| s = comparison.slice | |
| return { | |
| "schema_version": DASHBOARD_SCHEMA_VERSION, | |
| "generated_by": EXPORT_COMMAND, | |
| "slice": { | |
| "description": ( | |
| f"{s.images} COCO train2017 images, in the order of {latency_slice.source}, each " | |
| f"scored against its stored references: {s.references} in total, about " | |
| f"{s.references_per_image:.2f} per image. The latency runs time the first " | |
| f"{base.settings.num_images} of these images." | |
| ), | |
| "source": latency_slice.source, | |
| "fingerprint_sha256": s.fingerprint_sha256, | |
| "images": s.images, | |
| "references": s.references, | |
| "references_per_image": s.references_per_image, | |
| }, | |
| "overlap_caveat": comparison.caveat, | |
| "quality": { | |
| "protocol": comparison.protocol, | |
| "normalisation": comparison.normalisation, | |
| "summary_run_id": comparison_id, | |
| "metrics": [ | |
| {"key": key, "label": label} | |
| for key, label in zip(METRIC_KEYS, METRIC_LABELS, strict=True) | |
| ], | |
| "notes": [note.format(rpi=s.references_per_image) for note in QUALITY_NOTES], | |
| }, | |
| "latency": { | |
| "protocol": base.protocol, | |
| "unit": "seconds per call; one call captions one batch", | |
| "statistics": list(_Summary.model_fields), | |
| "settings": base.settings.model_dump(), | |
| "timing": base.timing.model_dump(), | |
| "notes": list(LATENCY_NOTES), | |
| }, | |
| "models": [_model_entry(model_id, comparison, runs) for model_id in model_ids], | |
| } | |
| def render_dashboard_json(data: dict[str, Any]) -> str: | |
| """Serialise :func:`build_dashboard_data` output exactly as it is committed.""" | |
| return json.dumps(data, indent=2, ensure_ascii=False) + "\n" | |
| def _check_latency_run(run_id: str, run: _Latency, prefix: str) -> None: | |
| expected_id = f"{prefix}{run.model_id}-{run.decode_strategy}-{run.device}" | |
| if run_id != expected_id: | |
| raise DashboardExportError( | |
| f"{run_id}: latency.json describes {expected_id} (model, decoding and device)" | |
| ) | |
| if run.captioner not in BATCH_MODES: | |
| raise DashboardExportError(f"{run_id}: unknown captioner {run.captioner!r}") | |
| batch_sizes = [batch.batch_size for batch in run.batches] | |
| if batch_sizes != run.settings.batch_sizes: | |
| raise DashboardExportError( | |
| f"{run_id}: batches {batch_sizes} differ from settings.batch_sizes " | |
| f"{run.settings.batch_sizes}" | |
| ) | |
| for batch in run.batches: | |
| expected_count = batch.calls_per_pass * run.settings.measured_passes | |
| if len(batch.samples_seconds) != expected_count: | |
| raise DashboardExportError( | |
| f"{run_id}: batch size {batch.batch_size} has {len(batch.samples_seconds)} " | |
| f"samples, expected {expected_count}" | |
| ) | |
| if summarize(batch.samples_seconds) != batch.summary_seconds.model_dump(): | |
| raise DashboardExportError( | |
| f"{run_id}: batch size {batch.batch_size} summary_seconds doesn't match its " | |
| "samples_seconds" | |
| ) | |
| def _model_entry( | |
| model_id: str, comparison: _Comparison, runs: Sequence[tuple[str, _Latency]] | |
| ) -> dict[str, Any]: | |
| rows = [row for row in comparison.rows if row.model_id == model_id] | |
| latency = sorted( | |
| ((run_id, run) for run_id, run in runs if run.model_id == model_id), | |
| key=lambda item: (item[1].device, item[1].decode_strategy, item[0]), | |
| ) | |
| identity = { | |
| field: _agreed( | |
| model_id, | |
| field, | |
| [(row.run_id, getattr(row, field)) for row in rows] | |
| + [(run_id, getattr(run, field)) for run_id, run in latency], | |
| ) | |
| for field in ("backend", "hub_repo", "revision") | |
| } | |
| quality = [ | |
| { | |
| "run_id": row.run_id, | |
| "kind": row.kind, | |
| "revision": row.revision, | |
| "decode_strategy": row.decode_strategy, | |
| "decode_settings": row.decode_settings, | |
| "n_samples": row.n_samples, | |
| "metrics": row.metrics, | |
| "metric_errors": row.metric_errors, | |
| } | |
| for row in rows | |
| ] | |
| latency_entries = [ | |
| { | |
| "run_id": run_id, | |
| "device": run.device, | |
| "environment": run.environment, | |
| "decode_strategy": run.decode_strategy, | |
| "batch_mode": BATCH_MODES[run.captioner], | |
| "load_seconds": run.load_seconds, | |
| "batches": [ | |
| { | |
| "batch_size": batch.batch_size, | |
| "calls_per_pass": batch.calls_per_pass, | |
| "summary_seconds": batch.summary_seconds.model_dump(), | |
| } | |
| for batch in run.batches | |
| ], | |
| } | |
| for run_id, run in latency | |
| ] | |
| return { | |
| "model_id": model_id, | |
| "display_name": DISPLAY_NAMES[model_id], | |
| **identity, | |
| "source_run_ids": [entry["run_id"] for entry in quality + latency_entries], | |
| "quality": quality, | |
| "latency": latency_entries, | |
| } | |
| def _agreed(model_id: str, field: str, values: list[tuple[str, str | None]]) -> str: | |
| """Return the one value every run that records ``field`` gives for ``model_id``.""" | |
| recorded = {value for _, value in values if value is not None} | |
| if not recorded: | |
| raise DashboardExportError(f"{model_id}: no run records its {field}") | |
| if len(recorded) > 1: | |
| detail = ", ".join(f"{run_id}={value}" for run_id, value in values if value is not None) | |
| raise DashboardExportError(f"{model_id}: runs disagree on {field}: {detail}") | |
| return recorded.pop() | |
| def _read(path: Path, model: type[_ModelT], run_id: str) -> _ModelT: | |
| if not path.is_file(): | |
| raise DashboardExportError(f"{run_id}: missing {path.name}") | |
| try: | |
| return model.model_validate(json.loads(path.read_text(encoding="utf-8"))) | |
| except (ValueError, ValidationError) as exc: | |
| detail = str(exc).splitlines()[0] if str(exc) else type(exc).__name__ | |
| raise DashboardExportError(f"{run_id}: {path.name} is malformed: {detail}") from exc | |