pclc-cloud / validate_model.py
HafsaZainab6's picture
Claude Opus 4.8
PCLC cloud: FastAPI train+sign+OTA service, Cloud Run deploy assets
9e7c196
Raw History Blame Contribute Delete
3.66 kB
"""
Safety / sanity gate for PCLC candidate models.
A model MUST pass this before it is exported for the app (population prior)
or shipped to a phone (per-user fine-tune). For electrical stimulation we never
let an unvetted model reach hardware.
Checks (over a dense grid of scaled inputs):
1. All outputs finite (no NaN/Inf).
2. Normalised outputs stay within [0,1].
3. Decoded physical params stay within hard safety limits.
4. Mean intensity is non-decreasing in pain (sane dose-response).
5. (fine-tune only) Mean output drift from the population prior is bounded
— a user model should personalise, not mutate into something alien.
"""
import numpy as np
from pclc_model import D_IN, y_to_params
def _input_grid(n=7):
# inputs are already scaled to [0,1]
axes = [np.linspace(0.0, 1.0, n) for _ in range(D_IN)]
mesh = np.array(np.meshgrid(*axes)).reshape(D_IN, -1).T
return mesh.astype(np.float32)
def validate(model, out_cfg, prior_model=None, max_mean_drift=0.45, grid_n=7):
"""
Returns (ok: bool, report: dict).
Pass prior_model for fine-tuned models to enforce a bounded-drift check.
"""
report = {}
ok = True
X = _input_grid(grid_n)
Y = model.forward_np(X)
# 1. finite
if not np.all(np.isfinite(Y)):
return False, {"error": "non-finite outputs"}
# 2. normalised range
report["y_min"], report["y_max"] = float(Y.min()), float(Y.max())
if Y.min() < -1e-3 or Y.max() > 1.0 + 1e-3:
ok = False
report["range_fail"] = True
# 3. physical hard limits
P = np.array([y_to_params(y, out_cfg) for y in Y]) # (N, 5): freq,I,pw,heat,timer
limits = [
("freq", out_cfg["freqHardMin"], out_cfg["freqHardMax"]),
("I", out_cfg["iHardMin"], out_cfg["iHardMax"]),
("pw", out_cfg["pwHardMin"], out_cfg["pwHardMax"]),
("heat", out_cfg["tHardMin"], out_cfg["tHardMax"]),
("timer", out_cfg["tmHardMin"], out_cfg["tmHardMax"]),
]
for k, (name, lo, hi) in enumerate(limits):
col = P[:, k]
report[f"{name}_range"] = [float(col.min()), float(col.max())]
if col.min() < lo - 1e-3 or col.max() > hi + 1e-3:
ok = False
report[f"{name}_limit_fail"] = True
# 4. dose-response: mean intensity non-decreasing in pain (pain is input idx 3)
pains = np.linspace(0.0, 1.0, 6)
mean_I = []
for p in pains:
Xp = X.copy()
Xp[:, 3] = p
Pp = np.array([y_to_params(y, out_cfg) for y in model.forward_np(Xp)])
mean_I.append(float(np.mean(Pp[:, 1])))
report["mean_intensity_by_pain"] = mean_I
# allow tiny numerical wiggle (-0.5 mA) but no real reversal
if np.any(np.diff(mean_I) < -0.5):
ok = False
report["intensity_monotonic_fail"] = True
# 5. bounded drift vs prior (fine-tune)
if prior_model is not None:
Yp = prior_model.forward_np(X)
drift = float(np.mean(np.abs(Y - Yp)))
report["mean_drift_from_prior"] = drift
if drift > max_mean_drift:
ok = False
report["excessive_drift"] = True
report["passed"] = ok
return ok, report
if __name__ == "__main__":
# Smoke test on a fresh (untrained) model — exercises the gate plumbing.
import argparse
from pclc_model import PCLCPolicy, load_scaler
ap = argparse.ArgumentParser()
ap.add_argument("--scaler", default="scaler.json")
args = ap.parse_args()
_, _, cfg = load_scaler(args.scaler)
ok, rep = validate(PCLCPolicy(), cfg)
print("passed:", ok)
for k, v in rep.items():
print(f" {k}: {v}")