সরাসরি পর্বে যান
পর্ব ৬৫ · ৭৯-এর মধ্যে · মডিউল ১১

Clustering আর PCA — label ছাড়া গঠন খোঁজা

Clustering and PCA — k-means from Scratch, k-means++, Silhouette, Dendrograms, DBSCAN, PCA by SVD and Explained Variance

Taught in Bangla, this lesson segments fictional shop customers by recency, frequency and monetary value with k-means written in NumPy, k-means++ and restarts, then chooses k with elbow and silhouette. It compares hierarchical clustering and DBSCAN on two moons, builds PCA from the SVD, and tests PCA before classifiers on load_digits, all with real in-browser and desktop runs.

এই পর্বে যা শিখবেন

  • বাজারঘরের গ্রাহকদের recency, frequency আর monetary দিয়ে segmentation; k-means-এর এক iteration হাতে
  • NumPy-তে k-means, k-means++ শুরু, restart কেন লাগে, scaling কেন বাধ্যতামূলক; inertia, elbow আর silhouette দিয়ে k বাছাই
  • k-means-এর অনুমান আর ব্যর্থতা, hierarchical clustering আর dendrogram পড়া, linkage-এর পার্থক্য, DBSCAN আর noise বিন্দু
  • PCA-র ধারণা, SVD দিয়ে নিজে PCA, explained variance ratio, scree plot, reconstruction error আর scaling-এর ফাঁদ
  • load_digits-এ PCA-র পরে logistic regression আর k-NN, held-out accuracy দিয়ে; t-SNE আর UMAP-এর সীমা

এ পর্যন্ত প্রায় সব model-এর হাতে উত্তর (label) ছিল; এই পর্বে উত্তর নেই, শুধু ডেটা। চালানো যায় এমন ব্লকগুলো পাতাতেই চলে (ব্রাউজারের Python: NumPy 2.4.6, SciPy 1.18.0, scikit-learn 1.8.0, matplotlib 3.10.8). scikit-learn প্রথমবার নামতে প্রায় ১০ সেকেন্ড লাগে; পরের run দ্রুত।

১ · label ছাড়া শেখা — বাজারঘরের গ্রাহকদের দল

পর্ব ৫৫ (যন্ত্র কীভাবে শেখে)-এ দেখেছেন, unsupervised learning-এ শুধু X থাকে, y থাকে না; model ডেটার ভেতরের গঠন খোঁজে। দুটি প্রধান কাজ: clustering (একই রকম সারিকে এক দলে রাখা) আর dimensionality reduction (অনেক কলামকে কম কলামে আনা, যতটা সম্ভব তথ্য রেখে)। এই পর্বে দুটিই, হাতে-কলমে।

মুদি দোকানের হিসাব খাতায় কেউ রোজ অল্প কেনেন, কেউ মাসে একবার পুরো বাজার করেন, কেউ ঈদের আগে একবার এসে আর আসেননি। কেউ “দল” বলে দেয়নি, তবু খাতা দেখে তিন রকম মানুষ চোখে পড়ে। Clustering এই চোখে পড়াকে সংখ্যায় বাঁধে।

বাজারঘর (এই কোর্সের কাল্পনিক অনলাইন দোকান) তার গ্রাহকদের জন্য আলাদা বার্তা পাঠাতে চায়: নিয়মিত ক্রেতাকে ধন্যবাদ, ঘুমিয়ে পড়া ক্রেতাকে ফিরে আসার অনুরোধ। প্রতিটি গ্রাহক তিনটি সংখ্যায়, direct marketing-এ বহু দশক ধরে প্রচলিত RFM ধারণা:

  • Recency (R): শেষ কেনার পর কত দিন গেছে। কম মানে সম্প্রতি কিনেছেন।
  • Frequency (F): বছরে কয়টি অর্ডার।
  • Monetary (M): বছরে মোট কত টাকার কেনাকাটা।

আমাদের ডেটা কাল্পনিক, seed দিয়ে বানানো: ৩০০ জন গ্রাহক, চারটি লুকানো দল থেকে। (১) নিয়মিত ছোট কেনাকাটা: ৯০ জন, R প্রায় ১২ দিন, F প্রায় ১৪, M প্রায় ৯০০০ টাকা; (২) কম কিন্তু বড় কেনাকাটা: ৫০ জন, R ৪০, F ৪, M ২৪০০০; (৩) ঘুমিয়ে পড়া: ১০০ জন, R ২১০, F ২, M ২৫০০; (৪) মাঝে মাঝে ছোট কেনাকাটা: ৬০ জন, R ২৫, F ৩, M ৩৫০০. দলগুলো আমরা জানি, algorithm জানে না; শেষে মিলিয়ে দেখব। বাস্তবের ডেটায় এমন “লুকানো সঠিক দল” প্রায়ই থাকে না।

মনে রাখুন: clustering যেকোনো ডেটায় দল ফেরত দেয়, এমনকি পুরোপুরি এলোমেলো ডেটাতেও। “k = ৪ বলেছি, ৪টি দল পেয়েছি” কোনো আবিষ্কার নয়। দলগুলো স্থির কি না (অন্য seed, অন্য নমুনায়), আর ব্যবসায় কাজে লাগে কি না, তা আলাদা করে যাচাই করতে হয় (অংশ ৪)।

২ · k-means — দুই ধাপের loop, হাতে এক iteration

k-means-এর লক্ষ্য একটি সংখ্যা ছোট করা। প্রতিটি দলের একটি কেন্দ্র (centroid) থাকে, আর প্রতিটি বিন্দু তার নিজের দলের কেন্দ্র থেকে যত দূরে, সেই Euclidean distance-এর বর্গের যোগফলই inertia (within-cluster sum of squares):

J = ∑i ‖xi − cদল(i)‖²

এখানে xi হলো i-তম বিন্দু, cদল(i) তার দলের কেন্দ্র, ‖·‖² বর্গ দূরত্ব। সব সম্ভাব্য ভাগ চেষ্টা করা অসম্ভব রকম ব্যয়বহুল, তাই k-means দুটি সহজ ধাপ পালাক্রমে চালায় (Lloyd-এর algorithm):

  1. Assign: কেন্দ্র স্থির রেখে প্রতিটি বিন্দুকে সবচেয়ে কাছের কেন্দ্রের দলে দেওয়া। এতে J বাড়ে না।
  2. Update: দল স্থির রেখে প্রতিটি কেন্দ্রকে তার দলের বিন্দুগুলোর গড়ে সরানো। এতেও J বাড়ে না, কারণ বর্গ দূরত্বের যোগফল গড়েই সবচেয়ে ছোট (পর্ব ৩৭)।

দুই ধাপেই J কমে বা সমান থাকে, আর সম্ভাব্য ভাগের সংখ্যা সসীম, তাই loop একসময় থামে: কোনো বিন্দুর দল আর বদলায় না। কিন্তু থামার জায়গাটি সবচেয়ে ছোট J না-ও হতে পারে; এটি একটি local minimum, ঠিক পর্ব ৪৪-এর hill climbing-এর মতো।

ছয়টি বিন্দু আর k = ২ দিয়ে হাতে, যেমন বিশ্ববিদ্যালয়, চাকরি বা ইন্টারভিউর পরীক্ষায় আসে। শুরুর কেন্দ্র ইচ্ছে করে খারাপ বাছা: c1 = P1 = (১, ১) আর c2 = P3 = (১, ৩), দুটিই বাঁ দিকের দলে।

One k-means run by hand: assign and update steps 01234567 0123456 P1P2P3P4P5P6
বর্গ দূরত্ব আর inertiak = 2

            
  1. শুরু। ছয়টি বিন্দু, দুটি দল চোখেই দেখা যায়। কিন্তু শুরুর দুটি কেন্দ্র (বৃত্ত) দুটিই বাঁ দিকে: c1 = P1, c2 = P3. এলোমেলো শুরুতে এমন ঘটতেই পারে।
  2. Assign ১. প্রতিটি বিন্দুর দুই কেন্দ্র থেকে বর্গ দূরত্ব; ছোটটির দল। P4, P5, P6 দূরের হলেও c2-এর একটু বেশি কাছে (যেমন P4: ১৭ বনাম ২৫), তাই কমলা দলে। P3 নিজেই c2. Inertia ৭২.
  3. Update ১. প্রতিটি কেন্দ্র তার দলের গড়ে: c1 = (১.৫, ১), c2 = (৪.২৫, ৪.৫). সবুজ ড্যাশ রেখা দেখায় কেন্দ্র কোথা থেকে কোথায় গেল। দল না বদলেও inertia ৭২ থেকে ২০.২৫.
  4. Assign ২. c2 ডানে সরে যাওয়ায় P3-এর কাছে এখন c1 (৪.২৫ বনাম ১২.৮১), তাই P3 নীল দলে গেল। Inertia ১১.৬৯. কেন্দ্র সরলে দল বদলায়, দল বদলালে কেন্দ্র।
  5. Update ২. নতুন গড়: c1 = (৪/৩, ৫/৩) ≈ (১.৩৩, ১.৬৭), c2 = (১৬/৩, ৫) ≈ (৫.৩৩, ৫). Inertia ৬.০.
  6. থামা। আবার assign করলে কারও দল বদলায় না, তাই update-এও কেন্দ্র একই থাকবে: algorithm converged. Inertia প্রতিটি ধাপে কমেছে: ৭২ → ২০.২৫ → ১১.৬৯ → ৬.০.

এখানে খারাপ শুরু থেকেও ঠিক ভাগ এসেছে, কারণ দল দুটি অনেক দূরে; অংশ ৩-এ দেখব সব সময় এমন হয় না।

এই algorithm একাধিকবার স্বাধীনভাবে আবিষ্কৃত হয়েছে বলে মনে করা হয়। Bell Labs-এর Stuart Lloyd ১৯৫৭ সালে signal quantization-এর জন্য একটি অভ্যন্তরীণ প্রতিবেদনে দুই ধাপের পদ্ধতিটি লেখেন; তা প্রকাশিত হয় অনেক পরে, ১৯৮২ সালে IEEE Transactions on Information Theory-তে (“Least squares quantization in PCM”). “k-means” নামটি সাধারণত James MacQueen-এর ১৯৬৭ সালের Berkeley Symposium-এর প্রবন্ধের সঙ্গে যুক্ত, যদিও তাঁর রূপটি প্রতিটি বিন্দু আসার সঙ্গে সঙ্গে কেন্দ্র হালনাগাদ করত। Hugo Steinhaus (১৯৫৬ সালের দিকে) আর Edward Forgy (১৯৬৫)-র কাজেও কাছাকাছি ধারণা পাওয়া যায়।

৩ · NumPy-তে k-means — k-means++, restart আর scaling

কেন scaling আগে

k-means দূরত্ব দিয়ে চলে, তাই পর্ব ৩৩ আর পর্ব ৫৭-এর শিক্ষা এখানেও খাটে: কাঁচা সংখ্যায় monetary (হাজার হাজার টাকা) recency (শত দিন) আর frequency (কয়েকটি অর্ডার)-কে ডুবিয়ে দেয়। তখন “দল” মানে কার্যত শুধু খরচের ধাপ। তাই আগে standardization: প্রতিটি কলাম থেকে গড় বাদ দিয়ে standard deviation দিয়ে ভাগ। টাকার মতো ডান-দিকে-লম্বা কলামে log রূপান্তরও প্রচলিত; scaling-এর নানা পথ পর্ব ৬৩ (Feature engineering)-এর বিষয়।

k-means++: কেন্দ্র ছড়িয়ে শুরু

অংশ ২-এর মতো দুটি কেন্দ্র এক দলে পড়লে কষ্ট বাড়ে। k-means++ (David Arthur আর Sergei Vassilvitskii, ২০০৭) শুরুর কেন্দ্র এভাবে বাছে: প্রথমটি এলোমেলো একটি বিন্দু; তারপর প্রতিটি বিন্দুর সবচেয়ে কাছের বাছা কেন্দ্র থেকে বর্গ দূরত্ব D(x)² মাপা হয়, আর পরের কেন্দ্র বাছা হয় D(x)²-এর অনুপাতে সম্ভাবনা দিয়ে। দূরের বিন্দুর কেন্দ্র হওয়ার সম্ভাবনা বেশি, তাই কেন্দ্রগুলো ছড়িয়ে পড়ে। তাঁরা দেখান, এই শুরুতেই প্রত্যাশিত inertia সেরা সম্ভবের O(log k) গুণের মধ্যে থাকে; এটি গড়ের নিশ্চয়তা, একটি run তবু খারাপ হতে পারে।

