অধ্যায় 05

সারি ফিল্টার করা — প্রতিটি সারিকে একই প্রশ্ন করা

যেসব সারি 'হ্যাঁ' বলে শুধু সেগুলো রাখা: বুলিয়ান মাস্ক, &, | এবং ~ দিয়ে শর্ত জোড়া লাগানো, isin, between ও .str মেথড, .loc দিয়ে ফিল্টার করা — এবং চেইন্ড অ্যাসাইনমেন্টের নীরব ফাঁদ এড়িয়ে সরাসরি সেই সারিগুলোর মান পরিবর্তন করা।

45 মিনিটPython 3.12
  1. 1সমস্যা
  2. 2বোঝা
  3. 3উদাহরণ
  4. 4অনুমান
  5. 5নিজে করা
  6. 6কঠিন করা

যে সমস্যাটা আমরা সমাধান করছি

দোকানের ম্যানেজার একটি মেসেজ পাঠালেন: "উত্তর শাখা (north branch) থেকে কোন কোন অর্ডারে তিনটির বেশি আইটেম কেনা হয়েছিল? আমি সেগুলো দেখতে চাই।"

গত অধ্যায়ের আটটি অর্ডারের ক্ষেত্রে আপনি চোখ বুলিয়েই উত্তর দিয়ে দিতে পারতেন। আঙুল দিয়ে branch কলাম বরাবর নিচে নামুন, প্রতিটি north এলে থামুন, আর পাশের quantity কলামে একবার চোখ রাখুন। তিনটি সারি পাওয়া গেল। কাজ শেষ।

এখন কল্পনা করুন আসল ফাইলের কথা — যেখানে বারোটি নয়, সারা বছরের বারো হাজার অর্ডার রয়েছে। তখন আর খালি চোখে দেখে কাজ হবে না। আর বেশিরভাগ পাইথন প্রোগ্রামারের মাথায় প্রথম যে বুদ্ধিটি আসে, তা হলো একটি লুপ (loop) চালানো:

python
import pandas as pd

orders = pd.read_csv("orders.csv")

keep = []
for i in range(len(orders)):
    row = orders.iloc[i]
    if row["branch"] == "north" and row["quantity"] > 3:
        keep.append(int(row["order_id"]))

print(keep)
text
[1001, 1005, 1008]

উত্তরটি সঠিক। কিন্তু দেখুন এর জন্য কী কী করতে হলো: একটি কাউন্টার ভেরিয়েবল, একটি খালি লিস্ট, একটি if, একটি append, এবং পুরো টেবিলটাকে এক এক সারি করে ভেঙে দেখা। আর এর ফলাফলটাও কিন্তু আর টেবিল রইল না — হয়ে গেল শুধু অর্ডার নম্বরের একটা সাধারণ লিস্ট। ম্যানেজারকে পণ্যের নাম আর দামও দেখাতে চাইলে আপনাকে আবার লুপ চালাতে হবে। বারো হাজার সারির ডেটাতে এটি অত্যন্ত ধীরগতিরও হবে, কারণ প্রতিটি orders.iloc[i] মাত্র দুটি মান পড়ার জন্য প্রতিবার একটি করে নতুন Series তৈরি করে।

পান্ডাসে এই প্রশ্নটা করার একেবারেই আলাদা একটি উপায় রয়েছে, আর কোনো DataFrame নিয়ে কাজ করার সময় আপনি সবচেয়ে বেশি এই কাজটিই করবেন: একসাথে প্রতিটি সারিকে একই 'হ্যাঁ' বা 'না' প্রশ্ন করা, আর যে সারিগুলো 'হ্যাঁ' বলেছে কেবল সেগুলোকে রেখে দেওয়া। এটাই হলো ফিল্টারিং (filtering), আর এই পুরো অধ্যায়টি ঠিক এই নিয়েই। ওপরের পুরো লুপটি নেমে আসে মাত্র এক লাইনে, ফলাফল সবসময় টেবিল আকারেই বজায় থাকে, এবং কোডটি পড়তে ঠিক ম্যানেজারের বলা বাক্যের মতোই শোনায়।

এই অধ্যায় শেষে আপনি পারবেন

  • একটি প্রশ্নকে বুলিয়ান মাস্ক (boolean mask)-এ রূপান্তর করতে — অর্থাৎ প্রতি সারির জন্য একটি করে True/False মানের কলাম তৈরি করতে — এবং সারিগুলো ফিল্টার করতে কিংবা mask.sum() ও mask.mean() দিয়ে সেগুলো গণনা করতে
  • একাধিক শর্তকে &, | এবং ~ দিয়ে যুক্ত করতে, এবং কেন প্রতিটি শর্তে বন্ধনী দেওয়া বাধ্যতামূলক তা ব্যাখ্যা করতে
  • তালিকা, সীমা এবং লেখার প্যাটার্নের জন্য isin, between ও .str মেথডগুলো ব্যবহার করতে
  • .loc[mask, columns] দিয়ে এক ধাপেই নির্দিষ্ট সারি ফিল্টার করতে ও নির্দিষ্ট কলাম বেছে নিতে
  • .loc[mask, column] = value ব্যবহার করে ফিল্টার করা সারির মান নিরাপদে পরিবর্তন করতে, এবং অনিরাপদ উপায়ে কোড লিখলে পান্ডাস ৩-এ কেন ChainedAssignmentError দেখা দেয় তা বুঝতে

আগে যা জানা লাগবে: কলাম আর সারি বেছে নেওয়া।


ফাইলটি আগে তৈরি করে নিন

এই অধ্যায়েও গত অধ্যায়ের orders.csv ফাইলটি ব্যবহার করা হয়েছে। ফাইলটি আপনার ফোল্ডারে না থাকলে ঠিক এই নয়টি লাইন দিয়ে আপনার প্রজেক্ট ফোল্ডারে এটি তৈরি করে নিন:

text
order_id,date,branch,product,category,quantity,price
1001,2024-03-01,north,pen,stationery,12,15.0
1002,2024-03-01,south,notebook,stationery,5,60.0
1003,2024-03-02,north,bag,accessories,2,850.0
1004,2024-03-02,east,bottle,accessories,7,120.0
1005,2024-03-03,north,eraser,stationery,30,8.0
1006,2024-03-03,south,bag,accessories,1,850.0
1007,2024-03-04,east,pen,stationery,20,15.0
1008,2024-03-04,north,bottle,accessories,4,120.0

কোড লেখার আগে যে প্রস্তুতি নেবেন

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

১. প্রশ্নটিকে প্রতিটি কলামের আলাদা আলাদা শর্ত হিসেবে সাজিয়ে নিন। ম্যানেজারের বাক্যটিকে ভেঙে ফেলুন:

  • "উত্তর শাখা (north branch) থেকে অর্ডার" → কলাম branch, তুলনা সমান কি না, মান "north"
  • "তিনটির বেশি আইটেম" → কলাম quantity, তুলনা বৃহত্তর কি না (greater than), মান 3
  • "আমি সেগুলো দেখতে চাই" → কোনো পরীক্ষা নয়; পুরো সারিটাই দেখতে চান

এই তালিকাটি আপনাকে দুটি বিষয়ে স্পষ্ট সিদ্ধান্ত নিতে বাধ্য করে। "তিনটির বেশি" মানে > 3, >= 3 নয় — অর্থাৎ ঠিক তিনটি আইটেমের অর্ডার কিন্তু গণনা করা যাবে না। আর এই দুটি শর্ত যুক্ত হয়েছে and (এবং) দিয়ে: একটি অর্ডারকে এই দুটি পরীক্ষাতেই পাস করতে হবে। এই দুটি সিদ্ধান্তই হলো পুরো ফিল্টারের মূল কাঠামো; বাকিটা শুধুই কোডের বানান।

