abinazebinoy commited on
Commit
f12e82b
·
1 Parent(s): b8ea3a2

feat(scripts): add fit_platt.py — standalone Platt calibration fitter

Browse files

platt_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

Files changed (1) hide show
  1. scripts/fit_platt.py +187 -0
scripts/fit_platt.py ADDED
@@ -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()