Restart: local minimum-এর ওষুধ

পর্ব ৪৪-এর random-restart hill climbing-এর মতো: কয়েকবার (এখানে ১০) আলাদা শুরু থেকে চালিয়ে সবচেয়ে কম inertia-র run রাখা। scikit-learn-এ এর নাম n_init.

নিচের ব্লক ডেটা বানায়, standardize করে, k-means++ আর সরল এলোমেলো শুরু দুটোই লেখে, তারপর (১) k = ৪-এ দুই রকম শুরুর ১০টি করে run-এর inertia, (২) k = ২ থেকে ৭-এ ১০ restart-এর সেরা inertia আর silhouette score, (৩) k = ৪-এর দলগুলোর আসল এককে গড় ছাপে। scikit-learn-এর তৈরি KMeans এখানে ব্যবহার করা হয়নি, কারণ ব্রাউজারের Python-এ এটি threadpoolctl থেকে একটি অপ্রাসঙ্গিক deprecation warning ছাপে; নিজে লেখা k-means-এ ভেতরটাও দেখা যায়। এই ব্লক আর পরের দুটি একই namespace ভাগ করে (data-shared); পরেরটি আগে চালালে এটি আপনা থেকে আগে চলে।

import numpy as np
from sklearn.metrics import silhouette_score, adjusted_rand_score
rng = np.random.default_rng(42)
# কাল্পনিক গ্রাহক: recency (শেষ কেনার পর দিন), frequency (বছরে অর্ডার), monetary (বছরে টাকা)
mu = [[12, 14, 9000], [40, 4, 24000], [210, 2, 2500], [25, 3, 3500]]   # ৪টি লুকানো দল
sd = [[6, 3, 2000], [15, 1.5, 5000], [60, 1, 800], [10, 1, 900]]
X = np.vstack([rng.normal(m, s, (n, 3)) for m, s, n in zip(mu, sd, [90, 50, 100, 60])]).clip(1)
Z = (X - X.mean(0)) / X.std(0)                     # standardize: প্রতিটি কলাম গড় 0, σ 1
truth = np.repeat(np.arange(4), [90, 50, 100, 60])  # লুকানো দল; algorithm এটি দেখে না
def kmeans(Z, k, rng, plusplus=True, iters=100):
    if plusplus:                                   # k-means++: দূরের বিন্দু কেন্দ্র হওয়ার সম্ভাবনা বেশি
        C = Z[[rng.integers(len(Z))]]
        while len(C) < k:
            d2 = ((Z[:, None] - C[None]) ** 2).sum(-1).min(1)
            C = np.vstack([C, Z[rng.choice(len(Z), p=d2 / d2.sum())]])
    else:
        C = Z[rng.choice(len(Z), k, replace=False)]  # সরল: k-টি এলোমেলো বিন্দু
    for _ in range(iters):
        lab = ((Z[:, None] - C[None]) ** 2).sum(-1).argmin(1)          # ধাপ ১: assign
        newC = np.array([Z[lab == j].mean(0) if (lab == j).any() else C[j] for j in range(k)])
        if np.allclose(newC, C): break                                 # ধাপ ২: update
        C = newC
    return lab, C, ((Z - C[lab]) ** 2).sum()       # inertia: নিজের কেন্দ্র থেকে বর্গ দূরত্বের যোগ
rng = np.random.default_rng(0)
print("random init, ১০ বার:", np.sort([kmeans(Z, 4, rng, False)[2] for _ in range(10)]).round(1))
print("k-means++,  ১০ বার:", np.sort([kmeans(Z, 4, rng)[2] for _ in range(10)]).round(1))
print(" k  inertia  silhouette")
for k in range(2, 8):
    lab, C, inertia = min((kmeans(Z, k, rng) for _ in range(10)), key=lambda r: r[2])
    print(f"{k:2d} {inertia:8.1f}  {silhouette_score(Z, lab):.3f}")
lab4, C4, _ = min((kmeans(Z, 4, rng) for _ in range(10)), key=lambda r: r[2])
print("লুকানো দলের সঙ্গে মিল (ARI):", round(adjusted_rand_score(truth, lab4), 3))
for j in np.argsort(C4[:, 0]):                     # দল, recency-র ক্রমে, আসল এককে গড়
    r, f, m = X[lab4 == j].mean(0)
    print(f"দল {j}: {np.sum(lab4 == j):3d} জন | recency {r:5.0f} দিন | {f:4.1f} অর্ডার | {m:6.0f} টাকা")

আসল আউটপুট (ব্রাউজারের Python, NumPy 2.4.6, scikit-learn 1.8.0):

random init, ১০ বার: [ 88.8  88.8  88.8  88.8  88.8  88.8  88.8 272.4 272.5 302.1]
k-means++,  ১০ বার: [ 88.8  88.8  88.8  88.8  88.8  88.8 226.1 226.1 226.1 230.8]
 k  inertia  silhouette
 2    473.9  0.483
 3    261.7  0.560
 4     88.8  0.667
 5     70.8  0.606
 6     54.6  0.543
 7     42.6  0.523
লুকানো দলের সঙ্গে মিল (ARI): 0.851
দল 3:  89 জন | recency    12 দিন | 14.0 অর্ডার |   8927 টাকা
দল 0:  78 জন | recency    40 দিন |  2.9 অর্ডার |   3490 টাকা
দল 1:  48 জন | recency    41 দিন |  3.9 অর্ডার |  24873 টাকা
দল 2:  85 জন | recency   222 দিন |  2.1 অর্ডার |   2440 টাকা

প্রথম দুই লাইন local minimum-কে চোখের সামনে আনে। একই ডেটা, একই k = ৪, শুধু শুরু আলাদা: সেরা inertia ৮৮.৮, কিন্তু সরল এলোমেলো শুরুর ১০টির ৩টি থেমেছে ২৭২ থেকে ৩০২-এ, তিন গুণেরও বেশি খারাপ ভাগে। k-means++-ও নিখুঁত নয়: এই run-এ ১০টির ৪টি ২২৬ থেকে ২৩১-এ আটকেছে। সেখানে সাধারণত দুটি আসল দল মিশে যায় আর একটি দল দুই ভাগ হয়। তাই একটি run-এ ভরসা বিপজ্জনক; ১০ restart-এর সেরাটি প্রতিবার ৮৮.৮ পেয়েছে।

দলগুলো চেনা গেলেও আকার লুকানো দলের সঙ্গে হুবহু মেলেনি (৮৯, ৭৮, ৪৮, ৮৫ বনাম ৯০, ৬০, ৫০, ১০০)। ঘুমিয়ে পড়া দলের কিছু গ্রাহকের recency এলোমেলোভাবে কম, তাই তাঁরা পাশের দলে গেছেন। মিলের মাপ ARI (adjusted Rand index) ০.৮৫১: ১ মানে দুই ভাগ হুবহু এক (দলের নম্বর যা-ই হোক), ০-এর কাছে মানে এলোমেলো ভাগের সমান। ARI মাপা যায় শুধু সত্যিকারের দল জানা থাকলে, অথবা দুটি run তুলনায়। দলের নম্বর শুধু নাম; অন্য seed-এ একই দল অন্য নম্বর পেতে পারে।

নিজের লেখা কোড ঠিক কি না, তা ডেস্কটপে scikit-learn-এর আসল KMeans-এর সঙ্গে মিলিয়ে দেখা হয়েছে: একই ডেটা আর একই Z, KMeans(n_clusters=4, n_init=10, random_state=0).

~/bazarghar-ai$ python kmeans_check.py
scikit-learn 1.9.1 | numpy 2.5.4
নিজের k-means inertia : 88.772
KMeans.inertia_        : 88.772
দুই ভাগের মিল (ARI)    : 1.0
KMeans কয়বার ঘুরল (n_iter_): 4

সত্যিকারের run (ডেস্কটপ, লেখার সময়ে Python 3.14.4, NumPy 2.5.4, scikit-learn 1.9.1). Inertia তিন দশমিক পর্যন্ত এক, আর দুই ভাগের ARI ১.০, অর্থাৎ প্রতিটি গ্রাহক একই দলে (নম্বর আলাদা হতে পারে)। সেরা run-এ মাত্র ৪টি iteration লেগেছে। একটি সূক্ষ্ম কথা: scikit-learn 1.4 থেকে n_init-এর default "auto", যা init="k-means++"-এর সঙ্গে মাত্র ১টি run চালায় (documentation অনুযায়ী)। উপরের ফলে আমাদের সরল k-means++-এর ১০টি run-এর ৪টি খারাপ ভাগে আটকেছে; তাই গুরুত্বপূর্ণ কাজে n_init নিজে বলে দেওয়াই নিরাপদ। (scikit-learn-এর k-means++ “greedy”: প্রতিটি কেন্দ্রের জন্য 2 + ⌊ln k⌋টি প্রার্থী থেকে সেরাটি নেয়, তাই তার একটি run আমাদের সরল রূপের চেয়ে কিছুটা নির্ভরযোগ্য।)

৪ · k কত? Elbow, silhouette আর দলের দায়িত্বশীল নাম

Inertia আর elbow

k বাড়ালে inertia প্রায় সবসময় কমে; k = n হলে প্রতিটি বিন্দু নিজেই কেন্দ্র, inertia ০. তাই “সবচেয়ে কম inertia” দিয়ে k বাছা যায় না। Elbow method দেখে কোথায় কমার গতি হঠাৎ ধীর হয়, হাতের কনুইয়ের মতো বাঁক। উপরের টেবিলে k = ৩ থেকে ৪-এ inertia ২৬১.৭ থেকে ৮৮.৮, প্রায় ১৭৩ কম; ৪ থেকে ৫-এ মাত্র ১৮. বাঁক k = ৪-এ। বাস্তবে বাঁক প্রায়ই এত স্পষ্ট নয়।

Silhouette: প্রতিটি বিন্দু কতটা নিজের দলে

Peter Rousseeuw ১৯৮৭ সালে প্রতিটি বিন্দু i-এর জন্য একটি মাপ প্রস্তাব করেন। a(i) = নিজের দলের অন্য বিন্দুগুলোর সঙ্গে গড় দূরত্ব; b(i) = সবচেয়ে কাছের অন্য দলের বিন্দুগুলোর সঙ্গে গড় দূরত্ব। তারপর

s(i) = (b(i) − a(i)) / max(a(i), b(i))

s(i)-এর মান −১ থেকে ১. ১-এর কাছে মানে নিজের দলের গায়ে লেগে আছে আর অন্য দল থেকে দূরে; ০-এর কাছে মানে দুই দলের সীমানায়; ঋণাত্মক মানে সম্ভবত ভুল দলে। সব বিন্দুর গড়ই silhouette score (sklearn.metrics.silhouette_score). আমাদের টেবিলে সর্বোচ্চ ০.৬৬৭, k = ৪-এ; elbow-এর সঙ্গে একমত। সতর্কতা: silhouette-ও গোল, ঘন দল পছন্দ করে, আর সব জোড়ার দূরত্ব লাগে বলে বড় ডেটায় ব্যয়বহুল।

দলের নাম দেওয়া — দায়িত্ব নিয়ে

ব্লকের শেষ চার লাইন দলগুলোর আসল এককে গড়। একজন বিশ্লেষক নাম দিতে পারেন: দল ৩ “নিয়মিত ক্রেতা” (R ১২ দিন, ১৪টি অর্ডার), দল ১ “বড় ঝুড়ির ক্রেতা” (প্রায় ২৫ হাজার টাকা), দল ০ “মাঝে মাঝে ছোট কেনাকাটা”, দল ২ “ঘুমিয়ে পড়া” (R ২২২ দিন)। কিন্তু মনে রাখুন:

  • নাম একটি ব্যাখ্যা, তথ্য নয়। “বড় ঝুড়ির ক্রেতা” হয়তো অফিসের হয়ে কেনা মানুষ। আগে কয়েকটি সারি নিজে পড়ুন, শুধু গড় নয়।
  • স্থিরতা যাচাই। অন্য seed, অন্য মাস বা ৮০% নমুনায় একই দল ফেরে কি না (দুই run-এর ARI). যে দল প্রতিবার বদলায়, তার নামে নীতি নয়।
  • মানুষকে ছোট করে না এমন নাম। “অলস” বা “কম মূল্যের গ্রাহক” নয়; বর্ণনামূলক নাম। আর লিঙ্গ, ধর্ম বা এলাকার মতো সংবেদনশীল তথ্য দলের ভেতরে লুকিয়ে আছে কি না, তা আলাদা করে দেখুন (ভাবনার প্রশ্ন ৪)।
  • দল ব্যবহারের ফল মাপুন। আলাদা বার্তায় কি সত্যিই ফেরত আসা বাড়ে? তা মাপে A/B test, silhouette নয়।
