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

বাছাই আর filter — loc, iloc, query আর boolean mask

Selecting and Filtering in pandas — loc, iloc, Boolean Masks, query() and Copy-on-Write

Taught in Bangla, this lesson explains selecting rows and columns in pandas 3: loc versus iloc and their slice ends, at and iat, boolean masks with &, | and ~, isin, between, string matching in Bangla text, and query with @variables. It shows Copy-on-Write and why chained assignment never works, plus sorting, nlargest ties and sampling, all from real runs.

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

  • [], loc, iloc, at আর iat-এর পার্থক্য, আর কোনটি কখন; label slice-এ শেষ সীমা কেন ধরা হয়
  • একসঙ্গে সারি আর কলাম বাছাই, set_index-এর পর label দিয়ে খোঁজা, আর অসাজানো বা পুনরাবৃত্ত index-এর ফাঁদ
  • Boolean mask, isin, between, আর নতুন str dtype-এ বাংলা লেখায় str.contains ও str.startswith; query() আর @variable
  • pandas 3-এর Copy-on-Write: chained assignment কেন কখনো কাজ করে না, loc দিয়ে ঠিক লেখা, আর where ও mask
  • sort_values, nlargest/nsmallest, sample আর select_dtypes দিয়ে সাজানো, সেরা k-টি, নমুনা আর ধরন ধরে কলাম বাছাই

পর্ব ২০ · pandas-এর শুরু — Series, DataFrame আর index-এ আপনি দেখেছেন, একটি DataFrame হলো কলামের একটি সংগ্রহ, যার প্রতিটি সারির একটি label আছে, আর সেই labelগুলোর সারিটিকে বলে index. পর্ব ২১-এ সেই টেবিল ফাইল থেকে পড়েছেন। এবার প্রশ্ন: পুরো টেবিল থেকে ঠিক যে সারি আর কলাম দরকার, সেগুলো কীভাবে তুলবেন, আর তোলার পর মান বদলালে মূল টেবিল বদলাবে কি না। NumPy-তে একই প্রশ্নের উত্তর আপনি পর্ব ১৪ · Indexing আর slicing — boolean mask, fancy index, view আর copy-তে দেখেছেন। pandas সেই ধারণাগুলো নেয়, কিন্তু দুটি বড় জায়গায় আলাদা: এখানে সারির নাম আছে, আর pandas 3-এ view-copy-র ধাঁধা একটি পরিষ্কার নিয়মে বদলে গেছে। এই পাতার সব কোড ব্রাউজারেই চলে, Pyodide-এর pandas 3.0.2 দিয়ে; প্রথমবার চালালে pandas নামতে কয়েক সেকেন্ড লাগতে পারে।

১ · বাজারঘরের অর্ডারের খাতা আর বাছাইয়ের চারটি দরজা

পাড়ার মুদি দোকানের বাকির খাতার কথা ভাবুন। প্রতিটি পাতায় একটি লাইন, প্রতিটি লাইনে ক্রেতার নাম, জিনিস, দাম। কাউকে খুঁজতে দুই উপায় আছে: “রহিম সাহেবের লাইন” (নাম দিয়ে), অথবা “ওপর থেকে তৃতীয় লাইন” (অবস্থান দিয়ে)। খাতার পাতা ছিঁড়ে নতুন করে সাজালে দ্বিতীয় উপায়ের উত্তর বদলে যায়, প্রথমটির নয়। pandas-এ এই দুই উপায়ের নাম loc (label দিয়ে) আর iloc (integer position দিয়ে)। আর বেশিরভাগ ভুলের শুরু হয় যখন দুটি একই রকম দেখায়।

এই পর্বের পুরো সময় আমরা বাজারঘরের (এই কোর্সের কাল্পনিক অনলাইন দোকান) অক্টোবরের প্রথম সপ্তাহের ১৪টি অর্ডার নিয়ে কাজ করব। ফাইলটি পাতার ভেতরেই রাখা, কোড তাকে orders.csv নামে পড়ে। টাকা সবসময় পূর্ণসংখ্যার paisa-তে (১ টাকা = ১০০ paisa), যাতে দশমিকের গোলমাল না হয়; ছাপার সময় শুধু টাকায় রূপান্তর। দুটি অর্ডারের delivery_days ফাঁকা, কারণ সেগুলো এখনো পৌঁছায়নি।

order_id,date,district,product,category,qty,price_paisa,delivery_days,payment,customer_id
1001,2026-10-01,ঢাকা,চা পাতা,মুদি,2,25000,1,bKash,C01
1002,2026-10-01,চট্টগ্রাম,পাঞ্জাবি,পোশাক,1,180000,3,COD,C02
1003,2026-10-02,ঢাকা,ইয়ারফোন,ইলেকট্রনিক্স,1,95000,2,bKash,C03
…   (মোট ১৪টি অর্ডার, 1001 থেকে 1014)
1006,2026-10-03,সিলেট,চার্জার,ইলেকট্রনিক্স,3,60000,,bKash,C05
…
1014,2026-10-07,সিলেট,শাড়ি,পোশাক,1,420000,5,bKash,C05

pd.read_csv("orders.csv") পড়লে pandas 3.0.2-এ কলামের dtype হয় এমন: order_id, qty, price_paisa হলো int64; delivery_days হলো float64, কারণ ফাঁকা ঘর NaN হিসেবে আসে আর NaN শুধু float-এ রাখা যায়; আর লেখার কলামগুলো (district, product, payment ইত্যাদি) নতুন str dtype. pandas 2-এর পুরোনো tutorial-এ এগুলো object দেখাত; pandas 3 থেকে লেখার জন্য আলাদা dtype default. index হলো RangeIndex(start=0, stop=14, step=1), মানে 0 থেকে 13 পর্যন্ত label, যা এখানে ঘটনাক্রমে position-এর সমান। এই “ঘটনাক্রমে সমান” কথাটিই পুরো পর্বের প্রথম ফাঁদ।

চারটি দরজা এক নজরে

লেখাকী দিয়ে খোঁজেউদাহরণফল
df["col"]কলামের নামdf["qty"]একটি Series
df[["a", "b"]]নামের তালিকাdf[["qty"]]DataFrame (এক কলামেরও)
df.loc[সারি, কলাম]labeldf.loc[2:4, "district":"qty"]শেষ label ধরা হয়
df.iloc[সারি, কলাম]position (0 থেকে)df.iloc[2:4, 2:5]শেষ position বাদ
df.at[label, নাম]label, একটি ঘরo.at[1005, "product"]একটি মান
df.iat[i, j]position, একটি ঘরdf.iat[4, 3]একটি মান

একটি সূক্ষ্ম কথা: df["qty"] একটি Series দেয় (এক কলামের লেবেলযুক্ত তালিকা), কিন্তু df[["qty"]] দেয় এক কলামের DataFrame. বাইরের বন্ধনীটি “বাছাই”, ভেতরেরটি “তালিকা”। model-এ feature দেওয়ার সময় scikit-learn সাধারণত 2-D চায়, তাই df[["qty"]]; আর একটি কলামের গড় চাইলে df["qty"]. NumPy-র a[:, 0] বনাম a[:, 0:1]-এর পার্থক্যের মতোই।

আর df[...]-এর একটি বিভ্রান্তিকর দিক আছে: ভেতরে একটি নাম দিলে কলাম আসে, কিন্তু slice (df[2:4]) বা boolean mask (df[mask]) দিলে সারি আসে। একই বন্ধনী, দুই অর্থ। এই কারণে অনেক দল নিয়ম রাখে: কলামের জন্য df["col"], সারি বা সারি-কলাম একসঙ্গে হলে সবসময় loc বা iloc, স্পষ্ট করে।

একই টেবিল, দুই ধরনের ঠিকানা — loc বনাম iloc

The first six orders: column positions and names, row labels, a loc label slice (end included) and an iloc position slice (end excluded)

loc আর iloc-এর slice একই টেবিলে orders.head(6) · label বনাম position 0123 position order_iddistrictqtypayment label 012345 1001ঢাকা2bKash 1002চট্টগ্রাম1COD 1003ঢাকা1bKash 1004খুলনা3COD 1005ঢাকা1card 1006সিলেট3bKash loc[1:3, "district":"payment"] → ৩ সারি × ৩ কলাম, শেষ label ধরা iloc[1:3, 1:3] → ২ সারি × ২ কলাম, শেষ position বাদ
ছবি ২২.১ — ওপরের নীল সংখ্যা কলামের position, তার নিচে কলামের নাম; বাঁয়ের লাল সংখ্যা সারির label. নীল ঘর df.loc[1:3, "district":"payment"]: label 1 থেকে 3 আর নাম district থেকে payment, দুই প্রান্তসহ. কমলা ড্যাশ দাগ df.iloc[1:3, 1:3]: position 1 আর 2, শেষটি বাদ। এখানে সারির label আর position একই সংখ্যা, তাই চোখে পার্থক্য ধরা কঠিন; set_index বা সাজানোর পর দুটি আলাদা হয়ে যায় (অংশ ৩)।

২ · loc আর iloc — label বনাম position, আর শেষ সীমার চমক

iloc-এর নিয়ম আপনার চেনা: start:stop-এ start ধরা, stop বাদ, ঠিক Python list আর NumPy-র মতো। তাই df.iloc[2:4] মানে position 2 আর 3, দুটি সারি। stop − start = কয়টি সারি।

loc-এর নিয়ম আলাদা, আর প্রথমবার দেখলে সত্যিই চমকে দেয়: label slice-এ দুই প্রান্তই ধরা হয়। df.loc[2:4] মানে label 2, 3 আর 4, তিনটি সারি। কলামেও একই: df.loc[:, "district":"qty"] district, product, category আর qty, চারটি কলাম, qty-সহ।

কেন এমন? কারণ label সংখ্যা না-ও হতে পারে। ধরুন index হলো জেলার নাম, আর আপনি লিখলেন loc["খুলনা":"ঢাকা"]. “ঢাকা বাদ দিয়ে তার আগ পর্যন্ত” বলতে হলে আপনাকে জানতে হতো ঢাকার আগের label কোনটি, যা টেবিলের ভেতর না দেখে বলা যায় না। সংখ্যার ক্ষেত্রেও একই সমস্যা: index 1001, 1003, 1010 হলে “1010-এর আগেরটি” কোনটি? তাই pandas-এর নকশা বলে: আপনি যে দুটি label লিখেছেন, দুটিই আপনি চান। pandas-এর docs-এ এটি স্পষ্ট লেখা: label slice-এ start আর stop দুটিই অন্তর্ভুক্ত।

নিচের ধাপে একটি ছোট টেবিলে দুটি নিয়ম পাশাপাশি দেখুন। এখানে o = df.set_index("order_id").head(6), মানে set_index দিয়ে order_id-কে সারির label বানানো হয়েছে, তাই label (1001 …) আর position (0 …) এখন আলাদা সংখ্যা। “পরে” চাপুন।