২. ইনপুট যাচাই করুন — আকার (shape), ডেটার ধরন (types), এবং যেসব মানের সাথে তুলনা করবেন সেগুলোর আসল চেহারা।

python
import pandas as pd

orders = pd.read_csv("orders.csv")

print(orders.shape)
print(orders.dtypes)
text
(8, 7)
order_id      int64
date            str
branch          str
product         str
category        str
quantity      int64
price       float64
dtype: object

quantity কলামটির ধরন int64, তাই > 3 সংখ্যা হিসেবে তুলনা করবে। এটি যদি ভুলবশত str হিসেবে আসত — ধরা যাক কোনো সেলে কেউ "3 pcs" লিখে রেখেছিল — তবে তুলনা করার সময় TypeError দিয়ে কোড আটকে যেত; আর সেই তথ্যটি কাজের মাঝপথে নয়, শুরুতেই জানা দরকার। branch কলামটি str, তাই এটি টেক্সট হিসেবে তুলনা করবে, আর টেক্সটের তুলনা হুবহু হতে হয়: "north", "North" এবং "north " তিনটি সম্পূর্ণ ভিন্ন মান। তাই কলামে আসলেই কী কী মান আছে তা দেখে নিন:

python
print(orders["branch"].unique())
text
<StringArray>
['north', 'south', 'east']
Length: 3, dtype: str

তিনটি শাখা, প্রতিটির বানান একক নিয়মে লেখা, কোনো বাড়তি স্পেস নেই। এখন আপনি নিশ্চিত যে কোডে "north" লিখলে তা ঠিকঠাক মিলবে।

৩. আউটপুট তৈরি করার আগেই তার একটি খসড়া অনুমান করে নিন। ছোট ফাইলে আগে হাতে-কলমে প্রশ্নটির উত্তর বের করুন। উত্তর শাখার অর্ডারগুলো হলো 1001 (১২টি আইটেম), 1003 (২টি), 1005 (৩০টি) এবং 1008 (৪টি)। তিনটির বেশি আইটেম রয়েছে: 1001, 1005, 1008-এ। সুতরাং প্রত্যাশিত ফলাফল হলো ৩টি সারি, সবকটি ৭টি কলাম, এবং কোন কোন অর্ডার নম্বর আসবে তা-ও জানা হয়ে গেল। কোড যদি পরে ৪টি সারি দেয়, বা ০টি সারি দেখায়, আপনি অন্ধের মতো বিশ্বাস না করে সাথে সাথেই বুঝবেন যে কোডে ভুল হয়েছে। বড় ফাইলে হয়তো সব সারি এভাবে হাতে গোনা সম্ভব নয়, তবে আপনি একটি যৌক্তিক অনুমান করতে পারেন: "উত্তর শাখায় প্রায় অর্ধেক অর্ডার আসে, তাই উত্তরটি অবশ্যই মোট সারির অর্ধেকের চেয়ে অনেক কম হবে।"

৪. শর্তের ধরন অনুযায়ী সঠিক টুলটি বেছে নিন।

  • একটি একক তুলনা → একটি মাস্ক: df["col"] > value
  • একাধিক শর্ত → & (and), | (or), ~ (not) দিয়ে যুক্ত করা মাস্ক
  • "এই মানগুলোর যেকোনো একটি" → df["col"].isin([...])
  • "দুটি মানের মধ্যবর্তী" → df["col"].between(low, high)
  • লেখার ভেতরে কোনো প্যাটার্ন খোঁজা → df["col"].str.contains(...), .str.startswith(...)
  • নির্দিষ্ট সারি ফিল্টার এবং শুধু কয়েকটি কলাম নেওয়া → df.loc[mask, ["col1", "col2"]]
  • ফিল্টার করা সারিগুলোর মান পরিবর্তন করা → df.loc[mask, "col"] = new_value

ম্যানেজারের প্রশ্নে দুটি তুলনা and দিয়ে যুক্ত হয়েছে, তাই আমাদের লাগবে: দুটি মাস্ক এবং &।

৫. উত্তরটি কীভাবে যাচাই করবেন তা ঠিক করুন। ফিল্টার করার পর তিনটি সহজ পরীক্ষা প্রায় সব ভুল ধরে ফেলে: সারির সংখ্যা আপনার অনুমানের সাথে মিলল কি না; টিকে থাকা প্রতিটি সারিতে শর্তটি সত্যি সত্যি বহাল আছে কি না ((result["quantity"] > 3).all() অবশ্যই True দেবে); এবং আপনি যে সারির উপস্থিতি আশা করেছিলেন তা বাদ পড়েনি তো (ফলাফলে 1008 নম্বরটি আছে কি না দেখুন)।

এবার চলুন কোডে যাই — এক এক করে প্রতিটি ধারণা বোঝা যাক।


তুলনা করলে একটি মাস্ক পাওয়া যায়

একটি কলামের সাথে কোনো মানের তুলনা করলে পান্ডাস কেবল একটি একক True বা False দেয় না। এটি প্রতিটি সারি আলাদা করে তুলনা করে এবং উত্তরের একটি পুরো কলাম ফেরত দেয়:

python
big = orders["quantity"] > 5

print(big)
text
0     True
1    False
2    False
3     True
4     True
5    False
6     True
7    False
Name: quantity, dtype: bool

এটি হলো bool ধরনের একটি Series — যাকে বলা হয় বুলিয়ান মাস্ক (boolean mask)। এর ইনডেক্স ঠিক orders-এর মতোই (0 থেকে 7), এবং প্রতি সারির জন্য একটি করে উত্তর রয়েছে: সারি 0-এ ১২টি আইটেম আছে, তাই True; সারি 1-এ ৫টি আছে, আর ৫ তো ৫-এর চেয়ে বড় নয়, তাই False।

পান্ডাস কেন লুপ চালানোর বদলে এভাবে কাজ করে? কারণ একটি কলাম মেমরিতে একটি একক ধরনের অবিচ্ছিন্ন সংখ্যার ব্লক হিসেবে জমা থাকে, আর পুরো ব্লকের সবকটি সংখ্যার সাথে 5-এর তুলনা করা একটি একক অপারেশন যা কম্পাইল করা অপ্টিমাইজড সি/সি++ কোডে চোখের পলকে সম্পন্ন হয়। আপনার লুপটি পাইথনকে প্রতিটি সারি আলাদা আলাদাভাবে ৮ বার দেখতে বাধ্য করেছিল — বা আসল ডেটায় ১২,০০০ বার। আর মাস্ক প্রশ্নটি করে মাত্র একবার।

সবগুলো তুলনামূলক অপারেটর ঠিক একইভাবে কাজ করে: ==, !=, <, <=, >, >=। এগুলো লেখার কলামেও একইভাবে চলে — orders["branch"] == "north" একটি মাস্ক তৈরি করে যার 0, 2, 4 এবং 7 নম্বর সারিতে থাকবে True।

এখনও কিন্তু কোনো ডেটা ফিল্টার করা হয়নি। মাস্কটি হলো কেবল উত্তরের তালিকা। পরবর্তী ধাপ হলো এই উত্তরগুলোকে কাজে লাগানো।

মাস্ক সারিগুলোকে আলাদা করে

মাস্কটিকে একটি স্কয়ার ব্র্যাকেটের (তৃতীয় বন্ধনী) ভেতরে রাখুন, আর পান্ডাস ঠিক সেই সারিগুলো রেখে দেবে যেখানে মাস্কের মান True:

python
print(orders[big])
text
order_id        date branch product     category  quantity  price
0      1001  2024-03-01  north     pen   stationery        12   15.0
3      1004  2024-03-02   east  bottle  accessories         7  120.0
4      1005  2024-03-03  north  eraser   stationery        30    8.0
6      1007  2024-03-04   east     pen   stationery        20   15.0

