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

k-Nearest Neighbors — NumPy দিয়ে নিজে লেখা

k-Nearest Neighbors from Scratch with NumPy — Distance Matrices, Voting, Choosing k, Scaling, Weighted Votes and Regression

Taught in Bangla, this lesson builds a k-nearest neighbors classifier in NumPy: hand-worked votes, a vectorised distance matrix and its floating-point trap, argpartition and tie-breaking, choosing k on validation data, why scaling decides the answer, weighted voting and k-NN regression. The scratch code matches scikit-learn exactly in real runs, including on the wine dataset.

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

  • k-NN-এর ধারণা, lazy learning আর হাতে k = ১, ৩, ৫-এর ভোট
  • NumPy-তে test × train distance matrix, dot-product কৌশল আর তার rounding-এর ফাঁদ, argpartition আর ভোটের টাই ভাঙার নিয়ম
  • Validation ডেটা দিয়ে k বাছাই, decision boundary-র ছবি, scaling আর distance-weighted voting
  • k-NN regression, prediction-এর খরচ, KD-tree আর approximate nearest neighbour, অপ্রাসঙ্গিক feature-এর ক্ষতি

মডিউল ১০-এ যন্ত্র ডেটা থেকে শেখে। পর্ব ৩৩ (Vector আর similarity)-এ দূরত্বের তিন মাপ, scaling-এ সবচেয়ে কাছের প্রতিবেশী বদলে যাওয়া আর curse of dimensionality দেখেছেন; এখানে সেই দূরত্ব দিয়ে প্রথম পূর্ণাঙ্গ classifier বানাব। চালানো যায় এমন ব্লকগুলো পাতাতেই চলে (ব্রাউজারের Python, NumPy 2.4.6, scikit-learn 1.8.0). scikit-learn প্রথমবার নামতে প্রায় ১০ সেকেন্ড লাগে; পরের run দ্রুত।

১ · প্রতিবেশী দেখে চেনা — k-NN-এর ধারণা

বাজারঘর (এই কোর্সের কাল্পনিক অনলাইন দোকান) জানতে চায়: যে নতুন গ্রাহক এই মাসে প্রথম কিনলেন, তিনি কি আবার কিনবেন? যাঁরা আবার কিনবেন না বলে মনে হয়, তাঁদের একটি ছাড়ের কুপন পাঠানো যায়। প্রতিটি গ্রাহকের তিনটি feature: প্রথম মাসে কয়টি অর্ডার, গড় ঝুড়ির দাম (টাকা) আর গড়ে কত দিনে ডেলিভারি পেয়েছেন। Label দুটি: ১ মানে পরের তিন মাসে আবার কিনেছেন, ০ মানে কেনেননি। এটি একটি binary classification.

পাড়ায় নতুন একটি পরিবার এলে প্রতিবেশীরা কী করেন? “ওঁরা কোন বাড়ির পাশে উঠেছেন, কাদের সঙ্গে মেশেন” দেখে আন্দাজ করেন ওঁরা কেমন মানুষ। লোকে বলে, কার সঙ্গে কার ওঠাবসা, তা দেখেই মানুষ চেনা যায়। k-Nearest Neighbors (k-NN) ঠিক এই নিয়মে চলে: নতুন গ্রাহকের সঙ্গে সবচেয়ে মিল আছে এমন k জন পুরোনো গ্রাহক খুঁজুন, আর তাঁদের বেশিরভাগ যা করেছেন, নতুনজনও তা-ই করবেন বলে ধরুন।

নিয়মটি তিন লাইনে: (১) নতুন বিন্দু q থেকে প্রতিটি training বিন্দুর দূরত্ব মাপুন, সাধারণত Euclidean distance; (২) সবচেয়ে ছোট k-টি দূরত্বের বিন্দু বাছুন; (৩) তাদের label-এর majority vote দিন। Regression-এ ভোটের বদলে গড় (অংশ ৮)।

অন্য প্রায় সব model training-এর সময় কিছু সংখ্যা শেখে: পর্ব ৫১-এর Naive Bayes শব্দ গোনে, পরের পর্বের linear regression ওজন শিখবে। k-NN কিছুই শেখে না; শুধু পুরো training ডেটা মনে রাখে, আর আসল কাজ করে প্রশ্ন আসার পর। একে বলে lazy learning, বা instance-based learning. উল্টো দিকে যেসব model আগেই একটি সূত্র বানিয়ে ডেটা ফেলে দিতে পারে, তাদের বলে eager learner. k-NN আবার একটি nonparametric model: এর “আকার” আগে থেকে ঠিক করা কয়েকটি সংখ্যায় বাঁধা নয়, ডেটা যত বাড়ে model তত বড় হয়।

ধারণাটি সাধারণত Evelyn Fix আর Joseph Hodges-এর ১৯৫১ সালের একটি প্রতিবেদনের সঙ্গে যুক্ত করা হয়, যা মার্কিন বিমানবাহিনীর School of Aviation Medicine-এর জন্য লেখা হয়েছিল; সেখানে “nonparametric discrimination” নিয়ে প্রতিবেশীভিত্তিক নিয়মের consistency আলোচনা ছিল। ১৯৬৭ সালে Thomas Cover আর Peter Hart IEEE Transactions on Information Theory-তে “Nearest neighbor pattern classification” প্রকাশ করেন; সেখানে দেখানো হয় যে অসীম ডেটার সীমায় ১-NN-এর ভুলের হার সর্বোত্তম সম্ভব ভুলের হারের (Bayes error rate) দ্বিগুণের বেশি নয় (ভাবনার প্রশ্ন ১)। আরও আগের কাজেও প্রতিবেশী দেখে শ্রেণি বলার ধারণা পাওয়া যায় বলে কোনো কোনো ইতিহাসবিদ মনে করেন, তাই “আবিষ্কারক” একজনকে বলা কঠিন।

২ · হাতে k-NN — সাতজন গ্রাহক, k = ১, ৩ আর ৫

প্রথমে কাগজে-কলমে, যেমন বিশ্ববিদ্যালয়, চাকরি বা ইন্টারভিউর পরীক্ষায় আসে। শুধু দুটি feature নিই, যাদের মাপ কাছাকাছি: প্রথম মাসের অর্ডার আর গড় ডেলিভারি দিন। সাতজন চেনা গ্রাহক (কাল্পনিক):

গ্রাহকঅর্ডার (প্রথম মাস)গড় ডেলিভারি দিনআবার কিনেছেন? (y)
জনি030
শিলা721
রুমি431
মিতু460
তানহা311
পলাশ160
সাকিব240
নতুন গ্রাহক q33?

সাতজন গ্রাহক আর নতুন গ্রাহক q; বৃত্তগুলো k = ১, ৩, ৫-এর প্রতিবেশীর সীমা

Seven customers in (orders, delivery days) space; circles enclose the 1, 3 and 5 nearest neighbours of q

k-NN by hand: query q = (3, 3) and its 1, 3 and 5 nearest neighbours 01234567 প্রথম মাসের অর্ডার (orders) 6543210 ডেলিভারি দিন (days) k=1 k=3 k=5 q রুমি তানহা শিলা সাকিব জনি মিতু পলাশ

নীল বৃত্ত = আবার কিনেছেন (y = 1), লাল বর্গ = কেনেননি (y = 0). রঙের পাশাপাশি আকারেও আলাদা।

দূরত্ব d = √(Δঅর্ডার² + Δদিন²)q = (3, 3)

            
এই ধাপে
কাজ
সিদ্ধান্ত
  1. সব দূরত্ব। q = (৩, ৩) থেকে প্রতিটি গ্রাহকের Euclidean দূরত্ব, টেবিলের ক্রমে। জনির জন্য Δঅর্ডার = ৩, Δদিন = ০, তাই √৯ = ৩. বর্গমূল না নিলেও চলত, কারণ ক্রম একই থাকে।
  2. সাজানো। ছোট থেকে বড়: রুমি ১.০০০, সাকিব ১.৪১৪, তানহা ২.০০০, জনি ৩.০০০, মিতু ৩.১৬২, পলাশ ৩.৬০৬, শিলা ৪.১২৩. কোনো দুটি দূরত্ব সমান নয়, তাই কে আগে, তা নিয়ে প্রশ্ন নেই।
  3. k = ১. শুধু সবচেয়ে কাছের রুমি (y = ১), তাই উত্তর ১: আবার কিনবেন। একজন প্রতিবেশীর ওপর পুরো ভরসা।
  4. k = ৩. রুমি (১), সাকিব (০), তানহা (১): ভোট ২–১, উত্তর ১.
  5. k = ৫. আরও জনি (০) আর মিতু (০) যোগ হলেন: ভোট ২–৩, উত্তর এবার ০. দূরের দুজন কাছের দুজনকে হারিয়ে দিলেন; অংশ ৭-এ দূরত্ব দিয়ে ওজন দিলে এই ফল আবার বদলাবে।
  6. তুলনা। একই ডেটা, একই q, শুধু k আলাদা, তবু উত্তর ১, ১, ০. তাই k একটি গুরুত্বপূর্ণ hyperparameter, যা ডেটা থেকে শিখতে হয় না, বাছতে হয় (অংশ ৫)।

দুই শ্রেণির সমস্যায় k বিজোড় রাখলে ভোটে কখনো টাই হয় না; তিন বা বেশি শ্রেণিতে বিজোড় k-ও টাই ঠেকাতে পারে না (যেমন k = ৩-এ তিন শ্রেণির একটি করে ভোট)। টাই ভাঙার নিয়ম অংশ ৪-এ।

৩ · Distance matrix — broadcasting বনাম ‖a‖² + ‖b‖² − 2a·b

একটি q-এর জন্য হাতে সাতটি দূরত্ব সহজ। কিন্তু ১০০০টি নতুন গ্রাহক আর ৩০০০টি চেনা গ্রাহক হলে লাগবে ৩০ লাখ দূরত্ব। NumPy-তে loop না লিখে পুরো test × train distance matrix একবারে বানানো যায়, দুই পথে।

পথ ১, broadcasting: পর্ব ১৫-এর কৌশল। Query A-এর shape (m, d), train B-এর (n, d). A[:, None, :] হয় (m, 1, d), B[None, :, :] হয় (1, n, d); বিয়োগ করলে (m, n, d), বর্গ করে শেষ অক্ষে যোগ করলে (m, n). সমস্যা মেমরিতে: মাঝের অস্থায়ী array-টি m × n × d × ৮ বাইট; ১০০০ × ৩০০০ × ৩ × ৮ = ৭.২ কোটি বাইট, প্রায় ৭২ MB.

পথ ২, dot-product কৌশল: বর্গ দূরত্বকে খুলে লিখুন। (a − b)·(a − b) = a·a − 2a·b + b·b, অর্থাৎ

‖a − b‖² = ‖a‖² + ‖b‖² − 2 a·b

এখানে ‖a‖² মানে a-এর ঘরগুলোর বর্গের যোগফল, আর a·b হলো dot product (পর্ব ১৭)। সব জোড়ার a·b একসঙ্গে একটি matrix গুণ: A @ B.T, shape (m, d) @ (d, n) → (m, n). ‖a‖² একটি (m, 1) কলাম, ‖b‖² একটি (1, n) সারি, broadcasting-এ যোগ। কোনো (m, n, d) array তৈরি হয় না, আর matrix গুণ NumPy-র সবচেয়ে দ্রুত কাজগুলোর একটি।