position
label (order_id)
district
0
1001
ঢাকা
1
1002
চট্টগ্রাম
2
1003
ঢাকা
3
1004
খুলনা
4
1005
ঢাকা
5
1006
সিলেট
  1. দুই ধরনের ঠিকানা। বাঁয়ের কলাম position (0 থেকে 5), মাঝেরটি label (order_id). set_index-এর পর দুটি আলাদা সংখ্যা, তাই এখন ভুল ধরা সহজ। ডানে প্রতিটি অর্ডারের জেলা।
  2. loc শুরু খোঁজে। o.loc[1002:1004] প্রথমে label 1002 খোঁজে; সেটি আছে position 1-এ। loc position নিয়ে মাথা ঘামায় না, শুধু label মেলায়।
  3. শেষ label-ও ধরা। এরপর label 1004 খোঁজে (position 3), আর তাকেও রাখে: ফল 1002, 1003, 1004, তিনটি সারি। জেলা: চট্টগ্রাম, ঢাকা, খুলনা। এটিই সেই চমক।
  4. iloc শেষটি বাদ দেয়। o.iloc[1:3] position 1 আর 2 নেয়, 3 নয়: 1002 আর 1003, দুটি সারি। একই “১ থেকে ৩” লেখা, ভিন্ন ফল।
  5. সমান করতে stop এক বাড়ান। o.iloc[1:4] position 1, 2, 3, তাই loc[1002:1004]-এর সমান তিনটি সারি। নিয়ম মনে রাখার উপায়: position-এ “কয়টি”, label-এ “কোথা থেকে কোথা পর্যন্ত”।
  6. উল্টো দিকে label দিলে ফাঁকা। o.loc[1004:1002] সাজানো index-এ 1004 থেকে সামনে হাঁটে, 1002-তে কখনো পৌঁছায় না, তাই কোনো সারি নেই: shape (0, 9). কোনো error নেই, শুধু ফাঁকা টেবিল, যা একটি নিঃশব্দ ভুল হতে পারে।

চালিয়ে দেখুন — একই টেবিলে loc, iloc, at আর iat

import pandas as pd
pd.set_option("display.width", 60)
df = pd.read_csv("orders.csv")
print(df.shape, "| index:", df.index)

print("loc[2:4]  ->", df.loc[2:4, "order_id"].tolist())
print("iloc[2:4] ->", df.iloc[2:4, 0].tolist())

print(df.loc[2:4, "district":"qty"])     # দুই দিকেই শেষটি ধরা
print(df.iloc[2:4, 2:5])                 # দুই দিকেই শেষটি বাদ

o = df.set_index("order_id")             # index এখন order_id
print(o.loc[1003:1005, ["district", "payment"]])
print("at :", o.at[1005, "product"], "| iat:", df.iat[4, 3])
print(type(df["qty"]).__name__, type(df[["qty"]]).__name__)

try:
    df.loc[1003]                         # 1003 এখানে label নয়
except KeyError as e:
    print("KeyError:", e)

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

(14, 10) | index: RangeIndex(start=0, stop=14, step=1)
loc[2:4]  -> [1003, 1004, 1005]
iloc[2:4] -> [1003, 1004]
  district     product      category  qty
2     ঢাকা    ইয়ারফোন  ইলেকট্রনিক্স    1
3    খুলনা  সরিষার তেল          মুদি    3
4     ঢাকা       শাড়ি         পোশাক    1
  district     product      category
2     ঢাকা    ইয়ারফোন  ইলেকট্রনিক্স
3    খুলনা  সরিষার তেল          মুদি
         district payment
order_id
1003         ঢাকা   bKash
1004        খুলনা     COD
1005         ঢাকা    card
at : শাড়ি | iat: শাড়ি
Series DataFrame
KeyError: 1003

প্রথম দুই লাইনে চমকটি সংখ্যায়: একই 2:4, কিন্তু loc তিনটি অর্ডার দেয়, iloc দুটি। তৃতীয় টেবিলে কলামেও একই নিয়ম: "district":"qty" চারটি কলাম, qty-সহ; আর iloc-এর 2:5 position 2, 3, 4, মানে district, product, category, qty বাদ। set_index-এর পর o.loc[1003:1005] অর্ডার নম্বর দিয়ে সরাসরি খোঁজে, দুই প্রান্তসহ। at আর iat একই ঘরে পৌঁছেছে দুই পথে: label (1005, "product") আর position (সারি 4, কলাম 3). শেষ লাইন সবচেয়ে শিক্ষণীয়: মূল df-এর index 0 থেকে 13, তাই df.loc[1003] label 1003 খুঁজে পায় না, KeyError. অর্ডার নম্বর কলামে আছে বলেই তা label হয়ে যায় না।

at আর iat কখন

at/iat শুধু একটি ঘরের জন্য, আর loc/iloc-এর চেয়ে দ্রুত, কারণ slice বা তালিকা আছে কি না যাচাইয়ের ধাপগুলো বাদ দেয়। loop-এর ভেতরে বারবার একটি ঘর পড়তে হলে কাজে লাগে, যদিও pandas-এ loop নিজেই প্রায়ই ধীর পথ (পর্ব ২৭-এ এর মাপজোখ আসবে)। at দিয়ে লেখাও যায়: o.at[1006, "delivery_days"] = 4.

মূল ধারণা

loc প্রশ্ন করে “কোন নাম?”, iloc প্রশ্ন করে “কত নম্বর জায়গা?”। নাম দিয়ে slice-এ দুই প্রান্তই আসে; জায়গা দিয়ে slice-এ শেষটি বাদ। ডিফল্ট RangeIndex-এ দুটি মিলে যায় বলে ভুলটি লুকিয়ে থাকে, তারপর একদিন সাজানো বা ছাঁকা টেবিলে হঠাৎ ভুল সারি আসে।

৩ · সারি আর কলাম একসঙ্গে, আর set_index-এর পর বাছাই

loc আর iloc দুটিই কমা দিয়ে দুটি নির্দেশ নেয়: বাঁয়ে সারি, ডানে কলাম। প্রতিটি জায়গায় চার রকম জিনিস বসতে পারে: একটি label বা position, একটি তালিকা, একটি slice, বা একটি boolean mask. কিছু উদাহরণ, মূল df-এ (RangeIndex):

df.loc[[0, 2], ["order_id", "product"]]      # সারি 0 আর 2, দুটি কলাম
df.loc[df["qty"] > 2, "order_id"]            # mask দিয়ে সারি, একটি কলাম
df.iloc[-3:, -2:]                            # শেষ ৩ সারি, শেষ ২ কলাম
df.loc[:, df.columns.str.startswith("p")]    # নাম 'p' দিয়ে শুরু এমন কলাম
# → ['product', 'price_paisa', 'payment']

শেষ লাইনটি দেখায়, কলামের দিকেও mask চলে: df.columns নিজেই একটি Index, তার ওপর str.startswith একটি True/False তালিকা দেয়। একটি নিয়ম মনে রাখুন: একটি জায়গায় একক label দিলে সেই দিকটি “নেমে যায়”। df.loc[0] একটি Series (এক সারি), df.loc[0, "qty"] একটি একক মান, আর df.loc[[0]] এক সারির DataFrame. NumPy-র নিয়মের মতোই: integer দিলে দিক হারায়, তালিকা বা slice দিলে থাকে।

set_index — একটি কলামকে সারির নাম বানানো

অর্ডার নম্বর দিয়ে বারবার খুঁজতে হলে o = df.set_index("order_id") সুবিধার: এরপর o.loc[1005] সরাসরি সেই অর্ডার। পর্ব ২০-এ index কী আর কেন, তা দেখেছেন; এখানে শুধু বাছাইয়ের দিক। এটি একটি নতুন DataFrame দেয়, মূল df বদলায় না; ফেরাতে o.reset_index().

Label দিয়ে খোঁজার তিনটি ফাঁদ আছে, যেগুলো আমরা ডেস্কটপে সত্যিকারের run-এ দেখেছি (Python 3.14.4, pandas 3.0.2, একই orders.csv):

>>> o2 = df.set_index("order_id").sort_values("price_paisa")
>>> o2.index.tolist()
[1008, 1001, 1010, 1013, 1004, 1007, 1006, 1003, 1012, 1011, 1002, 1009, 1005, 1014]
>>> o2.loc[1003:1005].index.tolist()
[1003, 1012, 1011, 1002, 1009, 1005]
>>> o2.loc[1003:1099]
KeyError: 'Cannot get right slice bound for non-monotonic index with a missing label 1099. Either sort the index or specify an existing label.'

>>> d = df.set_index("district")
>>> d.loc["ঢাকা":"খুলনা"]
KeyError: "Cannot get left slice bound for non-unique label: 'ঢাকা'"
>>> type(d.loc["রাজশাহী"]).__name__, type(d.loc["ঢাকা"]).__name__
('Series', 'DataFrame')
  • অসাজানো index-এ slice মানে “এই label থেকে ওই label পর্যন্ত যা আছে”, সংখ্যার মাঝামাঝি নয়। দামে সাজানোর পর o2.loc[1003:1005] ছয়টি সারি দিয়েছে, যার মধ্যে 1011, 1012 আর 1009-ও আছে, কারণ সাজানো টেবিলে এরা 1003 আর 1005-এর মাঝখানে বসেছে। আর এমন index-এ টেবিলে নেই এমন label (1099) দিলে pandas থেমে যায়। সমাধান: আগে o2.sort_index(), অথবা slice নয়, mask (o2.index.to_series().between(1003, 1005)).
  • পুনরাবৃত্ত label-এ slice-এর শুরু অস্পষ্ট। ঢাকা সাতবার আছে, কোন ঢাকা থেকে শুরু? pandas error দেয়। d.sort_index() করলে একই label পাশাপাশি আসে, তখন slice চলে।
  • ফলের ধরন ডেটার ওপর নির্ভর করে। রাজশাহী একবার আছে, তাই d.loc["রাজশাহী"] একটি Series; ঢাকা সাতবার, তাই একটি DataFrame. একই কোড এক মাসের ডেটায় Series, পরের মাসে DataFrame দিতে পারে। যে কোড একটি নির্দিষ্ট ধরন ধরে নেয়, সে তখন ভাঙে। নিশ্চিত থাকতে d.loc[["রাজশাহী"]] (তালিকা) লিখুন, তাতে সবসময় DataFrame আসে; আর index অনন্য কি না দেখতে d.index.is_unique.

৪ · Boolean mask — &, |, ~ আর বন্ধনী

ধরুন বাজারঘরের মার্কেটিং দল জানতে চায়: “ঢাকায় bKash দিয়ে কেনা ৫০০ টাকার বেশি দামের অর্ডার কোনগুলো?” এটি তিনটি শর্তের একটি filter. pandas-এ ধারণাটি পর্ব ১৪-এর NumPy mask-এর মতোই: প্রতিটি শর্ত প্রতিটি সারির জন্য একটি True বা False দেয়, মানে টেবিলের সমান দৈর্ঘ্যের একটি bool Series. তফাত একটি: pandas-এর mask-এর সঙ্গে index থাকে, আর df[mask] বা df.loc[mask] সেই index মিলিয়ে সারি বাছে।

জোড়া লাগানোর নিয়মও NumPy-র: “এবং” &, “অথবা” |, “না” ~, আর প্রতিটি তুলনা বন্ধনীতে। কারণটিও একই: Python-এ &-এর অগ্রাধিকার == আর >-এর চেয়ে বেশি, আর and পুরো Series-কে একটি একক True বা False-এ নামাতে চায়। নিচের ধাপে প্রথম ছয়টি অর্ডারে দুটি শর্ত জুড়ে দেখুন।

1d = df.head(6)2b = d["payment"] == "bKash"3k = d["district"] == "ঢাকা"4m = b & k5d.loc[m, "order_id"].tolist()6d.loc[~m, "order_id"].tolist()
মান (Watch) · সারি 0–5
payment
b
k
m
  1. ছয়টি অর্ডার। d = df.head(6): অর্ডার 1001 থেকে 1006, shape (6, 10). Watch-এর প্রথম লাইনে তাদের payment: bKash, COD, bKash, COD, card, bKash.
  2. প্রথম শর্ত। d["payment"] == "bKash" প্রতিটি সারিতে আলাদা করে তুলনা করে: সারি 0, 2, 5 True. ফল একটি bool Series, index 0–5, তিনটি True.
  3. দ্বিতীয় শর্ত। জেলা ঢাকা কি না: সারি 0, 2, 4 True (1001, 1003, 1005). এটিও তিনটি True, কিন্তু ভিন্ন সারিতে।
  4. ঘরে ঘরে AND. b & k শুধু সেই সারিতে True, যেখানে দুটিই True: সারি 0 আর 2. সারি 4 ঢাকা কিন্তু card, সারি 5 bKash কিন্তু সিলেট, দুটিই বাদ। m.sum() = 2.
  5. Mask দিয়ে সারি, নাম দিয়ে কলাম। d.loc[m, "order_id"] True সারির order_id দেয়: 1001 আর 1003. index মিলিয়ে বাছাই হয়, তাই mask-এর index আর টেবিলের index একই হতে হবে।
  6. উল্টে দেওয়া। ~m হলো [F T F T T T]: “ঢাকার bKash অর্ডার নয়” এমন সবগুলো, 1002, 1004, 1005, 1006. ~ শুধু bool-এ ব্যবহার করুন; “না” লিখতে not চলে না।