আটটি সারির মধ্যে চারটি সারি টিকে রইল। বাঁ পাশের ইনডেক্সগুলো খেয়াল করুন: 0, 3, 4, 6। টিকে থাকা সারিগুলো তাদের মূল লেবেল ধরে রাখে। ফিল্টারিং কিন্তু কোনো কিছুর নতুন নম্বর দেয় না — সারি 3 এখনও সারি 3-ই রয়ে গেছে, মূল টেবিলে এটি যে অর্ডার 1004 ছিল এখানেও ঠিক তাই। এই বিষয়টি অত্যন্ত গুরুত্বপূর্ণ, এবং আমরা একটু পরেই এতে ফিরে আসব।

পান্ডাস কীভাবে মাস্কটিকে টেবিলের সারির সাথে মেলায়? অবস্থানের ভিত্তিতে নয় — বরং ইনডেক্স লেবেলের ভিত্তিতে। মাস্কের লেবেল 0 সিদ্ধান্ত নেয় টেবিলের সারি 0-এর ভাগ্য, লেবেল 3 সিদ্ধান্ত নেয় সারি 3-এর ভাগ্য। এখানে দুটি ইনডেক্স হুবহু এক, কারণ মাস্কটি তৈরিই করা হয়েছিল সরাসরি orders থেকে, তাই বিষয়টি চোখে পড়ে না। এটি তখনই দৃশ্যমান হয় যখন আপনি অন্য কোনো টেবিল থেকে তৈরি করা মাস্ক ব্যবহার করতে যান — যা আলোচনা করা হয়েছে "কিছু ভাঙা অবস্থা ও তার সমাধান" অংশে।

সাধারণত কোডে আলাদা নাম না দিয়ে মাস্কটি সরাসরি ব্র্যাকেটের ভেতর এক লাইনে লেখা দেখতে পাবেন:

python
print(orders[orders["branch"] == "north"])
text
order_id        date branch product     category  quantity  price
0      1001  2024-03-01  north     pen   stationery        12   15.0
2      1003  2024-03-02  north     bag  accessories         2  850.0
4      1005  2024-03-03  north  eraser   stationery        30    8.0
7      1008  2024-03-04  north  bottle  accessories         4  120.0

ভেতর থেকে বাইরের দিকে পড়ুন: `orders["branch"] == "north"` অংশটি মাস্ক তৈরি করে, আর বাইরের `orders[ … ]` অংশটি True হওয়া সারিগুলোকে রেখে দেয়। শর্তটি বড় হলে বা একাধিকবার ব্যবহারের প্রয়োজন হলে মাস্কের একটি নাম দেওয়া (big = …) সুবিধাজনক; আর ছোট শর্তের জন্য সরাসরি এক লাইনে লেখাই যথেষ্ট।

ফিল্টার না করেই মাস্ক দিয়ে গণনা করা

অনেক সময় ম্যানেজারের পুরো সারিগুলোর দরকার হয় না, কেবল একটি সংখ্যার প্রয়োজন হয়: পাঁচটির বেশি আইটেম কেনা হয়েছে এমন অর্ডার কয়টি? এর জন্য ফিল্টার করে নতুন টেবিল বানানোর কোনো দরকার নেই। গণিতের হিসাবে True মানে 1 আর False মানে 0, সুতরাং:

python
print(big.sum())
print(big.mean())
text
4
0.5

sum() সবগুলো ১ যোগ করে: ৪টি অর্ডার। mean() মোট সারির সংখ্যা দিয়ে ভাগ করে গড় বের করে: ৮টির মধ্যে ৪টি হলো 0.5, অর্থাৎ মোট অর্ডারের অর্ধেক অর্ডারে পাঁচটির বেশি আইটেম ছিল। "কয়টি অর্ডার" জানতে চাইলে মাস্কের .sum() নিন; "শতকরা কত অংশ বা কোন ভগ্নাংশ" জানতে চাইলে মাস্কের .mean() নিন। দুটিই এক লাইনের কাজ এবং কোনোটিই নতুন কোনো টেবিল তৈরি করে মেমরি নষ্ট করে না।

আর যখন আপনি ফিল্টার করা সারিগুলোর কোনো কলামের মোট যোগফল বের করতে চান — যেমন সেই বড় অর্ডারগুলোতে মোট কতটি আইটেম বিক্রি হয়েছিল — তখন আগে ফিল্টার করুন, তারপর কলামটি বেছে নিন, এবং সবশেষে যোগ করুন:

python
print(orders[big]["quantity"].sum())
text
69

১২ + ৭ + ৩০ + ২০ = ৬৯। ফিল্টার করা টেবিলের কোনো কলাম এভাবে পড়া (read) সম্পূর্ণ নিরাপদ। কিন্তু এই পদ্ধতিতে ফিল্টার করা টেবিলে কোনো মান লেখা (write) বিপজ্জনক — যা আমরা এই অধ্যায়ের শেষ অংশে শিখব।


একাধিক শর্ত: &, |, ~

ম্যানেজারের প্রশ্নে দুটি শর্ত ছিল। পান্ডাসে মাস্কগুলোকে জোড়া লাগানোর জন্য তিনটি অপারেটর রয়েছে:

  • & মানে and — দুটি মাস্কের মানই True হলে তবেই সারিটি রাখা হবে
  • | মানে or — অন্তত একটি মাস্কের মান True হলেই সারিটি রাখা হবে
  • ~ মানে not — এটি প্রতিটি True-কে False এবং False-কে True-তে উল্টে দেয়

অবশেষে ম্যানেজারের প্রশ্নটির পূর্ণাঙ্গ সমাধান দেখা যাক:

python
north_big = orders[(orders["branch"] == "north") & (orders["quantity"] > 3)]

print(north_big)
text
order_id        date branch product     category  quantity  price
0      1001  2024-03-01  north     pen   stationery        12   15.0
4      1005  2024-03-03  north  eraser   stationery        30    8.0
7      1008  2024-03-04  north  bottle  accessories         4  120.0

তিনটি সারি: 1001, 1005, 1008 — ঠিক যেমনটি আমরা পরিকল্পনার ধাপে অনুমান করেছিলাম। এবার প্রস্তুতি পর্বের ৫ নম্বর ধাপের পরীক্ষাগুলো চালিয়ে দেখে নেওয়া যাক:

python
print(len(north_big))
print((north_big["quantity"] > 3).all())
print((north_big["branch"] == "north").all())
text
3
True
True

সারির সংখ্যা মিলে গেছে, এবং টিকে থাকা প্রতিটি সারিতে দুটি শর্তই সত্য। .all() মেথডটি প্রশ্ন করে "এই মাস্কের প্রতিটি মানই কি True?" — কোনো ফিল্টার আপনার মনের মতো কাজ করেছে কি না তা নিশ্চিত করার এটি সবচেয়ে দ্রুত ও নির্ভরযোগ্য উপায়।

এবার একটি or (বা)-এর প্রশ্ন দেখা যাক: পূর্ব শাখার (east branch) সব অর্ডার, অথবা যেকোনো শাখার ৮৫০ বা তার বেশি দামের অর্ডার।

python
print(orders[(orders["branch"] == "east") | (orders["price"] >= 850)])
text
order_id        date branch product     category  quantity  price
2      1003  2024-03-02  north     bag  accessories         2  850.0
3      1004  2024-03-02   east  bottle  accessories         7  120.0
5      1006  2024-03-03  south     bag  accessories         1  850.0
6      1007  2024-03-04   east     pen   stationery        20   15.0

চারটি সারি: পূর্ব শাখার দুটি অর্ডার, এবং উত্তর ও দক্ষিণ শাখার দুটি ব্যাগের অর্ডার। যদি কোনো সারি উভয় শর্তই পূরণ করত (এখানে যদিও নেই), তবে সেটি কিন্তু একবারই আসত, দুইবার নয় — কারণ ফিল্টার কোনো সারিকে শুধুমাত্র রাখে অথবা বাদ দেয়।