দাম একটি: floating-point-এ বড় সংখ্যা থেকে প্রায় সমান বড় সংখ্যা বিয়োগ করলে অর্থবহ অঙ্ক হারিয়ে যায় (catastrophic cancellation). দুটি প্রায় একই গ্রাহকের সত্যিকারের বর্গ দূরত্ব হয়তো 10−12, অথচ ‖a‖² প্রায় 106 (টাকার কলামের জন্য); বিয়োগের পর যা থাকে তা মূলত rounding error, আর তা ঋণাত্মকও হতে পারে। ঋণাত্মক সংখ্যার বর্গমূল nan. নিচের ব্লক দুটি পথ মাপে, argsort আর argpartition তুলনা করে, তারপর ফাঁদটি দেখায়।

import time
import numpy as np
np.set_printoptions(precision=3)

rng = np.random.default_rng(42)
# কাল্পনিক গ্রাহক: [প্রথম মাসের অর্ডার, গড় ঝুড়ি (টাকা), গড় ডেলিভারি দিন]
def customers(n):
    return np.column_stack([rng.poisson(2.5, n), rng.normal(1200, 400, n),
                            rng.uniform(1, 7, n)])
Q, T = customers(1000), customers(3000)          # 1000টি query, 3000টি train

def d2_broadcast(A, B):                           # (m,1,3) − (1,n,3) → (m,n,3)
    return ((A[:, None, :] - B[None, :, :]) ** 2).sum(axis=-1)
def d2_trick(A, B):                               # ‖a‖² + ‖b‖² − 2a·b
    return (A**2).sum(1)[:, None] + (B**2).sum(1)[None, :] - 2 * A @ B.T

for f in (d2_broadcast, d2_trick):
    t0 = time.perf_counter(); D = f(Q, T); t = time.perf_counter() - t0
    print(f"{f.__name__:12} {D.shape}  {t*1000:6.1f} ms")
print("দুই পথ মেলে (allclose)?", np.allclose(d2_broadcast(Q, T), d2_trick(Q, T)))

t0 = time.perf_counter(); full = np.argsort(D, axis=1)[:, :5]; t1 = time.perf_counter()
part = np.argpartition(D, 4, axis=1)[:, :5]; t2 = time.perf_counter()
print(f"argsort {1000*(t1-t0):.1f} ms, argpartition {1000*(t2-t1):.1f} ms,",
      "একই ৫ প্রতিবেশী?", np.array_equal(np.sort(full, 1), np.sort(part, 1)))

near = T[:5] + 1e-6                               # প্রায় হুবহু একই ৫ জন গ্রাহক
print("broadcast:", d2_broadcast(near, T[:5]).diagonal())
bad = d2_trick(near, T[:5]).diagonal()
print("trick    :", bad)
print("sqrt     :", np.sqrt(bad))
print("ঠিক করা  :", np.sqrt(np.maximum(bad, 0)))  # 0-এর নিচে কেটে দেওয়া

আসল আউটপুট (ব্রাউজারের Python; সময় আপনার যন্ত্রে আলাদা হবে):

d2_broadcast (1000, 3000)   123.0 ms
d2_trick     (1000, 3000)    24.3 ms
দুই পথ মেলে (allclose)? True
argsort 226.3 ms, argpartition 43.3 ms, একই ৫ প্রতিবেশী? True
broadcast: [3.e-12 3.e-12 3.e-12 3.e-12 3.e-12]
trick    : [ 0.000e+00  0.000e+00  0.000e+00  0.000e+00 -9.313e-10]
sqrt     : [ 0.  0.  0.  0. nan]
ঠিক করা  : [0. 0. 0. 0. 0.]

দুই পথ একই (1000, 3000) matrix দেয় (allclose True). এই run-এ dot-product কৌশল প্রায় ৫ গুণ দ্রুত (১২৩ বনাম ২৪ ms), কারণ (1000, 3000, 3) অস্থায়ী array বানাতে হয়নি। ডেস্কটপে একই কোডে পার্থক্য অনেক কম ছিল (প্রায় ৬০ বনাম ৪৮ ms), কারণ d = ৩ খুব ছোট; d বাড়লে matrix গুণের সুবিধা বাড়ে। একবারের মাপ, তাই অনুপাতকে নিয়ম ভাববেন না। argpartition এখানে argsort-এর চেয়ে প্রায় ৫ গুণ দ্রুত (৪৩ বনাম ২২৬ ms), আর প্রতিটি সারিতে একই ৫ প্রতিবেশী দিয়েছে।

শেষ চার লাইনই আসল শিক্ষা। প্রতিটি “প্রায় একই” গ্রাহক তিন ঘরেই 10−6 সরে আছেন, তাই সত্যিকারের বর্গ দূরত্ব 3 × 10−12; broadcasting ঠিক তা-ই দিয়েছে। কৌশলটি চারটিতে দিয়েছে হুবহু 0 (তথ্য পুরো হারিয়েছে), আর পঞ্চমটিতে −9.313 × 10−10, সত্যিকারের মানের চেয়ে শতগুণ বড় আর ঋণাত্মক। কারণ ‖a‖² এখানে প্রায় 106-এর ঘরে (টাকার কলাম), আর float64-এর আপেক্ষিক নির্ভুলতা মোটামুটি 10−16, তাই বিয়োগের পর 10−10-এর কাছাকাছি rounding error থেকে যায়। ঋণাত্মক সংখ্যার np.sqrt হলো nan, আর ব্রাউজারের Python-এ কোনো warning ছাড়াই (WebAssembly-তে floating-point-এর সতর্কসংকেত নেই); ডেস্কটপে একই কোড RuntimeWarning: invalid value encountered in sqrt ছাপে (সত্যিকারের run). np.maximum(bad, 0) দিয়ে ঋণাত্মকগুলো 0-তে কাটলে সমস্যা মেটে। k-NN-এর ক্রমের জন্য এটুকু যথেষ্ট, কারণ খুব কাছের বিন্দু কাছেই থাকে; কিন্তু খুব ছোট দূরত্বের সঠিক মান দরকার হলে broadcasting বা সরাসরি বিয়োগই ভালো। scikit-learn-এর euclidean_distances-ও এই কৌশল ব্যবহার করে, আর তার documentation-এ এই নির্ভুলতার সীমা নিয়ে সতর্কতা আছে।

argsort না argpartition?

np.argsort পুরো সারি সাজায়: n টি দূরত্বে মোটামুটি O(n log n). কিন্তু আমাদের দরকার শুধু সবচেয়ে ছোট k-টি, তাদের ভেতরের ক্রমও নয়। np.argpartition(D, k - 1, axis=1) প্রতিটি সারিকে এমনভাবে ভাগ করে যে প্রথম k ঘরে সবচেয়ে ছোট k-টির index থাকে, গড়ে O(n) সময়ে (introselect, quickselect-এর একটি রূপ)। ভেতরের ক্রম নিশ্চিত নয়; ভোটের জন্য তা লাগেও না। দূরত্বের ক্রমে প্রতিবেশী দেখাতে চাইলে প্রথমে argpartition, তারপর শুধু সেই k-টিকে argsort.

৪ · নিজের হাতে k-NN classifier — আর scikit-learn-এর সঙ্গে মেলানো

এবার বাজারঘরের ৬০০ জন কাল্পনিক নতুন গ্রাহকের ডেটা। সম্পর্কটি আমরা নিজেরা বানিয়েছি, আর তা জানা থাকায় পরে যাচাই করা যায়: বেশি অর্ডার আবার কেনার সম্ভাবনা বাড়ায়, ধীর ডেলিভারি কমায়, বড় ঝুড়ি সামান্য বাড়ায়, সঙ্গে এলোমেলো কোলাহল (কোডের logit লাইন; logistic function-এর বিস্তারিত পর্ব ৫৯-এ)। বাস্তব গ্রাহকের আচরণ এত সরল নয়।

ডেটা তিন ভাগ: ৬০% train (৩৬০ জন), ২০% validation (১২০), ২০% test (১২০), stratify দিয়ে দুই শ্রেণির অনুপাত সব ভাগে সমান রেখে। Validation দিয়ে k বাছা হবে, test শুধু শেষ মাপের জন্য; কেন এই তিন ভাগ, তা পর্ব ৫৬ (Train, validation আর test — data leakage-এর ফাঁদ)-এর বিষয়। Scaling-এর গড় আর standard deviation নেওয়া হয় শুধু train থেকে (অংশ ৬)।

ভোট আর টাই ভাঙার নিয়ম

votes = ytr[idx] একটি (m, k) array: প্রতিটি সারিতে একজন query-র k প্রতিবেশীর label. প্রতিটি সারিতে np.bincount(v, minlength=2) প্রতিটি শ্রেণির ভোট গোনে, আর argmax() সবচেয়ে বেশি ভোটের শ্রেণি দেয়। টাই হলে argmax প্রথমটি, অর্থাৎ সবচেয়ে ছোট label ফেরত দেয়। টাই ভাঙার কয়েকটি প্রচলিত নিয়ম:

  • ছোট label জেতে: সহজ আর নির্ধারিত; scikit-learn-এর uniform ভোটও কার্যত তাই করে। কিন্তু এতে একটি শ্রেণির দিকে সামান্য পক্ষপাত থাকে।
  • সবচেয়ে কাছের প্রতিবেশীর label: টাই হলে k কমিয়ে আবার ভোট, বা টাইয়ে থাকা শ্রেণিগুলোর মধ্যে যার প্রতিবেশী সবচেয়ে কাছে।
  • দূরত্বের ওজন: weighted voting-এ (অংশ ৭) সত্যিকারের টাই প্রায় হয়ই না।
  • এলোমেলো: seed দিয়ে random বাছাই; পক্ষপাতহীন, কিন্তু একই input-এ ফল seed-এর ওপর নির্ভর করে।

আরেক রকম টাই হলো দূরত্বের টাই: k-তম আর (k + ১)-তম প্রতিবেশী সমান দূরে, কিন্তু label আলাদা। তখন কে ঢুকবে, তা নির্ভর করে algorithm আর training ডেটার ক্রমের ওপর; scikit-learn-এর documentation-ও এ নিয়ে সতর্ক করে। আমাদের ডেটায় ঝুড়ি আর দিন ভগ্নাংশ, তাই এমন টাই কার্যত হয় না।

নিচের ব্লক ডেটা বানায়, নিজের knn_predict লেখে, তারপর একই ডেটায় scikit-learn-এর KNeighborsClassifier চালিয়ে prediction মেলায়। algorithm="kd_tree" দেওয়া হয়েছে কারণ default algorithm ব্রাউজারের Python-এ একটি অপ্রাসঙ্গিক deprecation warning ছাপে; ফল একই। এই ব্লক আর পরের দুটি একই namespace ভাগ করে (data-shared); পরের কোনো ব্লক আগে চালালে এটি আপনা থেকে আগে চলে।

import numpy as np
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.neighbors import KNeighborsClassifier