চালিয়ে দেখুন — ঢাকার bKash অর্ডার, mask আর query দুইভাবে

import pandas as pd
pd.set_option("display.width", 60)
df = pd.read_csv("orders.csv")
limit = 50000                            # ৫০০ টাকা, paisa-তে

is_bkash = df["payment"] == "bKash"
is_dhaka = df["district"] == "ঢাকা"
pricey = df["price_paisa"] > limit
m = is_bkash & is_dhaka & pricey
print("True গোনা:", is_bkash.sum(), is_dhaka.sum(), pricey.sum(), "->", m.sum())
print(df.loc[m, ["order_id", "product", "price_paisa"]])

q = df.query("payment == 'bKash' and district == 'ঢাকা'"
             " and price_paisa > @limit")
print("query একই?", q.equals(df[m]))

print("না-bKash:", df.loc[~is_bkash, "order_id"].tolist())
print("ঢাকায় COD বা card:",
      df.loc[is_dhaka & ~is_bkash, "order_id"].tolist())

try:
    df[is_bkash and is_dhaka]
except ValueError as e:
    print("and ->", e)

try:
    df[df["payment"] == "bKash" & df["qty"] > 2]
except TypeError as e:
    print("বন্ধনী ছাড়া ->", type(e).__name__)

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

True গোনা: 8 7 8 -> 2
   order_id       product  price_paisa
2      1003      ইয়ারফোন        95000
8      1009  স্মার্টওয়াচ       250000
query একই? True
না-bKash: [1002, 1004, 1005, 1008, 1011, 1012]
ঢাকায় COD বা card: [1005, 1011]
and -> The truth value of a Series is ambiguous. Use a.empty, a.bool(), a.item(), a.any() or a.all().
বন্ধনী ছাড়া -> TypeError

প্রথম লাইন mask-এর হিসাব দেখায়: bKash ৮টি, ঢাকা ৭টি, ৫০০ টাকার বেশি দাম ৮টি, কিন্তু তিনটি একসঙ্গে মাত্র ২টি, ঢাকার ইয়ারফোন আর স্মার্টওয়াচ। ঢাকার বাকি bKash অর্ডারগুলো (চা পাতা, চাল, গ্রিন টি) সস্তা বলে বাদ। query একই দুটি সারি দিয়েছে, index-সহ হুবহু (equals True). ~is_bkash ছয়টি না-bKash অর্ডার দেয়, আর is_dhaka & ~is_bkash ঢাকার শাড়ি (card) আর পাঞ্জাবি (COD). এরপর দুটি আসল ভুল। and দিলে pandas ঠিক NumPy-র মতো ValueError দেয়, বার্তায় বিকল্পসহ। বার্তায় a.bool()-এর নাম এখনো আছে, যদিও pandas 3-এ সেই method সরানো হয়েছে; error বার্তাও সবসময় হালনাগাদ থাকে না। বন্ধনী ছাড়া লেখায় error আলাদা: আগে "bKash" & df["qty"] হিসাব হতে চায়, আর একটি লেখা আর একটি সংখ্যার কলামের bitwise AND অর্থহীন, তাই TypeError.

দুটি ছোট অভ্যাস। (১) জটিল শর্তকে নাম দেওয়া mask-এ ভাঙুন (is_bkash, is_dhaka), তারপর জুড়ুন; প্রতিটির .sum() ছেপে দেখলে কোন শর্ত কত সারি কাটছে, তা সাথে সাথে বোঝা যায়, আর ভুল শর্ত (যেমন “Dhaka” ইংরেজিতে লেখা, অথচ ডেটায় বাংলা) তখনই ধরা পড়ে, কারণ তার .sum() হবে 0. (২) ফাঁকা ঘরের তুলনা সবসময় False: df["delivery_days"] > 2 NaN সারিতে False দেয়, আর ~(df["delivery_days"] > 2) সেই সারিতে True. তাই “২ দিনের বেশি নয়” আর “২ দিন বা কম” এক জিনিস নয়, ফাঁকা সারিগুলো প্রথমটিতে ঢোকে, দ্বিতীয়টিতে নয়। ফাঁকা ঘর আলাদা করে ধরতে .isna(); ফাঁকা ঘর কীভাবে পূরণ করবেন, সেটি পর্ব ২৩-এর বিষয়।

৫ · isin, between আর str — তালিকা, সীমা আর বাংলা লেখায় খোঁজা

অনেক শর্ত বারবার একই রূপে আসে, আর pandas তাদের জন্য ছোট method দেয়, যা পড়তে সহজ আর ভুলের সুযোগ কম।

isin — “এই তালিকার যেকোনো একটি”। (df.district == "ঢাকা") | (df.district == "সিলেট")-এর বদলে df["district"].isin(["ঢাকা", "সিলেট"]). বাজারঘরের ডেটায় এটি ৯টি সারিতে True. উল্টো চাইলে ~df["district"].isin([...]).

between — “এই দুই সীমার মাঝে”। df["price_paisa"].between(30000, 95000) মানে 30000 ≤ দাম ≤ 95000; default-এ দুই প্রান্তই ধরা (inclusive="both"). আমাদের run-এ এটি অর্ডার 1003, 1004, 1006, 1007, 1012, 1013 দিয়েছে। inclusive="left" দিলে 95000 বাদ পড়ে, আর 1003 ও 1012 চলে যায়। দাম বা নম্বরের সীমায় এক প্রান্ত ধরা হবে কি না, কাজের নিয়ম থেকে ঠিক করুন, আর কোডে স্পষ্ট লিখুন।

বাংলা লেখায় str.contains আর str.startswith

লেখার কলামে .str দিয়ে Python-এর string method পুরো কলামে একসঙ্গে চলে: str.contains, str.startswith, str.endswith, str.lower, str.split ইত্যাদি। pandas 3-এর নতুন str dtype-এ এগুলো আগের চেয়ে পরিষ্কার আচরণ করে: ফাঁকা ঘরে str.contains সরাসরি False দেয়, তাই ফল একটি খাঁটি bool mask. পুরোনো object কলামে ফাঁকা ঘরে NaN আসত, আর সেই mask দিয়ে বাছাই করতে গেলে error হতো, তাই পুরোনো কোডে na=False লেখার অভ্যাস দেখবেন।

এবার বাংলা লেখার একটি বাস্তব ফাঁদ। ধরুন আপনি “চা” বিক্রির হিসাব চান। নিচে ডেস্কটপে সত্যিকারের run (Python 3.14.4, pandas 3.0.2):

>>> p = df["product"]
>>> p.dtype
str
>>> p[p.str.contains("চা")].tolist()
['চা পাতা', 'চার্জার', 'মিনিকেট চাল', 'চা পাতা']
>>> p[p.str.startswith("চা")].tolist()
['চা পাতা', 'চার্জার', 'চা পাতা']
>>> p[p.str.split().str[0] == "চা"].tolist()
['চা পাতা', 'চা পাতা']
>>> p[p.str.match(r"চা\b")].tolist()
['চার্জার']

চারটি লাইন, চারটি শিক্ষা। contains("চা") অক্ষরের টুকরো খোঁজে, শব্দ নয়: “চার্জার” আর “মিনিকেট চাল”-এর ভেতরেও “চা” আছে, তাই চায়ের বিক্রি দ্বিগুণ দেখাবে। startswith চাল বাদ দেয়, কিন্তু চার্জার রাখে। শব্দ ধরে মেলাতে প্রথম শব্দ আলাদা করে পুরো মিল (str.split().str[0] == "চা") বা পুরো নাম মেলানো (p == "চা পাতা") নিরাপদ। শেষ লাইনটি সবচেয়ে অবাক করা: regular expression-এর \b (শব্দের সীমা) উল্টো ফল দিয়েছে, শুধু চার্জার! কারণ Python-এর re “া” (আ-কার, U+09BE)-কে শব্দের অক্ষর ধরে না ("া".isalnum() হলো False), তাই “চা” আর “র”-এর মাঝে সে একটি শব্দসীমা দেখে, আর “চা” আর ফাঁকা জায়গার মাঝে দেখে না। ইংরেজির জন্য লেখা regex-এর অভ্যাস বাংলায় চুপচাপ ভেঙে যায়।

বাংলা লেখা মেলানোর আগে তিনটি যাচাই। (১) একই দেখতে লেখা ভিন্ন code point-এ থাকতে পারে: “শাড়ি”-র “ড়” কখনো একটি অক্ষর (U+09DC), কখনো “ড” আর নুকতা (U+09A1 + U+09BC); দুটি চোখে এক, কিন্তু == False দেয়। তুলনার আগে Unicode normalization (str.normalize("NFC")) করুন; বিস্তারিত পর্ব ১০-এ। (২) শুরু-শেষের ফাঁকা জায়গা ছেঁটে নিন (str.strip())। (৩) ইংরেজি অংশে বড়-ছোট হাতের অক্ষর: ডেটায় “bKash”, “Bkash”, “BKASH” মিশে থাকলে str.lower() == "bkash". এগুলো আসলে ডেটা পরিষ্কারের কাজ, যা পর্ব ২৩-এ পুরোটা আসবে; বাছাইয়ের আগে অন্তত জানুন যে ফাঁদটি আছে।

৬ · query() — শর্তকে বাক্যের মতো লেখা

query() একটি শর্তকে string হিসেবে নেয় আর ভেতরে নিজেই mask বানায়। অংশ ৪-এর কোডে দেখেছেন, দুটি ফল হুবহু এক (q.equals(df[m]) True). পার্থক্য শুধু লেখায়:

# mask
df[(df["payment"] == "bKash") & (df["district"] == "ঢাকা") & (df["price_paisa"] > limit)]
# query
df.query("payment == 'bKash' and district == 'ঢাকা' and price_paisa > @limit")

লক্ষ করুন চারটি নিয়ম। (১) string-এর ভেতরে কলামের নাম সরাসরি, df[...] ছাড়া। (২) and, or, not এখানে চলে (pandas নিজে এগুলোকে &, |, ~-এ বদলে নেয়), আর তুলনার অগ্রাধিকারও স্বাভাবিক, তাই বন্ধনীর ফাঁদ নেই। (৩) Python-এর কোনো variable ব্যবহার করতে নামের আগে @: @limit. @ ছাড়া লিখলে pandas limit নামের কলাম খুঁজবে। (৪) কলামের নামে ফাঁকা জায়গা বা অদ্ভুত চিহ্ন থাকলে backtick: `unit price` > 100. তালিকার জন্য in চলে: df.query("district in ['সিলেট', 'খুলনা']") আমাদের run-এ 1004, 1006, 1012, 1014 দিয়েছে।

কখন query, কখন mask