মূল ধারণা

Cluster ground truth নয়। একই ডেটায় ভিন্ন scaling, ভিন্ন k বা ভিন্ন algorithm ভিন্ন দল দেয়, আর প্রতিটিই “ঠিক”, নিজের সংজ্ঞায়। প্রশ্নটি “সত্যিকারের দল কোনটি” নয়, বরং “কোন ভাগ আমার কাজে স্থির আর উপকারী”। (এর একটি তাত্ত্বিক ভিত্তি ভাবনার প্রশ্ন ৪-এ।)

৫ · k-means যা ধরে নেয় — আর hierarchical clustering

k-means প্রতিটি বিন্দুকে সবচেয়ে কাছের কেন্দ্রে দেয়, তাই দুই কেন্দ্রের মাঝের সীমারেখা সবসময় একটি সরল রেখা (বা উচ্চ মাত্রায় সমতল), আর প্রতিটি দল একটি উত্তল (convex) এলাকা। এর ফলে কিছু অনুমান চুপচাপ ঢুকে পড়ে:

  • দল মোটামুটি গোল: সব দিকে সমান ছড়ানো। লম্বাটে, তির্যক বা বাঁকা দল ভুলভাবে কাটা পড়ে।
  • দলগুলোর আকার আর ছড়ানো কাছাকাছি: বড় ছড়ানো দলের পাশে ছোট ঘন দল থাকলে সীমারেখা ভুল জায়গায় পড়ে।
  • প্রতিটি বিন্দু কোনো দলের: ছিটকে পড়া বিন্দুও দলে ঢোকে আর কেন্দ্রকে টেনে সরায়।
  • k আগে থেকে জানা, আর দূরত্ব Euclidean.

অংশ ৬-এ দুটি বাঁকা “চাঁদ” (make_moons)-এ এই ব্যর্থতা দেখবেন। আগে আরেক ধারার পদ্ধতি।

Hierarchical clustering: জোড়া লাগাতে লাগাতে গাছ

Agglomerative hierarchical clustering শুরুতে প্রতিটি বিন্দুকে আলাদা দল ধরে, তারপর বারবার সবচেয়ে কাছের দুটি দল জোড়া লাগায়, যতক্ষণ না একটি দল থাকে (পর্ব ৫৫-এর ব্লকে একটি ছোট run দেখেছেন)। পুরো ইতিহাস আঁকা যায় একটি গাছে, যার নাম dendrogram: প্রতিটি জোড়া লাগার উচ্চতা হলো তখন দুই দলের দূরত্ব। k আগে বলতে হয় না; গাছটি কোনো উচ্চতায় আড়াআড়ি কাটলেই দল।

“দুই দলের দূরত্ব” কীভাবে মাপা হবে, তা ঠিক করে linkage. Single linkage-এ দুই দলের সবচেয়ে কাছের দুটি বিন্দুর দূরত্ব। নিচে এক মাত্রার পাঁচটি বিন্দু (যেমন পাঁচজন গ্রাহকের মাসিক অর্ডার) দিয়ে single linkage-এর গাছ বানিয়ে কাটা হলো।

Building and cutting a single-linkage dendrogram 0123456 উচ্চতা (height) 1 2 3 6 ABCDE 2361214
Single linkage: দুই দলের সবচেয়ে কাছের জোড়া৫টি বিন্দু

            
  1. শুরু। পাঁচটি বিন্দু, প্রতিটি নিজেই একটি দল। দশটি জোড়ার দূরত্বের টেবিল; এক মাত্রায় দূরত্ব মানে শুধু পার্থক্যের পরম মান।
  2. প্রথম জোড়া। সবচেয়ে ছোট দূরত্ব A–B (১), তাই উচ্চতা ১-এ A আর B একটি দলে। গাছে উচ্চতা ১-এ একটি অনুভূমিক রেখা।
  3. দ্বিতীয় জোড়া। এখন প্রার্থী: {A,B}–C = min(৪, ৩) = ৩, C–D = ৬, D–E = ২. সবচেয়ে ছোট D–E, উচ্চতা ২.
  4. তৃতীয় জোড়া। {A,B}–C = ৩, C–{D,E} = min(৬, ৮) = ৬, {A,B}–{D,E} = ৯. তাই উচ্চতা ৩-এ {A,B,C}.
  5. শেষ জোড়া। দুই দলের সবচেয়ে কাছের জোড়া C (৬) আর D (১২), দূরত্ব ৬. scipy-র linkage(x, "single")-ও ঠিক ১, ২, ৩, ৬ দেয় (ডেস্কটপে মেলানো)।
  6. উচ্চতা ৪-এ কাটা। লাল রেখা দুটি খাড়া রেখা কাটে, তাই ২টি দল: {A,B,C} আর {D,E}. উচ্চতা ৩ আর ৬-এর মাঝের ফাঁক সবচেয়ে বড়, তাই এই কাটা সবচেয়ে “স্বাভাবিক” বলে ধরা হয়।
  7. উচ্চতা ২.৫-এ কাটা। এবার তিনটি খাড়া রেখা কাটা পড়ে: {A,B}, {C}, {D,E}. একই গাছ থেকে যেকোনো k পাওয়া যায়। Complete linkage নিলে উচ্চতা হতো ১, ২, ৪, ১২, অর্থাৎ linkage বদলালে গাছ বদলায়।
Linkageদুই দলের দূরত্বউপরের উদাহরণে উচ্চতাস্বভাব
singleসবচেয়ে কাছের জোড়া1, 2, 3, 6বাঁকা, লম্বাটে দল ধরতে পারে; কিন্তু একটি “সেতু” বিন্দু দুই দলকে জুড়ে দেয় (chaining), আর ছিটকে পড়া বিন্দুতে নাজুক
completeসবচেয়ে দূরের জোড়া1, 2, 4, 12ঘন, গোল দল; বড় দলকে ভেঙে ফেলতে পারে
averageসব জোড়ার গড়1, 2, 3.5, 9.33মাঝামাঝি
ward (scikit-learn-এর default)জোড়া লাগালে মোট within-cluster বর্গ যোগফল কত বাড়ে1, 2, 4.04, 14.46k-means-এর মতো inertia-ভিত্তিক; গোল, কাছাকাছি আকারের দল পছন্দ করে

উচ্চতাগুলো scipy 1.18-এর scipy.cluster.hierarchy.linkage দিয়ে ডেস্কটপে মাপা (ward-এর উচ্চতা scipy-র নিজস্ব সূত্রে, সাধারণ দূরত্ব নয়)। Ward-এর পদ্ধতি Joe H. Ward Jr.-এর ১৯৬৩ সালের প্রবন্ধ থেকে। Single linkage minimum spanning tree-র আত্মীয়: MST-র সবচেয়ে লম্বা k − ১টি edge কাটলে single-linkage-এর k দল মেলে (Gower ও Ross, ১৯৬৯)। সব জোড়ার দূরত্ব লাগে বলে মেমরি O(n²): কয়েক হাজার সারিতে চলে, লাখে নয়।

৬ · DBSCAN — ঘনত্ব দিয়ে দল, আর noise-কে noise বলা

DBSCAN (Martin Ester, Hans-Peter Kriegel, Jörg Sander আর Xiaowei Xu, KDD ১৯৯৬) দলকে দেখে “ঘন এলাকা” হিসেবে, যাকে ফাঁকা এলাকা আলাদা করে। দুটি parameter:

  • eps (ε): প্রতিবেশীর ব্যাসার্ধ।
  • min_samples: একটি বিন্দুর ε-এর ভেতরে (নিজেসহ) অন্তত এতগুলো বিন্দু থাকলে সে core point.

পরস্পরের ε-এর মধ্যে থাকা core বিন্দুগুলো জুড়ে একটি দল হয়, অনেকটা BFS দিয়ে graph-এর connected component খোঁজার মতো। কোনো core-এর ε-এর ভেতরে থাকা কিন্তু নিজে core নয় এমন বিন্দু হলো border point, সেই দলে যোগ হয়। আর যে বিন্দু কোনো core-এর কাছেই নেই, সে noise: scikit-learn তাকে label −১ দেয়। k লাগে না, দলের আকৃতি যেকোনো, আর ছিটকে পড়া বিন্দু জোর করে দলে ঢোকে না। দুর্বলতা: দলগুলোর ঘনত্ব আলাদা হলে একটি ε সবার জন্য খাটে না, আর ε ডেটার scale-এর ওপর নির্ভর করে।

মেলার মাঠ ওপর থেকে দেখুন: যেখানে মানুষ গায়ে গায়ে, সেখানে একটি ভিড়, কিনারের মানুষও ভিড়ের অংশ; ফাঁকা কোণে একা দাঁড়ানো চা-ওয়ালা কোনো ভিড়ের নন। ভিড় বাঁকা না লম্বা, তাতে কিছু যায় আসে না। DBSCAN এভাবেই গোনে।

নিচের ব্লক দুটি বাঁকা চাঁদের ৩০০টি বিন্দু (M) বানায়, যার আসল দল আমরা জানি; তারপর সঙ্গে ৮টি এলোমেলো ছিটকে পড়া বিন্দু যোগ করে আরেকটি ডেটা (M+8). k-means (আগের ব্লকের নিজের লেখা ফাংশন), AgglomerativeClustering দুই linkage-এ, আর DBSCAN চালিয়ে ARI মাপে (শুধু ৩০০টি চাঁদের বিন্দুতে)। শেষে scipy দিয়ে একই single linkage-এর dendrogram আঁকে।

import matplotlib.pyplot as plt
from sklearn.datasets import make_moons
from sklearn.cluster import AgglomerativeClustering, DBSCAN
from scipy.cluster.hierarchy import linkage, dendrogram, fcluster

M, moon = make_moons(n_samples=300, noise=0.06, random_state=42)    # দুটি বাঁকা চাঁদ
A = np.vstack([M, np.random.default_rng(1).uniform([-1.5, -1], [2.5, 1.5], (8, 2))])  # +৮টি ছিটকে পড়া
def show(name, lab):                               # ARI শুধু ৩০০টি চাঁদের বিন্দুতে
    print(f"{name:17} ARI {adjusted_rand_score(moon, lab[:300]):.3f} | আকার {np.unique(lab, return_counts=True)[1]}")
km = min((kmeans(M, 2, np.random.default_rng(s)) for s in range(10)), key=lambda r: r[2])[0]
show("k-means, M", km)
single = AgglomerativeClustering(n_clusters=2, linkage="single").fit_predict(M)
show("single, M", single)
show("single, M+8", AgglomerativeClustering(n_clusters=2, linkage="single").fit_predict(A))
show("ward, M+8", AgglomerativeClustering(n_clusters=2, linkage="ward").fit_predict(A))
db = DBSCAN(eps=0.2, min_samples=5).fit_predict(A)  # -1 মানে noise
show("DBSCAN, M+8", db)
print("DBSCAN-এর noise:", int((db == -1).sum()), "| তার মধ্যে ছিটকে পড়া:", int((db[300:] == -1).sum()))
L = linkage(M, method="single")                    # scipy: একই single linkage, merge-এর তালিকা
print("scipy বনাম scikit-learn (ARI):", adjusted_rand_score(single, fcluster(L, t=2, criterion="maxclust")))
print("শেষ তিন merge-এর উচ্চতা:", L[-3:, 2].round(3))