একটি not (না)-এর প্রশ্ন দেখা যাক: স্টেশনারি (stationery) বাদে অন্য সব পণ্যের অর্ডার।

python
print(orders[~(orders["category"] == "stationery")])
text
order_id        date branch product     category  quantity  price
2      1003  2024-03-02  north     bag  accessories         2  850.0
3      1004  2024-03-02   east  bottle  accessories         7  120.0
5      1006  2024-03-03  south     bag  accessories         1  850.0
7      1008  2024-03-04  north  bottle  accessories         4  120.0

~ প্রতিটি True-কে False বানিয়ে দেয় এবং বিপরীতটাও করে। একটি একক সমতার ক্ষেত্রে আপনি != ব্যবহার করতে পারতেন, যা আরও সহজ; তবে যখন আপনি একটি বড় শর্ত বা কোনো isin-কে উল্টে দিতে চাইবেন, তখন ~-এর প্রয়োজনীয়তা বোঝা যায় (একটু পরেই আমরা তা দেখব)।

কেন বন্ধনী দেওয়া বাধ্যতামূলক

ওপরের প্রতিটি শর্তকে আলাদা বন্ধনীর ( ... ) মধ্যে রাখা হয়েছে। এটি কোনো সৌন্দর্য বা কোডিং স্টাইল নয়। বন্ধনী তুলে দিলে কোড সরাসরি ভেঙে যাবে:

python
print(orders[orders["quantity"] > 3 & orders["price"] < 100])
text
TypeError: Cannot perform 'rand_' with a dtyped [float64] array and scalar of type [bool]

ওপরে যা দেখছেন তা হলো ট্রেসব্যাকের শেষ লাইন, যেখানে এররটির নাম বলা হয়েছে; এর আগের লাইনগুলোতে শুধু থাকে এটি কোথায় ঘটেছে। এই ত্রুটির আসল কারণ হলো অপারেটরের অগ্রাধিকার (operator precedence) — অর্থাৎ পাইথন কোন অপারেটরটি আগে হিসাব করে। পাইথনে &-এর অগ্রাধিকার > এবং <-এর চেয়ে বেশি। পান্ডাস তৈরির অনেক আগেই পাইথনে এই নিয়ম করা হয়েছিল, কারণ & মূলত পূর্ণসংখ্যার বিটওয়াইজ "and"-এর জন্য তৈরি হয়েছিল, যা গাণিতিক তুলনার আগেই ঘটা উচিত। ফলে পাইথন লাইনটিকে এভাবে পড়ে:

text
orders["quantity"] > (3 & orders["price"]) < 100

এটি প্রথমে 3 & orders["price"] হিসাব করার চেষ্টা করে — অর্থাৎ সংখ্যা ৩ এবং দশমিক সংখ্যার একটি কলামের মধ্যে বিটওয়াইজ "and" অপারেশন — যা একেবারেই অসম্ভব এবং ঠিক এই কারণেই ত্রুটিটি দেখা দেয়। ত্রুটি বার্তায় rand_ (ডান পাশের 'and') এবং অ্যারের কথা উল্লেখ থাকে, তবে এর মূল কারণ একটাই: এক জোড়া বন্ধনী বাদ পড়া।

ত্রুটি বার্তা আসা আসলে সৌভাগ্যের ব্যাপার। কিন্তু প্রথম শর্তে বন্ধনী দিলেন আর দ্বিতীয়টিতে দিতে ভুলে গেলেন, তখন কোনো ত্রুটিই আসবে না — আর সেটাই আসল বিপদ:

python
print(len(orders[(orders["quantity"] > 3) & orders["price"] < 100]))
print(len(orders[(orders["quantity"] > 3) & (orders["price"] < 100)]))
text
8
4

প্রথম লাইনটি পাইথনের কাছে পড়া হয় ((quantity > 3) & price) < 100 হিসেবে। & অপারেটরটি একটি বুলিয়ান মাস্কের সাথে দামের কলামটি গুলিয়ে ফেলে, এবং ঘটনাচক্রে প্রতিটি মানই ১০০-এর নিচে হওয়ায় এই "ফিল্টার" আটটি সারির আটটিই রেখে দেয়! অথচ সঠিক ফিল্টারে পাওয়ার কথা ছিল চারটি সারি। একই কলাম, একই সংখ্যা, শুধু এক জোড়া বন্ধনী বাদ পড়েছে — আর ভুল কোডটি কোনো ধরনের শব্দ বা ত্রুটি ছাড়াই ভুল উত্তর দিয়ে দিল। প্রতিটি শর্তকে সবসময় নিজের বন্ধনীতে মুড়ে রাখুন। তাহলে ত্রুটি বা এই ধরনের নীরব ভুল কোনোটিই ঘটতে পারবে না।

and ব্যবহার করলে কী সমস্যা?

অনেকেরই স্বাভাবিক প্রবণতা থাকে ইংরেজি শব্দ and লিখে ফেলা:

python
print(orders[(orders["branch"] == "north") and (orders["quantity"] > 3)])
text
ValueError: The truth value of a Series is ambiguous. Use a.empty, a.bool(), a.item(), a.any() or a.all().

পাইথনের and, or এবং not কিওয়ার্ডগুলোর দুই পাশে একটি একক True বা False মান লাগে — এগুলো তৈরিই করা হয়েছিল সাধারণ if শর্তের জন্য। and ব্যবহার করলে পাইথন প্রথম মাস্কটিকে জিজ্ঞেস করে "তুমি কি সত্য?", কিন্তু আটটি উত্তরের একটি কলাম তো একক কোনো উত্তর দিতে পারে না। এর যেকোনো একটি মান True হলেই কি পুরো কলামকে সত্য ধরা হবে? নাকি সবগুলো সত্য হতে হবে? পান্ডাস নিজে থেকে কোনো অনুমান করতে নারাজ, এবং ত্রুটি বার্তায় সিদ্ধান্ত নেওয়ার উপায়গুলো উল্লেখ করে দেয় (a.any(), a.all())। কিন্তু আপনি তো কোনো একক সিদ্ধান্ত চাননি; আপনি চেয়েছিলেন সারি-ধরে-ধরে 'and'। আর সেটাই হলো &। এই সহজ নিয়মটি মনে রাখবেন: ফিল্টারের ভেতরে and/or/not-এর জায়গায় সবসময় বসবে &/|/~।


একাধিক মানের যেকোনো একটি: isin

দক্ষিণ বা পূর্ব শাখার অর্ডারগুলো। আপনি | দিয়ে দুটি সমতা যুক্ত করে লিখতে পারেন। কিন্তু পাঁচটি শাখা থাকলে পাঁচটি তুলনা লিখতে হবে। isin একটি তালিকা নেয় এবং জিজ্ঞেস করে "এই সারির মানটি কি তালিকার মধ্যে আছে?":

python
print(orders[orders["branch"].isin(["east", "south"])])
text
order_id        date branch   product     category  quantity  price
1      1002  2024-03-01  south  notebook   stationery         5   60.0
3      1004  2024-03-02   east    bottle  accessories         7  120.0
5      1006  2024-03-03  south       bag  accessories         1  850.0
6      1007  2024-03-04   east       pen   stationery        20   15.0

আর ঠিক এই জায়গাতেই ~-এর চমৎকার ব্যবহার দেখা যায় — উত্তর ও পূর্ব ছাড়া বাকি সব শাখা বোঝাতে isin-এর সামনে একটি ~ বসিয়ে দিলেই হয়ে যায়:

python
print(orders[~orders["branch"].isin(["north", "east"])]["order_id"].tolist())
text
[1002, 1006]

