-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathapp.py
More file actions
188 lines (157 loc) · 7.26 KB
/
Copy pathapp.py
File metadata and controls
188 lines (157 loc) · 7.26 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
"""Streamlit dashboard for production model health checks.
The page is an operations tool, not a model report. It answers three questions
in the order an operator asks them: is the right *kind* of artefact deployed,
does it have the feature width the last release had, and did it actually load.
Everything below that — the feature sample, the importance table — is for
reading the model once those three answers are satisfactory.
Each block calls its own endpoint and renders its own error (FR-01). Nothing
here holds a model, and nothing computes an importance: what you see is exactly
what an API client would get.
"""
from __future__ import annotations
import os
from typing import Any
import pandas as pd
import plotly.express as px
import requests
import streamlit as st
API_BASE_URL = os.getenv("MODEL_INFO_API_URL", "http://127.0.0.1:8000").rstrip("/")
# FR-01 acceptance criterion 3. Kept above the floor rather than at it, because
# a cold container reloading a model is the case the timeout exists for.
REQUEST_TIMEOUT_SECONDS = 30
# FR-05 acceptance criterion 2 and FR-06: the table and the chart share a cap.
TOP_FEATURES = 20
st.set_page_config(page_title="RankShift Serving", page_icon="🎯", layout="wide")
def call_api(path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
"""Fetch one endpoint, returning every failure as data rather than raising.
FR-07: no transport failure may reach the page as a traceback, so each mode
is named here and comes back under an ``error`` key. The names are the ones
the specification lists, because "API server is offline" tells an operator
what to do next and ``ConnectionError(...)`` does not.
"""
url = f"{API_BASE_URL}/{path.lstrip('/')}"
try:
response = requests.get(url, params=params, timeout=REQUEST_TIMEOUT_SECONDS)
except requests.exceptions.ConnectionError:
return {"error": "API server is offline"}
except requests.exceptions.Timeout:
return {"error": "Request timeout"}
except requests.exceptions.RequestException as exc:
return {"error": f"Error: {exc}"}
if response.status_code >= 400:
return {"error": f"API Error: {response.status_code}"}
try:
return response.json()
except ValueError:
return {"error": "Error: the server returned a response that is not JSON"}
def failure(payload: dict[str, Any]) -> str | None:
return payload.get("error") if isinstance(payload, dict) else None
def render_metadata(info: dict[str, Any]) -> None:
"""FR-02 and FR-03 — the three identity cards and the load status."""
model_type = info.get("model_type") or "unknown"
version = info.get("model_version") or "unknown"
# FR-02: `feature_count` first, `raw_feature_count` as the fallback, 0 as
# the floor. The page repeats the server's chain rather than trusting it,
# because an older backend may not have run the chain at all.
count = info.get("feature_count")
if count is None:
count = info.get("raw_feature_count")
try:
count = int(count)
except (TypeError, ValueError):
count = 0
one, two, three = st.columns(3)
one.metric("Model Type", model_type)
two.metric("Feature Count", f"{count:d}")
three.metric("Version", version)
if model_type not in {"Pipeline", "unknown"}:
st.caption(
f"The deployed artefact is a `{model_type}`, not a `Pipeline`. Preprocessing "
"is therefore not travelling with the estimator, and the two can drift apart."
)
st.subheader("Status")
status = str(info.get("status") or "not_loaded")
if status == "loaded":
st.write(status)
else:
st.error(
f"**{status}** — the service is running but no model is in memory. "
"Predictions will fail. Check the backend start-up log for the load error."
)
st.subheader("Sample Features")
# FR-04: JSON, expandable, and `[]` rather than blank when there is nothing.
st.json(info.get("features") or info.get("raw_features") or [])
def render_feature_importance(payload: dict[str, Any]) -> None:
"""FR-05 and FR-06 — the Top-20 table and the horizontal bar chart."""
rows = payload.get("feature_importance") or []
if not rows:
# FR-05 acceptance criterion 3: when the list is missing or empty, the
# server's own message is what the reader sees.
st.info(payload.get("message") or "No feature importance available")
return
table = pd.DataFrame(rows)
for column in ("importance", "coefficient"):
if column in table.columns:
table[column] = pd.to_numeric(table[column], errors="coerce")
table = table.sort_values("importance", ascending=False).head(TOP_FEATURES)
table = table.reset_index(drop=True)
st.dataframe(
table[[c for c in ("feature", "importance", "coefficient") if c in table.columns]],
width="stretch",
column_config={
"feature": st.column_config.TextColumn("feature", width="large"),
"importance": st.column_config.NumberColumn("importance", format="%.4f"),
"coefficient": st.column_config.NumberColumn("coefficient", format="%.4f"),
},
)
# FR-06: horizontal bars, importance on X, feature on Y, least important at
# the bottom. Plotly draws the first category at the bottom, so the frame is
# reversed rather than the axis.
chart = px.bar(
table.iloc[::-1],
x="importance",
y="feature",
orientation="h",
title=f"Top {TOP_FEATURES} Feature Importance",
)
chart.update_traces(marker_color="#6cb4ee")
chart.update_layout(
height=max(360, 22 * len(table)),
margin=dict(l=10, r=10, t=50, b=10),
xaxis_title="importance",
yaxis_title="feature",
showlegend=False,
)
# The current spelling of `use_container_width=True`, which this version of
# Streamlit deprecates. Same behaviour: the chart tracks the container.
st.plotly_chart(chart, width="stretch")
# ---------------------------------------------------------------------------
# Page
# ---------------------------------------------------------------------------
health = call_api("health")
with st.sidebar:
st.header("🎛️ Controls")
if failure(health):
st.error(f"❌ API Status: {failure(health)}")
else:
st.success(f"✅ API Status: {str(health.get('status', 'unknown')).title()}")
st.metric("Uptime", f"{float(health.get('uptime_seconds', 0)):.0f}s")
st.divider()
page = st.selectbox("Select Page", ["Model Info"])
st.title("🎯 RankShift Serving")
st.caption("Production-ready ML system for predicting user engagement")
st.header("🤖 Model Info")
# The two modules are fetched and rendered independently. If `/model/info`
# fails, the cards show a red box and the importance table below still loads,
# and the reverse holds too (FR-01 acceptance criterion 2).
info = call_api("model/info")
if failure(info):
st.error(f"Failed to fetch model info: {failure(info)}")
else:
render_metadata(info)
st.subheader("Feature Importance")
importance = call_api("metrics/features", params={"top": TOP_FEATURES})
if failure(importance):
st.error(f"Failed to fetch feature importance: {failure(importance)}")
else:
render_feature_importance(importance)