rng = np.random.default_rng(42)
n = 600                                   # কাল্পনিক নতুন গ্রাহক
orders = rng.poisson(2.5, n)              # প্রথম মাসের অর্ডার
basket = rng.normal(1200, 400, n).clip(200)   # গড় ঝুড়ি, টাকা
days = rng.uniform(1, 7, n)               # গড় ডেলিভারি দিন
logit = 1.0 * (orders - 2.5) - 0.9 * (days - 4) + 0.001 * (basket - 1200)
y = (rng.random(n) < 1 / (1 + np.exp(-logit))).astype(int)   # 1 = আবার কিনবেন
X = np.column_stack([orders, basket, days])

X_tv, X_te, y_tv, y_te = train_test_split(X, y, test_size=0.2, stratify=y, random_state=42)
X_tr, X_va, y_tr, y_va = train_test_split(X_tv, y_tv, test_size=0.25, stratify=y_tv, random_state=42)
sc = StandardScaler().fit(X_tr)           # শুধু train থেকে μ আর σ
Z_tr, Z_va, Z_te = sc.transform(X_tr), sc.transform(X_va), sc.transform(X_te)

def knn_predict(Ztr, ytr, Zq, k):
    D = (Zq**2).sum(1)[:, None] + (Ztr**2).sum(1)[None, :] - 2 * Zq @ Ztr.T
    D = np.maximum(D, 0)                  # ক্ষুদ্র ঋণাত্মক কেটে দেওয়া
    idx = np.argpartition(D, k - 1, axis=1)[:, :k]   # k-টি সবচেয়ে কাছের (ক্রমহীন)
    votes = ytr[idx]                      # shape (m, k)
    return np.array([np.bincount(v, minlength=2).argmax() for v in votes])

print("train/val/test:", len(y_tr), len(y_va), len(y_te), "| আবার কেনেন:", y.mean().round(3))
for k in (1, 5, 15):
    p = knn_predict(Z_tr, y_tr, Z_te, k)
    sk = KNeighborsClassifier(n_neighbors=k, algorithm="kd_tree").fit(Z_tr, y_tr).predict(Z_te)
    print(f"k={k:2d} test accuracy {np.mean(p == y_te):.3f} | scikit-learn-এর সঙ্গে হুবহু মিল: {np.array_equal(p, sk)}")
raw = knn_predict(X_tr, y_tr, X_te, 15)
print("scaling ছাড়া k=15:", np.mean(raw == y_te).round(3))
print("সবসময় বেশি-শ্রেণি বললে:", max(y_te.mean(), 1 - y_te.mean()).round(3))

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

train/val/test: 360 120 120 | আবার কেনেন: 0.527
k= 1 test accuracy 0.717 | scikit-learn-এর সঙ্গে হুবহু মিল: True
k= 5 test accuracy 0.733 | scikit-learn-এর সঙ্গে হুবহু মিল: True
k=15 test accuracy 0.750 | scikit-learn-এর সঙ্গে হুবহু মিল: True
scaling ছাড়া k=15: 0.525
সবসময় বেশি-শ্রেণি বললে: 0.525

৬০০ জনের ৫২.৭% আবার কিনেছেন, প্রায় সমান দুই শ্রেণি; ভাগ ৩৬০, ১২০, ১২০. তিনটি k-তেই নিজের লেখা ১০ লাইনের classifier আর scikit-learn-এর kd_tree ১২০টি test গ্রাহকে হুবহু একই উত্তর দিয়েছে (array_equal True), অর্থাৎ দূরত্ব, k বাছাই আর ভোট তিনটিই ঠিক। Accuracy k = ১-এ ০.৭১৭, k = ১৫-এ ০.৭৫০; ১২০ জনে একজনের ভুল মানে ০.০০৮৩, তাই ছোট পার্থক্যকে বড় করে দেখবেন না। (এখানে test-এ তিনটি k দেখা হয়েছে শুধু মেলানোর জন্য; k বাছাই হবে validation-এ, পরের অংশে।)

সবচেয়ে শিক্ষণীয় শেষ দুই লাইন: scaling ছাড়া একই k = ১৫-এ accuracy ০.৫২৫, ঠিক “সবসময় বেশি শ্রেণি বলা” baseline-এর সমান। কাঁচা দূরত্বে টাকার কলাম (σ প্রায় ৪০০) অর্ডার (σ প্রায় ১.৬) আর দিনকে (σ প্রায় ১.৭) ডুবিয়ে দেয়, আর আমাদের বানানো সম্পর্কে ঝুড়িই সবচেয়ে দুর্বল feature. ফলে model কার্যত কিছুই শেখেনি।

“Training” কোথায় হলো? কোথাও না: knn_predict প্রতিবার পুরো Z_tr আর y_tr হাতে নেয়। scikit-learn-এর fit-ও মূলত ডেটা জমা রাখে, সঙ্গে kd_tree-র ক্ষেত্রে একটি খোঁজার গাছ বানায় (অংশ ৯)।

৫ · k বাছাই — ছোট k কোলাহলপূর্ণ, বড় k মসৃণ

k = ১ মানে প্রতিটি training বিন্দুর চারপাশে তার নিজের একটি ছোট এলাকা। একজন “উল্টো” গ্রাহক (বেশি অর্ডার দিয়েও আর কেনেননি) তাঁর চারপাশে একটি ভুলের দ্বীপ বানান। Training ডেটায় k = ১-এর accuracy প্রায় সবসময় ১০০% (প্রতিটি বিন্দুর সবচেয়ে কাছের প্রতিবেশী সে নিজেই), যা overfitting-এর পরিষ্কার উদাহরণ। k বাড়লে ভোট অনেকজনের ওপর গড় হয়, সীমারেখা মসৃণ হয়; কিন্তু k খুব বড় হলে, যেমন k = n, সবার জন্য একই উত্তর: সবচেয়ে বড় শ্রেণি। এই টানাপোড়েনই bias আর variance-এর বিনিময়, যার পূর্ণ আলোচনা পর্ব ৬১ (Overfitting, bias আর variance)-এ।

দুই শ্রেণির মাঝের সীমারেখাকে বলে decision boundary. নিচের ব্লক দুটি কাজ করে: (১) বিজোড় k = ১, ৩, …, ৬১-এ validation accuracy মাপে, তিনটি feature দিয়ে; (২) শুধু দুটি feature (অর্ডার আর দিন, scaled) দিয়ে k = ১, ৫, ২৫-এর decision boundary আঁকে, কারণ তিন মাত্রার সীমারেখা কাগজে আঁকা যায় না। ছবিতে নীল = আবার কেনেন (১), লাল = কেনেন না (০)।

import matplotlib.pyplot as plt

ks = list(range(1, 62, 2))                       # বিজোড় k: দুই শ্রেণিতে ভোট টাই হয় না
val = [np.mean(knn_predict(Z_tr, y_tr, Z_va, k) == y_va) for k in ks]
best = ks[int(np.argmax(val))]
print("validation-এ সেরা k:", best, "| accuracy:", round(max(val), 3))
print("k=1:", round(val[0], 3), "| k=5:", round(val[2], 3), "| k=61:", round(val[-1], 3))
print("এই k-তে test accuracy (একবারই):", np.mean(knn_predict(Z_tr, y_tr, Z_te, best) == y_te).round(3))

A = Z_tr[:, [0, 2]]                              # ছবির জন্য দুই feature: অর্ডার আর দিন
g0, g1 = np.meshgrid(np.linspace(A[:, 0].min() - 0.3, A[:, 0].max() + 0.3, 120),
                     np.linspace(A[:, 1].min() - 0.3, A[:, 1].max() + 0.3, 120))
grid = np.column_stack([g0.ravel(), g1.ravel()])
fig, axs = plt.subplots(2, 2, figsize=(8, 6.8))
for ax, k in zip(axs.flat, (1, 5, 25)):
    Zg = knn_predict(A, y_tr, grid, k).reshape(g0.shape)
    ax.contourf(g0, g1, Zg, levels=[-0.5, 0.5, 1.5], colors=["#f8c4b4", "#b9dcf2"])
    ax.scatter(A[:, 0], A[:, 1], c=y_tr, cmap="coolwarm_r", s=9, edgecolors="k", linewidths=0.2)
    ax.set_title(f"k = {k}")
    ax.set_xlabel("orders in first month (scaled)")
    ax.set_ylabel("delivery days (scaled)")
ax = axs[1, 1]
ax.plot(ks, val, marker="o", ms=3)
ax.axvline(best, ls="--", c="gray")
ax.set_title("validation accuracy vs k (3 features)")
ax.set_xlabel("k"); ax.set_ylabel("accuracy")
fig.subplots_adjust(hspace=0.5, wspace=0.35)
plt.show()

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

validation-এ সেরা k: 25 | accuracy: 0.817
k=1: 0.725 | k=5: 0.758 | k=61: 0.808
এই k-তে test accuracy (একবারই): 0.758

বিজোড় ৩১টি k-এর মধ্যে validation-এ সেরা k = ২৫ (০.৮১৭); k = ১-এ ০.৭২৫, k = ৫-এ ০.৭৫৮, k = ৬১-এ ০.৮০৮. ডান-নিচের রেখায় দেখা যায়, accuracy k = ১ থেকে প্রায় ১৫ পর্যন্ত দ্রুত বাড়ে, তারপর ০.৮০ থেকে ০.৮২-এর একটি সমতল অঞ্চলে ঘোরে; কয়েকটি k একই ০.৮১৭ ছুঁয়েছে, আর argmax প্রথমটি (২৫) নিয়েছে। সমতলের ওঠানামা ১২০ জনে এক-দুজনের পার্থক্য, অর্থাৎ কোলাহল; “২৫-ই একমাত্র ঠিক k” ভাবার কারণ নেই।

বাছা k-তে test accuracy ০.৭৫৮, validation-এর ০.৮১৭-এর চেয়ে কম। এটি প্রত্যাশিত: অনেক k-এর মধ্যে যেটি validation-এ সবচেয়ে ভালো, তার validation score একটু ভাগ্যবান হয়েই থাকে। তাই রিপোর্টে test-এর সংখ্যাই যাবে। ছবির তিনটি প্যানেল একই গল্প দেখায়: k = ১-এ নীল এলাকার ভেতরে অনেক লাল দ্বীপ আর লাল এলাকায় নীল ফিতা, প্রতিটি “ব্যতিক্রমী” গ্রাহকের চারপাশে; k = ৫-এ দ্বীপ কম; k = ২৫-এ প্রায় একটি মসৃণ তির্যক রেখা: বেশি অর্ডার আর কম ডেলিভারি দিন হলে নীল (আবার কেনেন), যা আমাদের বানানো সম্পর্কের সঙ্গেই মেলে। অর্ডার পূর্ণসংখ্যা বলে বিন্দুগুলো খাড়া সারিতে বসেছে।

মূল ধারণা

k বাছুন validation-এ, মাপুন test-এ, একবার। test দেখে k বাছলে test আর “না দেখা” ডেটা থাকে না, আর রিপোর্ট করা accuracy আশাবাদী হয়ে যায়। ছোট ডেটায় একটি validation ভাগ নিজেই নড়বড়ে; তখন cross-validation (পর্ব ৬৬, Model বাছাই) ভালো। পুরোনো একটি নিয়ম-অনুমান বলে k ≈ √n, এখানে √৩৬০ ≈ ১৯; শুরুর বিন্দু হিসেবে চলে, প্রমাণ হিসেবে নয়।

৬ · Scaling কেন বাধ্যতামূলক — টাকা বনাম দিন

