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

Indexing আর slicing — boolean mask, fancy index, view আর copy

NumPy Indexing and Slicing — Boolean Masks, Fancy Indexing, Views, Copies and Top-k Selection

Taught in Bangla, this lesson covers selecting cells, rows and columns from NumPy arrays: negative indices, start:stop:step slicing and result shapes, views versus copies checked with np.shares_memory, boolean masks combined with &, | and ~, np.where and np.nonzero, fancy indexing and np.ix_, top-k with argsort and argpartition, and silent bugs such as chained assignment, all run in the browser.

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

  • 1-D আর 2-D array-তে index, negative index, slice, step আর ... দিয়ে ঘর, সারি আর কলাম বাছাই, আর প্রতিটির shape আগে থেকে বলা
  • কোন কাজ view দেয় আর কোনটি copy, np.shares_memory, .base আর stride দিয়ে তা যাচাই, আর কখন .copy() দরকার
  • Boolean mask দিয়ে ছাঁকা, গোনা আর মান বদলানো; &, |, ~ আর বন্ধনীর নিয়ম; np.where আর np.nonzero
  • Fancy index, np.ix_, argmax, argsort, argpartition দিয়ে top-k বাছাই, আর এদের খরচের তুলনা
  • শিকলবাঁধা assignment, শেষ সীমার এক-ঘর ভুল আর shape-এর চমকের মতো নিঃশব্দ ভুল চেনা ও এড়ানো

আগের পর্বে (পর্ব ১৩ · NumPy array — shape, dtype আর list-এর চেয়ে কেন দ্রুত) দেখেছেন, NumPy array হলো একই ধরনের সংখ্যার একটি টানা সারি, যার একটি shape আর একটি dtype আছে। এই পর্বের প্রশ্ন একটিই: সেই array থেকে ঠিক যে সংখ্যাগুলো দরকার, সেগুলো কীভাবে তুলবেন, আর তোলার পর হাতে যা পেলেন তা নতুন সংখ্যা, নাকি পুরোনো সংখ্যারই আরেকটি জানালা। দ্বিতীয় প্রশ্নটিই ডেটার কাজে সবচেয়ে বেশি নিঃশব্দ ভুলের জন্ম দেয়। Python-এর list-এর slicing আপনি list পর্বে দেখেছেন; NumPy একই চিহ্ন ব্যবহার করে, কিন্তু ফল আলাদা। এই পর্বের সব কোড পাতাতেই চলবে, Pyodide দিয়ে; প্রথমবার চালালে NumPy নামতে কয়েক সেকেন্ড লাগতে পারে।

১ · বাজারঘরের বিক্রির খাতা — একটি ঘর, একটি সারি, একটি কলাম

সিনেমা হলের টিকিটের কথা ভাবুন: “সারি D, আসন ১২”। দুটি সংখ্যা দিলেই একটি আসন ঠিক হয়ে যায়। শুধু “সারি D” বললে পুরো একটি সারি, আর “সব সারির ১২ নম্বর আসন” বললে একটি খাড়া কলাম। 2-D array-তে indexing ঠিক এমন: প্রথম সংখ্যা সারি, দ্বিতীয় সংখ্যা কলাম, আর দুটিই গোনা শুরু হয় 0 থেকে।

এই পর্বের পুরো সময় আমরা বাজারঘরের (এই কোর্সের কাল্পনিক অনলাইন দোকান) এক সপ্তাহের বিক্রির খাতা নিয়ে কাজ করব। পাঁচটি জেলা সারিতে, সাতটি দিন কলামে, আর প্রতিটি ঘরে সেদিন সেই জেলার বিক্রি, হাজার টাকায়। ডেটা বানানো হয়েছে seed দেওয়া random generator দিয়ে, তাই প্রত্যেক পাঠক একই সংখ্যা পাবেন:

import numpy as np
rng = np.random.default_rng(42)              # seed ৪২: সবার একই সংখ্যা
sales = rng.integers(20, 90, size=(5, 7))    # ৫ জেলা × ৭ দিন, হাজার টাকা
# সারি: 0 ঢাকা, 1 চট্টগ্রাম, 2 রাজশাহী, 3 খুলনা, 4 সিলেট
# কলাম: 0 শনি, 1 রবি, 2 সোম, 3 মঙ্গল, 4 বুধ, 5 বৃহঃ, 6 শুক্র
print(sales)
[[26 74 65 50 50 80 26]
 [68 34 26 56 88 71 73]
 [70 75 55 28 78 51 55]
 [45 32 84 74 65 48 77]
 [58 51 51 35 26 58 82]]

rng.integers(20, 90, ...) 20 থেকে 89 পর্যন্ত পূর্ণসংখ্যা দেয় (90 বাদ), আর এর dtype সব যন্ত্রে int64, ব্রাউজারের ভেতরের Python-এও। নিচের ছবিতে একই খাতা, প্রতিটি সারি আর কলামের ঠিকানাসহ।

বাজারঘরের বিক্রির খাতা — ঠিকানা আর memory-র ছবি

A 5 × 7 sales array: positive and negative indices, a row, a column, a cell, and its row-major memory layout

sales array-এর ঠিকানা আর memory বিন্যাস sales · shape (5, 7) · হাজার টাকা শনিরবিসোমমঙ্গলবুধবৃহঃশুক্র 0123456 01234 ঢাকাচট্টগ্রামরাজশাহীখুলনাসিলেট 26746550508026 68342656887173 70755528785155 45328474654877 58515135265882 -7-6-5-4-3-2-1 -5-4-3-2-1 sales[1] বা sales[-4] — চট্টগ্রামের সারি sales[:, -1] — সব জেলার শুক্রবার sales[2, 4] = 78 — রাজশাহী, বুধবার memory-তে: ৩৫টি int64 পরপর (row-major) সারি 0 সারি 1 সারি 2 সারি 3 সারি 4 পরের কলাম +8 byte · পরের সারি +56 byte sales[2, 4] = শুরু + 2×56 + 4×8 = শুরু + 144 byte
ছবি ১৪.১ — ওপরে বাজারঘরের খাতা। নীল সংখ্যা সাধারণ index (বাঁ বা ওপর থেকে, 0 দিয়ে শুরু), লাল সংখ্যা negative index (ডান বা নিচ থেকে, -1 দিয়ে শুরু)। একই সারির দুটি নাম: sales[1] আর sales[-4]. নিচে memory-র আসল ছবি: ৫টি সারি একটির পর একটি, প্রতিটি সারি ৭ × 8 = 56 byte. এই দুটি দূরত্ব, (56, 8), হলো array-এর strides.

1-D আর 2-D-তে একটি ঘর

একটি সারি আলাদা করে নিলে সেটি একটি 1-D array, আর সেখানে একটি সংখ্যা দিলেই একটি ঘর:

dhaka = sales[0]          # [26 74 65 50 50 80 26]  ঢাকার পুরো সপ্তাহ
dhaka[1]                  # 74   রবিবার
dhaka[-1]                 # 26   শেষ দিন, শুক্রবার
sales[2, 4]               # 78   রাজশাহী, বুধবার
sales[-1, -2]             # 58   শেষ জেলা (সিলেট), শেষের আগের দিন (বৃহঃ)
sales[2][4]               # 78   একই ফল, কিন্তু দুই ধাপে (নিচে দেখুন কেন এড়াবেন)

Negative index মানে শেষ থেকে গোনা: -1 শেষটি, -2 তার আগেরটি। নিয়মটি সহজ: দৈর্ঘ্য n হলে a[-k] আর a[n - k] একই ঘর। তাই ৭ দিনের সারিতে -2 মানে 7 - 2 = 5, বৃহস্পতিবার। সীমার বাইরে গেলে (যেমন sales[5, 0] বা dhaka[-8]) NumPy সাথে সাথে IndexError দেয়; এখানে কোনো চুপচাপ ভুল নেই।

sales[2, 4] আর sales[2][4] একই সংখ্যা দেয়, কিন্তু কাজ আলাদা। প্রথমটি এক ধাপে ঠিকানা হিসাব করে সরাসরি ঘরে যায়। দ্বিতীয়টি আগে sales[2] দিয়ে একটি নতুন array object (সারির view) বানায়, তারপর সেখান থেকে [4] নেয়। পড়ার সময় তফাত শুধু গতিতে, কিন্তু অংশ ৫ আর ৮-এ দেখবেন, এমন দুই ধাপের লেখা কখনো কখনো কিছুই বদলায় না। তাই অভ্যাস হিসেবে সবসময় এক বন্ধনীতে কমা দিয়ে লিখুন: a[i, j].

সারি আর কলাম তোলা

একটি index বাদ দিলে, বা তার জায়গায় : (মানে “সবগুলো”) দিলে, পুরো একটি দিক উঠে আসে:

sales[1]         # [68 34 26 56 88 71 73]   চট্টগ্রামের সারি, shape (7,)
sales[1, :]      # একই জিনিস, স্পষ্ট করে লেখা
sales[:, -1]     # [26 73 55 77 82]         সব জেলার শুক্রবার, shape (5,)
sales[..., -1]   # একই; ... মানে "বাকি সব দিক পুরোটা"

... (Ellipsis) লেখে “আগের বা পরের যত দিক আছে, সব পুরো নেওয়া হবে”। 2-D-তে এটি :-এর সমান, তাই এখানে লাভ সামান্য। কিন্তু পর্ব ১৮-এ ছবির array আসবে shape (উচ্চতা, প্রস্থ, ৩) নিয়ে, আর সেখানে img[..., 0] মানে “সব pixel-এর লাল channel”, দিকের সংখ্যা যাই হোক না কেন। খেয়াল করুন, সারি বা কলাম একটি integer দিয়ে তুললে একটি দিক হারিয়ে যায়: 2-D থেকে 1-D. এই কথাটি অংশ ২-এর শেষে আবার আসবে।

মূল ধারণা

প্রতিটি দিকের জন্য একটি করে ঠিকানা, কমা দিয়ে আলাদা। integer দিলে সেই দিকটি “এক ঘরে নেমে আসে” আর হারিয়ে যায়; : বা slice দিলে দিকটি থাকে। তাই ফলের দিকের সংখ্যা = মূল দিকের সংখ্যা − যতগুলো জায়গায় একক integer দিয়েছেন।

২ · Slicing — start:stop:step আর বাদ-পড়া শেষ সীমা

Slice লেখা হয় start:stop:step: কোথা থেকে শুরু, কোথায় থামা, কত ঘর লাফ। নিয়ম তিনটি। start ধরা হয়, stop ধরা হয় না। কিছু না লিখলে start মানে শুরু, stop মানে শেষ, step মানে 1. আর step negative হলে উল্টো দিকে হাঁটা। Python list-এর slicing-এর নিয়মও ঠিক এই, তাই এটি নতুন কিছু নয়।

a = np.arange(10)      # [0 1 2 3 4 5 6 7 8 9]
a[2:5]                 # [2 3 4]        5 বাদ
a[2:8:3]               # [2 5]          2, 5, (8 বাদ)
a[-3:]                 # [7 8 9]        শেষ তিনটি
a[::-2]                # [9 7 5 3 1]    উল্টো, এক ঘর বাদ দিয়ে
a[7:2]                 # []             ধাপ +1, কিন্তু 7 থেকে 2-এ যাওয়া যায় না: ফাঁকা
a[5:100]               # [5 6 7 8 9]    সীমার বাইরের stop চুপচাপ কেটে ছোট হয়

