Module 8 — Calling a model from the application
The dashboard has widgets, filters, layout, caches and state; it can now afford to make a real prediction. This module wires the two plausible sources of that prediction: a model loaded locally and a remote inference API. The choice is not neutral — it changes what the app must handle, and this module walks each of them through with the code the running example ends up shipping.
Two deployment shapes, two contracts
Local model. The model file lives on disk next to the app. The
process loads it once with @st.cache_resource (module 5) and calls
.predict_proba(features) on every scoring request. Latency is
in-process microseconds. The cost is that every replica of the app
carries the full model in memory, and updating the model means
redeploying the app.
Remote API. A separate service — Flask, FastAPI, TorchServe, a managed endpoint — holds the model behind an HTTP contract. The Streamlit app becomes a client. Multiple apps can share the same model, scaling is decoupled from the UI, and updating the model does not touch Streamlit. The cost is a network hop, a serialization layer and the failure modes of any HTTP call: timeouts, retries, 5xx.
Small internal teams start local, then graduate to a remote API once the model is used by more than one app or the training team owns its own release cycle. Course 40 covers building such an API; this module covers what the client side must do to consume it correctly.
The local path
The two lines that matter live in module 5, and they belong at the top of the file so every callable in the module sees the same instance.
import streamlit as st
import joblib
import numpy as np
@st.cache_resource(show_spinner="Loading the churn model...")
def get_model():
return joblib.load("models/churn_v3.joblib")
FEATURES = ["tenure", "monthly_charge", "contract_encoded", "payment_encoded"]
def score_local(features: dict) -> float:
"""Return the churn probability for one customer."""
model = get_model()
vector = np.array([[features[name] for name in FEATURES]])
return float(model.predict_proba(vector)[0, 1])
Two habits carry over from the training code. Order the features
explicitly with a FEATURES list — a dict-order dependency between
training and inference has cost more than one production incident. And
never mutate the loaded model at inference time; if you must
augment it (a lookup table, a threshold), keep those alongside the model
in a small dict returned by get_model(), not on the model object.
The remote API path
For a remote model, the client speaks HTTP. requests is the standard,
httpx a modern alternative with the same synchronous API. Both accept
timeout= and both raise on network errors, which is exactly what you
want.
import requests
INFERENCE_URL = st.secrets.get("INFERENCE_URL", "http://localhost:8000/predict")
INFERENCE_TOKEN = st.secrets.get("INFERENCE_TOKEN", "")
def score_remote(features: dict, timeout: float = 3.0) -> float:
response = requests.post(
INFERENCE_URL,
json={"features": features},
headers={"Authorization": f"Bearer {INFERENCE_TOKEN}"},
timeout=timeout,
)
response.raise_for_status()
return float(response.json()["probability"])
st.secrets is populated from .streamlit/secrets.toml in development
and from the platform's secret manager in production — the mechanism is
covered in module 10. Two habits worth taking on now. Always pass a
timeout= (the default is no timeout, which will hang the browser
tab indefinitely on a stuck server). And .raise_for_status() turns a
500 or a 401 into an exception you can catch, instead of letting the
happy-path .json()["probability"] crash later with an unhelpful
KeyError.
Error handling, the version the user sees
The reason to handle errors well is that the sales team should never see a Python traceback. Three exceptions cover 95 % of what a remote call throws.
import requests
def score_with_feedback(features: dict) -> float | None:
try:
return score_remote(features, timeout=3.0)
except requests.Timeout:
st.error("The scoring service did not answer in time. Please try again.")
except requests.HTTPError as e:
if e.response.status_code == 401:
st.error("Your session expired. Please refresh the page.")
else:
st.error(f"The scoring service returned an error ({e.response.status_code}).")
except requests.RequestException:
st.error("Cannot reach the scoring service. Is it running?")
return None
Two design decisions in that helper. The function returns None on
failure rather than re-raising, so the calling code stays a linear
"score, check, display". And the messages talk in the user's terms
("session expired"), never in the framework's ("HTTP 401"). Log the
original exception (logging.exception(...)) for the on-call engineer,
but do not paste it into the UI.
Loading indicators and cancellation
A synchronous HTTP call blocks the run. st.spinner is the minimum
courtesy owed to the user during that block:
with st.spinner("Scoring the customer..."):
probability = score_with_feedback(features)
if probability is not None:
st.metric("Churn probability", f"{probability:.0%}", delta_color="inverse")
For a long batch — module 7's five-thousand-row upload — the spinner
becomes a progress bar and the loop is split into chunks so the UI
updates. Streamlit does not offer a "cancel" gesture on a running
Python call; the closest is st.button("Stop") combined with a chunked
loop that reads st.session_state.stop_requested between chunks:
if st.button("Stop"):
st.session_state.stop_requested = True
for chunk in chunks(customers, size=200):
if st.session_state.get("stop_requested"):
st.warning("Stopped by the user.")
break
scored.extend(score_batch_remote(chunk))
stop_requested is per-session, so one user's cancellation does not
affect anyone else. The mechanism is not free — checking it between
chunks — but it is a real answer to "my export got stuck at 30 000
rows and I want to cancel".
Cache the API call, not the model
Local models sit behind @st.cache_resource. Remote calls sit behind
@st.cache_data, because you are caching a value, not an object.
@st.cache_data(ttl=60, show_spinner=False)
def score_cached(features_tuple: tuple) -> float:
features = dict(zip(FEATURES, features_tuple))
return score_remote(features)
probability = score_cached(tuple(features[name] for name in FEATURES))
The tuple conversion is deliberate: tuples are hashable and cheap to
hash, dicts are not the natural cache key. A 60-second TTL keeps the
cache from growing without a bound on a demo, and short enough that the
next user of the same features re-hits the model within a normal
freshness horizon. Do not cache a POST that has side effects — a
"submit an order" endpoint has no business behind a cache.
Adding both paths to the running example
The final version of the app uses a single environment switch, so a developer runs against a local model and production runs against the API without any code change.
import os
USE_REMOTE = os.getenv("USE_REMOTE_MODEL", "").lower() == "true"
def score(features: dict) -> float | None:
if USE_REMOTE:
return score_with_feedback(features)
try:
return score_local(features)
except Exception:
st.error("The local model failed. See the server logs.")
return None
# In the Score tab, after submit:
with st.spinner("Scoring..."):
probability = score(features)
if probability is not None:
st.session_state.last_result = {"id": customer_id, "score": probability}
st.metric(f"Churn probability for {customer_id}", f"{probability:.0%}",
delta_color="inverse")
That switch is what lets the training team ship a new model with no Streamlit change: bump the API version and every dashboard picks it up.
Without an explicit timeout=, requests.post waits for the OS default,
which can be minutes. On a Streamlit page, that is a locked browser tab
with a spinning circle and a user who reloads the page — pushing another
request to the already-stuck server. Set a timeout on every network
call, no exceptions.
In summary
- Local model:
@st.cache_resourceloads once,.predict_probascores in microseconds; keep the feature order explicit. - Remote API:
requests.postwithtimeout=,raise_for_status(), a Bearer token fromst.secrets; returnNoneon error, message the user in their terms. - Loading indicators:
st.spinnerfor a single call, a progress bar for a batch; a stop button throughst.session_state, per chunk. - Caching:
@st.cache_dataon the remote result with a short TTL, never on a POST that has side effects.
Next module: theme, appearance and usability, and the split into a multipage application.