পর্ব ৩৩-এ দেখেছেন, কাঁচা সংখ্যায় যে কলামের পরিসর বড়, দূরত্ব প্রায় পুরোটাই সে ঠিক করে। k-NN-এ এর মানে, ভোটও সে ঠিক করে। অংশ ২-এর সাতজন গ্রাহকের সঙ্গে এবার তৃতীয় feature যোগ করি, গড় ঝুড়ি (টাকা): জনি ৮৮০, শিলা ২৬০০, রুমি ১১০০, মিতু ৮৭০, তানহা ১১৫০, পলাশ ১৯০০, সাকিব ৯৫০; নতুন গ্রাহক q-এর ঝুড়ি ৯০০ টাকা। k = ৩-এ ভোট কী হয়?

q = (3 অর্ডার, 3 দিন, 900 টাকা), k = 3দূরত্ব

            
  1. কাঁচা দূরত্ব। টাকা যোগ হতেই ক্রম পুরো বদলে গেছে: সবচেয়ে কাছে জনি (ঝুড়ি ৮৮০, মাত্র ২০ টাকা দূরে), অথচ অর্ডার আর দিনে সবচেয়ে কাছের রুমি এখন চতুর্থ।
  2. কে দূরত্ব ঠিক করছে? জনির বর্গ দূরত্ব ৪০৯-এর ৪০০ টাকার অংশ থেকে; রুমির ৪০০০১-এর ৪০০০০. ২০০ টাকার পার্থক্য “২০০ অর্ডারের পার্থক্য”-র সমান ওজন পাচ্ছে।
  3. কাঁচা ভোট। তিন প্রতিবেশীই ০, তাই উত্তর ০. সিদ্ধান্তটি আসলে শুধু “কার ঝুড়ির দাম ৯০০-র কাছে”, যা আমাদের কাল্পনিক সম্পর্কে সবচেয়ে দুর্বল feature.
  4. Standardize. প্রতিটি কলাম থেকে সাতজনের গড় (μ) বাদ দিয়ে standard deviation (σ) দিয়ে ভাগ। μ আর σ নেওয়া হয়েছে শুধু চেনা সাতজন থেকে; q-কে একই μ আর σ দিয়ে রূপান্তর করা হয়েছে, q-এর নিজের মান হিসাবে ঢোকেনি।
  5. Scaled দূরত্ব। এখন প্রতিটি কলামের একক “কত σ দূরে”. রুমি আবার সবচেয়ে কাছে (০.৫৭২): অর্ডারে ০.৪৬৮ আর টাকায় ০.৩৩০ σ দূরে।
  6. Scaled ভোট। রুমি (১), সাকিব (০), তানহা (১): ভোট ২–১, উত্তর ১. একই ডেটা, একই k, শুধু একক বদলানোয় সিদ্ধান্ত উল্টে গেছে।

এই হিসাবের যন্ত্র হলো standardization (feature scaling-এর সবচেয়ে প্রচলিত রূপ), scikit-learn-এ StandardScaler. নিয়মটি অংশ ৪-এর কোডেই আছে: StandardScaler().fit(X_tr), তারপর একই μ আর σ দিয়ে train, validation আর test সবকে transform. পুরো ডেটায় fit করলে test ডেটার গড় আর ছড়ানো আগেই model-এ ঢুকে যায়, একটি ছোট কিন্তু বাস্তব data leakage. scikit-learn 1.8.0-এর StandardScaler σ হিসাব করে n দিয়ে ভাগ করে (ddof = ০); উপরের হাতের হিসাবেও তাই।

দ্বিতীয় যাচাই: wine ডেটাসেট

কাল্পনিক ডেটায় নিজের কোড মেলানো এক কথা; একটি পরিচিত আসল ডেটাসেটে আরেক। scikit-learn-এর সঙ্গে আসা load_wine (UCI Machine Learning Repository-র wine ডেটা; ইতালির একই অঞ্চলের তিন জাতের আঙুর থেকে তৈরি ১৭৮টি wine-এর ১৩টি রাসায়নিক মাপ) দিয়ে ডেস্কটপে একই knn_predict চালানো হয়েছে। কলামগুলোর পরিসর খুব আলাদা, তাই scaling-এর প্রভাব এখানে আরও স্পষ্ট।

~/bazarghar-ai$ python wine_check.py
scikit-learn 1.8.0 | wine: (178, 13) শ্রেণি: [59 71 48]
  proline                278.00 থেকে 1680.00
  nonflavanoid_phenols     0.13 থেকে    0.66
কাঁচা  k= 1 accuracy 0.704 | kd_tree-এর সঙ্গে মিল True
কাঁচা  k= 5 accuracy 0.722 | kd_tree-এর সঙ্গে মিল True
কাঁচা  k=15 accuracy 0.722 | kd_tree-এর সঙ্গে মিল True
scaled k= 1 accuracy 0.963 | kd_tree-এর সঙ্গে মিল True
scaled k= 5 accuracy 0.944 | kd_tree-এর সঙ্গে মিল True
scaled k=15 accuracy 0.981 | kd_tree-এর সঙ্গে মিল True

সত্যিকারের run (ডেস্কটপ, লেখার সময়ে Python 3.14.4, NumPy 2.5.4, scikit-learn 1.8.0; ৭০:৩০ stratified ভাগ, random_state=42, test-এ ৫৪টি wine). proline কলামের মান কয়েকশো থেকে দেড় হাজারের বেশি, nonflavanoid_phenols-এর ১-এর নিচে; কাঁচা দূরত্বে প্রথমটিই প্রায় সব বলে, তাই accuracy ০.৭ এর কাছে। Scaling-এর পর ০.৯৪–০.৯৮. ছয় ক্ষেত্রেই নিজের লেখা k-NN আর kd_tree হুবহু এক। ৫৪টি নমুনায় একটি ভুল মানে ০.০১৯-এর পার্থক্য, তাই k = ৫ আর ১৫-এর তফাতকে গুরুত্ব দেবেন না।

৭ · Distance-weighted voting — কাছের প্রতিবেশীর কথা বেশি ভারী

অংশ ২-এর k = ৫-এ জনি আর মিতু, যাঁরা q থেকে ৩ একক দূরে, ১ একক দূরের রুমির সমান একটি করে ভোট পেয়েছেন। স্বাভাবিক বুদ্ধি বলে, কাছের প্রতিবেশীর মতামত বেশি দামি। Distance-weighted voting-এ প্রতিটি প্রতিবেশীর ভোটের ওজন w = 1/d (কখনো 1/d²); প্রতিটি শ্রেণির ওজন যোগ করে বড়টি জেতে। ধারণাটি পরিচিত হয় S. A. Dudani-র ১৯৭৬ সালের একটি প্রবন্ধে, যদিও তাঁর প্রস্তাবিত ওজনের সূত্র একটু আলাদা ছিল।

q = (3, 3), পাঁচ প্রতিবেশী, w = 1/d
এই ধাপে
w = 1/d
y=1-এর মোট
y=0-এর মোট
সিদ্ধান্ত
  1. সমান ভোট। অংশ ২-এর মতো: y = ১-এর দুটি ভোট (রুমি, তানহা), y = ০-এর তিনটি (সাকিব, জনি, মিতু), উত্তর ০.
  2. রুমি। d = ১, তাই w = ১/১ = ১.০০০, y = ১-এর ঘরে যোগ।
  3. সাকিব। d = √২ ≈ ১.৪১৪, w ≈ ০.৭০৭, y = ০-এর ঘরে।
  4. তানহা। d = ২, w = ০.৫০০; y = ১-এর মোট এখন ১.৫০০.
  5. জনি। d = ৩, w ≈ ০.৩৩৩; y = ০-এর মোট ০.৭০৭ + ০.৩৩৩ = ১.০৪০.
  6. মিতু আর সিদ্ধান্ত। d = √১০ ≈ ৩.১৬২, w ≈ ০.৩১৬; y = ০-এর মোট ১.৩৫৭. y = ১ জেতে ১.৫০০ বনাম ১.৩৫৭: দূরের দুজনের মিলিত ওজন (০.৬৪৯) কাছের রুমির একার (১.০০০) চেয়েও কম। ওজন 1/d² নিলে ব্যবধান আরও বড়: ১.২৫০ বনাম ০.৭১১.

scikit-learn-এ KNeighborsClassifier(weights="distance") ঠিক w = 1/d ব্যবহার করে। একটি বিশেষ ক্ষেত্র: query কোনো training বিন্দুর সঙ্গে হুবহু মিলে গেলে d = ০, আর 1/0 অসীম। scikit-learn তখন শূন্য দূরত্বের বিন্দুগুলোকেই সব ওজন দেয়; নিজের কোডে এই ক্ষেত্র আলাদা করে সামলাতে হয় (অনুশীলন ২)। Weighted voting k-র পছন্দকে একটু কম স্পর্শকাতর করে, কারণ দূরের অতিরিক্ত প্রতিবেশীর প্রভাব কম; তবে scaling-এর প্রয়োজন একটুও কমায় না, কারণ d নিজেই scaling-নির্ভর।

৮ · k-NN regression আর অপ্রাসঙ্গিক feature-এর ক্ষতি

লক্ষ্য যদি শ্রেণি না হয়ে একটি সংখ্যা হয়, যেমন “এই অর্ডার কত দিনে পৌঁছাবে”, তাহলে ভোটের বদলে k প্রতিবেশীর মানের গড় নিন: ŷ = (১/k) ∑ yi, যেখানে ŷ (ওয়াই-হ্যাট) হলো অনুমান আর যোগফল k প্রতিবেশীর ওপর। এটিই k-NN regression. হাতের উদাহরণ: গুদাম থেকে ৮ কিলোমিটার দূরের নতুন অর্ডারের তিনটি সবচেয়ে কাছের পুরোনো অর্ডার ২.০, ২.৪ আর ৩.১ দিনে পৌঁছেছিল; অনুমান (২.০ + ২.৪ + ৩.১)/৩ = ২.৫ দিন। Weighted রূপে গড়ের বদলে ∑wiyi / ∑wi.

নিচের ব্লকের দ্বিতীয় অংশ ১২০টি কাল্পনিক অর্ডারে (দূরত্ব ০.৫–২৫ km; সম্পর্ক: দিন ≈ ১ + ০.২ × km, সঙ্গে কোলাহল) k = ১ আর ১০-এর regression রেখা আঁকে, আর ৪০টি test অর্ডারে mean absolute error (MAE, গড় পরম ভুল) মাপে। প্রথম অংশ আরেকটি প্রশ্নের উত্তর দেয়: বাজারঘরের তিনটি অর্থবহ feature-এর সঙ্গে যদি অর্থহীন কলাম যোগ হয় (যেমন গ্রাহক আইডির শেষ অঙ্ক, অ্যাপ খোলার এলোমেলো সময়), তাহলে কী হয়? এখানে সেই কলামগুলো এলোমেলো normal সংখ্যা, একটি একটি করে বাড়ানো, ৫টি seed-এর গড়।

import matplotlib.pyplot as plt

# (ক) অর্থহীন noise feature যোগ, k = 25, ৫টি seed-এর গড়
for m in (0, 1, 2, 4, 8, 16, 32):
    accs = []
    for s in range(5):
        r = np.random.default_rng(s)
        Ntr, Nva = r.normal(size=(len(y_tr), m)), r.normal(size=(len(y_va), m))
        p = knn_predict(np.hstack([Z_tr, Ntr]), y_tr, np.hstack([Z_va, Nva]), 25)
        accs.append(np.mean(p == y_va))
    print(f"noise feature {m:2d}টি → validation accuracy {np.mean(accs):.3f}")