শেষ দুটি লাইন গুরুত্বপূর্ণ: index সীমার বাইরে গেলে IndexError হয়, কিন্তু slice সীমার বাইরে গেলে কোনো error হয় না, শুধু ছোট বা ফাঁকা ফল আসে। তাই ভুল slice প্রায়ই নিঃশব্দে ভুল ডেটা দেয়।

একটি slice-এ কয়টি উপাদান — হিসাবের সূত্র

পরীক্ষায় প্রায়ই জিজ্ঞাসা করা হয় “এই slice-এর shape কী?”। ধনাত্মক step-এর জন্য, start আর stop সীমার ভেতরে কেটে নেওয়ার পর, উপাদানের সংখ্যা:

n = max(0, ⌈(stop − start) / step⌉)

এখানে ⌈x⌉ মানে x-এর চেয়ে ছোট নয় এমন সবচেয়ে ছোট পূর্ণসংখ্যা (ওপরের দিকে round). a[2:8:3]: (8 − 2) / 3 = 2, তাই ২টি উপাদান। a[1:8:3]: (8 − 1) / 3 ≈ 2.33, ওপরে নিলে ৩টি: 1, 4, 7. বাজারঘরের খাতায় sales[1:4, ::3]: সারি (4 − 1) / 1 = ৩টি, কলাম (7 − 0) / 3 ≈ 2.33 থেকে ৩টি (0, 3, 6), তাই shape (3, 3). আর sales[:, 1:6:2]: কলাম 1, 3, 5, shape (5, 3). দুটিই নিজে চালিয়ে মিলিয়ে নিয়েছি।

2-D slice — প্রতিটি দিকে আলাদা slice

2-D-তে কমার দুই পাশে দুটি আলাদা slice বসে: প্রথমটি কোন সারি, দ্বিতীয়টি কোন কলাম। ফল তাদের ছেদ, একটি আয়তাকার টুকরো। নিচের ধাপে sales[1:3, ::2] ঘরে ঘরে দেখুন। “পরে” চাপুন।

সারি: 0 ঢাকা · 1 চট্টগ্রাম · 2 রাজশাহী · 3 খুলনা · 4 সিলেট
কলাম: 0 শনি … 6 শুক্র

0
1
2
3
4
5
6
0
26
74
65
50
50
80
26
1
68
34
26
56
88
71
73
2
70
75
55
28
78
51
55
3
45
32
84
74
65
48
77
4
58
51
51
35
26
58
82
  1. পুরো খাতা। লক্ষ্য sales[1:3, ::2]. কমার বাঁয়ে সারির নির্দেশ 1:3, ডানে কলামের নির্দেশ ::2. দুটি আলাদা করে পড়তে হয়।
  2. সারির অংশ 1:3. start 1 ধরা হয়, stop 3 ধরা হয় না, তাই সারি 1 (চট্টগ্রাম) আর 2 (রাজশাহী)। খুলনার সারি 3 বাদ, এটিই “শেষ সীমা বাদ” নিয়ম।
  3. কলামের অংশ ::2. start আর stop খালি মানে পুরো দৈর্ঘ্য, step 2 মানে এক ঘর বাদ দিয়ে: কলাম 0, 2, 4, 6, অর্থাৎ শনি, সোম, বুধ, শুক্র। সূত্রে ⌈7 / 2⌉ = 4.
  4. ছেদ। যে ঘর দুই শর্তই মানে, শুধু সেগুলো থাকে: 2 × 4 = 8টি ঘর। NumPy এগুলো আলাদা করে কপি করে না; শুধু শুরুর ঠিকানা আর নতুন লাফের মাপ (stride) মনে রাখে।
  5. ফল। [[68 26 88 73] [70 55 78 55]], shape (2, 4). দুটি দিকেই slice দেওয়া হয়েছে, তাই কোনো দিক হারায়নি। এটি একটি view: অংশ ৩-এ দেখবেন, এর কোনো ঘরে লিখলে মূল sales-ও বদলায়।
  6. Negative index দিয়ে একটি ঘর। sales[-1, -2]: শেষ সারি (সিলেট, 5 − 1 = 4) আর শেষের আগের কলাম (বৃহঃ, 7 − 2 = 5). মান 58. দুটিই integer, তাই ফল আর array নয়, একটি একক সংখ্যা।

Shape-এর চমক — a[0] বনাম a[0:1]

এক সারির ডেটা দুইভাবে তোলা যায়, আর দুটির shape আলাদা। এই পার্থক্য পরে model-এ ডেটা দেওয়ার সময় সবচেয়ে বেশি error ঘটায়, কারণ scikit-learn-এর মতো library সবসময় 2-D (সারি × feature) চায়।

লেখাফলshapeকেন
sales[0][26 74 … 26](7,)integer: সারির দিক হারায়
sales[0:1][[26 74 … 26]](1, 7)slice: দিক থাকে, দৈর্ঘ্য 1
sales[:, 2][65 26 55 84 51](5,)কলাম, কিন্তু 1-D
sales[:, 2:3]৫ সারি, ১ কলাম(5, 1)কলামের আকার থাকে
sales[2, 4]78()দুটিই integer: একক সংখ্যা (NumPy scalar)

কোনটি ঠিক, তা নির্ভর করে পরের কাজের ওপর। একটি কলামকে আরেকটি 2-D array-এর পাশে জোড়া দিতে বা model-এ একটি feature দিতে (5, 1) লাগে; গড় বা যোগফলের জন্য (5,) যথেষ্ট। পরের পর্বে broadcasting শিখলে দেখবেন, (5,) আর (5, 1) একসঙ্গে মেলালে একেবারে ভিন্ন ফল আসে।

৩ · View — একই খাতা, নতুন জানালা

দোকানের হিসাব খাতার একটি পাতার ফটোকপি আর সেই পাতায় একটি স্বচ্ছ ফ্রেম রাখার কথা ভাবুন। ফটোকপিতে কিছু লিখলে আসল খাতা বদলায় না। কিন্তু ফ্রেমের ভেতর দিয়ে লিখলে কলম সরাসরি আসল পাতাতেই পড়ে; ফ্রেম শুধু ঠিক করে কোন অংশ দেখা যাবে। NumPy-র basic slice হলো এই ফ্রেম, যার নাম view. আর .copy() হলো ফটোকপি, যার নাম copy.

কেন NumPy এমন করে? কারণ slice বানাতে কোনো সংখ্যা সরাতে হয় না। ছবি ১৪.১-এর memory-তে ফিরে যান: সারিগুলো row-major ক্রমে পরপর বসানো। sales-এর strides (56, 8): পরের সারিতে যেতে 56 byte, পরের কলামে 8 byte. sales[:, ::2] মানে “প্রতিটি সারিতে এক কলাম বাদ দিয়ে”, তাই এর strides হয় (56, 16). শুরুর ঠিকানা আর strides বদলালেই নতুন array তৈরি, data একটিও কপি হয় না। ফলে কোটি সংখ্যার array-তেও slice নেওয়া প্রায় শূন্য সময়ে হয়, আর বাড়তি memory লাগে না। দামটি হলো: দুটি নাম, একই সংখ্যা। একটিতে লিখলে অন্যটিও বদলায়।

নিচের কোড সেটি নিজের চোখে দেখায়। np.shares_memory(a, b) বলে দুটি array-এর memory কোথাও মেলে কি না, আর .base বলে একটি view কোন array-এর memory ধার করেছে (নিজের memory থাকলে None).

import numpy as np
rng = np.random.default_rng(42)
sales = rng.integers(20, 90, size=(5, 7))   # ৫ জেলা × ৭ দিন, হাজার টাকা

first2 = sales[:, :2]                # slice: view
print("shape:", first2.shape)
print("shares_memory:", np.shares_memory(first2, sales))
print("base is sales:", first2.base is sales)

first2[0, 0] = 999                   # view-এ লেখা
print("sales[0, 0] =", sales[0, 0])  # মূল array-ও বদলেছে

safe = sales[:, :2].copy()           # নিজস্ব memory
safe[0, 1] = -1
print("sales[0, 1] =", sales[0, 1])  # বদলায়নি
print("copy:", np.shares_memory(safe, sales), safe.base)

cols = sales[:, [0, 2]]              # fancy index: copy
print("fancy:", np.shares_memory(cols, sales), cols.base is None)
print("strides:", sales.strides, sales[:, ::2].strides)

আসল আউটপুট (ব্রাউজারের Pyodide, NumPy 2.4.6):

shape: (5, 2)
shares_memory: True
base is sales: True
sales[0, 0] = 999
sales[0, 1] = 74
copy: False None
fancy: False False
strides: (56, 8) (56, 16)

লাইন ধরে পড়ুন। first2 প্রথম দুই দিনের slice, shape (5, 2), আর তার memory sales-এরই অংশ (True, base is sales). first2[0, 0] = 999 লেখার পর sales[0, 0]-ও 999: ঢাকার শনিবারের আসল বিক্রি 26 হারিয়ে গেছে। .copy() দিয়ে বানানো safe-এ লিখলে মূলের sales[0, 1] আগের মতো 74 থাকে, কারণ safe-এর নিজস্ব memory (base হলো None). শেষের আগের লাইনে একটি সূক্ষ্ম কথা: কলামের তালিকা দিয়ে তোলা cols একটি copy (shares_memory False), তবু এর base None নয়, কারণ NumPy ভেতরে একটি অস্থায়ী array বানিয়ে তার view ফেরত দিয়েছে। তাই “base is None মানে copy” পরীক্ষাটি ভরসাযোগ্য নয়; copy কি না জানতে np.shares_memory দেখুন। শেষ লাইনে strides: এখানে dtype int64, তাই কলামে 8 byte আর সারিতে 7 × 8 = 56 byte; ::2 কলামের লাফ দ্বিগুণ করে 16 করেছে।

কখন .copy() ডাকবেন

  • টুকরো বদলাবেন, কিন্তু মূল অক্ষত রাখতে চান: যেমন পরীক্ষামূলকভাবে কিছু মান কেটে দেখা। trial = sales[:, :3].copy().
  • বড় array-এর ছোট টুকরো অনেক দিন রাখবেন: view বেঁচে থাকলে পুরো মূল array-এর memory-ও ছাড়া যায় না। এক GB-র array থেকে ১০টি সারির view রেখে বাকিটা del করলেও পুরো GB আটকে থাকে। small = big[:10].copy() দিলে শুধু ১০টি সারির memory থাকে।
  • Function-এ পাওয়া array বদলাতে হবে: ডাকা জায়গার array চুপচাপ বদলে দেওয়া একটি side effect; পর্ব ১১-এর পরিচ্ছন্ন কোডের নিয়মে শুরুতেই x = x.copy() ভালো অভ্যাস।

আর কখন দরকার নেই? শুধু পড়তে, যোগ-গড় করতে, বা ছবি আঁকতে view-ই যথেষ্ট আর দ্রুত। অকারণ copy বড় ডেটায় memory দ্বিগুণ করে। Python-এর list-এ কিন্তু উল্টো: lst[1:3] একটি নতুন list (shallow copy). দুই জগতের এই পার্থক্য নিয়ে Python কোর্সের mutability, alias আর copy পর্ব সহায়ক।

