docfield_extract / eval /splits.py
kenzychew's picture
eval: add tuning/held-out split selection to the score command
b8930b3
Raw
History Blame Contribute Delete
5.38 kB
"""Named subsets of a dataset's cache (tuning vs held-out).
Threshold selection and rule diagnosis have to happen on *some* documents, and
metrics reported on those same documents are contaminated: the operating point
was fitted to them. Splitting the cache keeps the two roles apart, so a held-out
number can be quoted without that caveat.
**Membership is by id, never by position.** A manifest file pins the exact ids
belonging to a split::
eval/splits/<dataset>_tuning.json {"ids": ["X00016469670", ...], ...}
Everything cached for the dataset that is *not* in the manifest is held out.
That definition is stable under any of the things that break a positional
"first N" rule: cache files are read in filesystem-glob order, which need not
match the order the dataset streamed them in; new documents can be added later;
a re-predict can rewrite a file's timestamp. Only the id set matters, so the
same documents land in the same split on every machine and every run.
The manifest is committed even though ``eval/cache/`` is git-ignored. The cache
can be regenerated from the dataset; the record of *which documents were used to
tune* cannot be, and losing it would silently turn a held-out claim into an
in-sample one.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Literal
# Committed alongside the code, unlike the cache itself.
DEFAULT_SPLITS_DIR = Path("eval/splits")
SplitName = Literal["all", "tuning", "heldout"]
SPLIT_NAMES: tuple[str, ...] = ("all", "tuning", "heldout")
class SplitError(RuntimeError):
"""Raised when a named split cannot be resolved for a dataset."""
def manifest_path(dataset: str, *, splits_dir: Path = DEFAULT_SPLITS_DIR) -> Path:
"""Return the tuning-manifest path for a dataset (not checked for existence)."""
return Path(splits_dir) / f"{dataset}_tuning.json"
def load_tuning_ids(dataset: str, *, splits_dir: Path = DEFAULT_SPLITS_DIR) -> frozenset[str]:
"""Load the set of tuning-slice ids for a dataset.
Args:
dataset: Dataset name.
splits_dir: Directory holding split manifests.
Returns:
The frozen set of example ids in the tuning slice.
Raises:
SplitError: If the manifest is missing, unreadable, or declares no ids.
"""
path = manifest_path(dataset, splits_dir=splits_dir)
if not path.exists():
raise SplitError(
f"No tuning manifest for dataset {dataset!r} at {path}. "
"A tuning/held-out split needs one; score the whole cache with "
"--split all, or write the manifest first."
)
try:
payload: dict[str, Any] = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise SplitError(f"Tuning manifest {path} is not valid JSON: {exc}") from exc
ids = payload.get("ids")
if not isinstance(ids, list) or not ids:
raise SplitError(f"Tuning manifest {path} declares no 'ids' list.")
return frozenset(str(value) for value in ids)
def select(
entries: list[dict[str, Any]],
split: str,
*,
dataset: str,
splits_dir: Path = DEFAULT_SPLITS_DIR,
) -> list[dict[str, Any]]:
"""Filter cached entries down to a named split.
Selection is set membership on the entry id, so the result depends only on
which documents are cached -- not on the order they were read in. Input order
is preserved among the surviving entries.
Args:
entries: Cached entries for the dataset.
split: One of ``"all"``, ``"tuning"``, or ``"heldout"``.
dataset: Dataset name, used to locate the manifest.
splits_dir: Directory holding split manifests.
Returns:
The subset of ``entries`` belonging to ``split``.
Raises:
SplitError: If ``split`` is unknown, the manifest is needed but missing,
or the split resolves to no documents at all.
"""
if split not in SPLIT_NAMES:
raise SplitError(f"Unknown split {split!r}; expected one of {', '.join(SPLIT_NAMES)}.")
if split == "all":
return list(entries)
tuning_ids = load_tuning_ids(dataset, splits_dir=splits_dir)
if split == "tuning":
selected = [e for e in entries if str(e.get("id")) in tuning_ids]
else:
selected = [e for e in entries if str(e.get("id")) not in tuning_ids]
if not selected:
raise SplitError(
f"Split {split!r} selected 0 of {len(entries)} cached entries for "
f"dataset {dataset!r}. The cache may not have been expanded beyond "
"the tuning slice yet."
)
return selected
def describe(split: str, n_selected: int, n_total: int) -> str:
"""Render a one-line description of the active split for a report header.
Args:
split: The split name.
n_selected: Documents in the split.
n_total: Documents cached for the dataset overall.
Returns:
A human-readable summary naming the split and its size.
"""
if split == "all":
note = "WHOLE CACHE -- mixes tuning and held-out documents"
elif split == "tuning":
note = "TUNING slice -- the operating point was fitted on these, not independent"
else:
note = "HELD OUT -- never used to select the threshold or diagnose rules"
return f"{split} ({n_selected} of {n_total} cached): {note}"