fig, axs = plt.subplots(2, 2, figsize=(8, 7))
for ax, P, lab, title in [(axs[0, 0], M, km, "k-means (k=2)"), (axs[0, 1], M, single, "single linkage"),
                          (axs[1, 0], A, db, "DBSCAN on moons + 8 outliers")]:
    ax.scatter(P[:, 0], P[:, 1], c=np.where(lab < 0, 0.5, lab), cmap="coolwarm", s=10)
    ax.set_title(title)
axs[1, 0].scatter(*A[db == -1].T, marker="x", c="k", s=40, label="noise (-1)")
axs[1, 0].legend(loc="lower left")
dendrogram(L, truncate_mode="lastp", p=12, ax=axs[1, 1], color_threshold=0.3, no_labels=True)
axs[1, 1].axhline(0.25, ls="--", c="gray")
axs[1, 1].set_title("single-linkage dendrogram (last 12)")
axs[1, 1].set_ylabel("merge height")
fig.subplots_adjust(hspace=0.35, wspace=0.25)
plt.show()

আসল আউটপুট (ব্রাউজারের Python, scikit-learn 1.8.0, SciPy 1.18.0; নিচে ছবি):

k-means, M        ARI 0.247 | আকার [151 149]
single, M         ARI 1.000 | আকার [150 150]
single, M+8       ARI 0.000 | আকার [307   1]
ward, M+8         ARI 0.425 | আকার [207 101]
DBSCAN, M+8       ARI 1.000 | আকার [  5 152 151]
DBSCAN-এর noise: 5 | তার মধ্যে ছিটকে পড়া: 5
scipy বনাম scikit-learn (ARI): 1.0
শেষ তিন merge-এর উচ্চতা: [0.124 0.133 0.355]

k-means ব্যর্থ: ARI মাত্র ০.২৪৭, যদিও দুই দলের আকার প্রায় সমান (১৫১, ১৪৯)। ছবির প্রথম প্যানেলে দেখা যায় k-means চাঁদ দুটিকে একটি প্রায় সরল রেখায় বাঁ আর ডান অর্ধেকে কেটেছে, কারণ তার সীমারেখা সরলই হতে পারে। এটি কোডের ভুল নয়, অনুমানের অমিল; restart-ও সাহায্য করে না, কারণ এই ভাগের inertia (১২১.৬) আসল দুই চাঁদের (১৮০.৯, ডেস্কটপে মাপা) চেয়েই কম।

Single linkage পরিষ্কার ডেটায় নিখুঁত (ARI ১.০০০), কিন্তু নাজুক: মাত্র ৮টি ছিটকে পড়া বিন্দু যোগ করতেই ভাগ হয়েছে ৩০৭ আর ১: একটি দূরের বিন্দু একাই “দল”, আর দুই চাঁদ একসঙ্গে অন্য দলে, ARI ০. কারণ সবচেয়ে দূরের ছিটকে পড়া বিন্দুটি বাকিদের থেকে প্রায় ০.৫৮৬ দূরে, যা দুই চাঁদের মাঝের ফাঁকের (০.৩৫৫) চেয়ে বেশি; তাই শেষ জোড়া লাগে সেই একা বিন্দুর সঙ্গে (ডেস্কটপে মাপা)। Ward ০.৪২৫, k-means-এর মতোই গোল দল খোঁজে। DBSCAN (eps=0.2, min_samples=5) একই ডেটায় দুই চাঁদ হুবহু আলাদা করেছে (ARI ১.০০০) আর ৫টি বিন্দুকে noise বলেছে, যার ৫টিই আমাদের যোগ করা ছিটকে পড়া বিন্দু; বাকি ৩টি চাঁদের গা ঘেঁষে পড়ায় দলে ঢুকেছে। আউটপুটে দলের আকারের প্রথম সংখ্যা (৫) হলো label −১, অর্থাৎ noise.

scipy-র linkage আর fcluster(L, t=2, criterion="maxclust") scikit-learn-এর single linkage-এর সঙ্গে হুবহু একই ভাগ দেয় (ARI ১.০)। শেষ তিনটি জোড়া লাগার উচ্চতা ০.১২৪, ০.১৩৩ আর ০.৩৫৫: শেষ লাফটি আগেরগুলোর প্রায় তিন গুণ, তাই dendrogram-এর ধূসর কাটা রেখা (উচ্চতা ০.২৫) ঠিক দুটি দল দেয়। মনে রাখবেন, eps, min_samples আর linkage আমরা দল জেনে বেছেছি; বাস্তবে বাছাই অনেক কঠিন।

৭ · PCA-র ধারণা — variance মানে তথ্য, অক্ষ ঘোরানো

এবার দ্বিতীয় কাজ: কলাম কমানো। ধরুন বাজারঘরের প্রতিটি গ্রাহকের ৪০টি কলাম। ছবি আঁকা যায় দুই মাত্রায়, আর অনেক কলামে k-NN ধীর ও দুর্বল হয় (পর্ব ৫৭)। কিন্তু কলামগুলো প্রায়ই একে অন্যের সঙ্গে জড়ানো: যিনি বেশি অর্ডার দেন, তিনি সাধারণত বেশি টাকাও খরচ করেন। তাহলে দুটি কলামের বদলে “কেনাকাটার সক্রিয়তা” নামের একটি কলামেই প্রায় সব কথা বলা যায়।

লম্বাটে ক্রিকেট মাঠের ছবি তুলতে ক্যামেরা মাঠ বরাবর ঘোরালে ফ্রেমে বেশি ধরে। PCA (Principal Component Analysis) ডেটার জন্য তা-ই করে: অক্ষগুলো এমনভাবে ঘোরায় যাতে প্রথম অক্ষ ডেটার সবচেয়ে লম্বা দিক বরাবর থাকে। বিন্দুগুলো সরে না, শুধু মাপার দিক বদলায়।

PCA-র মূল ধারণা: যে দিকে ডেটা বেশি ছড়ানো (variance বেশি), সেদিকে বিন্দুগুলোর পার্থক্য বেশি, তাই সেদিক বেশি তথ্য বহন করে। যে দিকে প্রায় সবাই একই মানে, সেদিক বাদ দিলে কম হারায়। প্রথম principal component (PC1) হলো সেই একক-দৈর্ঘ্যের দিক, যার ওপর বিন্দুগুলোর ছায়ার variance সবচেয়ে বেশি; PC2 হলো PC1-এর সঙ্গে orthogonal দিকগুলোর মধ্যে সবচেয়ে বেশি variance-এর দিক; এভাবে চলে। এই দিকগুলো হলো ডেটার covariance matrix-এর eigenvector, আর প্রতিটির variance তার eigenvalue (পর্ব ১৭-এর “আরও গভীরে”)।

পাঁচটি বিন্দু দিয়ে হাতে, এমন সংখ্যা বেছে যাতে সব হিসাব পরিষ্কার হয়:

PCA by hand: centring, principal directions and projection onto PC1 01234567 0123456 x′y′ PC1PC2 (1,2)(2,1)(3,4)(4,3)(5,5)
হিসাবn = 5

            
  1. ডেটা। পাঁচটি বিন্দু; দুই কলামই ১ থেকে ৫, আর একটি বাড়লে অন্যটিও মোটামুটি বাড়ে। গড় μ = (৩, ৩), তারা চিহ্নে।
  2. Centering. প্রতিটি বিন্দু থেকে গড় বিয়োগ, অর্থাৎ মূলবিন্দু গড়ে সরানো (বেগুনি অক্ষ)। PCA দিক খোঁজে গড়ের চারপাশে ছড়ানোর; centering না করলে প্রথম “দিক” মূলবিন্দু থেকে গড়ের দিকে চলে যেতে পারে।
  3. Covariance. দুই কলামের variance ২.৫ করে, covariance ২ (ধনাত্মক: একসঙ্গে বাড়ে)। মোট variance ৫, এটিই “মোট তথ্য” যা ভাগ হবে। ভাগ n − ১ দিয়ে, scikit-learn-এর মতো।
  4. দিক। Σ-এর eigenvector: v1 = (১, ১)/√২, ৪৫° কোণে (লাল), eigenvalue ৪.৫; v2 = (−১, ১)/√২, তার লম্ব (সবুজ), eigenvalue ০.৫. PC1 একাই মোট variance-এর ৪.৫/৫ = ৯০% বহন করে: এটিই explained variance ratio.
  5. ঘোরানো। প্রতিটি centred বিন্দুকে v1 আর v2-এর সঙ্গে dot product করলে নতুন স্থানাঙ্ক (z1, z2). ধূসর ড্যাশ রেখা PC1-এ লম্ব ছায়া। z1-এর variance ৪.৫, z2-এর ০.৫, আর দুইয়ের covariance ০: নতুন কলাম পরস্পর uncorrelated. (৫, ৫) আগে থেকেই PC1 রেখায়, তাই তার z2 = ০.
  6. এক মাত্রায় নামানো। শুধু z1 রেখে ফেরত আনলে প্রতিটি বিন্দু PC1 রেখার ওপর তার ছায়ায় বসে (লাল বর্গ; দুটি করে বিন্দু একই জায়গায়)। হারানো অংশ শুধু z2: প্রতিটির বর্গ ০.৫, চারটিতে মোট ২, যা মোট বর্গ ছড়ানো ২০-এর ১০%. এটিই reconstruction error, আর তা ঠিক বাদ দেওয়া PC-র variance-এর ভাগ।

PCA-র ধারণা সাধারণত Karl Pearson-এর ১৯০১ সালের প্রবন্ধ “On lines and planes of closest fit to systems of points in space”-এর সঙ্গে যুক্ত, যেখানে তিনি এমন রেখা খোঁজেন যার থেকে বিন্দুগুলোর লম্ব দূরত্বের বর্গযোগ সবচেয়ে কম। “Principal components” নাম আর variance-ভিত্তিক রূপটি দেন Harold Hotelling, ১৯৩৩ সালে। লক্ষ করুন, দুই দৃষ্টিভঙ্গি একই উত্তর দেয়: সবচেয়ে বেশি variance রাখা মানে সবচেয়ে কম reconstruction error, কারণ মোট variance = রাখা অংশ + হারানো অংশ।

৮ · SVD দিয়ে নিজে PCA — explained variance, scaling আর reconstruction

বাস্তবে covariance matrix বানিয়ে eigenvector খোঁজার বদলে সরাসরি centred ডেটার SVD নেওয়া হয় (পর্ব ১৭): Xc = U S VT. এখানে Xc-এর shape (n, d); VT-এর প্রতিটি সারি একটি PC-র দিক (d টি ঘর); S-এর কর্ণে singular value s1 ≥ s2 ≥ … ; আর প্রতিটি PC-র variance λj = sj² / (n − ১)। কারণ XcTXc / (n − ১) হলো covariance matrix, যা SVD বসালে V (S²/(n − ১)) VT. SVD পছন্দের কারণ XTX বানালে নির্ভুলতা কমে (condition number বর্গ হয়, পর্ব ১৭)।

Explained variance ratio = λj / ∑λ, অর্থাৎ মোট variance-এর কত ভাগ j-তম PC বহন করে। q টি PC রেখে ফেরত আনলে (X̂ = ZqVqT + μ) আপেক্ষিক reconstruction error = ১ − প্রথম q টি ratio-র যোগফল। নিচের ব্লক অংশ ৩-এর একই RFM ডেটায় (scaled Z আর কাঁচা X) এসব যাচাই করে।

from sklearn.decomposition import PCA
np.set_printoptions(precision=3, suppress=True)

def pca_svd(D):
    Xc = D - D.mean(0)                             # ১. কেন্দ্রে আনা (centering)
    U, S, Vt = np.linalg.svd(Xc, full_matrices=False)
    var = S**2 / (len(D) - 1)                      # প্রতিটি PC-র variance
    return Vt, var / var.sum(), Xc @ Vt.T          # দিক, explained ratio, নতুন স্থানাঙ্ক

Vt, ratio, T = pca_svd(Z)                          # scaled RFM (Z)
sk = PCA().fit(Z)
print("explained variance ratio:", ratio, "| scikit-learn:", sk.explained_variance_ratio_)
print("দিক মেলে (চিহ্ন বাদে)?", np.allclose(np.abs(Vt), np.abs(sk.components_)))
print("PC1-এর ওজন (R, F, M):", Vt[0])
C = np.cov(T.T)                                    # নতুন কলামগুলোর covariance matrix
print("PC-র variance:", C.diagonal(), "| বাকি ঘরের সর্বোচ্চ |মান|:", f"{np.abs(C - np.diag(C.diagonal())).max():.1e}")