৪ · Boolean mask — শর্ত দিয়ে ছাঁকা, গোনা আর বদলানো

পরীক্ষার খাতা দেখার পর শিক্ষক প্রতিটি নামের পাশে “পাশ” বা “ফেল” লেখেন। এবার সেই পাশ-ফেলের তালিকা দিয়েই বলা যায়: “যারা পাশ করেছে, তাদের নম্বর দেখান” বা “কতজন পাশ করেছে”। Boolean mask ঠিক এই তালিকা। sales > 75 লিখলে NumPy প্রতিটি ঘরের জন্য আলাদা করে তুলনা করে, আর একই shape-এর একটি True/False array দেয়। কোনো loop লিখতে হয় না, এটিই vectorization-এর প্রথম স্বাদ।

sales > 75          # shape (5, 7), dtype bool
[[False False False False False  True False]
 [False False False False  True False False]
 [False False False False  True False False]
 [False False  True False False False  True]
 [False False False False False False  True]]

এই mask দিয়ে তিনটি কাজ হয়। বাছাই: sales[mask] শুধু True ঘরের মানগুলো দেয়, একটি 1-D array হিসেবে (2-D আকার থাকে না, কারণ True ঘরগুলো আয়তাকার নয়)। গোনা: True মানে 1 আর False মানে 0 ধরে mask.sum() গোনে কয়টি ঘর শর্ত মানে, আর mask.mean() দেয় কত ভাগ। mask.sum(axis=1) প্রতিটি সারিতে আলাদা করে গোনে। বসানো: sales[mask] = x শুধু True ঘরগুলোতে x বসায়। নিচের ধাপে চট্টগ্রামের সপ্তাহ দিয়ে তিনটি কাজ দেখুন।

1d = sales[1]          # চট্টগ্রাম (view)2mask = d > 703d[mask]4mask.sum(), mask.mean()5d[mask] = 70          # ৭০-এ কাটা6print(sales[1])
মান (Watch)
d
mask
ফল
  1. একটি সারি তোলা। d = sales[1] চট্টগ্রামের সাত দিন। integer index দিয়ে সারি তোলা basic indexing, তাই d একটি view, নতুন সংখ্যা নয়। এই কথাটি ধাপ ৬-এ কাজে লাগবে।
  2. Mask বানানো। d > 70 সাতটি ঘরে আলাদা করে তুলনা করে: 68, 34, 26, 56 না (False); 88, 71, 73 হ্যাঁ (True). mask-এর shape d-এর মতোই (7,)।
  3. ছাঁকা। d[mask] শুধু True ঘরের মান দেয়: [88 71 73], shape (3,)। mask দিয়ে পড়া সবসময় একটি নতুন copy, তাই এতে কিছু লিখলে d বদলাবে না।
  4. গোনা। True গুলোকে 1 ধরে যোগ: 3টি দিন ৭০ হাজারের বেশি। গড় 3 / 7 ≈ 0.4286, মানে সপ্তাহের প্রায় ৪৩% দিন। নিজে loop লিখে গোনার দরকার নেই।
  5. Mask দিয়ে বসানো। d[mask] = 70 একটি এক ধাপের লেখা, তাই এটি সরাসরি d-এর True ঘরগুলোতে 70 বসায়। মান কেটে একটি সীমায় আনাকে বলে capping; এখানে শুধু কাজটি দেখানোর জন্য, ভালো কারণ ছাড়া আসল ডেটায় এমন করা উচিত নয়।
  6. মূলও বদলেছে। d ছিল sales-এর সারির view, তাই sales[1] এখন [68 34 26 56 70 70 70]. মূল ডেটা অক্ষত রাখতে চাইলে শুরুতেই d = sales[1].copy() লিখতে হতো।

চালিয়ে দেখুন — ভালো দিন গোনা আর ভুল এন্ট্রি সারানো

বাস্তবের ডেটায় ভুল থাকে। ধরুন রাজশাহীর মঙ্গলবারের 28 হাজার কেউ ভুল করে 280 লিখেছেন। নিচের কোড প্রথমে ৭৫ হাজারের বেশি বিক্রির দিন গোনে, তারপর একটি ব্যবসায়িক নিয়ম দিয়ে (একটি জেলায় একদিনে ২০০ হাজারের বেশি বিক্রি অসম্ভব) সন্দেহজনক ঘর খুঁজে সেই জেলার median দিয়ে বদলায়। এমন অস্বাভাবিক মানকে বলে outlier.

import numpy as np
rng = np.random.default_rng(42)
sales = rng.integers(20, 90, size=(5, 7))
sales[2, 3] = 280                    # ভুল এন্ট্রি: 28-এর পেছনে বাড়তি 0
districts = np.array(["ঢাকা", "চট্টগ্রাম", "রাজশাহী", "খুলনা", "সিলেট"])
days = np.array(["শনি", "রবি", "সোম", "মঙ্গল", "বুধ", "বৃহঃ", "শুক্র"])

good = sales > 75                    # একই shape-এর True/False array
print("৭৫-এর বেশি দিন:", good.sum(), "| ভাগ:", round(good.mean(), 3))
print("প্রতি জেলায়:", good.sum(axis=1))
print("ঢাকার ভালো দিন:", days[good[0]], sales[0][good[0]])

bad = sales > 200                    # ব্যবসার নিয়ম: একদিনে ২০০-র বেশি অসম্ভব
r, c = np.nonzero(bad)
print("সন্দেহজনক:", districts[r], days[c], sales[bad])

row_med = np.median(sales, axis=1)   # প্রতি জেলার median
print("median:", row_med)
sales[r, c] = row_med[r]             # শুধু সেই ঘরগুলোতে বসানো
print("রাজশাহী এখন:", sales[2])
print("সর্বোচ্চ এখন:", sales.max())

আসল আউটপুট (ব্রাউজারের Pyodide, NumPy 2.4.6):

৭৫-এর বেশি দিন: 7 | ভাগ: 0.2
প্রতি জেলায়: [1 1 2 2 1]
ঢাকার ভালো দিন: ['বৃহঃ'] [80]
সন্দেহজনক: ['রাজশাহী'] ['মঙ্গল'] [280]
median: [50. 68. 70. 65. 51.]
রাজশাহী এখন: [70 75 55 70 78 51 55]
সর্বোচ্চ এখন: 88

৩৫টি ঘরের ৭টি ৭৫-এর বেশি, মানে ২০%. কিন্তু খেয়াল করুন, রাজশাহীর ২টি “ভালো দিন”-এর একটি আসলে সেই ভুল 280: ভুল ডেটা আগে না সারালে গোনাও ভুল হয়। তাই বাস্তবে outlier যাচাই আগে, সারাংশ পরে। days[good[0]] দেখায়, একটি mask দিয়ে অন্য array-ও ছাঁকা যায়, যদি দৈর্ঘ্য মেলে: ঢাকার mask দিয়ে দিনের নাম। np.nonzero(bad) True ঘরগুলোর সারি আর কলামের index আলাদা দুটি array-তে দেয় (অংশ ৫)। Median দিয়ে বদলানো হয়েছে, কারণ গড় নিলে 280 নিজেই গড়কে টেনে ওপরে তুলত; median এমন একক বড় মানে প্রায় নড়ে না (রাজশাহীর median 70). শেষে একটি নিঃশব্দ রূপান্তর আছে: row_med float (70.), কিন্তু sales int64, তাই বসানোর সময় NumPy মানটিকে চুপচাপ পূর্ণসংখ্যায় কেটে 70 করেছে, কোনো সতর্কবার্তা ছাড়া। median ভগ্নাংশ হলে (যেমন 70.5) দশমিক অংশ হারিয়ে যেত।

Outlier মানেই ভুল নয়। ঈদের আগের সপ্তাহে বিক্রি সত্যিই তিন গুণ হতে পারে। একটি মান বদলানোর আগে কারণ খুঁজুন, কয়টি ঘর বদলালেন তা গুনে লিখে রাখুন, আর সম্ভব হলে মূল ডেটা অক্ষত রেখে নতুন কলামে সংশোধিত মান রাখুন। চুপচাপ বদলানো ডেটা পরে কেউ যাচাই করতে পারে না। Anomaly detection-এর নিয়মতান্ত্রিক পদ্ধতি পরে পর্ব ৩৭ আর ৬২-এর কাছাকাছি আসবে।

৫ · শর্ত জোড়া — &, |, ~, আর কেন and চলে না

“৫০ থেকে ৭৫-এর মধ্যে” একটি জোড়া শর্ত। NumPy-তে ঘরে ঘরে “এবং” হলো &, “অথবা” হলো |, আর “না” হলো ~. প্রতিটি শর্ত অবশ্যই বন্ধনীর ভেতরে: (sales >= 50) & (sales <= 75). কেন বন্ধনী, আর কেন Python-এর চেনা and চলে না, নিচের কোড আসল error দিয়েই দেখায়।

import numpy as np
rng = np.random.default_rng(42)
sales = rng.integers(20, 90, size=(5, 7))

mid = (sales >= 50) & (sales <= 75)
extreme = (sales < 30) | (sales > 85)
print("50–75:", mid.sum(), "| খুব কম বা বেশি:", extreme.sum())
print("বাকি:", (~(mid | extreme)).sum(), "| মোট:", sales.size)

try:
    (sales >= 50) and (sales <= 75)
except ValueError as e:
    print("and ->", type(e).__name__ + ":", e)

try:
    sales >= 50 & sales <= 75        # বন্ধনী নেই
except ValueError as e:
    print("বন্ধনী ছাড়া ->", type(e).__name__)

print("50 & sales[0] =", 50 & sales[0])
print("any/all:", (sales > 85).any(), (sales >= 20).all())

আসল আউটপুট (ব্রাউজারের Pyodide, NumPy 2.4.6):

50–75: 19 | খুব কম বা বেশি: 6
বাকি: 10 | মোট: 35
and -> ValueError: The truth value of an array with more than one element is ambiguous. Use a.any() or a.all()
বন্ধনী ছাড়া -> ValueError
50 & sales[0] = [18  2  0 50 50 16 18]
any/all: True True

প্রথম দুই লাইনে হিসাব মেলে: 19 + 6 + 10 = 35, সব ঘর গোনা হয়েছে, কোনোটি দুবার নয় (দুটি শর্ত একে অপরকে ছোঁয় না)। ~ উল্টে দেয়, তাই ~(mid | extreme) হলো “কোনো দলেই নেই”: 30–49 আর 76–85-এর ঘরগুলো। এরপর আসল error. Python-এর and দুই পাশের প্রতিটিকে একটি একক True বা False-এ নামাতে চায় (bool(...)), কারণ এটি short-circuit করে: বাঁ পাশ False হলে ডান পাশ দেখেই না। কিন্তু ৩৫টি True/False-এর একটি array “সত্যি” কি না, প্রশ্নটিই অস্পষ্ট: সব True হলে, নাকি অন্তত একটি? NumPy অনুমান না করে ValueError দেয়, আর বার্তাতেই বলে দেয় a.any() (অন্তত একটি) বা a.all() (সবগুলো) দিয়ে স্পষ্ট করতে। বন্ধনী ছাড়া লেখায় সমস্যা অন্য: Python-এ &-এর অগ্রাধিকার >=-এর চেয়ে বেশি, তাই আগে 50 & sales হিসাব হয়, যা একটি bitwise AND (সংখ্যার binary অঙ্ক ধরে AND). পরের লাইন দেখায় সেটি কী অদ্ভুত সংখ্যা দেয়: 50 & 26 = 18. তারপর পুরো লেখাটি একটি chained comparison হয়ে যায়, যা ভেতরে আবার and ব্যবহার করে, তাই সেই একই ValueError.