পরিস্থিতিভালো পছন্দকারণ
notebook-এ দ্রুত অনুসন্ধান, অনেক শর্তqueryপড়তে বাক্যের মতো, বন্ধনীর ভুল কম
একটি টেবিলের mask অন্য কাজে আবার লাগবে (গোনা, বসানো)maskনাম দেওয়া mask বারবার ব্যবহার করা যায়, loc-এ বসানোও যায়
শর্ত বানাতে function বা জটিল Python লাগেmaskquery-র ভাষা সীমিত, সব Python চলে না
কলামের নাম কোডে ভুল লিখলে আগেই ধরা পড়ুকmaskstring-এর ভেতরের নাম editor বা linter দেখে না; ভুল ধরা পড়ে শুধু চালানোর সময়
ব্যবহারকারীর লেখা ইনপুট থেকে শর্তmask (কখনো query নয়)query string-টিকে expression হিসেবে মূল্যায়ন করে; বাইরের লেখা সরাসরি দেওয়া নিরাপদ নয়

গতি নিয়ে প্রচলিত কথা হলো query দ্রুত। আংশিক সত্য: numexpr package বসানো থাকলে খুব বড় টেবিলে কিছু শর্ত দ্রুত হতে পারে, ছোট টেবিলে বরং ধীর। তাই পড়ার সুবিধার জন্য query বেছে নিন, গতির জন্য নয়; গতি জরুরি হলে নিজের ডেটায় মাপুন (পর্ব ২৭)।

৭ · মান বসানো — loc, Copy-on-Write আর chained assignment

পড়ার চেয়ে লেখা কঠিন। বাজারঘর ঠিক করেছে, ৩টি বা বেশি জিনিসের অর্ডারে ফ্রি ডেলিভারি। একটি নতুন কলাম free_ship, সবার জন্য 0, আর বড় অর্ডারে 1. প্রথম যে লেখাটি মাথায় আসে, তা হলো “বড় অর্ডারগুলো নেওয়া, তারপর তাদের free_ship-এ 1 বসানো”: df[big]["free_ship"] = 1. এটি দুই ধাপের লেখা, যার নাম chained assignment, আর pandas 3-এ এটি কখনো মূল টেবিল বদলায় না।

চায়ের দোকানের হিসাবের খাতার কথা ভাবুন। মালিক একটি পাতার ফটোকপি দিলেন, আপনি তাতে টিক দিলেন; আসল খাতায় কোনো টিক পড়ল না। df[big] হলো সেই নামহীন ফটোকপি, আর ["free_ship"] = 1 তাতে লিখে তা ফেলে দেয়।

pandas 3-এর নিয়ম: Copy-on-Write

pandas 2 পর্যন্ত এই প্রশ্নের উত্তর ছিল জটিল: কোনো বাছাই view দিত (তখন লেখা মূলে পৌঁছাত), কোনোটি copy (পৌঁছাত না), আর কোনটি কী, তা নির্ভর করত ভেতরের memory বিন্যাসের ওপর। সন্দেহ হলে pandas একটি SettingWithCopyWarning দিত। pandas 3.0 এই অনিশ্চয়তা পুরো সরিয়ে দিয়েছে Copy-on-Write (CoW) দিয়ে, আর এখন এটি বন্ধ করার কোনো উপায়ও নেই। নিয়ম মাত্র দুটি:

  1. যেকোনো বাছাই বা method-এর ফল আচরণে সবসময় একটি আলাদা object, একটি নতুন DataFrame বা Series. তাতে লিখলে মূলটি কখনো বদলায় না।
  2. ভেতরে memory তখনই কপি হয়, যখন কেউ লেখে। col = df["qty"] লিখলে শুরুতে col আর df একই memory ভাগ করে (আমাদের ডেস্কটপ run-এ np.shares_memory True দিয়েছে), তাই বাছাই সস্তা। col.iloc[0] = 99 লেখার মুহূর্তে pandas col-এর জন্য নিজস্ব copy বানায়; df.loc[0, "qty"] আগের মতো 2 থাকে, আর memory আর ভাগ হয় না। “ভাগ করে রাখা, লেখার সময় কপি করা” — নামটি এখান থেকেই।

এর ফল: chained assignment-এর প্রথম ধাপ সবসময় একটি আলাদা object, তাই দ্বিতীয় ধাপের লেখা কখনো মূলে পৌঁছায় না। কোন টেবিল বদলাতে চান, সেটিকে সরাসরি এক ধাপে লিখতে হবে: df.loc[সারি, কলাম] = মান. নিচের ধাপে ভেতরের ঘটনাটি দেখুন।

1big = df["qty"] > 2        # ৫টি True2tmp = df[big]              # নতুন DataFrame3tmp["free_ship"] = 14df["free_ship"].sum()5df.loc[big, "free_ship"] = 16col = df["qty"]; col.iloc[0] = 99
মান (Watch)
df free_ship-এ 1
tmp free_ship-এ 1
df.loc[0, "qty"]
col.iloc[0]
  1. Mask বানানো। df["qty"] > 2: পাঁচটি অর্ডারে ৩টি বা বেশি জিনিস (1004, 1006, 1008, 1010, 1013). free_ship কলামটি আগেই সবার জন্য 0 বসানো, তাই df-এ এখন 1 আছে 0টি।
  2. chained-এর প্রথম ধাপ। df[big] পাঁচ সারির একটি নতুন DataFrame দেয়। df[big]["free_ship"] = 1 লিখলে pandas ঠিক এই কাজটিই করে, শুধু tmp নামটি দেয় না। CoW-এর নিয়ম ১: এটি আচরণে সবসময় আলাদা object.
  3. লেখা পড়ে ফটোকপিতে। tmp["free_ship"] = 1 tmp-এর পাঁচটি সারি বদলায়। এখানে নাম আছে বলে pandas কোনো সতর্কবার্তা দেয় না, কারণ নিজের DataFrame বদলানো বৈধ কাজ।
  4. মূল টেবিল অক্ষত। df["free_ship"].sum() এখনো 0. chained লেখায় tmp-এর কোনো নাম থাকে না, তাই লেখার পরই সেটি হারিয়ে যায়, আর আপনার কাজ কোথাও থাকে না। pandas 3 এই ক্ষেত্রে ChainedAssignmentError নামের একটি সতর্কবার্তা দেয় (নিচের কোডে দেখবেন), কিন্তু সতর্কবার্তা মানে কাজ হয়েছে, এমন নয়।
  5. এক ধাপে loc. df.loc[big, "free_ship"] = 1 সারি আর কলাম দুটিই একবারে df-কে বলে দেয়, তাই লেখা সরাসরি df-এ পড়ে: পাঁচটি 1. এটিই একমাত্র নির্ভরযোগ্য নিয়ম: বদলাতে চাইলে বদলানোর টেবিলের ওপরই loc.
  6. CoW-এর নিয়ম ২. col = df["qty"] শুরুতে df-এর memory ভাগ করে, কিন্তু col.iloc[0] = 99 লেখার মুহূর্তে col নিজস্ব copy পায়। col-এ 99, df-এ এখনো 2. pandas 2-এ পুরোনো নিয়মে এই লেখা অনেক সময় df-ও বদলে দিত।

চালিয়ে দেখুন — chained assignment-এর ফাঁদ আর loc-এর সমাধান

নিচের কোড ইচ্ছা করে দুটি ভুল লেখা চালায়, তাই pandas সতর্কবার্তা দেবে; সেটিই দেখানোর বিষয়।

import pandas as pd
df = pd.read_csv("orders.csv")
df["free_ship"] = 0                      # নতুন কলাম, সবাই 0
big = df["qty"] > 2                      # ৩টি বা বেশি জিনিস

# ভুল: দুই ধাপের (chained) assignment
df[big]["free_ship"] = 1
print("chained-এর পর:", df["free_ship"].sum(), "টি 1")

# আরেক রূপ: প্রথমে কলাম, তারপর সারি
df["free_ship"][big] = 1
print("দ্বিতীয় রূপের পর:", df["free_ship"].sum(), "টি 1")

# ঠিক: এক ধাপে, loc[সারি, কলাম]
df.loc[big, "free_ship"] = 1
print("loc-এর পর:", df["free_ship"].sum(), "টি 1")
print(df.loc[big, ["order_id", "qty", "free_ship"]])

# আলাদা নাম দিলে: নিজস্ব DataFrame, মূলটি অক্ষত
dhaka = df[df["district"] == "ঢাকা"]
dhaka["note"] = "ঢাকা"                   # কোনো সতর্কবার্তা নেই
print("df-এ note আছে?", "note" in df.columns)
print("pandas", pd.__version__)

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

main.py:7: ChainedAssignmentError: A value is being set on a copy of a DataFrame or Series through chained assignment.
Such chained assignment never works to update the original DataFrame or Series, because the intermediate object on which we are setting values always behaves as a copy (due to Copy-on-Write).

Try using '.loc[row_indexer, col_indexer] = value' instead, to perform the assignment in a single step.

See the documentation for a more detailed explanation: https://pandas.pydata.org/pandas-docs/stable/user_guide/copy_on_write.html#chained-assignment
  df[big]["free_ship"] = 1
chained-এর পর: 0 টি 1
main.py:11: ChainedAssignmentError: A value is being set on a copy … (একই বার্তা)
  df["free_ship"][big] = 1
দ্বিতীয় রূপের পর: 0 টি 1
loc-এর পর: 5 টি 1
    order_id  qty  free_ship
3       1004    3          1
5       1006    3          1
7       1008    5          1
9       1010    4          1
12      1013    3          1
df-এ note আছে? False
pandas 3.0.2

সতর্কবার্তাটি নিজেই পুরো শিক্ষা দেয়: “Such chained assignment never works”, মানে এমন লেখা কখনো মূল টেবিল বদলায় না, কারণ মাঝের object সবসময় copy-র মতো আচরণ করে। আর সমাধানও বলে দেয়: .loc[row_indexer, col_indexer] = value, এক ধাপে। দুটি chained রূপের পরই গণনা 0: প্রথমটি আগে সারি ছেঁকে তারপর কলাম ধরে, দ্বিতীয়টি আগে কলাম (একটি Series) তুলে তারপর সারিতে লেখে; pandas 2-এ দ্বিতীয় রূপটি অনেক সময় কাজ করত, pandas 3-এ করে না। loc-এর পর পাঁচটি অর্ডারে 1, ঠিক সেই পাঁচটি, যাদের qty ৩ বা বেশি। শেষ অংশটি সূক্ষ্ম: dhaka একটি নাম দেওয়া নতুন DataFrame, তাতে নতুন কলাম যোগ করা বৈধ, তাই কোনো সতর্কবার্তা নেই, আর মূল df-এ note কলাম আসেনি। pandas 2-এ ঠিক এই লাইনে বিখ্যাত SettingWithCopyWarning আসত; pandas 3-এ সেটি আর নেই, কারণ আচরণ এখন অনিশ্চিত নয়। সতর্কবার্তা চাপা দেবেন না: এটি বলছে আপনার কোড এমন কিছু ভাবছে, যা ঘটছে না।

where আর mask — শর্ত দিয়ে মান রাখা বা বদলানো

কখনো সারি ছাঁকা নয়, শর্ত না মানা মানগুলো বদলানো দরকার, কিন্তু টেবিলের আকার আর ক্রম অক্ষত রেখে। এর জন্য দুটি method, যাদের অর্থ একে অপরের উল্টো (NumPy-র np.where-এর তিন-argument রূপের কাছাকাছি):

  • s.where(শর্ত, other): শর্ত True হলে মান রাখে, False হলে other বসায় (না দিলে NaN).
  • s.mask(শর্ত, other): শর্ত True হলে মান বদলায়, False হলে রাখে।
>>> p = df["price_paisa"]
>>> p.where(p < 200000).tolist()[:6]
[25000.0, 180000.0, 95000.0, 32000.0, nan, 60000.0]
>>> p.mask(p > 200000, 200000).tolist()[:6]
[25000, 180000, 95000, 32000, 200000, 60000]