Vr, ratio_raw, _ = pca_svd(X)                      # কাঁচা টাকা-দিন-অর্ডার
print("scaling ছাড়া ratio:", [f"{r:.5f}" for r in ratio_raw], "| PC1-এর ওজন:", [f"{v:.4f}" for v in Vr[0]])
print("কাঁচা কলামের σ (R, F, M):", X.std(0).round(1))

for q in (1, 2, 3):                                # q টি PC রেখে ফেরত আনা
    Zhat = T[:, :q] @ Vt[:q] + Z.mean(0)
    err = ((Z - Zhat) ** 2).sum() / ((Z - Z.mean(0)) ** 2).sum()
    print(f"q={q}: reconstruction error {err:.3f} | 1 − cumulative ratio {1 - ratio[:q].sum():.3f}")

আসল আউটপুট (ব্রাউজারের Python, NumPy 2.4.6, scikit-learn 1.8.0):

explained variance ratio: [0.587 0.283 0.13 ] | scikit-learn: [0.587 0.283 0.13 ]
দিক মেলে (চিহ্ন বাদে)? True
PC1-এর ওজন (R, F, M): [ 0.665 -0.575 -0.476]
PC-র variance: [1.768 0.851 0.391] | বাকি ঘরের সর্বোচ্চ |মান|: 4.5e-16
scaling ছাড়া ratio: ['0.99989', '0.00011', '0.00000'] | PC1-এর ওজন: ['0.0047', '-0.0001', '-1.0000']
কাঁচা কলামের σ (R, F, M): [  93.3    5.5 8045.3]
q=1: reconstruction error 0.413 | 1 − cumulative ratio 0.413
q=2: reconstruction error 0.130 | 1 − cumulative ratio 0.130
q=3: reconstruction error 0.000 | 1 − cumulative ratio 0.000

মিল: নিজের ১০ লাইনের PCA আর scikit-learn-এর explained variance ratio একই (০.৫৮৭, ০.২৮৩, ০.১৩০), আর দিকগুলো চিহ্ন বাদে হুবহু এক। “চিহ্ন বাদে” কারণ v একটি PC হলে −v-ও একই রেখা, একই variance; কোন চিহ্ন আসবে তা হিসাবের খুঁটিনাটির ওপর নির্ভর করে, তাই অন্য যন্ত্রে ওজনের চিহ্ন উল্টে গেলে ভয়ের কিছু নেই।

অর্থ: PC1-এর ওজন R +০.৬৬৫, F −০.৫৭৫, M −০.৪৭৬: এই অক্ষে বড় মান মানে অনেক দিন কেনেননি, কম অর্ডার আর কম টাকা, অর্থাৎ “ঘুমিয়ে পড়া বনাম সক্রিয়” মাপের একটি অক্ষ, যা একাই ৫৮.৭% variance বহন করে। নতুন কলামগুলোর variance ১.৭৬৮, ০.৮৫১, ০.৩৯১ (যোগ ৩.০১ = ৩ × n/(n − ১)), আর পরস্পর covariance ৪.৫ × 10−16, rounding error-এর মাপে শূন্য: নতুন কলাম uncorrelated.

Scaling-এর ফাঁদ: কাঁচা ডেটায় PC1 একাই ৯৯.৯৮৯% variance নেয়, আর তার ওজন প্রায় পুরোপুরি monetary (−১.০০০০)। কারণ টাকার σ প্রায় ৮০৪৫, recency-র ৯৩ আর frequency-র ৫.৫; variance-এ পার্থক্য আরও বর্গ হয়ে যায়। scaling ছাড়া PCA আসলে বলছে “সবচেয়ে বড় সংখ্যার কলামটিই সব”, যা এককের দুর্ঘটনা, তথ্য নয়। কলামগুলোর একক আলাদা হলে PCA-র আগে standardize করুন।

Reconstruction: ১টি PC রাখলে ৪১.৩% বর্গ ছড়ানো হারায়, ২টিতে ১৩.০%, ৩টিতে (সব) ০; প্রতিবার ঠিক ১ − cumulative ratio-র সমান, যেমন হাতের উদাহরণে।

৯ · load_digits-এ PCA — ছবি, scree plot আর model-এর আগে PCA

scikit-learn-এর সঙ্গে আসা load_digits হাতে লেখা ০ থেকে ৯ অঙ্কের ১৭৯৭টি ছোট ছবি, প্রতিটি ৮ × ৮ পিক্সেল, প্রতিটি পিক্সেলের মান ০ থেকে ১৬; তাই প্রতিটি সারি ৬৪ কলামের (scikit-learn-এর documentation অনুযায়ী এটি UCI Machine Learning Repository-র optical handwritten digits ডেটার একটি অংশ)। প্রশ্ন দুটি: দুই মাত্রায় কেমন দেখায়, আর কম PC-তে classifier-এর কাজ চলে কি না।

Scree plot হলো PC নম্বরের বিপরীতে explained variance (বা তার cumulative যোগফল)-এর রেখা; নামটি পাহাড়ের গোড়ায় জমা পাথরের ঢাল (scree) থেকে, Raymond Cattell-এর ১৯৬৬ সালের কাজে। প্রচলিত নিয়ম: cumulative ratio ৯০% বা ৯৫% ছুঁলে থামা; PCA(n_components=0.95) নিজেই তা করে।

নিচের ব্লকে প্রথমে ডেটা train আর test-এ ভাগ (পর্ব ৫৬), আর PCA শুধু train-এ fit, কারণ test-এর তথ্য দিয়ে দিক বাছলে তা leakage. তারপর q = ২ থেকে ৬৪ PC রেখে make_pipeline(PCA, model) দিয়ে logistic regression (পর্ব ৫৯) আর 5-NN (পর্ব ৫৭)-এর held-out accuracy মাপা; pipeline নিশ্চিত করে PCA প্রতিবার শুধু train-এ fit হয় (বিস্তারিত পর্ব ৬৪, scikit-learn pipeline). 5-NN-এ algorithm="kd_tree" দেওয়া হয়েছে, কারণ ব্রাউজারের Python-এ অন্য algorithm একটি অপ্রাসঙ্গিক warning ছাপে; ফল একই।

import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split
from sklearn.decomposition import PCA
from sklearn.linear_model import LogisticRegression
from sklearn.neighbors import KNeighborsClassifier
from sklearn.pipeline import make_pipeline

X, y = load_digits(return_X_y=True)                # 1797টি 8×8 হাতের লেখা অঙ্ক, 64 কলাম
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.25, stratify=y, random_state=42)
pca = PCA().fit(X_tr)                              # শুধু train-এ fit
cum = np.cumsum(pca.explained_variance_ratio_)
print("shape:", X.shape, "| PC1+PC2:", cum[1].round(3), "| 90% variance-এ লাগে", int(np.argmax(cum >= 0.9)) + 1, "টি PC")

for q in (2, 5, 10, 20, 30, 64):                   # পিক্সেলের একক একই (0–16), তাই scaling নেই
    lr = make_pipeline(PCA(q, random_state=0), LogisticRegression(max_iter=5000)).fit(X_tr, y_tr)
    kn = make_pipeline(PCA(q, random_state=0), KNeighborsClassifier(5, algorithm="kd_tree")).fit(X_tr, y_tr)
    print(f"q={q:2d} | logistic {lr.score(X_te, y_te):.3f} | 5-NN {kn.score(X_te, y_te):.3f}")

P = pca.transform(X_tr)[:, :2]                     # ছবির জন্য train-এর 2-D projection
fig, (a1, a2) = plt.subplots(1, 2, figsize=(9, 4))
sc = a1.scatter(P[:, 0], P[:, 1], c=y_tr, cmap="tab10", s=6)
a1.set_title("load_digits on PC1 and PC2")
a1.set_xlabel("PC1"); a1.set_ylabel("PC2")
fig.colorbar(sc, ax=a1, ticks=range(10), label="digit")
a2.plot(range(1, 65), cum, marker=".")
a2.axhline(0.9, ls="--", c="gray")
a2.set_title("scree: cumulative explained variance")
a2.set_xlabel("number of components"); a2.set_ylabel("cumulative ratio")
fig.subplots_adjust(wspace=0.35, bottom=0.15)
plt.show()

আসল আউটপুট (ব্রাউজারের Python, scikit-learn 1.8.0; নিচে ছবি):

shape: (1797, 64) | PC1+PC2: 0.284 | 90% variance-এ লাগে 21 টি PC
q= 2 | logistic 0.591 | 5-NN 0.620
q= 5 | logistic 0.838 | 5-NN 0.929
q=10 | logistic 0.916 | 5-NN 0.967
q=20 | logistic 0.931 | 5-NN 0.980
q=30 | logistic 0.958 | 5-NN 0.980
q=64 | logistic 0.962 | 5-NN 0.984

ছবি: প্রথম দুই PC মোট variance-এর মাত্র ২৮.৪% ধরে, তবু বাঁ প্যানেলে কয়েকটি অঙ্ক স্পষ্ট আলাদা জায়গায় জমেছে (যেমন ০ নিচে, ৪ আর ৬ ডানে), আর অনেকগুলো মাঝখানে মিশে আছে। PCA label দেখেনি; রং পরে বসানো। ডান প্যানেলের cumulative রেখা ২১টি PC-তে ৯০%-এর ধূসর রেখা ছোঁয়, তারপর ধীরে ১-এর দিকে যায়।

Model-এর আগে PCA: মাত্র ২টি PC-তে accuracy ০.৬ এর কাছে, ১০টিতে 5-NN ০.৯৬৭, ২০টিতে ০.৯৮০, আর পুরো ৬৪টিতে ০.৯৮৪. অর্থাৎ এক-তৃতীয়াংশেরও কম কলামে প্রায় একই ফল। k-NN-এর জন্য এটি বড় কথা, কারণ প্রতি query-র খরচ O(n·d) (পর্ব ৫৭); d = ৬৪ থেকে ২০ মানে দূরত্বের হিসাব প্রায় তিন গুণ কম। Logistic regression-এ PCA এখানে accuracy সামান্য কমিয়েছে (০.৯৬২ থেকে ২০ PC-তে ০.৯৩১)। “PCA noise সরিয়ে accuracy বাড়ায়” এই ডেটায় ঘটেনি; লাভ গতি, মেমরি আর ছবিতে। ৪৫০টি test ছবিতে ০.৯৮০ বনাম ০.৯৮৪ মাত্র দুটি ছবির পার্থক্য, আর এটি একটি ভাগের ফল।

এখানে scaling করা হয়নি, কারণ সব পিক্সেলের একক একই (০–১৬ কালির ঘনত্ব)। Scaling করলে কী হয়, তা ডেস্কটপে মাপা হয়েছে:

~/bazarghar-ai$ python digits_scaled.py
q= 5 | scaling ছাড়া 0.838 | StandardScaler-সহ 0.784
q=10 | scaling ছাড়া 0.916 | StandardScaler-সহ 0.858
q=20 | scaling ছাড়া 0.929 | StandardScaler-সহ 0.942
সব সময় 0 এমন পিক্সেল-কলাম: 3

সত্যিকারের run (ডেস্কটপ, Python 3.14.4, scikit-learn 1.9.1; একই ভাগ, PCA → logistic regression). কম PC-তে StandardScaler-সহ accuracy কম (৫টিতে ০.৭৮৪ বনাম ০.৮৩৮), কারণ standardize করলে কিনারার প্রায়-ফাঁকা পিক্সেলও মাঝের পিক্সেলের সমান variance পায়, আর প্রথম PC-গুলো সেই কোলাহলেও টানে। ২০টিতে উল্টো (০.৯৪২ বনাম ০.৯২৯)। নিয়মটি তাই “একক আলাদা হলে scale করুন”; একই এককে দুটোই validation-এ চেষ্টা করুন। (২০ PC-তে ডেস্কটপে ০.৯২৯, ব্রাউজারে ০.৯৩১: version-এর সামান্য সংখ্যাগত পার্থক্য, একটি ছবির।)

t-SNE আর UMAP — শুধু পরিচয়, সতর্কতাসহ