দুটি সংক্ষিপ্ত নিয়ম। (১) প্রতিটি তুলনা বন্ধনীতে, মাঝে & বা |; “না” লিখতে ~, not নয়। (২) একটি একক উত্তর দরকার হলে (“কোনো দিন কি ৮৫ ছাড়িয়েছে?”) .any() বা .all() দিয়ে mask-টিকে একটি True বা False-এ নামান, তারপর if-এ দিন। আর ~ শুধু bool array-তে ব্যবহার করুন: int array-তে ~5 মানে −6 (bitwise NOT), “না” নয়।

np.where — মান বাছাই আর ঠিকানা খোঁজা

np.where-এর দুটি আলাদা চেহারা আছে, আর দুটিকে গুলিয়ে ফেলা খুব সাধারণ ভুল।

তিনটি argument: ঘরে ঘরে বাছাই। np.where(শর্ত, x, y) প্রতিটি ঘরে শর্ত True হলে x থেকে, False হলে y থেকে মান নেয়, আর একটি নতুন array দেয়; মূল array বদলায় না। Excel-এর IF function-এর মতো, কিন্তু পুরো টেবিলে একসঙ্গে।

np.where(sales[0] >= 60, "ভালো", "কম")
# ['কম' 'ভালো' 'ভালো' 'কম' 'কম' 'ভালো' 'কম']
np.where(sales > 80, 80, sales)[1]     # ৮০-এর ওপরে কেটে, চট্টগ্রামের সারি
# [68 34 26 56 80 71 73]               (np.clip(sales, None, 80)-ও একই কাজ করে)

একটি argument: True ঘরের ঠিকানা। np.where(শর্ত) আর np.nonzero(শর্ত) একই জিনিস: প্রতিটি দিকের জন্য একটি index array, একটি tuple-এ। 1-D হলেও ফল tuple, তাই শেষে একটি কমা দেখা যায়, যা অনেককে চমকে দেয়। NumPy-র docs নিজেই এই রূপের বদলে সরাসরি np.nonzero লিখতে পরামর্শ দেয়, কারণ নামটিই কাজ বলে দেয়।

np.where(sales[0] > 70)          # (array([1, 5]),)   ঢাকার রবি আর বৃহঃ
np.nonzero(sales > 80)           # (array([1, 3, 4]), array([4, 2, 6]))
np.argwhere(sales > 80)          # [[1 4] [3 2] [4 6]]   প্রতি সারিতে একটি (সারি, কলাম) জোড়া
np.flatnonzero(sales > 80)       # [11 23 34]          সমতল index: সারি × 7 + কলাম

np.nonzero-এর দুটি array পাশাপাশি পড়তে হয়: (1, 4), (3, 2), (4, 6), মানে চট্টগ্রামের বুধ (88), খুলনার সোম (84), সিলেটের শুক্র (82). np.argwhere একই তথ্য জোড়া হিসেবে সাজায়, যা ছাপার জন্য সুবিধার; আর index হিসেবে আবার ব্যবহার করতে np.nonzero-এর tuple সরাসরি চলে: sales[np.nonzero(sales > 80)] দেয় [88 84 82], যা sales[sales > 80]-এর সমান।

৬ · Fancy index — তালিকা দিয়ে বাছাই, আর সবসময় copy

বাজার করার ফর্দের কথা ভাবুন: “৩ নম্বর তাক, তারপর ১ নম্বর তাক”। ক্রম আপনার ইচ্ছামতো, একই তাক দুবারও লেখা যায়। NumPy-তে integer-এর একটি list বা array দিয়ে index করাকে বলে fancy indexing (অফিসিয়াল নাম advanced indexing). sales[[3, 1]] দেয় খুলনা আর চট্টগ্রামের সারি, ঠিক এই ক্রমে; sales[[0, 0, 4]] ঢাকার সারি দুবার আর সিলেটের একবার।

Slice-এর সঙ্গে মূল পার্থক্য: তালিকার index-গুলো কোনো নিয়মিত লাফ মেনে চলে না (৩, তারপর ১), তাই শুধু শুরুর ঠিকানা আর strides দিয়ে এদের বর্ণনা করা যায় না। NumPy-কে তাই সংখ্যাগুলো নতুন memory-তে কপি করতে হয়। Fancy index সবসময় copy দেয়, আর boolean mask দিয়ে পড়াও একই কারণে copy. নিচের ধাপে দেখুন এর ফল কী।

1top = sales[[3, 1]]        # খুলনা, চট্টগ্রাম2np.shares_memory(top, sales)3top[0, 0] = 04sales[[3, 1]][0, 0] = 0    # শিকলবাঁধা5sales[[3, 1], 0] = 0       # এক ধাপে
মান (Watch)
top
sales[3, 0]
sales[1, 0]
  1. তালিকা দিয়ে দুটি সারি। sales[[3, 1]] খুলনা (সারি 3) আর চট্টগ্রাম (সারি 1)-এর সারি নতুন memory-তে কপি করে, তালিকার ক্রমে। top-এর shape (2, 7).
  2. Memory ভাগ হয় না। np.shares_memory(top, sales) দেয় False. slice হলে এখানে True আসত।
  3. Copy-তে লেখা। top[0, 0] = 0 শুধু top বদলায়। মূল sales[3, 0] আগের মতোই 45. এটি প্রত্যাশিত আচরণ, কোনো ভুল নয়।
  4. শিকলবাঁধা লেখার ফাঁদ। sales[[3, 1]][0, 0] = 0 দেখতে sales বদলানোর মতো, কিন্তু আসলে দুটি ধাপ: আগে sales[[3, 1]] একটি নামহীন copy বানায়, তারপর সেই copy-তে 0 বসে, তারপর copy-টি ফেলে দেওয়া হয়। sales[3, 0] এখনো 45, আর কোনো error বা সতর্কবার্তাও নেই।
  5. এক ধাপে লেখা। sales[[3, 1], 0] = 0 একটি একক index-এ লেখা, তাই NumPy সরাসরি মূল array-এর ঘরে বসায়: খুলনা আর চট্টগ্রামের শনিবার দুটিই এখন 0. নিয়ম: বদলাতে চাইলে সব ঠিকানা এক বন্ধনীতে।

দুটি তালিকা একসঙ্গে — জোড়া, নাকি জাল (np.ix_)

দুটি দিকেই তালিকা দিলে NumPy সেগুলো জোড়া হিসেবে পড়ে, প্রথমটির সঙ্গে প্রথমটি, দ্বিতীয়টির সঙ্গে দ্বিতীয়টি। “ঢাকা আর রাজশাহীর রবি আর মঙ্গলবার” চাইলে অনেকে লেখেন sales[[0, 2], [1, 3]], আর পান মাত্র দুটি সংখ্যা: (0, 1) আর (2, 3) ঘর। চারটি ঘরের পুরো জাল (প্রতিটি সারি × প্রতিটি কলাম) চাইলে np.ix_ লাগে, যা তালিকা দুটিকে এমন আকারে সাজায় যাতে সব জোড়া তৈরি হয়।

sales[[0, 2], [1, 3]]                    # [74 28]      শুধু (0,1) আর (2,3)
sales[np.ix_([0, 2], [1, 3])]            # [[74 50]
                                         #  [75 28]]    ২ × ২ জাল
sales[np.ix_([0, 2], [1, 3, 5])]         # [[74 50 80]
                                         #  [75 28 51]] ২ × ৩ জাল
sales[[0, 2]][:, [1, 3]]                 # একই জাল, কিন্তু দুটি copy বানিয়ে

শেষ লাইনটিও ঠিক ফল দেয়, কারণ এটি শুধু পড়া। কিন্তু একই রূপে বসাতে গেলে (sales[[0, 2]][:, [1, 3]] = 0) সেই শিকলবাঁধা ফাঁদ: কিছুই বদলাবে না। বসানোর জন্য sales[np.ix_([0, 2], [1, 3])] = 0 লিখুন। পর্ব ১৫-এ broadcasting শিখলে বুঝবেন np.ix_ ভেতরে আসলে কী করে: সারির তালিকাকে shape (2, 1) আর কলামের তালিকাকে (1, 2) বানায়, যাতে broadcasting সব জোড়া তৈরি করে।

আরও গভীরে। NumPy-র indexing-এর পুরো নিয়ম লেখা আছে অফিসিয়াল docs-এর “Indexing on ndarrays” অংশে, আর memory-র মডেলটি (data pointer, shape, strides, dtype) ব্যাখ্যা করেছে van der Walt, Colbert ও Varoquaux-এর ২০১১ সালের প্রবন্ধ “The NumPy array: a structure for efficient numerical computation”; ২০২০ সালে Nature-এ প্রকাশিত Harris ও সহকর্মীদের “Array programming with NumPy” পুরো নকশার একটি আধুনিক সারাংশ। একটি সূক্ষ্ম নিয়ম: fancy index দিয়ে বসানোর সময় একই index দুবার থাকলে a[[0, 0, 1]] += 1 ঘর 0-তে একবারই 1 যোগ করে (ফল [1 1 0 0]), কারণ এটি “পড়া, যোগ, লেখা” একবারে করে; প্রতিটি পুনরাবৃত্তি গুনতে চাইলে np.add.at(a, [0, 0, 1], 1) লাগে (ফল [2 1 0 0]). আর np.lib.stride_tricks.as_strided দিয়ে নিজে strides বানানো যায়, কিন্তু ভুল হলে array-এর বাইরের memory পড়ে; docs নিজেই এটি সাবধানে ব্যবহার করতে বলে।

৭ · সেরাটি আর সেরা k-টি — argmax, argsort, argpartition

ক্রিকেটের স্কোরকার্ডে “সর্বোচ্চ রান” জানা এক জিনিস, আর “সর্বোচ্চ রান কে করেছে” জানা আরেক জিনিস। sales.max() প্রথম প্রশ্নের উত্তর দেয়: 88. কিন্তু ডেটার কাজে প্রায় সবসময় দ্বিতীয়টি লাগে: কোন জেলা, কোন দিন। NumPy-র যে function-এর নাম arg দিয়ে শুরু, সেগুলো মান নয়, index দেয়। আর সেই index দিয়ে অন্য array (জেলার নাম, দিনের নাম) থেকে তথ্য তোলা যায়, যা fancy indexing-এর সবচেয়ে কাজের ব্যবহার।

  • argmax / argmin: সবচেয়ে বড় বা ছোট মানের index. 2-D-তে axis না দিলে array-কে সমতল ধরে একটি সংখ্যা দেয় (11), যাকে np.unravel_index দিয়ে (সারি, কলাম)-এ ফেরানো যায়। axis=1 দিলে প্রতিটি সারির সেরার index. সমান মান থাকলে প্রথমটি।
  • argsort: এমন index-এর ক্রম, যা দিয়ে সাজালে array ছোট থেকে বড় হয়: a[np.argsort(a)] সাজানো a. খরচ O(n log n), মানে n দ্বিগুণ হলে কাজ দ্বিগুণের একটু বেশি।
  • argpartition: np.argpartition(a, -k) index এমনভাবে সাজায় যে শেষ k-টি অবস্থানে বড় k-টির index থাকে, কিন্তু সেই k-টির নিজেদের মধ্যে ক্রম নিশ্চিত নয়, বাকিগুলোরও নয়। ভেতরে introselect নামের একটি selection অ্যালগরিদম চলে, গড়ে O(n) খরচে। বড় n আর ছোট k-তে (১০ লাখ পণ্যের মধ্যে সেরা ১০টি) এটি পুরো sort-এর চেয়ে সস্তা।