(ডেস্কটপে সত্যিকারের run, pandas 3.0.2.) প্রথমটিতে ৩৫০০ টাকার শাড়ি (350000 paisa) NaN হয়ে গেছে, আর পুরো কলাম int থেকে float হয়েছে, কারণ NaN শুধু float-এ থাকে; এটি একটি নিঃশব্দ dtype বদল। দ্বিতীয়টি দাম কেটে ২০০০ টাকায় আনে, int অক্ষত। দুটিই নতুন Series দেয়; মূলে রাখতে চাইলে df["price_capped"] = p.mask(...), নতুন কলামে, যাতে আসল দাম হারিয়ে না যায়। একটি কলামের কিছু মান সরাসরি বদলাতে চাইলে সেই পরিচিত পথ: df.loc[p > 200000, "price_paisa"] = 200000.

আরও গভীরে। Copy-on-Write-এর প্রস্তাব pandas-এর উন্নয়ন প্রস্তাব PDEP-7-এ লেখা; প্রধান কারিগর হিসেবে Joris Van den Bossche আর Patrick Hoefler-এর নাম আসে। pandas 1.5 (২০২২)-এ এটি একটি ঐচ্ছিক setting হিসেবে আসে, pandas 2.x-এ পরীক্ষামূলক থেকে পরিপক্ব হয়, আর pandas 3.0-তে একমাত্র আচরণ হয়; এখন pd.options.mode.copy_on_write পড়তে গেলেই pandas 3.0.2 একটি Pandas4Warning দেয় যে setting-টি অর্থহীন আর pandas 4-এ সরানো হবে (আমাদের ডেস্কটপ run-এ দেখা)। chained assignment ধরার কৌশলটি মজার: pandas লেখার মুহূর্তে মধ্যবর্তী object-এর reference count দেখে; কোনো নাম না থাকলে বোঝে এটি একটি নামহীন অস্থায়ী object, আর সতর্ক করে। কৌশলটি CPython-এর খুঁটিনাটির ওপর নির্ভর করে, তাই ভিন্ন পরিবেশে সতর্কবার্তা না-ও আসতে পারে; নিয়মটি কিন্তু সবখানে একই।

৮ · সাজানো, সেরা k-টি, নমুনা আর ধরন ধরে কলাম

ক্রিকেটের স্কোরকার্ডে ব্যাটারদের রান অনুযায়ী সাজানো এক কাজ, আর “সেরা পাঁচজন” বাছাই আরেক কাজ, যদিও প্রথমটি দিয়ে দ্বিতীয়টি করা যায়। pandas-এ দুটির জন্যই আলাদা method আছে, আর সমান রানের দুজন থাকলে কে তালিকায় ঢুকবে, সেই প্রশ্নে দুটির আচরণ আলাদা।

sort_values. df.sort_values("total", ascending=False) মোট দামে বড় থেকে ছোট। একাধিক key দিতে তালিকা: sort_values(["district", "total"], ascending=[True, False]) মানে আগে জেলা ক-থেকে-হ, একই জেলার ভেতরে মোট দাম বড় থেকে ছোট। বাংলা লেখার ক্রম এখানে Unicode code point-এর ক্রম, যা বাংলা বর্ণমালার ক্রমের কাছাকাছি, কিন্তু যুক্তাক্ষর আর কার-চিহ্নে সবসময় অভিধানের ক্রম নয়। ফাঁকা মান default-এ শেষে যায়; na_position="first" দিলে শুরুতে, যা “কোন অর্ডার এখনো পৌঁছায়নি” আগে দেখাতে কাজের। সাজানোর পর index-এর label সারির সঙ্গে যায়, তাই loc আর iloc ভিন্ন সারি দেখায় (অংশ ৩)।

nlargest আর nsmallest. df.nlargest(5, "total") মোট দামে সেরা পাঁচটি সারি দেয়। pandas-এর docs বলে এটি sort_values(ascending=False).head(5)-এর মতো, কিন্তু বেশি কার্যকর, কারণ পুরো টেবিল সাজায় না, শুধু সেরা k-টি আলাদা করে (পর্ব ১৪-এর argpartition-এর মতো ধারণা)। আর keep parameter দিয়ে সমান মানের নিয়ম স্পষ্ট বলা যায়: "first" (default, টেবিলে আগে থাকা সারিটি), "last", বা "all" (সীমায় সমান সবগুলো, তাতে k-এর বেশি সারি আসতে পারে)। নিচের কোডে বাজারঘরের ডেটায় ঠিক এমন একটি সমতা আছে: অর্ডার 1002 আর 1006 দুটিরই মোট 180000 paisa, আর সেটি পঞ্চম স্থানে।

import pandas as pd
pd.set_option("display.width", 60)
df = pd.read_csv("orders.csv")
df["total"] = df["qty"] * df["price_paisa"]       # paisa
show = ["order_id", "district", "total"]

print(df.nlargest(5, "total")[show])
a = df.nlargest(5, "total")["order_id"].tolist()
b = df.sort_values("total", ascending=False).head(5)["order_id"].tolist()
c = df.sort_values("total", ascending=False,
                   kind="stable").head(5)["order_id"].tolist()
print("nlargest      :", a)
print("sort+head     :", b)
print("sort(stable)  :", c)
print("keep='all'    :", df.nlargest(5, "total", keep="all")["order_id"].tolist())

s = df.sort_values(["district", "total"], ascending=[True, False])
print(s[show].head(4))
late = df.sort_values("delivery_days", ascending=False, na_position="first")
print("delivery_days:", late["delivery_days"].head(4).tolist())

print("sample:", df.sample(3, random_state=42)["order_id"].tolist(),
      df.sample(3, random_state=42)["order_id"].tolist())
print("number:", df.select_dtypes("number").columns.tolist())
print("str   :", df.select_dtypes("str").columns.tolist()[:3], "…")
top = df.nlargest(1, "total").iloc[0]
print(f"সেরা অর্ডার {top['order_id']}: ৳{top['total'] / 100:,.0f}")

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

    order_id   district   total
13      1014      সিলেট  420000
4       1005       ঢাকা  350000
10      1011       ঢাকা  320000
8       1009       ঢাকা  250000
1       1002  চট্টগ্রাম  180000
nlargest      : [1014, 1005, 1011, 1009, 1002]
sort+head     : [1014, 1005, 1011, 1009, 1002]
sort(stable)  : [1014, 1005, 1011, 1009, 1002]
keep='all'    : [1014, 1005, 1011, 1009, 1002, 1006]
    order_id   district   total
3       1004      খুলনা   96000
11      1012      খুলনা   95000
1       1002  চট্টগ্রাম  180000
9       1010  চট্টগ্রাম  100000
delivery_days: [nan, nan, 5.0, 4.0]
sample: [1010, 1012, 1001] [1010, 1012, 1001]
number: ['order_id', 'qty', 'price_paisa', 'delivery_days', 'total']
str   : ['date', 'district', 'product'] …
সেরা অর্ডার 1014: ৳4,200

ব্রাউজারে তিনটি পদ্ধতিই পঞ্চম স্থানে 1002 দিয়েছে, আর keep='all' সমান মানের 1006-কেও এনে ছয়টি সারি দিয়েছে। কিন্তু একই কোড ডেস্কটপে (Windows, Python 3.14.4, pandas 3.0.2, সত্যিকারের run) একটি লাইন ভিন্ন দিয়েছে:

sort+head     : [1014, 1005, 1011, 1009, 1006]

default sort_values quicksort ব্যবহার করে, যা stable নয়, তাই সমান মানের দুটি সারির ক্রম যন্ত্রভেদে বদলাতে পারে; সম্ভবত ব্রাউজারের 32-bit WebAssembly আর 64-bit ডেস্কটপে NumPy-র ভেতরের sort ভিন্ন পথে গেছে, তবে কারণ যাই হোক, প্রতিশ্রুতি না থাকাটাই আসল কথা। nlargest (keep="first") আর kind="stable" দুই জায়গাতেই 1002 দিয়েছে, কারণ এরা সমান মানে টেবিলের আগের সারিটি রাখার প্রতিশ্রুতি দেয়। বহু-key সাজানোয় জেলা Unicode ক্রমে (খুলনা, চট্টগ্রাম, …) আর জেলার ভেতরে মোট দাম বড় থেকে ছোট। na_position="first" দুটি না-পৌঁছানো অর্ডারকে সবার ওপরে এনেছে। একই random_state দুবার একই নমুনা দিয়েছে। select_dtypes("str") pandas 3-এর লেখার কলামগুলো ধরেছে। শেষ লাইনে paisa থেকে টাকা: 420000 / 100 = ৪২০০ টাকা, শুধু ছাপার সময়।

sample আর select_dtypes

sample এলোমেলো সারি তোলে: df.sample(3, random_state=42) তিনটি, df.sample(frac=0.25, random_state=42) মোটের ২৫% (১৪-র ২৫% হলো ৩.৫, pandas round করে ৪টি দিয়েছে, আমাদের ডেস্কটপ run-এ 1010, 1012, 1001, 1013). random_state ছাড়া প্রতিবার ভিন্ন নমুনা আসে, আর তখন কোনো ফল অন্য কেউ পুনরুৎপাদন করতে পারে না; পর্ব ১১-এর seed-এর নিয়ম এখানেও। model-এর train আর test ভাগ করতে নিজে sample জোড়াতালি না দিয়ে পরে scikit-learn-এর train_test_split ব্যবহার করুন।

select_dtypes নাম নয়, ধরন দেখে কলাম বাছে: df.select_dtypes("number") সব সংখ্যার কলাম, df.select_dtypes("str") pandas 3-এর সব লেখার কলাম, আর exclude= দিয়ে উল্টোটা। সাবধান: order_id সংখ্যা হলেও feature নয়, শুধু একটি পরিচয়; “সব সংখ্যার কলাম” model-এ দিলে সে অর্থহীন order_id থেকেও প্যাটার্ন শিখতে চেষ্টা করবে। ধরন দিয়ে বাছার পর তালিকাটি একবার চোখে দেখে নিন।

পরীক্ষা ও ইন্টারভিউতে। বিশ্ববিদ্যালয়, চাকরি বা ইন্টারভিউর পরীক্ষায় এমন প্রশ্ন প্রায়ই আসে:
● index 0–9-এর টেবিলে df.loc[2:5] আর df.iloc[2:5]-এ কয়টি সারি? — loc-এ ৪টি (2, 3, 4, 5), iloc-এ ৩টি (2, 3, 4).
● df[df.a > 0]["b"] = 1 কেন কাজ করে না? — প্রথম ধাপ একটি নতুন DataFrame দেয় (pandas 3-এ Copy-on-Write), লেখা সেখানে পড়ে; লিখুন df.loc[df.a > 0, "b"] = 1.
● (df.a > 1) and (df.b < 5) কেন error দেয়? — and পুরো Series-এর একটি একক সত্য-মিথ্যা চায়, যা অস্পষ্ট; লিখুন &, বন্ধনীসহ।
● query-তে Python variable কীভাবে দেবেন? — নামের আগে @: df.query("price > @limit").
● nlargest আর sort_values().head()-এর পার্থক্য? — ফল সাধারণত এক, কিন্তু nlargest পুরো টেবিল সাজায় না আর keep দিয়ে সমান মানের নিয়ম স্পষ্ট বলে; default sort-এ সমান মানের ক্রম নিশ্চিত নয়।

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

