Spaces:
Running
Running
| """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() | |