data-use-annotate / build_pool_queue.py
rafmacalaba's picture
annotation review app (per-user queues, Hub-backed rulings, static-safe direct commit)
53ea208 verified
Raw
History Blame Contribute Delete
4.78 kB
#!/usr/bin/env python3
"""Build an annotation queue from rafmacalaba/datause-ner via datasets-server.
Stdlib only (urllib) so it runs anywhere, including Spaces builds.
uv run python human_labeling/build_pool_queue.py [--config probe_candidates] [--split pool]
[--limit 500] [--out human_labeling/queue.json]
Defaults target the annotatable config: probe_candidates/pool is the only
one with an embedded `passage`. --config probe_splits yields surface-only
triage items flagged ctx_missing (no passage on the Hub; see README).
Stratified sample: round-robin over origin x score-band so the queue
covers keep/confusion/drop and every origin instead of the head-heavy
random draw (proxy bands on the Hub score; true tags come from rescore).
"""
import argparse
import json
import urllib.parse
import urllib.request
from pathlib import Path
REPO = Path(__file__).resolve().parent
DATASET = "rafmacalaba/datause-ner"
API = "https://datasets-server.huggingface.co/rows"
BANDS = (("drop", 0.0, 0.05), ("confusion", 0.05, 0.9), ("keep", 0.9, 1.01))
# Proxy strata on the Hub (v3-era) score, used ONLY to stratify the sample
# across the eventual decision zones (same 0.05/0.9 edges as the audit
# deck). True keep/confusion/drop labels are assigned later by
# rescore_singlepass.probe_labels.decide; the written item band is always
# "unscored" until then.
def band_of(score: float) -> str:
for name, lo, hi in BANDS:
if lo <= score < hi:
return name
return "confusion"
def fetch_rows(config: str, split: str, offset: int, length: int) -> list[dict]:
import time
import urllib.error
q = urllib.parse.urlencode(
{"dataset": DATASET, "config": config, "split": split,
"offset": offset, "length": length}
)
last = None
for attempt in range(6):
try:
with urllib.request.urlopen(f"{API}?{q}", timeout=60) as r:
payload = json.load(r)
return [d["row"] for d in payload.get("rows", [])]
except urllib.error.HTTPError as e:
last = e
if e.code not in (429, 500, 502, 503):
raise
time.sleep(2 ** attempt)
raise last
def to_item(row: dict, config: str, split: str) -> dict:
score = float(row.get("head_score") or 0.0)
passage = row.get("passage")
start, end = row.get("start"), row.get("end")
item = {
"key": row.get("key"),
"surface": row.get("surface"),
"ctx": passage, # None for probe_splits rows (no passage on Hub)
"ctx_missing": passage is None,
"head_score": score, # Hub v3-era placeholder; rescore overwrites
"start": start, "end": end,
"band": "unscored", # rescore_singlepass tags keep/confusion/drop
"origin": row.get("origin"),
"specificity": row.get("extractor_label") or row.get("stratum") or "",
"split": row.get("split", split),
"queue": f"{config}/{split}",
}
return item
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="probe_candidates")
ap.add_argument("--split", default="pool")
ap.add_argument("--limit", type=int, default=500)
ap.add_argument("--fetch", type=int, default=5000,
help="rows scanned from the Hub for stratification")
ap.add_argument("--seed", type=int, default=7)
ap.add_argument("--out", default=str(REPO / "queue.json"))
a = ap.parse_args()
scanned: list[dict] = []
offset = 0
while len(scanned) < a.fetch:
batch = fetch_rows(a.config, a.split, offset, min(100, a.fetch - len(scanned)))
if not batch:
break
scanned.extend(batch)
offset += len(batch)
# stratify: round-robin over (origin, band)
import random
rng = random.Random(a.seed)
buckets: dict[tuple, list[dict]] = {}
for row in scanned:
item = to_item(row, a.config, a.split)
buckets.setdefault((item["origin"], item["band"]), []).append(item)
for b in buckets.values():
rng.shuffle(b)
queue: list[dict] = []
while len(queue) < min(a.limit, len(scanned)) and buckets:
for k in sorted(buckets):
if len(queue) >= a.limit:
break
if buckets[k]:
queue.append(buckets[k].pop())
buckets = {k: v for k, v in buckets.items() if v}
rng.shuffle(queue)
out = Path(a.out)
out.write_text("\n".join(json.dumps(q) for q in queue) + "\n")
origins = sorted({q["origin"] for q in queue})
missing = sum(1 for q in queue if q["ctx_missing"])
print(f"scanned={len(scanned)} queued={len(queue)} origins={origins} "
f"ctx_missing={missing} -> {out}")
if __name__ == "__main__":
main()