CaxtonEmeraldS's picture
Upload folder using huggingface_hub
978eca4 verified
Raw
History Blame Contribute Delete
7.57 kB
"""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
# ------------------------------------------------------------------ #
# Per-buffer mean blanks across the 6 training phones (used as fallback
# when the caller does not supply a per-device blank).
# ------------------------------------------------------------------ #
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"],
# The released checkpoint was trained directly on raw μM targets
# (no y-normalization), so the identity transform is the correct default.
"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"]]
# The released model was trained on feature_set="all":
# [R, G, B, dR, dG, dB] + 4 buffer one-hot dims = 10 features.
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())