দুটি অর্ডার, দুটিই দক্ষিণ শাখার। লক্ষ্য করুন ~orders["branch"].isin([...])-এর চারপাশে আলাদা কোনো বন্ধনীর দরকার পড়েনি: কারণ মেথড কলের ফলাফলের উপর সরাসরি ~ কাজ করে এবং এখানে কোনো & বা |-এর সাথে অগ্রাধিকারের লড়াই নেই। তবে আপনি এটিকে অন্য কোনো শর্তের সাথে যুক্ত করার সাথে সাথেই বন্ধনী ফেরত আনতে হবে: (~orders["branch"].isin([...])) & (orders["quantity"] > 3)।

একটি নির্দিষ্ট পরিসীমা: between

যেসব অর্ডারের দাম ১৫ থেকে ১২০-এর মধ্যে। এটি দুটি তুলনা, >= 15 এবং <= 120। between এক শব্দেই এটি প্রকাশ করে:

python
print(orders[orders["price"].between(15, 120)])
text
order_id        date branch   product     category  quantity  price
0      1001  2024-03-01  north       pen   stationery        12   15.0
1      1002  2024-03-01  south  notebook   stationery         5   60.0
3      1004  2024-03-02   east    bottle  accessories         7  120.0
6      1007  2024-03-04   east       pen   stationery        20   15.0
7      1008  2024-03-04  north    bottle  accessories         4  120.0

between ডিফল্টভাবে উভয় প্রান্তের মানকেই অন্তর্ভুক্ত করে — অর্থাৎ ঠিক ১৫.০ দামের কলম এবং ঠিক ১২০.০ দামের বোতল দুটিই ফলাফলে রয়েছে। মানুষ সাধারণত "১৫ থেকে ১২০" বলতে যেভাবে বোঝে এটি তার সাথে মিলে যায়, তবে প্রশ্নের সাথে এটি মিলিয়ে নেওয়া জরুরি। প্রশ্নে যদি প্রান্তের মান বাদ দিয়ে কঠোরভাবে মধ্যবর্তী মান চাওয়া হয়, তবে তা স্পষ্ট বলে দিন:

python
print(orders["price"].between(15, 120, inclusive="neither").sum())
text
1

কেবল ৬০.০ দামের নোটবুকটিই কঠোরভাবে মধ্যবর্তী। কোনো এক পাশ খোলা রাখার জন্য inclusive-এ "left" বা "right" ব্যবহার করা যায় — যা সাধারণত টাকার স্ল্যাব বা ব্যান্ডের ক্ষেত্রে প্রয়োজন হয় (যেমন ০–১০০, ১০০–২০০), যাতে কোনো সীমানার মান দুটি ব্যান্ডের কোনোটিতেই দ্বিগুণ গণনা না হয়।

লেখার প্যাটার্ন: .str মেথডসমূহ

টেক্সটের উপর সাধারণ সমতা হুবহু মিল খোঁজে। আর .str অ্যাক্সেসরটি টেক্সট কলামে পাইথনের চেনা স্ট্রিং মেথডগুলো ব্যবহার করার সুযোগ দেয়, যা প্রতিটি সারির উপর প্রযুক্ত হয় এবং প্রতিটির জন্য একটি মাস্ক ফেরত দেয়।

যেসব পণ্যের নাম "b" দিয়ে শুরু:

python
print(orders[orders["product"].str.startswith("b")])
text
order_id        date branch product     category  quantity  price
2      1003  2024-03-02  north     bag  accessories         2  850.0
3      1004  2024-03-02   east  bottle  accessories         7  120.0
5      1006  2024-03-03  south     bag  accessories         1  850.0
7      1008  2024-03-04  north  bottle  accessories         4  120.0

যেসব পণ্যের নামে ছোট বা বড় হাতের "o" অক্ষরটি রয়েছে:

python
print(orders[orders["product"].str.contains("O", case=False)])
text
order_id        date branch   product     category  quantity  price
1      1002  2024-03-01  south  notebook   stationery         5   60.0
3      1004  2024-03-02   east    bottle  accessories         7  120.0
7      1008  2024-03-04  north    bottle  accessories         4  120.0

ডেটার কোথাও কিন্তু বড় হাতের O নেই, তবুও নোটবুক এবং দুটি বোতলের সারি ঠিকই পাওয়া গেছে, কারণ case=False বড় বা ছোট হাতের অক্ষরের ভেদাভেদ ভুলে যায়। এটি না দিলে .str.contains("O") কিছুই খুঁজে পেত না।

সরাসরি সমতায় (==) কিন্তু এমন কোনো অপশন থাকে না। আর ফিল্টারিংয়ে "আমার কোড কোনো সারি দেখাচ্ছে না কেন" জাতীয় সবচেয়ে সাধারণ বাগটি ঘটে ঠিক এখানেই:

python
print(orders[orders["branch"] == "North"])
text
Empty DataFrame
Columns: [order_id, date, branch, product, category, quantity, price]
Index: []

কোনো ত্রুটি নেই, আবার কোনো সারিও নেই। টেবিলটিও ভুল নয়, পান্ডাসও ভুল নয় — ডেটাতে বড় হাতের অক্ষরে লেখা "North" নামের কোনো শাখাই নেই। এই কারণেই পরিকল্পনার ২ নম্বর ধাপে আমরা unique() প্রিন্ট করে দেখে নিয়েছিলাম: কোডে যেসব মানের সাথে তুলনা করবেন তা স্মৃতি থেকে টাইপ না করে কলামে আসলেই কী রয়েছে সেখান থেকে হুবহু কপি করা উচিত।

টেক্সট তারিখের তুলনা সঠিকভাবে কাজ করে — যখন তা ISO ফরম্যাটে থাকে

date কলামটি str হিসেবে লোড হয়েছে। আপনি কি >= দিয়ে এটি ফিল্টার করতে পারবেন? ৩ মার্চ বা তার পরের সব অর্ডার:

python
print(orders["date"] >= "2024-03-03")
text
0    False
1    False
2    False
3    False
4     True
5     True
6     True
7     True
Name: date, dtype: bool

এটি কাজ করেছে, তবে কারণটি বোঝা জরুরি। এগুলো সাধারণ স্ট্রিং, তাই >= এদের টেক্সট হিসেবে তুলনা করে — অক্ষর ধরে ধরে, যেভাবে ডিকশনারিতে শব্দ সাজানো থাকে। এটি সঠিক উত্তর দিতে পেরেছে কেবল এই কারণে যে তারিখগুলো বছর-মাস-দিন আকারে শূন্য যোগ করে লেখা হয়েছে (2024-03-03), ফলে টেক্সটের ক্রম কাকতালীয়ভাবে সময়ের ক্রমের সাথে হুবহু মিলে গেছে। কিন্তু তারিখ যদি 3/3/2024 আকারে লেখা থাকত, তবে ভুল উত্তর আসত — কারণ "1" অক্ষরটি "9"-এর আগে আসায় "10/1/2024" টেক্সটটি "9/1/2024"-এর চেয়ে ছোট হিসেবে বিবেচিত হতো। তাই সাধারণ ফিল্টারের বাইরে যেকোনো জটিল কাজের জন্য pd.to_datetime দিয়ে আসল তারিখে রূপান্তর করে নেওয়া উচিত, যা নতুন কলাম তৈরির অধ্যায়ে বিস্তারিত রয়েছে।


সারি এবং কলাম একসাথে: .loc[mask, columns]

এতক্ষণ পর্যন্ত সবকটি ফিল্টার টেবিলের প্রতিটি কলামকেই রেখে দিচ্ছিল। কিন্তু ম্যানেজার উত্তর শাখার অর্ডার চাইলেও রিপোর্টে হয়তো শুধু পণ্যের নাম আর পরিমাণ দরকার। গত অধ্যায়ে আপনি লেবেল দিয়ে .loc[rows, columns] ব্যবহার করা শিখেছিলেন। এই সারির জায়গায় আপনি চাইলে একটি মাস্ক বসিয়ে দিতে পারেন:

python
print(orders.loc[orders["branch"] == "north", ["product", "quantity"]])
text
product  quantity
0     pen        12
2     bag         2
4  eraser        30
7  bottle         4

