Part 02 · Machine Learning

Project: Customer Segmentation with K-Means

K-meansFeature scalingElbow methodCluster profiles

আসল দোকানের 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 এর ঠিক কী করে।

যা আগে জানা লাগবে

Architecture

customers300 × 3 numbersvisits, basket in taka, days since last visitrowsscalemean 0, std 1otherwise basket (std 1,544) drowns out visits (std 5)z-scoresk-means++10 restartsbest of 10 runs, each starting from spread-out pointslabelselbowk = 1..7inertia drops steeply until k = 4, then flattensk = 4profilesback to takaun-scale each center so a person can read ittablesegmentsnamed by people"regulars", "daily top-ups", "big monthly", "drifting away"the model finds groups; people decide what they mean and what to do
তিনটা সংখ্যার 300 জন customer standardise হয়, k = 1 থেকে 7 এর জন্য k-means++ দিয়ে cluster হয় (10 বার চালিয়ে সেরাটা), elbow বেছে নেয় k = 4, প্রতিটা center আবার visit, টাকা আর দিনে ফেরানো হয়, আর মানুষ segment গুলোর নাম আর করণীয় ঠিক করে।
Fileকাজ
data.pyচারটা লুকানো আচরণ থেকে বানানো 300 জন customer (k-means কে এগুলো বলা হয় না)
kmeans.pyk-means++ শুরু, assign/update loop, কয়েকবার চালানো, আর একটা scaler যেটা নিজের কাজ উল্টাতে পারে
main.pyraw বনাম 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সাইজকী করবে
নিয়মিত পরিবার128loyalty 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
বানানো dataanswer মিলিয়ে দেখা যায়আসল customer আরো এলোমেলো: হেলানো basket (log নাও), মৌসুমি ওঠানামা, একবারের ক্রেতা

Segment ও পুরনো হয়: মাসে মাসে আবার চালাও, নতুন cluster কে center দেখে পুরনোগুলোর সাথে মেলাও (ID অদলবদল হতে পারে), আর কতজন এক segment থেকে আরেকটায় যাচ্ছে চোখে রাখো। হঠাৎ "সরে যাচ্ছে" তে অনেকে ঢুকলে সেটা business এর alarm।

আরো এগিয়ে নাও

  1. Scale করার আগে basket এর বদলে log(basket) নাও, দেখো cluster গুলো আরো আঁটসাঁট হয় কিনা।
  2. Scaled data তে DBSCAN চালিয়ে তুলনা করো: কোন customer দের সে noise বলে?
  3. k = 2 থেকে 7 এর জন্য silhouette score বের করো, আর elbow এর বাছাইয়ের সাথে মেলাও।
  4. দ্বিতীয় মাসের data বানাও যেখানে 20 টা নিয়মিত পরিবার সরে যেতে শুরু করেছে, তাদের পুরনো center গুলোতে assign করো, আর জানাও কতজন customer segment বদলেছে।

Station stamp

Chapter শেষ করো

পড়া শেষ হলে Complete চাপো, progress save হবে। XP শুধু Pro তে — Pro থাকলে এই chapter এ +20 XP পেতে।

Pro নিয়ে XP পাও →