“ঢাকার bKash অর্ডারগুলোর ডেলিভারি দিন ০ করার কোড চাই” লিখলে একটি AI সহকারী সাথে সাথে pandas কোড দেবে। pandas-এর বিপুল পুরোনো কোড আর tutorial থেকে শেখা বলে এই বিষয়ে AI-এর ভুলের একটি চেনা ধরন আছে, আর বেশিরভাগ ভুল কোড চলে, error দেয় না:

  • পুরোনো pandas-এর অভ্যাস: chained assignment (df[mask]["col"] = x), inplace=True দিয়ে বাছাইয়ের ফল বদলানো, SettingWithCopyWarning চাপা দেওয়ার পরামর্শ (pandas 3-এ সেই সতর্কবার্তাই নেই), pd.options.mode.copy_on_write = True লেখা, বা সরিয়ে দেওয়া .ix আর DataFrame.append.
  • লেখার কলামকে object ধরে নেওয়া: df.select_dtypes("object") দিয়ে লেখার কলাম খোঁজা, যা pandas 3-এর str কলাম বাদ দিতে পারে; বা dtype == object দিয়ে শর্ত লেখা।
  • loc আর iloc গুলিয়ে ফেলা: সাজানো বা ছাঁকা টেবিলে df.loc[0] লিখে “প্রথম সারি” ধরে নেওয়া, বা “শেষ সীমা বাদ” ভেবে loc-এর slice-এ এক কম লেখা।
  • বাংলা লেখায় ইংরেজির অভ্যাস: str.contains("চা") দিয়ে শব্দ খোঁজা, \b দিয়ে শব্দের সীমা, normalization ছাড়া তুলনা; আর শর্তে “Dhaka” লেখা, অথচ ডেটায় “ঢাকা”, যার ফল চুপচাপ শূন্য সারি।
  • ভুল প্রক্রিয়া: পুরো ডেটার ওপর nlargest বা filter দিয়ে “সেরা” গ্রাহক বেছে তারপর train-test ভাগ করা, যা data leakage; বা সমান মানে কোন সারি আসবে, তা না ভেবে “সেরা ১০” রিপোর্ট করা।

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

আমি Python 3.14 আর pandas 3.0-এ কাজ করছি (Copy-on-Write সবসময় চালু, লেখার কলামের dtype হলো str). আমার DataFrame df-এর কলাম: order_id (int64), district (str, বাংলায়, যেমন "ঢাকা"), product (str, বাংলায়), qty (int64), price_paisa (int64, টাকা × ১০০), delivery_days (float64, কিছু NaN), payment (str: "COD", "bKash", "card"). বানানো দুটি সারি: (1001, "ঢাকা", "চা পাতা", 2, 25000, 1.0, "bKash"), (1006, "সিলেট", "চার্জার", 3, 60000, NaN, "bKash"). ১. ঢাকার bKash অর্ডার, যাদের price_paisa একটি variable limit-এর বেশি, সেগুলো বের করো দুইভাবে: & | ~ আর বন্ধনী দিয়ে mask, আর query() দিয়ে @limit; দুটির ফল equals দিয়ে মিলিয়ে দেখাও। ২. qty ৩ বা বেশি হলে নতুন কলাম free_ship-এ 1 বসাও, শুধু df.loc[mask, "free_ship"] দিয়ে। কোনো chained assignment, inplace=True বা warning চাপা দেওয়ার কোড লিখবে না। ৩. product-এ শুধু "চা" শব্দটি আছে এমন সারি খোঁজো; str.contains("চা") কেন "চার্জার" ধরে ফেলে, তা এক লাইনে বলো, আর \b ব্যবহার করবে না। ৪. মোট দাম (qty × price_paisa) অনুযায়ী সেরা ৫টি অর্ডার দাও nlargest দিয়ে, আর সমান মান হলে keep কী করবে, স্পষ্ট বলো। ৫. প্রতিটি ধাপের পর shape আর mask.sum() ছাপো। কোনো function বা parameter নিয়ে নিশ্চিত না হলে বলো; বানিয়ে লিখবে না।

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

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

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

প্র ০১Python-এর সব slice-এ শেষ সীমা বাদ পড়ে, অথচ pandas-এর loc-এ শেষটি ধরা হয়। একই চিহ্নের দুই নিয়ম রেখে pandas কি ভুল করেছে? .ix-এর ইতিহাস এ বিষয়ে কী শেখায়?

শেষ সীমা বাদ দেওয়ার নিয়মের পক্ষে একটি বিখ্যাত যুক্তি আছে। ১৯৮২ সালে Edsger Dijkstra একটি ছোট লেখায় (EWD831, “Why numbering should start at zero”) দেখিয়েছিলেন, start ≤ i < stop রূপে লিখলে দৈর্ঘ্য হয় সরাসরি stop − start, পাশাপাশি দুটি টুকরো (a[0:k] আর a[k:n]) কোনো ফাঁক বা দ্বৈততা ছাড়া জোড়া লাগে, আর ফাঁকা টুকরো লেখা যায় a[k:k]. Python আর NumPy এই নিয়মই নিয়েছে, আর position-এর জগতে এটি সত্যিই সুবিধার।

কিন্তু label-এর জগতে এই সুবিধাগুলোর প্রায় কোনোটিই কাজ করে না। label-এর মাঝে “দৈর্ঘ্য” নেই: 1003 থেকে 1005-এর মাঝে কয়টি অর্ডার, তা টেবিল না দেখে বলা যায় না। আর বাদ-পড়া stop লিখতে হলে আপনাকে জানতে হবে শেষ যে label-টি চান, তার পরের label কোনটি, যা জেলার নাম, তারিখ বা গ্রাহকের পরিচয়ে প্রায় কখনো জানা থাকে না। তারিখের উদাহরণটি সবচেয়ে স্পষ্ট: “অক্টোবরের ১ থেকে ৭ তারিখ” বললে মানুষ ৭ তারিখও বোঝায়। pandas তাই label slice-এ দুই প্রান্ত রাখে, যা মানুষের ভাষার কাছাকাছি।

সমস্যা হয়েছিল দুই নিয়ম এক জায়গায় মেশানোয়। pandas-এর শুরুর বছরগুলোতে .ix নামের একটি indexer ছিল, যা অনুমান করত আপনি label দিয়েছেন নাকি position. index পূর্ণসংখ্যার হলে সে label ধরত, না হলে position, ফলে একই কোড একটি টেবিলে এক অর্থ আর আরেকটিতে অন্য অর্থ দিত। এই অস্পষ্টতা থেকে অসংখ্য নিঃশব্দ ভুল হয়েছে। ঐকমত্য হলো আলাদা, স্পষ্ট দুটি দরজা: loc শুধু label, iloc শুধু position. .ix pandas 0.20 (২০১৭)-এ deprecated হয় আর 1.0 (২০২০)-এ পুরো সরানো হয়।

তাই উত্তর হলো: দুই নিয়ম রাখা ভুল নয়, যদি দুটির জন্য আলাদা, স্পষ্ট নাম থাকে। ভুল ছিল একটি চিহ্নকে দুই অর্থে অনুমান করতে দেওয়া।

মূল উপলব্ধি: position-এ শেষ সীমা বাদ আর label-এ শেষ সীমা ধরা, দুটিই নিজের জগতে যুক্তিসংগত; বিপদ আসে যখন কোনো কোড বা মানুষ অনুমান করে কোনটি চলছে, তাই সবসময় loc বা iloc লিখে স্পষ্ট করুন।

প্র ০২SettingWithCopyWarning থেকে Copy-on-Write — pandas কেন বহু বছরের পুরোনো আচরণ ভেঙে নতুন নিয়ম আনল, আর তার দাম কী?

pandas-এর শুরু ২০০৮ সালে, Wes McKinney-র হাতে, আর্থিক ডেটা বিশ্লেষণের জন্য; ২০০৯ সালে এটি open source হয়। ভেতরে এটি NumPy-র ওপর দাঁড়ানো, আর NumPy-র basic slice একটি view (পর্ব ১৪)। ফলে pandas-এর কিছু বাছাই view দিত, কিছু copy, আর কোনটি কী হবে তা নির্ভর করত ভেতরের “block” বিন্যাসের মতো খুঁটিনাটির ওপর: একই dtype-এর কলাম একসঙ্গে থাকলে এক রকম, ভিন্ন dtype মিশলে আরেক রকম। ব্যবহারকারীর পক্ষে এটি আগে থেকে জানা প্রায় অসম্ভব ছিল।

এর সমাধান হিসেবে এসেছিল SettingWithCopyWarning: যখন pandas সন্দেহ করত আপনি একটি copy-তে লিখছেন, সে সতর্ক করত। কিন্তু এই সতর্কবার্তা ছিল অনুমানভিত্তিক। কখনো ঠিক কোডেও আসত (যেমন একটি ছাঁকা টেবিলে, যা আপনি ইচ্ছা করেই আলাদা রাখতে চেয়েছেন), কখনো ভুল কোডেও আসত না। ফলে অনেকে এটি বন্ধ করেই রাখতেন।

Copy-on-Write নিয়মটিকে অনুমান থেকে সংজ্ঞায় আনে: যেকোনো বাছাইয়ের ফল আচরণে আলাদা object, আর memory কপি হয় শুধু লেখার মুহূর্তে। এতে দুটি লাভ। প্রথমত আচরণ আগে থেকে জানা যায়, ভেতরের বিন্যাস যাই হোক। দ্বিতীয়ত গতি: আগে নিরাপত্তার জন্য অনেক method নিজে থেকেই পুরো ডেটা কপি করত (যেমন reset_index, rename); এখন তারা memory ভাগ করে রাখে, আর কপি হয় শুধু দরকার হলে। pandas-এর উন্নয়ন দল এটি PDEP-7 নামে প্রস্তাব করে, pandas 1.5-এ ঐচ্ছিক, 2.x-এ পরিপক্ব, আর 3.0-তে (২০২৬ সালের শুরুর দিকে প্রকাশিত) একমাত্র আচরণ করে।

দামও আছে। যে পুরোনো কোড জেনেশুনে chained assignment বা view-এর মাধ্যমে লিখে মূল টেবিল বদলাত, তা pandas 3-এ চুপচাপ কাজ বন্ধ করে দেয়, শুধু একটি সতর্কবার্তা থাকে। এজন্য pandas 2.2-এ একটি “warn” মোড রাখা হয়েছিল, যাতে মানুষ আগে থেকে নিজের কোড যাচাই করতে পারেন।

মূল উপলব্ধি: একটি “সতর্ক করা” সমাধান সমস্যাটিকে ব্যবহারকারীর ঘাড়ে রাখে; Copy-on-Write নিয়মটিকেই সহজ করে দিয়েছে, যার দাম পুরোনো কোডের এককালীন সংশোধন।

প্র ০৩query() পড়তে সুন্দর, তবু অনেক দল production কোডে এটি নিরুৎসাহিত করে। পক্ষে-বিপক্ষের যুক্তিগুলো কী, আর আপনি কোথায় সীমা টানবেন?

query()-এর পক্ষে প্রধান যুক্তি পড়ার সুবিধা। df.query("payment == 'bKash' and district == 'ঢাকা' and price_paisa > @limit") প্রায় বাক্যের মতো পড়া যায়, df[...] তিনবার লিখতে হয় না, আর &-এর অগ্রাধিকারের বন্ধনীর ফাঁদ নেই। method chain-এ (একটির পর আরেকটি কাজ) এটি সুন্দরভাবে বসে, কারণ মাঝের টেবিলের নাম না দিয়েই তার কলাম ধরা যায়। notebook-এ ডেটা ঘুরে দেখার সময় এই সুবিধা বড়।

