| """Standalone inference for the UA-ANN colorimetric biosensor model. |
| |
| Loads the trained 5-seed MLP ensemble and predicts uric-acid concentration (μM) |
| from smartphone RGB readings. |
| |
| Two input modes are supported: |
| |
| 1. **With per-phone blank** (recommended, matches training conditions). |
| Caller supplies (R_blank, G_blank, B_blank) — the values measured on the |
| same phone for the same buffer at zero analyte. ΔRGB is computed exactly |
| as in training. |
| |
| 2. **Without blank** (less accurate, single-shot inference). |
| The script falls back to the per-buffer mean blanks computed across the 6 |
| training phones. |
| |
| Usage from the command line |
| --------------------------- |
| |
| # Single sample with blank reference |
| python inference.py --R 50 --G 70 --B 90 --buffer DI \\ |
| --R0 60 --G0 78 --B0 104 |
| |
| # Batch from CSV (columns: R, G, B, Buffer, [R0, G0, B0]) |
| python inference.py --csv samples.csv --out predictions.csv |
| |
| Usage from Python |
| ----------------- |
| |
| from inference import load_model, predict |
| bundle = load_model("model.pt") |
| pred_uM = predict(bundle, R=50, G=70, B=90, buffer="DI", |
| R0=60, G0=78, B0=104) |
| """ |
| from __future__ import annotations |
|
|
| import argparse |
| import json |
| import sys |
| from pathlib import Path |
| from typing import Optional |
|
|
| import numpy as np |
| import pandas as pd |
| import torch |
| import torch.nn as nn |
|
|
|
|
| |
| |
| |
| |
| MEAN_BLANKS = { |
| "DI": {"R": 59.864, "G": 77.204, "B": 103.901}, |
| "pH4": {"R": 80.980, "G": 125.474, "B": 157.914}, |
| "pH11": {"R": 96.932, "G": 125.474, "B": 145.695}, |
| "SBF": {"R": 80.434, "G": 127.047, "B": 153.688}, |
| } |
|
|
|
|
| class MLP(nn.Module): |
| def __init__(self, in_dim, hidden, dropout=0.10): |
| super().__init__() |
| layers, prev = [], in_dim |
| for h in hidden: |
| layers += [nn.Linear(prev, h), nn.ReLU(), nn.Dropout(dropout)] |
| prev = h |
| layers.append(nn.Linear(prev, 1)) |
| self.net = nn.Sequential(*layers) |
|
|
| def forward(self, x): |
| return self.net(x).squeeze(-1) |
|
|
|
|
| def load_model(path: str | Path = "model.pt") -> dict: |
| """Load checkpoint and rebuild the 5-seed ensemble. |
| |
| Returns a dict with keys: ensemble, scaler_mean, scaler_scale, feature_cols, |
| feature_set, buffer_order, y_mean, y_std. |
| """ |
| ckpt = torch.load(path, map_location="cpu", weights_only=False) |
| in_dim = ckpt["in_dim"] |
| hidden = ckpt["architecture"] |
| ensemble = [] |
| for sd in ckpt["ensemble_state_dicts"]: |
| m = MLP(in_dim=in_dim, hidden=hidden) |
| m.load_state_dict(sd) |
| m.eval() |
| ensemble.append(m) |
| return { |
| "ensemble": ensemble, |
| "scaler_mean": np.asarray(ckpt["scaler_mean"], dtype=np.float32), |
| "scaler_scale": np.asarray(ckpt["scaler_scale"], dtype=np.float32), |
| "feature_cols": ckpt["feature_cols"], |
| "feature_set": ckpt["feature_set"], |
| "buffer_order": ckpt["buffer_order"], |
| |
| |
| "y_mean": float(ckpt.get("y_mean", 0.0)), |
| "y_std": float(ckpt.get("y_std", 1.0)), |
| } |
|
|
|
|
| def _build_features(R: float, G: float, B: float, buffer: str, |
| R0: Optional[float], G0: Optional[float], B0: Optional[float], |
| bundle: dict) -> np.ndarray: |
| """Construct a 1-D feature vector matching the model's input layout.""" |
| if buffer not in bundle["buffer_order"]: |
| raise ValueError(f"Unknown buffer {buffer!r}; expected one of {bundle['buffer_order']}") |
|
|
| if R0 is None or G0 is None or B0 is None: |
| mb = MEAN_BLANKS[buffer] |
| R0 = R0 if R0 is not None else mb["R"] |
| G0 = G0 if G0 is not None else mb["G"] |
| B0 = B0 if B0 is not None else mb["B"] |
|
|
| dR, dG, dB = R0 - R, G0 - G, B0 - B |
| ohe = [1.0 if b == buffer else 0.0 for b in bundle["buffer_order"]] |
| |
| |
| feats = [R, G, B, dR, dG, dB] + ohe |
| if len(feats) != len(bundle["feature_cols"]): |
| raise RuntimeError(f"Feature length mismatch: built {len(feats)}, " |
| f"expected {len(bundle['feature_cols'])}") |
| return np.asarray(feats, dtype=np.float32) |
|
|
|
|
| def predict(bundle: dict, R: float, G: float, B: float, buffer: str, |
| R0: Optional[float] = None, G0: Optional[float] = None, |
| B0: Optional[float] = None) -> float: |
| """Predict UA concentration (μM) for a single RGB sample.""" |
| x = _build_features(R, G, B, buffer, R0, G0, B0, bundle) |
| x_n = (x - bundle["scaler_mean"]) / bundle["scaler_scale"] |
| xt = torch.tensor(x_n, dtype=torch.float32).unsqueeze(0) |
| with torch.no_grad(): |
| preds = [m(xt).item() for m in bundle["ensemble"]] |
| yhat_n = float(np.mean(preds)) |
| return yhat_n * bundle["y_std"] + bundle["y_mean"] |
|
|
|
|
| def predict_batch(bundle: dict, df: pd.DataFrame) -> np.ndarray: |
| """Predict for a DataFrame with columns R, G, B, Buffer (and optionally R0, G0, B0).""" |
| has_blank = all(c in df.columns for c in ("R0", "G0", "B0")) |
| out = np.empty(len(df), dtype=np.float32) |
| for i, row in enumerate(df.itertuples(index=False)): |
| out[i] = predict( |
| bundle, |
| R=row.R, G=row.G, B=row.B, buffer=row.Buffer, |
| R0=row.R0 if has_blank else None, |
| G0=row.G0 if has_blank else None, |
| B0=row.B0 if has_blank else None, |
| ) |
| return out |
|
|
|
|
| def _cli() -> int: |
| p = argparse.ArgumentParser(description="UA-ANN biosensor inference") |
| p.add_argument("--model", default="model.pt", help="Path to model.pt") |
| p.add_argument("--csv", help="Batch CSV with R, G, B, Buffer columns") |
| p.add_argument("--out", default="predictions.csv", |
| help="Output path for batch predictions") |
| p.add_argument("--R", type=float); p.add_argument("--G", type=float); p.add_argument("--B", type=float) |
| p.add_argument("--R0", type=float); p.add_argument("--G0", type=float); p.add_argument("--B0", type=float) |
| p.add_argument("--buffer", choices=["DI", "pH4", "pH11", "SBF"]) |
| args = p.parse_args() |
|
|
| if not Path(args.model).exists(): |
| print(f"Model file not found: {args.model}", file=sys.stderr); return 1 |
|
|
| bundle = load_model(args.model) |
|
|
| if args.csv: |
| df = pd.read_csv(args.csv) |
| df["predicted_UA_uM"] = predict_batch(bundle, df) |
| df.to_csv(args.out, index=False) |
| print(f"Wrote {args.out} ({len(df)} predictions)") |
| print(df.head()) |
| else: |
| for k in ("R", "G", "B", "buffer"): |
| if getattr(args, k) is None: |
| print(f"--{k} required for single-sample mode", file=sys.stderr); return 1 |
| y = predict(bundle, R=args.R, G=args.G, B=args.B, buffer=args.buffer, |
| R0=args.R0, G0=args.G0, B0=args.B0) |
| used_blank = "supplied" if (args.R0 is not None) else "mean-blank fallback" |
| print(json.dumps({ |
| "predicted_UA_uM": round(y, 2), |
| "buffer": args.buffer, |
| "blank_source": used_blank, |
| }, indent=2)) |
| return 0 |
|
|
|
|
| if __name__ == "__main__": |
| sys.exit(_cli()) |
|
|