import numpy as np
rng = np.random.default_rng(42)
sales = rng.integers(20, 90, size=(5, 7))
districts = np.array(["ঢাকা", "চট্টগ্রাম", "রাজশাহী", "খুলনা", "সিলেট"])
days = np.array(["শনি", "রবি", "সোম", "মঙ্গল", "বুধ", "বৃহঃ", "শুক্র"])

total = sales.sum(axis=1)
order = np.argsort(total)            # ছোট থেকে বড়, index ফেরত দেয়
print("মোট:", total, "| argsort:", order)

top3 = order[::-1][:3]               # উল্টে প্রথম তিনটি
print("argsort top-3:", districts[top3], total[top3])

part = np.argpartition(total, -3)[-3:]   # বড় তিনটি, ক্রম নিশ্চিত নয়
print("argpartition:", part, districts[part])
part = part[np.argsort(total[part])[::-1]]  # শুধু ৩টি সাজানো
print("সাজানো:", districts[part], total[part])

flat = sales.argmax()                # সমতল (flat) index
i, j = np.unravel_index(flat, sales.shape)
print("argmax:", flat, "->", (int(i), int(j)), districts[i], days[j], sales[i, j])
print("প্রতি জেলার সেরা দিন:", days[sales.argmax(axis=1)])

আসল আউটপুট (ব্রাউজারের Pyodide, NumPy 2.4.6):

মোট: [371 416 412 425 361] | argsort: [4 0 2 1 3]
argsort top-3: ['খুলনা' 'চট্টগ্রাম' 'রাজশাহী'] [425 416 412]
argpartition: [2 3 1] ['রাজশাহী' 'খুলনা' 'চট্টগ্রাম']
সাজানো: ['খুলনা' 'চট্টগ্রাম' 'রাজশাহী'] [425 416 412]
argmax: 11 -> (1, 4) চট্টগ্রাম বুধ 88
প্রতি জেলার সেরা দিন: ['বৃহঃ' 'বুধ' 'বুধ' 'সোম' 'শুক্র']

argsort-এর ফল [4 0 2 1 3] পড়তে হয় এভাবে: সবচেয়ে কম বিক্রি সারি 4-এর (সিলেট, 361), তারপর 0 (ঢাকা, 371), … সবচেয়ে বেশি 3 (খুলনা, 425). উল্টে প্রথম তিনটি নিলে সেরা তিন। argpartition একই তিনটি জেলা দিয়েছে, কিন্তু এলোমেলো ক্রমে, রাজশাহী আগে: এটি ভুল নয়, এই function ক্রমের কোনো প্রতিশ্রুতি দেয় না। প্রমাণও হাতেনাতে: একই কোড আমাদের ডেস্কটপের NumPy 2.5.1-এ দিয়েছে [2 1 3], আর ব্রাউজারের NumPy 2.4.6-এ [2 3 1]. তাই top-k-এর সাধারণ রেসিপি: আগে argpartition দিয়ে k-টি আলাদা, তারপর শুধু সেই k-টি argsort. খরচ মোটামুটি O(n + k log k). শেষ দুই লাইনে argmax: সমতল index 11 মানে 11 = 1 × 7 + 4, অর্থাৎ সারি 1, কলাম 4, চট্টগ্রামের বুধবার, 88 হাজার। int(i) লেখা হয়েছে যাতে ছাপায় np.int32(1) বা np.int64(1) না আসে; ব্রাউজারের Python-এ index-এর dtype int32, ডেস্কটপে সাধারণত int64.

সমান মানে (tie) ক্রম কে ঠিক করে? np.argsort-এর default অ্যালগরিদম stable নয়; সমান মানের ক্রম স্থির রাখতে kind="stable" দিন। আর বড় থেকে ছোট চাইলে দুটি পথ আলাদা ফল দিতে পারে: [5, 3, 5, 1]-এ np.argsort(-a, kind="stable") দেয় [0 2 1 3] (সমান ৫-এর মধ্যে আগেরটি আগে), কিন্তু np.argsort(a, kind="stable")[::-1] দেয় [2 0 1 3] (পরেরটি আগে)। NumPy-র সাম্প্রতিক কিছু সংস্করণে np.argsort-এ একটি descending parameter-ও আছে (আমাদের ডেস্কটপের NumPy 2.5.1-এ আছে, আর সেখানে এটি [0 2 1 3] দিয়েছে), কিন্তু পুরোনো version-এ নেই, তাই ব্যবহারের আগে নিজের version-এর docs দেখুন। র‍্যাঙ্কিং-এ, বা পরে recommendation-এ, কোন নিয়ম চান তা আগে ঠিক করে কোডে লিখুন। Quickselect আর partition-এর মূল ভাবনা DSA কোর্সের Quick sort, pivot আর quickselect পর্বে আছে।

AI-এর কাজে top-k সর্বত্র: k-nearest neighbors-এ সবচেয়ে কাছের k-টি প্রতিবেশী (পর্ব ৫৭-এ NumPy দিয়ে নিজে লিখবেন), একটি recommendation system-এ সবচেয়ে মানানসই ১০টি পণ্য, embedding দিয়ে খোঁজায় (পর্ব ৬৯) সবচেয়ে কাছের লেখা, আর ভাষার model-এ পরের token বাছাইয়ের “top-k sampling”. সবখানে একই দুই ধাপ: অনেক স্কোর থেকে argpartition দিয়ে k-টি, তারপর সেগুলো সাজানো।

৮ · সাধারণ ভুল আর view-copy-র তালিকা

Indexing-এর ভুলগুলোর বিশেষত্ব হলো, এরা প্রায় কখনো error দেয় না। কোড চলে, সংখ্যা ছাপে, শুধু সংখ্যাগুলো ভুল। এমন silent bug ধরার একমাত্র উপায় হলো ভুলগুলো আগে থেকে চেনা।

ভুল ১ — শিকলবাঁধা assignment, যা কিছুই বদলায় না

sales[sales > 80][0] = 0        # কিছুই বদলায় না: mask দিয়ে পড়া একটি copy
sales[[3, 1]][0, 0] = 0         # কিছুই বদলায় না: fancy index একটি copy
sales[:, 1][[0, 2]] = 0         # বদলায়! প্রথম ধাপ slice (view), তারপর view-এ লেখা
sales[[0, 2], 1] = 0            # সবচেয়ে পরিষ্কার: এক বন্ধনীতে সব ঠিকানা

প্রথম দুটি লাইনে chained assignment একটি নামহীন copy-তে লেখে। তৃতীয়টি কাজ করে শুধু ভাগ্যক্রমে, কারণ প্রথম ধাপটি basic slice. কোনটি view আর কোনটি copy, তা মনে রেখে শিকল লেখার চেয়ে নিয়মটি সহজ: লেখার সময় সব ঠিকানা এক বন্ধনীতে। pandas-এ এই সমস্যাটি আরও পরিচিত, আর তার সমাধানের ইতিহাস ভাবনার প্রশ্ন ৩-এ।

ভুল ২ — শেষ সীমার এক-ঘর ভুল (off-by-one)

“সোম থেকে বুধবার” মানে কলাম 2, 3, 4. অনেকে লেখেন sales[:, 2:4], আর পান shape (5, 2), বুধবার বাদ। ঠিক হলো sales[:, 2:5], shape (5, 3). নিয়মটি মনে রাখার একটি সহজ কৌশল: stop − start = কয়টি ঘর। ৩টি দিন চাইলে stop = start + 3. দশমিক দিনের হিসাবে, শতাংশে, বা সময়ের জানালায় (শেষ ৭ দিন = a[-7:]) একই নিয়ম।

ভুল ৩ — shape-এর চমক

  • sales[0] হলো (7,), sales[0:1] হলো (1, 7): model-এ একটি সারি দিতে গেলে প্রথমটিতে প্রায়ই “Expected 2D array” ধরনের error আসে।
  • 2-D array-তে mask দিলে ফল 1-D: sales[sales > 80] হলো [88 84 82], shape (3,)। কোন জেলা, কোন দিন, সেই তথ্য হারিয়ে যায়; দরকার হলে np.nonzero বা np.where(শর্ত, sales, 0), যা আকার রাখে।
  • sales[2, 4] একটি NumPy scalar (np.int64), array নয়; এর ndim 0. ছোট কথা, কিন্তু JSON-এ লেখার সময় int(...) দিয়ে Python সংখ্যায় না আনলে error হতে পারে।

কোনটি view, কোনটি copy — এক নজরে

কাজফলমন্তব্য
Basic slice: a[1:3], a[:, ::2], a[::-1]viewশুধু শুরু আর strides বদলায়
একক integer দিয়ে সারি: a[1], a[1, :]viewএকটি দিক কমে, data একই
a[...], a[:, np.newaxis]viewনতুন দিক যোগ হলেও data একই
a.T, a.reshape(...), a.ravel()সাধারণত viewmemory-র বিন্যাসে না মিললে reshape আর ravel copy করে; a.T.ravel() copy
সব দিকে integer: a[2, 4]scalarএকটি আলাদা সংখ্যা, view নয়
Fancy index: a[[0, 2]], a[:, [0, 2]], a[np.ix_(r, c)]copy.base None না-ও হতে পারে
Boolean mask দিয়ে পড়া: a[mask]copyফল সবসময় 1-D (পুরো array-তে mask দিলে)
np.where(c, x, y), np.sort(a), a.flatten(), a.copy()copyনতুন array
বাঁ পাশে index: a[mask] = 0, a[[0, 2], 1] = 0মূলে লেখাএক ধাপের লেখা সবসময় মূল array বদলায়

তালিকা মুখস্থ না করে নিয়মটি বুঝুন: শুরুর ঠিকানা আর নিয়মিত লাফ (strides) দিয়ে যদি ফলটি বর্ণনা করা যায়, তাহলে view; না গেলে copy. আর সন্দেহ হলে আন্দাজ নয়, np.shares_memory(x, a) চালিয়ে দেখুন। NumPy-র গতি আর memory নিয়ে আরও বিস্তারিত পর্ব ১৯ · NumPy-র গতি — memory, dtype, view আর কখন loop-ই ঠিক-এ আসবে।

