Part 02 · Machine Learning
Clustering
কোনো label নেই: k-means আর DBSCAN দিয়ে একই রকম জিনিস এক group এ রাখা, কয়টা group হবে সেটা ঠিক করা, আর clustering কীভাবে চুপচাপ ভুল বোঝায়।
⏱ 1-2 ঘণ্টা · Station 10 / 92
জিনিসটা কী, কেন দরকার
একটা supermarket chain এর দশ লাখ customer, কিন্তু কার সাথে কীভাবে কথা বলবে কোনো ধারণা নেই। কেউ কাউকে "দাম খোঁজা মানুষ" বা "সপ্তাহের বাজার করা পরিবার" label দেয়নি। অথচ data র ভেতরে দলগুলো আছেই: কেউ কম আসে কিন্তু অনেক কেনে, কেউ রোজ এসে দুই একটা জিনিস নেয়। Clustering label ছাড়াই এই দলগুলো খুঁজে বের করে। Unsupervised learning এর সবচেয়ে common রূপ এটাই।
Team গুলো এটা দিয়ে customer ভাগ করে, একরকম support ticket এক জায়গায় আনে, ছবির রং কমায়, document কে topic ধরে সাজায়, আর অস্বাভাবিকটাকে ধরে (fraud, নষ্ট sensor)। সমস্যা হলো: মিলিয়ে দেখার মতো কোনো সঠিক answer নেই, তাই clustering চুপচাপ এমন দল দিতে পারে যেগুলো দেখতে সুন্দর কিন্তু কোনো মানে নেই। এই lesson এ দেখবে এটা কীভাবে কাজ করে, আর কীভাবে ধোঁকা দেয়।
ভেতরে কীভাবে চলে
আগে দূরত্ব। Clustering এর দরকার দুইটা জিনিস কতটা দূরে সেটা বলার একটা উপায়। সংখ্যার জন্য default হলো Euclidean distance (সোজা লাইনে দূরত্ব)। Text embedding এর জন্য নেবে cosine distance (Part 5)। আলাদা scale এর feature আগে scale করে নিতে হয়, নইলে সবচেয়ে বড় সংখ্যাগুলোই সব ঠিক করে দেয় (পরের lesson)।
k-means দুইটা ধাপ বারবার করে k টা দল খোঁজে:
- Assign: প্রতিটা point তার সবচেয়ে কাছের center এ যোগ দেয়।
- Update: প্রতিটা center তার point গুলোর গড়ে (mean) সরে যায়।
যতক্ষণ কিছু না বদলায়, চলতে থাকে। দ্রুত আর সহজ, কিন্তু এর কিছু জেদ আছে: ধরে নেয় দলগুলো গোল আর কাছাকাছি সাইজের, k তোমাকে বলে দিতে হয়, আর প্রতিটা point কে কোনো না কোনো cluster এ থাকতেই হবে, যে কোথাও খাপ খায় না সেটাকেও।
k বাছাই। Inertia মাপো, মানে প্রতিটা point থেকে তার center এর দূরত্বের বর্গের যোগফল। k বাড়ালে এটা সবসময় কমে (k = point এর সংখ্যা হলে 0), তাই খোঁজো elbow, যেখানে আরেকটা cluster যোগ করলে আর তেমন লাভ হয় না। আরো ভালো হলো জিজ্ঞেস করা দলগুলো কাজের কিনা: marketing team কাজে লাগাতে পারে এমন তিনটা segment, পারে না এমন সাতটার চেয়ে ভালো।
DBSCAN অন্যভাবে দেখে: cluster মানে একটা ঘন জায়গা। যে point এর eps দূরত্বের মধ্যে অন্তত min টা প্রতিবেশী আছে সেটা core point; একে অপরের নাগাল পায় এমন core point গুলো মিলে একটা cluster; ফাঁকা জায়গার point গুলো noise। k বলে দিতে হয় না, অদ্ভুত আকারও সামলায়, আর জোর করে দলে না ঢুকিয়ে বলে "এটা কোথাও খাপ খায় না"। তবে eps বাছতে হয়, যেটা তোমার data র scale এর উপর নির্ভর করে।
Customer দের উপর k = 3 দিয়ে k-means চালিয়ে তিনটা cluster পেলে। কোন কথাটা নিশ্চিন্তে বলা যায়?
ছবিতে দেখো
Code এ customer এর দল
ষোলজন customer, তিনটা পরিষ্কার দল আর একজন অস্বাভাবিক বড় ক্রেতা। k = 1 থেকে 5 পর্যন্ত k-means, k = 3 আর k = 4 এর cluster, তারপর একই data তে DBSCAN। দুইটা program একই output দেয়।
import math
# Shop customers: (visits per month, average basket in hundred taka). No labels.
PTS = [(2, 3), (3, 2), (2, 2), (3, 3), (4, 2), # rare, small baskets
(10, 3), (11, 4), (12, 3), (10, 4), (11, 2), # frequent, small baskets
(3, 12), (2, 13), (4, 14), (3, 13), (4, 12), # rare, big baskets
(20, 20)] # one unusual customer
def dist(a, b):
return math.hypot(a[0] - b[0], a[1] - b[1])
def kmeans(k, iters=20):
# start from spread-out points: the first point, then repeatedly the farthest from those chosen
centers = [PTS[0]]
while len(centers) < k:
centers.append(max(PTS, key=lambda p: min(dist(p, c) for c in centers)))
for _ in range(iters):
groups = [[] for _ in range(k)]
for p in PTS: # step 1: assign each point to its nearest center
groups[min(range(k), key=lambda i: dist(p, centers[i]))].append(p)
centers = [(sum(x for x, _ in g) / len(g), sum(y for _, y in g) / len(g)) for g in groups] # step 2: move centers
inertia = sum(min(dist(p, c) ** 2 for c in centers) for p in PTS)
return centers, groups, inertia
print("k inertia (sum of squared distances to the nearest center)")
for k in range(1, 6):
print(f"{k} {kmeans(k)[2]:8.1f}")
for k in (3, 4):
centers, groups, _ = kmeans(k)
print(f"\nk = {k}:")
for c, g in sorted(zip(centers, groups)):
print(f" center ({c[0]:5.2f}, {c[1]:5.2f}) {len(g)} customers")
# DBSCAN: a point with at least MIN neighbours within EPS starts or grows a cluster; others are noise.
EPS, MIN = 2.0, 3
labels = [None] * len(PTS)
cluster = 0
for i, p in enumerate(PTS):
if labels[i] is not None:
continue
near = [j for j, q in enumerate(PTS) if dist(p, q) <= EPS]
if len(near) < MIN:
labels[i] = -1 # noise for now
continue
cluster += 1
queue = near
while queue:
j = queue.pop()
if labels[j] in (None, -1):
labels[j] = cluster
more = [m for m, q in enumerate(PTS) if dist(PTS[j], q) <= EPS]
if len(more) >= MIN:
queue.extend(m for m in more if labels[m] in (None, -1))
print(f"\nDBSCAN eps={EPS} min={MIN}: {cluster} clusters")
for c in sorted(set(labels)):
members = [PTS[i] for i in range(len(PTS)) if labels[i] == c]
print(f" {'noise' if c == -1 else 'cluster ' + str(c)}: {len(members)} customers, first {members[0]}")package main
import (
"fmt"
"math"
"sort"
)
type pt struct{ x, y float64 }
// Shop customers: (visits per month, average basket in hundred taka). No labels.
var pts = []pt{{2, 3}, {3, 2}, {2, 2}, {3, 3}, {4, 2}, // rare, small baskets
{10, 3}, {11, 4}, {12, 3}, {10, 4}, {11, 2}, // frequent, small baskets
{3, 12}, {2, 13}, {4, 14}, {3, 13}, {4, 12}, // rare, big baskets
{20, 20}} // one unusual customer
func dist(a, b pt) float64 { return math.Hypot(a.x-b.x, a.y-b.y) }
func nearest(p pt, cs []pt) int {
best := 0
for i := range cs {
if dist(p, cs[i]) < dist(p, cs[best]) {
best = i
}
}
return best
}
func kmeans(k, iters int) ([]pt, [][]pt, float64) {
// start from spread-out points: the first point, then repeatedly the farthest from those chosen
centers := []pt{pts[0]}
for len(centers) < k {
far, farD := pts[0], -1.0
for _, p := range pts {
if d := dist(p, centers[nearest(p, centers)]); d > farD {
far, farD = p, d
}
}
centers = append(centers, far)
}
var groups [][]pt
for it := 0; it < iters; it++ {
groups = make([][]pt, k)
for _, p := range pts { // step 1: assign each point to its nearest center
i := nearest(p, centers)
groups[i] = append(groups[i], p)
}
for i, g := range groups { // step 2: move centers
sx, sy := 0.0, 0.0
for _, p := range g {
sx, sy = sx+p.x, sy+p.y
}
centers[i] = pt{sx / float64(len(g)), sy / float64(len(g))}
}
}
inertia := 0.0
for _, p := range pts {
d := dist(p, centers[nearest(p, centers)])
inertia += d * d
}
return centers, groups, inertia
}
func main() {
fmt.Println("k inertia (sum of squared distances to the nearest center)")
for k := 1; k <= 5; k++ {
_, _, in := kmeans(k, 20)
fmt.Printf("%d %8.1f\n", k, in)
}
for _, k := range []int{3, 4} {
centers, groups, _ := kmeans(k, 20)
idx := make([]int, k)
for i := range idx {
idx[i] = i
}
sort.Slice(idx, func(a, b int) bool {
ca, cb := centers[idx[a]], centers[idx[b]]
return ca.x < cb.x || (ca.x == cb.x && ca.y < cb.y)
})
fmt.Printf("\nk = %d:\n", k)
for _, i := range idx {
fmt.Printf(" center (%5.2f, %5.2f) %d customers\n", centers[i].x, centers[i].y, len(groups[i]))
}
}
// DBSCAN: a point with at least MIN neighbours within EPS starts or grows a cluster; others are noise.
const eps, minPts = 2.0, 3
labels := make([]int, len(pts)) // 0 = unvisited, -1 = noise, 1.. = cluster
neighbours := func(p pt) []int {
var out []int
for j, q := range pts {
if dist(p, q) <= eps {
out = append(out, j)
}
}
return out
}
cluster := 0
for i, p := range pts {
if labels[i] != 0 {
continue
}
near := neighbours(p)
if len(near) < minPts {
labels[i] = -1 // noise for now
continue
}
cluster++
queue := near
for len(queue) > 0 {
j := queue[len(queue)-1]
queue = queue[:len(queue)-1]
if labels[j] == 0 || labels[j] == -1 {
labels[j] = cluster
if more := neighbours(pts[j]); len(more) >= minPts {
for _, m := range more {
if labels[m] == 0 || labels[m] == -1 {
queue = append(queue, m)
}
}
}
}
}
}
fmt.Printf("\nDBSCAN eps=%.1f min=%d: %d clusters\n", eps, minPts, cluster)
for c := -1; c <= cluster; c++ {
var members []pt
for i, l := range labels {
if l == c {
members = append(members, pts[i])
}
}
if len(members) == 0 {
continue
}
name := fmt.Sprintf("cluster %d", c)
if c == -1 {
name = "noise"
}
fmt.Printf(" %s: %d customers, first (%v, %v)\n", name, len(members), members[0].x, members[0].y)
}
}Output:
k inertia (sum of squared distances to the nearest center)
1 928.0
2 553.3
3 176.8
4 15.2
5 13.0
k = 3:
center ( 3.20, 12.80) 5 customers
center ( 6.80, 2.80) 10 customers
center (20.00, 20.00) 1 customers
k = 4:
center ( 2.80, 2.40) 5 customers
center ( 3.20, 12.80) 5 customers
center (10.80, 3.20) 5 customers
center (20.00, 20.00) 1 customers
DBSCAN eps=2.0 min=3: 3 clusters
noise: 1 customers, first (20, 20)
cluster 1: 5 customers, first (2, 3)
cluster 2: 5 customers, first (10, 3)
cluster 3: 5 customers, first (3, 12)
কী দেখলাম:
- Inertia k = 4 পর্যন্ত হুড়মুড় করে কমে (15.2), তারপর k = 5 এ প্রায় নড়ে না (13.0)। Elbow হলো 4 এ।
- k = 3 এ outlier জিতে যায়। (20, 20) এর একজন customer পুরো একটা center একাই পায়, আর ঘন ঘন আসা ছোট ক্রেতা আর কম আসা ছোট ক্রেতা মিলে 10 জনের একটা cluster হয়, center (6.8, 2.8) এ, যেখানে আসলে কোনো customer ই নেই। Marketing team এমন একটা customer এর জন্য campaign বানাত যার অস্তিত্বই নেই।
- k = 4 এ তিনটা আসল দল পরিষ্কারভাবে আসে, আর outlier একা একটা cluster এ।
- DBSCAN ঘনত্ব দিয়ে তিনটা আসল দল খুঁজে পায়, আর বড় ক্রেতাকে noise বলে, কয়টা দল খুঁজতে হবে না বলেই। সাধারণত এটাই চাও: outlier কে আলাদা করে জানাও, হয়তো সে তোমার সেরা customer, হয়তো fraud।
Production এ যা মাথায় রাখবে
- আগে feature scale করো। Basket যদি টাকায় হয় (হাজার) আর visit এক অঙ্কের, তাহলে দূরত্ব মানে প্রায় শুধু basket। Clustering এর আগে প্রতিটা feature standardise করো (পরের lesson)।
- k-means কয়েকবার চালাও। আলাদা শুরু আলাদা answer দিতে পারে। Library গুলো k-means++ দিয়ে শুরু করে আর কয়েকটা run এর সেরাটা রাখে (
n_init)। Random seed fix করো, যাতে প্রতিবার চালালে segment গুলো এলোমেলো না হয়। - Cluster সরে যায়। Customer এর আচরণ বদলায়; এই মাসের segment 2 আর পরের মাসের segment 2 এক না, আর run ভেদে cluster এর ID অদলবদল হতে পারে। কখনো "cluster 3 = VIP" hard-code করবে না; center দেখে cluster মেলাও।
- Scale আর speed। সাধারণ k-means হলো O(point × k × iteration), লাখ লাখ row এও চলে। Spatial index ছাড়া DBSCAN এর প্রতিবেশী খোঁজা ধীর। অনেক dimension এ (embedding) দূরত্বের মানে কমে যায়; আগে dimension কমাও বা cosine নাও।
- মানুষ দিয়ে যাচাই করো। প্রতিটা cluster থেকে কিছু sample domain জানা কাউকে দেখাও। সে যদি একটা cluster এর নাম দিতে না পারে, সম্ভবত ওটা কোনো cluster ই না।
এক কথায়
Clustering label ছাড়া data কে দূরত্ব দিয়ে দলে ভাগ করে: k-means পালা করে "সবচেয়ে কাছের center এ যাও" আর "center কে গড়ে সরাও" করে, k লাগে আর outlier এ টান খায়; DBSCAN ঘনত্ব দিয়ে দল বানায় আর ফাঁকা জায়গার point কে noise বলে। দলগুলোর আসলে মানে আছে কিনা সবসময় check করো।
নিজে করে দেখো
Data থেকে বড় ক্রেতা (20, 20) কে বাদ দিয়ে আবার চালাও। Elbow এখন কোথায়, আর k = 3 এর cluster গুলো কেমন? তারপর তাকে ফিরিয়ে এনে eps = 1.0 দিয়ে DBSCAN চালাও। কী বদলায়, আর কেন?
Answer
Outlier ছাড়া elbow সরে k = 3 এ আসে (inertia 553 → 177 → 15.2, তারপর k = 4 এ 13.0), আর k = 3 এর cluster গুলো হুবহু তিনটা আসল দল: k = 4 এ নিয়ে যাচ্ছিল outlier টাই। eps = 1.0 এ DBSCAN এখনো 3 টা cluster পায়, কিন্তু 1 জনের বদলে 4 জন noise হয়ে যায়: বড় ক্রেতা, সাথে (12, 3) এর মতো কিনারার customer রা, যাদের সবচেয়ে কাছের প্রতিবেশী 1 এর একটু বেশি দূরে। দলগুলো ছোট হয়ে 5, 3 আর 4 জন। eps কে তোমার data র ফাঁকের সাথে মিলতে হবে; বেশি ছোট হলে আসল সদস্যরা noise হয়ে যায়, বেশি বড় হলে আলাদা দল মিশে যায়।
Station stamp
Chapter শেষ করো
পড়া শেষ হলে Complete চাপো, progress save হবে। XP শুধু Pro তে — Pro থাকলে এই chapter এ +10 XP পেতে।
Pro নিয়ে XP পাও →