এক ধাপ, এক অবজেক্ট, এবং আপনার কোড যে পড়বে সে এক জায়গাতেই পুরো প্রশ্নটি দেখতে পাবে — কোন কোন সারি, আর কোন কোন কলাম। কলামের জায়গায় লিস্টের বদলে একক নাম দিয়ে .loc[mask, "quantity"] লিখলে একটি Series পাওয়া যায়, যা সরাসরি গণিতের জন্য প্রস্তুত:

python
print(orders.loc[orders["branch"] == "north", "quantity"].sum())
text
48

১২ + ২ + ৩০ + ৪ = উত্তর শাখায় বিক্রি হওয়া মোট ৪৮টি পণ্য।

query(): ফিল্টারকে সাধারণ বাক্যের মতো লেখা

পান্ডাসে ফিল্টার লেখার আরেকটি বিকল্প বাক্যরীতি রয়েছে, যার নাম query()। এটি শর্তটিকে একটি সাধারণ স্ট্রিং হিসেবে গ্রহণ করে:

python
print(orders.query("branch == 'north' and quantity > 3"))
text
order_id        date branch product     category  quantity  price
0      1001  2024-03-01  north     pen   stationery        12   15.0
4      1005  2024-03-03  north  eraser   stationery        30    8.0
7      1008  2024-03-04  north  bottle  accessories         4  120.0

হুবহু একই তিনটি সারি। স্ট্রিংয়ের ভেতরে সরাসরি কলামের নাম লেখা যায় এবং এখানে সাধারণ ইংরেজি and/or/not ব্যবহার করা যায়, কারণ query স্ট্রিংটিকে পাইথনের অপারেটরের হাতে না দিয়ে নিজেই বিশ্লেষণ করে। কোনো পাইথন ভেরিয়েবল ব্যবহার করতে চাইলে তার নামের আগে @ বসাতে হয়:

python
min_qty = 5
print(orders.query("quantity >= @min_qty")["order_id"].tolist())
text
[1001, 1002, 1004, 1005, 1007]

বড় ও জটিল শর্তের ক্ষেত্রে query() বেশ সুন্দর ও পাঠযোগ্য দেখায়। তবে এর কিছু সীমাবদ্ধতাও আছে: স্ট্রিংয়ের ভেতরে কোনো টাইপো থাকলে তা কোড চালানোর আগ পর্যন্ত ধরা পড়ে না, আপনার কোড এডিটর এর ভেতরে অটো-কমপ্লিশনে সাহায্য করতে পারে না, এবং কলামের নামে স্পেস থাকলে ব্যাকটিক ব্যবহার করতে হয়। এই কোর্সে আমরা মূলত বুলিয়ান মাস্ককেই প্রধান হাতিয়ার হিসেবে ব্যবহার করছি, কারণ পান্ডাসের বাকি সবকিছু — .loc, নতুন মান নির্ধারণ, গণনা — এই মাস্কের ওপর ভিত্তি করেই গড়ে উঠেছে। তবে query() জেনে রাখা দরকার যাতে অন্যের লেখা কোড সহজে বুঝতে পারেন।


ফিল্টার করা টেবিল মূল লেবেলগুলো ধরে রাখে

চলুন ইনডেক্সের প্রসঙ্গে ফিরে আসা যাক। উত্তর শাখার অর্ডারগুলো একটি ভেরিয়েবলে সেভ করে তার ইনডেক্স দেখা যাক:

python
north = orders[orders["branch"] == "north"]

print(north.index.tolist())
text
[0, 2, 4, 7]

ইনডেক্স লেবেলগুলো হলো 0, 2, 4, 7। তাহলে north.loc[1] লিখলে কী হবে? এই টেবিলে 1 নামের কোনো লেবেলই তো নেই — কারণ অর্ডার 1002 ছিল দক্ষিণ শাখার এবং ফিল্টারে তা বাদ পড়ে গেছে:

python
print(north.loc[1])
text
KeyError: 1

.loc খোঁজে লেবেল, আর লেবেল 1 এখানে অনুপস্থিত। আপনি যদি বোঝাতে চেয়ে থাকেন "উত্তর শাখার দ্বিতীয় অর্ডারটি দাও", তবে সেটি হলো একটি অবস্থান (position), আর অবস্থানের জন্য রয়েছে .iloc:

python
print(north.iloc[1])
text
order_id           1003
date         2024-03-02
branch            north
product             bag
category    accessories
quantity              2
price             850.0
Name: 2, dtype: object

অবস্থানের বিচারে দ্বিতীয় সারিটি হলো ব্যাগের অর্ডার — এবং লক্ষ্য করুন এর Name হলো 2, যা মূল টেবিলের লেবেল। এটিই হলো গত অধ্যায়ের loc আর iloc-এর মূল পার্থক্য, আর ফিল্টারিংয়ের পরেই এর প্রভাব সবচেয়ে বেশি বোঝা যায়, কারণ ফিল্টার করার পর লেবেল এবং অবস্থান আর এক থাকে না।

আসল লেবেলগুলো রেখে দেওয়া পান্ডাসের একটি ইচ্ছাকৃত নকশা: এর ফলে ফলাফলের যেকোনো সারিকে খুব সহজেই মূল টেবিলের সাথে মিলিয়ে নেওয়া যায়। তবে যখন এর আর প্রয়োজন থাকে না — ধরা যাক ফিল্টার করা টেবিলটিই আপনার চূড়ান্ত রিপোর্ট — তখন এর ইনডেক্স নতুন করে সাজিয়ে নিন:

python
print(north.reset_index(drop=True))
text
order_id        date branch product     category  quantity  price
0      1001  2024-03-01  north     pen   stationery        12   15.0
1      1003  2024-03-02  north     bag  accessories         2  850.0
2      1005  2024-03-03  north  eraser   stationery        30    8.0
3      1008  2024-03-04  north  bottle  accessories         4  120.0

drop=True পুরনো লেবেলগুলোকে ফেলে দেয়। এটি না দিলে পুরনো লেবেলগুলো index নামের একটি নতুন কলাম হিসেবে টেবিলে রয়ে যায়, যা সাধারণত কোনো রিপোর্টে দেখতে ভালো লাগে না।

খালি ফলাফল কোনো ত্রুটি নয়

python
none = orders[orders["branch"] == "west"]

print(none)
print(none.shape)
print(none.empty)
text
Empty DataFrame
Columns: [order_id, date, branch, product, category, quantity, price]
Index: []
(0, 7)
True

পশ্চিমে কোনো শাখা নেই, তাই কোনো সারি নেই — এবং কোনো ত্রুটিও নেই। পান্ডাসের দিক থেকে এটি সম্পূর্ণ সঠিক আচরণ (শূন্য তো একটি বৈধ উত্তর হতেই পারে), তবে এর মানে হলো ফিল্টারে ভুল বানান লিখলে কোনো সতর্কতা ছাড়াই কোড পার হয়ে যায়। একটি খালি ফলাফলের কলাম যোগ করলে যোগফল আসে 0 এবং গড় আসে nan, যা নজরে পড়ার আগেই রিপোর্টের গভীরে ঢুকে যেতে পারে। যখন কোনো ফিল্টারের ফলাফলের ওপর গুরুত্বপূর্ণ কিছু নির্ভর করে, তখন result.empty বা সারির সংখ্যা পরীক্ষা করে দেখুন, এবং আউটপুটে তা স্পষ্ট ভাষায় বলে দিন: একটি ফাঁকা টেবিল দেখানোর চেয়ে "No orders from the west branch." লিখে দেওয়া অনেক বেশি পেশাদার।


ফিল্টার করা সারির মান পরিবর্তন: .loc[mask, column] = value