PCA একটি linear পদ্ধতি: নতুন অক্ষ পুরোনো কলামের ওজনযুক্ত যোগ। বাঁকা গঠন (যেমন অংশ ৬-এর চাঁদ) এটি খুলে দেখাতে পারে না। ছবি আঁকার জন্য দুটি nonlinear পদ্ধতি জনপ্রিয়: t-SNE (Laurens van der Maaten আর Geoffrey Hinton, ২০০৮) আর UMAP (Leland McInnes ও সহকর্মীরা, ২০১৮)। দুটিই কাছের বিন্দুকে কাছে রাখতে চায়, আর প্রায়ই PCA-র চেয়ে পরিষ্কার দল দেখায়। কিন্তু:

  • দলের মাঝের দূরত্ব অর্থহীন হতে পারে: t-SNE ছবিতে দুটি দল দূরে আছে মানে আসল ডেটায় তারা বেশি আলাদা, এমন বলা যায় না। দলের ছড়ানোও ঘনত্বের কথা বলে না।
  • Parameter আর seed-এ ছবি বদলায়: perplexity, n_neighbors বা seed বদলালে ছবি অনেক বদলায়; এলোমেলো ডেটাতেও দল দেখা যেতে পারে।
  • Pipeline-এ নয়: scikit-learn-এর TSNE-এ transform নেই, তাই নতুন বিন্দু বসানো যায় না; UMAP আলাদা package (umap-learn).

আরও গভীরে: Eckart–Young (১৯৩৬) উপপাদ্য বলে, SVD-র প্রথম q টি অংশ রেখে বানানো matrix-ই সব rank-q matrix-এর মধ্যে মূলের সবচেয়ে কাছের (Frobenius norm-এ); PCA-র “সবচেয়ে কম reconstruction error” দাবির ভিত্তি এটিই। বড় ডেটায় পুরো SVD-র বদলে randomized SVD (Halko, Martinsson ও Tropp, ২০১১) চলে, scikit-learn-এ svd_solver="randomized". k-means-এর উদ্দেশ্য সাধারণ মাত্রায় ঠিকঠাক কমানো NP-hard বলে প্রমাণিত (২০০৯ সালের দিকের কয়েকটি কাজে), তাই Lloyd-এর loop একটি heuristic; আর অনেক নমুনায় দ্রুত চালাতে mini-batch k-means (Sculley, ২০১০) ব্যবহৃত হয়। ঘনত্বের পার্থক্য সামলাতে DBSCAN-এর উত্তরসূরি HDBSCAN (Campello, Moulavi ও Sander, ২০১৩) scikit-learn 1.3 থেকে sklearn.cluster.HDBSCAN নামে আছে।

১০ · AI-এর যুগে — AI assistant-এর লেখা clustering আর PCA কোড যাচাই

“গ্রাহকদের segment করে দিন” বললে AI assistant কোড, ছবি আর দলের সুন্দর নাম দেয়। Unsupervised কাজে সঠিক উত্তর নেই, তাই ভুল কোডও বিশ্বাসযোগ্য দল দেখায়।

AI যা প্রায়ই করেকীভাবে ধরবেন
Scaling ছাড়া k-means বা PCA চালায়; টাকার কলাম একাই দল ঠিক করেকাঁচা কলামের σ ছাপুন; PC1-এর ওজন আর explained ratio দেখুন (৯৯% একটিতে মানে সন্দেহ)
KMeans() default দিয়ে একবার চালিয়ে ফল চূড়ান্ত ধরে; পুরোনো n_init=10 default-এর কথা লেখেনিজের version-এর docs দেখুন; n_init স্পষ্ট দিন, কয়েকটি seed-এ ARI দিয়ে স্থিরতা মাপুন
বানানো parameter বা ভুল নাম দেয় (যেমন KMeans(k=4), PCA(variance=0.95))আসল নাম n_clusters, n_components; চালিয়ে দেখুন, TypeError লুকাবেন না
silhouette বা inertia দেখিয়ে বলে “৪টি দলই সত্যিকারের”Cluster ground truth নয়; অন্য নমুনায় স্থিরতা আর ব্যবসায় উপকার আলাদা করে মাপুন
পুরো ডেটায় PCA fit করে তারপর train/test ভাগ করে classifier-এর accuracy রিপোর্ট করেআগে ভাগ, তারপর Pipeline-এ PCA; শুধু train-এ fit

নির্দিষ্ট করে চান, আর এমন আউটপুট চান যা নিজে যাচাই করা যায়। আসল গ্রাহকের নাম, ফোন নম্বর বা কেনাকাটার ইতিহাস কখনো পেস্ট করবেন না; কাল্পনিক বা বেনামি সারাংশ দিন।

আমার কাছে একটি NumPy array X আছে, shape (n, 3): recency (দিন), frequency (বছরে অর্ডার), monetary (বছরে টাকা)। Python 3.14, NumPy 2.4 আর scikit-learn 1.8 দিয়ে কোড লেখো যা: ১. প্রতিটি কলামের গড় আর standard deviation ছাপাবে, তারপর StandardScaler দিয়ে scale করবে, আর কেন scale করলে, comment-এ বলবে। ২. k = 2 থেকে 8-এ KMeans(n_clusters=k, n_init=10, random_state=0) চালিয়ে inertia আর silhouette_score-এর টেবিল দেবে। ৩. বাছা k-তে পাঁচটি আলাদা random_state-এ চালিয়ে জোড়ায় জোড়ায় adjusted_rand_score ছাপাবে, যাতে দলগুলো স্থির কি না দেখা যায়। ৪. প্রতিটি দলের আকার, আর আসল এককে গড় ও median দেবে; দলের নাম প্রস্তাব করলে বলবে কোন সংখ্যা দেখে, আর নামগুলো যে অনুমান তা লিখবে। ৫. কোনো deprecated বা বানানো parameter ব্যবহার করবে না; ব্যবহার করা প্রতিটি parameter-এর নাম scikit-learn 1.8-এর docs-এ আছে কি না বলবে।

AI-এর লেখা clustering বা PCA হাতে পেলে — যাচাইয়ের চেকলিস্ট

পরীক্ষা ও ইন্টারভিউতে:

  • k-means-এর দুই ধাপ আর কেন থামে? Assign (সবচেয়ে কাছের কেন্দ্র) আর update (দলের গড়); দুই ধাপেই inertia কমে বা সমান থাকে আর ভাগের সংখ্যা সসীম, তাই থামে, তবে local minimum-এ।
  • k-means++ কী সমাধান করে? শুরুর কেন্দ্রগুলো D(x)²-এর অনুপাতে বেছে ছড়িয়ে দেয়, খারাপ শুরু কমায়; তবু restart লাগে।
  • Silhouette-এর সূত্র? s = (b − a)/max(a, b); a নিজের দলের গড় দূরত্ব, b সবচেয়ে কাছের অন্য দলের; −১ থেকে ১.
  • k-means কোথায় ব্যর্থ? বাঁকা, লম্বাটে বা খুব আলাদা আকার ও ঘনত্বের দলে, আর outlier-এ; DBSCAN বা single linkage বিকল্প।
  • PCA-র আগে কেন centre আর (প্রায়ই) scale? Centre না করলে দিক গড়ের দিকে টানে; একক আলাদা হলে বড় সংখ্যার কলাম variance-এ একাই জেতে।

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন, তারপর “→ উত্তর” চাপুন।

প্র ০১k-means প্রায় সত্তর বছরের পুরোনো, আর এর ফল শুরুর ওপর নির্ভর করে। তবু এটি আজও সবচেয়ে বেশি ব্যবহৃত clustering পদ্ধতিগুলোর একটি কেন?

ইতিহাসটাই উত্তরের অর্ধেক। Stuart Lloyd ১৯৫৭ সালে Bell Labs-এ কাজটি করেছিলেন একটি প্রকৌশল সমস্যার জন্য: analog signal-কে কয়েকটি নির্দিষ্ট মানে (quantization level) নামাতে হবে, যাতে গড় বর্গ ভুল সবচেয়ে কম হয়। তাঁর প্রতিবেদন প্রকাশিত হয় ১৯৮২ সালে, ততদিনে MacQueen ১৯৬৭ সালে “k-means” নাম দিয়ে ফেলেছেন, আর অন্যরাও কাছাকাছি পদ্ধতি লিখেছেন। একাধিক ক্ষেত্রে স্বাধীনভাবে একই পদ্ধতি বেরিয়ে আসা বলে, ধারণাটি স্বাভাবিক: “প্রতিটি দলকে তার গড় দিয়ে প্রতিনিধিত্ব করা”।

টিকে থাকার কারণ কয়েকটি। এক, সরলতা: দুটি ধাপ, পাঁচ লাইনের কোড, আর ফল ব্যাখ্যা করা সহজ (প্রতিটি দলের একটি “গড় গ্রাহক”)। দুই, গতি: প্রতি iteration O(n·k·d), আর সাধারণত অল্প কয়েকটি iteration (আমাদের ডেটায় ৪টি)। তিন, ব্যবহারযোগ্য গুণ: নতুন গ্রাহক এলে সবচেয়ে কাছের কেন্দ্রে দেওয়া যায় (predict), যা DBSCAN বা agglomerative-এ সরাসরি নেই। চার, vector quantization বা ছবির রং কমানোর মতো কাজেও একই ধারণা।

দুর্বলতাগুলোও সুপরিচিত, আর প্রতিটির একটি পরিচিত প্রতিকার আছে। শুরুর ওপর নির্ভরতা কমাতে k-means++ আর restart; এখানে ১০টি restart-এর সেরাটি প্রতিবার একই inertia পেয়েছে। সেরা সমাধান খোঁজা সাধারণভাবে NP-hard বলে প্রমাণিত, তাই কেউ নিখুঁত উত্তরের দাবি করে না; লক্ষ্য “যথেষ্ট ভালো আর স্থির”। আকৃতির অনুমান (গোল দল) না খাটলে অন্য পদ্ধতি।

মূল উপলব্ধি: একটি পুরোনো, সরল, দ্রুত আর ব্যাখ্যাযোগ্য পদ্ধতি, যার দুর্বলতা পরিচিত ও মাপা যায়, বাস্তবে প্রায়ই নতুন জটিল পদ্ধতির চেয়ে বেশি কাজে লাগে; শর্ত হলো তার অনুমান ডেটায় খাটে কি না যাচাই করা।

প্র ০২scikit-learn 1.4-এ KMeans-এর n_init-এর default ১০ থেকে বদলে “auto” হয়েছে, যা k-means++-এর সঙ্গে একটিই run চালায়। এই সিদ্ধান্তের পক্ষে-বিপক্ষে যুক্তি কী, আর আপনি কী করবেন?

পক্ষের যুক্তি মূলত খরচ আর k-means++-এর মান। Arthur আর Vassilvitskii ২০০৭ সালে দেখান, k-means++ শুরু থেকেই প্রত্যাশিত inertia সেরার O(log k) গুণের মধ্যে থাকে; scikit-learn-এর “greedy” রূপ প্রতিটি কেন্দ্রের জন্য কয়েকটি প্রার্থী পরীক্ষা করে আরও ভালো শুরু দেয়। বড় ডেটায় ১০টি পূর্ণ run মানে দশ গুণ সময়; অনেক ব্যবহারকারী জানতেনই না যে প্রতিবার দশবার চলছে।

বিপক্ষের যুক্তি আমাদের নিজের ডেটায় দেখা গেছে। অংশ ৩-এ সরল k-means++-এর ১০টি run-এর ৪টি খারাপ local minimum-এ (inertia ২২৬ থেকে ২৩১, সেরা ৮৮.৮) থেমেছে। scikit-learn-এর greedy রূপে এই হার হয়তো কম, কিন্তু শূন্য নয়, আর ডেটা যত জটিল (অনেক দল, কাছাকাছি দল), ঝুঁকি তত বেশি। খারাপ run-এ কোনো error বা warning আসে না; শুধু দুটি দল চুপচাপ মিশে থাকে।

