| """ |
| Product segmentation via demand-based feature engineering + HDBSCAN clustering. |
| |
| Features per SKU: |
| - Mean demand level |
| - Coefficient of variation (volatility) |
| - Event sensitivity (demand lift on event days) |
| - Trend slope (linear regression over time) |
| - Seasonality strength (std of monthly means / overall std) |
| |
| Clusters are mapped to human-readable labels for stakeholder consumption. |
| """ |
|
|
| import numpy as np |
| import pandas as pd |
| import hdbscan |
| from sklearn.preprocessing import StandardScaler |
|
|
|
|
| |
| |
| SEGMENT_LABELS = { |
| "high_volume_stable": "Steady Staples", |
| "high_volume_volatile": "Event-Driven Heroes", |
| "low_volume_volatile": "Volatile Accessories", |
| "declining": "Declining SKUs", |
| "growing": "Rising Stars", |
| "noise": "Unique / Unclassified", |
| } |
|
|
|
|
| def build_features(df: pd.DataFrame) -> pd.DataFrame: |
| """ |
| Compute one row per unique_id with demand-based features. |
| Returns a DataFrame indexed by unique_id. |
| """ |
| df = df.copy() |
| df["ds"] = pd.to_datetime(df["ds"]) |
| df["t"] = (df["ds"] - df["ds"].min()).dt.days |
|
|
| features = {} |
|
|
| for uid, group in df.groupby("unique_id"): |
| group = group.sort_values("ds") |
| y = group["y"].values |
| t = group["t"].values |
|
|
| |
| mean_demand = y.mean() |
|
|
| |
| cv = y.std() / (mean_demand + 1e-8) |
|
|
| |
| event_mask = group["event_name"] != "" |
| event_mean = y[event_mask.values].mean() if event_mask.any() else mean_demand |
| non_event_mean = y[~event_mask.values].mean() if (~event_mask).any() else mean_demand |
| event_sensitivity = event_mean / (non_event_mean + 1e-8) |
|
|
| |
| if len(t) > 1: |
| slope = np.polyfit(t, y, 1)[0] |
| trend_slope = slope / (mean_demand + 1e-8) |
| else: |
| trend_slope = 0.0 |
|
|
| |
| monthly_means = group.groupby(group["ds"].dt.month)["y"].mean() |
| seasonality = monthly_means.std() / (y.std() + 1e-8) |
|
|
| features[uid] = { |
| "mean_demand": mean_demand, |
| "cv": cv, |
| "event_sensitivity": event_sensitivity, |
| "trend_slope": trend_slope, |
| "seasonality": seasonality, |
| "category": group["category"].iloc[0], |
| "store": group["store"].iloc[0], |
| } |
|
|
| return pd.DataFrame.from_dict(features, orient="index") |
|
|
|
|
| def cluster_products( |
| feature_df: pd.DataFrame, |
| min_cluster_size: int = 3, |
| min_samples: int = 2, |
| ) -> pd.DataFrame: |
| """ |
| Run HDBSCAN on the numeric feature columns. |
| Returns feature_df with added columns: cluster_id, segment_label. |
| """ |
| numeric_cols = ["mean_demand", "cv", "event_sensitivity", "trend_slope", "seasonality"] |
| X = feature_df[numeric_cols].values |
|
|
| scaler = StandardScaler() |
| X_scaled = scaler.fit_transform(X) |
|
|
| clusterer = hdbscan.HDBSCAN( |
| min_cluster_size=min_cluster_size, |
| min_samples=min_samples, |
| metric="euclidean", |
| cluster_selection_method="eom", |
| ) |
| labels = clusterer.fit_predict(X_scaled) |
|
|
| result = feature_df.copy() |
| result["cluster_id"] = labels |
| result["segment_label"] = _infer_labels(result, labels) |
| result["cluster_prob"] = clusterer.probabilities_ |
|
|
| return result |
|
|
|
|
| def _infer_labels(df: pd.DataFrame, labels: np.ndarray) -> pd.Series: |
| """ |
| Map numeric cluster IDs to human-readable segment names |
| by inspecting each cluster's centroid profile. |
| """ |
| label_series = pd.Series("", index=df.index) |
|
|
| for cluster_id in set(labels): |
| if cluster_id == -1: |
| label_series[df["cluster_id"] == cluster_id] = SEGMENT_LABELS["noise"] |
| continue |
|
|
| mask = df["cluster_id"] == cluster_id |
| centroid = df.loc[mask, ["mean_demand", "cv", "trend_slope", "event_sensitivity"]].mean() |
|
|
| high_volume = centroid["mean_demand"] > df["mean_demand"].median() |
| high_cv = centroid["cv"] > df["cv"].median() |
| trending_up = centroid["trend_slope"] > 0.01 |
| trending_dn = centroid["trend_slope"] < -0.01 |
| event_driven = centroid["event_sensitivity"] > 1.5 |
|
|
| if trending_dn: |
| label = SEGMENT_LABELS["declining"] |
| elif trending_up and not high_cv: |
| label = SEGMENT_LABELS["growing"] |
| elif high_volume and event_driven: |
| label = SEGMENT_LABELS["high_volume_volatile"] |
| elif high_volume and not high_cv: |
| label = SEGMENT_LABELS["high_volume_stable"] |
| else: |
| label = SEGMENT_LABELS["low_volume_volatile"] |
|
|
| label_series[mask] = label |
|
|
| return label_series |
|
|
|
|
| def get_cluster_profiles(clustered_df: pd.DataFrame) -> pd.DataFrame: |
| """ |
| Summarise each segment: size, mean features, representative category. |
| Used by the Streamlit tab for cluster profile cards. |
| """ |
| numeric_cols = ["mean_demand", "cv", "event_sensitivity", "trend_slope", "seasonality"] |
| profiles = ( |
| clustered_df.groupby("segment_label") |
| .agg( |
| count=("mean_demand", "count"), |
| **{col: (col, "mean") for col in numeric_cols}, |
| ) |
| .round(3) |
| .reset_index() |
| ) |
| return profiles |
|
|