বিপক্ষের প্রথম যুক্তি: string-এর ভেতরের কোড সাধারণ কোড নয়। editor কলামের নাম ভুল বানানে ধরিয়ে দেয় না, linter বা type checker ভেতরে দেখে না, আর কলামের নাম বদলালে (rename) সব জায়গায় নাম বদলানোর টুল string-এর ভেতরের নাম ছোঁয় না। ভুল ধরা পড়ে শুধু চালানোর সময়, অনেক সময় দেরিতে। দ্বিতীয়ত, ভাষাটি Python-এর একটি অংশমাত্র; জটিল শর্তে কোনটি চলে আর কোনটি নয়, তা মনে রাখা আরেকটি বোঝা। @ ভুলে গেলে pandas variable না খুঁজে কলাম খোঁজে, আর একই নামের কলাম থাকলে নিঃশব্দে ভুল ফল দেয়।

তৃতীয় এবং সবচেয়ে গুরুতর যুক্তি নিরাপত্তা। query আর eval string-টিকে expression হিসেবে মূল্যায়ন করে। একটি ওয়েব অ্যাপে ব্যবহারকারীর লেখা ফিল্টার সরাসরি query-তে দিলে এটি SQL injection-এর মতো ঝুঁকি তৈরি করতে পারে: pandas কিছু সুরক্ষা রাখে, কিন্তু বাইরের লেখাকে কোড হিসেবে চালানোর নীতিই ঝুঁকিপূর্ণ। নিরাপদ পথ হলো ব্যবহারকারীর দেওয়া মান আলাদা variable-এ রেখে নির্দিষ্ট কলামে mask দিয়ে তুলনা, ঠিক যেমন SQL-এ parameterized query (DBMS ও SQL কোর্সে এর বিস্তারিত)।

একটি যুক্তিসংগত সীমা: notebook-এ অনুসন্ধানে query স্বাধীনভাবে; পুনর্ব্যবহারযোগ্য module আর pipeline-এ নাম দেওয়া mask, যা test আর linter দেখতে পায়; আর বাইরের ইনপুটে কখনো query নয়।

মূল উপলব্ধি: query মানুষের পড়ার জন্য অনুকূল, টুল আর নিরাপত্তার জন্য প্রতিকূল; কোথায় কোড চলবে আর কে তা রক্ষণাবেক্ষণ করবে, তা দেখে বেছে নিন।

প্র ০৪বাংলা লেখায় খোঁজা কেন ইংরেজির চেয়ে কঠিন? str.contains, regex-এর \b আর Unicode-এর দিক থেকে ব্যাখ্যা করুন, আর pandas 3-এর str dtype এতে কী বদলেছে।

কম্পিউটারের কাছে লেখা হলো Unicode code point-এর সারি। ইংরেজিতে একটি অক্ষর প্রায় সবসময় একটি code point, আর শব্দের সীমা ফাঁকা জায়গা বা যতিচিহ্ন। বাংলায় একটি দেখা অক্ষর (grapheme) কয়েকটি code point হতে পারে: “ক্ষ” হলো ক + হসন্ত + ষ, “শা” হলো শ + আ-কার। ফলে substring খোঁজা (contains) অক্ষরের টুকরো ধরে ফেলে: “চা” আছে “চার্জার”, “চাল”, এমনকি “চাকরি”-র ভেতরেও। ইংরেজিতেও “cat” আছে “category”-র ভেতরে, কিন্তু বাংলায় ছোট শব্দ আর যুক্ত রূপের কারণে এই সংঘর্ষ অনেক বেশি।

regex-এর \b আরও সূক্ষ্ম। Python-এর re শব্দের অক্ষর (\w) ঠিক করে Unicode-এর “letter বা number” শ্রেণি দিয়ে। কিন্তু আ-কার, ই-কার, হসন্তের মতো কার-চিহ্ন Unicode-এ “combining mark” শ্রেণিতে, letter নয়। তাই re-এর চোখে “চা” শব্দটি শেষ হয় “চ”-এর পরেই, আর “চা”-র আ-কার আর পরের “র”-এর মাঝে সে একটি সীমা দেখে। আমাদের run-এ \b ঠিক উল্টো ফল দিয়েছিল।

তৃতীয় সমস্যা normalization. “ড়” লেখা যায় একটি code point (U+09DC) বা “ড” + নুকতা (U+09A1 + U+09BC) দিয়ে; বিজয় থেকে রূপান্তরিত লেখা, ভিন্ন কিবোর্ড বা ফোনে এমন মিশ্রণ প্রায়ই আসে। চোখে এক, কম্পিউটারের কাছে ভিন্ন, তাই == আর isin চুপচাপ মেলে না। str.normalize("NFC") সব লেখাকে একটি নির্দিষ্ট রূপে আনে, আর বাছাইয়ের আগে এটি করা নিরাপদ অভ্যাস (পর্ব ১০)।

pandas 3-এর str dtype এই ভাষাগত সমস্যাগুলো সমাধান করে না, কিন্তু কাঠামো পরিষ্কার করে। আগে লেখার কলাম ছিল object, যেখানে একই কলামে লেখা, সংখ্যা, None সব মিশতে পারত, আর str.contains ফাঁকা ঘরে NaN দিয়ে mask নষ্ট করত। PDEP-14-এর প্রস্তাবে pandas 3.0 লেখার জন্য একটি নিবেদিত dtype default করেছে: কলামে শুধু লেখা আর ফাঁকা মান, আর ফাঁকা ঘরে contains False দেয়।

মূল উপলব্ধি: বাংলায় শব্দ মেলানো মানে শুধু অক্ষর মেলানো নয়; normalization, শব্দ ধরে তুলনা আর নিজের ডেটায় পরীক্ষা ছাড়া ইংরেজির জন্য বানানো অভ্যাস নিঃশব্দে ভুল সংখ্যা দেয়।

প্র ০৫“সেরা ৫টি অর্ডার” বা “এলোমেলো ১০০টি নমুনা” — সামান্য বাছাইয়ের কাজ কীভাবে AI-এর ফলাফলের পুনরুৎপাদনযোগ্যতা আর সততা ভাঙতে পারে?

প্রথম সমস্যা সমান মান। বাজারঘরের ডেটায় দুটি অর্ডারের মোট দাম হুবহু এক, আর সেটি পঞ্চম স্থানে। nlargest(5) সবখানে আগের সারিটি নিয়েছে (1002). কিন্তু default sort_values().head(5) ব্রাউজারে 1002 আর ডেস্কটপে 1006 দিয়েছে, একই pandas 3.0.2-এ। কারণ default sort-এর পদ্ধতি (quicksort) stable নয়: সমান মানের সারিগুলোর ক্রম রক্ষার প্রতিশ্রুতি দেয় না, আর ভেতরের বাস্তবায়ন যন্ত্রভেদে ভিন্ন হতে পারে। এক সহকর্মীর রিপোর্টে চট্টগ্রামের অর্ডার, আরেকজনের রিপোর্টে সিলেটের, অথচ ডেটা আর কোড একই। সমাধান সহজ, কিন্তু সচেতনতা লাগে: kind="stable", বা দ্বিতীয় একটি key (যেমন order_id) দিয়ে সাজানো, বা keep-এর নিয়ম স্পষ্ট লেখা।

দ্বিতীয় সমস্যা random নমুনা। sample(100) প্রতিবার ভিন্ন সারি দেয়। কোনো বিশ্লেষণে “নমুনায় গড় ডেলিভারি ২.৪ দিন” লিখলে আর কেউ সেই সংখ্যা পাবেন না। random_state দিলে একই pandas version-এ একই ফল আসে। তবু যে সিদ্ধান্ত শুধু একটি seed-এ দাঁড়ায়, সেটি দুর্বল; কয়েকটি seed-এ চালিয়ে দেখুন।

তৃতীয় সমস্যা প্রক্রিয়ার ক্রম, যা সবচেয়ে বিপজ্জনক। ধরুন আপনি “যে গ্রাহকেরা সবচেয়ে বেশি কেনেন” তাদের পুরো ডেটা থেকে nlargest দিয়ে বেছে নিলেন, তারপর তাদের ওপর একটি model বানিয়ে test করলেন। test-এর সারিগুলোও সেই বাছাইয়ের অংশ ছিল, যা ভবিষ্যতের তথ্য (কে বেশি কিনবেন) ব্যবহার করে করা হয়েছে। এটি data leakage: model-এর মাপা ক্ষমতা বাস্তবের চেয়ে ভালো দেখায়। নিয়ম হলো, যে filter বা ranking লক্ষ্যের (target) তথ্য ব্যবহার করে, তা train-test ভাগের পরে, শুধু train অংশে।

চতুর্থত, filter নিজেই নির্বাচন-পক্ষপাত তৈরি করতে পারে: “ডেলিভারি হয়ে গেছে এমন অর্ডার” বেছে গড় ডেলিভারি সময় মাপলে যে অর্ডারগুলো এখনো পৌঁছায়নি (সম্ভবত সবচেয়ে দেরির), সেগুলো বাদ পড়ে, আর গড় আসলের চেয়ে ভালো দেখায়। আমাদের দুটি NaN অর্ডার ঠিক এমন।

মূল উপলব্ধি: বাছাই কখনো নিরপেক্ষ নয়; সমান মানের নিয়ম, seed, কাজের ক্রম আর কোন সারি বাদ পড়ল, এই চারটি লিখে না রাখলে ফল পুনরুৎপাদন করা যায় না, আর অনেক সময় সৎও থাকে না।

