File size: 11,412 Bytes
497f49b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
"""Measure Fast-DetectGPT accuracy on HC3 and fit its logistic calibration.

This is the provenance for the constants in models/text_calib/fastdetectgpt.json.
Re-run it to reproduce or refresh them.

    python scripts/eval_text_detection.py results.json
    python scripts/eval_text_detection.py results.json --write-calibration

Reports AUROC first, because it is threshold-free and calibration-independent:
it answers "does the curvature signal separate human from machine text at all?"
Accuracy at a fixed threshold conflates that question with how good the
calibration is, so it is reported second.

FPR (humans wrongly flagged) is reported separately and broken out by document
length. For an academic-integrity tool a false accusation is the expensive
error, so a single averaged accuracy figure hides the number that matters.

Requires: datasets, scikit-learn (both in requirements.txt). Downloads the HC3
dataset by streaming and the scoring model (default distilgpt2) on first run.
"""

from __future__ import annotations

import argparse
import json
import random
import sys
import time
from pathlib import Path

import numpy as np
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score, roc_curve

# Allow `python scripts/eval_text_detection.py` from the repo root: Python puts
# this file's directory on sys.path, not the cwd, so the package is not found.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

from text_detection.calibration import sigmoid  # noqa: E402
from text_detection.config import settings  # noqa: E402
from text_detection.detectors.fastdetectgpt import (  # noqa: E402
    _DEFAULT_A,
    _DEFAULT_B,
    FastDetectGPTDetector,
)

MIN_WORDS = 50           # matches the liability firewall's reliability floor
N_PER_CLASS = 300
SEED = 0
TRAIN_FRACTION = 0.5
FPR_TARGETS = (0.0, 0.005, 0.01, 0.02, 0.05)
LENGTH_BUCKETS = ((50, 100), (100, 200), (200, 10**9))


def collect_samples() -> list[tuple[str, int]]:
    """Balanced HC3 sample: label 1 = ChatGPT, 0 = human.

    Takes at most one answer of each class per row, so the human and machine
    answers are responses to the same questions. That pairing controls for
    topic: any separation measured is register, not subject matter.

    Works with datasets>=3 (script-based) or loads raw JSONL files directly
    as fallback (compatible with datasets v4+/v5+).
    """
    try:
        from datasets import load_dataset

        ds = load_dataset("Hello-SimpleAI/HC3", "all", split="train", streaming=True)
        return collect_from_records(ds)
    except (ImportError, RuntimeError):
        pass

    # Fallback: load JSONL files directly from HuggingFace Hub
    import json

    import httpx

    BASE = "https://huggingface.co/datasets/Hello-SimpleAI/HC3/resolve/main"
    JSONL_FILES = [
        "all.jsonl",
        "finance.jsonl",
        "medicine.jsonl",
        "open_qa.jsonl",
        "reddit_eli5.jsonl",
        "wiki_csai.jsonl",
    ]
    rows: list[dict] = []
    for fname in JSONL_FILES:
        url = f"{BASE}/{fname}"
        resp = httpx.get(url, follow_redirects=True, timeout=120)
        resp.raise_for_status()
        for line in resp.text.strip().splitlines():
            line = line.strip()
            if line:
                rows.append(json.loads(line))

    return collect_from_records(rows)