# (খ) k-NN regression: দূরত্ব (km) থেকে ডেলিভারি দিন
r = np.random.default_rng(42)
km = r.uniform(0.5, 25, 120)
dd = (1 + 0.2 * km + r.normal(0, 0.6, 120)).clip(0.5)
km_tr, dd_tr, km_te, dd_te = km[:80], dd[:80], km[80:], dd[80:]
def knn_reg(xtr, ytr, xq, k):
    D = np.abs(xq[:, None] - xtr[None, :])          # 1 মাত্রায় দূরত্ব
    idx = np.argpartition(D, k - 1, axis=1)[:, :k]
    return ytr[idx].mean(axis=1)                    # ভোট নয়, গড়
for k in (1, 10):
    mae = np.mean(np.abs(knn_reg(km_tr, dd_tr, km_te, k) - dd_te))
    print(f"k={k:2d}: test MAE {mae:.2f} দিন")
print(f"সবসময় train-এর গড় বললে: MAE {np.mean(np.abs(dd_tr.mean() - dd_te)):.2f} দিন")

xs = np.linspace(0.5, 25, 400)
fig, ax = plt.subplots(figsize=(6, 3.8))
ax.scatter(km_tr, dd_tr, s=12, c="gray", label="training orders")
for k, c in ((1, "#d62728"), (10, "#1f77b4")):
    ax.plot(xs, knn_reg(km_tr, dd_tr, xs, k), c=c, label=f"k-NN, k = {k}")
ax.set_xlabel("distance from warehouse (km)"); ax.set_ylabel("delivery days")
ax.set_title("k-NN regression: mean of the k nearest"); ax.legend()
fig.subplots_adjust(bottom=0.16)
plt.show()

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

noise feature  0টি → validation accuracy 0.817
noise feature  1টি → validation accuracy 0.800
noise feature  2টি → validation accuracy 0.810
noise feature  4টি → validation accuracy 0.765
noise feature  8টি → validation accuracy 0.743
noise feature 16টি → validation accuracy 0.762
noise feature 32টি → validation accuracy 0.685
k= 1: test MAE 0.75 দিন
k=10: test MAE 0.52 দিন
সবসময় train-এর গড় বললে: MAE 1.22 দিন

প্রথম অংশ: কোনো noise ছাড়া validation accuracy ০.৮১৭; ৪টি অর্থহীন কলামে ০.৭৬৫, ৩২টিতে ০.৬৮৫, baseline ০.৫২৫-এর দিকে নামছে। পতন সরলরেখায় নয় (১৬টিতে ০.৭৬২, ৮টির ০.৭৪৩-এর চেয়ে বেশি), কারণ ৫টি seed আর ১২০ জনের গড়েও কোলাহল থাকে; কিন্তু প্রবণতা স্পষ্ট। প্রতিটি noise কলাম scaled তিন কলামের মতোই দূরত্বে ভাগ বসায়, আর k-NN জানে না কোন কলাম অর্থহীন (ভাবনার প্রশ্ন ৩)।

দ্বিতীয় অংশ: k = ১-এর test MAE ০.৭৫ দিন, k = ১০-এর ০.৫২, আর সবসময় train-এর গড় বললে ১.২২. ছবিতে লাল রেখা (k = ১) প্রতিটি training বিন্দু ছুঁয়ে লাফায়, অর্থাৎ কোলাহলও মুখস্থ করে; নীল রেখা (k = ১০) একটি মসৃণ সিঁড়ি, আসল ঊর্ধ্বমুখী সম্পর্কের কাছাকাছি। দুই প্রান্তে নীল রেখা সমতল হয়ে যায়: ২৫ km-এর কাছে সবচেয়ে কাছের ১০টি অর্ডার সবই বাঁ দিকে, তাই অনুমান নিচের দিকে টানে। k-NN regression কখনো দেখা মানের বাইরে যায় না: ৪০ km দূরের অর্ডারকেও এটি প্রায় ২৫ km-এর অর্ডারের মতো দিন বলবে। পরের পর্বের linear regression একটি রেখা শেখে বলে বাইরেও অনুমান (extrapolation) করতে পারে, যদিও তা ভুলও হতে পারে।

৯ · খরচ, গতি আর শক্তি-দুর্বলতা

Training-এ খরচ প্রায় শূন্য, তাই সব খরচ prediction-এ। সরাসরি খোঁজায় (brute force) একটি query-র জন্য n টি training বিন্দুর প্রতিটিতে d টি ঘরের হিসাব: O(n·d) সময় (time complexity), সঙ্গে k-টি বাছতে গড়ে O(n). m টি query-তে O(m·n·d). মেমরিতে পুরো training ডেটা রাখতে হয়: n × d × ৮ বাইট (float64). ১০ লাখ গ্রাহক আর ১০০টি feature হলে শুধু ডেটাই প্রায় ৮০০ MB, আর প্রতিটি নতুন গ্রাহকের জন্য ১০ কোটি গুণ-যোগ। তুলনায় linear regression-এর model মাত্র d + ১টি সংখ্যা।

গতি বাড়ানোর তিনটি পরিচিত পথ, এখানে শুধু ধারণা হিসেবে:

  • KD-tree (Jon Bentley, ১৯৭৫): জায়গাটিকে বারবার একটি করে অক্ষ ধরে দুই ভাগে কাটা একটি binary গাছ। খোঁজার সময় যে ভাগ নিশ্চিতভাবে দূরে, তা পুরো বাদ। কম মাত্রায় খুব দ্রুত; মাত্রা বাড়লে বাদ দেওয়ার সুযোগ কমে।
  • Ball tree: অক্ষের বদলে বিন্দুগুলোকে নেস্টেড “বল”-এ (কেন্দ্র আর ব্যাসার্ধ) ভাগ করে; যেকোনো metric দূরত্বে চলে, কিছু ডেটায় KD-tree-র চেয়ে মাঝারি মাত্রায় ভালো।
  • Approximate nearest neighbour (ANN): হুবহু সবচেয়ে কাছের বদলে “প্রায় সবচেয়ে কাছের” দ্রুত দেয়, যেমন graph-ভিত্তিক HNSW বা ভেক্টর ভাগ করে খোঁজা। Embedding আর vector search-এ এটিই চলে (পর্ব ৩৩-এর শেষ অংশ, আর DBMS কোর্সের vector database পর্ব)।

মাত্রা কীভাবে গাছের সুবিধা খেয়ে ফেলে, তা ডেস্কটপে একটি ছোট পরীক্ষায় দেখা গেছে: ১ লাখ এলোমেলো বিন্দু, ১০০০টি query, k = ৫, scikit-learn-এর NearestNeighbors.

~/bazarghar-ai$ python speed.py
d= 3 brute     fit     0.5 ms | 1000 query    91.1 ms
d= 3 kd_tree   fit    81.1 ms | 1000 query     3.5 ms
d= 3 ball_tree fit    47.6 ms | 1000 query    35.9 ms
d=50 brute     fit     2.7 ms | 1000 query    88.8 ms
d=50 kd_tree   fit   952.2 ms | 1000 query 11109.1 ms
d=50 ball_tree fit   539.7 ms | 1000 query 10089.9 ms

সত্যিকারের run (ডেস্কটপ, লেখার সময়ে Python 3.14.4, scikit-learn 1.8.0; একবারের মাপ, অন্য যন্ত্রে সংখ্যা আলাদা হবে)। ৩ মাত্রায় KD-tree brute force-এর চেয়ে প্রায় ২৫ গুণ দ্রুত, গাছ বানানোর খরচ (৮১ ms) সত্ত্বেও। ৫০ মাত্রার এলোমেলো ডেটায় চিত্র উল্টো: দুই গাছই brute force-এর চেয়ে শতগুণের বেশি ধীর, কারণ প্রায় কোনো শাখাই বাদ দেওয়া যায় না, আর গাছ ঘোরার বাড়তি খরচ যোগ হয়। এটি পর্ব ৩৩-এর curse of dimensionality-র বাস্তব চেহারা। মনে রাখবেন, ডেটা পুরোপুরি এলোমেলো; গঠনযুক্ত বাস্তব ডেটায় গাছ এতটা খারাপ না-ও হতে পারে। scikit-learn-এর default algorithm="auto" ডেটা দেখে এদের মধ্যে বাছে।

শক্তিদুর্বলতা
বোঝা আর ব্যাখ্যা করা সহজ: “এই ৫ জন গ্রাহকের মতো”Prediction ধীর: প্রতি query-তে O(n·d)
Training নেই; নতুন ডেটা যোগ মানে শুধু সারি যোগপুরো training ডেটা মেমরিতে রাখতে হয়
সীমারেখার কোনো নির্দিষ্ট আকার ধরে নেয় না; বাঁকা, জটিল সীমাও ধরতে পারেScaling ছাড়া প্রায় অর্থহীন (টাকা বনাম দিন)
একই কোডে classification, regression আর বহু শ্রেণিঅপ্রাসঙ্গিক feature আর অনেক মাত্রায় দুর্বল (curse of dimensionality)
ডেটা অনেক হলে ভালো ফল দিতে পারে (Cover–Hart-এর সীমা)শ্রেণির অসম ভাগে বড় শ্রেণি ভোটে জিততে থাকে; সম্ভাবনা মোটা দাগের (k-এর ভগ্নাংশ)

আরও গভীরে: Cover আর Hart-এর ১৯৬৭ সালের ফল একটি সীমার কথা: n অসীমের দিকে গেলে ১-NN-এর ঝুঁকি R* ≤ R ≤ 2R*(১ − R*) (দুই শ্রেণিতে), যেখানে R* হলো Bayes error. Charles Stone ১৯৭৭ সালে দেখান, n-এর সঙ্গে k বাড়ালে কিন্তু k/n → ০ রাখলে k-NN যেকোনো বণ্টনে Bayes error-এর দিকে যায় (universal consistency). দূরত্ব নিজেও শেখা যায়: Weinberger ও Saul-এর large margin nearest neighbor (LMNN, ২০০৫ সালের দিকে) একটি metric learning পদ্ধতি। আর বড় মাপের ANN-এ HNSW (Malkov ও Yashunin, প্রথম প্রকাশ ২০১৬) আর FAISS লাইব্রেরি বহুল ব্যবহৃত।

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

“k-NN classifier লিখে দাও” বললে AI assistant দ্রুত কাজ চলার মতো কোড দেয়, আর সেটি একটি accuracy-ও ছাপে। কিন্তু k-NN-এ ভুলগুলো চুপচাপ থাকে, কারণ ভুল কোডও একটি সংখ্যা দেয়।