অনেক সময় আমরা কিছু মান পরিবর্তন করার উদ্দেশ্যে ফিল্টার করি। পূর্ব শাখায় বিক্রি হওয়া কলমগুলোর দামে ভুল ছিল; এগুলোর দাম ১৫.০ নয়, ১৩.৫ হওয়া উচিত। অনেকের মনে যে কোডটি লেখার ইচ্ছা জাগে তা হলো আগে ফিল্টার করা এবং তারপর কলামটি বেছে নেওয়া — অর্থাৎ পরপর দুটি ব্র্যাকেট, ঠিক যেভাবে আমরা শুরুতে মান পড়ার কাজ করেছিলাম:

python
import pandas as pd

orders = pd.read_csv("orders.csv")

orders[orders["branch"] == "east"]["price"] = 13.5

print(orders["price"].tolist())
text
[15.0, 60.0, 850.0, 120.0, 8.0, 850.0, 15.0, 120.0]
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

দামের লিস্টটি দেখুন: কোনো কিছুই পরিবর্তিত হয়নি! আর পান্ডাস বিশদভাবে সতর্কবার্তা দিল। (টার্মিনালে এই ওয়ার্নিংটি stderr-এ যায়, তাই এটি তালিকার ওপরে এবং ফাইলের নাম ও লাইন নম্বরসহ দেখা যায়। এখানে আউটপুটের সুবিধার্থে তা নিচে দেখানো হলো।) একে বলে চেইন্ড অ্যাসাইনমেন্ট (chained assignment) — অর্থাৎ পরপর দুটি ইনডেক্সিং ধাপ, প্রথমে orders[mask] এবং তারপর ["price"], আর চেইনের শেষে অ্যাসাইনমেন্ট চিহ্ন =। প্রথম ধাপটি, orders[mask], পূর্ব শাখার সারিগুলো নিয়ে একটি নতুন অস্থায়ী টেবিল তৈরি করে। দ্বিতীয় ধাপটি সেই নতুন অস্থায়ী টেবিলে নতুন দাম বসায়। এরপর সেই নতুন টেবিলটি মেমরি থেকে হারিয়ে যায়, কারণ কোনো ভেরিয়েবলে সেটিকে ধরে রাখা হয়নি। মূল orders টেবিলটি অপরিবর্তিতই থেকে যায়।

পান্ডাস ৩-এ এই নিয়মটি সুনির্দিষ্ট ও নিশ্চিত, যার নাম কপি-অন-রাইট (Copy-on-Write): ইনডেক্সিং করে পাওয়া যেকোনো টেবিল নিজস্ব একটি স্বাধীন কপি হিসেবে কাজ করে, তাই এতে কোনো কিছু লিখলে তা কখনোই মূল টেবিলে প্রতিফলিত হয় না। এই ChainedAssignmentError হলো একটি সতর্কবার্তা, কোনো এক্সেপশন নয় — ফলে প্রোগ্রামটি বন্ধ না হয়ে স্বাভাবিকভাবেই চলতে থাকে — আর ঠিক এই কারণেই এটি এত বিপজ্জনক: এই লাইনটি থাকা একটি স্ক্রিপ্ট পুরোনো দাম রেখেই "সফলভাবে" রান শেষ করবে।

(পুরোনো টিউটোরিয়ালগুলোতে এই পরিস্থিতিকে SettingWithCopyWarning দিয়ে বোঝানো হতো এবং বলা হতো এটি "কাজ করতেও পারে আবার নাও করতে পারে"। পান্ডাস ৩-এ উত্তর একেবারেই পরিষ্কার: এটি কখনোই কাজ করে না, তাই কখনোই এভাবে লিখবেন না।)

এর সমাধান সতর্কবার্তার নিজের পরামর্শেই রয়েছে: একক ইনডেক্সিং ধাপ, .loc[rows, column], যার সাথে সরাসরি নতুন মান নির্ধারণ করা হবে:

python
orders.loc[orders["branch"] == "east", "price"] = 13.5

print(orders["price"].tolist())
text
[15.0, 60.0, 850.0, 13.5, 8.0, 850.0, 13.5, 120.0]

একটু দাঁড়ান — এটি তো পূর্ব শাখার উভয় অর্ডারের দামই বদলে দিয়েছে, কলমের দামও বদলেছে আবার বোতলের দামও! বোতলের দামে তো কোনো ভুল ছিল না। কোডটি কিন্তু যা বলা হয়েছিল হুবহু তাই করেছে; আসল গলদ ছিল কোডের শর্তে। এবার পরিকল্পনার ১ নম্বর ধাপটি মনে করুন: "পূর্ব শাখায় বিক্রি হওয়া কলম" আসলে দুটি শর্ত, আর আমরা লিখেছিলাম মাত্র একটি। নতুন করে ফাইলটি লোড করে দুটি শর্তই একসাথে লিখুন:

python
import pandas as pd

orders = pd.read_csv("orders.csv")

fix = (orders["branch"] == "east") & (orders["product"] == "pen")
print(fix.sum())

orders.loc[fix, "price"] = 13.5
print(orders.loc[orders["branch"] == "east", ["product", "price"]])
text
1
  product  price
3  bottle  120.0
6     pen   13.5

এখানে দুটি চমৎকার অভ্যাস দেখা যাচ্ছে। মাস্কটির একটি স্পষ্ট নাম দেওয়া হয়েছে (fix) কারণ এটি একটি গুরুত্বপূর্ণ কাজে ব্যবহৃত হচ্ছে; এবং মান পরিবর্তনের আগেই fix.sum() দিয়ে দেখে নেওয়া হয়েছে কয়টি সারি বদলাতে যাচ্ছে — ঠিক ১টি, যেমনটা আমরা আশা করেছিলাম। কোনো কিছু ওভাররাইট করার আগে সবসময় সারি গুনে নিন।

পড়ার সময় df[mask]["col"] লেখা সম্পূর্ণ নিরাপদ। কিন্তু লেখার সময় সবসময় df.loc[mask, "col"] = value ব্যবহার করুন। চেইন্ড পদ্ধতিতে কোনো এরর আসে না, কিন্তু আপনার পরিবর্তনটি নীরবে হারিয়ে যায়।

ফিল্টার করা টেবিল সেভ করে আলাদা কাজ করা সম্পূর্ণ নিরাপদ

তাহলে নিচের কোডটি সম্পর্কে কী বলবেন?

python
import pandas as pd

orders = pd.read_csv("orders.csv")

east = orders[orders["branch"] == "east"]
east["price"] = 0.0

print(east["price"].tolist())
print(orders["price"].tolist())
text
[0.0, 0.0]
[15.0, 60.0, 850.0, 120.0, 8.0, 850.0, 15.0, 120.0]

এখানে কোনো সতর্কবার্তা নেই, আর সেটাই স্বাভাবিক। এখানে আপনি ইচ্ছাকৃতভাবেই একটি আলাদা টেবিল বানাতে চেয়েছিলেন: সেটির নাম দিয়েছেন east, এবং এটিতে মান পরিবর্তন করলে শুধু east-এর মানই বদলায়। মূল orders অপরিবর্তিত থাকে। কপি-অন-রাইট এই আচরণকে অত্যন্ত নির্ভরযোগ্য ও অনুমানযোগ্য করে তুলেছে — ফিল্টার করে পাওয়া টেবিল সবসময় মূল টেবিল থেকে স্বাধীন। ভুলটি শুধু তখনই হয় যখন চেইন্ড পদ্ধতিতে নামহীন কোনো অস্থায়ী টেবিলে মান লিখে তা হারিয়ে ফেলা হয়।


একটা সম্পূর্ণ উদাহরণ

ম্যানেজার এবার একটি সংক্ষিপ্ত রিপোর্ট দেখতে চেয়েছেন। report.py:

python
import pandas as pd

orders = pd.read_csv("orders.csv")

# Check what arrived before asking it anything.
print("Rows, columns:", orders.shape)
print("Branches:", list(orders["branch"].unique()))
print()