পরীক্ষা ও ইন্টারভিউতে। বিশ্ববিদ্যালয়, চাকরি বা ইন্টারভিউর পরীক্ষায় এমন প্রশ্ন প্রায়ই আসে:
● Slice আর fancy index-এর ফলের পার্থক্য কী? — slice একটি view (একই memory), fancy index একটি copy.
● (a > 2) and (a < 5) কেন error দেয়? — and একটি একক bool চায়, অনেক উপাদানের array-এর bool অস্পষ্ট; লিখুন (a > 2) & (a < 5).
● shape (4, 6) array-তে a[1:3, ::2]-এর shape? — (2, 3).
● ১০ লাখ স্কোরের মধ্যে সেরা ১০টি সবচেয়ে কম খরচে? — np.argpartition (গড়ে O(n)), তারপর শুধু ১০টি sort.
● np.where এক argument আর তিন argument-এ কী দেয়? — এক argument-এ True ঘরের index-এর tuple (np.nonzero-এর সমান), তিন argument-এ ঘরে ঘরে বাছাই করা নতুন array.

৯ · AI-এর যুগে — AI সহকারীর indexing কোড যাচাই

“যেসব দিনে বিক্রি ৫০ থেকে ৭৫-এর মধ্যে, সেগুলো বের করার কোড চাই” লিখলে একটি AI সহকারী সাথে সাথে NumPy বা pandas কোড দেবে। এই পর্বের বিষয়গুলোতে AI সহকারীর ভুলের একটি চেনা ধরন আছে, কারণ ভুল কোডও প্রায়ই চলে:

  • mask জোড়ায় and/or লেখে, বা & দেয় কিন্তু বন্ধনী দেয় না; তারপর error এলে try/except: pass দিয়ে চাপা দেয়।
  • শিকলবাঁধা assignment লেখে (a[mask][0] = x), বা function-এর ভেতরে slice বদলে ডাকা জায়গার array চুপচাপ বদলে দেয়, কারণ ধরে নেয় slice মানে copy.
  • পুরোনো বা বানানো API: NumPy 1.24 থেকে সরিয়ে দেওয়া np.float আর np.int, অস্তিত্বহীন np.argtopk; আবার উল্টোটাও ঘটে, খুব নতুন সংস্করণে যোগ হওয়া parameter (যেমন np.argsort-এর descending) আপনার পুরোনো version-এ নেই। আর pandas 3-এ যা আর কাজ করে না, সেই শিকলবাঁধা df[...][...] = x.
  • argpartition-এর ফলকে সাজানো ধরে নেয়, বা argsort()[:3] দিয়ে সবচেয়ে কম তিনটিকে “সেরা” বলে; আর পরে model-এর কাজে পুরো ডেটার mask বা ranking দিয়ে test অংশ বাছাই করে data leakage ঘটায়।

প্রম্পটে ডেটার shape, dtype, কোন অক্ষ কী, আর কোন ফাঁদ চলবে না, সব বলে দিন। আসল ডেটা নয়, বানানো ছোট উদাহরণ দিন।

আমি Python 3.14 আর NumPy 2.4-এ কাজ করছি। আমার একটি int64 array আছে, shape (5, 7): সারি = ৫টি জেলা, কলাম = ৭টি দিন, মান = হাজার টাকায় বিক্রি (বানানো উদাহরণ: [[26, 74, 65, 50, 50, 80, 26], [68, 34, 26, 56, 88, 71, 73], ...])। ১. যেসব ঘরে মান 50 থেকে 75 (দুটিই ধরে), সেগুলোর সংখ্যা আর (সারি, কলাম) তালিকা দাও। mask জোড়ায় শুধু & | ~ আর বন্ধনী ব্যবহার করবে, and/or নয়। ২. 200-এর বেশি মানগুলোকে সেই সারির median দিয়ে বদলাও, কিন্তু মূল array বদলাবে না: শুরুতে .copy() করো, আর কয়টি ঘর বদলালে তা ফেরত দাও। ৩. মোট বিক্রিতে সেরা ৩টি সারির index দাও, বড় থেকে ছোট ক্রমে; np.argpartition ব্যবহার করলে পরে শুধু ওই ৩টি সাজাবে। সমান মোট হলে কোনটি আগে, স্পষ্ট বলো। ৪. কোনো chained assignment (a[x][y] = ...) লিখবে না, কোনো error বা warning চাপা দেবে না। ৫. প্রতিটি ধাপে কোনটি view আর কোনটি copy, এক লাইনে বলো, আর np.shares_memory দিয়ে একটি assert দাও। কোনো function বা parameter নিয়ে নিশ্চিত না হলে বলো; বানিয়ে লিখবে না।

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

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

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

প্র ০১Python-এর list-এর slice copy দেয়, কিন্তু NumPy-র slice view দেয়। একই চিহ্নের দুই আচরণ কেন? NumPy-র নকশাকারেরা এই ঝুঁকি কেন নিলেন?

Python-এর list একটি সাধারণ কাজের সংগ্রহ। এতে যেকোনো ধরনের object থাকতে পারে, আর list-এর আকার প্রায়ই ছোট। এমন জগতে slice-কে নতুন list বানানো নিরাপদ আর সহজবোধ্য: b = a[1:3] লিখে b বদলালে a অক্ষত থাকে। দাম সামান্য, কারণ shallow copy শুধু object-এর ঠিকানাগুলো কপি করে।

NumPy-র পূর্বসূরি Numeric তৈরি হয়েছিল ১৯৯০-এর দশকের মাঝামাঝি (Jim Hugunin-সহ কয়েকজনের কাজ), বিজ্ঞানের বড় সংখ্যার হিসাবের জন্য: ছবি, আবহাওয়ার গ্রিড, সংকেত। সেখানে একটি array কয়েকশো MB হতে পারে, আর একটি অ্যালগরিদম একই array-এর অসংখ্য টুকরো (প্রতিটি সারি, প্রতিটি জানালা) নিয়ে কাজ করে। প্রতিটি slice-এ copy হলে memory আর সময় দুটিই ভেঙে পড়ত। Fortran আর C-এর বৈজ্ঞানিক library-গুলোও বহু আগে থেকে একই ধারণায় চলত: একটি memory-র খণ্ড, তার ওপর শুরুর ঠিকানা আর লাফের মাপ। এই পথ ধরে NumPy (২০০৫–২০০৬ সালে Travis Oliphant-এর হাতে Numeric আর Numarray মিলে তৈরি) slice-কে view রেখে দেয়।

ঝুঁকিটি বাস্তব: অংশ ৩-এর কোডে ঢাকার শনিবারের আসল বিক্রি একটি view-এ লেখায় হারিয়ে গিয়েছিল। কিন্তু নকশাকারেরা বিকল্পের দামও বিবেচনা করেছিলেন। view না থাকলে ছবির একটি অংশ ঝাপসা করা বা বড় matrix-এর একটি ব্লক বদলানো সহজে করা যেত না, প্রতিবার copy আর ফেরত বসানো লাগত। আর পুরো ecosystem এই আচরণের ওপর দাঁড়িয়েছে: অনেক library ভেতরে view দিয়েই দ্রুত চলে। ফলে নিয়মটি এখন প্রায় অপরিবর্তনীয়; বদলালে লাখো প্রোগ্রাম ভাঙবে। তবে অন্য একটি পথও আছে, যা pandas ২০২০-এর দশকে নিয়েছে: Copy-on-Write, যেখানে ব্যবহারকারী সবসময় copy-র মতো আচরণ দেখেন, কিন্তু ভেতরে লেখার আগ পর্যন্ত memory ভাগ করা থাকে (প্রশ্ন ৩)।

মূল উপলব্ধি: view একটি সচেতন নকশা-সিদ্ধান্ত, বড় সংখ্যার কাজে গতি আর memory-র জন্য নেওয়া; এর দাম হলো ব্যবহারকারীকে জানতে হয় কোন কাজ view দেয়, আর সন্দেহ হলে np.shares_memory দিয়ে যাচাই করতে হয়।

প্র ০২NumPy কেন and-কে ঘরে ঘরে কাজ করাতে পারে না, অথচ & পারে? Python ভাষার নকশায় এর পেছনে কী আছে?

Python-এ +, <, &, |, ~ সবই “operator overloading”-এর আওতায়: একটি class নিজের __add__, __lt__, __and__ লিখে ঠিক করতে পারে এগুলো তার জন্য কী করবে। NumPy ঠিক তাই করেছে: < ঘরে ঘরে তুলনা করে একটি bool array দেয়, আর & দুটি bool array-কে ঘরে ঘরে AND করে। কিন্তু and, or আর not operator নয়, ভাষার নিয়ন্ত্রণ-কাঠামো। x and y-এর অর্থ: আগে bool(x) দেখা হয়; False হলে y-কে ছুঁয়েও দেখা হয় না, x-ই ফেরত আসে। এই “short-circuit” আচরণের জন্য Python-কে প্রতিটি পাশকে একটি একক সত্য-মিথ্যায় নামাতে হয়, আর সেই কাজের জন্য class শুধু __bool__ লিখতে পারে, যা একটিমাত্র True বা False দিতে পারে।

অনেক উপাদানের array-এর জন্য সেই একক উত্তর কী হবে? “সব True হলে True” (all) যুক্তিসংগত, “অন্তত একটি True হলে” (any)-ও যুক্তিসংগত। NumPy-র নকশাকারেরা অনুমান না করে error দেওয়ার পথ নিয়েছেন, আর বার্তাতেই দুটি বিকল্প লিখে দিয়েছেন। এটি একটি ভালো নকশা-নীতি: অস্পষ্ট প্রশ্নে চুপচাপ একটি উত্তর বেছে নেওয়ার চেয়ে থেমে যাওয়া ভালো।

and/or-কে overload করার প্রস্তাব কিন্তু এসেছিল। ২০০৪ সালে Gregory Ewing PEP 335 (“Overloadable Boolean Operators”) প্রস্তাব করেন, যাতে NumPy-র মতো library নিজের অর্থ দিতে পারত। বহু বছর আলোচনার পর ২০১২ সালে Guido van Rossum প্রস্তাবটি প্রত্যাখ্যান করেন; প্রচলিত ব্যাখ্যা অনুযায়ী ভাষার জটিলতা আর short-circuit-এর গতি নিয়ে উদ্বেগ ছিল প্রধান কারণ। পরে আরও কিছু সম্পর্কিত প্রস্তাব (যেমন chained comparison-কে overload-যোগ্য করা) এসেছে, কিন্তু লেখার সময় পর্যন্ত গৃহীত হয়নি। ফলে আজও নিয়মটি একই: array-তে &, |, ~, আর বন্ধনী, কারণ এগুলো bitwise operator হিসেবে তুলনার চেয়ে আগে হিসাব হয়।

মূল উপলব্ধি: and চলে না কোনো NumPy-র ত্রুটির কারণে নয়, Python-এর একটি ভেবেচিন্তে রাখা নিয়মের কারণে; সেই নিয়ম বুঝলে বন্ধনী আর &-এর অভ্যাস যুক্তিসংগত মনে হয়, মুখস্থ নয়।

প্র ০৩pandas বহু বছর ধরে “SettingWithCopyWarning” দেখাত, আর এখন Copy-on-Write চালু করেছে। শিকলবাঁধা assignment-এর সমস্যা NumPy থেকে pandas-এ কীভাবে এল, আর কীভাবে সমাধানের চেষ্টা হলো?

