Spaces:
Running
Running
File size: 6,623 Bytes
f12e82b | 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 | """
Fit Platt scaling calibration parameters from features.csv.
Usage:
python scripts/fit_platt.py # fit on val split (default)
python scripts/fit_platt.py --split val # explicit val split
python scripts/fit_platt.py --split train+val # use all labelled rows
python scripts/fit_platt.py --dry-run # print params, do not save
Prerequisites:
data/features.csv β produced by scripts/extract_features.py
data/manifest.csv β provides split/label columns (optional fallback)
Output:
data/reference/platt_params.json β {"A": float, "B": float}
Reload: restart the backend, or add a POST /api/v1/platt/reload endpoint.
"""
import csv
import json
import math
import logging
import argparse
import numpy as np
from pathlib import Path
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
datefmt="%H:%M:%S",
)
logger = logging.getLogger(__name__)
ROOT = Path(__file__).parents[1]
FEATURES = ROOT / "data" / "features.csv"
MANIFEST = ROOT / "data" / "manifest.csv"
PARAMS_OUT = ROOT / "data" / "reference" / "platt_params.json"
N_FEATURES = 30 # f0..f29
def _load_manifest_meta() -> dict:
"""Return {path: {split, label}} from manifest.csv if available."""
meta = {}
if not MANIFEST.exists():
return meta
with open(MANIFEST, newline="", encoding="utf-8") as f:
# Skip Git LFS pointer stubs
first = f.read(50)
if "git-lfs" in first:
logger.warning("manifest.csv is a Git LFS stub β split info from features.csv only")
return meta
f.seek(0)
for row in csv.DictReader(f):
meta[row.get("path", "")] = {
"split": row.get("split", ""),
"label": row.get("label", ""),
}
return meta
def load_feature_matrix(split_filter: set) -> tuple:
"""
Load feature rows for the requested splits.
Returns (raw_scores, labels) as 1-D float64 numpy arrays.
raw_score = mean(f0..f29) β a monotonic predictor suitable for Platt fit.
"""
path_meta = _load_manifest_meta()
feat_cols = [f"f{i}" for i in range(N_FEATURES)]
scores, labels = [], []
with open(FEATURES, newline="", encoding="utf-8") as f:
first = f.read(50)
if "git-lfs" in first:
raise RuntimeError(
"features.csv is a Git LFS pointer stub. "
"Run: git lfs pull && python scripts/extract_features.py"
)
f.seek(0)
for row in csv.DictReader(f):
path = row.get("path", "")
meta = path_meta.get(path, {})
split = (meta.get("split")
or row.get("split", "")
or "train")
if split_filter and split not in split_filter:
continue
label_str = (meta.get("label")
or row.get("label", "")
or "")
if label_str in ("ai", "1"):
label = 1
elif label_str in ("real", "0"):
label = 0
else:
continue # unknown label β skip
try:
vals = [float(row.get(k, 0.5)) for k in feat_cols]
except (ValueError, TypeError):
continue
scores.append(float(np.mean(vals)))
labels.append(label)
return np.array(scores, dtype=np.float64), np.array(labels, dtype=np.float64)
def main():
parser = argparse.ArgumentParser(description="Fit Platt scaling parameters for VeriFile-X")
parser.add_argument(
"--split", default="val",
help="Comma/plus-separated splits: train, val, test, train+val. Default: val",
)
parser.add_argument("--max-iter", type=int, default=500,
help="Gradient descent iterations. Default: 500")
parser.add_argument("--lr", type=float, default=0.01,
help="Learning rate. Default: 0.01")
parser.add_argument("--dry-run", action="store_true",
help="Print params without writing platt_params.json")
args = parser.parse_args()
split_filter = {s.strip() for s in args.split.replace("+", ",").split(",")}
logger.info("Loading features for splits: %s", split_filter)
if not FEATURES.exists():
raise FileNotFoundError(
f"{FEATURES} not found. Run: python scripts/extract_features.py"
)
raw_scores, labels = load_feature_matrix(split_filter)
if len(raw_scores) == 0:
raise ValueError(
f"No labelled rows found for splits {split_filter}. "
"Check that manifest.csv has split/label columns and paths match "
"features.csv, or pass --split train+val to use all labelled rows."
)
n_ai = int(labels.sum())
n_real = int((labels == 0).sum())
logger.info("Loaded %d samples (AI=%d real=%d) mean_score=%.4f",
len(raw_scores), n_ai, n_real, float(raw_scores.mean()))
if n_ai == 0 or n_real == 0:
raise ValueError(
"Both AI and real samples are required for Platt fitting. "
f"Found AI={n_ai}, real={n_real}."
)
import sys
sys.path.insert(0, str(ROOT))
from backend.services.platt_calibrator import fit
logger.info("Fitting Platt parameters (max_iter=%d, lr=%.4f)β¦", args.max_iter, args.lr)
A, B = fit(raw_scores, labels, max_iter=args.max_iter, lr=args.lr)
def _sig(x: float) -> float:
return 1.0 / (1.0 + math.exp(-max(-500.0, min(500.0, x))))
p0 = _sig(A * 0.0 + B)
p05 = _sig(A * 0.5 + B)
p1 = _sig(A * 1.0 + B)
logger.info("Fitted A=%.6f B=%.6f", A, B)
logger.info("Sanity: calibrate(0.0)=%.3f calibrate(0.5)=%.3f calibrate(1.0)=%.3f",
p0, p05, p1)
if p05 < 0.35 or p05 > 0.65:
logger.warning(
"calibrate(0.5) = %.3f is far from 0.5 β the val set may be "
"imbalanced or raw scores may not be centred at 0.5. "
"Consider using --split train+val for more data.", p05
)
if args.dry_run:
logger.info("--dry-run active: not writing params")
return
PARAMS_OUT.parent.mkdir(parents=True, exist_ok=True)
payload = {"A": round(float(A), 6), "B": round(float(B), 6)}
PARAMS_OUT.write_text(json.dumps(payload, indent=2))
logger.info("Written: %s", PARAMS_OUT)
logger.info("Restart the backend (or add a /api/v1/platt/reload endpoint) to apply.")
if __name__ == "__main__":
main()
|