# 1. The original question: north-branch orders of more than three items.
north_big = orders.loc[
    (orders["branch"] == "north") & (orders["quantity"] > 3),
    ["order_id", "product", "quantity"],
]
print("North orders over 3 items:")
print(north_big.reset_index(drop=True))
print()

# 2. Counting with masks: no new table needed.
accessories = orders["category"] == "accessories"
print("Accessory orders   :", accessories.sum())
print("Share of all orders:", accessories.mean())
print("Accessory items    :", orders.loc[accessories, "quantity"].sum())
print()

# 3. Outside north, cheap items (under 100).
cheap_outside = orders[
    (orders["branch"] != "north") & (orders["price"] < 100)
]
print("Cheap orders outside north:", cheap_outside["order_id"].tolist())

# 4. A branch we do not have: say so instead of printing an empty table.
west = orders[orders["branch"] == "west"]
if west.empty:
    print("No orders from the west branch.")
text
Rows, columns: (8, 7)
Branches: ['north', 'south', 'east']

North orders over 3 items:
   order_id product  quantity
0      1001     pen        12
1      1005  eraser        30
2      1008  bottle         4

Accessory orders   : 4
Share of all orders: 0.5
Accessory items    : 14

Cheap orders outside north: [1002, 1007]
No orders from the west branch.

কোডটি কেন এভাবে লেখা হলো:

  • আগে যাচাই, তারপর প্রশ্ন। ফিল্টার করার আগেই shape এবং শাখার নামগুলো প্রিন্ট করা হয়েছে। ফাইলটিতে যদি কোনো শাখার নাম বড় হাতের North হিসেবে আসত, তবে খালি টেবিল প্রিন্ট হওয়ার আগেই এই লাইনে তা ধরা পড়ে যেত।
  • মূল প্রশ্নের জন্য .loc[mask, columns]। উত্তরের জন্য প্রয়োজনীয় সারি ও কলাম এক জায়গাতেই নির্বাচন করা হয়েছে, এবং এটি যেহেতু চূড়ান্ত রিপোর্ট তাই reset_index(drop=True) দিয়ে ফলাফলকে নতুন নম্বর দেওয়া হয়েছে।
  • একটি নামযুক্ত মাস্ক, যা তিনবার ব্যবহৃত হয়েছে। accessories মাস্কটি একবার তৈরি করে তা দিয়ে গণনা করা হয়েছে (sum), অনুপাত বের করা হয়েছে (mean), এবং একটি কলাম যোগ করা হয়েছে। নামযুক্ত মাস্ক ব্যবহারের সুবিধা হলো একই শর্ত বারবার লিখে ভুল হওয়ার কোনো সুযোগ থাকে না।
  • প্রতিটি শর্ত নিজস্ব বন্ধনীতে আবদ্ধ, এমনকি ছোট শর্তগুলোর ক্ষেত্রেও, যাতে ভবিষ্যতে কোনো তৃতীয় শর্ত যোগ করলেও অগ্রাধিকারের ভুল না হয়।
  • খালি ফলাফল সুন্দরভাবে সামলানো হয়েছে। কোনো সারি না পাওয়াও একটি সঠিক উত্তর হতে পারে, এবং রিপোর্টে তা স্পষ্ট ভাষায় জানানো হয়েছে।

কিছু ভাঙা অবস্থা ও তার সমাধান

ValueError: The truth value of a Series is ambiguous. Use a.empty, a.bool(), a.item(), a.any() or a.all(). দুটি মাস্কের মাঝে and, or বা not ব্যবহার করেছেন — অথবা পুরো একটি মাস্কের ওপর if চালিয়েছেন। পাইথনের এই কিওয়ার্ডগুলোর একটি একক True/False দরকার হয়, অথচ মাস্কে প্রতি সারির জন্য একটি করে মান থাকে। ফিল্টারের ভেতরে লিখুন &, |, ~। আর কোনো if-এর ভেতরে স্পষ্ট করে বলুন আপনি কী চান: "অন্তত একটি সারি কি সত্য?" বোঝাতে if mask.any():, অথবা "সবগুলো সারিই কি সত্য?" বোঝাতে if mask.all():।

TypeError: Cannot perform 'rand_' with a dtyped [float64] array and scalar of type [bool] (বা 'ror_', কিংবা [int64]) & বা |-এর পাশের শর্তে বন্ধনী বাদ পড়েছে। পাইথন তুলনামূলক অপারেটর >-এর আগেই & প্রয়োগ করায় একটি সংখ্যা ও একটি কলামের মধ্যে বিটওয়াইজ "and" করার চেষ্টা করেছে। প্রতিটি শর্তকে নিজস্ব বন্ধনীতে মুড়ে নিন: (a > 3) & (b < 100)।

ফিল্টারে কোনো ত্রুটি নেই, কিন্তু খালি টেবিল রিটার্ন করছে আপনি কলামের যে মানটি খুঁজছেন তা ডেটাতে হুবহু ওই বানানে নেই। df["col"].unique() প্রিন্ট করুন — অথবা আরও ভালো হয় list(df["col"].unique()) প্রিন্ট করলে, যা প্রতিটি মানকে কোটেশনের মধ্যে দেখায়, ফলে 'north '-এর পেছনের অতিরিক্ত স্পেসটি স্পষ্ট ধরা পড়ে। df["col"].str.strip() দিয়ে ডেটা পরিষ্কার করুন, অথবা .str.contains(..., case=False) দিয়ে শিথিলভাবে খুঁজুন।

ফিল্টার করার ঠিক পরেই KeyError: 1 (বা অন্য কোনো সংখ্যা) ফিল্টার করা টেবিলে আপনি এমনভাবে .loc[n] চালিয়েছেন যেন এর লেবেলগুলো এখনও 0, 1, 2… আছে। অথচ ফিল্টারের পরেও মূল লেবেলগুলোই বহাল থাকে। "n-তম সারি" বোঝাতে .iloc[n] ব্যবহার করুন, অথবা আগের লেবেলগুলোর দরকার না থাকলে আগে reset_index(drop=True) করে নিন।

ChainedAssignmentError: A value is being set on a copy of a DataFrame or Series through chained assignment. আপনি লিখেছেন df[mask]["col"] = value। পান্ডাস ৩-এ এটি কখনোই df-কে পরিবর্তন করে না — এই সতর্কবার্তা আপনাকে জানাচ্ছে যে লাইনটি কোনো কাজ করেনি। লিখুন df.loc[mask, "col"] = value।

UserWarning: Boolean Series key will be reindexed to match DataFrame index. যে টেবিলটি ফিল্টার করছেন মাস্কটি তার বদলে অন্য কোনো টেবিল থেকে তৈরি করা হয়েছে — যেমন মূল orders থেকে তৈরি মাস্ক দিয়ে ফিল্টার করা north টেবিলকে ফিল্টার করা। পান্ডাস লেবেল মিলিয়ে সারিগুলো সাজায় এবং যে লেবেলগুলো পায় না সেগুলো নীরবে বাদ দেয়:

python
import pandas as pd

orders = pd.read_csv("orders.csv")

north = orders[orders["branch"] == "north"]
big = orders["quantity"] > 5

print(north[big])
text
order_id        date branch product    category  quantity  price
0      1001  2024-03-01  north     pen  stationery        12   15.0
4      1005  2024-03-03  north  eraser  stationery        30    8.0
UserWarning: Boolean Series key will be reindexed to match DataFrame index.

(টার্মিনালে এই ওয়ার্নিংটি টেবিলের ওপরে আসে।) কাকতালীয়ভাবে লেবেলগুলো টিকে থাকায় এখানে সঠিক উত্তর পাওয়া গেছে। তবে সবসময় যে টেবিল ফিল্টার করছেন মাস্কটি সেখান থেকেই তৈরি করুন: north[north["quantity"] > 5]।