BitBullet:Lessons: Wholesale Customer Segmentation¶
Every business serves a mix of customer types — but most treat them identically. A wholesale food distributor serves two fundamentally different customer groups: Horeca (hotels, restaurants, cafes) and retailers — yet within each group, spending patterns diverge dramatically. Some customers are fresh-produce-heavy; others lean heavily on packaged groceries or dairy.
In this tutorial we apply BitBullet's clustering engine to the UCI Wholesale Customers dataset — a real-world record of annual spending across six product categories for 440 wholesale distributor customers. The goal: discover natural spending segments that cut across the known Channel/Region labels and reveal actionable customer archetypes.
Dataset: Wholesale_customers_data.csv — 440 customers, 8 features.
Source: UCI ML Repository — Wholesale Customers
Task: Discover natural spending clusters with zero pre-labelled ground truth.
What You Will Build¶
| Step | Component | What BitBullet Handles For You |
|---|---|---|
| 1 | Feature profiling | generate_feature_stats — detect numerical vs. categorical automatically |
| 2 | Zero-config clustering | auto_cluster — algorithm selection, K selection, fitting, evaluation |
| 3 | Column type detection | detect_column_types, select_algorithm — mixed-data awareness |
| 4 | Optimal K search | find_optimal_k — silhouette scoring across candidate K values |
| 5 | Manual configuration | KPrototypesClusterer — full control over mixed-type clustering |
| 6 | Evaluation metrics | evaluate_clustering — silhouette, Calinski-Harabász, Davies-Bouldin, quality score |
| 7 | Cluster profiling | profile_clusters — suggested labels, key characteristics, feature importance |
| 8 | Visualisation suite | Scatter, silhouette, FAMD decomposition, comprehensive dashboard |
| 9 | Segment activation | Attach labels to DataFrame, compute segment means, business interpretation |
1. Environment & Imports¶
import sys
import os
import warnings
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.ticker as mticker
import plotly.express as px
import plotly.graph_objects as go
from sklearn.metrics import silhouette_samples
warnings.filterwarnings('ignore')
# When running this notebook from bitbullet/lessons in a local clone, prefer the local SDK source.
sdk_root = os.path.abspath("..")
if sdk_root not in sys.path:
sys.path.insert(0, sdk_root)
import bitbullet
print(f"bitbullet : v{getattr(bitbullet, '__version__', 'dev')}")
from bitbullet.cluster import ClusterConfig
from bitbullet.cluster.core import auto_cluster, detect_column_types, select_algorithm
from bitbullet.cluster.algorithms.partitional import KPrototypesClusterer, KMeansClusterer
from bitbullet.cluster.evaluation import evaluate_clustering, profile_clusters, find_optimal_k
from bitbullet.cluster.visualization import (
plot_cluster_scatter,
plot_silhouette,
plot_cluster_sizes,
create_dashboard,
auto_decompose,
plot_decomposition,
)
from bitbullet.transform.eda import generate_feature_stats
print("All imports successful.")
bitbullet : vdev All imports successful.
2. Load and Prepare the Dataset¶
Each row is a wholesale customer. Columns record their annual spending in monetary units across six product categories. Channel (1 = Horeca, 2 = Retail) and Region (1 = Lisbon, 2 = Oporto, 3 = Other) are integer-coded categorical attributes.
We convert Channel and Region to descriptive strings before clustering. This serves two purposes: (1) detect_column_types will correctly identify them as categorical, and (2) cluster profiles and suggested labels will be human-readable without needing a lookup table.
Dataset: Wholesale Customers Dataset
Source: UCI ML Repository — Wholesale Customers
File: Wholesale_customers_data.csv
Before running this cell: download
Wholesale_customers_data.csvfrom the link above and place it in the same directory as this notebook. If you store it elsewhere, updatedata_pathin the code cell below to match your chosen location.
# Update this path if you stored the file in a different location.
data_path = "Wholesale_customers_data.csv"
df_raw = pd.read_csv(data_path)
print(f"Dataset loaded — Shape: {df_raw.shape}")
print(f"Columns: {list(df_raw.columns)}")
display(df_raw.head())
Dataset loaded — Shape: (440, 8) Columns: ['Channel', 'Region', 'Fresh', 'Milk', 'Grocery', 'Frozen', 'Detergents_Paper', 'Delicassen']
| Channel | Region | Fresh | Milk | Grocery | Frozen | Detergents_Paper | Delicassen | |
|---|---|---|---|---|---|---|---|---|
| 0 | 2 | 3 | 12669 | 9656 | 7561 | 214 | 2674 | 1338 |
| 1 | 2 | 3 | 7057 | 9810 | 9568 | 1762 | 3293 | 1776 |
| 2 | 2 | 3 | 6353 | 8808 | 7684 | 2405 | 3516 | 7844 |
| 3 | 1 | 3 | 13265 | 1196 | 4221 | 6404 | 507 | 1788 |
| 4 | 2 | 3 | 22615 | 5410 | 7198 | 3915 | 1777 | 5185 |
# Convert integer-coded categorical columns to descriptive strings
df = df_raw.copy()
df['Channel'] = df['Channel'].map({1: 'Horeca', 2: 'Retail'})
df['Region'] = df['Region'].map({1: 'Lisbon', 2: 'Oporto', 3: 'Other'})
print("Channel distribution:")
print(df['Channel'].value_counts().to_string())
print("\nRegion distribution:")
print(df['Region'].value_counts().to_string())
print()
display(df.head())
Channel distribution: Channel Horeca 298 Retail 142 Region distribution: Region Other 316 Lisbon 77 Oporto 47
| Channel | Region | Fresh | Milk | Grocery | Frozen | Detergents_Paper | Delicassen | |
|---|---|---|---|---|---|---|---|---|
| 0 | Retail | Other | 12669 | 9656 | 7561 | 214 | 2674 | 1338 |
| 1 | Retail | Other | 7057 | 9810 | 9568 | 1762 | 3293 | 1776 |
| 2 | Retail | Other | 6353 | 8808 | 7684 | 2405 | 3516 | 7844 |
| 3 | Horeca | Other | 13265 | 1196 | 4221 | 6404 | 507 | 1788 |
| 4 | Retail | Other | 22615 | 5410 | 7198 | 3915 | 1777 | 5185 |
3. Exploratory Feature Analysis¶
Wholesale spending data is notoriously right-skewed: a handful of large accounts drive the mean far above the median. generate_feature_stats surfaces this immediately — and confirms we have a mixed-type dataset with both numerical spend features and categorical channel/region labels.
stats_df, numerical_cols, categorical_cols = generate_feature_stats(df)
print(f"Numerical ({len(numerical_cols)}): {numerical_cols}")
print(f"Categorical ({len(categorical_cols)}): {categorical_cols}")
print("-" * 70)
display(stats_df)
Numerical (6): ['Fresh', 'Milk', 'Grocery', 'Frozen', 'Detergents_Paper', 'Delicassen'] Categorical (2): ['Channel', 'Region'] ----------------------------------------------------------------------
| dtype | missing_count | missing_percent | unique_values | zero_count | negative_count | mean | std | skewness | kurtosis | mean_percentile | min | 25% | 50% | 75% | max | top | freq | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Channel | object | 0 | 0.0 | 2 | 0 | 0 | - | - | - | - | - | - | - | - | - | - | Horeca | 298 |
| Region | object | 0 | 0.0 | 3 | 0 | 0 | - | - | - | - | - | - | - | - | - | - | Other | 316 |
| Fresh | int64 | 0 | 0.0 | 433 | 0 | 0 | 12000.297727 | 12647.328865 | 2.561323 | 11.536408 | 64.090909 | 3.0 | 3127.75 | 8504.0 | 16933.75 | 112151.0 | - | - |
| Milk | int64 | 0 | 0.0 | 421 | 0 | 0 | 5796.265909 | 7380.377175 | 4.053755 | 24.669398 | 66.136364 | 55.0 | 1533.0 | 3627.0 | 7190.25 | 73498.0 | - | - |
| Grocery | int64 | 0 | 0.0 | 430 | 0 | 0 | 7951.277273 | 9503.162829 | 3.587429 | 20.91467 | 66.136364 | 3.0 | 2153.0 | 4755.5 | 10655.75 | 92780.0 | - | - |
| Frozen | int64 | 0 | 0.0 | 426 | 0 | 0 | 3071.931818 | 4854.673333 | 5.907986 | 54.689281 | 71.136364 | 25.0 | 742.25 | 1526.0 | 3554.25 | 60869.0 | - | - |
| Detergents_Paper | int64 | 0 | 0.0 | 417 | 0 | 0 | 2881.493182 | 4767.854448 | 3.631851 | 19.009464 | 68.863636 | 3.0 | 256.75 | 816.5 | 3922.0 | 40827.0 | - | - |
| Delicassen | int64 | 0 | 0.0 | 403 | 0 | 0 | 1524.870455 | 2820.105937 | 11.151586 | 170.694939 | 68.863636 | 3.0 | 408.25 | 965.5 | 1820.25 | 47943.0 | - | - |
Key observations from the feature audit:
- Mixed feature types —
ChannelandRegionare categorical; all spend columns are numerical. This rules out plain K-Means. - Extreme right skew on all spend columns —
Fresh,Frozen, andDelicassenall show skewness > 5. A few very large accounts will distort centroids if left untreated.auto_clusterhandles normalisation internally. - No missing values — the dataset is clean.
- Small dataset (440 rows) — K-search range should be kept tight (2–7) to avoid fitting noise as structure.
4. Zero-Config Auto-Clustering¶
print("Running auto_cluster — detecting column types, selecting algorithm,")
print("searching for optimal K, fitting, and evaluating automatically.\n")
result = auto_cluster(df, verbose=True)
Running auto_cluster — detecting column types, selecting algorithm,
searching for optimal K, fitting, and evaluating automatically.
================================================================================
AUTO-CLUSTERING PIPELINE
================================================================================
[1/5] Auto-detecting column types...
Numerical: ['Fresh', 'Milk', 'Grocery', 'Frozen', 'Detergents_Paper', 'Delicassen']
Categorical: ['Channel', 'Region']
[2/5] Auto-selecting algorithm...
Selected: kprototypes
[3/5] Finding optimal K (testing 2-10)...
Recommended K: 2 (confidence: low)
- Silhouette score peaks at k=2 (-1.000)
- Tested k from 2 to 10
- Multiple k values have similar scores - try domain knowledge
- Note: Elbow method suggests k=3 instead
[4/5] Clustering with kprototypes (K=2)...
Fitting kprototypes...
Calculating gamma using 'huang' method...
Gamma = 3497.7919
Calculating categorical weights using 'relevance' method...
Applying per-feature weights. Gamma range: [1786.6539, 5208.9299]
Initialization method and algorithm are deterministic. Setting n_init to 1.
Fitting K-Prototypes (n_clusters=2)...
Init: initializing centroids
Init: initializing centroids
Init: initializing clusters
Init: initializing clusters
Init: initializing centroids
Init: initializing centroids
Init: initializing clusters
Init: initializing centroids
Init: initializing clusters
Init: initializing clusters
Init: initializing centroids
Init: initializing centroids
Init: initializing centroids
Init: initializing clusters
Init: initializing centroids
Init: initializing clusters
Init: initializing clusters
Init: initializing centroids
Init: initializing clusters
Init: initializing clusters
Starting iterations...
Starting iterations...
Starting iterations...
Starting iterations...
Starting iterations...
Starting iterations...
Starting iterations...
Starting iterations...
Starting iterations...
Starting iterations...
Run: 2, iteration: 1/100, moves: 47, ncost: 123614095846.56082
Run: 1, iteration: 1/100, moves: 16, ncost: 114438252123.06128
Run: 5, iteration: 1/100, moves: 26, ncost: 123542653483.19649
Run: 4, iteration: 1/100, moves: 59, ncost: 115221241623.77393
Run: 2, iteration: 2/100, moves: 27, ncost: 121882603579.68947
Run: 8, iteration: 1/100, moves: 48, ncost: 114819707216.52832
Run: 1, iteration: 2/100, moves: 14, ncost: 113892018217.07164
Run: 10, iteration: 1/100, moves: 80, ncost: 121651564687.64377
Run: 5, iteration: 2/100, moves: 25, ncost: 121882054671.93109
Run: 4, iteration: 2/100, moves: 18, ncost: 114571716477.99518
Run: 2, iteration: 3/100, moves: 28, ncost: 120629337219.31061
Run: 8, iteration: 2/100, moves: 11, ncost: 114544377192.07077
Run: 1, iteration: 3/100, moves: 10, ncost: 113559610606.74165
Run: 10, iteration: 2/100, moves: 53, ncost: 118060689238.85434
Run: 5, iteration: 3/100, moves: 29, ncost: 120629337219.31061
Run: 4, iteration: 3/100, moves: 14, ncost: 114124923387.80376
Run: 2, iteration: 4/100, moves: 17, ncost: 120126662534.90063
Run: 8, iteration: 3/100, moves: 13, ncost: 114124923387.80376
Run: 6, iteration: 1/100, moves: 20, ncost: 114247161638.78679
Run: 7, iteration: 1/100, moves: 28, ncost: 113693698699.33847
Run: 3, iteration: 1/100, moves: 109, ncost: 120949607760.71414
Run: 1, iteration: 4/100, moves: 11, ncost: 113248392104.92256
Run: 9, iteration: 1/100, moves: 31, ncost: 114706574594.15979
Run: 10, iteration: 3/100, moves: 35, ncost: 115615795947.62486
Run: 5, iteration: 4/100, moves: 17, ncost: 120126662534.90063
Run: 4, iteration: 4/100, moves: 9, ncost: 113901908192.20638
Run: 2, iteration: 5/100, moves: 11, ncost: 119897192593.96259
Run: 8, iteration: 4/100, moves: 9, ncost: 113901908192.20638
Run: 1, iteration: 5/100, moves: 2, ncost: 113223071281.33212
Run: 10, iteration: 4/100, moves: 24, ncost: 114716161244.52449
Run: 5, iteration: 5/100, moves: 11, ncost: 119897192593.96259
Run: 4, iteration: 5/100, moves: 5, ncost: 113824498016.4025
Run: 2, iteration: 6/100, moves: 7, ncost: 119776842897.16995
Run: 8, iteration: 5/100, moves: 5, ncost: 113824498016.4025
Run: 6, iteration: 2/100, moves: 10, ncost: 113891937069.4903
Run: 7, iteration: 2/100, moves: 10, ncost: 113234847977.8884
Run: 3, iteration: 2/100, moves: 23, ncost: 120207166856.01108
Run: 1, iteration: 6/100, moves: 1, ncost: 113218428133.06653
Run: 9, iteration: 2/100, moves: 15, ncost: 114307974813.4363
Run: 5, iteration: 6/100, moves: 7, ncost: 119776842897.16995
Run: 10, iteration: 5/100, moves: 13, ncost: 114272665863.77339
Run: 4, iteration: 6/100, moves: 7, ncost: 113569936611.23627
Run: 2, iteration: 7/100, moves: 3, ncost: 119732693953.55994
Run: 8, iteration: 6/100, moves: 7, ncost: 113569936611.23627
Run: 1, iteration: 7/100, moves: 0, ncost: 113218428133.06653
Run: 5, iteration: 7/100, moves: 3, ncost: 119732693953.55994
Run: 10, iteration: 6/100, moves: 12, ncost: 113920390656.67844
Run: 2, iteration: 8/100, moves: 6, ncost: 119626208811.00294
Run: 4, iteration: 7/100, moves: 6, ncost: 113248392104.92256
Run: 6, iteration: 3/100, moves: 4, ncost: 113824498016.4025
Run: 8, iteration: 7/100, moves: 6, ncost: 113248392104.92256
Run: 7, iteration: 3/100, moves: 2, ncost: 113218428133.06653
Run: 3, iteration: 3/100, moves: 13, ncost: 119938498955.17961
Run: 5, iteration: 8/100, moves: 6, ncost: 119626208811.00294
Run: 10, iteration: 7/100, moves: 6, ncost: 113824498016.4025
Run: 9, iteration: 3/100, moves: 13, ncost: 113920390656.67844
Run: 4, iteration: 8/100, moves: 2, ncost: 113223071281.33212
Run: 2, iteration: 9/100, moves: 5, ncost: 119556236333.16246
Run: 6, iteration: 4/100, moves: 7, ncost: 113569936611.23627
Run: 8, iteration: 8/100, moves: 2, ncost: 113223071281.33212
Run: 5, iteration: 9/100, moves: 5, ncost: 119556236333.16246
Run: 2, iteration: 10/100, moves: 0, ncost: 119556236333.16246
Run: 4, iteration: 9/100, moves: 1, ncost: 113218428133.06653
Run: 10, iteration: 8/100, moves: 7, ncost: 113569936611.23627
Run: 8, iteration: 9/100, moves: 1, ncost: 113218428133.06653
Run: 6, iteration: 5/100, moves: 6, ncost: 113248392104.92256
Run: 7, iteration: 4/100, moves: 0, ncost: 113218428133.06653
Run: 3, iteration: 4/100, moves: 7, ncost: 119804439348.86752
Run: 5, iteration: 10/100, moves: 0, ncost: 119556236333.16246
Run: 4, iteration: 10/100, moves: 0, ncost: 113218428133.06653
Run: 10, iteration: 9/100, moves: 6, ncost: 113248392104.92256
Run: 9, iteration: 4/100, moves: 6, ncost: 113824498016.4025
Run: 8, iteration: 10/100, moves: 0, ncost: 113218428133.06653
Run: 6, iteration: 6/100, moves: 2, ncost: 113223071281.33212
Run: 10, iteration: 10/100, moves: 2, ncost: 113223071281.33212
Run: 9, iteration: 5/100, moves: 7, ncost: 113569936611.23627
Run: 6, iteration: 7/100, moves: 1, ncost: 113218428133.06653
Run: 3, iteration: 5/100, moves: 5, ncost: 119732693953.55994
Run: 10, iteration: 11/100, moves: 1, ncost: 113218428133.06653
Run: 6, iteration: 8/100, moves: 0, ncost: 113218428133.06653
Run: 9, iteration: 6/100, moves: 6, ncost: 113248392104.92256
Run: 3, iteration: 6/100, moves: 6, ncost: 119626208811.00294
Run: 10, iteration: 12/100, moves: 0, ncost: 113218428133.06653
Run: 9, iteration: 7/100, moves: 2, ncost: 113223071281.33212
Run: 3, iteration: 7/100, moves: 5, ncost: 119556236333.16246
Run: 9, iteration: 8/100, moves: 1, ncost: 113218428133.06653
Run: 3, iteration: 8/100, moves: 0, ncost: 119556236333.16246
Run: 9, iteration: 9/100, moves: 0, ncost: 113218428133.06653
Best run was number 1
Clustering complete. Final cost: 113218428133.07
Fitted kprototypes. Found 2 clusters.
Clustering complete!
[5/5] Evaluating clustering quality...
Quality: 7.2/10 (Good)
Silhouette: 0.512
Top Insights:
- Clustering quality is Good (7.2/10)
- Cluster 0 is weakly defined (avg silhouette: 0.13)
- Cluster 1 is very large (375 samples, 85.2% of data)
================================================================================
AUTO-CLUSTERING COMPLETE
================================================================================
5. Inspecting the Auto-Cluster Result¶
auto_labels = result.labels
auto_eval = result.evaluation
auto_config = result.config_used
auto_clusterer = result.clusterer
print("\u2550" * 60)
print(" AUTO-CLUSTER RESULT SUMMARY")
print("\u2550" * 60)
print(f" Algorithm selected : {auto_config.get('algorithm', 'N/A')}")
print(f" K selected : {auto_config.get('n_clusters', 'N/A')}")
print(f" Samples clustered : {len(auto_labels):,}")
print()
print(" Cluster sizes:")
unique, counts = np.unique(auto_labels, return_counts=True)
for cid, cnt in zip(unique, counts):
print(f" Cluster {cid} \u2192 {cnt:,} customers ({cnt / len(auto_labels):.1%})")
print()
print(f" Silhouette score : {auto_eval.metrics.silhouette:.4f}")
print(f" Quality score : {auto_eval.metrics.overall_quality:.2f} / 10 ({auto_eval.metrics.quality_label})")
print()
print(" Configuration applied:")
for k, v in auto_config.items():
print(f" {str(k):30s}: {v}")
════════════════════════════════════════════════════════════
AUTO-CLUSTER RESULT SUMMARY
════════════════════════════════════════════════════════════
Algorithm selected : kprototypes
K selected : 2
Samples clustered : 440
Cluster sizes:
Cluster 0 → 65 customers (14.8%)
Cluster 1 → 375 customers (85.2%)
Silhouette score : 0.5115
Quality score : 7.18 / 10 (Good)
Configuration applied:
algorithm : kprototypes
n_clusters : 2
numerical_columns : ['Fresh', 'Milk', 'Grocery', 'Frozen', 'Detergents_Paper', 'Delicassen']
categorical_columns : ['Channel', 'Region']
auto_gamma : True
auto_weights : True
6. Manual Configuration¶
6.1 Column Type Detection¶
numerical_cols, categorical_cols = detect_column_types(df)
algorithm = select_algorithm(numerical_cols, categorical_cols)
print(f"Numerical columns ({len(numerical_cols)}): {numerical_cols}")
print(f"Categorical columns ({len(categorical_cols)}): {categorical_cols}")
print()
print(f"Recommended algorithm: {algorithm.upper()}")
print()
if algorithm == "kprototypes":
print("K-Prototypes selected because the dataset contains both numerical and")
print("categorical columns. It combines Euclidean distance for spend features")
print("with simple matching distance for Channel and Region.")
Numerical columns (6): ['Fresh', 'Milk', 'Grocery', 'Frozen', 'Detergents_Paper', 'Delicassen'] Categorical columns (2): ['Channel', 'Region'] Recommended algorithm: KPROTOTYPES K-Prototypes selected because the dataset contains both numerical and categorical columns. It combines Euclidean distance for spend features with simple matching distance for Channel and Region.
6.2 Finding a Useful Number of Clusters¶
A plain silhouette search can over-prefer K = 2 when one or more influential features have cardinality two. That is mathematically valid, but it is not always the most useful segmentation answer.
For each point $i$, silhouette is:
$$ s(i) = \frac{b(i) - a(i)}{\max(a(i), b(i))} $$
where $a(i)$ is the mean distance from point $i$ to points in its own cluster, and $b(i)$ is the mean distance to the nearest other cluster. If a meaningful feature has cardinality two, it can create a strong binary partition in the distance geometry. When other features line up with that split, K = 2 can maximise the average gap between clusters. As K increases, the nearest alternative cluster may become a neighbouring sub-segment inside the same binary side, so $b(i)$ shrinks and the global silhouette score can fall.
For business segmentation we often want the next stable refinement, not just the coarsest separation. So we use find_optimal_k to calculate the silhouette curve, then select an interior post-2 local peak where:
$$ S(k) > S(k - 1) \quad \text{and} \quad S(k) \ge S(k + 1) $$
That means silhouette improved despite adding another cluster, then stopped improving. If the best score is at the edge of the tested range, extend the range before treating it as a peak. If no post-2 local peak exists, we keep the global silhouette recommendation and note that the richer split is weak.
def clusterer_factory(k):
config = ClusterConfig(
name="k_search",
algorithm_type="partitional",
method="kmeans",
n_clusters=k,
numerical_columns=numerical_cols,
)
return KMeansClusterer(config)
print("Searching for silhouette scores in range [2, 7]...\n")
k_result = find_optimal_k(
data=df[numerical_cols],
clusterer_factory=clusterer_factory,
k_range=range(2, 8),
method="silhouette",
)
scores_by_k = {int(k): float(score) for k, score in sorted(k_result.scores.items())}
ks = list(scores_by_k.keys())
score_values = [scores_by_k[k] for k in ks]
local_peak_ks = []
score_rows = []
for idx, k in enumerate(ks):
score = score_values[idx]
previous_score = score_values[idx - 1] if idx > 0 else np.nan
next_score = score_values[idx + 1] if idx < len(score_values) - 1 else np.nan
delta = score - previous_score if idx > 0 else np.nan
is_post_2_peak = (
idx > 0
and idx < len(score_values) - 1
and delta > 0
and score >= next_score
)
if is_post_2_peak:
local_peak_ks.append(k)
score_rows.append({
"K": k,
"Silhouette Score": round(score, 4),
"Delta vs Previous K": "" if np.isnan(delta) else round(delta, 4),
"Post-2 Local Peak": "Yes" if is_post_2_peak else "",
})
if local_peak_ks:
optimal_k = max(local_peak_ks, key=lambda k: scores_by_k[k])
selection_reason = (
f"Selected K={optimal_k}: strongest post-2 local silhouette peak "
f"({scores_by_k[optimal_k]:.4f})."
)
else:
optimal_k = int(k_result.recommended_k)
selection_reason = (
f"No post-2 local silhouette peak was found, so we keep the global "
f"silhouette recommendation K={optimal_k}."
)
print(f"Global silhouette maximum : K = {k_result.recommended_k}")
print(f"Confidence : {str(k_result.confidence).title()}")
print(f"Tutorial selection : K = {optimal_k}")
print(f"Reasoning : {selection_reason}")
print()
display(pd.DataFrame(score_rows))
Searching for silhouette scores in range [2, 7]... Global silhouette maximum : K = 2 Confidence : Low Tutorial selection : K = 6 Reasoning : Selected K=6: strongest post-2 local silhouette peak (0.3903).
| K | Silhouette Score | Delta vs Previous K | Post-2 Local Peak | |
|---|---|---|---|---|
| 0 | 2 | 0.5115 | ||
| 1 | 3 | 0.4784 | -0.0332 | |
| 2 | 4 | 0.3901 | -0.0882 | |
| 3 | 5 | 0.3833 | -0.0068 | |
| 4 | 6 | 0.3903 | 0.007 | Yes |
| 5 | 7 | 0.3297 | -0.0606 |
fig, axes = plt.subplots(1, 2, figsize=(13, 5))
fig.suptitle("Optimal K Selection — Wholesale Customer Spending", fontsize=13, fontweight="bold")
BLUE, RED, GREY, GREEN = "#2563EB", "#DC2626", "#64748B", "#059669"
deltas = [np.nan] + [score_values[i] - score_values[i - 1] for i in range(1, len(score_values))]
ax = axes[0]
ax.plot(ks, score_values, color=BLUE, lw=2.5, marker="o", markersize=7)
ax.axvline(k_result.recommended_k, color=GREY, ls="--", lw=1.6, label=f"Global max = K {k_result.recommended_k}")
ax.axvline(optimal_k, color=RED, ls="--", lw=1.8, label=f"Tutorial choice = K {optimal_k}")
if local_peak_ks:
ax.scatter(local_peak_ks, [scores_by_k[k] for k in local_peak_ks], color=RED, s=90, zorder=4)
ax.set_xlabel("Number of Clusters (K)")
ax.set_ylabel("Silhouette Score")
ax.set_title("Silhouette Score vs K", fontweight="bold")
ax.xaxis.set_major_locator(mticker.MultipleLocator(1))
ax.legend(fontsize=9)
ax.grid(True, alpha=0.3)
ax = axes[1]
bar_ks = ks[1:]
bar_deltas = deltas[1:]
bar_colours = [RED if k == optimal_k else (GREEN if delta > 0 else GREY) for k, delta in zip(bar_ks, bar_deltas)]
ax.axhline(0, color="#111827", lw=1)
ax.bar(bar_ks, bar_deltas, color=bar_colours, alpha=0.85, edgecolor="white", linewidth=0.8)
ax.set_xlabel("Number of Clusters (K)")
ax.set_ylabel("Delta vs Previous K")
ax.set_title("Silhouette Gain from Adding a Cluster", fontweight="bold")
ax.xaxis.set_major_locator(mticker.MultipleLocator(1))
ax.grid(axis="y", alpha=0.3)
plt.tight_layout()
plt.show()
print(f"\nProceeding with K = {optimal_k} for the full K-Prototypes fit.")
Proceeding with K = 6 for the full K-Prototypes fit.
6.3 Fitting K-Prototypes¶
With the tutorial K established from the silhouette local-peak rule, we fit K-Prototypes across all 8 features. Huang's gamma balances the overall categorical contribution against the numerical distance. Categorical weights then redistribute that categorical budget across Channel and Region, so a more useful categorical column can count more than a weaker one without changing the overall gamma scale. After fitting, BitBullet stores the resolved gamma_by_column and categorical_weights_by_column values in clusterer.state.fitted_params so future assignments can reproduce the same distance calculation exactly.
config = ClusterConfig(
name="wholesale_segments",
algorithm_type="partitional",
method="kprototypes",
n_clusters=optimal_k,
numerical_columns=numerical_cols,
categorical_columns=categorical_cols,
params={
"gamma": "huang",
"categorical_weights": "relevance",
"init": "Cao",
"n_init": 10,
},
)
clusterer = KPrototypesClusterer(config)
clusterer.fit(df, verbose=True)
labels = clusterer.labels_
print(f"\nFitting complete — {len(np.unique(labels))} clusters across {len(labels):,} customers.")
Fitting kprototypes... Calculating gamma using 'huang' method... Gamma = 3497.7919 Calculating categorical weights using 'relevance' method... Applying per-feature weights. Gamma range: [1786.6539, 5208.9299] Initialization method and algorithm are deterministic. Setting n_init to 1. Fitting K-Prototypes (n_clusters=6)... Init: initializing centroids Init: initializing centroids Init: initializing centroids Init: initializing centroids Init: initializing clusters Init: initializing clusters Init: initializing centroids Init: initializing clusters Init: initializing centroids Init: initializing clusters Init: initializing centroids Init: initializing centroids Init: initializing centroids Init: initializing centroids Init: initializing clusters Init: initializing clusters Init: initializing clusters Init: initializing clusters Init: initializing clusters Init: initializing clusters Starting iterations... Starting iterations... Init: initializing centroids Init: initializing centroids Init: initializing clusters Init: initializing clusters Starting iterations... Starting iterations... Init: initializing centroids Starting iterations... Starting iterations... Init: initializing centroids Init: initializing clusters Init: initializing clusters Starting iterations... Init: initializing centroids Init: initializing clusters Starting iterations... Run: 1, iteration: 1/100, moves: 70, ncost: 49649392753.392715 Run: 2, iteration: 1/100, moves: 180, ncost: 51308332205.64385 Run: 7, iteration: 1/100, moves: 130, ncost: 51682792308.93562 Starting iterations... Run: 4, iteration: 1/100, moves: 130, ncost: 65414946639.87763 Run: 5, iteration: 1/100, moves: 94, ncost: 51819045722.895485 Init: initializing centroids Run: 1, iteration: 2/100, moves: 16, ncost: 49298549332.669174 Init: initializing clusters Run: 7, iteration: 2/100, moves: 28, ncost: 49065141592.241196 Run: 2, iteration: 2/100, moves: 63, ncost: 48301610492.62795 Run: 3, iteration: 1/100, moves: 129, ncost: 54918192783.91495 Run: 4, iteration: 2/100, moves: 79, ncost: 57312279566.96777 Run: 1, iteration: 3/100, moves: 10, ncost: 48332061568.87334 Run: 7, iteration: 3/100, moves: 15, ncost: 48453376935.35493 Run: 5, iteration: 2/100, moves: 43, ncost: 47648855855.11714 Run: 2, iteration: 3/100, moves: 10, ncost: 47828884263.17672 Run: 6, iteration: 1/100, moves: 127, ncost: 59931196650.75278 Init: initializing centroids Init: initializing clusters Run: 3, iteration: 2/100, moves: 78, ncost: 51340773812.6057 Run: 9, iteration: 1/100, moves: 82, ncost: 58933184277.77784 Run: 1, iteration: 4/100, moves: 5, ncost: 47669234105.81741 Run: 4, iteration: 3/100, moves: 47, ncost: 56249512450.14134 Run: 7, iteration: 4/100, moves: 3, ncost: 48227652054.49703 Run: 2, iteration: 4/100, moves: 9, ncost: 47794026754.00624 Run: 5, iteration: 3/100, moves: 10, ncost: 47407916731.60952 Run: 3, iteration: 3/100, moves: 53, ncost: 49172026027.47575 Run: 8, iteration: 1/100, moves: 117, ncost: 48199476104.368126 Starting iterations... Run: 1, iteration: 5/100, moves: 1, ncost: 47668348996.0103 Run: 4, iteration: 4/100, moves: 42, ncost: 54332538127.34818 Run: 7, iteration: 5/100, moves: 10, ncost: 47882352428.88638 Run: 2, iteration: 5/100, moves: 3, ncost: 47786508436.10258 Run: 5, iteration: 4/100, moves: 7, ncost: 46993091189.986496 Run: 6, iteration: 2/100, moves: 71, ncost: 56984069126.89836 Run: 3, iteration: 4/100, moves: 32, ncost: 47892857341.56697 Run: 1, iteration: 6/100, moves: 0, ncost: 47668348996.0103 Run: 9, iteration: 2/100, moves: 46, ncost: 52882785311.721466 Run: 7, iteration: 6/100, moves: 4, ncost: 47793652177.47762 Run: 4, iteration: 5/100, moves: 51, ncost: 50887690839.78267 Run: 5, iteration: 5/100, moves: 2, ncost: 46986315331.912796 Run: 2, iteration: 6/100, moves: 0, ncost: 47786508436.10258 Run: 3, iteration: 5/100, moves: 19, ncost: 47666655863.303474 Run: 8, iteration: 2/100, moves: 27, ncost: 47754445955.88371 Run: 6, iteration: 3/100, moves: 73, ncost: 52136709109.60636 Run: 7, iteration: 7/100, moves: 6, ncost: 47763755780.27575 Run: 5, iteration: 6/100, moves: 1, ncost: 46984794187.20419 Run: 9, iteration: 3/100, moves: 23, ncost: 51481027287.35657 Run: 4, iteration: 6/100, moves: 30, ncost: 48959749757.79218 Run: 3, iteration: 6/100, moves: 11, ncost: 47528644509.02759 Run: 6, iteration: 4/100, moves: 45, ncost: 50253091517.93228 Run: 10, iteration: 1/100, moves: 90, ncost: 59083225690.359116 Run: 7, iteration: 8/100, moves: 2, ncost: 47685487773.957695 Run: 5, iteration: 7/100, moves: 0, ncost: 46984794187.20419 Run: 9, iteration: 4/100, moves: 23, ncost: 47696913389.91361 Run: 4, iteration: 7/100, moves: 19, ncost: 48207937033.868996 Run: 3, iteration: 7/100, moves: 4, ncost: 47516544907.77154 Run: 8, iteration: 3/100, moves: 7, ncost: 47691343472.49512 Run: 6, iteration: 5/100, moves: 19, ncost: 48553154878.49 Run: 7, iteration: 9/100, moves: 5, ncost: 47274647130.41096 Run: 10, iteration: 2/100, moves: 29, ncost: 56289824356.230644 Run: 9, iteration: 5/100, moves: 8, ncost: 47315399345.09145 Run: 4, iteration: 8/100, moves: 7, ncost: 47953054232.44269 Run: 3, iteration: 8/100, moves: 0, ncost: 47516544907.77154 Run: 6, iteration: 6/100, moves: 15, ncost: 48086820664.54063 Run: 7, iteration: 10/100, moves: 0, ncost: 47274647130.41096 Run: 9, iteration: 6/100, moves: 7, ncost: 46989966012.00096 Run: 10, iteration: 3/100, moves: 22, ncost: 54193989336.3235 Run: 4, iteration: 9/100, moves: 4, ncost: 47716322402.81821 Run: 8, iteration: 4/100, moves: 3, ncost: 47682029908.17376 Run: 6, iteration: 7/100, moves: 5, ncost: 47851644388.32763 Run: 9, iteration: 7/100, moves: 10, ncost: 46780813589.77353 Run: 10, iteration: 4/100, moves: 13, ncost: 53864785435.76567 Run: 4, iteration: 10/100, moves: 5, ncost: 47610834134.838905 Run: 8, iteration: 5/100, moves: 0, ncost: 47682029908.17376 Run: 6, iteration: 8/100, moves: 4, ncost: 47604974300.35931 Run: 9, iteration: 8/100, moves: 1, ncost: 46776955072.002396 Run: 10, iteration: 5/100, moves: 12, ncost: 53754873397.0775 Run: 4, iteration: 11/100, moves: 3, ncost: 47595685970.181305 Run: 6, iteration: 9/100, moves: 7, ncost: 47548639341.52393 Run: 9, iteration: 9/100, moves: 1, ncost: 46775028002.47143 Run: 10, iteration: 6/100, moves: 9, ncost: 53661130319.79628 Run: 4, iteration: 12/100, moves: 5, ncost: 47576285369.82584 Run: 6, iteration: 10/100, moves: 2, ncost: 47472063926.32072 Run: 9, iteration: 10/100, moves: 0, ncost: 46775028002.47143 Run: 4, iteration: 13/100, moves: 0, ncost: 47576285369.82584 Run: 10, iteration: 7/100, moves: 13, ncost: 52231041788.457535 Run: 6, iteration: 11/100, moves: 5, ncost: 47441530524.503624 Run: 10, iteration: 8/100, moves: 13, ncost: 51803332200.7302 Run: 6, iteration: 12/100, moves: 3, ncost: 47434954413.24388 Run: 10, iteration: 9/100, moves: 8, ncost: 51752174729.57088 Run: 6, iteration: 13/100, moves: 0, ncost: 47434954413.24388 Run: 10, iteration: 10/100, moves: 3, ncost: 51727649570.51242 Run: 10, iteration: 11/100, moves: 0, ncost: 51727649570.51242 Best run was number 9 Clustering complete. Final cost: 46775028002.47 Fitted kprototypes. Found 6 clusters. Fitting complete — 6 clusters across 440 customers.
7. Evaluation Metrics¶
report = evaluate_clustering(df[numerical_cols], labels)
print("\u2550" * 60)
print(" CLUSTERING EVALUATION REPORT")
print("\u2550" * 60)
print(f" Silhouette score : {report.metrics.silhouette:.4f}")
print(f" Calinski-Harabasz : {report.metrics.calinski_harabasz:.2f}")
print(f" Davies-Bouldin : {report.metrics.davies_bouldin:.4f}")
print(f" Overall quality score : {report.metrics.overall_quality:.2f} / 10")
print(f" Quality label : {report.metrics.quality_label}")
print()
print(" Cluster sizes:")
for cid, cnt in sorted(report.cluster_sizes.items()):
print(f" Cluster {cid} \u2192 {cnt:,} customers ({cnt / len(labels):.1%})")
print()
if report.insights:
print(" Insights:")
for insight in report.insights:
severity_tag = f"[{insight.severity.upper()}] " if hasattr(insight, 'severity') and insight.severity else ""
print(f" {severity_tag}{insight.message}")
════════════════════════════════════════════════════════════
CLUSTERING EVALUATION REPORT
════════════════════════════════════════════════════════════
Silhouette score : 0.3766
Calinski-Harabasz : 205.65
Davies-Bouldin : 0.9558
Overall quality score : 6.98 / 10
Quality label : Good
Cluster sizes:
Cluster 0 → 22 customers (5.0%)
Cluster 1 → 7 customers (1.6%)
Cluster 2 → 80 customers (18.2%)
Cluster 3 → 3 customers (0.7%)
Cluster 4 → 224 customers (50.9%)
Cluster 5 → 104 customers (23.6%)
Insights:
[POSITIVE] Clustering quality is Good (7.0/10)
[MEDIUM] Cluster 1 is weakly defined (avg silhouette: 0.12)
[MEDIUM] Cluster 1 is very small (7 samples, 1.6% of data)
[MEDIUM] Cluster 2 is weakly defined (avg silhouette: 0.28)
[MEDIUM] Cluster 3 is weakly defined (avg silhouette: 0.01)
[MEDIUM] Cluster 3 is very small (3 samples, 0.7% of data)
[LOW] Cluster 4 is very large (224 samples, 50.9% of data)
[MEDIUM] Clusters are highly imbalanced in size
8. Cluster Profiling¶
profiles = profile_clusters(df, labels)
print("\u2550" * 70)
print(" CLUSTER PROFILES")
print("\u2550" * 70)
for profile in profiles.cluster_profiles:
print(f"\n Cluster {profile.cluster_id} \u2014 {profile.size:,} customers ({profile.percentage:.1f}%)")
print(f" Suggested label : {profile.suggested_label}")
print(f" Key characteristics:")
for char in profile.key_characteristics:
print(f" \u2022 {char}")
══════════════════════════════════════════════════════════════════════
CLUSTER PROFILES
══════════════════════════════════════════════════════════════════════
Cluster 0 — 22 customers (5.0%)
Suggested label : Moderately High Frozen · Very High Fresh
Key characteristics:
• Niche segment (5.0% of population)
• Fresh: 317.1% higher than average (50049.68 vs population 12000.30)
• Milk: 23.3% lower than average (4447.41 vs population 5796.27)
• Grocery: 34.3% lower than average (5225.05 vs population 7951.28)
• Frozen: 91.9% higher than average (5895.64 vs population 3071.93)
• Detergents_Paper: 67.1% lower than average (948.18 vs population 2881.49)
• Delicassen: 57.7% higher than average (2404.36 vs population 1524.87)
Cluster 1 — 7 customers (1.6%)
Suggested label : Very High Detergents_Paper · Very High Milk
Key characteristics:
• Niche segment (1.6% of population)
• Channel: Predominantly Retail (100.0%) vs population Horeca
• Fresh: 66.9% higher than average (20031.29 vs population 12000.30)
• Milk: 557.0% higher than average (38084.00 vs population 5796.27)
• Grocery: 605.9% higher than average (56126.14 vs population 7951.28)
• Detergents_Paper: 859.4% higher than average (27644.57 vs population 2881.49)
• Delicassen: 67.1% higher than average (2548.14 vs population 1524.87)
Cluster 2 — 80 customers (18.2%)
Suggested label : High Detergents_Paper · Moderately High Milk
Key characteristics:
• Channel: Predominantly Retail (91.2%) vs population Horeca
• Fresh: 60.6% lower than average (4724.21 vs population 12000.30)
• Milk: 104.2% higher than average (11837.01 vs population 5796.27)
• Grocery: 132.2% higher than average (18461.59 vs population 7951.28)
• Frozen: 49.7% lower than average (1546.58 vs population 3071.93)
• Detergents_Paper: 184.4% higher than average (8195.89 vs population 2881.49)
Cluster 3 — 3 customers (0.7%)
Suggested label : Very High Delicassen · Very High Frozen
Key characteristics:
• Niche segment (0.7% of population)
• Fresh: 166.5% higher than average (31979.00 vs population 12000.30)
• Milk: 458.7% higher than average (32385.67 vs population 5796.27)
• Grocery: 134.0% higher than average (18605.00 vs population 7951.28)
• Frozen: 1012.8% higher than average (34185.67 vs population 3071.93)
• Detergents_Paper: 32.3% lower than average (1949.33 vs population 2881.49)
• Delicassen: 1431.8% higher than average (23358.33 vs population 1524.87)
Cluster 4 — 224 customers (50.9%)
Suggested label : Moderately Low Fresh · Horeca
Key characteristics:
• Dominant segment (50.9% of population)
• Fresh: 49.4% lower than average (6072.05 vs population 12000.30)
• Milk: 43.2% lower than average (3292.95 vs population 5796.27)
• Grocery: 48.1% lower than average (4122.93 vs population 7951.28)
• Frozen: 20.2% lower than average (2451.99 vs population 3071.93)
• Detergents_Paper: 57.5% lower than average (1224.50 vs population 2881.49)
• Delicassen: 34.6% lower than average (996.64 vs population 1524.87)
Cluster 5 — 104 customers (23.6%)
Suggested label : Moderately High Fresh · Horeca
Key characteristics:
• Fresh: 76.7% higher than average (21200.06 vs population 12000.30)
• Milk: 32.9% lower than average (3886.42 vs population 5796.27)
• Grocery: 35.4% lower than average (5138.93 vs population 7951.28)
• Frozen: 34.1% higher than average (4119.86 vs population 3071.93)
• Detergents_Paper: 60.7% lower than average (1131.52 vs population 2881.49)
8.1 Feature Importance — What Separates Wholesale Segments?¶
importance = profiles.feature_importance
imp_df = (
pd.DataFrame(list(importance.items()), columns=["feature", "importance"])
.sort_values("importance", ascending=False)
.reset_index(drop=True)
)
q67 = imp_df["importance"].quantile(0.67)
q33 = imp_df["importance"].quantile(0.33)
colours = [
"#2563EB" if v >= q67 else ("#60A5FA" if v >= q33 else "#BFDBFE")
for v in imp_df["importance"]
]
fig, ax = plt.subplots(figsize=(9, 5))
ax.barh(imp_df["feature"][::-1], imp_df["importance"][::-1],
color=colours[::-1], edgecolor="white", linewidth=0.6)
ax.set_xlabel("Feature Importance Score", fontsize=11)
ax.set_title(
"Feature Importance for Cluster Separation\n(darker = stronger discriminator between segments)",
fontweight="bold", fontsize=12,
)
ax.grid(axis="x", alpha=0.3)
plt.tight_layout()
plt.show()
print("\nTop-5 most discriminative features:")
for _, row in imp_df.head(5).iterrows():
print(f" {row['feature']:25s} {row['importance']:.4f}")
Top-5 most discriminative features: Delicassen 1.0000 Frozen 0.7029 Detergents_Paper 0.5040 Milk 0.4583 Grocery 0.4496
9. Scatter Plot¶
import plotly.graph_objects as go
feature_options = [feature for feature in imp_df["feature"].tolist() if feature in numerical_cols]
feature_options += [feature for feature in numerical_cols if feature not in feature_options]
x_col = feature_options[0]
y_col = feature_options[1] if len(feature_options) > 1 else feature_options[0]
plot_df = df[feature_options].copy()
plot_df["cluster"] = labels.astype(str)
cluster_ids = sorted(plot_df["cluster"].unique(), key=lambda value: int(value) if str(value).isdigit() else str(value))
cluster_frames = {cluster_id: plot_df[plot_df["cluster"] == cluster_id] for cluster_id in cluster_ids}
fig = go.Figure()
for cluster_id, cluster_df in cluster_frames.items():
fig.add_trace(go.Scatter(
x=cluster_df[x_col].tolist(),
y=cluster_df[y_col].tolist(),
mode="markers",
name=f"Cluster {cluster_id}",
marker=dict(size=8, line=dict(width=0.5, color="white")),
hovertemplate=(
"X value: %{x}<br>"
"Y value: %{y}<br>"
f"Cluster: {cluster_id}<extra></extra>"
),
))
x_buttons = [
dict(
label=feature,
method="update",
args=[
{"x": [cluster_frames[cluster_id][feature].tolist() for cluster_id in cluster_ids]},
{"xaxis": {"title": feature}},
],
)
for feature in feature_options
]
y_buttons = [
dict(
label=feature,
method="update",
args=[
{"y": [cluster_frames[cluster_id][feature].tolist() for cluster_id in cluster_ids]},
{"yaxis": {"title": feature}},
],
)
for feature in feature_options
]
fig.update_layout(
title="Cluster Scatter Plot — Select Features",
xaxis_title=x_col,
yaxis_title=y_col,
plot_bgcolor="white",
hovermode="closest",
width=900,
height=650,
margin=dict(t=110),
updatemenus=[
dict(
buttons=x_buttons,
direction="down",
showactive=True,
x=0.0,
xanchor="left",
y=1.16,
yanchor="top",
),
dict(
buttons=y_buttons,
direction="down",
showactive=True,
x=0.32,
xanchor="left",
y=1.16,
yanchor="top",
),
],
annotations=[
dict(text="X axis", x=0.0, xref="paper", y=1.22, yref="paper", showarrow=False, xanchor="left"),
dict(text="Y axis", x=0.32, xref="paper", y=1.22, yref="paper", showarrow=False, xanchor="left"),
],
)
fig.update_xaxes(showgrid=True, gridcolor="rgba(148, 163, 184, 0.25)")
fig.update_yaxes(showgrid=True, gridcolor="rgba(148, 163, 184, 0.25)")
fig.show()
10. Silhouette Analysis¶
sil_samples = silhouette_samples(df[numerical_cols], labels)
print(f"Mean silhouette coefficient : {report.metrics.silhouette:.4f}")
print(f"Points with score < 0 : {(sil_samples < 0).sum():,} ({(sil_samples < 0).mean():.1%})")
fig = plot_silhouette(labels, sil_samples, report.metrics.silhouette)
fig.show()
Mean silhouette coefficient : 0.3766 Points with score < 0 : 16 (3.6%)
11. FAMD Decomposition¶
FAMD (Factor Analysis of Mixed Data) correctly handles our mix of numerical spend columns and categorical Channel/Region labels — preserving the contribution of all 8 features in a 2D projection.
import plotly.express as px
print("Running FAMD decomposition — synthesises all 8 features into 2 components.")
reduced_data, explained_var, method_used = auto_decompose(df, n_components=2)
method_label = method_used.upper()
print(f"Method selected : {method_label}")
print(f"Explained variance — Component 1: {explained_var[0]:.1%} | Component 2: {explained_var[1]:.1%}")
print(f"Total variance captured by 2D projection: {sum(explained_var):.1%}")
decomp_df = reduced_data.copy()
decomp_df["cluster"] = labels.astype(str)
fig = px.scatter(
decomp_df,
x=reduced_data.columns[0],
y=reduced_data.columns[1],
color="cluster",
title=f"{method_label} Decomposition — Wholesale Customer Segments",
labels={
reduced_data.columns[0]: f"{reduced_data.columns[0]} ({explained_var[0] * 100:.1f}%)",
reduced_data.columns[1]: f"{reduced_data.columns[1]} ({explained_var[1] * 100:.1f}%)",
"cluster": "Cluster",
},
)
fig.update_traces(marker=dict(size=8, line=dict(width=0.5, color="white")))
fig.update_layout(plot_bgcolor="white", hovermode="closest")
fig.show()
Running FAMD decomposition — synthesises all 8 features into 2 components. Method selected : FAMD Explained variance — Component 1: 63.4% | Component 2: 36.6% Total variance captured by 2D projection: 100.0%
12. Cluster Size Distribution¶
fig = plot_cluster_sizes(labels)
fig.show()
13. Comprehensive Dashboard¶
fig = create_dashboard(df, labels, report, sil_samples)
fig.show()
14. Putting Segments to Work¶
df_segmented = df.copy()
df_segmented["segment_id"] = labels
label_map = {p.cluster_id: p.suggested_label for p in profiles.cluster_profiles}
df_segmented["segment_name"] = df_segmented["segment_id"].map(label_map)
print("Segment labels attached.")
print(f"Shape: {df_segmented.shape}\n")
display(df_segmented[["segment_id", "segment_name", "Channel", "Region"] + numerical_cols[:3]].head(8))
Segment labels attached. Shape: (440, 10)
| segment_id | segment_name | Channel | Region | Fresh | Milk | Grocery | |
|---|---|---|---|---|---|---|---|
| 0 | 4 | Moderately Low Fresh · Horeca | Retail | Other | 12669 | 9656 | 7561 |
| 1 | 4 | Moderately Low Fresh · Horeca | Retail | Other | 7057 | 9810 | 9568 |
| 2 | 4 | Moderately Low Fresh · Horeca | Retail | Other | 6353 | 8808 | 7684 |
| 3 | 4 | Moderately Low Fresh · Horeca | Horeca | Other | 13265 | 1196 | 4221 |
| 4 | 5 | Moderately High Fresh · Horeca | Retail | Other | 22615 | 5410 | 7198 |
| 5 | 4 | Moderately Low Fresh · Horeca | Retail | Other | 9413 | 8259 | 5126 |
| 6 | 4 | Moderately Low Fresh · Horeca | Retail | Other | 12126 | 3199 | 6975 |
| 7 | 4 | Moderately Low Fresh · Horeca | Retail | Other | 7579 | 4956 | 9426 |
spend_cols = numerical_cols
segment_means = (
df_segmented.groupby(["segment_id", "segment_name"])[spend_cols]
.mean()
.round(0)
.reset_index()
.sort_values("Fresh", ascending=False)
)
print("\u2550" * 80)
print(" SEGMENT MEANS \u2014 ANNUAL SPEND BY PRODUCT CATEGORY (monetary units)")
print("\u2550" * 80)
display(segment_means)
════════════════════════════════════════════════════════════════════════════════ SEGMENT MEANS — ANNUAL SPEND BY PRODUCT CATEGORY (monetary units) ════════════════════════════════════════════════════════════════════════════════
| segment_id | segment_name | Fresh | Milk | Grocery | Frozen | Detergents_Paper | Delicassen | |
|---|---|---|---|---|---|---|---|---|
| 0 | 0 | Moderately High Frozen · Very High Fresh | 50050.0 | 4447.0 | 5225.0 | 5896.0 | 948.0 | 2404.0 |
| 3 | 3 | Very High Delicassen · Very High Frozen | 31979.0 | 32386.0 | 18605.0 | 34186.0 | 1949.0 | 23358.0 |
| 5 | 5 | Moderately High Fresh · Horeca | 21200.0 | 3886.0 | 5139.0 | 4120.0 | 1132.0 | 1690.0 |
| 1 | 1 | Very High Detergents_Paper · Very High Milk | 20031.0 | 38084.0 | 56126.0 | 2565.0 | 27645.0 | 2548.0 |
| 4 | 4 | Moderately Low Fresh · Horeca | 6072.0 | 3293.0 | 4123.0 | 2452.0 | 1224.0 | 997.0 |
| 2 | 2 | High Detergents_Paper · Moderately High Milk | 4724.0 | 11837.0 | 18462.0 | 1547.0 | 8196.0 | 1639.0 |
n_cols, n_rows = 3, (len(spend_cols) + 2) // 3
fig, axes = plt.subplots(n_rows, n_cols, figsize=(15, 5 * n_rows))
fig.suptitle("Wholesale Customer Segment Profiles \u2014 Annual Spend by Category",
fontsize=14, fontweight="bold")
palette = ["#2563EB", "#10B981", "#F59E0B", "#DC2626", "#8B5CF6", "#EC4899"]
segment_order = segment_means["segment_name"].tolist()
short_names = [name[:14] + ".." if len(name) > 14 else name for name in segment_order]
for idx, (ax, col) in enumerate(zip(axes.flat, spend_cols)):
values = segment_means.set_index("segment_name")[col].reindex(segment_order)
ax.bar(range(len(values)), values.values, color=palette[:len(values)], alpha=0.85,
edgecolor="white", linewidth=0.8)
ax.set_xticks(range(len(values)))
ax.set_xticklabels(short_names, rotation=30, ha="right", fontsize=8)
ax.set_title(col, fontweight="bold", fontsize=10)
ax.grid(axis="y", alpha=0.3)
ax.yaxis.set_tick_params(labelsize=8)
for ax in axes.flat[len(spend_cols):]:
ax.set_visible(False)
plt.tight_layout()
plt.show()
What You Built¶
| You Wrote | BitBullet Handled |
|---|---|
Called generate_feature_stats(df) |
Full audit — skewness, kurtosis, cardinality, nulls |
| Mapped integer Channel/Region to strings | Correct categorical type detection by detect_column_types |
Called auto_cluster(df) |
Algorithm selection, K search, fitting, evaluation |
Defined a clusterer_factory and local-peak rule |
Silhouette scores across candidate K values |
Declared a ClusterConfig |
Cao initialisation, Huang gamma, categorical distance weighting, resolved per-column metadata |
Called evaluate_clustering(df[num], labels) |
Silhouette, Calinski-Harabász, Davies-Bouldin, quality score |
Called profile_clusters(df, labels) |
Per-cluster statistics, suggested labels, feature importance |
Called auto_decompose(df) |
FAMD selection for mixed data, explained variance |
Called create_dashboard(...) |
Multi-panel visualisation assembly |
Key Takeaways¶
Algorithm selection matters. The Channel and Region columns carry real discriminative information. Converting integers to strings ensures they are treated as categoricals, not as ordered numerical variables.
Gamma and categorical weights have different jobs. Gamma controls the total scale of categorical distance relative to numerical distance; categorical weights allocate that categorical contribution across categorical columns.
Small datasets need compact K searches. With 440 rows, K > 7 risks fitting noise. A global silhouette maximum can over-prefer K = 2 when a coarse binary split dominates. Looking for a post-2 local peak asks whether a richer segmentation improves separation despite the extra cluster.
Spending distributions are always right-skewed. A handful of very large accounts inflate centroids.
auto_clusternormalises internally; for manual configuration, consider log-transforming spend features before callingfind_optimal_k.Segments are only valuable when activated. Attaching labels and computing per-segment spend means is the step that turns a mathematical result into a procurement or sales planning artefact.
Continue the Academy:
04_Data_Transformations.ipynb— deep dive into every transform inbitbullet.transform05_Model_Management.ipynb— versioning, comparison, and model governance