AI যা প্রায়ই করেকীভাবে ধরবেন
Scaling ছাড়াই দূরত্ব মাপে, বা পুরো ডেটায় StandardScaler fit করে তারপর ভাগ করেআগে ভাগ, শুধু train-এ fit; কাঁচা আর scaled দুটোর accuracy পাশাপাশি দেখুন
Test set দেখে k বাছে, তারপর সেই test accuracy রিপোর্ট করেআলাদা validation বা cross-validation; test একবারই
k = ১-এর training accuracy ১০০% দেখিয়ে “নিখুঁত model” বলেনতুন ডেটায় মাপুন; train-এ k = ১ প্রায় সবসময় ১০০%
dot-product কৌশলের পর np.sqrt সরাসরি নেয়, nan চুপচাপ ঢোকেnp.maximum(D, 0); np.isnan(D).sum() যাচাই
বড় ডেটায় (m, n, d) broadcasting চালিয়ে MemoryError, বা পুরো সারি argsortমেমরি হিসাব করুন; ভাগে ভাগে (chunk) হিসাব, argpartition
নেই এমন parameter বানায় (যেমন KNeighborsClassifier(scale=True))নিজের version-এর অফিসিয়াল docs-এ parameter তালিকা মিলিয়ে দেখুন

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

আমার কাছে একটি NumPy array X (shape (n, 3): প্রথম মাসের অর্ডার, গড় ঝুড়ি টাকায়, গড় ডেলিভারি দিন) আর y (0/1) আছে। Python 3.14, NumPy 2.4 আর scikit-learn 1.8 দিয়ে কোড লেখো যা: ১. আগে train_test_split(stratify=y, random_state=42) দিয়ে train, validation আর test-এ ভাগ করবে; StandardScaler শুধু train-এ fit করবে। ২. নিজের knn_predict(Ztr, ytr, Zq, k) লিখবে: ‖a‖² + ‖b‖² − 2a·b দিয়ে distance matrix, np.maximum(D, 0), np.argpartition, আর টাইয়ে কোন শ্রেণি জেতে তা comment-এ বলবে। ৩. বিজোড় k = 1 থেকে 51-এ validation accuracy দেখিয়ে সেরা k বাছবে, তারপর শুধু সেই k-তে একবার test accuracy আর সবচেয়ে বেশি শ্রেণি বলার baseline ছাপাবে। ৪. KNeighborsClassifier(n_neighbors=k, algorithm="kd_tree")-এর prediction-এর সঙ্গে np.array_equal দিয়ে মিলিয়ে দেখাবে। ৫. কোনো deprecated বা বানানো parameter ব্যবহার করবে না; প্রতিটি parameter-এর নাম scikit-learn 1.8-এর docs-এ আছে কি না বলবে।

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

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

  • k-NN-কে lazy learner বলে কেন? Training-এ কিছু শেখে না, শুধু ডেটা রাখে; সব হিসাব prediction-এর সময়।
  • দুই শ্রেণিতে k বিজোড় কেন? ভোটে টাই এড়াতে; বহু শ্রেণিতে বিজোড় k-ও টাই ঠেকায় না।
  • k খুব ছোট বা খুব বড় হলে? ছোট k: কোলাহলে সংবেদনশীল, overfitting; বড় k: অতি মসৃণ, underfitting, শেষে সবসময় বড় শ্রেণি।
  • Brute-force k-NN-এর এক query-র খরচ? O(n·d) দূরত্ব, সঙ্গে k বাছাই গড়ে O(n) (argpartition).
  • scaling না করলে কী হয়? বড় পরিসরের feature (যেমন টাকা) দূরত্ব আর ভোট একাই ঠিক করে।

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

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

প্র ০১Cover আর Hart দেখিয়েছিলেন, অসীম ডেটায় ১-NN-এর ভুল Bayes error-এর দ্বিগুণের বেশি নয়। এই ফলটি কতটা আশ্বাসের, আর বাস্তবে কেন তা প্রায়ই খাটে না?

১৯৬৭ সালের প্রবন্ধে Thomas Cover আর Peter Hart একটি সরল কিন্তু চমকপ্রদ প্রশ্ন তোলেন: কোনো সম্ভাবনার model না বানিয়ে, শুধু “সবচেয়ে কাছের চেনা উদাহরণের label নাও” নিয়মটি কতটা খারাপ হতে পারে? উত্তর: training ডেটা অসীমের দিকে গেলে, যেকোনো মসৃণ বণ্টনে ১-NN-এর ভুলের হার সর্বনিম্ন সম্ভব ভুলের হার R* (Bayes error, যা নিখুঁত সম্ভাবনা জানা থাকলেও এড়ানো যায় না) এবং মোটামুটি ২R*-এর মধ্যে থাকে। যুক্তির সারকথা: অসীম ডেটায় q-এর সবচেয়ে কাছের প্রতিবেশী q-এর গায়ে লেগে থাকে, তাই তার label যেন একই বণ্টন থেকে তোলা আরেকটি স্বাধীন নমুনা। দুটি স্বাধীন নমুনা অমিল হওয়ার সম্ভাবনাই ভুলের হার, যা Bayes error-এর দ্বিগুণের কাছাকাছি সীমায় বাঁধা।

আশ্বাসটি তাত্ত্বিক: “নির্বোধ” নিয়মটিও সর্বোত্তমের খুব বেশি পিছনে নয়, এবং “ডেটার অর্ধেক তথ্য কাছের প্রতিবেশীতেই আছে” বলে প্রায়ই উদ্ধৃত হয়। পরে Charles Stone (১৯৭৭) দেখান, k-কে n-এর সঙ্গে ধীরে বাড়ালে k-NN Bayes error-এর দিকেই যায়।

কিন্তু শর্তগুলো লক্ষ করুন। “অসীম ডেটা” বাস্তবে নেই, আর প্রয়োজনীয় ডেটা মাত্রার সঙ্গে প্রায় গুণোত্তর হারে বাড়ে: ৩ মাত্রায় “কাছের” প্রতিবেশী সত্যিই কাছে, ৫০ মাত্রায় সবাই প্রায় সমান দূরে (পর্ব ৩৩)। দূরত্বের মাপ ভুল হলে (scaling ছাড়া টাকা বনাম দিন) “কাছের” বিন্দু আসলে অপ্রাসঙ্গিক। আর সীমাটি অনুপাতের: Bayes error ২০% হলে দুই শ্রেণিতে ১-NN-এর ভুল ২R*(১ − R*) = ৩২% পর্যন্ত হতে পারে, যা ব্যবসার কাজে বড় পার্থক্য। আমাদের ৩৬০ জনের ডেটায় k = ১-এর validation accuracy অন্য k-এর চেয়ে স্পষ্টতই কম ছিল।

মূল উপলব্ধি: তাত্ত্বিক নিশ্চয়তা সবসময় শর্তসাপেক্ষ; Cover–Hart বলে k-NN মৌলিকভাবে যুক্তিসংগত, কিন্তু সসীম ডেটা, অনেক মাত্রা আর ভুল দূরত্বে বাস্তব ফল মাপতে হয় নিজের validation ডেটায়।

প্র ০২“Training নেই” k-NN-এর সুবিধা না অসুবিধা? বাজারঘর যদি k-NN দিয়ে প্রতিদিন লাখো গ্রাহকের জন্য সিদ্ধান্ত নেয়, তাহলে কোন খরচ আর ঝুঁকি সামনে আসবে?

সুবিধাটি বাস্তব। নতুন ডেটা এলে model নতুন করে বানাতে হয় না; সারি যোগ করলেই পরের prediction তা ব্যবহার করে। কোনো gradient, কোনো convergence-এর দুশ্চিন্তা নেই। সীমারেখার আকার নিয়ে কোনো অনুমানও নেই: ডেটা যত জটিল আকৃতির হোক, যথেষ্ট উদাহরণ থাকলে k-NN তা অনুসরণ করে। আর ব্যাখ্যা সহজ: “আপনার মতো এই পাঁচজন গ্রাহক আবার কিনেছেন” কথাটি একজন ব্যবস্থাপক সহজেই বোঝেন।

কিন্তু খরচ শুধু সরে গেছে, মোছেনি। Eager model (যেমন logistic regression, পর্ব ৫৯) training-এ একবার খেটে prediction-এ d টি গুণ-যোগ করে; k-NN প্রতিটি prediction-এ n·d. লাখো গ্রাহকের দৈনিক সিদ্ধান্তে এটি সার্ভারের খরচ আর দেরি (latency) দুই-ই বাড়ায়; তাই প্রকৃত ব্যবস্থায় KD-tree, ANN সূচক, বা সময়ে সময়ে নতুন করে বানানো সূচক লাগে, যা এক অর্থে “training”-ই ফিরিয়ে আনে। System Design-এর ভাষায় এটি latency বনাম throughput আর মেমরির বিনিময় (System Design কোর্সের latency ও throughput পর্ব)।

দ্বিতীয় ঝুঁকি গোপনীয়তার। k-NN-এর “model” মানে পুরো গ্রাহক ডেটা। model যেখানে চলে, সেখানে সব গ্রাহকের তথ্য থাকে; model ফাঁস মানে ডেটা ফাঁস। প্রতিবেশী দেখিয়ে ব্যাখ্যা দিলে অন্য গ্রাহকের তথ্য প্রকাশের ভয়ও আছে। আবার ইতিবাচক দিকও আছে: কোনো গ্রাহক নিজের তথ্য মুছে দিতে বললে তাঁর সারি সরালেই model থেকে তাঁর প্রভাব পুরোপুরি চলে যায়, যা ওজন-শেখা model-এ এত সহজ নয়।

তৃতীয়ত, পুরোনো ডেটা বয়ে বেড়ানো: গত বছরের ঈদের মৌসুমের আচরণ এখনো ভোট দিচ্ছে কি না, তা নিয়মিত দেখতে হয়; পুরোনো সারি বাদ দেওয়া বা সময় দিয়ে ওজন দেওয়া একটি নকশার সিদ্ধান্ত।

মূল উপলব্ধি: lazy learning খরচকে training থেকে prediction-এ সরায়, আর model-কে ডেটার সঙ্গে এক করে দেয়; তাই বেছে নেওয়ার আগে query-র সংখ্যা, latency, মেমরি আর গোপনীয়তার হিসাব একসঙ্গে করতে হয়।

প্র ০৩অর্থহীন feature যোগ করলে k-NN-এর accuracy কেন পড়ে? Naive Bayes বা linear model-এর তুলনায় k-NN এ ব্যাপারে বেশি নাজুক কেন, আর প্রতিকার কী?

অংশ ৮-এর পরীক্ষায় তিনটি অর্থবহ feature-এর সঙ্গে ৩২টি এলোমেলো কলাম যোগ করতেই validation accuracy অনেকটা নেমেছে। কারণটি দূরত্বের সূত্রেই: Euclidean দূরত্বের বর্গ প্রতিটি কলামের বর্গ পার্থক্যের যোগফল, আর প্রতিটি কলাম সমান ওজন পায়। একটি noise কলাম standardize করার পর গড়ে প্রায় ২ একক বর্গ পার্থক্য যোগ করে (দুটি স্বাধীন standard normal-এর পার্থক্যের variance ২)। ৩২টি noise কলাম মানে গড়ে প্রায় ৬৪, অথচ তিনটি অর্থবহ কলামের মিলিত অবদান গড়ে প্রায় ৬. ফলে “কাছের” প্রতিবেশী বাছাই মূলত এলোমেলো সংখ্যার মিল দিয়ে হয়, আসল মিল দিয়ে নয়। এটি curse of dimensionality-র একটি সরাসরি রূপ; ১৯৯৯ সালে Kevin Beyer ও সহকর্মীরা “When is nearest neighbor meaningful?” প্রবন্ধে দেখান কোন শর্তে অনেক মাত্রায় কাছের আর দূরের দূরত্ব প্রায় সমান হয়ে যায়।

