RJuro's picture
Fix Streamlit API: replace width='stretch' with use_container_width and guard empty MAPE df
ee8d17f verified
Raw
History Blame Contribute Delete
13.4 kB
"""
Streamlit dashboard: Pharma Demand Forecasting & Replenishment
"""
import streamlit as st
import pandas as pd
import numpy as np
import plotly.express as px
import plotly.graph_objects as go
from plotly.subplots import make_subplots
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from simulation_engine import run_arena, list_demo_skus
st.set_page_config(page_title="Pharma Demand Forecasting", layout="wide")
# ------------------------------------------------------------------
# Load data
# ------------------------------------------------------------------
DATA_DIR = os.path.join(os.path.dirname(__file__), "data")
@st.cache_data
def load_all():
products = pd.read_csv(os.path.join(DATA_DIR, "products.csv"))
demand = pd.read_csv(os.path.join(DATA_DIR, "demand.csv"))
inventory = pd.read_csv(os.path.join(DATA_DIR, "inventory.csv"))
replenishment = pd.read_csv(os.path.join(DATA_DIR, "replenishment.csv"))
vbp = pd.read_csv(os.path.join(DATA_DIR, "vbp_impact.csv"))
demand["month"] = pd.to_datetime(demand["month"])
inventory["month"] = pd.to_datetime(inventory["month"])
vbp["month"] = pd.to_datetime(vbp["month"])
return products, demand, inventory, replenishment, vbp
try:
products, demand, inventory, replenishment, vbp = load_all()
except Exception:
st.error("Data not found. Run `python data/generate_data_sdv.py` (or generate_data.py) first.")
st.stop()
@st.cache_data
def run_arena_cached(sku_id, horizon_days, vbp_shock_day, seed, inject_vbp):
return run_arena(sku_id=sku_id, horizon_days=horizon_days,
vbp_shock_day=vbp_shock_day, seed=seed, inject_vbp=inject_vbp)
# ------------------------------------------------------------------
# Sidebar
# ------------------------------------------------------------------
st.sidebar.title("Filters")
therapy_filter = st.sidebar.multiselect(
"Therapy Area",
options=products["therapy_area"].unique(),
default=products["therapy_area"].unique(),
)
vbp_only = st.sidebar.checkbox("VBP Products Only", value=False)
filtered_prods = products[
products["therapy_area"].isin(therapy_filter)
& ((not vbp_only) | products["vbp_flag"])
]
selected_skus = filtered_prods["sku_id"].tolist()
# ------------------------------------------------------------------
# KPI Cards
# ------------------------------------------------------------------
st.title("💊 Pharma Demand Forecasting & Replenishment")
col1, col2, col3, col4 = st.columns(4)
total_skus = len(filtered_prods)
total_inv_value = inventory[inventory["sku_id"].isin(selected_skus)]["inventory_value_cny"].iloc[-len(selected_skus):].sum()
stockout_count = inventory[inventory["sku_id"].isin(selected_skus)]["stockout_flag"].sum()
high_priority_orders = replenishment[
(replenishment["sku_id"].isin(selected_skus)) & (replenishment["priority"] == "High")
]["order_value_cny"].sum()
col1.metric("Products", f"{total_skus}")
col2.metric("Inventory Value (CNY)", f"¥{total_inv_value:,.0f}")
col3.metric("Stock-out Events", f"{int(stockout_count)}")
col4.metric("High-Priority Orders (CNY)", f"¥{high_priority_orders:,.0f}")
# ------------------------------------------------------------------
# Tabs
# ------------------------------------------------------------------
tab1, tab2, tab3, tab4, tab5 = st.tabs([
"📈 Demand vs Forecast",
"📦 Inventory & Stock-outs",
"🔄 Replenishment",
"💰 VBP Impact",
"🏟️ Simulation Arena",
])
# --- TAB 1: Demand vs Forecast ---
with tab1:
st.subheader("Historical Demand vs. Forecast")
d_filt = demand[demand["sku_id"].isin(selected_skus)]
d_agg = d_filt.groupby("month").agg({"actual_demand": "sum", "forecast_demand": "sum"}).reset_index()
fig1 = go.Figure()
fig1.add_trace(go.Scatter(x=d_agg["month"], y=d_agg["actual_demand"], mode="lines+markers", name="Actual Demand"))
fig1.add_trace(go.Scatter(x=d_agg["month"], y=d_agg["forecast_demand"], mode="lines+markers", name="Forecast", line=dict(dash="dash")))
fig1.update_layout(height=450, xaxis_title="Month", yaxis_title="Units", legend=dict(orientation="h", yanchor="bottom", y=1.02))
st.plotly_chart(fig1, use_container_width=True)
st.subheader("Forecast Accuracy by SKU (last 6 months)")
acc = []
for sku in selected_skus:
sub = d_filt[d_filt["sku_id"] == sku].sort_values("month").tail(6)
if len(sub) == 0:
continue
mape = np.mean(np.abs((sub["actual_demand"] - sub["forecast_demand"]) / sub["actual_demand"].replace(0, np.nan))) * 100
acc.append({"SKU": sku, "MAPE": mape})
df_acc = pd.DataFrame(acc)
if not df_acc.empty:
df_acc = df_acc.sort_values("MAPE", ascending=False)
fig1b = px.bar(df_acc, x="SKU", y="MAPE", color="MAPE", color_continuous_scale="RdYlGn_r")
fig1b.update_layout(height=350)
st.plotly_chart(fig1b, use_container_width=True)
# --- TAB 2: Inventory ---
with tab2:
st.subheader("Inventory Levels Over Time")
i_filt = inventory[inventory["sku_id"].isin(selected_skus)]
i_agg = i_filt.groupby("month").agg({"ending_inventory": "sum", "inventory_value_cny": "sum"}).reset_index()
fig2 = make_subplots(specs=[[{"secondary_y": True}]])
fig2.add_trace(go.Bar(x=i_agg["month"], y=i_agg["ending_inventory"], name="Units", marker_color="steelblue"), secondary_y=False)
fig2.add_trace(go.Scatter(x=i_agg["month"], y=i_agg["inventory_value_cny"], name="Value (CNY)", mode="lines+markers", line=dict(color="darkorange")), secondary_y=True)
fig2.update_layout(height=450, legend=dict(orientation="h", yanchor="bottom", y=1.02))
fig2.update_yaxes(title_text="Units", secondary_y=False)
fig2.update_yaxes(title_text="CNY", secondary_y=True)
st.plotly_chart(fig2, use_container_width=True)
st.subheader("Stock-out Events")
stockouts = i_filt[i_filt["stockout_flag"]].groupby("sku_id").size().reset_index(name="stockout_count")
if not stockouts.empty:
fig2b = px.bar(stockouts, x="sku_id", y="stockout_count", color="stockout_count", color_continuous_scale="Reds")
fig2b.update_layout(height=350)
st.plotly_chart(fig2b, use_container_width=True)
else:
st.info("No stock-outs in selected range — great job!")
# --- TAB 3: Replenishment ---
with tab3:
st.subheader("Current Replenishment Recommendations")
r_filt = replenishment[replenishment["sku_id"].isin(selected_skus)].merge(
products[["sku_id", "product_name", "vbp_flag"]], on="sku_id"
)
r_filt = r_filt[["sku_id", "product_name", "vbp_flag", "current_inventory", "avg_monthly_demand",
"safety_stock", "reorder_point", "suggested_order_qty", "order_value_cny", "priority"]]
# Color-coded priority
def color_priority(val):
if val == "High":
return "background-color: #ffcccc"
elif val == "Medium":
return "background-color: #ffffcc"
return "background-color: #ccffcc"
st.dataframe(
r_filt.style.map(color_priority, subset=["priority"]),
height=400,
)
st.subheader("Suggested Order Value by Priority")
fig3 = px.pie(r_filt, names="priority", values="order_value_cny", hole=0.4,
color="priority", color_discrete_map={"High": "#ff6b6b", "Medium": "#feca57", "Low": "#1dd1a1"})
fig3.update_layout(height=400)
st.plotly_chart(fig3, use_container_width=True)
# --- TAB 4: VBP Impact ---
with tab4:
st.subheader("Volume-Based Procurement (VBP) Price & Volume Impact")
v_filt = vbp[vbp["sku_id"].isin(selected_skus)].merge(products[["sku_id", "product_name"]], on="sku_id")
if not v_filt.empty:
sku_choice = st.selectbox("Select VBP Product", v_filt["sku_id"].unique())
v_sub = v_filt[v_filt["sku_id"] == sku_choice]
fig4 = make_subplots(specs=[[{"secondary_y": True}]])
fig4.add_trace(go.Scatter(x=v_sub["month"], y=v_sub["pre_vbp_price"], name="Pre-VBP Price", mode="lines", line=dict(dash="dot")), secondary_y=False)
fig4.add_trace(go.Scatter(x=v_sub["month"], y=v_sub["post_vbp_price"], name="Post-VBP Price", mode="lines+markers"), secondary_y=False)
fig4.add_trace(go.Bar(x=v_sub["month"], y=v_sub["volume_uplift_pct"]*100, name="Volume Uplift %", marker_color="green", opacity=0.5), secondary_y=True)
fig4.update_layout(height=450, title=f"{v_sub['product_name'].iloc[0]} ({sku_choice})", legend=dict(orientation="h", yanchor="bottom", y=1.02))
fig4.update_yaxes(title_text="Price (CNY)", secondary_y=False)
fig4.update_yaxes(title_text="Volume Uplift %", secondary_y=True)
st.plotly_chart(fig4, use_container_width=True)
else:
st.info("No VBP products match current filter.")
# --- TAB 5: Simulation Arena ---
with tab5:
st.subheader("🏟️ Simulation Arena — Naïve vs Adaptive Replenishment")
st.caption(
"Two universes run the SAME demand from the same seed; only the "
"replenishment policy differs. **Universe A** uses a naïve reorder point "
"(fixed safety stock, 7-day average, blind to the VBP shock). "
"**Universe B** reviews periodically, sizes safety stock from demand "
"volatility, and anticipates the VBP demand spillover."
)
demo = list_demo_skus()
demo["label"] = (demo["product_name"] + " · " + demo["demand_class"]
+ demo["vbp_flag"].map({True: " · VBP", False: ""}))
c1, c2, c3 = st.columns([2, 1, 1])
with c1:
idx = st.selectbox("SKU", options=list(demo.index),
format_func=lambda i: demo.loc[i, "label"])
arena_sku = demo.loc[idx, "sku_id"]
with c2:
horizon = st.slider("Horizon (days)", 90, 365, 180, step=15)
with c3:
seed = st.number_input("Seed", min_value=0, max_value=9999, value=42, step=1)
c4, c5 = st.columns([1, 2])
with c4:
inject_vbp = st.checkbox("Inject VBP shock", value=True)
with c5:
vbp_day = st.slider("VBP shock day", 10, int(horizon) - 10,
min(90, int(horizon) - 10), step=5, disabled=not inject_vbp)
res = run_arena_cached(arena_sku, int(horizon), int(vbp_day), int(seed), bool(inject_vbp))
A, B, delta = res["A"]["kpis"], res["B"]["kpis"], res["delta"]
st.markdown(f"#### Outcome ({int(horizon)}-day P&L)")
k1, k2, k3, k4 = st.columns(4)
k1.metric("Gross margin Δ (B−A)", f"¥{delta['gross_margin']:,.0f}",
help=f"A ¥{A['gross_margin']:,.0f} · B ¥{B['gross_margin']:,.0f}")
k2.metric("Service level Δ", f"{delta['service_level']*100:+.1f} pp",
help=f"A {A['service_level']*100:.1f}% · B {B['service_level']*100:.1f}%")
k3.metric("Stock-out days Δ", f"{delta['stockout_days']:+d}",
delta=f"{delta['stockout_days']:+d}", delta_color="inverse",
help=f"A {A['stockout_days']} · B {B['stockout_days']}")
k4.metric("Avg on-hand Δ", f"{delta['avg_on_hand']:+,.0f}",
help=f"A {A['avg_on_hand']:,.0f} · B {B['avg_on_hand']:,.0f}")
days = list(range(res["config"]["horizon_days"]))
figA = go.Figure()
figA.add_trace(go.Bar(x=days, y=res["demand"], name="Daily demand", marker_color="lightgray"))
figA.add_trace(go.Scatter(x=days, y=res["A"]["series"]["on_hand"], name="A on-hand (naïve)", line=dict(color="#ff6b6b")))
figA.add_trace(go.Scatter(x=days, y=res["B"]["series"]["on_hand"], name="B on-hand (adaptive)", line=dict(color="#1dd1a1")))
if inject_vbp and res["sku"].vbp_flag:
figA.add_vline(x=int(vbp_day), line_dash="dot", line_color="purple", annotation_text="VBP shock")
figA.update_layout(height=380, title="Inventory on hand vs daily demand",
xaxis_title="Day", yaxis_title="Units", legend=dict(orientation="h", y=1.02))
st.plotly_chart(figA, use_container_width=True)
cc1, cc2 = st.columns(2)
with cc1:
figB = go.Figure()
figB.add_trace(go.Scatter(x=days, y=res["A"]["series"]["cum_cash"], name="A", line=dict(color="#ff6b6b")))
figB.add_trace(go.Scatter(x=days, y=res["B"]["series"]["cum_cash"], name="B", line=dict(color="#1dd1a1")))
figB.update_layout(height=320, title="Cumulative net margin (¥)",
xaxis_title="Day", legend=dict(orientation="h", y=1.02))
st.plotly_chart(figB, use_container_width=True)
with cc2:
figC = go.Figure()
figC.add_trace(go.Bar(x=days, y=res["A"]["series"]["unmet"], name="A unmet", marker_color="#ff6b6b"))
figC.add_trace(go.Bar(x=days, y=res["B"]["series"]["unmet"], name="B unmet", marker_color="#1dd1a1"))
figC.update_layout(height=320, title="Daily unmet demand (stock-outs)",
barmode="overlay", xaxis_title="Day", legend=dict(orientation="h", y=1.02))
figC.update_traces(opacity=0.7)
st.plotly_chart(figC, use_container_width=True)
with st.expander(f"📋 Event log ({len(res['events'])} events)"):
if res["events"]:
ev = pd.DataFrame(res["events"], columns=["Day", "Event", "Detail"])
st.dataframe(ev, height=300)
else:
st.info("No notable events for this configuration.")
st.markdown("---")
st.caption("Synthetic demo data • Built for Pharma Demand Forecasting case")