Spaces:
Sleeping
Sleeping
| """ | |
| 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") | |
| 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() | |
| 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") | |