Naive Bayes আর linear model এখানে তুলনামূলক শক্ত, কারণ তারা প্রতিটি feature-এর ওজন বা প্রভাব আলাদাভাবে শেখে; একটি অর্থহীন feature-এর শেখা ওজন শূন্যের কাছে থাকে (ছোট ডেটায় পুরোপুরি শূন্য নয়, তাই তারাও কিছুটা ক্ষতিগ্রস্ত হয়)। k-NN নিজে কোনো ওজন শেখে না; সব feature-কে সমান ভরসা করে।

প্রতিকার কয়েক স্তরে। এক, ডোমেইন জ্ঞান দিয়ে feature বাছাই: গ্রাহক আইডির অঙ্ক কেনার আচরণ বলে না। দুই, validation-এ দেখে feature বাছাই বা বাদ (পর্ব ৬৩, Feature engineering; পর্ব ৬৬)। তিন, মাত্রা কমানো, যেমন PCA (পর্ব ৬৫), যদিও PCA বড় variance-এর দিক রাখে, যা সবসময় শ্রেণির জন্য দরকারি দিক নয়। চার, দূরত্ব শেখা (metric learning), যেখানে প্রতিটি feature-এর ওজন ডেটা থেকে শেখা হয়, যেমন LMNN.

মূল উপলব্ধি: k-NN “সব feature সমান জরুরি” ধরে নেয়; তাই এর ফল যতটা ডেটার ওপর, ততটাই feature বাছাই আর দূরত্বের সংজ্ঞার ওপর নির্ভর করে।

প্র ০৪হুবহু সবচেয়ে কাছের প্রতিবেশী (KD-tree) না প্রায়-সবচেয়ে-কাছের (ANN)? বাজারঘরের “এর মতো পণ্য” ফিচারে কোনটি বাছবেন, আর ভুলের দাম কীভাবে মাপবেন?

Jon Bentley ১৯৭৫ সালে Communications of the ACM-এ multidimensional binary search tree, অর্থাৎ KD-tree, প্রকাশ করেন। ধারণাটি binary search tree-এর সম্প্রসারণ: প্রতিটি স্তরে একটি অক্ষ ধরে মধ্যমা দিয়ে ভাগ। খোঁজার সময় এখন পর্যন্ত পাওয়া সেরা দূরত্বের চেয়ে দূরের ভাগ পুরো বাদ পড়ে। উত্তর হুবহু brute force-এর মতো, শুধু দ্রুত। কিন্তু অংশ ৯-এর ডেস্কটপ পরীক্ষায় ৫০ মাত্রার এলোমেলো ডেটায় KD-tree brute force-এর চেয়েও শতগুণ ধীর হয়েছে, কারণ উচ্চ মাত্রায় প্রায় কোনো ভাগই নিশ্চিতভাবে দূরে নয়।

আধুনিক পণ্য-সুপারিশ বা লেখা খোঁজায় embedding সাধারণত শত বা হাজার মাত্রার, আর বস্তু লাখ বা কোটি। সেখানে approximate nearest neighbour চলে: locality-sensitive hashing (১৯৯০-এর দশকের শেষে Indyk ও Motwani-র কাজ থেকে জনপ্রিয়), ভেক্টর ভাগ করে খোঁজা (product quantization), আর graph-ভিত্তিক HNSW (Malkov ও Yashunin). এরা সামান্য নির্ভুলতা ছেড়ে অনেক গুণ গতি দেয়। নির্ভুলতা মাপা হয় recall@k দিয়ে: হুবহু খোঁজায় যে k-টি পাওয়া যেত, তার কত ভাগ ANN দিয়েছে।

বাজারঘরের “এর মতো পণ্য” ফিচারে দশম সবচেয়ে কাছের পণ্যের বদলে এগারোতমটি দেখালে গ্রাহকের প্রায় কোনো ক্ষতি নেই; তাই ANN যুক্তিসংগত, আর গতি সরাসরি পাতা খোলার সময় কমায়। কিন্তু একই কৌশল যদি জালিয়াতি ধরায় ব্যবহার হয় (“এই লেনদেন কি পরিচিত প্রতারণার কাছাকাছি?”), তখন একটি সত্যিকারের কাছের প্রতিবেশী হারানো দামি হতে পারে; সেখানে recall উঁচু রাখার parameter, বা ANN-এর পর ছোট প্রার্থী তালিকায় হুবহু দূরত্ব দিয়ে আবার সাজানো (re-ranking) লাগে।

মূল উপলব্ধি: হুবহু বনাম আনুমানিক কোনো প্রযুক্তির প্রশ্ন নয়, ভুলের দামের প্রশ্ন; recall@k আর latency দুটোই মেপে, কাজের ঝুঁকি অনুযায়ী বিনিময়টি বাছুন।

প্র ০৫k-NN দিয়ে ঠিক করা হচ্ছে কোন গ্রাহক ছাড়ের কুপন পাবেন। প্রতিবেশীর ভোটে সিদ্ধান্ত হলে ন্যায্যতা, ব্যাখ্যা আর গোপনীয়তায় কী কী প্রশ্ন ওঠে?

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

ব্যাখ্যার দিক থেকে k-NN আকর্ষণীয়: “আপনার সবচেয়ে কাছের এই পাঁচজন” একটি মূর্ত ব্যাখ্যা। আইন ও নীতির জগতে এই ধরনের উদাহরণভিত্তিক ব্যাখ্যা নিয়ে আলোচনা আছে। কিন্তু সেই ব্যাখ্যা দিতে গেলে অন্য গ্রাহকের তথ্য দেখাতে হয়, যা গোপনীয়তা ভাঙে; তাই বাস্তবে প্রতিবেশীদের সারাংশ (“আপনার মতো গ্রাহকদের ৮০% …”) দেখানো নিরাপদ। আবার ব্যাখ্যাটি আংশিক সত্য: সিদ্ধান্তের পেছনে আসলে দূরত্বের সংজ্ঞা আর scaling, যা চোখে পড়ে না।

সৎ নকশায় কয়েকটি ধাপ: কোন feature ব্যবহার হবে তা আগে বিচার করা (এলাকা বা লিঙ্গের সঙ্গে ঘনিষ্ঠভাবে জড়িত feature সাবধানে); বিভিন্ন দলে আলাদা করে ভুলের হার মাপা; মানুষের পর্যালোচনার সুযোগ রাখা; আর মনে রাখা যে এই পর্বের সব সংখ্যা কাল্পনিক ডেটার। বাংলাদেশে অনলাইন কেনাকাটায় এলাকাভেদে ডেলিভারির অভিজ্ঞতা আলাদা হতে পারে, এমন সাধারণ পর্যবেক্ষণ থেকেই এই সতর্কতা; নির্দিষ্ট পরিসংখ্যান এখানে দাবি করা হচ্ছে না। ন্যায্যতার মাপজোখ পর্ব ৭২-এ আরও আসবে।

মূল উপলব্ধি: প্রতিবেশীর ভোট অতীতের প্রতিফলন, ন্যায়বিচার নয়; feature, দূরত্ব আর প্রতিটি দলের ভুলের হার যাচাই না করে k-NN-কে মানুষের সুযোগ-সুবিধার সিদ্ধান্তে বসানো উচিত নয়।

