ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β STREAMLIT APP (app.py) β
β ββββββββββββββββββββββββββββ βββββββββββββββββββββββββββββββββββββββββ β
β β MEMBER TAB β β NON-MEMBER (GUEST) TAB β β
β β Β· Segment Distribution β β Β· Segment Distribution β β
β β Β· RFM Profiles + Radar β β Β· Feature Profiles β β
β β Β· Rules per Segment β β Β· Rules per Segment β β
β ββββββββββββ¬ββββββββββββββββ ββββββββββββ¬βββββββββββββββββββββββββββββ β
βββββββββββββββΌββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββββββββββββββ
β β
βββββββββββΌβββββββββββ ββββββββββββΌβββββββββββ
β MEMBER MODELS β β GUEST MODELS β
β (member_*.joblib, β β (guest_*.joblib, β
β *_member.*) β β *_guest.*) β
βββββββββββ¬βββββββββββ ββββββββββββ¬βββββββββββ
β β
βββββββββββΌββββββββββββββββββββββββββββββΌβββββββββββ
β PROCESSING LAYER β
β ββββββββββββββββββββ ββββββββββββββββββββββββ β
β β K-MeansMember β β K-MeansNonMember β β
β β (RFM Clustering) β β (Transaction Feat.) β β
β ββββββββββ¬ββββββββββ ββββββββββββ¬ββββββββββββ β
β β β β
β ββββββββββΌββββββββββββββββββββββββΌββββββββββββ β
β β aprioriMember / aprioriNonMember β β
β β (Per-Segment Association Rules) β β
β ββββββββββββββββββββββββββββββββββββββββββββββ β
β β
β ββββββββββββββββββββββββββββββββββββββββββββββ β
β β 06_Modeling_and_Evaluation.ipynb β β
β β Β· Baseline (ARIMA/SARIMA/Prophet) β β
β β Β· XGBoost Supervised Learning β β
β β Β· Hybrid Detrending (HWR/SARIMA/Prophet) β β
β β Β· Comparative Evaluation β β
β ββββββββββββββββββββ¬ββββββββββββββββββββββββββ β
βββββββββββββββββββββββΌβββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββΌβββββββββββββββββββββββββββββ
β DATA LAYER β
β Β· df_transaction_features.parquet (14.6M rows) β
β Β· df_basket_apriori.parquet (9.1M rows, 8 prods)β
β Β· df_rfm-Member.parquet (2.2M rows) β
β Β· df_forecast_90days.parquet (900 rows) β
β β
β β β
β VOUCHER ENGINE β
β (S_time, S_margin) β
ββββββββββββββββββββββββββββββββββββββββββββββββββββ
| File | Rows | Columns | Content |
|---|---|---|---|
df_transaction_features.parquet |
14,623,691 | 17 | All transactions (member + guest) with features |
df_basket_apriori.parquet |
9,064,669 | 9 | One-hot encoded 8 beverage products per transaction_id |
df_rfm-Member.parquet |
2,196,257 | 5 | RFM per member (Recency, Frequency, Monetary, user_id, is_repeat_customer) |
df_forecast_90days.parquet |
900 | 3 | 90-day forecast per branch (best model output) |
Note: df_transaction_features.parquet uses categorical dtypes that cause pandas read_parquet() to fail. Must read with PyArrow (pq.read_table()).
Input: df_rfm-Member.parquet
ββ columns: [user_id, Recency, Frequency, Monetary, is_repeat_customer]
Pipeline:
1. Load RFM data (pandas read_parquet β aman, no categorical issue)
2. Feature Transforms (7 methods):
- No Transform (raw)
- Log1p
- Log1p β StandardScaler
- Log1p β MinMaxScaler
- QuantileTransformer (n_quantiles=1000, output_distribution='normal')
- PowerTransformer (Yeo-Johnson)
- PowerTransformer (Box-Cox after shift)
3. MiniBatchKMeans (batch_size=8192, n_init=3, random_state=42)
β Loop k=2..10, pick best via Silhouette + DBI
4. PCA (2D) for visualization
5. HDBSCAN for label refinement
6. Segment naming (manual mapping)
7. Profile aggregation (R/F/M means per cluster)
Output:
βββ model_kmeans_member.joblib (MiniBatchKMeans object)
βββ scaler_member.joblib (best quantile scaler)
βββ member_cluster_metadata.json (segment labels, profiles, k, silhouette)
βββ df_member_with_segments.parquet (user_id + RFM + cluster + segment_name)
ββ columns: [user_id, Recency, Frequency, Monetary, is_repeat_customer, cluster, segment_name]
Input: df_transaction_features.parquet
ββ filter: user_id.isna() β ~7.3M guest transactions
ββ features: [final_amount, basket_size, item_count, hour,
is_weekend_bool, is_voucher_used_bool, discount_ratio]
Pipeline: (same structure as Member, different features)
1. Load via PyArrow (avoid categorical crash)
2. Filter guest (user_id IS NULL)
3. Feature transforms (7 methods)
4. MiniBatchKMeans (k determined via elbow)
5. PCA + HDBSCAN
6. Segment naming: Big Spender, Weekend Visitor, Deal Hunter, Quick Buy
7. Save model + scaler + metadata
Output:
βββ model_kmeans_guest.joblib (MiniBatchKMeans object)
βββ scaler_guest.joblib (best scaler)
βββ guest_cluster_metadata.json (segment labels, cluster_cols, optimal_k)
βββ df_guest_with_segments.parquet (transaction_id + features + cluster + segment_name)
ββ columns: [transaction_id, ..., cluster, cluster_normalized, segment_name]
Input:
βββ df_basket_apriori.parquet (9M basket-product rows)
βββ df_transaction_features.parquet (for user_id + member_status join)
βββ df_member_with_segments.parquet (for segment_name join via user_id)
Join Chain:
basket_table ββ(transaction_id)βββ tx_table ββ(user_id)βββ seg_table
β
filter member_status='Member'
Apriori:
For each segment:
df_seg = filter(segment_name == seg)
frequent_itemsets = apriori(df_seg, min_support=0.01, use_colnames=True)
rules = association_rules(frequent_itemsets, metric='confidence', min_threshold=0.1)
rules['segment_name'] = seg
Output:
βββ df_rules_member.parquet (224 rules, 4 segments, 15 columns)
βββ df_rules_member.csv (same, CSV)
Note: Lift < 1.0 (~0.70) for all rules β beverages are bought singly.
Using confidence metric to capture all 56 pairwise rules per segment.
Input:
βββ df_basket_apriori.parquet (9M basket-product rows)
βββ df_transaction_features.parquet (for member_status join)
βββ df_guest_with_segments.parquet (for segment_name join)
Join Chain:
basket_table ββ(transaction_id)βββ tx_table ββ(transaction_id)βββ gseg_table
β
filter member_status='Guest'
Apriori:
For each segment:
df_seg = filter(segment_name == seg)
frequent_itemsets = apriori(df_seg, min_support=0.01, use_colnames=True)
rules = association_rules(frequent_itemsets, metric='confidence', min_threshold=0.1)
Output:
βββ df_rules_guest.parquet (168 rules, 3 segments, 15 columns)
βββ df_rules_guest.csv (same, CSV)
Note: Guest has 4 segments but Apriori produces rules for only 3
(Big Spender, Weekend Visitor, Deal Hunter).
"Quick Buy" has no beverage items in baskets β no rules.
Input: df_transaction_features.parquet
ββ aggregated daily per city:
[total_transactions, total_revenue, avg_basket,
voucher_rate, lag_1, lag_7, rolling_avg_7,
day_of_week, month, created_at, city]
Pipeline (4 sections):
A. BASELINE MODELS (univariate, per branch)
1. ARIMA - auto_arima(seasonal=False, stepwise=True)
2. SARIMA - auto_arima(seasonal=True, m=7)
3. Prophet - weekly_seasonality=True
Metrics: MAE, RMSE, MAPE per branch; aggregate across 10 branches
B. XGBoost SUPERVISED (pooled, multivariate)
Features: [lag_1, lag_7, rolling_avg_7, day_of_week,
month, voucher_rate, city_onehot]
Train/test split: 2025-03-25 (80/20 temporal)
Feature importance (gain-based)
Residual diagnostics
C. HYBRID DETRENDING ARCHITECTURES
1. Holt-Winters (additive, seasonal_periods=365)
ββ XGBoost on residuals [day_of_week, month, voucher_rate]
2. SARIMA (1,0,1)(1,0,1,7)
ββ XGBoost on residuals (same features)
3. Prophet (weekly)
ββ XGBoost on residuals (same features)
Rationale: Non-autoregressive residual features avoid
low-pass recursive forecast collapse
D. EVALUATION AND OUTPUT
Cross-architecture comparison (MAE, RMSE, R2, MAPE)
Best model selection -> 90-day forecast
Variability check (std/mean per branch)
Output:
βββ df_forecast_90days.parquet
ββ columns: [branch, created_at, total_transactions]
Consumption:
-> Voucher Engine (S_time, S_margin dynamic scoring)
Key design decision: XGBoost on residuals uses only day_of_week, month, and voucher_rate β autoregressive features (lag_1, rolling_avg_7) are excluded to prevent error accumulation in multi-step recursive forecasting.
Supersedes: Legacy notebooks 07.ipynb, 08-Benchmark-Models.ipynb, 09_Hybrid_Forecast_HW_XGB.ipynb, and 09-testingXgb.ipynb. These remain on disk but are no longer part of the active pipeline.
βββββββββββββββββββββββββ¬βββββββββββββββββββββββ¬βββββββββββββββββββββββ
β Artifact β Member Prefix β Guest Prefix β
βββββββββββββββββββββββββΌβββββββββββββββββββββββΌβββββββββββββββββββββββ€
β KMeans model β model_kmeans_member β model_kmeans_guest β
β Scaler/Transform β scaler_member β scaler_guest β
β Cluster metadata β member_cluster_meta β guest_cluster_meta β
β Segment data β df_member_with_seg.. β df_guest_with_seg.. β
β Association rules β df_rules_member β df_rules_guest β
βββββββββββββββββββββββββ΄βββββββββββββββββββββββ΄βββββββββββββββββββββββ
member_cluster_metadata.json
{
"model_type": "MiniBatchKMeans",
"k": 2,
"features": ["Recency", "Frequency", "Monetary"],
"best_transform": "Quantile",
"silhouette_score": 0.454,
"total_members": 2196257,
"cluster_labels": {
"0": "At Risk Regulars",
"1": "New Occasional",
"2": "Hibernating",
"3": "Champions"
},
"cluster_profiles": [
{
"cluster": 0,
"count": 399069,
"pct": 18.17,
"revenue_share_pct": 44.0,
"R_mean": 95.7, "F_mean": 7.8, "M_mean": 249.6,
"R_median": 82.0, "F_median": 7.0, "M_median": 224.5,
"monetary_sum": 99625920.0
}
]
}guest_cluster_metadata.json
{
"model_type": "MiniBatchKMeans",
"optimal_k": 4,
"cluster_cols": [
"final_amount", "basket_size", "item_count", "hour",
"is_weekend_bool", "is_voucher_used_bool", "discount_ratio"
],
"best_transform": "Quantile",
"cluster_id_to_name": {
"0": "Big Spender",
"1": "Weekend Visitor",
"2": "Deal Hunter",
"3": "Quick Buy"
}
}app.py
βββ @st.cache_data
β βββ load_json(path) β dict
β βββ load_rules(path) β pd.DataFrame
β βββ load_segment_counts(path, col) β pd.DataFrame
β
βββ Sidebar
β βββ radio("Select View")
β βββ "Member"
β βββ "Non-Member (Guest)"
β
βββ Member Tab
β βββ Metrics (total, k, silhouette, transform)
β βββ Segment Distribution (pie chart + table)
β βββ Segment Profiles (RFM means table)
β βββ RFM Radar (normalized, per segment)
β βββ Association Rules
β β βββ Segment selector (dropdown)
β β βββ Filter: min_lift, min_confidence (sliders)
β β βββ Rules table (sortable)
β β βββ Top 15 lift bar chart
β βββ Model Info (expandable JSON)
β
βββ Guest Tab
βββ Metrics (total, k, model)
βββ Segment Distribution (pie chart + table)
βββ Cluster Features (list + segment names)
βββ Association Rules (same structure as Member)
β βββ Handles empty rules gracefully
βββ Model Info (expandable JSON)
| Component | Source | Method |
|---|---|---|
| Segment counts | *_with_segments.parquet |
pq.read_table(columns=['segment_name']) + Counter |
| Segment profiles | *_cluster_metadata.json |
Direct json.load |
| Association rules | df_rules_*.parquet |
pd.read_parquet (small data, <300 rows) |
| Model info | *_cluster_metadata.json |
json.load |
| Visualizations | Computed from above | Plotly charts |
Performance: load_segment_counts() reads only 1 column from multi-million-row parquet β memory efficient.
Source Processing Output
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
df_rfm-Member.parquet βββ K-MeansMember.ipynb βββ model_kmeans_member.joblib
scaler_member.joblib
member_cluster_metadata.json
df_member_with_segments.parquet
β
df_basket_apriori.parquet βββ aprioriMember.ipynb ββββββββββββββ€
df_transaction_features.parquet β
ββββ app.py (Member tab)
β
df_rules_member.parquet ββββββββββββββββββββββββββββββββββββββββ
df_transaction_features.parquet βββ K-MeansNonMember.ipynb βββ model_kmeans_guest.joblib
(fitur guest, user_id IS NULL) scaler_guest.joblib
guest_cluster_metadata.json
df_guest_with_segments.parquet
β
df_basket_apriori.parquet βββ aprioriNonMember.ipynb βββββββββββββββββββββββββ€
df_transaction_features.parquet β
ββββ app.py (Guest tab)
β
df_rules_guest.parquet βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
df_transaction_features.parquet βββ 06_Modeling_and_Evaluation.ipynb βββ df_forecast_90days.parquet
(daily aggregation) β
ββββ Voucher Engine
β (S_time, S_margin)
β
model artifacts:
βββ hw_models (dict of ExponentialSmoothing)
βββ xgb_model (XGBRegressor)
βββ xgb_residual (XGBRegressor)
βββ sar_macro_models (dict of ARIMA)
βββ prop_macro_models (dict of Prophet)
| Notebook | Left Table | Right Table | Join Key | Type |
|---|---|---|---|---|
| aprioriMember | basket_table | tx_table | transaction_id |
inner |
| aprioriMember | member_tbl | seg_table | user_id |
left outer |
| aprioriNonMember | basket_table | tx_table | transaction_id |
inner |
| aprioriNonMember | guest_tbl | gseg_table | transaction_id |
left outer |
df_rules_member.parquet / df_rules_guest.parquet
antecedents object (set converted to comma-separated string)
consequents object (set converted to comma-separated string)
support float64 (P(A βͺ B) / total)
confidence float64 (P(B | A))
lift float64 (confidence / P(B))
leverage float64
conviction float64
zhangs_metric float64
jaccard float64
certainty float64
kulczynski float64
representativity float64
antecedent support float64 (P(A))
consequent support float64 (P(B))
segment_name object (segment identifier)
- Always use PyArrow when reading
df_transaction_features.parquet(categorical dtype crash). - Never remove
segment_namefrom rules DataFrames β it's the primary join key for the app. - All lift ~0.70 β beverages are rarely co-purchased. Use
metric='confidence'with low threshold. - Guest "Quick Buy" segment may have no Apriori rules β app must handle empty gracefully.
- Create new K-Means notebook β follow
K-Means*.ipynbtemplate - New parquet output:
df_<type>_with_segments.parquet - New Apriori notebook β copy structure, adjust join key
- New rules file:
df_rules_<type>.parquet - Add new tab in
app.pyfollowing existing pattern
- Add int8 flag column to
df_basket_apriori.parquet - No code changes needed β notebooks auto-detect product columns
- Edit the segment mapping in K-Means notebook
- Re-run notebook β updates metadata JSON + segment parquet
- Re-run Apriori β updates rules with new segment names
- App reads dynamically β no code changes needed