Skip to content

Commit fe953cf

Browse files
committed
benchmark analysis: HITL pass-through, all-predictions audit, per-field breakdown
Tooling additions used to prepare steering-committee briefing materials. - hitl-planner: track per-document sample index on each prediction; compute per-document pass-through (zero-flag rate) at each target recall and emit hitl-passthrough.csv alongside the existing per-category / combined CSVs. - report-errors: add --include-all-predictions to emit all-predictions.csv (every cell, matched + non-matched), same schema as wrong-by-category.csv. Share wrapper passes the flag through. - hitl-per-category-plots: new diagnostic plotting tool that emits one two-panel PNG per category (recall-vs-workload + threshold-vs-workload) to make threshold cliffs in small-error categories (e.g. name, date) visually obvious. Share wrapper included. - verify-all-predictions: aggregate-only verification over all-predictions.csv (counts, accuracy per category, no values). Also emits per-field-breakdown with reviewable counts so the "fields with content per doc" figure is traceable to specific fields.
1 parent 73c0bf8 commit fe953cf

6 files changed

Lines changed: 549 additions & 3 deletions

File tree

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
#!/usr/bin/env bash
2+
# Generate per-category HITL detail plots from a UNC share benchmark JSON.
3+
# Same streaming pattern as hitl-planner-share.sh.
4+
set -euo pipefail
5+
6+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
7+
FIFO_DIR="$(mktemp -d -t hitl-plots-fifo-XXXXXX)"
8+
SHM_DIR="$(mktemp -d -p /dev/shm hitl-plots-XXXXXX 2>/dev/null \
9+
|| mktemp -d -t hitl-plots-XXXXXX)"
10+
POWERSHELL="/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe"
11+
WRITER_PIDS=()
12+
13+
cleanup() {
14+
for pid in "${WRITER_PIDS[@]}"; do kill "$pid" 2>/dev/null || true; done
15+
rm -rf "$FIFO_DIR" "$SHM_DIR"
16+
}
17+
trap cleanup EXIT
18+
19+
INPUT=""
20+
OUT_DIR=""
21+
EXTRA=()
22+
while [[ $# -gt 0 ]]; do
23+
case "$1" in
24+
--out-dir) OUT_DIR="$2"; shift 2 ;;
25+
--categories|--docs-count|--exclude-missing-in-categories|--skip-trivial-predictions-in-categories)
26+
EXTRA+=("$1" "$2"); shift 2 ;;
27+
*) if [[ -z "$INPUT" ]]; then INPUT="$1"; shift
28+
else echo "error: unexpected arg: $1" >&2; exit 2; fi ;;
29+
esac
30+
done
31+
[[ -z "$INPUT" || -z "$OUT_DIR" ]] && { echo "usage: $0 <input.json> --out-dir <dir> [options]" >&2; exit 2; }
32+
33+
INPUT_PATH="$INPUT"
34+
if [[ "$INPUT" == \\\\* || "$INPUT" == //* ]]; then
35+
INPUT_FIFO="$FIFO_DIR/input.json.fifo"
36+
mkfifo "$INPUT_FIFO"
37+
( "$POWERSHELL" -NoProfile -Command \
38+
"\$b = [System.IO.File]::ReadAllBytes('$INPUT'); \
39+
\$o = [System.Console]::OpenStandardOutput(); \
40+
\$o.Write(\$b, 0, \$b.Length); \$o.Close()" \
41+
> "$INPUT_FIFO" ) &
42+
WRITER_PIDS+=("$!")
43+
INPUT_PATH="$INPUT_FIFO"
44+
echo "input ← $INPUT (streamed)" >&2
45+
fi
46+
47+
python3 "${SCRIPT_DIR}/hitl-per-category-plots.py" \
48+
"$INPUT_PATH" \
49+
--out-dir "$SHM_DIR" \
50+
"${EXTRA[@]}"
51+
52+
for pid in "${WRITER_PIDS[@]}"; do wait "$pid" 2>/dev/null || true; done
53+
54+
if [[ "$OUT_DIR" == \\\\* || "$OUT_DIR" == //* ]]; then
55+
SHM_WIN=$(wslpath -w "$SHM_DIR")
56+
"$POWERSHELL" -NoProfile -Command \
57+
"if (-not (Test-Path -LiteralPath '$OUT_DIR')) { New-Item -ItemType Directory -Path '$OUT_DIR' -Force | Out-Null }; \
58+
Get-ChildItem -LiteralPath '$SHM_WIN' -File | ForEach-Object { Copy-Item -LiteralPath \$_.FullName -Destination '$OUT_DIR' -Force }" >&2
59+
echo "outputs → $OUT_DIR" >&2
60+
else
61+
mkdir -p "$OUT_DIR"
62+
cp "${SHM_DIR}"/* "$OUT_DIR/"
63+
echo "outputs → $OUT_DIR" >&2
64+
fi
65+
echo "done." >&2
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
#!/usr/bin/env python3
2+
"""Generate individual per-category HITL plots showing the full continuous
3+
threshold sweep. One PNG per category, plus one combined overview.
4+
5+
Usage:
6+
python hitl-per-category-plots.py <input.json> \
7+
--out-dir <dir> \
8+
--categories income_amounts,sin,phone,name,date \
9+
[--exclude-missing-in-categories income_amounts] \
10+
[--skip-trivial-predictions-in-categories income_amounts] \
11+
[--docs-count 99]
12+
"""
13+
from __future__ import annotations
14+
15+
import argparse
16+
import sys
17+
from pathlib import Path
18+
19+
from importlib import import_module
20+
21+
planner = import_module("hitl-planner")
22+
23+
import matplotlib
24+
matplotlib.use("Agg")
25+
import matplotlib.pyplot as plt
26+
27+
28+
def plot_single_category(
29+
cat: str,
30+
sweep_rows: list[dict],
31+
full_curve: list[dict],
32+
targets: list[float],
33+
out_path: Path,
34+
) -> None:
35+
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5.5))
36+
37+
xs = [r["reviews_per_100_docs"] for r in full_curve]
38+
ys = [r["recall"] * 100 for r in full_curve]
39+
ts = [r["threshold"] for r in full_curve]
40+
colour = planner.CATEGORY_COLOURS.get(cat, "#333")
41+
42+
# Left panel: recall vs reviews (log scale) — same as the combined chart
43+
ax1.plot(xs, ys, color=colour, linewidth=2)
44+
for r in sweep_rows:
45+
if r["threshold"] is None:
46+
continue
47+
ax1.scatter([r["reviews_per_100_docs"]], [r["recall_actual"] * 100],
48+
color=colour, edgecolor="black", s=70, zorder=5)
49+
ax1.annotate(
50+
f"T={r['threshold']:.2f}\n{r['predictions_reviewable_flagged']}/{r['predictions_reviewable_total']} flagged",
51+
xy=(r["reviews_per_100_docs"], r["recall_actual"] * 100),
52+
xytext=(8, -8), textcoords="offset points", fontsize=8,
53+
)
54+
55+
for tgt in targets:
56+
ax1.axhline(tgt * 100, color="grey", linestyle=":", linewidth=0.7, alpha=0.5)
57+
58+
ax1.set_xscale("log")
59+
ax1.set_xlim(left=1)
60+
ax1.set_ylim(0, 105)
61+
ax1.set_xlabel("Reviews per 100 documents (log scale)")
62+
ax1.set_ylabel("Errors caught (%)")
63+
ax1.set_title(f"{cat} — recall vs review workload")
64+
ax1.grid(alpha=0.3, which="both")
65+
66+
# Right panel: threshold vs reviews (linear) — shows the cliff
67+
ax2.plot(ts, xs, color=colour, linewidth=2)
68+
for r in sweep_rows:
69+
if r["threshold"] is None:
70+
continue
71+
ax2.scatter([r["threshold"]], [r["reviews_per_100_docs"]],
72+
color=colour, edgecolor="black", s=70, zorder=5)
73+
ax2.annotate(
74+
f"{int(r['target_recall']*100)}% recall",
75+
xy=(r["threshold"], r["reviews_per_100_docs"]),
76+
xytext=(6, 6), textcoords="offset points", fontsize=8,
77+
)
78+
79+
ax2.set_xlabel("Confidence threshold T")
80+
ax2.set_ylabel("Reviews per 100 documents")
81+
ax2.set_title(f"{cat} — threshold vs review workload")
82+
ax2.grid(alpha=0.3)
83+
84+
n_err = sweep_rows[0]["errors_total"]
85+
n_reviewable = sweep_rows[0]["predictions_reviewable_total"]
86+
fig.suptitle(
87+
f"{cat}: {n_err} errors, {n_reviewable} reviewable predictions across 99 docs",
88+
fontsize=12, fontweight="bold",
89+
)
90+
fig.tight_layout()
91+
fig.savefig(out_path, dpi=150)
92+
plt.close(fig)
93+
94+
95+
def main(argv: list[str]) -> int:
96+
ap = argparse.ArgumentParser()
97+
ap.add_argument("input")
98+
ap.add_argument("--out-dir", required=True, type=Path)
99+
ap.add_argument("--categories", default="income_amounts,sin,phone,name,date")
100+
ap.add_argument("--docs-count", type=int, default=99)
101+
ap.add_argument("--exclude-missing-in-categories", default="")
102+
ap.add_argument("--skip-trivial-predictions-in-categories", default="")
103+
args = ap.parse_args(argv)
104+
args.out_dir.mkdir(parents=True, exist_ok=True)
105+
106+
allowlist = [c.strip() for c in args.categories.split(",") if c.strip()]
107+
allowlist = [c for c in allowlist if c in planner.CATEGORY_ORDER]
108+
109+
exclude_missing = {c.strip() for c in args.exclude_missing_in_categories.split(",") if c.strip()}
110+
skip_trivial = {c.strip() for c in args.skip_trivial_predictions_in_categories.split(",") if c.strip()}
111+
112+
preds = planner.load_predictions(Path(args.input))
113+
print(f"loaded {len(preds)} predictions", file=sys.stderr)
114+
115+
by_cat_preds: dict[str, list] = {}
116+
for p in preds:
117+
if p.category in allowlist:
118+
by_cat_preds.setdefault(p.category, []).append(p)
119+
120+
for cat in allowlist:
121+
cat_preds = by_cat_preds.get(cat, [])
122+
if not cat_preds:
123+
continue
124+
cat_preds = planner.filter_predictions_for_category(cat_preds, cat, cat in exclude_missing)
125+
cat_skip = cat in skip_trivial
126+
sweep = planner.sweep_for_category(cat_preds, planner.TARGET_RECALLS, args.docs_count, skip_trivial=cat_skip)
127+
curve = planner.sweep_full_curve(cat_preds, args.docs_count, step=0.005, skip_trivial=cat_skip)
128+
129+
out_path = args.out_dir / f"hitl-detail-{cat}.png"
130+
plot_single_category(cat, sweep, curve, planner.TARGET_RECALLS, out_path)
131+
print(f" {cat}{out_path}", file=sys.stderr)
132+
133+
return 0
134+
135+
136+
if __name__ == "__main__":
137+
sys.exit(main(sys.argv[1:]))

scripts/benchmark analysis/hitl-planner.py

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ class Prediction:
111111
predicted_is_empty: bool # the model returned null / empty for this cell
112112
expected_is_empty: bool # ground truth is null / empty for this cell
113113
predicted_is_trivial: bool # predicted is empty or "looks like 0" — see _predicted_looks_trivial
114+
sample_index: int = 0 # ordinal index of the source sample (no PII)
114115

115116

116117
TARGET_RECALLS = [0.50, 0.70, 0.80, 0.90, 0.95, 0.99]
@@ -161,7 +162,7 @@ def _predicted_looks_trivial(value: Any) -> bool:
161162
def load_predictions(path: Path) -> list[Prediction]:
162163
raw = json.loads(path.read_text("utf-8"))
163164
out: list[Prediction] = []
164-
for sample in raw.get("perSampleResults") or []:
165+
for sample_idx, sample in enumerate(raw.get("perSampleResults") or []):
165166
for det in sample.get("evaluationDetails") or []:
166167
field = det.get("field")
167168
conf = det.get("confidence")
@@ -179,6 +180,7 @@ def load_predictions(path: Path) -> list[Prediction]:
179180
predicted_is_empty=pred_empty,
180181
expected_is_empty=exp_empty,
181182
predicted_is_trivial=pred_trivial,
183+
sample_index=sample_idx,
182184
))
183185
return out
184186

@@ -508,6 +510,93 @@ def plot_hitl_curves(
508510
plt.close(fig)
509511

510512

513+
# ---------------------------------------------------------------------------
514+
# Per-document pass-through analysis
515+
# ---------------------------------------------------------------------------
516+
517+
518+
def compute_passthrough(
519+
by_cat_preds: dict[str, list[Prediction]],
520+
by_category_sweep: dict[str, list[dict]],
521+
targets: list[float],
522+
docs_count: int,
523+
exclude_missing: set[str],
524+
skip_trivial: set[str],
525+
) -> list[dict]:
526+
"""For each target recall, compute the fraction of documents that have
527+
zero reviewable flagged predictions across ALL HITL-scoped categories.
528+
A document passes through only if none of its in-scope predictions are
529+
flagged at the combined per-category thresholds for that recall level.
530+
"""
531+
cats_present = [c for c in CATEGORY_ORDER if c in by_category_sweep]
532+
533+
per_cat_filtered: dict[str, list[Prediction]] = {}
534+
for cat in cats_present:
535+
cat_preds = by_cat_preds.get(cat, [])
536+
per_cat_filtered[cat] = filter_predictions_for_category(
537+
cat_preds, cat, cat in exclude_missing
538+
)
539+
540+
rows: list[dict] = []
541+
for idx, target in enumerate(targets):
542+
per_cat_thresholds: dict[str, float] = {}
543+
for cat in cats_present:
544+
sweep_row = by_category_sweep[cat][idx]
545+
t = sweep_row["threshold"]
546+
per_cat_thresholds[cat] = t if t is not None else 0.0
547+
548+
doc_flags: dict[int, int] = {i: 0 for i in range(docs_count)}
549+
for cat in cats_present:
550+
t = per_cat_thresholds[cat]
551+
use_skip_trivial = cat in skip_trivial
552+
reviewable_fn = _reviewable_skip_trivial if use_skip_trivial else _reviewable_default
553+
for p in per_cat_filtered[cat]:
554+
if p.confidence < t and reviewable_fn(p):
555+
doc_flags[p.sample_index] = doc_flags.get(p.sample_index, 0) + 1
556+
557+
flag_counts = list(doc_flags.values())
558+
zero_flag_docs = sum(1 for c in flag_counts if c == 0)
559+
one_flag_docs = sum(1 for c in flag_counts if c == 1)
560+
two_flag_docs = sum(1 for c in flag_counts if c == 2)
561+
three_plus_docs = sum(1 for c in flag_counts if c >= 3)
562+
563+
rows.append({
564+
"target_recall": target,
565+
"docs_total": docs_count,
566+
"docs_zero_flags": zero_flag_docs,
567+
"docs_one_flag": one_flag_docs,
568+
"docs_two_flags": two_flag_docs,
569+
"docs_three_plus_flags": three_plus_docs,
570+
"passthrough_pct": zero_flag_docs * 100 / docs_count if docs_count else 0.0,
571+
"avg_flags_per_doc": sum(flag_counts) / docs_count if docs_count else 0.0,
572+
"max_flags": max(flag_counts) if flag_counts else 0,
573+
})
574+
return rows
575+
576+
577+
def write_passthrough_csv(rows: list[dict], out_path: Path) -> None:
578+
header = [
579+
"target_recall", "docs_total",
580+
"docs_zero_flags", "docs_one_flag", "docs_two_flags", "docs_three_plus_flags",
581+
"passthrough_pct", "avg_flags_per_doc", "max_flags",
582+
]
583+
with out_path.open("w", newline="", encoding="utf-8") as f:
584+
w = csv.writer(f)
585+
w.writerow(header)
586+
for r in rows:
587+
w.writerow([
588+
f"{r['target_recall']:.2f}",
589+
r["docs_total"],
590+
r["docs_zero_flags"],
591+
r["docs_one_flag"],
592+
r["docs_two_flags"],
593+
r["docs_three_plus_flags"],
594+
f"{r['passthrough_pct']:.1f}",
595+
f"{r['avg_flags_per_doc']:.2f}",
596+
r["max_flags"],
597+
])
598+
599+
511600
# ---------------------------------------------------------------------------
512601
# Entrypoint
513602
# ---------------------------------------------------------------------------
@@ -610,8 +699,14 @@ def main(argv: list[str]) -> int:
610699
args.out_dir / "hitl-curves.png", args.engine_label,
611700
)
612701

702+
passthrough_rows = compute_passthrough(
703+
by_cat_preds, by_category_sweep, TARGET_RECALLS,
704+
args.docs_count, exclude_missing, skip_trivial,
705+
)
706+
write_passthrough_csv(passthrough_rows, args.out_dir / "hitl-passthrough.csv")
707+
613708
print(f"\nWrote outputs to {args.out_dir}/", file=sys.stderr)
614-
print(" CSVs: hitl-per-category.csv, hitl-combined.csv", file=sys.stderr)
709+
print(" CSVs: hitl-per-category.csv, hitl-combined.csv, hitl-passthrough.csv", file=sys.stderr)
615710
print(" PNG: hitl-curves.png", file=sys.stderr)
616711
# Summary line
617712
last = by_category_sweep

scripts/benchmark analysis/report-errors-share.sh

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,11 @@ trap cleanup EXIT
3333

3434
ENGINE_ARGS=()
3535
OUT_DIR=""
36+
EXTRA_FLAGS=()
3637
while [[ $# -gt 0 ]]; do
3738
case "$1" in
3839
--out-dir) OUT_DIR="$2"; shift 2 ;;
40+
--include-all-predictions) EXTRA_FLAGS+=("$1"); shift ;;
3941
-h|--help)
4042
sed -n 's/^# //p; s/^#//p' "$0" | sed -n '1,18p'
4143
exit 0 ;;
@@ -74,7 +76,8 @@ done
7476
# Run the report generator.
7577
python3 "${SCRIPT_DIR}/report-errors.py" \
7678
"${STAGED[@]}" \
77-
--out-dir "$SHM_DIR"
79+
--out-dir "$SHM_DIR" \
80+
"${EXTRA_FLAGS[@]}"
7881

7982
# Wait for input writers.
8083
for pid in "${WRITER_PIDS[@]}"; do

0 commit comments

Comments
 (0)