Finding Hidden Groups in Unlabeled Data
Department of Computer Science
University of the Philippines Cebu
Lecture 17: K-Means & Hierarchical
"The goal is to turn data into information, and information into insight."
— Carly Fiorina
Distinguish labeled from unlabeled learning paradigms.
Apply the K-Means algorithm and choose optimal K.
Build dendrograms and compare linkage methods.
Profile clusters and translate them into business actions.
In Weeks 7-8 we built supervised models (regression, trees) that learn from labeled outcomes. This week we remove the labels entirely.
| Supervised | Unsupervised | |
|---|---|---|
| Labels | Has target variable (y) | No labels at all |
| Goal | Predict an outcome | Discover hidden structure |
| Methods | Regression, Classification | Clustering, Dim. Reduction |
| Question | "What will happen?" | "What patterns exist?" |
| Evaluation | Accuracy, RMSE, AUC | Silhouette, Inertia |
Clustering is the most widely deployed unsupervised learning technique. Every major tech company uses it daily.
Partition n observations into K clusters by minimizing within-cluster variance.
Minimize Within-Cluster Sum of Squares (WCSS):
J = Σk Σi ∈
Ck ||xi −
μk||²
K-Means uses Euclidean distance. Features with larger scales will dominate the distance calculation and distort the clusters.
Income (0-1M) vs Age (18-80). Income dominates: clusters formed by income alone, age ignored.
Both features centered at 0 with unit variance. Equal contribution to distance.
Scale → Fit → Predict → Add labels to DataFrame
from sklearn.cluster import KMeans
from sklearn.preprocessing import StandardScaler
import pandas as pd
# 1. Prepare features
features = ['income', 'age', 'spending_score']
X = df[features]
# 2. Always scale first!
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)
# 3. Fit K-Means
kmeans = KMeans(
n_clusters=4,
random_state=42,
n_init=10
)
clusters = kmeans.fit_predict(X_scaled)
# 4. Add to dataframe
df['cluster'] = clusters
print(df['cluster'].value_counts())
Plot inertia (WCSS) vs. K. The elbow is where adding clusters stops giving meaningful improvement.
The elbow is subjective. Two analysts may disagree on the exact K. Always cross-check with silhouette score or domain knowledge.
inertias = [KMeans(k).fit(X_scaled).inertia_
for k in range(1, 11)]
plt.plot(range(1, 11), inertias, 'bo-')
Rule of thumb: Pick K at the elbow — adding more clusters gives diminishing returns.
In practice, the elbow can be sharp, gradual, or completely absent. Here’s how to handle each case.
✔ Clear Elbow
Pick K=3
Sharp bend — easy choice
⚠ Gradual Elbow
K=3–5 all reasonable
Use silhouette to break the tie
❌ No Elbow
No natural clusters?
Consider if clustering is appropriate
Pro tip: When the elbow is ambiguous, run silhouette analysis for K=2 through 8 and pick the K with the highest average score. You can also try the Gap Statistic for a more rigorous approach.
GCash wants to segment 94M users by transaction frequency, average amount, and account age. How many segments?
| # | Profile | Size |
|---|---|---|
| 1 | Power Users (daily, high ₱) | 12% |
| 2 | Regular (weekly, moderate ₱) | 38% |
| 3 | Occasional (monthly, low ₱) | 35% |
| 4 | Dormant (rare, very low ₱) | 15% |
Measures how similar a point is to its own cluster vs. the nearest other cluster.
s(i) = (b(i) − a(i)) / max(a(i), b(i))
from sklearn.metrics import silhouette_score
scores = []
for k in range(2, 11):
km = KMeans(n_clusters=k, random_state=42)
labels = km.fit_predict(X_scaled)
score = silhouette_score(X_scaled, labels)
scores.append(score)
print(f"K={k}: Silhouette = {score:.3f}")
# Pick K with highest silhouette score
best_k = range(2, 11)[scores.index(max(scores))]
print(f"\nBest K = {best_k}")
# Interpretation guide:
# > 0.7 Strong structure
# 0.5-0.7 Reasonable structure
# 0.25-0.5 Weak, may be artificial
# < 0.25 No substantial structure
Silhouette Score is not the only option. Use multiple metrics together for a more robust evaluation of clustering quality.
Ratio of within-cluster scatter to between-cluster separation. Lower = better (0 is perfect).
Ratio of between-cluster variance to within-cluster variance. Higher = better (no upper bound).
from sklearn.metrics import (silhouette_score,
davies_bouldin_score, calinski_harabasz_score)
for k in range(2, 8):
km = KMeans(n_clusters=k, random_state=42)
labels = km.fit_predict(X_scaled)
print(f"K={k}: Silhouette={silhouette_score(X_scaled, labels):.3f}"
f" DB={davies_bouldin_score(X_scaled, labels):.3f}"
f" CH={calinski_harabasz_score(X_scaled, labels):.0f}")
Clusters are only useful when you can interpret and name them. Compute summary statistics per cluster.
Always ask: "How would I describe this group to a non-technical stakeholder?" If you can't name it, the clustering may not be meaningful.
# Cluster profiles
profile = df.groupby('cluster').agg({
'income': 'mean',
'age': 'mean',
'spending_score': 'mean',
'customer_id': 'count'
}).round(1)
profile.columns = [
'Avg Income', 'Avg Age',
'Avg Spending', 'Count'
]
print(profile)
# Name clusters based on patterns
cluster_names = {
0: 'High-Value Loyalists',
1: 'Budget-Conscious Shoppers',
2: 'Young Big Spenders',
3: 'Inactive Customers'
}
df['segment'] = df['cluster'].map(
cluster_names
)
When features exceed 2 dimensions, use PCA (Principal Component Analysis) to project clusters onto a 2D plane for plotting.
from sklearn.decomposition import PCA
# Reduce to 2D
pca = PCA(n_components=2)
X_pca = pca.fit_transform(X_scaled)
# Plot
plt.figure(figsize=(10, 7))
scatter = plt.scatter(
X_pca[:, 0], X_pca[:, 1],
c=df['cluster'], cmap='viridis',
alpha=0.6, s=50
)
# Plot centroids
centers_pca = pca.transform(
kmeans.cluster_centers_
)
plt.scatter(
centers_pca[:, 0], centers_pca[:, 1],
marker='X', s=200, c='red',
edgecolors='black', linewidths=1.5,
label='Centroids'
)
plt.xlabel(f'PC1 ({pca.explained_variance_ratio_[0]:.1%})')
plt.ylabel(f'PC2 ({pca.explained_variance_ratio_[1]:.1%})')
plt.legend()
K-Means is fast and intuitive but makes strong assumptions that don't always hold.
Assumes circular/spherical clusters. Fails on elongated or irregular shapes.
Mitigated by k-means++ (sklearn default) and n_init=10, but still possible.
You must choose K before fitting. No automatic detection.
Outliers pull centroids, distorting cluster boundaries.
Assumes clusters have similar size and density.
Builds a tree of clusters without needing to specify K upfront. Agglomerative (bottom-up) is the most common approach.
The dendrogram lets you visually choose K after fitting by cutting at a chosen height.
Dendrogram (cut at height = 4 → K=3)
The linkage criterion determines how the "distance" between two clusters is computed during merging.
| Method | Distance Computed As | Best For | Sensitivity |
|---|---|---|---|
| Single | Minimum distance between any two points | Chain-like / elongated clusters | Very sensitive to noise |
| Complete | Maximum distance between any two points | Compact, spherical clusters | Moderate |
| Average | Mean pairwise distance between all points | Balanced general-purpose | Moderate |
| Ward | Minimizes total within-cluster variance | Similar-size clusters (most popular) | Low (most robust) |
Use truncate_mode='level' to show only the top merges when you have many
samples.
from sklearn.cluster import (
AgglomerativeClustering
)
from scipy.cluster.hierarchy import (
dendrogram, linkage
)
# 1. Dendrogram (visual exploration)
Z = linkage(X_scaled, method='ward')
plt.figure(figsize=(12, 6))
dendrogram(Z, truncate_mode='level', p=5)
plt.xlabel('Sample Index')
plt.ylabel('Distance')
plt.title('Ward Linkage Dendrogram')
# 2. Fit with chosen K
hc = AgglomerativeClustering(
n_clusters=4,
linkage='ward'
)
df['hc_cluster'] = hc.fit_predict(X_scaled)
With 94+ million users, GCash uses clustering to segment customers by transaction behavior and tailor financial products.
# Build RFM features from transactions
from datetime import datetime
today = datetime.now()
rfm = transactions.groupby('user_id').agg({
'txn_date': lambda x: (today - x.max()).days,
'txn_id': 'count',
'amount': 'sum'
}).rename(columns={
'txn_date': 'recency',
'txn_id': 'frequency',
'amount': 'monetary'
})
# Scale and cluster
scaler = StandardScaler()
rfm_scaled = scaler.fit_transform(rfm)
kmeans = KMeans(n_clusters=4, random_state=42)
rfm['segment'] = kmeans.fit_predict(rfm_scaled)
# Profile
rfm.groupby('segment').mean().round(1)
After clustering, profile each segment and assign actionable marketing strategies.
| Segment | Recency | Frequency | Monetary | Strategy |
|---|---|---|---|---|
| Champions | Low (recent) | High | High | Reward loyalty, early access to GCredit |
| Potential | Low | Medium | Medium | Upsell GInvest, push GCash Mastercard |
| At Risk | High (lapsed) | Low | Medium | Win-back campaign, cashback offers |
| Lost | Very High | Low | Low | Exit survey, final reactivation attempt |
1. Clustering finds natural groups in data without labels.
2. Always scale features before running K-Means.
3. Use elbow + silhouette together to choose K.
4. Hierarchical clustering shows the full merge tree via dendrograms.
DBSCAN, Market Basket Analysis, and RFM Segmentation
Department of Computer Science
University of the Philippines Cebu
Lecture 18: Beyond K-Means
Apply density-based clustering for arbitrary shapes and outlier detection.
Use clustering and Isolation Forest to flag unusual observations.
Find product associations with Apriori (support, confidence, lift).
Score and segment customers by Recency, Frequency, Monetary value.
Density-Based Spatial Clustering of Applications with Noise. Groups dense regions and labels sparse points as noise.
Point Classification in DBSCAN
DBSCAN labels noise points as −1. Always check how many noise points you get — too many means ε is too small.
from sklearn.cluster import DBSCAN
# Fit DBSCAN
dbscan = DBSCAN(eps=0.5, min_samples=5)
labels = dbscan.fit_predict(X_scaled)
# Analyze results
n_clusters = len(set(labels)) - (
1 if -1 in labels else 0
)
n_noise = list(labels).count(-1)
print(f"Clusters found: {n_clusters}")
print(f"Noise points: {n_noise}")
print(f"Noise ratio: {n_noise/len(labels):.1%}")
# K-distance plot to find optimal eps
from sklearn.neighbors import NearestNeighbors
nn = NearestNeighbors(n_neighbors=5)
nn.fit(X_scaled)
distances, _ = nn.kneighbors(X_scaled)
distances = np.sort(distances[:, -1])
plt.plot(distances)
plt.ylabel('5-NN Distance')
plt.title('K-Distance Plot')
# Look for the "elbow" = optimal eps
| Aspect | K-Means | DBSCAN |
|---|---|---|
| Cluster shape | Spherical / convex only | Any arbitrary shape |
| Number of clusters | Must specify K | Discovered automatically |
| Outlier handling | Forces into nearest cluster | Labels as noise (−1) |
| Cluster density | Assumes uniform | Handles varying density |
| Scalability | Fast (O(nKt)) | Slower (O(n log n) with index) |
| Reproducibility | Varies by initialization | Nearly deterministic* |
| Best for | Customer segmentation, compression | Anomaly detection, spatial data |
| *Border points reachable from multiple clusters may vary by processing order. | ||
Use clustering to identify observations that don't belong to any group — potential fraud, errors, or rare events.
# Method 1: DBSCAN noise detection
dbscan = DBSCAN(eps=0.5, min_samples=10)
labels = dbscan.fit_predict(X_scaled)
anomalies_db = df[labels == -1]
print(f"DBSCAN anomalies: {len(anomalies_db)}")
# Method 2: Centroid distance threshold
kmeans = KMeans(n_clusters=5, random_state=42)
kmeans.fit(X_scaled)
distances = kmeans.transform(X_scaled).min(axis=1)
# Flag top 5% as anomalies
threshold = np.percentile(distances, 95)
anomalies_km = df[distances > threshold]
print(f"K-Means anomalies: {len(anomalies_km)}")
# Method 3: Isolation Forest
from sklearn.ensemble import IsolationForest
iso = IsolationForest(contamination=0.05)
pred = iso.fit_predict(X_scaled)
anomalies_if = df[pred == -1] # -1 = anomaly
Goal: Discover products that are frequently purchased together. Powers product recommendations, store layouts, and promotional bundles.
Example Transaction Data
| TXN | Items Purchased |
|---|---|
| 1 | Rice, Eggs, Milk |
| 2 | Rice, Eggs |
| 3 | Bread, Butter, Coffee |
| 4 | Rice, Milk, Coffee |
| 5 | Eggs, Milk, Bread |
Which items are often bought together?
How often items appear together in all transactions.
Support(A→B) = P(A ∩ B)
If Rice & Eggs appear in 2 of 5 transactions: Support = 0.4
How often the rule is correct (conditional probability).
Conf(A→B) = P(B|A)
Of people who buy Rice, 67% also buy Eggs: Confidence = 0.67
Strength of association (vs random chance).
Lift = Conf(A→B) / P(B)
Lift > 1 = positive association · Lift = 1 = independent
pip install mlxtend — provides Apriori and association rule functions.
from mlxtend.frequent_patterns import (
apriori, association_rules
)
from mlxtend.preprocessing import (
TransactionEncoder
)
# 1. Encode transactions
transactions = [
['rice', 'eggs', 'milk'],
['rice', 'eggs'],
['bread', 'butter', 'coffee'],
['rice', 'milk', 'coffee'],
['eggs', 'milk', 'bread'],
]
te = TransactionEncoder()
te_arr = te.fit_transform(transactions)
basket = pd.DataFrame(te_arr, columns=te.columns_)
# 2. Find frequent itemsets
freq = apriori(basket, min_support=0.3,
use_colnames=True)
# 3. Generate rules (mlxtend >= 0.23)
rules = association_rules(
freq, num_itemsets=len(basket),
metric='lift', min_threshold=1.0
)
print(rules[['antecedents', 'consequents',
'support', 'confidence', 'lift']])
The Philippines has over 1 million sari-sari stores. Basket analysis helps small retailers optimize their limited shelf space and bundling strategies.
Place eggs next to instant noodles, and bundle coffee + bread for "breakfast packs."
# Load sari-sari store transactions
baskets = pd.read_csv('ph_sarisari_txns.csv')
# One-hot encode
encoded = baskets.groupby(
['txn_id', 'product']
)['qty'].sum().unstack().fillna(0)
encoded = (encoded > 0).astype(int)
# Find associations
freq = apriori(encoded, min_support=0.02,
use_colnames=True)
rules = association_rules(
freq, num_itemsets=len(encoded),
metric='lift', min_threshold=1.5
)
# Top actionable rules
top = rules[
(rules['confidence'] > 0.5) &
(rules['lift'] > 2)
].sort_values('lift', ascending=False)
print(top.head(10))
RFM segments customers by three behavioral dimensions, each answering a critical business question.
"How recently did they buy?"
Days since last purchase. Lower = better.
"How often do they buy?"
Total number of purchases. Higher = better.
"How much do they spend?"
Total revenue from customer. Higher = better.
Divide each metric into 5 equal groups. Score 5 = best. For Recency, lower days = higher score (inverted).
RFM Score "555" = Champion (recent, frequent, high-value).
RFM Score "111" = Lost customer.
from datetime import timedelta
# 1. Calculate RFM
snapshot = df['order_date'].max() + timedelta(1)
rfm = df.groupby('customer_id').agg({
'order_date': lambda x: (snapshot - x.max()).days,
'order_id': 'nunique',
'amount': 'sum'
}).rename(columns={
'order_date': 'recency',
'order_id': 'frequency',
'amount': 'monetary'
})
# 2. Quintile scores (1-5)
rfm['R'] = pd.qcut(
rfm['recency'], 5, labels=[5,4,3,2,1]
)
rfm['F'] = pd.qcut(
rfm['frequency'].rank(method='first'),
5, labels=[1,2,3,4,5]
)
rfm['M'] = pd.qcut(
rfm['monetary'], 5, labels=[1,2,3,4,5]
)
# 3. Combined score
rfm['RFM'] = (rfm['R'].astype(str) +
rfm['F'].astype(str) +
rfm['M'].astype(str))
| Segment | R | F | M | Description | Marketing Action |
|---|---|---|---|---|---|
| Champions | 5 | 5 | 5 | Best customers, recent & frequent | Loyalty rewards, referral program |
| Loyal | 3-5 | 4-5 | 3-5 | Consistent, reliable buyers | Upsell, premium access |
| Potential | 4-5 | 1-3 | 1-3 | Recent but low engagement | Onboarding, education |
| At Risk | 1-2 | 3-5 | 3-5 | Were loyal, now lapsing | Win-back campaign, discounts |
| Hibernating | 1-2 | 1-2 | 2-3 | Long inactive, moderate value | Reactivation offer |
| Lost | 1 | 1 | 1 | Inactive, low value | Exit survey, deprioritize |
Instead of manually defining segments with quintile thresholds, let K-Means discover natural groupings in the RFM space.
Manual RFM uses arbitrary thresholds. Clustering finds the actual boundaries in your data — often more meaningful and data-driven.
Monetary values are often heavily right-skewed. Apply np.log1p() before scaling to prevent a few
high-spenders from dominating the clusters.
# Use K-Means on RFM features
rfm_features = rfm[['recency', 'frequency',
'monetary']].copy()
# Log-transform skewed monetary column
rfm_features['monetary'] = np.log1p(
rfm_features['monetary'])
scaler = StandardScaler()
rfm_scaled = scaler.fit_transform(rfm_features)
# Find optimal K
from sklearn.metrics import silhouette_score
for k in range(2, 8):
km = KMeans(n_clusters=k, random_state=42)
labels = km.fit_predict(rfm_scaled)
sil = silhouette_score(rfm_scaled, labels)
print(f"K={k}: Silhouette={sil:.3f}")
# Fit best K (from silhouette loop above)
best_k = max(range(2, 8), key=lambda k:
silhouette_score(rfm_scaled,
KMeans(n_clusters=k, random_state=42)
.fit_predict(rfm_scaled)))
best_km = KMeans(n_clusters=best_k, random_state=42)
rfm['cluster'] = best_km.fit_predict(rfm_scaled)
# Profile and name clusters
profile = rfm.groupby('cluster').agg({
'recency': 'mean',
'frequency': 'mean',
'monetary': ['mean', 'count']
}).round(1)
print(profile)
K-Means, Hierarchical, and DBSCAN cover most use cases. But two modern algorithms solve specific pain points worth knowing.
Soft clustering — each point gets a probability of belonging to each cluster, not a hard assignment.
sklearn.mixture.GaussianMixtureHierarchical DBSCAN
— eliminates the need to tune the eps parameter manually.
min_cluster_sizepip install hdbscan (or sklearn 1.3+)Start with K-Means or DBSCAN. Move to GMM/HDBSCAN only when simpler methods fail to capture your data's structure. Simpler models are easier to explain to stakeholders.
1. DBSCAN finds arbitrary-shaped clusters and naturally identifies outliers as noise.
2. Anomaly detection uses distance from normal patterns (DBSCAN noise, centroid distance, Isolation Forest).
3. Market basket analysis reveals product co-occurrence patterns via support, confidence, and lift.
4. RFM + Clustering creates data-driven customer segments with clear marketing actions.
A comprehensive end-to-end customer analytics exercise combining everything from this week.
Next Week: Time Series Analytics — Forecasting trends, seasonality, and building ARIMA models.