default গতির জন্য না নিরাপত্তার জন্য, এর কোনো সর্বজনীন উত্তর নেই; তাই গুরুত্বপূর্ণ parameter default-এ ছেড়ে না দেওয়াই ভালো অভ্যাস। আমি n_init স্পষ্ট লিখব (যেমন ১০ বা ২০), random_state দিয়ে ফল পুনরুৎপাদনযোগ্য রাখব, আর কয়েকটি seed-এ চালিয়ে দলের মিল (ARI) দেখব। প্রকল্পের নথিতে library-র version লিখে রাখব, কারণ একই কোড অন্য version-এ ভিন্ন default পেতে পারে (পর্ব ১২-এর lockfile). AI assistant-এর লেখা কোড প্রায়ই পুরোনো documentation-এর ভিত্তিতে “default n_init=10” ধরে নেয়, যা এখন সত্য নয়।

মূল উপলব্ধি: library-র default একটি নকশার আপস, আর তা version-এ বদলায়; ফলের ওপর বড় প্রভাব ফেলে এমন parameter নিজে বলে দিন, আর ফল স্থির কি না মেপে দেখুন।

প্র ০৩PCA বেশি variance-এর দিক রাখে। কিন্তু বেশি variance কি সবসময় বেশি দরকারি? কোন পরিস্থিতিতে PCA classification-এর জন্য দরকারি তথ্যই ফেলে দেয়?

PCA একটি unsupervised পদ্ধতি: label দেখে না। Pearson (১৯০১) আর Hotelling (১৯৩৩)-এর লক্ষ্য ছিল ডেটার বিশ্বস্ত সংক্ষিপ্ত বর্ণনা, শ্রেণি আলাদা করা নয়। “Variance মানে তথ্য” কথাটি তাই একটি অনুমান: যে দিকে বিন্দুগুলো বেশি আলাদা, সেদিকে বলার মতো বেশি কিছু আছে। নির্দিষ্ট প্রশ্নের উত্তরের জন্য এটি নিশ্চিত নয়।

একটি সরল পাল্টা উদাহরণ ভাবুন। দুটি শ্রেণি, দুটি লম্বা সমান্তরাল ফিতার মতো, পাশাপাশি; ফিতা বরাবর ছড়ানো বিশাল, আর দুই ফিতার মাঝের ফাঁক ছোট। PC1 হবে ফিতা বরাবর, কারণ সেদিকেই variance বেশি; কিন্তু শ্রেণি আলাদা করার দিক হলো আড়াআড়ি, যা ছোট variance-এর PC2. শুধু PC1 রাখলে দুই শ্রেণি একেবারে মিশে যায়। Ronald Fisher-এর ১৯৩৬ সালের linear discriminant analysis (LDA) ঠিক এই কারণে label ব্যবহার করে এমন দিক খোঁজে যেখানে শ্রেণির গড় দূরে আর শ্রেণির ভেতরের ছড়ানো কম।

আমাদের ফলেও এর ছায়া আছে। digits-এ প্রথম দুই PC মাত্র ২৮.৪% variance আর প্রায় ০.৬ accuracy দিয়েছে; logistic regression-এর accuracy পুরো ৬৪ কলামে সবচেয়ে বেশি ছিল, PCA-তে কমেছে। আর কোন variance “বড়”, তা এককের ওপর নির্ভর করে, তাই PCA-র “তথ্য” ধারণাটি আমাদের মাপার পছন্দের ওপরই দাঁড়িয়ে।

তাই ব্যবহারের নিয়ম: PCA-কে feature বাছাইয়ের বিকল্প নয়, একটি hyperparameter-যুক্ত preprocessing ধাপ হিসেবে দেখুন। কত PC, scale হবে কি না, PCA আদৌ লাগবে কি না, এসব validation বা cross-validation দিয়ে বাছুন (পর্ব ৬৬), PCA ছাড়া একটি baseline-এর পাশে।

মূল উপলব্ধি: PCA ডেটার আকৃতি বর্ণনা করে, প্রশ্নের উত্তর নয়; বেশি variance-এর দিক আর লক্ষ্যের জন্য দরকারি দিক এক না-ও হতে পারে, তাই PCA-র মূল্য মাপতে হয় নিচের কাজের held-out ফল দিয়ে।

প্র ০৪বাজারঘর clustering দিয়ে গ্রাহকদের দলে ভাগ করে আলাদা দাম, ছাড় বা সেবার অগ্রাধিকার দিতে চায়। দলগুলোকে “সত্যিকারের গ্রাহক ধরন” ভাবলে কোন ঝুঁকি আসে, আর সৎ নকশা কেমন হবে?

প্রথম ঝুঁকি দলের অস্তিত্ব নিয়েই। Jon Kleinberg ২০০২ সালে একটি “impossibility theorem” দেন: scale বদলালে ফল না বদলানো, যেকোনো ভাগ সম্ভব হওয়া, আর দূরত্ব একটু টানলে-ঠেললে ফল স্থির থাকা, এই তিনটি স্বাভাবিক চাওয়া কোনো clustering পদ্ধতি একসঙ্গে মানতে পারে না। অর্থাৎ প্রতিটি পদ্ধতি কিছু একটা ধরে নেয়, আর ফল সেই ধরে নেওয়ার প্রতিফলন। আমাদের ডেটায় চারটি দল আমরাই বানিয়েছিলাম, তবু ফল হুবহু মেলেনি (ARI ০.৮৫১); বাস্তবে কোনো “বানানো সত্য” নেই, শুধু একটি দরকারি সরলীকরণ।

দ্বিতীয় ঝুঁকি নামের শক্তি। একবার একটি দলের নাম “কম মূল্যের গ্রাহক” হলে, সেই দলের মানুষ ধীর সেবা পেতে পারেন; তাঁরা আরও কম কেনেন; পরের মাসের ডেটায় দলটি আরও “কম মূল্যের” দেখায়। এটি একটি স্বয়ংক্রিয় ফিডব্যাক চক্র। তার ওপর feature-গুলো এলাকা বা আয়ের মতো সংবেদনশীল বৈশিষ্ট্যের সঙ্গে জড়িয়ে থাকতে পারে, ফলে দল অজান্তেই কোনো জনগোষ্ঠীকে আলাদা করে। বাংলাদেশে অনলাইন কেনাকাটায় এলাকাভেদে ডেলিভারি আর পেমেন্টের সুবিধা আলাদা হতে পারে, এমন সাধারণ পর্যবেক্ষণ থেকেই এই সতর্কতা; এখানে কোনো নির্দিষ্ট পরিসংখ্যান দাবি করা হচ্ছে না।

সৎ নকশার কয়েকটি ধাপ। দলকে লক্ষ্য নয়, প্রস্তাব হিসেবে ব্যবহার: “এই দলের জন্য এই বার্তা চেষ্টা করি”, তারপর A/B test দিয়ে ফল মাপা। দলের স্থিরতা যাচাই (অন্য seed আর মাসে ARI). নামে বর্ণনা, বিচার নয় (“৯০ দিনের বেশি কেনেননি”, “অলস” নয়)। দাম বা মৌলিক সেবায় বৈষম্য না করা, আর দলভেদে ফল (যেমন ফেরত আসার হার, অভিযোগ) আলাদা করে দেখা। দায়িত্বশীল AI-এর অভ্যাস নিয়ে বিস্তারিত পর্ব ৭২-এ।

মূল উপলব্ধি: clustering মানুষকে আবিষ্কার করে না, একটি মাপের ভিত্তিতে সাজায়; দলের নাম আর তার ব্যবহার মানুষের সিদ্ধান্ত, যার ফল মাপতে আর জবাবদিহি করতে হয়।

প্র ০৫DBSCAN-এর eps আর min_samples label ছাড়া কীভাবে বাছবেন? আর যে ডেটায় এক দল খুব ঘন, আরেক দল পাতলা, সেখানে DBSCAN কেন বিপদে পড়ে?

মূল ১৯৯৬ সালের প্রবন্ধেই একটি পথ আছে, যা আজও প্রচলিত: k-distance plot. প্রতিটি বিন্দুর k-তম সবচেয়ে কাছের প্রতিবেশীর দূরত্ব বের করুন (k = min_samples − ১ বা কাছাকাছি), সেগুলো বড় থেকে ছোট সাজিয়ে আঁকুন। ঘন দলের ভেতরের বিন্দুদের এই দূরত্ব ছোট, noise-এর বড়; রেখায় যেখানে হঠাৎ বাঁক, সেখানকার দূরত্ব eps-এর একটি যুক্তিসংগত মান। min_samples-এর প্রচলিত শুরু মাত্রা সংখ্যার দ্বিগুণের কাছাকাছি; বেশি হলে বেশি noise, কম হলে ছোট ছোট দল। এই প্রবন্ধটি পরে KDD-র test of time পুরস্কার পায় বলে উল্লেখ করা হয়, যা এর প্রভাবের একটি চিহ্ন।

আমাদের চাঁদের ডেটায় eps = ০.২ কাজ করেছে, কিন্তু মনে রাখুন আমরা দল জেনে মিলিয়েছি। ডেস্কটপে একই চাঁদে (noise ০.১) eps ০.১৫ আর min_samples ৮ দিলে ৫টি দল আর ৩৫টি noise এসেছিল, ARI নেমে ০.৪০৬; eps ০.২ আর min_samples ৫-এ আবার ১.০. অর্থাৎ ফল parameter-এ সংবেদনশীল। তাই কয়েকটি মানে চালিয়ে দলের সংখ্যা, noise-এর ভাগ আর দলের স্থিরতা দেখা জরুরি।

ভিন্ন ঘনত্বের সমস্যাটি মৌলিক। eps একটিমাত্র দূরত্ব: ঘন দলের জন্য ছোট eps দরকার, নইলে কাছের দুটি ঘন দল জুড়ে যায়; পাতলা দলের জন্য বড় eps, নইলে পুরো দলটাই noise. দুটো একসঙ্গে মেটে না। সমাধানের চেষ্টা OPTICS (Ankerst ও সহকর্মীরা, ১৯৯৯) আর HDBSCAN (২০১৩): সব eps একসঙ্গে বিবেচনা করে ঘনত্বের একটি গাছ থেকে স্থায়ী দল বাছা। scikit-learn-এ দুটিই আছে (HDBSCAN যোগ হয়েছে 1.3 version-এ)। অনেক কলামে “ঘনত্ব” নিজেই ঝাপসা (পর্ব ৩৩), তাই আগে PCA, তারপর DBSCAN একটি প্রচলিত কৌশল।

মূল উপলব্ধি: DBSCAN-এর শক্তি (আকৃতিহীন দল, noise) আসে একটি ঘনত্বের মাপ থেকে, আর দুর্বলতাও সেখানে; eps বাছুন k-distance plot আর কয়েকটি মানের তুলনায়, আর ঘনত্ব বেশি আলাদা হলে HDBSCAN ভাবুন।

অনুশীলন