অনুশীলন

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

  1. হাতে k-NN (হিসাব): পাঁচটি কাল্পনিক অর্ডার, প্রতিটি (feature ১, feature ২) → শ্রেণি, আর ডেলিভারি দিন: P1 (1, 1) → A, 2.0 দিন; P2 (2, 4) → A, 3.5 দিন; P3 (3, 2) → B, 2.5 দিন; P4 (4, 1) → B, 1.5 দিন; P5 (5, 4) → B, 4.0 দিন। নতুন বিন্দু q = (2, 2). Euclidean দূরত্বে (ক) k = ১, ৩, ৫-এ শ্রেণি; (খ) k = ৩-এ w = 1/d ওজনের ভোট; (গ) k = ৩-এ ডেলিভারি দিনের k-NN regression অনুমান বের করুন।

    বর্গ দূরত্ব: P1: 1 + 1 = 2 (d ≈ ১.৪১৪); P2: 0 + 4 = 4 (d = ২); P3: 1 + 0 = 1 (d = ১); P4: 4 + 1 = 5 (d ≈ ২.২৩৬); P5: 9 + 4 = 13 (d ≈ ৩.৬০৬). ক্রম: P3, P1, P2, P4, P5.

    • (ক) k = ১: P3 → B. k = ৩: P3 (B), P1 (A), P2 (A) → A ২–১, A. k = ৫: A ২টি, B ৩টি → B.
    • (খ) k = ৩, w = 1/d: B = ১/১ = ১.০০০; A = ১/১.৪১৪ + ১/২ = ০.৭০৭ + ০.৫০০ = ১.২০৭ → A (১.২০৭ বনাম ১.০০০)।
    • (গ) k = ৩-এর দিন: P3 ২.৫, P1 ২.০, P2 ৩.৫ → (২.৫ + ২.০ + ৩.৫)/৩ = ৮/৩ ≈ ২.৬৭ দিন.

    লক্ষ করুন, k = ৩-এ সবচেয়ে কাছের P3 একা B, তবু দুটি A-এর মিলিত ওজন (১.২০৭) তাকে হারায়। ওজন 1/d² নিলে B = ১, A = ০.৫ + ০.২৫ = ০.৭৫, উত্তর B: ওজনের সূত্রও একটি সিদ্ধান্ত।

  2. Distance-weighted k-NN (কোড): অংশ ৪-এর knn_predict-এর পাশে knn_predict_w(Ztr, ytr, Zq, k) লিখুন, যা w = 1/d দিয়ে ভোট দেয়; d = ০ হলে শুধু শূন্য-দূরত্বের প্রতিবেশীরা ভোট দেবে। তারপর k = ১৫-এ test accuracy আর KNeighborsClassifier(n_neighbors=15, weights="distance", algorithm="kd_tree")-এর সঙ্গে মিল দেখান।
    def knn_predict_w(Ztr, ytr, Zq, k):
        D = np.maximum((Zq**2).sum(1)[:, None] + (Ztr**2).sum(1)[None, :] - 2 * Zq @ Ztr.T, 0)
        idx = np.argpartition(D, k - 1, axis=1)[:, :k]
        d = np.sqrt(np.take_along_axis(D, idx, axis=1))       # (m, k) দূরত্ব
        zero = d == 0
        w = np.where(zero.any(1, keepdims=True), zero * 1.0,  # d = 0 থাকলে শুধু তারা
                     1.0 / np.where(zero, 1.0, d))            # নইলে 1/d
        out = []
        for wi, yi in zip(w, ytr[idx]):
            out.append(np.bincount(yi, weights=wi, minlength=2).argmax())
        return np.array(out)
    
    p_u = knn_predict(Z_tr, y_tr, Z_te, 15)
    p_w = knn_predict_w(Z_tr, y_tr, Z_te, 15)
    sk = KNeighborsClassifier(n_neighbors=15, weights="distance",
                              algorithm="kd_tree").fit(Z_tr, y_tr).predict(Z_te)
    print("uniform  test accuracy:", np.mean(p_u == y_te).round(3))
    print("weighted test accuracy:", np.mean(p_w == y_te).round(3))
    print("scikit-learn-এর সঙ্গে হুবহু মিল:", np.array_equal(p_w, sk))
    print("uniform আর weighted কতজনে আলাদা:", int((p_u != p_w).sum()))
    print("d = 0 পরীক্ষা:", knn_predict_w(Z_tr, y_tr, Z_tr[:5], 15), "আসল:", y_tr[:5])

    সত্যিকারের run (ডেস্কটপ, Python 3.14.4, NumPy 2.5.4, scikit-learn 1.8.0; অংশ ৪-এর একই ডেটা আর ভাগ):

    uniform  test accuracy: 0.75
    weighted test accuracy: 0.733
    scikit-learn-এর সঙ্গে হুবহু মিল: True
    uniform আর weighted কতজনে আলাদা: 2
    d = 0 পরীক্ষা: [0 1 1 0 1] আসল: [0 1 1 0 1]

    np.take_along_axis প্রতিটি সারি থেকে বাছা k-টি দূরত্ব তুলে আনে, আর np.bincount(yi, weights=wi) গোনার বদলে প্রতিটি শ্রেণির ওজন যোগ করে। নিজের weighted k-NN আর scikit-learn-এর weights="distance" ১২০টি test গ্রাহকে হুবহু মেলে। এই ডেটায় ওজন দেওয়ায় মাত্র দুজনের উত্তর বদলেছে, আর accuracy ০.৭৫০ থেকে ০.৭৩৩-এ নেমেছে, অর্থাৎ একজন বেশি ভুল; ১২০ জনে এই পার্থক্য কোলাহলের মধ্যে, তাই “weighted সবসময় ভালো” বলা যায় না। শেষ লাইন: train-এর বিন্দুকেই query দিলে d = ০, আর শুধু সেই বিন্দুর label ফেরে; নিজের label-এর সঙ্গে পাঁচটিই মেলে।

  3. সমালোচনা ও নতুন নকশা: একজন সহকর্মী লিখেছেন: “বাজারঘরের ২ লাখ গ্রাহকের পুরো ডেটায় StandardScaler fit করেছি, তারপর ৮০:২০ ভাগ করেছি। k = ১ থেকে ১০০ পর্যন্ত test set-এ চালিয়ে সেরা k = ৭ পেয়েছি, test accuracy ৮৬%. feature হিসেবে গ্রাহক আইডি, ফোন নম্বরের শেষ চার অঙ্কসহ ৪০টি কলাম দিয়েছি, কারণ বেশি feature মানে বেশি তথ্য। প্রতিটি অ্যাপ খোলায় সব ২ লাখ গ্রাহকের সঙ্গে দূরত্ব মেপে কুপন ঠিক হবে।” কী কী ভুল, আর আপনি কীভাবে সাজাবেন?
    • ভাগের আগে scaling: test-এর μ আর σ model-এ ঢুকেছে (data leakage). আগে ভাগ, তারপর শুধু train-এ fit, বা Pipeline.
    • Test দিয়ে k বাছাই: ১০০টি k-এর মধ্যে test-এ সেরাটি বাছলে ৮৬% আশাবাদী। আলাদা validation বা cross-validation-এ k বাছুন, test একবার।
    • অর্থহীন feature: আইডি আর ফোনের অঙ্ক দূরত্বে কোলাহল যোগ করে (অংশ ৮); ফোন নম্বর ব্যক্তিগত তথ্যও। ডোমেইন জ্ঞান আর validation দিয়ে feature বাছুন।
    • খরচ: প্রতিটি অ্যাপ খোলায় ২ লাখ × ৪০ হিসাব; ব্যস্ত সময়ে latency আর সার্ভারের খরচ বাড়বে। কম feature, KD-tree বা ANN সূচক, বা কুপনের সিদ্ধান্ত দিনে একবার ব্যাচে করে রাখা।
    • Baseline আর ন্যায্যতা অনুপস্থিত: “সবসময় বড় শ্রেণি” baseline, এলাকা বা দলভেদে ভুলের হার, আর কয়েকটি seed-এর ছড়ানো রিপোর্টে থাকা উচিত।
  4. আউটপুট অনুমান: নিচের কোড কী ছাপবে? দ্বিতীয় আর তৃতীয় লাইনের ফল থেকে টাই ভাঙার নিয়ম সম্পর্কে কী বোঝা যায়?
    import numpy as np
    D = np.array([4.0, 1.0, 9.0, 1.0, 0.25, 16.0])   # বর্গ দূরত্ব
    y = np.array([1, 0, 1, 1, 0, 1])
    idx = np.argpartition(D, 3)[:4]                   # k = 4
    print(sorted(idx.tolist()))
    print(np.bincount(y[idx]))
    print(np.bincount(y[idx]).argmax())
    print(np.sqrt(np.maximum(np.array([-1e-12, 4.0]), 0)))

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

    [0, 1, 3, 4]
    [2 2]
    0
    [0. 2.]

    সবচেয়ে ছোট চারটি বর্গ দূরত্ব ০.২৫ (index ৪), ১ (১), ১ (৩) আর ৪ (০); argpartition এদের যেকোনো ক্রমে দিতে পারে, তাই sorted. তাদের label ০, ০, ১, ১: bincount দেয় [2 2], অর্থাৎ ২–২ টাই। argmax প্রথম সর্বোচ্চের index দেয়, তাই ০ জেতে: টাইয়ে সবচেয়ে ছোট label. জোড় k-তে দুই শ্রেণির সমস্যায় এমন টাই সহজেই আসে। শেষ লাইনে −10−12 কেটে ০ হয়েছে, তাই বর্গমূল 0., nan নয়; ৪-এর বর্গমূল ২.

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

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

ক্রমএকটি k-NN classifier বানানো আর মূল্যায়নের ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. Seed আর stratify দিয়ে ডেটা train, validation আর test-এ ভাগ করা
  2. শুধু train ডেটায় StandardScaler fit করে তিন ভাগকেই transform করা
  3. Query × train distance matrix বানিয়ে প্রতিটি সারিতে সবচেয়ে ছোট k-টি বাছা
  4. k প্রতিবেশীর label-এর ভোট, নির্দিষ্ট নিয়মে টাই ভেঙে
  5. কয়েকটি k-এ validation accuracy দেখে সেরা k বাছা
  6. বাছা k-তে একবার test accuracy মাপা আর baseline-এর সঙ্গে তুলনা

k-Nearest Neighbors-কে “lazy learner” বলা হয় কেন?

  • কারণ এর accuracy সাধারণত কম
  • কারণ training-এ কিছু শেখে না, সব হিসাব prediction-এর সময় করে
  • কারণ এর learning rate খুব ছোট রাখতে হয়
  • কারণ এটি শুধু কয়েকটি training বিন্দু দেখে বাকিগুলো ফেলে দেয়

একটি query থেকে ছয়টি training বিন্দুর দূরত্ব ও শ্রেণি: 1.0 (B), 1.5 (A), 2.0 (A), 2.5 (B), 3.0 (B), 4.0 (A). ওজনহীন ভোটে k = 3 আর k = 5-এ উত্তর কী?

  • k = 3: B, k = 5: B
  • k = 3: A, k = 5: A
  • k = 3: A, k = 5: B
  • k = 3: টাই, k = 5: B

গ্রাহকের feature: অর্ডার সংখ্যা (0–8) আর গড় ঝুড়ি (200–3000 টাকা). scaling ছাড়া k-NN চালালে কী হয়?

  • দূরত্ব আর ভোট প্রায় পুরোটাই ঝুড়ির টাকা ঠিক করে, অর্ডার প্রায় উপেক্ষিত হয়
  • দুই feature স্বয়ংক্রিয়ভাবে সমান গুরুত্ব পায়
  • অর্ডার সংখ্যা দূরত্বে সবচেয়ে বেশি প্রভাব রাখে
  • কোড error দেয়, কারণ k-NN-এ scaling বাধ্যতামূলক parameter

একজন ডেটা বিজ্ঞানী k = 1 থেকে 50 পর্যন্ত চালিয়ে test set-এ যে k সেরা, সেটি বেছে সেই test accuracy রিপোর্ট করলেন। সমস্যা কী?

  • ৫০ পর্যন্ত চালানো খুব ধীর
  • শুধু বিজোড় k চালানো উচিত ছিল, তাই ফল ভুল
  • Test-এর বদলে training accuracy রিপোর্ট করা উচিত ছিল
  • Test ডেটা দিয়েই hyperparameter বাছায় রিপোর্ট করা accuracy আশাবাদী হয়ে গেছে

n টি training বিন্দু, প্রতিটির d টি feature. Brute-force k-NN-এ একটি query-র দূরত্ব হিসাবের সময় কত?

  • O(d)
  • O(n·d)
  • O(n²·d)
  • O(log n)

k = 3-এ তিন প্রতিবেশী: d = 1 (শ্রেণি A), d = 2 (B), d = 2 (B). ওজন w = 1/d দিলে কোন শ্রেণি জেতে?

  • A, কারণ সবচেয়ে কাছের প্রতিবেশী A
  • B, কারণ B-এর ভোট বেশি
  • টাই: দুই শ্রেণির ওজনই 1
  • উত্তর অসংজ্ঞায়িত, কারণ 1/d অসীম হয়ে যায়

শব্দকোষ

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

মনে রাখুন

  1. ধারণা: সবচেয়ে কাছের k-টি training বিন্দুর ভোট (classification) বা গড় (regression). Training মানে শুধু ডেটা রাখা; k একটি hyperparameter, আর k বদলালে উত্তর বদলাতে পারে (হাতের উদাহরণে ১, ১, ০)।
  2. Distance matrix: broadcasting-এ (m, n, d) অস্থায়ী array; ‖a‖² + ‖b‖² − 2a·b-এ শুধু একটি matrix গুণ, কিন্তু প্রায় একই বিন্দুতে ক্ষুদ্র ঋণাত্মক, তাই np.maximum(D, 0). k-টি বাছতে argpartition.
  3. k বাছাই: ছোট k কোলাহলপূর্ণ, বড় k মসৃণ; validation-এ বাছুন, test-এ একবার মাপুন। দুই শ্রেণিতে বিজোড় k; টাইয়ে argmax ছোট label দেয়।
  4. Scaling আর ওজন: StandardScaler শুধু train-এ fit; scaling ছাড়া টাকা একাই ভোট দেয়। w = 1/d দিলে কাছের প্রতিবেশীর কথা ভারী, k-এর প্রতি সংবেদনশীলতা কমে।
  5. খরচ ও সীমা: প্রতি query O(n·d), পুরো ডেটা মেমরিতে। KD-tree কম মাত্রায় দ্রুত, উচ্চ মাত্রায় নয়; বড় মাপে ANN. অপ্রাসঙ্গিক feature আর অনেক মাত্রায় k-NN দুর্বল।

পরের পর্বে: k-NN কোনো সূত্র শেখেনি, শুধু মনে রেখেছে। পর্ব ৫৮ (Linear regression — loss, gradient descent আর closed form)-এ উল্টো পথ: পুরো ডেটা থেকে কয়েকটি ওজন শিখে বাকি ডেটা ফেলে দেওয়া, বাজারঘরের ডেলিভারির দিন দূরত্ব আর ওজন থেকে।

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

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