def collect_from_records(rows) -> list[tuple[str, int]]:
    """Pull HC3 answers from dataset rows, balanced by class."""
    import datasets as _ds

    human: list[str] = []
    ai: list[str] = []

    for row in rows:
        if len(human) >= N_PER_CLASS and len(ai) >= N_PER_CLASS:
            break
        if isinstance(row, dict):
            ha = row.get("human_answers") or []
            ca = row.get("chatgpt_answers") or []
        else:
            ha = row["human_answers"] if hasattr(row, "get") else []
            ca = row["chatgpt_answers"] if hasattr(row, "get") else []
        if not isinstance(ha, list):
            try:
                ha = list(ha) if hasattr(ha, "__iter__") else [str(ha)]
            except TypeError:
                ha = []
        if not isinstance(ca, list):
            try:
                ca = list(ca) if hasattr(ca, "__iter__") else [str(ca)]
            except TypeError:
                ca = []
        for txt in ha:
            if isinstance(txt, str) and txt.strip():
                if len(human) < N_PER_CLASS and len(txt.split()) >= MIN_WORDS:
                    human.append(txt.strip())
                    break
        for txt in ca:
            if isinstance(txt, str) and txt.strip():
                if len(ai) < N_PER_CLASS and len(txt.split()) >= MIN_WORDS:
                    ai.append(txt.strip())
                    break

    if len(human) < N_PER_CLASS or len(ai) < N_PER_CLASS:
        print(
            f"  collected {len(human)} human / {len(ai)} AI samples "
            f"(target {N_PER_CLASS} each) — fewer than requested",
            flush=True,
        )

    samples = [(t, 0) for t in human] + [(t, 1) for t in ai]
    random.Random(SEED).shuffle(samples)
    return samples


def metrics(labels: np.ndarray, preds: np.ndarray) -> dict:
    tp = int(((preds == 1) & (labels == 1)).sum())
    tn = int(((preds == 0) & (labels == 0)).sum())
    fp = int(((preds == 1) & (labels == 0)).sum())
    fn = int(((preds == 0) & (labels == 1)).sum())
    n = len(labels)
    return {
        "accuracy": round((tp + tn) / n, 4) if n else 0.0,
        "fpr_humans_wrongly_flagged": round(fp / (fp + tn), 4) if (fp + tn) else 0.0,
        "recall_ai_caught": round(tp / (tp + fn), 4) if (tp + fn) else 0.0,
        "precision": round(tp / (tp + fp), 4) if (tp + fp) else 0.0,
        "tp": tp, "tn": tn, "fp": fp, "fn": fn,
    }


def score_samples(
    samples: list[tuple[str, int]]
) -> tuple[np.ndarray, np.ndarray, np.ndarray, float]:
    """Score every sample, returning (scores, labels, word_counts, seconds)."""
    det = FastDetectGPTDetector()
    scores: list[float] = []
    labels: list[int] = []
    words: list[int] = []

    t0 = time.perf_counter()
    for i, (text, y) in enumerate(samples):
        try:
            res = det._score(text)
        except Exception as exc:
            print(f"  scoring failed on {i}: {exc}", flush=True)
            continue
        scores.append(float(res.raw["discrepancy"]))
        labels.append(y)
        words.append(len(text.split()))
        if (i + 1) % 100 == 0:
            print(f"  scored {i + 1}/{len(samples)} "
                  f"({time.perf_counter() - t0:.0f}s)", flush=True)

    return (
        np.asarray(scores),
        np.asarray(labels),
        np.asarray(words),
        time.perf_counter() - t0,
    )


def operating_points(y: np.ndarray, s: np.ndarray) -> list[dict]:
    """Recall achievable at each target FPR ceiling.

    The decision-relevant view for an integrity tool: pick the false-accusation
    rate you can defend, then read off how much AI text you still catch.
    """
    fpr, tpr, thr = roc_curve(y, s)
    out = []
    for target in FPR_TARGETS:
        i = max(int(np.searchsorted(fpr, target, side="right")) - 1, 0)
        out.append({
            "target_fpr": target,
            "actual_fpr": round(float(fpr[i]), 4),
            "recall_ai_caught": round(float(tpr[i]), 4),
            "raw_threshold": round(float(thr[i]), 4),
        })
    return out


def fpr_by_length(y: np.ndarray, p: np.ndarray, w: np.ndarray) -> list[dict]:
    """FPR among humans only, bucketed by document length."""
    out = []
    for lo, hi in LENGTH_BUCKETS:
        m = (y == 0) & (w >= lo) & (w < hi)
        if not m.sum():
            continue
        out.append({
            "words": f"{lo}-{'+' if hi >= 10**9 else hi}",
            "n_human": int(m.sum()),
            "fpr": round(float((p[m] >= settings.ai_threshold).mean()), 4),
        })
    return out


