Part 02 · Machine Learning
Project: Customer Segmentation with K-Means
আসল দোকানের customer দের কেনাকাটার ধরন দেখে group করো, k-means নিজে লেখো, আর প্রতিটা cluster কে এমন profile বানাও যেটা দিয়ে marketing team কাজ করতে পারে।
⏱ 3-4 ঘণ্টা · Station 15 / 92
কী বানাবে
একটা online grocery shop সব customer কে একই message পাঠানো বন্ধ করতে চায়। প্রতিটা customer এর তিনটা সংখ্যা আছে: মাসে কয়বার আসে, গড়ে কত টাকার বাজার, আর শেষ কবে এসেছিল তার পর কত দিন। কেউ কাউকে label দেয়নি। তুমি নিজে k-means লিখবে (k-means++ শুরু আর কয়েকবার চালানো সহ), elbow দিয়ে k বাছবে, আর প্রতিটা cluster কে এমন একটা profile বানাবে যেটা নিয়ে marketing team কাজ করতে পারে: তারা কারা, কতজন, আর তাদের জন্য কী করতে হবে।
সাথে দেখবে segmentation এর প্রথম চেষ্টার বেশিরভাগই যে ভুলে নষ্ট হয়, scale না করা সংখ্যায় clustering, আর সেটা result এর ঠিক কী করে।
যা আগে জানা লাগবে
- Clustering: assign/update loop, inertia আর elbow।
- Feature Engineering: feature standardise করা।
- NumPy সহ Python।
Architecture
| File | কাজ |
|---|---|
data.py | চারটা লুকানো আচরণ থেকে বানানো 300 জন customer (k-means কে এগুলো বলা হয় না) |
kmeans.py | k-means++ শুরু, assign/update loop, কয়েকবার চালানো, আর একটা scaler যেটা নিজের কাজ উল্টাতে পারে |
main.py | raw বনাম scaled clustering, elbow, profile |
test_segments.py | যে ধর্মগুলো প্রতিটা run কে মানতেই হবে |
Data টা ইচ্ছা করেই বানানো: generator চারটা আসল দল জানে (120 টা নিয়মিত পরিবার, 80 জন রোজ অল্প কেনা, 60 জন মাসে একবার বড় বাজার, 40 জন সরে যাচ্ছে), তাই k-means সেগুলো খুঁজে পেল কিনা check করা যায়। আসল customer এ এই answer key কখনো পাবে না।
ধাপে ধাপে বানাও
Step 1: data। স্বাভাবিক ছড়ানো সহ চারটা আচরণ, shuffle করা যাতে ক্রম দেখে কিছু বোঝা না যায়।
"""300 synthetic shop customers from four hidden behaviours (the generator knows them; k-means won't)."""
import numpy as np
def make_customers(seed=42):
rng = np.random.default_rng(seed)
groups = [ # (count, visits/month, basket taka, days since last visit), with spread
(120, (4, 1.2), (900, 250), (10, 5)), # regular families
(80, (14, 3.0), (250, 80), (2, 1.5)), # daily small top-ups
(60, (1.5, 0.6), (4500, 900), (25, 8)), # rare big monthly shop
(40, (1, 0.5), (600, 200), (95, 20)), # drifting away
]
rows = []
for n, v, b, d in groups:
rows.append(np.column_stack([
np.clip(rng.normal(*v, n), 0.2, None),
np.clip(rng.normal(*b, n), 50, None),
np.clip(rng.normal(*d, n), 0, None),
]))
X = np.vstack(rows)
return X[rng.permutation(len(X))] # shuffle so order gives nothing away
FEATURES = ["visits/month", "basket (taka)", "days since visit"]
Step 2: ঠিকঠাক k-means। Lesson এর version এর চেয়ে তিনটা উন্নতি:
- k-means++ শুরু: প্রথম center random; পরেরটা বাছা হয় এমনভাবে যে সবচেয়ে কাছের center থেকে যত দূরে (দূরত্বের বর্গ), বাছাই হওয়ার chance তত বেশি, তাই শুরুগুলো ছড়ানো থাকে।
- কয়েকবার চালানো: 10 বার চালিয়ে যার inertia সবচেয়ে কম সেটা রাখো, কারণ একটা খারাপ শুরু একটা আসল দলকে ভেঙে দিতে বা দুইটাকে মিশিয়ে দিতে পারে।
- Vectorised দূরত্ব:
X[:, None, :] - C[None, :, :]একটা NumPy operation এ প্রতিটা point থেকে প্রতিটা center এর দূরত্ব বের করে, shape (300, k)।
"""k-means with k-means++ starts and several restarts, in NumPy."""
import numpy as np
def kmeans_pp_init(X, k, rng):
centers = [X[rng.integers(len(X))]]
for _ in range(k - 1):
d2 = np.min([((X - c) ** 2).sum(axis=1) for c in centers], axis=0)
centers.append(X[rng.choice(len(X), p=d2 / d2.sum())]) # far points are more likely
return np.array(centers)
def kmeans_once(X, k, rng, iters=100):
C = kmeans_pp_init(X, k, rng)
for _ in range(iters):
dist = ((X[:, None, :] - C[None, :, :]) ** 2).sum(axis=2) # (points, k)
labels = dist.argmin(axis=1)
newC = np.array([X[labels == j].mean(axis=0) if np.any(labels == j) else C[j] for j in range(k)])
if np.allclose(newC, C):
break
C = newC
inertia = float(((X - C[labels]) ** 2).sum())
return C, labels, inertia
def kmeans(X, k, n_init=10, seed=0):
rng = np.random.default_rng(seed)
return min((kmeans_once(X, k, rng) for _ in range(n_init)), key=lambda r: r[2])
class Scaler:
def fit(self, X):
self.mean, self.std = X.mean(axis=0), X.std(axis=0)
return self
def transform(self, X):
return (X - self.mean) / self.std
def inverse(self, Z):
return Z * self.std + self.mean
Step 3: আগে raw, তারপর scaled চালাও।
import numpy as np
from data import FEATURES, make_customers
from kmeans import Scaler, kmeans
X = make_customers()
print(f"{len(X)} customers, features: {', '.join(FEATURES)}")
print("spread (std) of each raw feature:", np.round(X.std(axis=0), 1))
# Step 1: what happens without scaling
rawC, raw_labels, _ = kmeans(X, 4)
print("\nk=4 on RAW numbers")
print(f"{'size':>5} {'visits':>6} {'basket':>7} {'days':>5}")
for j in np.argsort(-np.bincount(raw_labels)):
v, b, d = rawC[j]
print(f"{np.sum(raw_labels == j):>5} {v:6.1f} {b:7.0f} {d:5.0f}")
# Step 2: scale, then the elbow
scaler = Scaler().fit(X)
Z = scaler.transform(X)
print("\nk inertia (scaled)")
for k in range(1, 8):
print(f"{k} {kmeans(Z, k)[2]:8.1f}")
# Step 3: profiles at k=4, in original units
C, labels, _ = kmeans(Z, 4)
centers = scaler.inverse(C)
print("\nk=4 on SCALED numbers")
print(f"{'size':>5} {'visits':>6} {'basket':>7} {'days':>5}")
for j in np.argsort(-np.bincount(labels)):
v, b, d = centers[j]
print(f"{np.sum(labels == j):>5} {v:6.1f} {b:7.0f} {d:5.0f}")
Output:
300 customers, features: visits/month, basket (taka), days since visit
spread (std) of each raw feature: [ 5.3 1544.1 27.9]
k=4 on RAW numbers
size visits basket days
124 9.8 346 19
117 3.4 953 21
33 1.6 3720 24
26 1.4 5079 29
k inertia (scaled)
1 900.0
2 505.4
3 257.9
4 74.6
5 60.2
6 48.8
7 38.9
k=4 on SCALED numbers
size visits basket days
128 4.0 885 10
75 14.1 257 2
58 1.5 4352 26
39 1.0 624 87
কী দেখলাম:
- Raw ছড়ানো clustering এর আগেই গল্পটা বলে দেয়: basket ওঠানামা করে 1,544 টাকা, visit 5, দিন 28। Raw দূরত্বে basket ই সব।
- Raw k = 4 অকাজের। চারটা cluster আসলে basket এর চারটা range (346, 953, 3,720, 5,079 টাকা)। দিনের column প্রতিটা cluster এ 19 থেকে 29: যে 40 জন তিন মাস ধরে আসেনি, business এর জন্য সবচেয়ে জরুরি দল, তারা অন্যদের ভেতরে হারিয়ে গেছে।
- Scale করার পরে elbow পরিষ্কার: inertia নামে 900 → 505 → 258 → 75, তারপর k = 5 এ মাত্র 60। চারটা দল।
- Scaled k = 4 আসল আচরণগুলো ফিরিয়ে আনে: 128 জন মাসে 4 বার আর 885 টাকা (নিয়মিত পরিবার), 75 জন 14 বার আর 257 টাকা (রোজ অল্প কেনা), 58 জন 1.5 বার আর 4,352 টাকা (মাসের বড় বাজার), 39 জন 87 দিন আসেনি (সরে যাচ্ছে)। আসল সাইজ ছিল 120, 80, 60 আর 40; যে কয়জন সরে গেছে তারা দুই দলের সীমানায় ছিল।
Step 4: নাম দাও আর কাজে লাগাও। এই ধাপটা মানুষের, code এর না:
| Segment | সাইজ | কী করবে |
|---|---|---|
| নিয়মিত পরিবার | 128 | loyalty point, সাপ্তাহিক বাজারের reminder |
| রোজ অল্প কেনা | 75 | দ্রুত delivery slot, ছোট order এ free delivery |
| মাসের বড় বাজার | 58 | বেতনের দিনের আশেপাশে bulk discount |
| সরে যাচ্ছে | 39 | এখনই ফিরিয়ে আনার offer, পুরো চলে যাওয়ার আগে |
Test করো
import numpy as np
from data import make_customers
from kmeans import Scaler, kmeans
Z = Scaler().fit(make_customers()).transform(make_customers())
def test_every_point_is_with_its_nearest_center():
C, labels, _ = kmeans(Z, 4)
nearest = ((Z[:, None, :] - C[None]) ** 2).sum(axis=2).argmin(axis=1)
assert np.array_equal(nearest, labels)
def test_more_clusters_never_fit_worse():
inertias = [kmeans(Z, k)[2] for k in range(1, 7)]
assert all(b <= a for a, b in zip(inertias, inertias[1:]))
def test_same_seed_same_segments():
assert np.array_equal(kmeans(Z, 4, seed=3)[1], kmeans(Z, 4, seed=3)[1])
def test_scaled_features_have_mean_0_std_1():
assert np.allclose(Z.mean(axis=0), 0) and np.allclose(Z.std(axis=0), 1)
if __name__ == "__main__":
for name, fn in list(globals().items()):
if name.startswith("test_"):
fn()
print("pass", name)
pass test_every_point_is_with_its_nearest_center
pass test_more_clusters_never_fit_worse
pass test_same_seed_same_segments
pass test_scaled_features_have_mean_0_std_1
- প্রতিটা point তার সবচেয়ে কাছের center এর সাথে: converge করা k-means এর মূল ধর্ম। Fail করলে loop আগেভাগে থেমেছে বা bug আছে।
- বেশি cluster কখনো খারাপ fit দেয় না: কয়েকবার চালালে k বাড়লে inertia বাড়তে পারে না। বাড়লে কোনো run আটকে গেছে;
n_initবাড়াও। - একই seed, একই segment: marketing এই দলগুলোর উপর campaign বানাবে; প্রতিবার চালালে এলোমেলো হতে পারবে না।
- Scaled feature এর mean 0 আর std 1: যে ধাপ না থাকায় raw run নষ্ট হয়েছিল, সেটাকে পাহারা দেয়।
Trade-offs
| Choice | ভালো দিক | খরচ |
|---|---|---|
| k-means | দ্রুত, সহজ, center ব্যাখ্যা করা সহজ | ধরে নেয় দলগুলো গোল আর সমান ছড়ানো; প্রতিটা customer কে জোর করে কোনো segment এ দেয় |
| Standardise করা | প্রতিটা feature সমান কথা বলার সুযোগ পায় | সব feature সমান গুরুত্ব পায়, business হয়তো সেটা চায় না; না চাইলে ভেবেচিন্তে weight দাও |
| Elbow method | দ্রুত, চোখে দেখা যায় | আসল data তে elbow প্রায়ই ঝাপসা; শেষে business এর কাজে লাগে কিনা সেটাই ঠিক করুক |
| 10 বার চালানো | খারাপ শুরুতে অনেক কম টলে | 10 গুণ compute; হাজার customer এ ঠিক আছে, লাখ লাখ হলে mini-batch k-means |
| বানানো data | answer মিলিয়ে দেখা যায় | আসল customer আরো এলোমেলো: হেলানো basket (log নাও), মৌসুমি ওঠানামা, একবারের ক্রেতা |
Segment ও পুরনো হয়: মাসে মাসে আবার চালাও, নতুন cluster কে center দেখে পুরনোগুলোর সাথে মেলাও (ID অদলবদল হতে পারে), আর কতজন এক segment থেকে আরেকটায় যাচ্ছে চোখে রাখো। হঠাৎ "সরে যাচ্ছে" তে অনেকে ঢুকলে সেটা business এর alarm।
আরো এগিয়ে নাও
- Scale করার আগে basket এর বদলে
log(basket)নাও, দেখো cluster গুলো আরো আঁটসাঁট হয় কিনা। - Scaled data তে DBSCAN চালিয়ে তুলনা করো: কোন customer দের সে noise বলে?
- k = 2 থেকে 7 এর জন্য silhouette score বের করো, আর elbow এর বাছাইয়ের সাথে মেলাও।
- দ্বিতীয় মাসের data বানাও যেখানে 20 টা নিয়মিত পরিবার সরে যেতে শুরু করেছে, তাদের পুরনো center গুলোতে assign করো, আর জানাও কতজন customer segment বদলেছে।
Station stamp
Chapter শেষ করো
পড়া শেষ হলে Complete চাপো, progress save হবে। XP শুধু Pro তে — Pro থাকলে এই chapter এ +20 XP পেতে।
Pro নিয়ে XP পাও →