pandas-এর DataFrame ভেতরে NumPy array (আর নতুন সংস্করণে Arrow array) দিয়ে তৈরি, তাই NumPy-র view-copy নিয়মের উত্তরাধিকার সে পেয়েছে। কিন্তু pandas-এ সমস্যাটি আরও জটিল হয়েছিল, কারণ df[df["district"] == "ঢাকা"]["sales"] = 0-এর মতো শিকলবাঁধা লেখায় প্রথম ধাপটি কখনো view, কখনো copy দিত, আর কোনটি দেবে তা নির্ভর করত ভেতরের memory বিন্যাস, কলামের dtype আর অন্য খুঁটিনাটির ওপর। ব্যবহারকারীর পক্ষে আগে থেকে বলা প্রায় অসম্ভব ছিল।

এর জন্য pandas একটি সতর্কবার্তা চালু করেছিল, SettingWithCopyWarning: “আপনি হয়তো একটি copy-তে লিখছেন”। বার্তাটি এত সাধারণ হয়ে উঠেছিল যে Stack Overflow-এর মতো জায়গায় এ নিয়ে প্রশ্ন অসংখ্য, আর অনেকে বার্তাটি বন্ধ করে দিতেন, যা মূল সমস্যা লুকিয়ে রাখত। সতর্কবার্তাটি নিজেও নিখুঁত ছিল না: কখনো ভুল জায়গায় আসত, কখনো আসল সমস্যায় আসত না।

দীর্ঘ আলোচনার পর pandas-এর উন্নয়ন দল Copy-on-Write (CoW) গ্রহণ করে, যার প্রস্তাব PDEP-7 নামে লেখা আছে। ধারণাটি: যেকোনো indexing-এর ফল ব্যবহারকারীর কাছে সবসময় একটি আলাদা object-এর মতো আচরণ করবে, কিন্তু ভেতরে memory ভাগ করা থাকবে যতক্ষণ না কেউ লেখে; লেখার মুহূর্তে কপি হবে। ২.x সংস্করণে এটি ঐচ্ছিক ছিল, আর pandas 3.0-তে default হয়েছে। এর ফলে শিকলবাঁধা assignment আর কখনো মূল DataFrame বদলায় না, আর সঠিক পথ একটিই: df.loc[শর্ত, "sales"] = 0, মানে NumPy-র মতোই “এক বন্ধনীতে সব ঠিকানা”। খুঁটিনাটি পর্ব ২২ · বাছাই আর filter — loc, iloc, query আর boolean mask-এ আসবে।

এই ইতিহাসের শিক্ষা হলো, “কখনো view, কখনো copy” এমন আচরণ ব্যবহারকারীর জন্য সবচেয়ে খারাপ; NumPy-র স্পষ্ট নিয়ম (basic মানে view, advanced মানে copy) বা pandas-এর নতুন নিয়ম (সবসময় copy-র মতো) দুটিই অনুমানযোগ্য, আর অনুমানযোগ্যতাই আসল মূল্য।

মূল উপলব্ধি: একটি library-র আচরণ কতটা অনুমানযোগ্য, তা তার গতির মতোই গুরুত্বপূর্ণ; আর দুই library-তেই নিরাপদ অভ্যাস একটি: পড়তে শিকল চলে, লিখতে সব ঠিকানা এক বন্ধনীতে।

প্র ০৪সেরা k-টি খুঁজতে পুরো তালিকা সাজানোর দরকার নেই — এই ধারণা কোথা থেকে এল, আর AI-এর কাজে এর গুরুত্ব কোথায়?

পরীক্ষার ফল প্রকাশের দিন যদি শুধু প্রথম দশজনের নাম লাগে, তাহলে পুরো দুই লাখ পরীক্ষার্থীকে ক্রমানুসারে সাজানো অপচয়। এই অন্তর্দৃষ্টির নাম selection problem: একটি তালিকায় k-তম বড় উপাদান, বা বড় k-টি, খুঁজে বের করা। ১৯৬১ সালে C. A. R. Hoare, যিনি quicksort-এর জন্য বিখ্যাত, একই সময়ে “Find” নামের একটি অ্যালগরিদম প্রকাশ করেন, যা আজ quickselect নামে পরিচিত। ভাবনাটি quicksort-এর মতোই: একটি pivot বেছে তালিকাকে দুই ভাগ করা; কিন্তু তারপর শুধু সেই ভাগে নামা যেখানে উত্তর আছে। অন্য ভাগ ছুঁয়েও দেখা হয় না, তাই গড় খরচ O(n), পুরো sort-এর O(n log n) নয়।

Quickselect-এর একটি দুর্বলতা হলো, খারাপ pivot বারবার পড়লে খরচ O(n²) পর্যন্ত যেতে পারে। ১৯৭৩ সালে Blum, Floyd, Pratt, Rivest আর Tarjan “median of medians” পদ্ধতি দেখান, যা সবচেয়ে খারাপ ক্ষেত্রেও O(n) নিশ্চিত করে, কিন্তু বাস্তবে ধীর। ১৯৯৭ সালে David Musser introsort আর introselect প্রস্তাব করেন: সাধারণত দ্রুত quickselect চলে, আর বিপদ দেখলে নিরাপদ পদ্ধতিতে বদলে যায়। NumPy-র argpartition-এর default kind হলো এই introselect.

AI-এর কাজে এই প্রশ্ন সর্বত্র। k-nearest neighbors প্রতিটি নতুন বিন্দুর জন্য হাজারো দূরত্ব থেকে সবচেয়ে ছোট k-টি চায়। Recommendation বা vector search লাখো পণ্য বা লেখার স্কোর থেকে সেরা দশটি দেখায়। ভাষার model পরের token বাছাইয়ে হাজারো সম্ভাব্য token-এর মধ্যে সবচেয়ে সম্ভাব্য k-টি রেখে বাকিগুলো বাদ দেয় (top-k sampling). প্রতিটি ক্ষেত্রে n বড় আর k ছোট, তাই selection-এর লাভ বাস্তব। তবে একটি সতর্কতা: ছোট array-তে (বাজারঘরের ৫টি জেলা) পার্থক্য অদৃশ্য, আর সরল argsort-ই বেশি পড়তে সহজ। গতির দাবি করার আগে নিজের ডেটায় মেপে দেখুন; বড় পরিসরে খুব বড় তালিকার জন্য আলাদা approximate nearest neighbor পদ্ধতিও আছে, যা পর্ব ৬৯-এর কাছাকাছি আসবে।

মূল উপলব্ধি: প্রশ্নটি যত ছোট, উত্তরের খরচও তত কম হওয়া উচিত; “সেরা k-টি” আর “পুরো ক্রম” দুটি আলাদা প্রশ্ন, আর argpartition সেই পার্থক্যকে কোডে আনে।

প্র ০৫ভুল সারি বা ভুল কলাম বাছাই কি বাস্তবে বড় ক্ষতি করেছে? Reinhart–Rogoff-এর spreadsheet আর ২০০৬ সালের প্রোটিন-গঠনের প্রবন্ধ প্রত্যাহারের ঘটনা কী শেখায়?

২০১০ সালে অর্থনীতিবিদ Carmen Reinhart আর Kenneth Rogoff “Growth in a Time of Debt” নামে একটি প্রভাবশালী প্রবন্ধ প্রকাশ করেন, যেখানে সরকারি ঋণ আর অর্থনৈতিক প্রবৃদ্ধির সম্পর্ক দেখানো হয়। প্রবন্ধটি সে সময়ের কৃচ্ছ্রনীতির বিতর্কে বহুবার উদ্ধৃত হয়। ২০১৩ সালে University of Massachusetts Amherst-এর Thomas Herndon, Michael Ash আর Robert Pollin মূল spreadsheet পরীক্ষা করে কয়েকটি সমস্যা পান। সবচেয়ে আলোচিতটি একটি পরিসর-বাছাইয়ের ভুল: একটি গড়ের সূত্রে কয়েকটি সারি বাদ পড়েছিল, ফলে পাঁচটি দেশ (প্রচলিত বিবরণে অস্ট্রেলিয়া, অস্ট্রিয়া, বেলজিয়াম, কানাডা আর ডেনমার্ক) হিসাবের বাইরে ছিল। মূল লেখকেরা এই coding-এর ভুল স্বীকার করেন, তবে তাঁদের মতে মূল উপসংহার তাতে বদলায়নি; ভুলটি ফলকে কতটা প্রভাবিত করেছিল, আর অন্য পদ্ধতিগত পছন্দগুলো কতটা, তা নিয়ে অর্থনীতিবিদদের মধ্যে বিতর্ক আছে। কিন্তু একটি পরিসরের শেষ সীমা, ঠিক অংশ ৮-এর off-by-one-এর মতো, একটি বিশ্বব্যাপী বিতর্কে জায়গা পেয়েছিল।

দ্বিতীয় ঘটনা বিজ্ঞানের। ২০০৬ সালে Scripps Research Institute-এর গবেষক Geoffrey Chang-এর দল পাঁচটি প্রবন্ধ প্রত্যাহার করে, যার তিনটি Science পত্রিকায়, প্রোটিনের ত্রিমাত্রিক গঠন নিয়ে। Science-এর সংবাদ বিভাগের বিবরণ অনুযায়ী কারণ ছিল দলের নিজের লেখা একটি ছোট প্রোগ্রাম, যা ডেটার দুটি কলাম অদলবদল করে ফেলেছিল; ফলে গঠনের একটি দিক উল্টে গিয়েছিল। অন্য দলের পাওয়া ফলের সঙ্গে না মেলায় ভুলটি ধরা পড়ে, কয়েক বছর পরে।

দুটি ঘটনায় একই নকশা। ভুলটি কোনো জটিল গণিতে নয়, “কোন সারি” আর “কোন কলাম” বাছাইয়ে। প্রোগ্রাম কোনো error দেয়নি; ফল বিশ্বাসযোগ্য দেখাচ্ছিল। আর ভুল ধরা পড়েছে বাইরের কেউ মূল ডেটা আর কোড হাতে পেয়ে নিজে চালানোর পর। ডেটার কাজে এর শিক্ষা সরাসরি: index আর slice-এর পর shape ছাপুন, কয়েকটি ঘর হাতে মিলিয়ে দেখুন, অক্ষের নাম কোডে লিখে রাখুন (কোন দিক জেলা, কোন দিক দিন), আর কোড ও ডেটা এমনভাবে রাখুন যাতে অন্যরা ফল পুনরায় তৈরি করতে পারেন, ঠিক যেমন পর্ব ১২-এর পুনরুৎপাদনযোগ্য প্রকল্পে দেখেছেন।

মূল উপলব্ধি: সবচেয়ে বিপজ্জনক ভুল সেগুলো, যা error দেয় না; ভুল সারি বা কলাম বাছাই ঠিক এমন ভুল, আর তার প্রতিষেধক হলো shape যাচাই, হাতে মেলানো আর অন্যদের যাচাইয়ের সুযোগ রাখা।

অনুশীলন