def main() -> None:
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("out", nargs="?", default="eval_results.json",
                    help="where to write the JSON report")
    ap.add_argument("--write-calibration", action="store_true",
                    help=f"also write the refitted (a, b) to "
                         f"{settings.calib_dir}/fastdetectgpt.json")
    args = ap.parse_args()

    print("collecting HC3 samples...", flush=True)
    samples = collect_samples()
    n_human = sum(1 for _, y in samples if y == 0)
    print(f"  {len(samples)} samples ({n_human} human / "
          f"{len(samples) - n_human} AI)", flush=True)

    s, y, w, elapsed = score_samples(samples)

    out: dict = {
        "n": int(len(y)),
        "n_human": int((y == 0).sum()),
        "n_ai": int((y == 1).sum()),
        "scoring_model": settings.scoring_model,
        "ai_threshold": settings.ai_threshold,
        "seconds": round(elapsed, 1),
        "ms_per_doc": round(1000 * elapsed / max(len(y), 1), 1),
        "raw_score": {
            "human_mean": round(float(s[y == 0].mean()), 4),
            "ai_mean": round(float(s[y == 1].mean()), 4),
            "human_std": round(float(s[y == 0].std()), 4),
            "ai_std": round(float(s[y == 1].std()), 4),
        },
        # Threshold-free and calibration-independent - report this first.
        "auroc": round(float(roc_auc_score(y, s)), 4),
        "operating_points": operating_points(y, s),
    }

    # Shipped calibration, whatever is currently in effect.
    p_default = np.array([sigmoid(_DEFAULT_A * v + _DEFAULT_B) for v in s])
    out["shipped_calibration"] = {
        "a": _DEFAULT_A, "b": _DEFAULT_B,
        **metrics(y, (p_default >= settings.ai_threshold).astype(int)),
    }

    # Refit on a train split, measure on held-out test. The split is what makes
    # the threshold metrics honest; AUROC would be unchanged by any refit.
    idx = np.arange(len(y))
    np.random.default_rng(SEED).shuffle(idx)
    cut = int(len(idx) * TRAIN_FRACTION)
    tr, te = idx[:cut], idx[cut:]

    lr = LogisticRegression()
    lr.fit(s[tr].reshape(-1, 1), y[tr])
    a_fit = float(lr.coef_[0][0])
    b_fit = float(lr.intercept_[0])

    p_test = np.array([sigmoid(a_fit * v + b_fit) for v in s[te]])
    out["refitted_calibration"] = {
        "a": round(a_fit, 4), "b": round(b_fit, 4),
        "n_train": int(len(tr)), "n_test": int(len(te)),
        "at_ai_threshold": metrics(
            y[te], (p_test >= settings.ai_threshold).astype(int)
        ),
    }

    p_all = np.array([sigmoid(a_fit * v + b_fit) for v in s])
    out["fpr_by_length"] = fpr_by_length(y, p_all, w)

    print("\n=== RESULTS ===")
    print(json.dumps(out, indent=2))
    Path(args.out).write_text(json.dumps(out, indent=2))
    print(f"\nwrote {args.out}")

    if args.write_calibration:
        path = Path(settings.calib_dir) / "fastdetectgpt.json"
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(json.dumps({"a": round(a_fit, 4), "b": round(b_fit, 4)}))
        print(f"wrote {path}")

    print(
        "\nNOTE: HC3 is an easy benchmark - unedited 2022-era ChatGPT prose against\n"
        "native-English Reddit/expert answers. It does not measure over-flagging of\n"
        "non-native-English writing, nor paraphrased or human-edited AI text.\n"
        "Treat these numbers as an upper bound, not a production expectation."
    )


if __name__ == "__main__":
    main()