অনুশীলন

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

  1. হিসাব — shape আর মান: t = pd.DataFrame(np.arange(20).reshape(5, 4), index=list("abcde"), columns=["w", "x", "y", "z"]), মানে সারি a থেকে e, প্রতিটিতে পরপর চারটি সংখ্যা (a: 0 1 2 3, b: 4 5 6 7, …, e: 16 17 18 19). (ক) t.loc["b":"d", "x":"z"], t.iloc[1:3, 1:3], t.loc["b":"d"] আর t.iloc[-2:, :2]-এর shape লিখুন। (খ) t.loc[(t["w"] > 4) & (t["z"] < 19), "x"]-এর মান কী? (গ) t.at["c", "y"] আর t.iat[2, 2] কি একই ঘর?

    (ক) loc["b":"d", "x":"z"]: সারি b, c, d (৩টি, d-সহ) আর কলাম x, y, z (৩টি, z-সহ), তাই (3, 3). iloc[1:3, 1:3]: position 1, 2 দুই দিকেই, (2, 2). loc["b":"d"]: ৩ সারি, সব ৪ কলাম, (3, 4). iloc[-2:, :2]: শেষ দুই সারি (d, e) আর প্রথম দুই কলাম (w, x), (2, 2).

    (খ) w > 4: সারি c (8), d (12), e (16); b-এর w ঠিক 4, তাই বাদ। z < 19: a থেকে d (e-এর z ঠিক 19, বাদ)। দুটি একসঙ্গে: c আর d. তাদের x: 9 আর 13. ফল [9, 13].

    (গ) হ্যাঁ। label "c" হলো position 2, আর "y" হলো কলাম position 2, তাই দুটিই 10. আমাদের ডেস্কটপ run-এ (pandas 3.0.2) shape চারটি (3, 3) (2, 2) (3, 4) (2, 2), mask-এর ফল [9, 13] আর দুটি ঘর 10 10 এসেছে।

  2. আউটপুট অনুমান করুন: চালানোর আগে চারটি print-এর আউটপুট লিখুন (সতর্কবার্তা বাদে)। তারপর যেকোনো pyodide কোডের জায়গায় বসিয়ে চালিয়ে মিলিয়ে দেখুন।
    df = pd.DataFrame({"qty": [2, 5, 1, 4]}, index=[10, 20, 30, 40])
    print(df.loc[20:30, "qty"].tolist(), df.iloc[1:3, 0].tolist())
    print(df.loc[10:30].shape[0], df.iloc[10:30].shape[0])
    df[df["qty"] > 2]["qty"] = 0
    lim = 3
    print(df.query("qty > @lim").index.tolist())
    df.loc[df["qty"] > 2, "qty"] = 0
    print(df["qty"].tolist())
    [5, 1] [5, 1]
    3 0
    [20, 40]
    [2, 0, 1, 0]

    প্রথম লাইনে দুটি ফল একই, কিন্তু ভিন্ন কারণে: loc[20:30] label 20 আর 30 (দুই প্রান্তসহ), iloc[1:3] position 1 আর 2, যারা ঘটনাক্রমে সেই একই দুটি সারি। দ্বিতীয় লাইনে ফাঁদ: loc[10:30] label 10, 20, 30, তিনটি সারি; কিন্তু iloc[10:30] position 10 থেকে, অথচ টেবিলে মাত্র ৪টি সারি, তাই ফাঁকা, 0. slice সীমার বাইরে গেলে error দেয় না। এরপর chained assignment কিছুই বদলায় না (শুধু একটি ChainedAssignmentError সতর্কবার্তা), তাই query এখনো qty 5 আর 4-এর সারি পায়: label 20 আর 40. শেষে loc সত্যিই বদলায়: [2, 0, 1, 0]. আমাদের ডেস্কটপ run-এ (pandas 3.0.2) ঠিক এই আউটপুট এসেছে।

  3. নকশা ও সমালোচনা: একটি AI সহকারী নিচের function দিয়েছে: “ঢাকার চায়ের অর্ডারে ১০% ছাড় বসাবে আর সবচেয়ে দামি ৩টি অর্ডার দেবে”। অন্তত পাঁচটি সমস্যা চিহ্নিত করে একটি ভালো সংস্করণ লিখুন।
    def tea_discount(df):
        dhaka = df[df.district == "Dhaka" and df.product.str.contains("চা")]
        dhaka["price_paisa"] = dhaka["price_paisa"] * 0.9
        df[df.district == "ঢাকা"]["discounted"] = True
        top3 = df.sort_values("price_paisa").head(3)
        return df, top3
    • and: দুটি Series-এর মাঝে and দিলে ValueError; লাগবে & আর বন্ধনী।
    • ভুল বানান: ডেটায় জেলা বাংলায় (“ঢাকা”), তাই "Dhaka" চুপচাপ শূন্য সারি দেবে।
    • contains("চা"): “চার্জার” আর “মিনিকেট চাল”-ও ধরে; চায়ের জন্য শব্দ ধরে মিল লাগবে।
    • লেখা মূলে পৌঁছায় না: dhaka["price_paisa"] = ... একটি আলাদা DataFrame বদলায় (pandas 3-এ সবসময়), আর df[...]["discounted"] = True chained assignment, যা কখনো কাজ করে না।
    • paisa-তে float: * 0.9 পূর্ণসংখ্যার paisa-কে float বানায় (২৫০০০ × ০.৯ = ২২৫০০.০); টাকার হিসাবে স্পষ্ট round করে int-এ রাখা ভালো।
    • উল্টো ক্রম: sort_values("price_paisa").head(3) সবচেয়ে সস্তা ৩টি দেয়, আর মোট দাম (qty × দাম) নয়, শুধু একক দাম দেখে; সমান মানের নিয়মও নেই।
    • মূল টেবিল বদলানো: function ডাকা জায়গার df চুপচাপ বদলালে side effect; শুরুতে df = df.copy() রাখা স্পষ্ট।
    def tea_discount(df):
        df = df.copy()                                    # ডাকা জায়গার টেবিল অক্ষত
        is_tea = df["product"].str.split().str[0] == "চা"
        m = (df["district"] == "ঢাকা") & is_tea
        df["discounted"] = m
        df.loc[m, "price_paisa"] = (df.loc[m, "price_paisa"] * 0.9).round().astype("int64")
        df["total"] = df["qty"] * df["price_paisa"]
        top3 = df.nlargest(3, "total", keep="first")
        return df, top3, int(m.sum())

    বাজারঘরের ডেটায় ঢাকার চায়ের অর্ডার শুধু 1001 (চট্টগ্রামের 1010 বাদ, আর “গ্রিন টি” “চা” দিয়ে লেখা নয়), তাই m.sum() 1, আর তার দাম 25000 থেকে 22500 paisa. “গ্রিন টি”-ও কি চা? এটি কোডের নয়, ব্যবসার সিদ্ধান্ত; আসল কাজে পণ্যের নাম নয়, একটি পণ্য-কোড বা category কলাম দিয়ে মেলানো সবচেয়ে নির্ভরযোগ্য।

  4. কোড লিখুন — দেরির অর্ডারের তালিকা: বাজারঘরের orders.csv থেকে সেই অর্ডারগুলো বের করুন, যাদের পেমেন্ট bKash নয় এবং যাদের delivery_days ৩ বা বেশি অথবা এখনো অজানা (NaN). ফল সাজান delivery_days বড় থেকে ছোট, অজানাগুলো সবার আগে, আর সমান দিনে order_id ছোট থেকে বড়। একবার mask দিয়ে আর একবার query দিয়ে করুন, দুটির order_id মিলিয়ে দেখুন, আর এই অর্ডারগুলোর মোট দাম টাকায় ছাপুন।
    o = pd.read_csv("orders.csv")
    o["total"] = o["qty"] * o["price_paisa"]
    slow = (o["payment"] != "bKash") & ((o["delivery_days"] >= 3) | o["delivery_days"].isna())
    r = (o.loc[slow, ["order_id", "district", "payment", "delivery_days", "total"]]
           .sort_values(["delivery_days", "order_id"], ascending=[False, True],
                        na_position="first"))
    print(r)
    q = o.query("payment != 'bKash' and (delivery_days >= 3 or delivery_days.isna())")
    print(q["order_id"].tolist(), slow.sum())
    print("৳", r["total"].sum() / 100)

    আমাদের ডেস্কটপ run-এর আউটপুট (Python 3.14.4, pandas 3.0.2):

        order_id   district payment  delivery_days   total
    10      1011       ঢাকা     COD            NaN  320000
    3       1004      খুলনা     COD            4.0   96000
    11      1012      খুলনা    card            4.0   95000
    1       1002  চট্টগ্রাম     COD            3.0  180000
    [1002, 1004, 1011, 1012] 4
    ৳ 6910.0

    মূল জায়গাটি | o["delivery_days"].isna(): এটি না দিলে NaN-এর তুলনা False হয়ে 1011 নিঃশব্দে বাদ পড়ত, অথচ এখনো না-পৌঁছানো অর্ডারই সম্ভবত সবচেয়ে বেশি দেরির। বাইরের বন্ধনীটিও জরুরি: “bKash নয় এবং (দেরি অথবা অজানা)”। query-র ভেতরে delivery_days.isna()-র মতো method ডাকা যায়; তার ফল একই চারটি অর্ডার, তবে query সাজানো ক্রম রাখে না, টেবিলের মূল ক্রমে দেয়। মোট 320000 + 96000 + 95000 + 180000 = 691000 paisa, মানে ৬৯১০ টাকা।

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

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

ক্রমpandas টেবিলে “ঢাকার bKash অর্ডারে একটি কলাম বদলানো”-র নির্ভরযোগ্য ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. 📥 টেবিল পড়া, আর df.dtypes ও কয়েকটি সারি দেখে কলামের নাম, বানান আর dtype যাচাই
  2. 🧩 প্রতিটি শর্তের আলাদা নাম দেওয়া mask বানানো (is_bkash, is_dhaka)
  3. 🔢 প্রতিটি mask-এর .sum() ছেপে দেখা যে কোনোটি অপ্রত্যাশিতভাবে শূন্য নয়
  4. 🔗 & আর বন্ধনী দিয়ে mask জোড়া লাগানো
  5. ✏️ এক ধাপে df.loc[mask, "col"] = মান দিয়ে বসানো
  6. ✅ বসানোর পর df.loc[mask, "col"] পড়ে মান সত্যিই বদলেছে কি না যাচাই

একটি DataFrame-এর index 0 থেকে 9 (ডিফল্ট RangeIndex). df.loc[3:6] আর df.iloc[3:6]-এ কয়টি করে সারি আসবে?

  • দুটিতেই ৩টি
  • দুটিতেই ৪টি
  • loc-এ ৪টি, iloc-এ ৩টি
  • loc-এ ৩টি, iloc-এ ৪টি

pandas 3-এ df[df["qty"] > 2]["free_ship"] = 1 চালালে কী ঘটে?

  • df-এর সেই সারিগুলোতে free_ship 1 হয়
  • df বদলায় না, আর pandas একটি ChainedAssignmentError সতর্কবার্তা দেয়
  • df বদলায়, সঙ্গে SettingWithCopyWarning আসে
  • KeyError দিয়ে প্রোগ্রাম থেমে যায়

একটি টেবিলে payment আর district কলাম আছে। “bKash দিয়ে কেনা, ঢাকার বাইরের অর্ডার” পেতে কোনটি সঠিক?

  • df[(df.payment == "bKash") and (df.district != "ঢাকা")]
  • df[df.payment == "bKash" & df.district != "ঢাকা"]
  • df[(df.payment == "bKash") | (df.district != "ঢাকা")]
  • df[(df.payment == "bKash") & ~(df.district == "ঢাকা")]

limit = 50000 একটি Python variable. query-তে “price_paisa এর চেয়ে বেশি” লিখতে কোনটি ঠিক?

  • df.query("price_paisa > @limit")
  • df.query("price_paisa > limit")
  • df.query(df.price_paisa > limit)
  • df.query("price_paisa > $limit")

একটি কলামে পণ্যের নাম: ["চা পাতা", "চার্জার", "মিনিকেট চাল"]. s.str.contains("চা").sum()-এর মান কত?

  • 1
  • 3
  • 2
  • error হয়, কারণ বাংলায় contains চলে না

মোট দামে পঞ্চম স্থানে দুটি অর্ডারের মান সমান। df.nlargest(5, "total") সম্পর্কে কোনটি সঠিক?

  • সমান দুটিই রাখে, তাই ৬টি সারি দেয়
  • সমান মান থাকলে ValueError দেয়
  • ঠিক ৫টি দেয়, সমান দুটির মধ্যে টেবিলে আগেরটি; keep="all" দিলে দুটিই
  • সবসময় sort_values(ascending=False).head(5)-এর হুবহু সমান

শব্দকোষ

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

মনে রাখুন

  1. loc label দিয়ে, শেষটি ধরে; iloc position দিয়ে, শেষটি বাদ দেয়; at/iat একটি ঘরের জন্য। ডিফল্ট RangeIndex-এ দুটি মিলে যায় বলে ভুল লুকিয়ে থাকে।
  2. অসাজানো বা পুনরাবৃত্ত index-এ label slice বিপজ্জনক; আগে sort_index(), আর ফলের ধরন নিশ্চিত রাখতে loc[["label"]].
  3. Mask জোড়ায় &, |, ~ আর বন্ধনী; প্রতিটি mask-এর .sum() দেখুন; NaN-এর তুলনা False, তাই অজানা মান .isna() দিয়ে আলাদা ধরুন।
  4. বাংলা লেখায় contains অক্ষরের টুকরো ধরে, \b ভরসাযোগ্য নয়; শব্দ ধরে মেলান, NFC normalization করুন। query-তে variable @limit, আর বাইরের ইনপুটে কখনো query নয়।
  5. pandas 3-এ chained assignment কখনো কাজ করে না; বদলাতে শুধু df.loc[mask, "col"] = x. top-k-এ nlargest আর keep, সাজানোয় kind="stable", নমুনায় random_state.

পরের পর্বে: বাস্তবের ডেটা কখনো পরিষ্কার আসে না। ফাঁকা ঘর, একই অর্ডার দুবার, ভুল type আর এলোমেলো বাংলা বানান খুঁজে বের করা আর ঠিক করা, প্রতিটি পরিবর্তন গুনে রেখে।

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

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