GitHub Actions commited on
Commit
59ebe6e
·
0 Parent(s):

Deploy to Hugging Face Space

Browse files
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