Spaces:
Running
feat(scripts): add fit_platt.py — standalone Platt calibration fitter
Browse filesplatt_calibrator.py has had a correct fit() function since the
initial implementation, but no script existed to invoke it. The
backend was running on hardcoded defaults A=5.0, B=-2.5 with no
CLI path to fit real parameters.
fit_platt.py:
- Reads data/features.csv (produced by extract_features.py)
- Filters to requested split (--split val, train, train+val, etc.)
- Computes raw_score = mean(f0..f29) as a monotonic predictor
- Calls platt_calibrator.fit() (gradient descent on log-loss)
- Writes data/reference/platt_params.json
- Prints sanity check: calibrate(0.0), calibrate(0.5), calibrate(1.0)
- Warns if calibrate(0.5) is far from 0.5 (imbalanced split)
- Handles Git LFS stubs with clear remediation messages
- --dry-run flag to preview params without writing
Usage: python scripts/fit_platt.py --split val
- scripts/fit_platt.py +187 -0
|
@@ -0,0 +1,187 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Fit Platt scaling calibration parameters from features.csv.
|
| 3 |
+
|
| 4 |
+
Usage:
|
| 5 |
+
python scripts/fit_platt.py # fit on val split (default)
|
| 6 |
+
python scripts/fit_platt.py --split val # explicit val split
|
| 7 |
+
python scripts/fit_platt.py --split train+val # use all labelled rows
|
| 8 |
+
python scripts/fit_platt.py --dry-run # print params, do not save
|
| 9 |
+
|
| 10 |
+
Prerequisites:
|
| 11 |
+
data/features.csv — produced by scripts/extract_features.py
|
| 12 |
+
data/manifest.csv — provides split/label columns (optional fallback)
|
| 13 |
+
|
| 14 |
+
Output:
|
| 15 |
+
data/reference/platt_params.json — {"A": float, "B": float}
|
| 16 |
+
Reload: restart the backend, or add a POST /api/v1/platt/reload endpoint.
|
| 17 |
+
"""
|
| 18 |
+
import csv
|
| 19 |
+
import json
|
| 20 |
+
import math
|
| 21 |
+
import logging
|
| 22 |
+
import argparse
|
| 23 |
+
import numpy as np
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
|
| 26 |
+
logging.basicConfig(
|
| 27 |
+
level=logging.INFO,
|
| 28 |
+
format="%(asctime)s %(levelname)s %(message)s",
|
| 29 |
+
datefmt="%H:%M:%S",
|
| 30 |
+
)
|
| 31 |
+
logger = logging.getLogger(__name__)
|
| 32 |
+
|
| 33 |
+
ROOT = Path(__file__).parents[1]
|
| 34 |
+
FEATURES = ROOT / "data" / "features.csv"
|
| 35 |
+
MANIFEST = ROOT / "data" / "manifest.csv"
|
| 36 |
+
PARAMS_OUT = ROOT / "data" / "reference" / "platt_params.json"
|
| 37 |
+
N_FEATURES = 30 # f0..f29
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _load_manifest_meta() -> dict:
|
| 41 |
+
"""Return {path: {split, label}} from manifest.csv if available."""
|
| 42 |
+
meta = {}
|
| 43 |
+
if not MANIFEST.exists():
|
| 44 |
+
return meta
|
| 45 |
+
with open(MANIFEST, newline="", encoding="utf-8") as f:
|
| 46 |
+
# Skip Git LFS pointer stubs
|
| 47 |
+
first = f.read(50)
|
| 48 |
+
if "git-lfs" in first:
|
| 49 |
+
logger.warning("manifest.csv is a Git LFS stub — split info from features.csv only")
|
| 50 |
+
return meta
|
| 51 |
+
f.seek(0)
|
| 52 |
+
for row in csv.DictReader(f):
|
| 53 |
+
meta[row.get("path", "")] = {
|
| 54 |
+
"split": row.get("split", ""),
|
| 55 |
+
"label": row.get("label", ""),
|
| 56 |
+
}
|
| 57 |
+
return meta
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def load_feature_matrix(split_filter: set) -> tuple:
|
| 61 |
+
"""
|
| 62 |
+
Load feature rows for the requested splits.
|
| 63 |
+
Returns (raw_scores, labels) as 1-D float64 numpy arrays.
|
| 64 |
+
raw_score = mean(f0..f29) — a monotonic predictor suitable for Platt fit.
|
| 65 |
+
"""
|
| 66 |
+
path_meta = _load_manifest_meta()
|
| 67 |
+
feat_cols = [f"f{i}" for i in range(N_FEATURES)]
|
| 68 |
+
scores, labels = [], []
|
| 69 |
+
|
| 70 |
+
with open(FEATURES, newline="", encoding="utf-8") as f:
|
| 71 |
+
first = f.read(50)
|
| 72 |
+
if "git-lfs" in first:
|
| 73 |
+
raise RuntimeError(
|
| 74 |
+
"features.csv is a Git LFS pointer stub. "
|
| 75 |
+
"Run: git lfs pull && python scripts/extract_features.py"
|
| 76 |
+
)
|
| 77 |
+
f.seek(0)
|
| 78 |
+
for row in csv.DictReader(f):
|
| 79 |
+
path = row.get("path", "")
|
| 80 |
+
meta = path_meta.get(path, {})
|
| 81 |
+
|
| 82 |
+
split = (meta.get("split")
|
| 83 |
+
or row.get("split", "")
|
| 84 |
+
or "train")
|
| 85 |
+
if split_filter and split not in split_filter:
|
| 86 |
+
continue
|
| 87 |
+
|
| 88 |
+
label_str = (meta.get("label")
|
| 89 |
+
or row.get("label", "")
|
| 90 |
+
or "")
|
| 91 |
+
if label_str in ("ai", "1"):
|
| 92 |
+
label = 1
|
| 93 |
+
elif label_str in ("real", "0"):
|
| 94 |
+
label = 0
|
| 95 |
+
else:
|
| 96 |
+
continue # unknown label — skip
|
| 97 |
+
|
| 98 |
+
try:
|
| 99 |
+
vals = [float(row.get(k, 0.5)) for k in feat_cols]
|
| 100 |
+
except (ValueError, TypeError):
|
| 101 |
+
continue
|
| 102 |
+
|
| 103 |
+
scores.append(float(np.mean(vals)))
|
| 104 |
+
labels.append(label)
|
| 105 |
+
|
| 106 |
+
return np.array(scores, dtype=np.float64), np.array(labels, dtype=np.float64)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def main():
|
| 110 |
+
parser = argparse.ArgumentParser(description="Fit Platt scaling parameters for VeriFile-X")
|
| 111 |
+
parser.add_argument(
|
| 112 |
+
"--split", default="val",
|
| 113 |
+
help="Comma/plus-separated splits: train, val, test, train+val. Default: val",
|
| 114 |
+
)
|
| 115 |
+
parser.add_argument("--max-iter", type=int, default=500,
|
| 116 |
+
help="Gradient descent iterations. Default: 500")
|
| 117 |
+
parser.add_argument("--lr", type=float, default=0.01,
|
| 118 |
+
help="Learning rate. Default: 0.01")
|
| 119 |
+
parser.add_argument("--dry-run", action="store_true",
|
| 120 |
+
help="Print params without writing platt_params.json")
|
| 121 |
+
args = parser.parse_args()
|
| 122 |
+
|
| 123 |
+
split_filter = {s.strip() for s in args.split.replace("+", ",").split(",")}
|
| 124 |
+
logger.info("Loading features for splits: %s", split_filter)
|
| 125 |
+
|
| 126 |
+
if not FEATURES.exists():
|
| 127 |
+
raise FileNotFoundError(
|
| 128 |
+
f"{FEATURES} not found. Run: python scripts/extract_features.py"
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
raw_scores, labels = load_feature_matrix(split_filter)
|
| 132 |
+
|
| 133 |
+
if len(raw_scores) == 0:
|
| 134 |
+
raise ValueError(
|
| 135 |
+
f"No labelled rows found for splits {split_filter}. "
|
| 136 |
+
"Check that manifest.csv has split/label columns and paths match "
|
| 137 |
+
"features.csv, or pass --split train+val to use all labelled rows."
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
n_ai = int(labels.sum())
|
| 141 |
+
n_real = int((labels == 0).sum())
|
| 142 |
+
logger.info("Loaded %d samples (AI=%d real=%d) mean_score=%.4f",
|
| 143 |
+
len(raw_scores), n_ai, n_real, float(raw_scores.mean()))
|
| 144 |
+
|
| 145 |
+
if n_ai == 0 or n_real == 0:
|
| 146 |
+
raise ValueError(
|
| 147 |
+
"Both AI and real samples are required for Platt fitting. "
|
| 148 |
+
f"Found AI={n_ai}, real={n_real}."
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
import sys
|
| 152 |
+
sys.path.insert(0, str(ROOT))
|
| 153 |
+
from backend.services.platt_calibrator import fit
|
| 154 |
+
|
| 155 |
+
logger.info("Fitting Platt parameters (max_iter=%d, lr=%.4f)…", args.max_iter, args.lr)
|
| 156 |
+
A, B = fit(raw_scores, labels, max_iter=args.max_iter, lr=args.lr)
|
| 157 |
+
|
| 158 |
+
def _sig(x: float) -> float:
|
| 159 |
+
return 1.0 / (1.0 + math.exp(-max(-500.0, min(500.0, x))))
|
| 160 |
+
|
| 161 |
+
p0 = _sig(A * 0.0 + B)
|
| 162 |
+
p05 = _sig(A * 0.5 + B)
|
| 163 |
+
p1 = _sig(A * 1.0 + B)
|
| 164 |
+
logger.info("Fitted A=%.6f B=%.6f", A, B)
|
| 165 |
+
logger.info("Sanity: calibrate(0.0)=%.3f calibrate(0.5)=%.3f calibrate(1.0)=%.3f",
|
| 166 |
+
p0, p05, p1)
|
| 167 |
+
|
| 168 |
+
if p05 < 0.35 or p05 > 0.65:
|
| 169 |
+
logger.warning(
|
| 170 |
+
"calibrate(0.5) = %.3f is far from 0.5 — the val set may be "
|
| 171 |
+
"imbalanced or raw scores may not be centred at 0.5. "
|
| 172 |
+
"Consider using --split train+val for more data.", p05
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
if args.dry_run:
|
| 176 |
+
logger.info("--dry-run active: not writing params")
|
| 177 |
+
return
|
| 178 |
+
|
| 179 |
+
PARAMS_OUT.parent.mkdir(parents=True, exist_ok=True)
|
| 180 |
+
payload = {"A": round(float(A), 6), "B": round(float(B), 6)}
|
| 181 |
+
PARAMS_OUT.write_text(json.dumps(payload, indent=2))
|
| 182 |
+
logger.info("Written: %s", PARAMS_OUT)
|
| 183 |
+
logger.info("Restart the backend (or add a /api/v1/platt/reload endpoint) to apply.")
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
if __name__ == "__main__":
|
| 187 |
+
main()
|