File size: 6,153 Bytes
35d483e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
#!/usr/bin/env python3
"""Create a privacy-safe failure queue from development predictions."""

from __future__ import annotations

import argparse
import hashlib
import json
from collections import defaultdict
from pathlib import Path
from typing import Any

ROOT = Path(__file__).resolve().parents[1]


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--metrics", required=True)
    parser.add_argument("--predictions", help="default: <metrics stem>.predictions.jsonl")
    parser.add_argument("--output", required=True)
    parser.add_argument("--examples-per-type", type=int, default=20)
    parser.add_argument("--min-slice-count", type=int, default=5)
    return parser.parse_args()


def _resolve(value: str) -> Path:
    path = Path(value)
    return path if path.is_absolute() else ROOT / path


def _case_id(record_id: object) -> str:
    return "case_" + hashlib.sha256(str(record_id).encode()).hexdigest()[:12]


def _load_predictions(path: Path) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    seen: set[str] = set()
    with path.open(encoding="utf-8") as handle:
        for line_number, line in enumerate(handle, start=1):
            if not line.strip():
                continue
            try:
                row = json.loads(line)
            except json.JSONDecodeError as exc:
                raise ValueError(f"{path}:{line_number}: invalid JSON") from exc
            if not isinstance(row, dict):
                raise ValueError(f"{path}:{line_number}: expected an object")
            record_id = str(row.get("record_id", ""))
            if not record_id or record_id in seen:
                raise ValueError(f"{path}:{line_number}: missing or duplicate record_id")
            seen.add(record_id)
            rows.append(row)
    if not rows:
        raise ValueError("prediction file is empty")
    return rows


def _review_case(row: dict[str, Any], prediction: int) -> dict[str, Any]:
    return {
        "case_id": _case_id(row["record_id"]),
        "target": "END" if int(row["label"]) else "HOLD",
        "predicted": "END" if prediction else "HOLD",
        "p_end": float(row["probability"]),
        "language": row.get("language"),
        "dataset": row.get("dataset"),
        "synthetic": row.get("synthetic"),
        "filler_type": row.get("filler_type"),
        "duration_bin": row.get("duration_bin"),
        "review_note": "Listen under authorized local access; do not export audio or transcript.",
    }


def main() -> int:
    args = parse_args()
    if args.examples_per_type < 1 or args.min_slice_count < 1:
        raise SystemExit("example and slice counts must be positive")
    metrics_path = _resolve(args.metrics)
    try:
        metrics = json.loads(metrics_path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError) as exc:
        raise SystemExit(f"invalid metrics JSON: {metrics_path}") from exc
    threshold = metrics.get("threshold")
    if not isinstance(threshold, int | float) or not 0.0 <= threshold <= 1.0:
        raise SystemExit("metrics JSON has no valid threshold")
    predictions_path = (
        _resolve(args.predictions)
        if args.predictions
        else metrics_path.with_name(metrics_path.stem + ".predictions.jsonl")
    )
    rows = _load_predictions(predictions_path)
    false_interruptions: list[dict[str, Any]] = []
    missed_ends: list[dict[str, Any]] = []
    slices: dict[str, dict[str, dict[str, int]]] = {
        dimension: defaultdict(lambda: {"count": 0, "false_interruptions": 0, "missed_ends": 0})
        for dimension in ("language", "dataset", "synthetic", "filler_type", "duration_bin")
    }
    for row in rows:
        label = int(row["label"])
        probability = float(row["probability"])
        if label not in (0, 1) or not 0.0 <= probability <= 1.0:
            raise SystemExit("predictions contain invalid labels or probabilities")
        prediction = int(probability >= threshold)
        is_false_interruption = prediction == 1 and label == 0
        is_missed_end = prediction == 0 and label == 1
        if is_false_interruption:
            false_interruptions.append(_review_case(row, prediction))
        elif is_missed_end:
            missed_ends.append(_review_case(row, prediction))
        for dimension, values in slices.items():
            value = str(row.get(dimension, "<missing>"))
            values[value]["count"] += 1
            values[value]["false_interruptions"] += int(is_false_interruption)
            values[value]["missed_ends"] += int(is_missed_end)

    false_interruptions.sort(key=lambda row: float(row["p_end"]), reverse=True)
    missed_ends.sort(key=lambda row: float(row["p_end"]))
    filtered_slices = {
        dimension: {
            value: counts
            for value, counts in sorted(values.items())
            if counts["count"] >= args.min_slice_count
        }
        for dimension, values in slices.items()
    }
    report = {
        "scope": metrics.get("data_scope"),
        "split": metrics.get("split"),
        "development_only": metrics.get("development_only"),
        "threshold": threshold,
        "privacy": (
            "Case IDs are one-way hashes. No audio, transcript, raw record ID, or source path "
            "is included. Hypotheses require authorized local listening."
        ),
        "counts": {
            "examples": len(rows),
            "false_interruptions": len(false_interruptions),
            "missed_ends": len(missed_ends),
        },
        "highest_confidence_false_interruptions": false_interruptions[: args.examples_per_type],
        "highest_confidence_missed_ends": missed_ends[: args.examples_per_type],
        "failure_counts_by_slice": filtered_slices,
    }
    output = _resolve(args.output)
    output.parent.mkdir(parents=True, exist_ok=True)
    output.write_text(
        json.dumps(report, indent=2, sort_keys=True, allow_nan=False) + "\n",
        encoding="utf-8",
    )
    print(json.dumps(report["counts"], indent=2))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())