Spaces:
Runtime error
Runtime error
GitHub Actions commited on
Commit ·
59ebe6e
0
Parent(s):
Deploy to Hugging Face Space
Browse files- README.md +41 -0
- deployment/README.md +14 -0
- deployment/__init__.py +7 -0
- deployment/app.py +65 -0
- deployment/eda.py +250 -0
- deployment/prediction.py +171 -0
- deployment/requirements.txt +7 -0
- deployment/shipping.csv +0 -0
- requirements.txt +7 -0
README.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: MLOps Mid Exam - Shipping Delay Prediction
|
| 3 |
+
emoji: 📦
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: purple
|
| 6 |
+
sdk: streamlit
|
| 7 |
+
app_file: deployment/app.py
|
| 8 |
+
pinned: false
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# MLOps Mid Exam – Shipping Delay Prediction
|
| 12 |
+
|
| 13 |
+
A lightweight MLOps project that predicts whether a shipment will arrive on time. The model is a scikit-learn pipeline (KNN + preprocessing) and the Streamlit app is deployed to Hugging Face Spaces while CI keeps the training artifacts healthy.
|
| 14 |
+
|
| 15 |
+
- **Demo**: https://huggingface.co/spaces/vorddd/MLOps-MidExam
|
| 16 |
+
- **Model repo**: `vorddd/shipping-delay-knn`
|
| 17 |
+
|
| 18 |
+
## How It Works
|
| 19 |
+
|
| 20 |
+
- The training notebook exports `models/best_model_pipeline.joblib`.
|
| 21 |
+
- `deployment/prediction.py` loads that file from `models/` during development and from the Hugging Face Hub in production (via `hf_hub_download`).
|
| 22 |
+
- `deployment/app.py` stitches a simple overview page, an EDA tab (`deployment/eda.py`), and the prediction form.
|
| 23 |
+
- Runtime dependencies live in `deployment/requirements.txt`; dev/test tooling stays in `requirements-dev.txt`.
|
| 24 |
+
|
| 25 |
+
## Run Locally
|
| 26 |
+
|
| 27 |
+
```bash
|
| 28 |
+
python -m venv .venv
|
| 29 |
+
.venv\Scripts\activate # or source .venv/bin/activate
|
| 30 |
+
pip install -r deployment/requirements.txt -r requirements-dev.txt
|
| 31 |
+
streamlit run deployment/app.py
|
| 32 |
+
```
|
| 33 |
+
|
| 34 |
+
Place the exported pipelines inside `models/` (already ignored in CD) and Streamlit will use them automatically. `pytest` runs the quick smoke tests.
|
| 35 |
+
|
| 36 |
+
## CI/CD
|
| 37 |
+
|
| 38 |
+
- **CI** (`.github/workflows/ci.yml`): runs on pushes/PRs to `main`, installs runtime + dev requirements, then executes `pytest`.
|
| 39 |
+
- **CD** (`.github/workflows/cd.yml`): mirrors the minimal app bundle (README + `deployment/` folder + requirements) into a temp directory and force-pushes it to the Hugging Face Space `vorddd/MLOps-MidExam` with `HF_TOKEN`. If the token is missing, the deploy step exits gracefully.
|
| 40 |
+
|
| 41 |
+
This setup keeps the repository easy to iterate on locally while ensuring the public app always downloads the latest pipeline from the Hub.
|
deployment/README.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: MLOps Mid Exam - Shipping Delay Prediction
|
| 3 |
+
emoji: 📦
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: purple
|
| 6 |
+
sdk: docker
|
| 7 |
+
sdk_version: "latest"
|
| 8 |
+
app_file: deployment/app.py
|
| 9 |
+
pinned: false
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
# MLOps Mid Exam - Shipping Delay Prediction
|
| 13 |
+
|
| 14 |
+
Streamlit dashboard to explore shipping data and run the trained KNN model for on-time delivery predictions. Built for the Hacktiv8 MLOps Mid Exam and deployed via GitHub Actions to Hugging Face Spaces.
|
deployment/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# deployment/__init__.py
|
| 2 |
+
|
| 3 |
+
"""
|
| 4 |
+
Package untuk komponen aplikasi MLOps MidExam.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from .prediction import load_model, model_page
|
deployment/app.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
|
| 3 |
+
import pandas as pd
|
| 4 |
+
import streamlit as st
|
| 5 |
+
|
| 6 |
+
from deployment.eda import eda_page
|
| 7 |
+
from deployment.prediction import model_page
|
| 8 |
+
|
| 9 |
+
st.set_page_config(
|
| 10 |
+
page_title="Shipping Service Monitor",
|
| 11 |
+
page_icon=":package:",
|
| 12 |
+
layout="wide",
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
BASE_DIR = Path(__file__).resolve().parent
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@st.cache_data(show_spinner=False)
|
| 19 |
+
def load_data() -> pd.DataFrame:
|
| 20 |
+
"""Read the dataset packaged with the deployment bundle."""
|
| 21 |
+
return pd.read_csv(BASE_DIR / "shipping.csv")
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def render_overview(data: pd.DataFrame) -> None:
|
| 25 |
+
st.title("Shipping Service Monitor")
|
| 26 |
+
st.caption("Shipping delay prediction")
|
| 27 |
+
|
| 28 |
+
col1, col2, col3 = st.columns(3)
|
| 29 |
+
col1.metric("Total Shipments", f"{len(data):,}")
|
| 30 |
+
col2.metric("Average Cost", f"${data['Cost_of_the_Product'].mean():.0f}")
|
| 31 |
+
on_time_rate = data["Reached.on.Time_Y.N"].mean() * 100
|
| 32 |
+
col3.metric("On-time Rate", f"{on_time_rate:.1f}%")
|
| 33 |
+
|
| 34 |
+
st.divider()
|
| 35 |
+
st.subheader("Sample of the Dataset")
|
| 36 |
+
st.dataframe(
|
| 37 |
+
data.head(5),
|
| 38 |
+
use_container_width=True,
|
| 39 |
+
hide_index=True,
|
| 40 |
+
)
|
| 41 |
+
st.info(
|
| 42 |
+
"This app mirrors the Hugging Face Space layout and reads the same CSV + model "
|
| 43 |
+
"artifacts, so local development and production behave identically."
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def main() -> None:
|
| 48 |
+
data = load_data()
|
| 49 |
+
render_overview(data)
|
| 50 |
+
|
| 51 |
+
st.sidebar.header("Navigation")
|
| 52 |
+
selected_option = st.sidebar.radio(
|
| 53 |
+
"Choose a page",
|
| 54 |
+
options=("Data Analysis", "Model Prediction"),
|
| 55 |
+
index=0,
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
if selected_option == "Data Analysis":
|
| 59 |
+
eda_page(data)
|
| 60 |
+
else:
|
| 61 |
+
model_page(data)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
if __name__ == "__main__":
|
| 65 |
+
main()
|
deployment/eda.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
import pandas as pd
|
| 3 |
+
import plotly.express as px
|
| 4 |
+
import streamlit as st
|
| 5 |
+
|
| 6 |
+
TARGET_COLUMN = "Reached.on.Time_Y.N"
|
| 7 |
+
TARGET_LABELS = {1: "On Time", 0: "Late"}
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def _label_target(series: pd.Series) -> pd.Series:
|
| 11 |
+
return series.map(TARGET_LABELS).fillna("Unknown")
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _get_categorical_columns(data: pd.DataFrame) -> list[str]:
|
| 15 |
+
return data.select_dtypes(include=["object"]).columns.tolist()
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _get_numeric_columns(data: pd.DataFrame) -> list[str]:
|
| 19 |
+
"""Return only business-relevant numeric features (exclude ID & target)."""
|
| 20 |
+
candidate_cols = [
|
| 21 |
+
"Customer_care_calls",
|
| 22 |
+
"Customer_rating",
|
| 23 |
+
"Cost_of_the_Product",
|
| 24 |
+
"Prior_purchases",
|
| 25 |
+
"Discount_offered",
|
| 26 |
+
"Weight_in_gms",
|
| 27 |
+
]
|
| 28 |
+
return [c for c in candidate_cols if c in data.columns]
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _make_numeric_bins(series: pd.Series, n_bins: int = 5) -> pd.Categorical:
|
| 32 |
+
"""Create human-friendly ranges like '0–1000' instead of raw numbers."""
|
| 33 |
+
min_v = float(series.min())
|
| 34 |
+
max_v = float(series.max())
|
| 35 |
+
|
| 36 |
+
if min_v == max_v:
|
| 37 |
+
return pd.cut(series, bins=[min_v - 1, max_v + 1], labels=[f"{min_v:.0f}"])
|
| 38 |
+
|
| 39 |
+
bins = np.linspace(min_v, max_v, n_bins + 1)
|
| 40 |
+
labels = [f"{bins[i]:.0f}–{bins[i+1]:.0f}" for i in range(len(bins) - 1)]
|
| 41 |
+
return pd.cut(series, bins=bins, labels=labels, include_lowest=True)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def eda_page(data: pd.DataFrame) -> None:
|
| 45 |
+
st.header("Exploratory Data Analysis")
|
| 46 |
+
|
| 47 |
+
total_shipments = len(data)
|
| 48 |
+
on_time_rate = data[TARGET_COLUMN].mean() * 100
|
| 49 |
+
|
| 50 |
+
median_discount = data["Discount_offered"].median()
|
| 51 |
+
median_weight = data["Weight_in_gms"].median()
|
| 52 |
+
loyal_share = (data["Prior_purchases"] > 3).mean() * 100
|
| 53 |
+
|
| 54 |
+
c1, c2, c3, c4 = st.columns(4)
|
| 55 |
+
c1.metric("Total shipments", f"{total_shipments:,}")
|
| 56 |
+
c2.metric("On-time delivery rate", f"{on_time_rate:.1f}%")
|
| 57 |
+
c3.metric("Median discount", f"{median_discount:.1f}%")
|
| 58 |
+
c4.metric("Loyal customers (> 3 prior purchases)", f"{loyal_share:.1f}%")
|
| 59 |
+
|
| 60 |
+
st.caption(
|
| 61 |
+
"Quick overview: how many shipments you have, how reliable deliveries are, "
|
| 62 |
+
"and how generous you are with discounts and loyal customers."
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
tab_target, tab_category, tab_numeric, tab_segments = st.tabs(
|
| 66 |
+
["Delivery status", "By category", "By numeric feature", "Business segments"]
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
with tab_target:
|
| 70 |
+
st.subheader("Overall delivery status")
|
| 71 |
+
|
| 72 |
+
target_series = _label_target(data[TARGET_COLUMN])
|
| 73 |
+
target_counts = (
|
| 74 |
+
target_series.value_counts().rename_axis("Status").reset_index(name="Count")
|
| 75 |
+
)
|
| 76 |
+
target_counts["Percentage"] = (
|
| 77 |
+
target_counts["Count"] / target_counts["Count"].sum() * 100
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
fig = px.pie(
|
| 81 |
+
target_counts,
|
| 82 |
+
values="Count",
|
| 83 |
+
names="Status",
|
| 84 |
+
hole=0.35,
|
| 85 |
+
)
|
| 86 |
+
fig.update_traces(textposition="inside", textinfo="percent+label")
|
| 87 |
+
st.plotly_chart(fig, use_container_width=True)
|
| 88 |
+
|
| 89 |
+
late_row = target_counts.loc[target_counts["Status"] == "Late"]
|
| 90 |
+
if not late_row.empty:
|
| 91 |
+
late_pct = late_row["Percentage"].iloc[0]
|
| 92 |
+
st.write(
|
| 93 |
+
f"**Insight:** about **{late_pct:.1f}%** of all shipments arrive late. "
|
| 94 |
+
"This is the high-level reliability of your operation."
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
with tab_category:
|
| 98 |
+
st.subheader("How delivery status changes by category")
|
| 99 |
+
|
| 100 |
+
categorical_columns = _get_categorical_columns(data)
|
| 101 |
+
if not categorical_columns:
|
| 102 |
+
st.info("This dataset does not contain categorical features.")
|
| 103 |
+
else:
|
| 104 |
+
selected_cat = st.selectbox(
|
| 105 |
+
"Choose a categorical feature",
|
| 106 |
+
options=categorical_columns,
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
# Instead of 100% bars, show on-time rate per category
|
| 110 |
+
summary = (
|
| 111 |
+
data.groupby(selected_cat)
|
| 112 |
+
.agg(
|
| 113 |
+
on_time_percent=(TARGET_COLUMN, lambda x: x.mean() * 100),
|
| 114 |
+
total_shipments=(TARGET_COLUMN, "size"),
|
| 115 |
+
)
|
| 116 |
+
.reset_index()
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
fig_cat = px.bar(
|
| 120 |
+
summary,
|
| 121 |
+
x=selected_cat,
|
| 122 |
+
y="on_time_percent",
|
| 123 |
+
text_auto=".1f",
|
| 124 |
+
labels={
|
| 125 |
+
"on_time_percent": "On-time delivery rate (%)",
|
| 126 |
+
"total_shipments": "Number of shipments",
|
| 127 |
+
},
|
| 128 |
+
)
|
| 129 |
+
st.plotly_chart(fig_cat, use_container_width=True)
|
| 130 |
+
|
| 131 |
+
st.caption(
|
| 132 |
+
"Each bar shows **what percentage of shipments are delivered on time** "
|
| 133 |
+
f"for each value of **{selected_cat}**. Lower bars = higher risk segments."
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
st.write("Detailed numbers:")
|
| 137 |
+
st.dataframe(
|
| 138 |
+
summary.rename(
|
| 139 |
+
columns={
|
| 140 |
+
"on_time_percent": "On-time rate (%)",
|
| 141 |
+
"total_shipments": "Total shipments",
|
| 142 |
+
}
|
| 143 |
+
),
|
| 144 |
+
use_container_width=True,
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
with tab_numeric:
|
| 148 |
+
st.subheader("Distribution of numeric features")
|
| 149 |
+
|
| 150 |
+
numeric_columns = _get_numeric_columns(data)
|
| 151 |
+
if not numeric_columns:
|
| 152 |
+
st.info("This dataset does not contain numeric features (other than ID/target).")
|
| 153 |
+
else:
|
| 154 |
+
selected_num = st.selectbox(
|
| 155 |
+
"Choose a numeric feature",
|
| 156 |
+
options=numeric_columns,
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
# Convert numeric values into simple ranges (bins)
|
| 160 |
+
binned = _make_numeric_bins(data[selected_num])
|
| 161 |
+
temp = data.copy()
|
| 162 |
+
temp["range"] = binned
|
| 163 |
+
|
| 164 |
+
summary_num = (
|
| 165 |
+
temp.groupby("range")
|
| 166 |
+
.agg(
|
| 167 |
+
on_time_percent=(TARGET_COLUMN, lambda x: x.mean() * 100),
|
| 168 |
+
total_shipments=(TARGET_COLUMN, "size"),
|
| 169 |
+
)
|
| 170 |
+
.reset_index()
|
| 171 |
+
.dropna()
|
| 172 |
+
)
|
| 173 |
+
|
| 174 |
+
fig_num = px.bar(
|
| 175 |
+
summary_num,
|
| 176 |
+
x="range",
|
| 177 |
+
y="on_time_percent",
|
| 178 |
+
text_auto=".1f",
|
| 179 |
+
labels={
|
| 180 |
+
"range": f"{selected_num} range",
|
| 181 |
+
"on_time_percent": "On-time delivery rate (%)",
|
| 182 |
+
},
|
| 183 |
+
)
|
| 184 |
+
st.plotly_chart(fig_num, use_container_width=True)
|
| 185 |
+
|
| 186 |
+
st.caption(
|
| 187 |
+
f"Bars show how **on-time delivery rate** changes across different ranges of "
|
| 188 |
+
f"**{selected_num}**. For example, you can see whether very high values are "
|
| 189 |
+
"associated with more late shipments."
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
st.write("Detailed numbers:")
|
| 193 |
+
st.dataframe(
|
| 194 |
+
summary_num.rename(
|
| 195 |
+
columns={
|
| 196 |
+
"range": f"{selected_num} range",
|
| 197 |
+
"on_time_percent": "On-time rate (%)",
|
| 198 |
+
"total_shipments": "Total shipments",
|
| 199 |
+
}
|
| 200 |
+
),
|
| 201 |
+
use_container_width=True,
|
| 202 |
+
)
|
| 203 |
+
|
| 204 |
+
with tab_segments:
|
| 205 |
+
st.subheader("Business segments: where do we perform well or poorly?")
|
| 206 |
+
|
| 207 |
+
grouping_column = st.selectbox(
|
| 208 |
+
"Group by",
|
| 209 |
+
["Mode_of_Shipment", "Warehouse_block", "Product_importance", "Gender"],
|
| 210 |
+
)
|
| 211 |
+
|
| 212 |
+
summary = (
|
| 213 |
+
data.groupby(grouping_column)
|
| 214 |
+
.agg(
|
| 215 |
+
on_time_percent=(TARGET_COLUMN, lambda x: x.mean() * 100),
|
| 216 |
+
avg_cost=("Cost_of_the_Product", "mean"),
|
| 217 |
+
avg_discount=("Discount_offered", "mean"),
|
| 218 |
+
)
|
| 219 |
+
.reset_index()
|
| 220 |
+
)
|
| 221 |
+
|
| 222 |
+
fig_segment = px.bar(
|
| 223 |
+
summary,
|
| 224 |
+
x=grouping_column,
|
| 225 |
+
y="on_time_percent",
|
| 226 |
+
text_auto=".1f",
|
| 227 |
+
color="avg_discount",
|
| 228 |
+
color_continuous_scale="Blues",
|
| 229 |
+
labels={
|
| 230 |
+
"on_time_percent": "On-time delivery rate (%)",
|
| 231 |
+
"avg_discount": "Average discount",
|
| 232 |
+
},
|
| 233 |
+
)
|
| 234 |
+
st.plotly_chart(fig_segment, use_container_width=True)
|
| 235 |
+
|
| 236 |
+
st.dataframe(
|
| 237 |
+
summary.rename(
|
| 238 |
+
columns={
|
| 239 |
+
"on_time_percent": "On-time rate (%)",
|
| 240 |
+
"avg_cost": "Avg product cost",
|
| 241 |
+
"avg_discount": "Avg discount",
|
| 242 |
+
}
|
| 243 |
+
),
|
| 244 |
+
use_container_width=True,
|
| 245 |
+
)
|
| 246 |
+
|
| 247 |
+
st.caption(
|
| 248 |
+
"Use this view to spot **high-risk segments**: groups with low on-time rate, "
|
| 249 |
+
"especially if they also receive high discounts or involve high product cost."
|
| 250 |
+
)
|
deployment/prediction.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
from typing import Optional
|
| 3 |
+
|
| 4 |
+
import joblib
|
| 5 |
+
import pandas as pd
|
| 6 |
+
import streamlit as st
|
| 7 |
+
from huggingface_hub import hf_hub_download
|
| 8 |
+
|
| 9 |
+
# ==== Model configuration ====
|
| 10 |
+
# Local path (used for CI tests & when you commit the artifact)
|
| 11 |
+
LOCAL_MODEL_PATH = Path(__file__).resolve().parent / "best_model_pipeline.joblib"
|
| 12 |
+
|
| 13 |
+
# Model repo on Hugging Face Hub (fallback when local file is not available)
|
| 14 |
+
MODEL_REPO_ID = "vorddd/shipping-delay-knn-v1"
|
| 15 |
+
MODEL_FILENAME = "best_model_pipeline.joblib"
|
| 16 |
+
|
| 17 |
+
FEATURE_ORDER = [
|
| 18 |
+
"Customer_care_calls",
|
| 19 |
+
"Cost_of_the_Product",
|
| 20 |
+
"Prior_purchases",
|
| 21 |
+
"Discount_offered",
|
| 22 |
+
"Weight_in_gms",
|
| 23 |
+
"Product_importance",
|
| 24 |
+
]
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@st.cache_resource(show_spinner=False)
|
| 28 |
+
def load_model():
|
| 29 |
+
"""
|
| 30 |
+
Load the trained model pipeline.
|
| 31 |
+
|
| 32 |
+
Priority:
|
| 33 |
+
1. If LOCAL_MODEL_PATH exists -> use that (for unit tests & local dev).
|
| 34 |
+
2. Otherwise -> download from Hugging Face Hub (for Spaces).
|
| 35 |
+
"""
|
| 36 |
+
if LOCAL_MODEL_PATH.exists():
|
| 37 |
+
model_path = LOCAL_MODEL_PATH
|
| 38 |
+
else:
|
| 39 |
+
model_path = hf_hub_download(
|
| 40 |
+
repo_id=MODEL_REPO_ID,
|
| 41 |
+
filename=MODEL_FILENAME,
|
| 42 |
+
repo_type="model",
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
model = joblib.load(model_path)
|
| 46 |
+
return model
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _get_feature_ranges(data: pd.DataFrame) -> dict:
|
| 50 |
+
"""Get min, max, median for numeric features to build friendly sliders."""
|
| 51 |
+
ranges = {}
|
| 52 |
+
for column in FEATURE_ORDER[:-1]: # numeric only, last one is categorical
|
| 53 |
+
series = data[column]
|
| 54 |
+
ranges[column] = (
|
| 55 |
+
int(series.min()),
|
| 56 |
+
int(series.max()),
|
| 57 |
+
int(series.median()),
|
| 58 |
+
)
|
| 59 |
+
return ranges
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def model_page(reference_data: Optional[pd.DataFrame] = None) -> None:
|
| 63 |
+
st.header("Shipment Delay Prediction")
|
| 64 |
+
|
| 65 |
+
st.write(
|
| 66 |
+
"Use this tool to estimate **whether a shipment is likely to arrive on time or late** "
|
| 67 |
+
"based on key business inputs such as product cost, discount, and customer history."
|
| 68 |
+
)
|
| 69 |
+
st.caption(
|
| 70 |
+
"Fill in the form below with realistic values. "
|
| 71 |
+
"The model will return a simple prediction: **On Time** or **Late**."
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
if reference_data is None:
|
| 75 |
+
raise ValueError("reference_data is required to build sensible input ranges")
|
| 76 |
+
|
| 77 |
+
# Build slider ranges from real data so the UI feels realistic
|
| 78 |
+
feature_ranges = _get_feature_ranges(reference_data)
|
| 79 |
+
product_options = sorted(reference_data["Product_importance"].unique())
|
| 80 |
+
|
| 81 |
+
with st.form("prediction_form"):
|
| 82 |
+
st.subheader("Shipment details")
|
| 83 |
+
|
| 84 |
+
col1, col2 = st.columns(2)
|
| 85 |
+
|
| 86 |
+
customer_care_calls = col1.slider(
|
| 87 |
+
"Customer care calls",
|
| 88 |
+
min_value=feature_ranges["Customer_care_calls"][0],
|
| 89 |
+
max_value=feature_ranges["Customer_care_calls"][1],
|
| 90 |
+
value=feature_ranges["Customer_care_calls"][2],
|
| 91 |
+
help="How many times this customer contacted customer service about this order.",
|
| 92 |
+
)
|
| 93 |
+
cost_of_product = col2.slider(
|
| 94 |
+
"Cost of the product",
|
| 95 |
+
min_value=feature_ranges["Cost_of_the_Product"][0],
|
| 96 |
+
max_value=feature_ranges["Cost_of_the_Product"][1],
|
| 97 |
+
value=feature_ranges["Cost_of_the_Product"][2],
|
| 98 |
+
help="Total product cost. Higher-value items may be treated differently in operations.",
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
prior_purchases = col1.slider(
|
| 102 |
+
"Prior purchases",
|
| 103 |
+
min_value=feature_ranges["Prior_purchases"][0],
|
| 104 |
+
max_value=feature_ranges["Prior_purchases"][1],
|
| 105 |
+
value=feature_ranges["Prior_purchases"][2],
|
| 106 |
+
help="How many times this customer has purchased before.",
|
| 107 |
+
)
|
| 108 |
+
discount_offered = col2.slider(
|
| 109 |
+
"Discount offered (%)",
|
| 110 |
+
min_value=feature_ranges["Discount_offered"][0],
|
| 111 |
+
max_value=feature_ranges["Discount_offered"][1],
|
| 112 |
+
value=feature_ranges["Discount_offered"][2],
|
| 113 |
+
help="Discount given for this order, in percent.",
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
weight_in_gms = st.slider(
|
| 117 |
+
"Product weight (grams)",
|
| 118 |
+
min_value=feature_ranges["Weight_in_gms"][0],
|
| 119 |
+
max_value=feature_ranges["Weight_in_gms"][1],
|
| 120 |
+
value=feature_ranges["Weight_in_gms"][2],
|
| 121 |
+
help="Heavier products may take more time to handle and ship.",
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
product_importance = st.selectbox(
|
| 125 |
+
"Product importance",
|
| 126 |
+
options=product_options,
|
| 127 |
+
help="Business importance of the product (for example: low, medium, high).",
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
submitted = st.form_submit_button("Predict shipment status")
|
| 131 |
+
|
| 132 |
+
if not submitted:
|
| 133 |
+
st.info("Fill in the shipment details and click **Predict shipment status**.")
|
| 134 |
+
return
|
| 135 |
+
|
| 136 |
+
# Build feature vector in the same order used during training
|
| 137 |
+
features = pd.DataFrame(
|
| 138 |
+
[[
|
| 139 |
+
customer_care_calls,
|
| 140 |
+
cost_of_product,
|
| 141 |
+
prior_purchases,
|
| 142 |
+
discount_offered,
|
| 143 |
+
weight_in_gms,
|
| 144 |
+
product_importance,
|
| 145 |
+
]],
|
| 146 |
+
columns=FEATURE_ORDER,
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
model = load_model()
|
| 150 |
+
prediction_raw = model.predict(features)[0]
|
| 151 |
+
is_on_time = prediction_raw == 1
|
| 152 |
+
|
| 153 |
+
st.subheader("Prediction result")
|
| 154 |
+
|
| 155 |
+
if is_on_time:
|
| 156 |
+
st.success("This shipment is **predicted to arrive ON TIME**.")
|
| 157 |
+
else:
|
| 158 |
+
st.error("This shipment is **predicted to be LATE**.")
|
| 159 |
+
|
| 160 |
+
st.caption(
|
| 161 |
+
"The prediction is based on historical patterns in the training data. "
|
| 162 |
+
"Use it as a rough risk indicator, not as a guarantee."
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
st.markdown("---")
|
| 166 |
+
st.markdown("### Input summary")
|
| 167 |
+
st.write(
|
| 168 |
+
"These are the values you entered. "
|
| 169 |
+
"to see how the model reacts to different shipment profiles."
|
| 170 |
+
)
|
| 171 |
+
st.dataframe(features, use_container_width=True)
|
deployment/requirements.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
huggingface_hub
|
| 2 |
+
joblib
|
| 3 |
+
pandas
|
| 4 |
+
streamlit
|
| 5 |
+
plotly
|
| 6 |
+
numpy
|
| 7 |
+
scikit-learn==1.5.1
|
deployment/shipping.csv
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
requirements.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
huggingface_hub
|
| 2 |
+
joblib
|
| 3 |
+
pandas
|
| 4 |
+
streamlit
|
| 5 |
+
plotly
|
| 6 |
+
numpy
|
| 7 |
+
scikit-learn==1.5.1
|