CaxtonEmeraldS's picture
Upload app.py with huggingface_hub
166e6c8 verified
Raw
History Blame Contribute Delete
15 kB
"""
Uric-Acid Colorimetric Concentration Predictor — interactive demo.
Smartphone RGB reading of an oxidised-TMB well (and, ideally, the same phone's
blank reading) -> predicted uric-acid concentration from four models. The ANN
released with the manuscript is highlighted.
Models, all evaluated on the same 28-sample held-out fold (manuscript §3.8):
Linear (raw RGB) per-buffer OLS on R,G,B overall R² 0.587, RMSE 128.5 μM
Linear (ΔRGB) per-buffer OLS on ΔR,ΔG,ΔB overall R² 0.722, RMSE 105.4 μM
Random Forest 500 trees, depth 10, all feats overall R² 0.893, RMSE 65.3 μM
ANN (this work) 5-seed MLP-Wide-Reg [128,64] overall R² 0.874, RMSE 70.9 μM
This file changes ONLY presentation. The model loading and prediction math are
unchanged from the working version.
"""
import json, pickle
import numpy as np
import torch
import torch.nn as nn
import gradio as gr
BUFFER_ORDER = ["DI", "pH4", "pH11", "SBF"]
BUFFER_LABELS = {
"DI": "DI water", "pH4": "pH 4 buffer",
"pH11": "pH 11 buffer", "SBF": "Simulated body fluid (SBF)",
}
CONC_MAX = 600.0 # μM, training range ceiling — used to scale the result bars
# Published held-out metrics (manuscript §3.8, Table 2 / Figure 3)
PUBLISHED = {
"Linear (raw RGB)": {"R2": 0.587, "RMSE": 128.5},
"Linear (ΔRGB)": {"R2": 0.722, "RMSE": 105.4},
"Random Forest": {"R2": 0.893, "RMSE": 65.3},
"ANN (this work)": {"R2": 0.874, "RMSE": 70.9},
}
MODEL_META = {
"Linear (raw RGB)": {"sub": "per-buffer OLS · R,G,B", "accent": "#94a3b8"},
"Linear (ΔRGB)": {"sub": "per-buffer OLS · ΔR,ΔG,ΔB", "accent": "#64748b"},
"Random Forest": {"sub": "500 trees · depth 10 · all feats", "accent": "#f59e0b"},
"ANN (this work)": {"sub": "5-seed MLP-Wide-Reg [128, 64]", "accent": "#4f46e5"},
}
# ---------------------------------------------------------------- load artifacts
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)
_ck = torch.load("model.pt", map_location="cpu", weights_only=False)
_ann = []
for sd in _ck["ensemble_state_dicts"]:
m = MLP(_ck["in_dim"], _ck["architecture"]); m.load_state_dict(sd); m.eval()
_ann.append(m)
_scaler_mean = np.asarray(_ck["scaler_mean"], np.float32)
_scaler_scale = np.asarray(_ck["scaler_scale"], np.float32)
with open("baselines_linear.json") as f: _lin = json.load(f)
with open("rf_model.pkl", "rb") as f: _rf_bundle = pickle.load(f); _rf = _rf_bundle["model"]
with open("mean_blanks.json") as f: _mean_blanks = json.load(f)
# ---------------------------------------------------------------- feature builders
def _ohe(buffer):
v = [0.0] * 4; v[BUFFER_ORDER.index(buffer)] = 1.0; return v
def _all_features(R, G, B, dR, dG, dB, buffer):
return np.asarray([R, G, B, dR, dG, dB] + _ohe(buffer), np.float32)
def _lin_predict(block, cols_vals, buffer):
p = _lin[block][buffer]
return float(np.dot(p["coef"], cols_vals) + p["intercept"])
# ---------------------------------------------------------------- prediction core
def _compute(buffer, R, G, B, use_blank, R0, G0, B0):
"""Return (preds_dict, dR, dG, dB, blank_note). Identical math to the working app."""
if use_blank:
r0, g0, b0 = R0, G0, B0
blank_note = "user-supplied per-device blank"
else:
mb = _mean_blanks[buffer]
r0, g0, b0 = mb["R"], mb["G"], mb["B"]
blank_note = "cohort-mean blank (no per-device blank given)"
dR, dG, dB = r0 - R, g0 - G, b0 - B
p_lin_rgb = _lin_predict("lin_rgb", [R, G, B], buffer)
p_lin_drgb = _lin_predict("lin_drgb", [dR, dG, dB], buffer)
x_all = _all_features(R, G, B, dR, dG, dB, buffer).reshape(1, -1)
p_rf = float(_rf.predict(x_all)[0])
x_std = (x_all.ravel() - _scaler_mean) / _scaler_scale
xt = torch.tensor(x_std, dtype=torch.float32).unsqueeze(0)
with torch.no_grad():
p_ann = float(np.mean([m(xt).item() for m in _ann]))
clamp = lambda v: max(0.0, v)
preds = {
"Linear (raw RGB)": clamp(p_lin_rgb),
"Linear (ΔRGB)": clamp(p_lin_drgb),
"Random Forest": clamp(p_rf),
"ANN (this work)": clamp(p_ann),
}
return preds, dR, dG, dB, blank_note
# ---------------------------------------------------------------- HTML rendering
def _hero(preds, buffer, blank_note, dR, dG, dB):
ann = preds["ANN (this work)"]
others = [v for k, v in preds.items() if k != "ANN (this work)"]
consensus = np.mean(list(preds.values()))
pct = max(2.0, min(100.0, 100.0 * ann / CONC_MAX))
return f"""
<div class="hero">
<div class="hero-row">
<div class="hero-main">
<div class="hero-label">ANN predicted concentration</div>
<div class="hero-value">{ann:.1f}<span class="hero-unit">μM</span></div>
<div class="hero-meta">released model · 5-seed MLP-Wide-Reg [128, 64]</div>
</div>
<div class="hero-side">
<div class="chip"><span>Buffer</span><b>{BUFFER_LABELS[buffer]}</b></div>
<div class="chip"><span>4-model mean</span><b>{consensus:.1f} μM</b></div>
<div class="chip"><span>ΔR / ΔG / ΔB</span><b>{dR:.1f} / {dG:.1f} / {dB:.1f}</b></div>
</div>
</div>
<div class="scale">
<div class="scale-track"><div class="scale-fill" style="width:{pct:.1f}%"></div>
<div class="scale-knob" style="left:{pct:.1f}%"></div></div>
<div class="scale-ticks"><span>0</span><span>150</span><span>300</span><span>450</span><span>600 μM</span></div>
</div>
<div class="blank-note">ΔRGB source: <i>{blank_note}</i></div>
</div>
"""
def _model_card(name, value):
meta = MODEL_META[name]; pub = PUBLISHED[name]
is_ann = name == "ANN (this work)"
pct = max(2.0, min(100.0, 100.0 * value / CONC_MAX))
cls = "mcard ann" if is_ann else "mcard"
star = '<span class="badge-ann">RELEASED</span>' if is_ann else ""
return f"""
<div class="{cls}">
<div class="mc-top">
<div class="mc-name">{name} {star}</div>
<div class="mc-val">{value:.1f}<span class="mc-unit">μM</span></div>
</div>
<div class="mc-sub">{meta['sub']}</div>
<div class="bar"><div class="bar-fill" style="width:{pct:.1f}%;background:{meta['accent']}"></div></div>
<div class="mc-stats">
<span class="stat">R²&nbsp;<b>{pub['R2']:.3f}</b></span>
<span class="stat">RMSE&nbsp;<b>{pub['RMSE']:.1f}&nbsp;μM</b></span>
</div>
</div>
"""
def _detail(preds):
order = ["ANN (this work)", "Random Forest", "Linear (ΔRGB)", "Linear (raw RGB)"]
cards = "".join(_model_card(n, preds[n]) for n in order)
return f"""
<div class="grid">{cards}</div>
<div class="legend">Bars show each model's predicted concentration on a 0–600&nbsp;μM scale.
R² and RMSE are the published held-out values (n&nbsp;=&nbsp;28, manuscript §3.8), not per-sample
uncertainties. Predictions are clamped at 0&nbsp;μM and are most reliable within 0–600&nbsp;μM.</div>
"""
def predict(buffer, R, G, B, use_blank, R0, G0, B0):
preds, dR, dG, dB, note = _compute(buffer, R, G, B, use_blank, R0, G0, B0)
return _hero(preds, buffer, note, dR, dG, dB), _detail(preds)
# ---------------------------------------------------------------- styling
CUSTOM_CSS = """
:root{
--indigo:#4f46e5; --indigo-soft:#eef2ff; --ink:#0f172a; --muted:#64748b;
--line:#e2e8f0; --bg:#f8fafc; --card:#ffffff;
}
.gradio-container{max-width:1180px!important;margin:0 auto!important;background:var(--bg);}
#hdr{padding:18px 4px 6px;border-bottom:1px solid var(--line);margin-bottom:14px;}
#hdr h1{font-size:1.55rem;line-height:1.2;margin:0;color:var(--ink);font-weight:700;}
#hdr .accent{display:inline-block;width:42px;height:4px;border-radius:3px;
background:linear-gradient(90deg,var(--indigo),#22c55e);margin:8px 0 6px;}
#hdr p{color:var(--muted);margin:2px 0 0;font-size:.93rem;}
.panel{background:var(--card);border:1px solid var(--line);border-radius:16px;
padding:16px 18px;box-shadow:0 1px 2px rgba(15,23,42,.04);}
.panel h3{margin:.1rem 0 .7rem;font-size:1rem;color:var(--ink);font-weight:650;}
.sectlabel{font-size:.72rem;letter-spacing:.06em;text-transform:uppercase;
color:var(--muted);font-weight:700;margin:10px 0 4px;}
button.primary,.gr-button-primary{border-radius:12px!important;font-weight:650!important;
background:var(--indigo)!important;border:none!important;}
/* hero */
.hero{background:linear-gradient(135deg,#eef2ff 0%,#f5f3ff 60%,#ecfdf5 140%);
border:1px solid #e0e7ff;border-radius:18px;padding:18px 20px;}
.hero-row{display:flex;justify-content:space-between;gap:16px;flex-wrap:wrap;align-items:flex-start;}
.hero-label{font-size:.74rem;letter-spacing:.06em;text-transform:uppercase;color:var(--indigo);font-weight:700;}
.hero-value{font-size:3rem;line-height:1.05;font-weight:800;color:var(--ink);
font-variant-numeric:tabular-nums;margin-top:2px;}
.hero-unit{font-size:1.2rem;font-weight:600;color:var(--muted);margin-left:6px;}
.hero-meta{color:var(--muted);font-size:.85rem;margin-top:2px;}
.hero-side{display:flex;flex-direction:column;gap:6px;min-width:210px;}
.chip{display:flex;justify-content:space-between;gap:10px;background:rgba(255,255,255,.7);
border:1px solid #e0e7ff;border-radius:10px;padding:6px 10px;font-size:.82rem;color:var(--muted);}
.chip b{color:var(--ink);font-variant-numeric:tabular-nums;}
.scale{margin-top:16px;}
.scale-track{position:relative;height:8px;background:#e5e7eb;border-radius:6px;}
.scale-fill{position:absolute;left:0;top:0;height:100%;border-radius:6px;
background:linear-gradient(90deg,var(--indigo),#22c55e);}
.scale-knob{position:absolute;top:50%;width:14px;height:14px;border-radius:50%;
background:#fff;border:3px solid var(--indigo);transform:translate(-50%,-50%);}
.scale-ticks{display:flex;justify-content:space-between;color:var(--muted);
font-size:.72rem;margin-top:5px;font-variant-numeric:tabular-nums;}
.blank-note{margin-top:10px;color:var(--muted);font-size:.8rem;}
/* model cards grid */
.grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:14px;}
.mcard{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:12px 14px;}
.mcard.ann{border:1.5px solid var(--indigo);background:linear-gradient(180deg,#fbfbff,#f5f3ff);
box-shadow:0 4px 14px rgba(79,70,229,.12);}
.mc-top{display:flex;justify-content:space-between;align-items:baseline;gap:8px;}
.mc-name{font-weight:650;color:var(--ink);font-size:.92rem;}
.badge-ann{font-size:.6rem;letter-spacing:.05em;background:var(--indigo);color:#fff;
padding:2px 6px;border-radius:6px;vertical-align:middle;margin-left:4px;}
.mc-val{font-size:1.35rem;font-weight:750;color:var(--ink);font-variant-numeric:tabular-nums;}
.mc-unit{font-size:.78rem;color:var(--muted);font-weight:600;margin-left:3px;}
.mc-sub{color:var(--muted);font-size:.78rem;margin:2px 0 8px;}
.bar{height:7px;background:#eef2f7;border-radius:5px;overflow:hidden;}
.bar-fill{height:100%;border-radius:5px;}
.mc-stats{display:flex;gap:14px;margin-top:8px;}
.stat{font-size:.76rem;color:var(--muted);} .stat b{color:var(--ink);}
.legend{color:var(--muted);font-size:.76rem;margin-top:12px;line-height:1.5;}
@media (max-width:760px){.grid{grid-template-columns:1fr;}}
"""
HEADER = """
<div id="hdr">
<h1>🧪 Uric-Acid Colorimetric Concentration Predictor</h1>
<div class="accent"></div>
<p>Smartphone RGB → uric-acid concentration · Ag–Cu micro-flower / TMB nanozyme assay ·
four models compared, the manuscript ANN highlighted.</p>
</div>
"""
INTRO = (
"Enter the measured **R, G, B** of the reaction well and choose the **buffer**. "
"If you have the same phone's **blank (0 μM)** reading, switch on *Use per-device "
"blank* for best accuracy; otherwise a cohort-mean blank is used."
)
# ---------------------------------------------------------------- UI
with gr.Blocks(
title="Uric-Acid Colorimetric Concentration Predictor",
theme=gr.themes.Soft(primary_hue="indigo", secondary_hue="emerald", neutral_hue="slate"),
css=CUSTOM_CSS,
) as demo:
gr.HTML(HEADER)
with gr.Row(equal_height=False):
# ---- Input column ----
with gr.Column(scale=4, min_width=320):
with gr.Group():
gr.Markdown(INTRO)
gr.HTML('<div class="sectlabel">Assay matrix</div>')
buffer = gr.Dropdown(BUFFER_ORDER, value="DI", label="Buffer",
info="Matrix the assay was run in")
gr.HTML('<div class="sectlabel">Measurement RGB · oxidised-TMB well</div>')
with gr.Row():
R = gr.Number(value=50.0, label="R", precision=2)
G = gr.Number(value=70.0, label="G", precision=2)
B = gr.Number(value=90.0, label="B", precision=2)
use_blank = gr.Checkbox(value=False,
label="Use per-device blank (recommended)")
gr.HTML('<div class="sectlabel">Blank RGB · same phone, 0 μM well</div>')
with gr.Row():
R0 = gr.Number(value=60.0, label="R₀", precision=2)
G0 = gr.Number(value=78.0, label="G₀", precision=2)
B0 = gr.Number(value=104.0, label="B₀", precision=2)
btn = gr.Button("Predict concentration", variant="primary", size="lg")
# ---- Results column ----
with gr.Column(scale=6, min_width=420):
hero = gr.HTML()
detail = gr.HTML()
with gr.Accordion("Worked examples (real held-out measurements) — click to load, then Predict",
open=False):
gr.Examples(
examples=[
["pH4", 7.04, 68.67, 99.89, True, 49.66, 74.12, 105.82],
["DI", 44.0, 56.0, 75.0, True, 60.0, 78.0, 104.0],
["pH11", 96.93, 125.47, 145.70, True, 96.93, 125.47, 145.70],
["SBF", 60.0, 100.0, 120.0, False, 0, 0, 0],
],
inputs=[buffer, R, G, B, use_blank, R0, G0, B0],
)
gr.Markdown(
"**Model performance (held-out fold, n = 28, manuscript §3.8):** "
"Linear raw-RGB R² 0.587 · Linear ΔRGB R² 0.722 · Random Forest R² 0.893 · "
"**ANN R² 0.874, RMSE 70.9 μM**. The ANN is the most even performer across all "
"four buffers and is the released inference model. _Research use only; not a medical device._"
)
inputs = [buffer, R, G, B, use_blank, R0, G0, B0]
btn.click(predict, inputs, [hero, detail])
# show a result on load so the page is never empty
demo.load(predict, inputs, [hero, detail])
if __name__ == "__main__":
demo.launch()