হাতে হিসাব, কোড, সমালোচনা আর আউটপুট অনুমান; প্রতিটির উত্তর নিচে লুকানো আছে।

  1. হাতে k-means আর silhouette (হিসাব): সাতজন কাল্পনিক গ্রাহকের মাসিক অর্ডার: 2, 4, 5, 10, 11, 12, 20. k = ২, শুরুর কেন্দ্র c1 = 2, c2 = 12. (ক) প্রথম assign-এর দল আর inertia; (খ) update-এর পর কেন্দ্র; (গ) আবার assign-এ কিছু বদলায় কি না, আর শেষ inertia; (ঘ) শেষ ভাগে 10-এর silhouette মান।
    • (ক) 2, 4, 5 → c1 (যেমন 5: |5 − 2| = 3 বনাম 7). 10, 11, 12, 20 → c2 (10: 8 বনাম 2). Inertia = 0² + 2² + 3² + 2² + 1² + 0² + 8² = 0 + 4 + 9 + 4 + 1 + 0 + 64 = ৮২.
    • (খ) c1 = (2 + 4 + 5)/3 = 11/3 ≈ ৩.৬৭; c2 = (10 + 11 + 12 + 20)/4 = 53/4 = ১৩.২৫.
    • (গ) সীমানার বিন্দু 5: 1.33 বনাম 8.25, c1-ই; 10: 6.33 বনাম 3.25, c2-ই। কোনো বদল নেই, তাই থামা। শেষ inertia = (1.67² + 0.33² + 1.33²) + (3.25² + 2.25² + 1.25² + 6.75²) = 4.667 + 62.75 = ৬৭.৪২ (দুই দশমিকে)।
    • (ঘ) 10-এর নিজের দলের অন্যদের সঙ্গে গড় দূরত্ব a = (1 + 2 + 10)/3 = 4.333; অন্য দলের সঙ্গে b = (8 + 6 + 5)/3 = 6.333. s = (6.333 − 4.333)/6.333 ≈ ০.৩১৬. ইতিবাচক, কিন্তু ছোট: 10 দুই দলের সীমানার কাছে, আর 20-এর মতো দূরের বিন্দু নিজের দলের a বাড়িয়ে দিয়েছে।

    scikit-learn-এর silhouette_samples দিয়ে ডেস্কটপে মেলানো (সত্যিকারের run): সাতটি মান [0.778 0.838 0.758 0.316 0.5 0.56 0.449], চতুর্থটি 10-এর।

  2. কত PC লাগবে (কোড): একটি ফাংশন q_for(X, target) লিখুন, যা centred ডেটার singular value থেকে সবচেয়ে ছোট q ফেরত দেয় যাতে cumulative explained variance অন্তত target হয়। load_digits-এ ০.৮০, ০.৯০, ০.৯৫ আর ০.৯৯-এর জন্য চালিয়ে PCA(n_components=target)-এর n_components_-এর সঙ্গে মেলান।
    import numpy as np
    from sklearn.datasets import load_digits
    from sklearn.decomposition import PCA
    
    def q_for(X, target):
        Xc = X - X.mean(0)
        S = np.linalg.svd(Xc, compute_uv=False)        # শুধু singular value
        cum = np.cumsum(S**2) / (S**2).sum()
        return int(np.searchsorted(cum, target)) + 1, cum
    
    X, _ = load_digits(return_X_y=True)
    for target in (0.80, 0.90, 0.95, 0.99):
        q, cum = q_for(X, target)
        sk = PCA(n_components=target).fit(X).n_components_
        print(f"{target:.2f}: নিজের q = {q:2d} | scikit-learn = {sk:2d} | ধরে রাখা {cum[q-1]:.3f}")

    সত্যিকারের run (ডেস্কটপ, Python 3.14.4, NumPy 2.5.4, scikit-learn 1.9.1):

    0.80: নিজের q = 13 | scikit-learn = 13 | ধরে রাখা 0.803
    0.90: নিজের q = 21 | scikit-learn = 21 | ধরে রাখা 0.903
    0.95: নিজের q = 29 | scikit-learn = 29 | ধরে রাখা 0.955
    0.99: নিজের q = 41 | scikit-learn = 41 | ধরে রাখা 0.990

    চারটিতেই মিল। variance-এর অনুপাতের জন্য n − ১ দিয়ে ভাগ লাগে না, কারণ লব আর হর দুই জায়গাতেই কেটে যায়। np.searchsorted(cum, target) প্রথম যে index-এ cum ≥ target, তা দেয়; index ০ থেকে শুরু বলে + ১. ৯৫% থেকে ৯৯%-এ যেতে আরও ১২টি PC লাগে: scree-র লম্বা লেজ।

  3. সমালোচনা ও নতুন নকশা: একজন সহকর্মী লিখেছেন: “বাজারঘরের ৫০ হাজার গ্রাহকের ১২টি কলামে (বয়স, এলাকা কোড, মাসিক খরচ টাকায়, অর্ডার সংখ্যা …) সরাসরি KMeans(n_clusters=6) চালিয়েছি, একবার। silhouette ০.৭১, তাই দলগুলো আসল। দল ৫-এর নাম দিয়েছি ‘অলাভজনক গ্রাহক’, তাঁদের ডেলিভারি অগ্রাধিকার কমানো হবে। ছবির জন্য t-SNE-তে দেখেছি দল ২ আর ৩ খুব দূরে, তাই ওদের জন্য একেবারে আলাদা ক্যাম্পেইন।” কী কী ভুল, আর আপনি কীভাবে সাজাবেন?
    • Scaling নেই: টাকার কলাম একাই দূরত্ব ঠিক করছে; উচ্চ silhouette হয়তো শুধু খরচের ধাপ ভাগ হওয়ার ফল। আগে standardize (বা টাকায় log), তারপর PC1-এর ওজন আর দলের গড় দেখুন।
    • এলাকা কোড সংখ্যা হিসেবে: কোড ১০ আর ১১ “কাছে” নয়; category-কে সংখ্যার দূরত্বে দেওয়া অর্থহীন (encoding পর্ব ৬৩-এ)।
    • একবার run, স্থিরতা অজানা: n_init স্পষ্ট দিন, কয়েকটি seed আর নমুনায় ARI মাপুন; k-ও elbow, silhouette আর ব্যবসার প্রয়োজন মিলিয়ে বাছুন।
    • “silhouette উঁচু, তাই আসল”: cluster ground truth নয়; silhouette শুধু বলে দলগুলো জ্যামিতিকভাবে কতটা আলাদা।
    • বিচারমূলক নাম আর ক্ষতিকর ব্যবহার: ‘অলাভজনক’ নাম, আর তার ভিত্তিতে সেবা কমানো একটি ফিডব্যাক চক্র তৈরি করে, এলাকার মাধ্যমে বৈষম্যও হতে পারে। বর্ণনামূলক নাম, আর যেকোনো নীতি A/B test আর দলভেদে ফল দেখে।
    • t-SNE-র দূরত্ব: t-SNE ছবিতে দলের মাঝের দূরত্ব অর্থবহ নয়; দলের কেন্দ্রের দূরত্ব আসল (scaled) স্থানে মাপুন।
  4. আউটপুট অনুমান: নিচের কোড কী ছাপবে? প্রথম লাইনের ফল কেন এমন?
    import numpy as np
    X = np.array([[1, 2], [2, 4], [3, 6], [4, 8]], float)
    Xc = X - X.mean(0)
    S = np.linalg.svd(Xc, compute_uv=False)
    print((S**2 / (S**2).sum()).round(3))
    print(np.allclose(Xc[:, 1], 2 * Xc[:, 0]))
    lab = np.array([0, 0, 1, 1])
    C = np.array([X[lab == j].mean(0) for j in (0, 1)])
    print(C)
    print(((X - C[lab])**2).sum())

    সত্যিকারের run (ডেস্কটপ, Python 3.14.4, NumPy 2.5.4):

    [1. 0.]
    True
    [[1.5 3. ]
     [3.5 7. ]]
    5.0

    দ্বিতীয় কলাম সবসময় প্রথমটির দ্বিগুণ, তাই চারটি বিন্দুই একটি সরলরেখায়; centred ডেটাতেও y′ = 2x′ (দ্বিতীয় লাইন True). সব variance একটি দিকে (১, ২)/√৫, তাই explained ratio [1. 0.]: দ্বিতীয় singular value শূন্য (বা rounding-এ প্রায় শূন্য)। দুটি কলাম আসলে একটি তথ্যই বহন করছিল। তৃতীয় লাইন দুই দলের গড়: (১.৫, ৩) আর (৩.৫, ৭)। শেষ লাইন inertia: প্রতিটি বিন্দু তার কেন্দ্র থেকে (০.৫, ১) সরে, বর্গ দূরত্ব ০.২৫ + ১ = ১.২৫, চারটিতে ৫.০.

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমk-means দিয়ে গ্রাহক segmentation-এর ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. প্রশ্ন ঠিক করে feature বাছা, যেমন recency, frequency আর monetary
  2. কলামগুলো standardize করা, যাতে টাকার কলাম একাই দূরত্ব ঠিক না করে
  3. কয়েকটি k-তে k-means++ আর কয়েকটি restart দিয়ে চালানো
  4. Elbow আর silhouette দেখে k বাছা
  5. অন্য seed আর নমুনায় চালিয়ে দলের স্থিরতা মাপা
  6. দলের গড় আর নমুনা সারি পড়ে বর্ণনামূলক নাম দেওয়া, তারপর ব্যবহার পরীক্ষা করে ফল মাপা

এক মাত্রার চারটি বিন্দু 0, 2, 10, 12; k = 2, শুরুর কেন্দ্র c1 = 0, c2 = 2. k-means-এর প্রথম assign আর update-এর পর কেন্দ্র দুটি কী?

  • c1 = 1, c2 = 11
  • c1 = 0, c2 = 2
  • c1 = 0, c2 = 8
  • c1 = 0, c2 = 11

একটি বিন্দুর নিজের দলের অন্য বিন্দুগুলোর সঙ্গে গড় দূরত্ব a = 2, আর সবচেয়ে কাছের অন্য দলের সঙ্গে গড় দূরত্ব b = 6. তার silhouette মান কত?

  • 4
  • ≈ 0.667
  • ≈ 0.333
  • −0.667

নিচের কোন ডেটায় k-means (Euclidean দূরত্ব, সঠিক k দিয়ে) সবচেয়ে বেশি ব্যর্থ হবে বলে আশা করা যায়?

  • তিনটি গোল, সমান আকারের, দূরে দূরে থাকা দল
  • দুটি গোল দল, একটু কাছাকাছি কিন্তু আলাদা
  • চারটি গোল দল, কলামগুলো standardize করার পর
  • একটি ছোট বৃত্তের চারপাশ ঘিরে আরেকটি বড় বৃত্ত (nested circles)

একটি ডেটার covariance matrix-এর eigenvalue 6, 3 আর 1. প্রথম দুটি principal component মিলে মোট variance-এর কত ভাগ ব্যাখ্যা করে?

  • 90%
  • 60%
  • 75%
  • 100%

scikit-learn-এর DBSCAN-এর fit_predict কোনো বিন্দুর জন্য −1 ফেরত দিলে তার অর্থ কী?

  • বিন্দুটি শেষ দলের সদস্য
  • বিন্দুটি একটি core point
  • বিন্দুটি noise, কোনো দলে পড়েনি
  • হিসাবের সময় ভুল হয়েছে

গ্রাহকের তিনটি কলাম: recency (0–365 দিন), frequency (1–30 অর্ডার) আর monetary (500–40000 টাকা)। scaling ছাড়া PCA চালালে প্রথম PC কেমন হবে?

  • তিন কলামের প্রায় সমান ওজনের মিশ্রণ
  • প্রায় পুরোপুরি monetary কলামের দিক, explained ratio প্রায় 1
  • প্রায় পুরোপুরি frequency কলামের দিক
  • PCA error দেবে, কারণ scaling বাধ্যতামূলক

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন, অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. k-means: assign (সবচেয়ে কাছের কেন্দ্র) আর update (দলের গড়) পালাক্রমে; inertia প্রতিটি ধাপে কমে বা সমান থাকে, কিন্তু local minimum-এ থামতে পারে। হাতের উদাহরণে ৭২ → ২০.২৫ → ১১.৬৯ → ৬.০.
  2. শুরু আর scaling: আগে standardize; k-means++ দিয়ে ছড়ানো শুরু, তবু কয়েকটি restart (n_init স্পষ্ট)। RFM ডেটায় k-means++-এর ১০টির ৪টি run খারাপ ভাগে আটকেছিল।
  3. k বাছাই আর অর্থ: elbow আর silhouette (s = (b − a)/max(a, b)) একসঙ্গে; স্থিরতা অন্য seed-এ ARI দিয়ে। Cluster ground truth নয়; নাম বর্ণনামূলক, ব্যবহার পরীক্ষা করে।
  4. অন্য পদ্ধতি: k-means গোল, কাছাকাছি আকারের দল ধরে নেয়; বাঁকা চাঁদে ব্যর্থ। Agglomerative-এ dendrogram কেটে দল, linkage গাছ বদলায়; single linkage outlier-এ নাজুক। DBSCAN ঘনত্ব দিয়ে দল আর noise (−১) দেয়।
  5. PCA: centred ডেটার SVD; PC-র variance s²/(n − ১), explained ratio তার ভাগ, reconstruction error = ১ − রাখা ratio. একক আলাদা হলে আগে scale; PCA শুধু train-এ fit. t-SNE আর UMAP ছবির জন্য, দূরত্বের দাবির জন্য নয়।

পরের পর্বে: এখানে k, eps বা PC-র সংখ্যা আমরা কয়েকটি মান চালিয়ে বেছেছি। পর্ব ৬৬ (Model বাছাই — cross-validation আর hyperparameter search)-এ এই বাছাইয়ের নিয়মতান্ত্রিক পথ: cross-validation, GridSearchCV আর pipeline-এর ভেতরে PCA-র q-এর মতো parameter খোঁজা।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান