Spaces:
Runtime error
Runtime error
| """Calibrate the 7-detector AI ensemble on HC3 (human vs ChatGPT answers). | |
| Pipeline: | |
| 1. sample balanced human/AI texts from data/calibration/hc3_all.jsonl | |
| 2. score all 7 detectors on each text | |
| 3. train a logistic meta-classifier (standardized features) | |
| 4. isotonic calibration on a held-out calib split -> honest probabilities | |
| 5. split-conformal abstain band: t_hi = 95th percentile of p over calib | |
| HUMAN texts (≤5% of humans exceed it), t_lo = 5th percentile over calib | |
| AI texts — between the two the verdict is INCONCLUSIVE, not a guess | |
| 6. save models/ai_meta.json + report held-out test metrics | |
| Run: python scripts/calibrate_ensemble.py [n_per_class] (default 300) | |
| """ | |
| import json | |
| import os | |
| import random | |
| import sys | |
| import time | |
| import numpy as np | |
| ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) | |
| sys.path.insert(0, ROOT) | |
| from plagdetect.aidetect import FEATURE_ORDER, detect_ai # noqa: E402 | |
| from plagdetect.normalize import deobfuscate # noqa: E402 | |
| from plagdetect.textutils import sentences # noqa: E402 | |
| HC3 = os.path.join(ROOT, "data", "calibration", "hc3_all.jsonl") | |
| OUT = os.path.join(ROOT, "models", "ai_meta.json") | |
| SCORED = os.path.join(ROOT, "data", "calibration", "hc3_scored.jsonl") | |
| MIN_CHARS, MIN_SENTS = 700, 8 | |
| def load_samples(n_per_class, seed=13): | |
| rng = random.Random(seed) | |
| human, ai = [], [] | |
| with open(HC3, "r", encoding="utf-8") as f: | |
| lines = f.readlines() | |
| rng.shuffle(lines) | |
| for ln in lines: | |
| try: | |
| rec = json.loads(ln) | |
| except json.JSONDecodeError: | |
| continue | |
| for pool, key in ((human, "human_answers"), (ai, "chatgpt_answers")): | |
| if len(pool) >= n_per_class: | |
| continue | |
| for ans in rec.get(key) or []: | |
| ans = (ans or "").strip() | |
| if len(ans) >= MIN_CHARS and len(sentences(ans)) >= MIN_SENTS: | |
| pool.append(ans) | |
| break | |
| if len(human) >= n_per_class and len(ai) >= n_per_class: | |
| break | |
| return human, ai | |
| def score_all(texts, label): | |
| rows = [] | |
| t0 = time.time() | |
| for i, t in enumerate(texts): | |
| t = deobfuscate(t)[0] | |
| try: | |
| r = detect_ai(t) | |
| except Exception as exc: | |
| print(f" [skip] {exc}") | |
| continue | |
| det = {d["name"]: d["score"] for d in r["detectors"]} | |
| if not all(k in det for k in FEATURE_ORDER): | |
| continue | |
| rows.append({"y": label, "x": [det[k] for k in FEATURE_ORDER]}) | |
| if (i + 1) % 25 == 0: | |
| rate = (i + 1) / (time.time() - t0) | |
| print(f" scored {i+1}/{len(texts)} (label={label}, " | |
| f"{rate:.1f}/s, eta {(len(texts)-i-1)/rate:.0f}s)", flush=True) | |
| return rows | |
| def main(n_per_class=300, refit=False): | |
| if refit and os.path.exists(SCORED): | |
| rows = [json.loads(ln) for ln in open(SCORED, encoding="utf-8")] | |
| print(f"REFIT: loaded {len(rows)} cached scores from {SCORED} " | |
| "(skipping detector scoring)") | |
| else: | |
| print("loading HC3 samples...") | |
| human, ai = load_samples(n_per_class) | |
| print(f"human={len(human)} ai={len(ai)}") | |
| rows = score_all(human, 0) + score_all(ai, 1) | |
| with open(SCORED, "w", encoding="utf-8") as f: | |
| for r in rows: | |
| f.write(json.dumps(r) + "\n") | |
| print(f"scored {len(rows)} samples -> {SCORED}") | |
| X = np.array([r["x"] for r in rows], dtype=float) | |
| y = np.array([r["y"] for r in rows], dtype=int) | |
| rng = np.random.RandomState(13) | |
| idx = rng.permutation(len(y)) | |
| n_tr, n_cal = int(0.6 * len(y)), int(0.2 * len(y)) | |
| tr, cal, te = idx[:n_tr], idx[n_tr:n_tr + n_cal], idx[n_tr + n_cal:] | |
| from sklearn.isotonic import IsotonicRegression | |
| from sklearn.linear_model import LogisticRegression | |
| from sklearn.metrics import roc_auc_score | |
| from sklearn.preprocessing import StandardScaler | |
| sc = StandardScaler().fit(X[tr]) | |
| clf = LogisticRegression(max_iter=2000, C=1.0).fit(sc.transform(X[tr]), y[tr]) | |
| p_cal = clf.predict_proba(sc.transform(X[cal]))[:, 1] | |
| p_te_raw = clf.predict_proba(sc.transform(X[te]))[:, 1] | |
| iso = IsotonicRegression(out_of_bounds="clip", y_min=0.001, y_max=0.999) | |
| iso.fit(p_cal, y[cal]) | |
| p_cal_c = iso.predict(p_cal) | |
| p_te = iso.predict(p_te_raw) | |
| # split-conformal abstain band (class-conditional, alpha=0.05): | |
| # t_lo = 95th pct of HUMAN probs -> below it, human is safe to call | |
| # t_hi = 5th pct of AI probs -> above it, AI is safe to call | |
| # between them: INCONCLUSIVE (statistically honest abstain) | |
| t_lo = float(np.quantile(p_cal_c[y[cal] == 0], 0.95)) | |
| t_hi = float(np.quantile(p_cal_c[y[cal] == 1], 0.05)) | |
| if t_lo >= t_hi: | |
| # classes so separable the thresholds cross — keep a minimum honest | |
| # abstain band around the midpoint so borderline real text still | |
| # gets INCONCLUSIVE rather than a false-confident call | |
| mid = 0.5 * (t_lo + t_hi) | |
| t_lo, t_hi = min(t_lo, mid - 0.08), max(t_hi, mid + 0.08) | |
| t_lo, t_hi = max(0.05, t_lo), min(0.95, t_hi) | |
| acc = float(((p_te >= 0.5) == y[te]).mean()) | |
| auc = float(roc_auc_score(y[te], p_te)) | |
| decided = (p_te <= t_lo) | (p_te >= t_hi) | |
| abstain = float(1 - decided.mean()) | |
| err_dec = float(((p_te[decided] >= 0.5) != y[te][decided]).mean()) if decided.any() else 0.0 | |
| print(f"\nTEST: acc={acc:.3f} auc={auc:.3f} | conformal band " | |
| f"[{t_lo:.3f}, {t_hi:.3f}] abstain={abstain:.1%} " | |
| f"error-when-decided={err_dec:.3%}") | |
| print("feature coefs:", dict(zip(FEATURE_ORDER, | |
| np.round(clf.coef_[0], 3).tolist()))) | |
| # piecewise isotonic curve for runtime interpolation | |
| gx = np.linspace(0, 1, 101) | |
| meta = { | |
| "features": FEATURE_ORDER, | |
| "mean": sc.mean_.tolist(), "scale": sc.scale_.tolist(), | |
| "coef": clf.coef_[0].tolist(), "intercept": float(clf.intercept_[0]), | |
| "iso_x": gx.tolist(), "iso_y": iso.predict(gx).tolist(), | |
| "t_lo": round(t_lo, 4), "t_hi": round(t_hi, 4), | |
| "test_accuracy": round(acc, 4), "test_auc": round(auc, 4), | |
| "abstain_rate": round(abstain, 4), | |
| "error_when_decided": round(err_dec, 5), | |
| "n_samples": len(rows), "dataset": "HC3", | |
| "trained": time.strftime("%Y-%m-%d %H:%M"), | |
| } | |
| with open(OUT, "w", encoding="utf-8") as f: | |
| json.dump(meta, f, indent=1) | |
| print("saved", OUT) | |
| if __name__ == "__main__": | |
| args = [a for a in sys.argv[1:] if a != "--refit"] | |
| main(int(args[0]) if args else 300, refit="--refit" in sys.argv) | |