প্রথমটি হিসাবের, দ্বিতীয়টি আউটপুট অনুমানের, তৃতীয়টি নকশা ও সমালোচনার, শেষটি কোড লেখার।

  1. হিসাব — shape, মান আর ঠিকানা: b = np.arange(12, dtype=np.int64).reshape(3, 4), মানে [[0 1 2 3] [4 5 6 7] [8 9 10 11]]. (ক) b[1], b[1:2], b[:, -1], b[::2, 1::2] আর b[-1, -2]-এর মান আর shape লিখুন। (খ) b আর b[::2, 1::2]-এর strides কত? (গ) b[2, 3] শুরু থেকে কত byte দূরে?

    (ক) b[1] = [4 5 6 7], shape (4,)। b[1:2] = [[4 5 6 7]], shape (1, 4). b[:, -1] = [3 7 11], shape (3,)। b[::2, 1::2]: সারি 0 আর 2, কলাম 1 আর 3, তাই [[1 3] [9 11]], shape (2, 2). b[-1, -2]: শেষ সারির শেষের আগের ঘর, 10, একটি scalar.

    (খ) int64 মানে 8 byte. এক সারিতে 4টি সংখ্যা, তাই b-এর strides (4 × 8, 8) = (32, 8). b[::2, 1::2] সারিতে এক সারি বাদ দিয়ে (2 × 32 = 64) আর কলামে এক কলাম বাদ দিয়ে (2 × 8 = 16) লাফায়: (64, 16). এটি view, তাই শুধু শুরুর ঠিকানা এক ঘর (8 byte) সরেছে।

    (গ) 2 × 32 + 3 × 8 = 64 + 24 = 88 byte. যাচাই: b[2, 3] = 11, মানে সমতল ক্রমে ১১ নম্বর ঘর (0 থেকে গুনে), আর 11 × 8 = 88. মেলে। আমাদের run-এ b.strides আর b[::2, 1::2].strides সত্যিই (32, 8) আর (64, 16) দিয়েছে। dtype না দিলে ব্রাউজারের Python-এ np.arange int32 দিত, আর তখন সব সংখ্যা অর্ধেক হতো।

  2. আউটপুট অনুমান করুন: চালানোর আগে দুটি লাইনের আউটপুট লিখুন, তারপর অংশ ৩-এর কোডের নিচে বসিয়ে চালিয়ে মিলিয়ে দেখুন।
    a = np.array([5, 12, 7, 20, 3])
    v = a[1:4]
    m = a > 6
    a[m] = 0
    print(v)
    c = a[[0, 4]]
    c[:] = -1
    print(a, m.sum())
    [0 0 0]
    [5 0 0 0 3] 3

    m হলো [F T T T F] (12, 7, 20 ছয়ের বেশি)। a[m] = 0 এক ধাপের লেখা, তাই a হয় [5 0 0 0 3]. v ছিল a[1:4]-এর view, আলাদা করে কিছু না করলেও সে এখন [0 0 0] দেখায়। c fancy index দিয়ে বানানো copy, তাই c[:] = -1 শুধু c বদলায়, a নয়। আর m বানানো হয়েছিল বদলানোর আগে, তাই এর True সংখ্যা এখনো 3; mask নিজেও একটি আলাদা array, পরে a বদলালে সে নিজে নিজে হালনাগাদ হয় না। আমাদের run-এ ঠিক এই আউটপুট এসেছে।

  3. নকশা ও সমালোচনা: একটি AI সহকারী নিচের function দিয়েছে: “২০০-এর বেশি বিক্রি কেটে ২০০ করবে আর সেরা তিন জেলা দেবে”। অন্তত পাঁচটি সমস্যা চিহ্নিত করে একটি ভালো সংস্করণ লিখুন।
    def cap_outliers(sales, limit=200):
        s = sales[:, :]              # copy বানালাম
        bad = s > limit and s >= 0
        s[bad][0] = limit
        top = np.argsort(s.sum(axis=1))[:3]
        return s, top
    • মন্তব্য মিথ্যা: sales[:, :] একটি view, copy নয়; এই function ডাকা জায়গার মূল array বদলে দেবে। s = sales.copy() লাগবে।
    • and: দুই array-এর মাঝে and লিখলে ValueError. লাগবে (s > limit) & (s >= 0), যদিও এখানে দ্বিতীয় শর্তটি অপ্রয়োজনীয়।
    • শিকলবাঁধা লেখা: s[bad][0] = limit একটি copy-র প্রথম ঘর বদলায়, তারপর তা ফেলে দেওয়া হয়; s একটুও বদলায় না। আর উদ্দেশ্য সব ঘর হলে [0] এমনিতেও ভুল। ঠিক: s[bad] = limit.
    • উল্টো ক্রম: argsort ছোট থেকে বড়, তাই [:3] সবচেয়ে কম তিন জেলা দেয়। লাগবে [::-1][:3] বা argpartition আর পরে সাজানো।
    • স্বচ্ছতা নেই: কয়টি ঘর বদলানো হলো, তা ফেরত দেয় না; আর কেটে ২০০ করা ভুল এন্ট্রি (280, যা আসলে 28) ঢেকে দেয়, সারায় না। অন্তত গণনাটি ফেরত দিন, আর ভালো হয় median দিয়ে বদলানো বা আলাদা করে চিহ্নিত করা।
    def cap_outliers(sales, limit=200):
        s = sales.copy()                      # মূল অক্ষত
        bad = s > limit
        n_bad = int(bad.sum())
        s[bad] = limit                        # এক ধাপের লেখা
        top = np.argsort(s.sum(axis=1), kind="stable")[::-1][:3]
        return s, top, n_bad

    বাজারঘরের খাতায় sales[2, 3] = 280 বসিয়ে চালালে আমাদের run-এ top এসেছে [2 3 1], n_bad 1, আর মূল array-এ 280 অক্ষত, ফেরত array-এ 200. রাজশাহী শীর্ষে উঠেছে শুধু ভুল এন্ট্রির কারণে, যা দেখায় কেটে ফেলা কেন সমাধান নয়।

  4. কোড লিখুন — প্রতিটি জেলার সেরা দুই দিন: বাজারঘরের sales (seed 42, shape (5, 7)) থেকে np.argpartition(..., axis=1) দিয়ে প্রতিটি জেলার সবচেয়ে বেশি বিক্রির দুটি দিন বের করুন, বড় থেকে ছোট ক্রমে, দিনের নামসহ ছাপুন, আর এই ১০টি ঘরের মোট বের করুন। ইঙ্গিত: সারি ধরে index দিয়ে মান তুলতে np.take_along_axis.
    idx = np.argpartition(sales, -2, axis=1)[:, -2:]        # প্রতি সারিতে বড় ২টির কলাম
    vals = np.take_along_axis(sales, idx, axis=1)
    order = np.argsort(vals, axis=1)[:, ::-1]               # শুধু ২টি সাজানো
    idx = np.take_along_axis(idx, order, axis=1)
    vals = np.take_along_axis(sales, idx, axis=1)
    for k in range(5):
        print(districts[k], days[idx[k]], vals[k])
    print(vals.sum())

    আমাদের run-এর আউটপুট (NumPy 2.5.1, ডেস্কটপ):

    ঢাকা ['বৃহঃ' 'রবি'] [80 74]
    চট্টগ্রাম ['বুধ' 'শুক্র'] [88 73]
    রাজশাহী ['বুধ' 'রবি'] [78 75]
    খুলনা ['সোম' 'শুক্র'] [84 77]
    সিলেট ['শুক্র' 'বৃহঃ'] [82 58]
    769

    সিলেটের সারিতে একটি tie আছে: শনি আর বৃহঃ দুটিতেই 58. argpartition কোনটি নেবে তার প্রতিশ্রুতি দেয় না; আমাদের run-এ বৃহঃ এসেছে, কিন্তু শনিও সমান সঠিক উত্তর, আর আপনার version-এ ভিন্ন হতে পারে। মোট 769 দুই ক্ষেত্রেই একই। take_along_axis প্রতিটি সারির জন্য সেই সারির index দিয়ে মান তোলে, যা sales[np.arange(5)[:, None], idx]-এর একটি পড়তে সহজ রূপ।

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

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

ক্রমজেলা × দিনের একটি বিক্রির array থেকে মোট বিক্রিতে সেরা তিন জেলার নাম বের করার ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. 🎲 seed দিয়ে ডেটা বানানো বা পড়া, আর shape (জেলা, দিন) যাচাই
  2. ➕ sum(axis=1) দিয়ে প্রতিটি জেলার মোট
  3. ✂️ argpartition(total, -3)[-3:] দিয়ে বড় তিনটির index
  4. 📊 শুধু সেই তিনটির মান argsort করে বড় থেকে ছোট সাজানো
  5. 🏷️ সাজানো index দিয়ে জেলার নামের array থেকে নাম তোলা
  6. ✅ একটি জেলার মোট হাতে যোগ করে মিলিয়ে দেখা

a = np.arange(10); b = a[2:5]; b[0] = 99; print(a[2]) — আউটপুট কী?

  • 2
  • 99
  • IndexError
  • 0

একটি array a-এর shape (4, 6). a[1:3, ::2]-এর shape কী?

  • (2, 3)
  • (2, 6)
  • (3, 3)
  • (2, 2)

a = np.array([1, 4, 6, 9]). “2-এর বেশি আর 7-এর কম” উপাদানগুলো পেতে কোন লেখাটি সঠিক?

  • a[(a > 2) and (a < 7)]
  • a[a > 2 & a < 7]
  • a[(a > 2) & (a < 7)]
  • a[(a > 2) | (a < 7)]

একটি 2-D NumPy array a-এর ওপর নিচের কোন কাজটি একটি copy দেয় (মূলের সঙ্গে memory ভাগ করে না)?

  • a[1:3]
  • a[:, 0]
  • a.T
  • a[[0, 2]]

idx = np.argpartition(scores, -3)[-3:] লিখলে idx সম্পর্কে কোনটি নিশ্চিত?

  • তিনটি index বড় থেকে ছোট ক্রমে সাজানো
  • এগুলো সবচেয়ে বড় তিনটি মানের index, ক্রম নিশ্চিত নয়
  • এগুলো সবচেয়ে ছোট তিনটি মানের index
  • এগুলো সবচেয়ে বড় তিনটি মান

np.where(np.array([3, 8, 1, 9]) > 5)-এর ফল কী?

  • (array([1, 3]),)
  • array([8, 9])
  • array([False, True, False, True])
  • array([1, 3])

শব্দকোষ

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

মনে রাখুন

  1. a[i, j] এক বন্ধনীতে, কমা দিয়ে; negative index শেষ থেকে গোনে; slice-এর stop বাদ পড়ে, আর stop − start = কয়টি ঘর।
  2. integer দিলে দিক হারায়, slice দিলে থাকে: a[0] হলো (7,), a[0:1] হলো (1, 7); mask দিয়ে পড়লে ফল 1-D.
  3. Basic slice = view, fancy index আর mask দিয়ে পড়া = copy; সন্দেহ হলে np.shares_memory, আর মূল অক্ষত রাখতে .copy().
  4. Mask জোড়ায় &, |, ~ আর বন্ধনী; and/or দিলে ValueError; গুনতে .sum(), একক উত্তরে .any()/.all(); np.where এক argument-এ index, তিনটিতে বাছাই।
  5. লিখতে সব ঠিকানা এক বন্ধনীতে (a[[0, 2], 1] = 0, a[mask] = x); top-k-এ argpartition, তারপর শুধু k-টি সাজানো, আর tie-এর নিয়ম স্পষ্ট।

পরের পর্বে: loop ছাড়াই পুরো array-তে হিসাব। Vectorization কেন দ্রুত, আর broadcasting-এর নিয়মে (5,) আর (5, 1) shape মেলালে কী হয়, ধাপে ধাপে।

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

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