Repository navigation
Expand file tree
/
Copy pathstreamlit_app.py
More file actions
239 lines (195 loc) · 12 KB
/
Copy pathstreamlit_app.py
File metadata and controls
239 lines (195 loc) · 12 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
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
"""RetailFlow Executive Dashboard — Streamlit edition.
The third front-end. Same Gold data, same stylesheet tokens, same chart
builders as the Dash app (app/figures.py); Streamlit supplies the page,
filters and caching.
streamlit run streamlit_app.py
"""
import os
import sys
import streamlit as st
import streamlit.components.v1 as components
ROOT = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, ROOT)
from app import figures as fig # noqa: E402
from app.components import FILTER_FIELDS, ML_CARDS, PAGES_URL, REPO_URL # noqa: E402
from app.data import Dataset # noqa: E402
from app.ml_artefacts import load_ml # noqa: E402
from app.theme import METRIC_COLOURS, PALETTES # noqa: E402
NOTEBOOK_HTML = os.path.join(ROOT, "docs", "notebook.html")
NOTEBOOK_IPYNB = f"{REPO_URL}/blob/main/notebooks/retailflow_analytics.ipynb"
st.set_page_config(page_title="RetailFlow Executive Dashboard", page_icon="📊",
layout="wide", initial_sidebar_state="expanded")
# --------------------------------------------------------------------------- #
# Data — loaded once per process, aggregated once per filter set
# --------------------------------------------------------------------------- #
@st.cache_resource(show_spinner="Loading the Gold layer…")
def dataset():
return Dataset()
@st.cache_resource
def ml_artefacts():
return load_ml()
@st.cache_data(show_spinner=False)
def aggregate(filters_key, metric):
return dataset().aggregate(dict(filters_key), metric)
DATA = dataset()
ML = ml_artefacts()
THEME = "light"
P = PALETTES[THEME]
# --------------------------------------------------------------------------- #
# Chrome — the dashboard tokens, applied to Streamlit's own widgets
# --------------------------------------------------------------------------- #
st.markdown(f"""
<style>
.block-container {{ padding-top: 1.4rem; max-width: 1680px; }}
h1 {{ font-size: 1.5rem !important; letter-spacing: -0.01em; margin-bottom: 0 !important; }}
.lede {{ color: {P["--text-secondary"]}; font-size: 0.85rem; margin: 0.15rem 0 1rem; }}
.card {{ background: {P["--surface"]}; border: 1px solid {P["--border"]}; border-radius: 12px;
padding: 14px 16px 6px; margin-bottom: 4px; }}
.card-title {{ font-size: 0.95rem; font-weight: 620; margin: 0; color: {P["--text-primary"]}; }}
.card-sub {{ font-size: 0.78rem; color: {P["--text-muted"]}; margin: 2px 0 6px; }}
.kpi {{ position: relative; overflow: hidden; background: {P["--surface"]}; border: 1px solid {P["--border"]};
border-radius: 12px; padding: 14px 16px 12px; margin-bottom: 12px; }}
.kpi::before {{ content: ""; position: absolute; inset: 0 0 auto 0; height: 3px; background: var(--accent); }}
.kpi-label {{ font-size: 0.66rem; font-weight: 600; letter-spacing: 0.07em; text-transform: uppercase;
color: {P["--text-muted"]}; margin-bottom: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }}
.kpi-value {{ font-size: 1.6rem; font-weight: 640; letter-spacing: -0.02em; line-height: 1.1; color: {P["--text-primary"]}; }}
.kpi-sub {{ margin-top: 4px; font-size: 0.74rem; color: {P["--text-secondary"]}; }}
.kpi-sub .dot {{ display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--accent); margin-right: 6px; }}
.score {{ background: {P["--surface"]}; border: 1px solid {P["--border"]}; border-radius: 12px; padding: 12px 16px; }}
.score-name {{ font-size: 0.7rem; letter-spacing: 0.07em; text-transform: uppercase; color: {P["--text-muted"]}; font-weight: 600; }}
.score-value {{ font-size: 1.15rem; font-weight: 640; color: {P["--text-primary"]}; }}
.beat {{ font-size: 0.75rem; margin-left: 6px; font-weight: 600; }}
.beat.yes {{ color: {P["--status-good"]}; }} .beat.no {{ color: {P["--status-critical"]}; }}
.score-note {{ font-size: 0.75rem; color: {P["--text-secondary"]}; }}
</style>
""", unsafe_allow_html=True)
st.title("RetailFlow Executive Business Intelligence Dashboard")
st.markdown('<p class="lede">Enterprise Data Engineering Pipeline · Bronze → Silver → Gold | AWS S3 | Spark | Airflow</p>',
unsafe_allow_html=True)
tab_dashboard, tab_notebook = st.tabs(["📊 Dashboard", "📓 Notebook"])
# --------------------------------------------------------------------------- #
# Sidebar filters
# --------------------------------------------------------------------------- #
with st.sidebar:
st.header("Filters")
metric = st.radio("Metric", ["Revenue", "Profit", "Quantity"], horizontal=True)
filters = {}
for key, label, placeholder in FILTER_FIELDS:
choice = st.selectbox(label, [placeholder] + list(DATA.options[key]), key=f"f-{key}")
filters[key] = None if choice == placeholder else choice
if st.button("Reset filters", use_container_width=True):
for key, _, _ in FILTER_FIELDS:
st.session_state.pop(f"f-{key}", None)
st.rerun()
st.caption(f"[Source on GitHub]({REPO_URL}) · [Static report]({PAGES_URL})")
# --------------------------------------------------------------------------- #
# Dashboard tab
# --------------------------------------------------------------------------- #
def card(title, subtitle, figure):
st.markdown(f'<div class="card"><p class="card-title">{title}</p><p class="card-sub">{subtitle}</p></div>',
unsafe_allow_html=True)
st.plotly_chart(figure, use_container_width=True, config=fig.GRAPH_CONFIG)
with tab_dashboard:
agg = aggregate(tuple(sorted(filters.items())), metric)
is_money = metric != "Quantity"
accent = P[METRIC_COLOURS[metric]]
orders_colour = P["--orders"]
total = agg["metricTotal"]
active = [f"**{lbl}:** {filters[k]}" for k, lbl, _ in FILTER_FIELDS if filters.get(k)]
pct = agg["rows"] / DATA.rows * 100 if DATA.rows else 0
st.caption((" · ".join(active) if active else "No filters applied — full dataset")
+ f" → **{agg['rows']:,}** of {DATA.rows:,} order lines ({pct:.1f}%)")
def kpi(col, label, value, sub, accent):
col.markdown(
f'<div class="kpi" style="--accent:{P[accent]}"><div class="kpi-label">{label}</div>'
f'<div class="kpi-value">{value}</div><div class="kpi-sub"><span class="dot"></span>{sub}</div></div>',
unsafe_allow_html=True)
k = st.columns(4)
kpi(k[0], "Total revenue", fig.money(agg["revenue"]), fig.money_full(agg["revenue"]), "--revenue")
kpi(k[1], "Gross profit", fig.money(agg["profit"]), f"{agg['margin']:.1f}% margin", "--profit")
kpi(k[2], "Orders", fig.compact(agg["orders"]), f"{agg['orders']:,} order lines", "--orders")
kpi(k[3], "Customers", fig.compact(agg["customers"]), f"{agg['customers']:,} active buyers", "--profit")
k = st.columns(4)
kpi(k[0], "Products sold", fig.compact(agg["products"]), f"{agg['products']:,} SKUs sold", "--quantity")
kpi(k[1], "Avg order value", fig.money_full(agg["aov"]), "per order", "--revenue")
kpi(k[2], "Avg profit margin", f"{agg['margin']:.1f}%", f"{fig.money(agg['profit'])} on {fig.money(agg['revenue'])}", "--profit")
kpi(k[3], "Cities served", fig.compact(agg["cities"]), f"{agg['cities']:,} UK locations", "--orders")
def label(v):
return fig.metric_label(v, is_money)
def hover(row):
share = f"{row['value'] / total * 100:.1f}% of total" if total else ""
return f"{fig.metric_full(row['value'], is_money)}<br>{fig.count(row['orders'])} orders · {share}"
def bars(key):
return fig.h_bar(agg[key], accent, label, THEME, hover)
trend = agg["trend"]
status_rows = fig.status_colours(list(agg["status"]), THEME)
for row in status_rows:
row["hover"] = f"{fig.count(row['value'])} orders"
lower = metric.lower()
def ranked(noun, rows):
return f"Top {len(rows)} {noun} by {lower}" if rows else f"{noun.capitalize()} by {lower}"
if agg["rows"] == 0:
st.info("No records match the current filters.")
else:
c = st.columns(3)
with c[0]: card(f"{metric} Trend", "By order month · first and last months may be partial",
fig.line(trend["dates"], trend["values"], accent, "£" if is_money else "", THEME))
with c[1]: card("Top Products by Brand", ranked("brands", agg["brand"]), bars("brand"))
with c[2]: card(f"{metric} by Category", "Product category mix", bars("category"))
c = st.columns(3)
with c[0]: card(f"{metric} by City", ranked("cities", agg["city"]), bars("city"))
with c[1]: card(f"{metric} by Gender", "Customer demographic split", bars("gender"))
with c[2]: card("Payment Methods", f"{metric} by payment method", bars("payment"))
c = st.columns(3)
with c[0]: card("Order Status", "Order count by fulfilment state",
fig.h_bar(status_rows, orders_colour, fig.count, THEME, lambda r: r["hover"]))
with c[1]: card("Top Customers", ranked("customers", agg["customer"]), bars("customer"))
with c[2]: card("Monthly Orders", "Order volume by month",
fig.column(trend["dates"], trend["orders"], orders_colour,
[f"{fig.count(n)} orders" for n in trend["orders"]], THEME))
# ---- Predictive analytics --------------------------------------------- #
if ML:
st.subheader("Predictive Analytics")
st.caption("Trained on the Gold layer. Every model is scored against an explicit baseline, shown below.")
c = st.columns(3)
builders = {"forecast": lambda: fig.forecast_chart(ML.get("forecast", []), THEME),
"risk": lambda: fig.risk_chart(ML.get("riskByCategory", []), THEME),
"segments": lambda: fig.segment_chart(ML.get("segments", []), THEME)}
for col, (key, title, subtitle) in zip(c, ML_CARDS):
with col:
card(title, subtitle, builders[key]())
m = ML.get("metrics", {})
rows = []
if r := m.get("fulfilment_risk"):
rows.append(("Fulfilment risk", f"AUC {r['roc_auc']:.3f}", r["beats_baseline"],
f"PR-AUC {r['pr_auc']:.3f} vs {r['baseline_pr_auc']:.3f} base rate"))
if c2 := m.get("repeat_purchase"):
rows.append(("Repeat purchase", f"AUC {c2['roc_auc']:.3f}", c2["beats_recency_rule"],
f"recency-only rule {c2['recency_only_roc_auc']:.3f}"))
if f := m.get("forecast"):
rows.append(("Revenue forecast", f"{f['mape'] * 100:.1f}% MAPE", f["beats_naive"],
f"naive {f['naive_mape'] * 100:.1f}% · seasonal naive {f['seasonal_naive_mape'] * 100:.1f}%"))
if s := m.get("segmentation"):
rows.append(("Segmentation", f"{s['k']} segments", True, f"K-means on log RFM · {s['customers']:,} customers"))
cols = st.columns(len(rows))
for col, (name, value, beats, note) in zip(cols, rows):
col.markdown(
f'<div class="score"><div class="score-name">{name}</div>'
f'<div class="score-value">{value}<span class="beat {"yes" if beats else "no"}">'
f'{"beats baseline" if beats else "at baseline"}</span></div>'
f'<div class="score-note">{note}</div></div>', unsafe_allow_html=True)
# --------------------------------------------------------------------------- #
# Notebook tab
# --------------------------------------------------------------------------- #
with tab_notebook:
st.markdown(
f"The executed analytics notebook — one section per dashboard visual, the signal checks, "
f"the four models with their baselines, and the dashboards. "
f"[Open the source notebook on GitHub]({NOTEBOOK_IPYNB}) · "
f"[Open the rendered copy on GitHub Pages]({PAGES_URL}/notebook.html)")
if os.path.exists(NOTEBOOK_HTML):
with open(NOTEBOOK_HTML, "r", encoding="utf-8") as handle:
components.html(handle.read(), height=1400, scrolling=True)
else:
st.warning("docs/notebook.html is not built yet — run `python notebooks/build_notebook.py`.")
components.iframe(f"{PAGES_URL}/notebook.html", height=1400, scrolling=True)