অধ্যায় 08

গ্রুপিং ও এগ্রিগেশন — প্রতি গ্রুপে একটি করে উত্তর

"প্রতি শাখায়", "প্রতি ক্যাটাগরিতে" কিংবা "প্রতি দিনে"র মতো প্রশ্নগুলোকে এক লাইনের groupby-তে রূপান্তর: কি (key), ভ্যালু ও এগ্রিগেশন, named agg দিয়ে একসাথে একাধিক হিসাব, size বনাম count, দুটি কি দিয়ে গ্রুপিং, এবং গড়ের গড় বের করার ভুল এড়ানো।

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

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

দোকানের মালিক আপনাকে একটি লাইনের মেসেজ পাঠালেন: "এই সপ্তাহে আমাদের কোন শাখায় কয়টি পণ্য বিক্রি হয়েছে?"

আপনার কাছে আছে orders.csv — আটটি অর্ডার, প্রতি সারিতে একটি করে। গত চারটি অধ্যায়ে যা শিখেছেন, তা দিয়েই আপনি এর উত্তর দিতে পারেন। একটি শাখা ফিল্টার করুন, তার কলাম যোগ করুন, আর পুনরাবৃত্তি করুন:

python
import pandas as pd

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

for branch in ["north", "south", "east"]:
    units = orders[orders["branch"] == branch]["quantity"].sum()
    print(branch, units)
text
north 48
south 6
east 27

সংখ্যাগুলো সঠিক। কিন্তু এই পদ্ধতির তিনটি বড় সমস্যা রয়েছে, এবং ডেটার আকার বাড়ার সাথে সাথে সমস্যাগুলো আরও মারাত্মক হয়।

প্রথমত, শাখাগুলোর নাম আপনাকে আগে থেকেই জানতে হয়েছে। তালিকাটি আপনি নিজে হাতে টাইপ করেছেন। পরের সপ্তাহের ফাইলে যদি নতুন কোনো west শাখা থেকে অর্ডার আসে, তবে এই লুপ তা দেখতেই পাবে না — কোনো ত্রুটি বা এরর দেবে না, স্রেফ west-কে বাদ রেখে যাবে, আর মোট যোগফলের সাথে আলাদা আলাদা শাখার যোগফল আর মিলবে না।

দ্বিতীয়ত, প্রতিটি নতুন প্রশ্নের জন্য নতুন একটি লুপ লিখতে হবে। "প্রতি ক্যাটাগরিতে আয় (revenue)" জানার জন্য হাতে টাইপ করা দ্বিতীয় আরেকটি তালিকার ওপর দ্বিতীয় লুপ লাগবে। "প্রতি শাখা এবং প্রতি ক্যাটাগরিতে বিক্রি" জানতে গেলে লুপের ভেতর লুপ লাগবে।

তৃতীয়ত, উত্তরটি কোনো টেবিল নয়। এটি কেবল প্রিন্ট হওয়া কিছু লাইনের লেখা। এটিকে নতুন করে সাজানো (sort), ফিল্টার করা কিংবা অন্য কোনো ফলাফলের পাশে টেবিল আকারে বসানো যায় না।

আপনি আসলে যা বলতে চান তা এক লাইনেই বলা সম্ভব: সারিগুলোকে শাখা অনুযায়ী ভাগ করো, প্রতিটি টুকরোর ভেতরে quantity যোগ করো, এবং টুকরোগুলোকে একটি টেবিল হিসেবে ফিরিয়ে দাও। এটিই হলো groupby, এবং পান্ডাসে প্রায় প্রতিটি সারসংক্ষেপ বা সামারি এভাবেই লেখা হয় — কোনো রিপোর্ট, ড্যাশবোর্ডের সংখ্যা, কিংবা "ব্যবসার টাকা আসলে কোথা থেকে আসছে?" প্রশ্নের প্রথম উত্তর।

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

  • "প্রতি শাখায় মোট বিক্রি"-র মতো প্রশ্নকে তিনটি অংশে ভাগ করতে — কি (key), ভ্যালু কলাম ও এগ্রিগেশন — এবং গ্রুপিং করার আগে কি যাচাই করতে
  • df.groupby(key)[column].sum() লিখতে এবং কি-কে ইনডেক্স করে ফেরত আসা Series পড়তে
  • .agg() এবং named aggregation দিয়ে একসাথে একাধিক সামারি হিসাব করতে
  • size() এবং count()-এর পার্থক্য বুঝতে, এবং মিসিং কি ও মিসিং ভ্যালুর ক্ষেত্রে কী ঘটে তা নির্ধারণ করতে
  • দুটি কলামের ভিত্তিতে গ্রুপিং করতে, ফলাফল ফ্ল্যাট বা নতুন আকারে রূপান্তর (reshape) করতে, এবং "গড়ের গড়" করার মারাত্মক ভুল এড়াতে

আগে যা জানা লাগবে: সারি সাজানো।


যে ফাইলটি আমরা ব্যবহার করছি

আগের অধ্যায়গুলোর সেই একই 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

প্রতিটি সারি একটি একক অর্ডার: কোন শাখা থেকে এসেছে, কোন পণ্য, পণ্যের ক্যাটাগরি, কয়টি ইউনিট এবং প্রতি ইউনিটের দাম।


কোড লেখার আগে

গ্রুপিং-সংক্রান্ত প্রতিটি প্রশ্নের কাঠামো একই রকম। groupby টাইপ করার আগেই প্রশ্নটিকে ভেঙে বিশ্লেষণ করুন।

১. প্রশ্নটিকে তিনটি অংশে ভাগ করুন

গ্রুপিংয়ের যেকোনো প্রশ্নে সর্বদা তিনটি বিষয় থাকে, এবং প্রশ্নের শব্দগুলোই বলে দেয় কোনটি কী:

  • কি (Key) — যে কলামের মানগুলোর ওপর ভিত্তি করে গ্রুপ তৈরি হয়। বাংলায় প্রতি, বা ইংরেজিতে per, by, for each-এর পরের শব্দটি এটি নির্দেশ করে। যেমন "প্রতি শাখায় মোট ইউনিট"-এ এটি হলো branch।
  • ভ্যালু (Value) — যে কলামটির সারসংক্ষেপ করা হচ্ছে: যা পরিমাপ করা হচ্ছে। এখানে সেটি হলো quantity।
  • এগ্রিগেশন (Aggregation) — একাধিক মান মিলে কীভাবে একটি মানে পরিণত হবে: মোট, গড়, কতগুলো, সর্বোচ্চ। এখানে সেটি হলো sum।

প্যাটার্নটি ভালোভাবে বোঝার জন্য আরও কয়েকটি উদাহরণ দেখা যাক:

  • "প্রতি ক্যাটাগরিতে আয়" — কি category, ভ্যালু revenue, এগ্রিগেশন sum
  • "প্রতি শাখায় গড় অর্ডারের আকার" — কি branch, ভ্যালু quantity, এগ্রিগেশন mean
  • "প্রতি দিনে কতগুলো অর্ডার" — কি date, কোনো ভ্যালু কলাম নেই (সারি গুনবেন), এগ্রিগেশন size
  • "প্রতি পণ্যের সবচেয়ে বড় একক অর্ডার" — কি product, ভ্যালু quantity, এগ্রিগেশন max

যদি এই তিনটি অংশ আলাদা করে বলতে না পারেন, তবে প্রশ্নটি এখনো কোড লেখার উপযোগী হয়নি। "কোন শাখাটি সেরা?" — এই প্রশ্নে কোনো ভ্যালু বা এগ্রিগেশন নেই। বিক্রির সংখ্যার দিক থেকে সেরা? আয়ের দিক থেকে সেরা? নাকি অর্ডারের সংখ্যার দিক থেকে সেরা? কোড লেখার আগেই তা স্পষ্ট করুন; কারণ তিনটি প্রশ্নের উত্তর তিনটি আলাদা শাখা হতে পারে।

২. গ্রুপিং করার আগে ইনপুট দেখে নিন

গ্রুপিং সারিগুলোকে আড়াল করে ফেলে। আটটি অর্ডার যখন তিনটি মোট সংখ্যায় রূপান্তরিত হয়ে যায়, তখন যোগফলের ভেতর কোনো ভাঙা বা ভুল সারি চোখে পড়ে না। তাই যাচাইয়ের কাজটি করতে হয় আগে:

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 — অর্থাৎ সংখ্যাবাচক, তাই sum টেক্সট জোড়া না লাগিয়ে গাণিতিক যোগ করবে। আর কি কলাম branch হলো str, যা কি হিসেবে ঠিক আছে।

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

python
print(orders["branch"].value_counts())
print(orders[["branch", "quantity"]].isna().sum())
text
branch
north    4
south    2
east     2
Name: count, dtype: int64
branch      0
quantity    0
dtype: int64

তিনটি আলাদা শাখা, এবং কোনো কলামেই কোনো মিসিং মান নেই। groupby প্রতিটি স্বতন্ত্র মানের জন্য হুবহু অক্ষরের মিল দেখে আলাদা গ্রুপ বানায় — ফলে "north" এবং "north " দুটি আলাদা গ্রুপ হয়ে যাবে। ভুল রিপোর্ট তৈরির আগেই value_counts() সেই সমস্যাটি চোখে আঙুল দিয়ে দেখিয়ে দেয়।

এই যাচাই বাদ দিলে কী ঘটতে পারে তা নিচে দেখুন। এখানে তিনটি সারিই আসলে north শাখাকে বোঝাচ্ছে — একটি ঠিকভাবে লেখা, একটির শেষে স্পেস আছে, এবং অন্যটির প্রথম অক্ষর বড় হাতের:

python
messy = pd.DataFrame({"branch": ["north", "north ", "North"], "quantity": [12, 2, 30]})
print(messy.groupby("branch")["quantity"].sum())

messy["branch"] = messy["branch"].str.strip().str.lower()
print(messy.groupby("branch")["quantity"].sum())
text
branch
North     30
north     12
north      2
Name: quantity, dtype: int64
branch
north    44
Name: quantity, dtype: int64

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

৩. আউটপুট কেমন হবে তা আগেই কল্পনা করুন

ফলাফল দেখতে কেমন হবে তা আগেই ঠিক করে নিন, যাতে কাঙ্ক্ষিত রূপ না পেলে সঙ্গে সঙ্গে ধরতে পারেন:

text
branch    units
east          ?
north         ?
south         ?

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

৪. সঠিক টুল বেছে নিন

  • প্রতি গ্রুপে একটি কলাম থেকে একটি সংখ্যা: df.groupby(key)[col].sum() (বা mean, max, ...)
  • একটি কলাম থেকে একাধিক সংখ্যা: df.groupby(key)[col].agg(["sum", "mean"])
  • একাধিক কলাম থেকে নিজের দেওয়া নামসহ একাধিক সংখ্যা: df.groupby(key).agg(name=(col, "sum"), ...)
  • প্রতিটি গ্রুপে কয়টি সারি রয়েছে: df.groupby(key).size()
  • যে কলামটি এখনো টেবিলে নেই (যেমন রাজস্ব/revenue): আগে কলামটি তৈরি করুন, তারপর গ্রুপিং করুন

এবার কোডে আসা যাক।


স্প্লিট, অ্যাপ্লাই, কম্বাইন (Split, apply, combine)

groupby ক্রমানুসারে তিনটি কাজ সম্পন্ন করে, এবং এই নামগুলো জানা দরকার কারণ গ্রুপিংয়ের প্রতিটি সমস্যা মূলত এই তিনটি ধাপেরই সমষ্টি:

  1. স্প্লিট (Split) — কি-এর স্বতন্ত্র মানের ভিত্তিতে সারিগুলোকে আলাদা আলাদা টুকরোয় বিভক্ত করা
  2. অ্যাপ্লাই (Apply) — প্রতিটি টুকরোর ওপর আলাদাভাবে হিসাবটি প্রয়োগ করা
  3. কম্বাইন (Combine) — ফলাফলগুলোকে কি-কে ইনডেক্স বানিয়ে একটি নতুন অবজেক্টে জোড়া লাগানো

স্প্লিট ধাপটি আপনি নিজে লুপ চালিয়েও দেখতে পারেন। একটি groupby অবজেক্টের ওপর লুপ চালালে প্রতি ধাপে একটি কি এবং টেবিলটির সংশ্লিষ্ট টুকরোটি পাওয়া যায়:

python
for branch, part in orders.groupby("branch"):
    print(branch, part.shape, list(part["order_id"]))
text
east (2, 7) [1004, 1007]
north (4, 7) [1001, 1003, 1005, 1008]
south (2, 7) [1002, 1006]

প্রতিটি টুকরো হলো সাতটি কলামসহ একটি সাধারণ DataFrame — north টুকরোয় চারটি সারি রয়েছে, বাকি দুটিতে দুটি করে। কাউকে হাত দিয়ে শাখার নাম টাইপ করতে হয়নি; পান্ডাস নিজে ডেটা থেকে তা খুঁজে নিয়েছে। আগামী সপ্তাহে যদি নতুন কোনো west শাখা থেকে অর্ডার আসে, তবে স্রেফ চতুর্থ আরেকটি টুকরো তৈরি হবে।

বাস্তব কোডে এভাবে নিজে লুপ চালানোর প্রয়োজন প্রায় কখনোই হয় না। এটি এখানে দেখানো হয়েছে যাতে ভেতরের মানসিক মডেলটি পরিষ্কার হয়: নিচের সবকিছুই মূলত "প্রতিটি টুকরোর ওপর কিছু কাজ করো, তারপর ফলাফলগুলোকে একসাথে জোড়া লাগাও"।

প্রতি গ্রুপে একটি সংখ্যা

"প্রতি শাখায় মোট ইউনিট" প্রশ্নটির তিনটি অংশ — কি branch, ভ্যালু quantity, এগ্রিগেশন sum — ঠিক সেই ক্রমানুসারেই কোডে লেখা হয়:

python
units = orders.groupby("branch")["quantity"].sum()
print(units)
text
branch
east     27
north    48
south     6
Name: quantity, dtype: int64

বাম থেকে ডানে পড়ুন: branch অনুযায়ী গ্রুপ করো, quantity কলামটি নাও, এবং যোগ করো। লুপের সেই একই সংখ্যাগুলো এবার এক লাইনে পাওয়া গেল, কোনো শাখার নাম হাতে টাইপ না করেই।

এবার আপনার আগের কল্পিত রূপরেখার সাথে মিলিয়ে নিন। তিনটি সারি — হ্যাঁ। বর্ণানুক্রম — হ্যাঁ। আর যোগফলের প্রমাণ:

python
print(units.sum(), orders["quantity"].sum())
text
81 81

গ্রুপগুলোর যোগফল পুরো টেবিলের যোগফলের সমান, সুতরাং কোনো সারি হারিয়ে যায়নি এবং কোনো সারি দুবার গণনা করা হয়নি। গ্রুপিং করার সময় এই এক লাইনের যাচাইটি অভ্যাসে পরিণত করা অত্যন্ত মূল্যবান: যখনই গ্রুপিং করবেন, ফলাফলের মোট যোগফল ইনপুটের মোট যোগফলের সাথে মিলিয়ে দেখুন।

ফলাফল একটি Series, আর কি (key) হলো তার ইনডেক্স

python
print(type(units))
print(units.index)
print(units["north"])
text
<class 'pandas.Series'>
Index(['east', 'north', 'south'], dtype='str', name='branch')
48

কম্বাইন ধাপটি শাখাগুলোকে কোনো কলামে না রেখে ইনডেক্সে বসিয়ে দিয়েছে। এই কারণেই আউটপুটে মানের উপরে একটি আলাদা লাইনে branch শব্দটি দেখা যায় — এটি ইনডেক্সের নাম। এর আরেকটি অর্থ হলো, আপনি শাখার নাম দিয়ে মান খুঁজে নিতে পারেন, যেমন units["north"], ঠিক যেভাবে চতুর্থ অধ্যায়ে .loc দিয়ে খুঁজেছিলেন।

যেহেতু এটি একটি সাধারণ Series, তাই পূর্ববর্তী অধ্যায়গুলোর সবকিছুই এতে কাজ করে। এটিকে সাজালে (sort) "কোন শাখা সবচেয়ে বেশি বিক্রি করেছে" প্রশ্নের উত্তর পাওয়া যায়:

python
print(units.sort_values(ascending=False))
print(units.idxmax())
text
branch
north    48
east     27
south     6
Name: quantity, dtype: int64
north

idxmax() সর্বোচ্চ মানের লেবেলটি ফেরত দেয় — সংখ্যাটি নয়, শাখার নামটি। "কে শীর্ষে আছে" জানতে চাইলে সাধারণত এটাই প্রয়োজন হয়।

কেন আগেই কলাম বেছে নেবেন

অনেকেরই ইচ্ছা হতে পারে ["quantity"] অংশটি বাদ দিয়ে পান্ডাসকে সব কলাম একসাথে যোগ করতে বলার:

python
print(orders.groupby("branch").sum())
text
order_id                                      date  ... quantity  price
branch                                                      ...                
east        2011                      2024-03-022024-03-04  ...       27  135.0
north       4017  2024-03-012024-03-022024-03-032024-03-04  ...       48  993.0
south       2008                      2024-03-012024-03-03  ...        6  910.0

[3 rows x 6 columns]

কোনো এরর আসেনি — অথচ এর প্রায় প্রতিটি সংখ্যাই অর্থহীন। order_id যোগ করে ফেলা হয়েছে, যেন অর্ডারের সিরিয়াল নম্বর কোনো আর্থিক পরিমাণ। date-এর স্ট্রিংগুলো একটার সাথে আরেকটা জোড়া লেগে গেছে, কারণ টেক্সটের ক্ষেত্রে "যোগ" মানে হলো কনক্যাটেনেশন। price কলামটিও যোগ করা হয়েছে: north-এর জন্য 993.0 হলো চারটি ভিন্ন পণ্যের একক দামের যোগফল, যা কোনো অর্থ প্রকাশ করে না।

orders.groupby("branch").sum() কোনো এরর দেয় না। এটি আইডি নম্বর যোগ করে, তারিখগুলোকে জোড়া লাগায় এবং একক দামগুলোকে মোট করে ফেলে — আর ফলাফলটি এমনভাবে ফিরিয়ে দেয় যেন এটি একটি সুন্দর রিপোর্ট।

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

mean মেথডটি আরও কঠোর; অর্থহীন ফলাফল দেওয়ার বদলে এটি কাজ করতে সরাসরি অস্বীকার করে:

python
print(orders.groupby("branch").mean())
text
TypeError: dtype 'str' does not support operation 'mean'

এই এররটি মূলত একই ভুলকে প্রকাশ করে দেয়। এটিকে এভাবে পড়ুন: আপনি টেক্সট কলামের গড় করতে বলেছেন। এর সমাধান numeric_only=True দেওয়া নয় — কারণ তা দিলেও order_id ও price-এর মতো কলামের অর্থহীন গড় হতো — বরং কোন কলামটির গড় চাইছেন তা স্পষ্টভাবে বলে দেওয়াই সঠিক সমাধান।

একসাথে একাধিক সংখ্যা: .agg()

একটিমাত্র সামারি অনেক সময়ই যথেষ্ট হয় না। "প্রতিটি শাখা কত বিক্রি করেছে, প্রতি অর্ডারে গড়ে কত এবং মোট কতগুলো অর্ডারে?" — এটি একই কলামের ওপর তিনটি এগ্রিগেশন। নামগুলোর একটি লিস্ট .agg()-এ পাস করুন:

python
print(orders.groupby("branch")["quantity"].agg(["sum", "mean", "count"]))
text
sum  mean  count
branch                  
east     27  13.5      2
north    48  12.0      4
south     6   3.0      2

এখন ফলাফলটি একটি DataFrame — প্রতি এগ্রিগেশনের জন্য একটি কলাম এবং প্রতি শাখার জন্য একটি সারি। এটি এমন একটি চিত্র তুলে ধরে যা কেবল মোট যোগফল আড়াল করেছিল: east শাখা north-এর অর্ধেকের কিছু বেশি পণ্য বিক্রি করেছে, কিন্তু এর গড় অর্ডার আকারে বড়। north শাখায় বেশি সংখ্যক অর্ডার আসে; আর east-এ কম সংখ্যক কিন্তু তুলনামূলক বড় অর্ডার আসে।

লিস্টের নামগুলো সাধারণ স্ট্রিং: "sum", "mean", "count", "min", "max", "median", "nunique" (অনন্য মানের সংখ্যা), "first", "last"। এগুলো হলো সেই একই মেথড যা আপনি আগে একক কলামে ব্যবহার করেছেন, যা এখন প্রতিটি টুকরোর ওপর প্রয়োগ করা হচ্ছে।

Named aggregation: আপনার কলাম, আপনার দেওয়া নাম

আসল ব্যবসায়িক রিপোর্টে ভিন্ন ভিন্ন কলামের ওপর ভিন্ন ভিন্ন হিসাব করতে হয়, এবং কলামের অর্থপূর্ণ নাম প্রয়োজন হয়। Named aggregation এই দুটি কাজই এক কলে করে দেয়। প্রতিটি কিওয়ার্ড আর্গুমেন্ট হলো একটি আউটপুট কলামের নাম, এবং তার মান হলো একটি জোড়া: (ইনপুট কলাম, এগ্রিগেশন)।

রিপোর্টে রাজস্ব (revenue) দেখানো খুব স্বাভাবিক একটি বিষয়, অথচ এটি টেবিলে কোনো কলাম হিসেবে নেই — এটি হলো প্রতিটি সারির quantity গুণ price। নতুন কলাম তৈরির জন্য পরবর্তী অধ্যায়ে বিস্তারিত আলোচনা রয়েছে; আপাতত এক লাইনের কোডই যথেষ্ট, যা আমাদের পরিকল্পনার ৪ নম্বর ধাপকে বাস্তবে দেখায়: যদি ভ্যালু কলামটি টেবিলে না থাকে, গ্রুপিং করার আগেই তা তৈরি করে নিন।

python
orders["revenue"] = orders["quantity"] * orders["price"]

summary = orders.groupby("branch").agg(
    orders=("order_id", "count"),
    units=("quantity", "sum"),
    revenue=("revenue", "sum"),
)
print(summary)
text
orders  units  revenue
branch                        
east         2     27   1140.0
north        4     48   2600.0
south        2      6   1150.0

এটি এমন একটি রিপোর্ট যা আপনি সরাসরি পাঠাতে পারেন। agg কলের প্রতিটি লাইন একটি বাক্যের মতো পড়া যায়: orders হলো order_id-এর count, units হলো quantity-র sum, revenue হলো revenue-র sum। আউটপুট কলামের নামগুলো আপনি নিজে বেছে নিয়েছেন, ডিফল্ট sum বা count নাম পায়নি — তাই পরে আলাদা করে নাম পরিবর্তনের প্রয়োজন নেই।

এবং এটি আগের উত্তরকে বদলে দেয়। বিক্রিত ইউনিটের দিক থেকে east পরিষ্কারভাবে দ্বিতীয় অবস্থানে ছিল; কিন্তু আয়ের দিক থেকে south সামান্য এগিয়ে গেছে — ৮৫০ মূল্যের একটি ব্যাগ বিশটি কলমের দামকে ছাড়িয়ে গেছে। এই কারণেই ১ নম্বর ধাপে ভ্যালুর নাম স্পষ্ট করার ওপর জোর দেওয়া হয়েছিল: বিক্রির দিক থেকে "সেরা শাখা" এবং আয়ের দিক থেকে "সেরা শাখা" দুটি ভিন্ন প্রশ্ন।

ফলাফলটি যেহেতু একটি DataFrame, তাই এটিকে সেভাবেই সাজানো যায়:

python
print(summary.sort_values("revenue", ascending=False))
text
orders  units  revenue
branch                        
north        4     48   2600.0
south        2      6   1150.0
east         2     27   1140.0

গণনা: size() বনাম count()

"প্রতি শাখায় কতগুলো অর্ডার" শুনলে মনে হয় একটিমাত্র প্রশ্ন। কিন্তু পান্ডাসে এর দুটি উত্তর রয়েছে, এবং ডেটায় কোনো মান মিসিং থাকলেই কেবল তারা ভিন্ন উত্তর দেয়।

  • size() প্রতিটি গ্রুপের মোট সারি গণনা করে, তাতে যে মানই থাকুক না কেন।
  • count() প্রতিটি গ্রুপের একটি নির্দিষ্ট কলামের উপস্থিত (নন-মিসিং) মানগুলো গণনা করে।

নিখুঁত টেবিলে দুটিই এক ফলাফল দেয়। এদের পার্থক্য দেখার জন্য orders-এর পাশে একটি ছোট টেবিল তৈরি করা যাক, যাতে একটি মিসিং branch এবং একটি মিসিং quantity রয়েছে:

python
gaps = pd.DataFrame(
    {
        "order_id": [1001, 1002, 1003, 1004, 1005],
        "branch": ["north", "south", None, "east", "north"],
        "quantity": [12, 5, 2, None, 30],
    }
)
print(gaps)
text
order_id branch  quantity
0      1001  north      12.0
1      1002  south       5.0
2      1003    NaN       2.0
3      1004   east       NaN
4      1005  north      30.0

(মিসিং মানের কারণে quantity ফ্লোটে রূপান্তরিত হয়েছে — ষষ্ঠ অধ্যায়ে এর কারণ ব্যাখ্যা করা হয়েছিল।)

python
print(gaps.groupby("branch").size())
print(gaps.groupby("branch")["quantity"].count())
text
branch
east     1
north    2
south    1
dtype: int64
branch
east     0
north    2
south    1
Name: quantity, dtype: int64

east শাখায় একটি অর্ডার আছে (size = 1), কিন্তু জানা quantity আছে শূন্যটি (count = 0)। দুটি কথাই সত্য; তারা ভিন্ন প্রশ্নের উত্তর দিচ্ছে। "কতগুলো অর্ডার এসেছে" জানতে size() ব্যবহার করুন, আর "কতগুলো অর্ডারে আসল সংখ্যা উল্লেখ আছে" জানতে count() ব্যবহার করুন।

মিসিং ডেটা যে দুটি কাজ নীরবে করে

উপরের আউটপুটগুলো আবার ভালো করে লক্ষ্য করুন। ১০০৩ নম্বর অর্ডারটি, যার কোনো branch ছিল না, সেটির দেখা কোনো ফলাফলেই নেই। পাঁচটি অর্ডার ইনপুট হিসেবে ঢুকেছিল; কিন্তু ফলাফল হিসেবে বের হলো চারটি:

python
print(gaps.groupby("branch").size().sum(), len(gaps))
text
4 5

ডিফল্টভাবে groupby যেসব সারির কি (key) মিসিং, সেগুলোকে বাদ দিয়ে দেয়। "unknown" নামে আলাদা কোনো গ্রুপ তৈরি হয় না, ফলে সেই সারিটি প্রতিটি ফলাফল থেকে অদৃশ্য হয়ে যায়। আগের সেই মোট যোগফল মেলানোর যাচাইটি অবিলম্বে এই সমস্যাটি ধরে ফেলে। যদি সেই সারিগুলো গুরুত্বপূর্ণ হয় — আর শাখা না থাকা সত্ত্বেও একটি অর্ডার তো আসলে একটি অর্ডারই — তবে dropna=False দিয়ে সেগুলো দাবি করুন:

python
print(gaps.groupby("branch", dropna=False)["quantity"].sum())
text
branch
east      0.0
north    42.0
south     5.0
NaN       2.0
Name: quantity, dtype: float64

এখন চারটি গ্রুপই উপস্থিত, এবং NaN গ্রুপে সেই ২ ইউনিট পণ্য সংরক্ষিত আছে যা কোনো নির্দিষ্ট শাখার সাথে মেলানো যায়নি।

দ্বিতীয় নীরব ঘটনাটি একই আউটপুটে দেখা যায়: east-এর মান দেখাচ্ছে 0.0। east শাখা শূন্য বিক্রি করেনি; তার একমাত্র বিক্রির পরিমাণটি ছিল অজানা। sum মেথড "যোগ করার মতো কিছু নেই"-কে শূন্য হিসেবে গণ্য করে। যদি চান অজানা মানটি অজানাই থাকুক, তবে min_count=1 দিয়ে তা স্পষ্ট করুন — অর্থাৎ "কেবলমাত্র তখনই যোগফল দেবে যদি অন্তত একটি বাস্তব মান থাকে":

python
print(gaps.groupby("branch", dropna=False)["quantity"].sum(min_count=1))
text
branch
east      NaN
north    42.0
south     5.0
NaN       2.0
Name: quantity, dtype: float64

কোনটি সঠিক তা নির্ভর করে আপনার প্রশ্নের ওপর, আর এটাই মূল বিষয়: এটি কোনো দুর্ঘটনা নয়, পরিকল্পনার ধাপেই এটি নির্ধারণ করুন। এই দুটি সমস্যারই মূল সমাধান অবশ্য আরও আগের ধাপে নিহিত — গ্রুপিং করার আগেই মিসিং মানগুলো পরিষ্কার করে নিন (অধ্যায় ছয়)।

দুটি কি (key): গ্রুপের ভেতর গ্রুপ

"প্রতি শাখা এবং প্রতি ক্যাটাগরিতে রাজস্ব" — এই প্রশ্নে দুটি কি রয়েছে। একটি লিস্ট পাস করুন:

python
by_both = orders.groupby(["branch", "category"])["revenue"].sum()
print(by_both)
text
branch  category   
east    accessories     840.0
        stationery      300.0
north   accessories    2180.0
        stationery      420.0
south   accessories     850.0
        stationery      300.0
Name: revenue, dtype: float64

ডেটাতে প্রকৃতপক্ষে বিদ্যমান প্রতিটি কম্বিনেশনের জন্য একটি করে সারি তৈরি হয়েছে — এখানে ছয়টি। ইনডেক্সে এখন দুটি স্তর রয়েছে, branch এবং category; পান্ডাস একে বলে MultiIndex, এবং সহজে পড়ার সুবিধার্থে বাইরের স্তরটিকে প্রতি ব্লকে কেবল একবার প্রিন্ট করে। east-এর নিচের খালি জায়গাটির অর্থ হলো "এখনও east"।

স্বাভাবিক প্রত্যাশা অনুযায়ীই আপনি এখান থেকে ডেটা নির্বাচন করতে পারেন:

python
print(by_both.loc["north"])
print(by_both.loc[("north", "stationery")])
text
category
accessories    2180.0
stationery      420.0
Name: revenue, dtype: float64
420.0

একটি লেবেল দিলে north-এর পুরো অংশটি পাওয়া যায়; আর উভয় লেবেল নিয়ে একটি টাপল দিলে নির্দিষ্ট একটি একক সংখ্যা পাওয়া যায়।

সাধারণ কলাম ফিরিয়ে আনা

MultiIndex দেখতে সংক্ষিপ্ত হলেও অনেক পরবর্তী কাজের জন্য — যেমন CSV-তে সেভ করা, মাস্ক দিয়ে ফিল্টার করা, কিংবা অন্য কোনো টেবিলের সাথে যুক্ত করা — সাধারণ কলাম হিসেবে থাকাটাই বেশি সুবিধাজনক। এটি করার দুটি উপায় রয়েছে, এবং দুটিই একই টেবিল তৈরি করে:

python
flat = orders.groupby(["branch", "category"], as_index=False)["revenue"].sum()
print(flat)
print(flat.equals(by_both.reset_index()))
text
branch     category  revenue
0   east  accessories    840.0
1   east   stationery    300.0
2  north  accessories   2180.0
3  north   stationery    420.0
4  south  accessories    850.0
5  south   stationery    300.0
True

as_index=False groupby-কে শুরুতেই কি-গুলোকে ইনডেক্সে রূপান্তর না করার নির্দেশ দেয়; আর reset_index() গ্রুপিং শেষে সেগুলোকে আবার সাধারণ কলামে ফিরিয়ে আনে। আপনার কোডের যে জায়গায় যেটি পড়তে বেশি সহজ মনে হয়, সেটিই ব্যবহার করুন।

মানুষের পড়ার উপযোগী রূপ: unstack()

মানুষের পড়ার জন্য একটি দীর্ঘ তালিকার চেয়ে গ্রিড বা টেবিল কাঠামো অনেক বেশি সুবিধাজনক — যেমন বাম পাশে শাখা এবং ওপরের সারিতে ক্যাটাগরি:

python
print(by_both.unstack())
text
category  accessories  stationery
branch                           
east            840.0       300.0
north          2180.0       420.0
south           850.0       300.0

unstack() ভেতরের ইনডেক্স স্তর category-কে কলামে রূপান্তর করে দেয়। ভেতরের সংখ্যাগুলো আগের সেই ছয়টি সংখ্যাই। কোনো শাখা যদি কোনো ক্যাটাগরির পণ্য একেবারেই বিক্রি না করত, তবে সেই ঘরটিতে NaN আসত; unstack(fill_value=0) লিখলে তার বদলে সেখানে 0 বসে যাবে।

গড়ের গড় (average of averages) করার ফাঁদ

একটি সহজ মনে হওয়া প্রশ্ন: প্রতি শাখায় গড়ে একটি পণ্যের দাম কত ছিল? অনেকেই চট করে price কলামটির গড় করে ফেলেন:

python
print(orders.groupby("branch")["price"].mean())
text
branch
east      67.50
north    248.25
south    455.00
Name: price, dtype: float64

এটি হলো অর্ডার লাইনের দামগুলোর গড়, এবং প্রতিটি লাইন এখানে সমান গুরুত্ব পাচ্ছে — south শাখার একটিমাত্র ব্যাগের অর্ডার (১টি ইউনিট ৮৫০ টাকায়) তার খাতার অর্ডারের (৫টি ইউনিট ৬০ টাকায়) সমান গুরুত্ব বহন করছে। কিন্তু ক্রেতারা প্রতিটি লাইনের একটি করে পণ্য কেনেননি। তারা কিনেছেন একাধিক ইউনিট। বিক্রিত ইউনিট প্রতি গড় দাম বের করতে হলে মোট রাজস্বকে মোট ইউনিট দিয়ে ভাগ করতে হবে, এবং তা অবশ্যই যোগফল থেকে হিসাব করতে হবে:

python
per_unit = orders.groupby("branch").agg(
    units=("quantity", "sum"),
    revenue=("revenue", "sum"),
)
per_unit["avg_unit_price"] = (per_unit["revenue"] / per_unit["units"]).round(2)
print(per_unit)
text
units  revenue  avg_unit_price
branch                                
east       27   1140.0           42.22
north      48   2600.0           54.17
south       6   1150.0          191.67

south-এর জন্য হিসাবটি ৪৫৫ থেকে নেমে প্রায় ১৯২-এ চলে এসেছে। পাটিগণিতের দিক থেকে কোনো সংখ্যাই "ভুল" নয়; কিন্তু কেবল একটি সংখ্যাই প্রশ্নের আসল উত্তর দেয়। নিয়মটি মনে রাখুন: যখন আপনার কাঙ্ক্ষিত উত্তরটি একটি অনুপাত বা রেশিও, তখন লব ও হরকে আলাদাভাবে এগ্রিগেট করুন, তারপর ভাগ করুন। যে সংখ্যাটি নিজেই একটি গড়, কিংবা যে দামটি আগেই ইউনিট-প্রতি দাম, তার ওপর আবার গড় করলে প্রতিটি সারি সমান গুরুত্ব পায় — তা একটি ইউনিট হোক কিংবা ত্রিশটি।


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

branch_report.py দোকানের মালিকের প্রশ্নের একটি পূর্ণাঙ্গ ও নির্ভরযোগ্য উত্তর তৈরি করে — প্রতি শাখায়: অর্ডারের সংখ্যা, মোট ইউনিট, মোট রাজস্ব, মোট রাজস্বে শাখার শতকরা হার এবং সর্বাধিক বিক্রিত পণ্য — এবং সবশেষে নিজের মোট যোগফল যাচাই করে নেয়।

python
import pandas as pd

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

# 1. Check the input before grouping hides anything.
print("Rows, columns:", orders.shape)
print("Branches:", sorted(orders["branch"].unique()))
print("Missing in key/value:", int(orders[["branch", "quantity", "price"]].isna().sum().sum()))
print()

# 2. Create the value column that does not exist yet.
orders["revenue"] = orders["quantity"] * orders["price"]

# 3. One grouped call, one row per branch.
report = orders.groupby("branch").agg(
    orders=("order_id", "count"),
    units=("quantity", "sum"),
    revenue=("revenue", "sum"),
)
report["share_pct"] = (report["revenue"] / report["revenue"].sum() * 100).round(1)

# 4. Best product per branch: group by both keys, then keep each branch's top row.
by_product = orders.groupby(["branch", "product"], as_index=False)["quantity"].sum()
top = by_product.sort_values("quantity", ascending=False).drop_duplicates("branch")
report["top_product"] = top.set_index("branch")["product"]

report = report.sort_values("revenue", ascending=False)
print(report)
print()

# 5. Prove nothing was lost or double-counted.
print("Revenue check:", report["revenue"].sum(), "==", orders["revenue"].sum())
print("Orders check :", report["orders"].sum(), "==", len(orders))
text
Rows, columns: (8, 7)
Branches: ['east', 'north', 'south']
Missing in key/value: 0

        orders  units  revenue  share_pct top_product
branch                                               
north        4     48   2600.0       53.2      eraser
south        2      6   1150.0       23.5    notebook
east         2     27   1140.0       23.3         pen

Revenue check: 4890.0 == 4890.0
Orders check : 8 == 8

প্রোগ্রামটি কেন এভাবে লেখা হয়েছে:

  • যাচাইগুলো সবার আগে করা হয়েছে এবং প্রিন্ট করা হয়েছে। তিনটি স্বতন্ত্র শাখা এবং শূন্য মিসিং মান হলো সেই শর্ত যার ওপর এই রিপোর্টের নির্ভরযোগ্যতা দাঁড়িয়ে আছে। পরের সপ্তাহের ফাইলে যদি চারটি শাখা বা দুটি মিসিং মান থাকে, কোনো যোগফল পড়ার আগেই আপনি সবার ওপরে তা দেখতে পাবেন।
  • গ্রুপিংয়ের আগেই revenue কলাম তৈরি করা হয়েছে। প্রতি সারিতে quantity * price হিসাব করে তারপর গ্রুপিং করলে আসল রাজস্ব পাওয়া যায়। পরে যোগ করা পরিমাণকে গড় দাম দিয়ে গুণ করতে গেলে আবার সেই গড়ের গড়ের ফাঁদে পড়তে হতো।
  • একটি agg কল, সুনির্দিষ্ট কলামের নাম। প্রতিটি আউটপুট কলাম একবার ঘোষণা করা হয়েছে, ঠিক সেই নাম দিয়ে যা পাঠক দেখতে পাবেন। চতুর্থ আরেকটি মেট্রিক যোগ করতে হলে স্রেফ আরেকটি লাইন লিখতে হবে।
  • share_pct ইনপুটের বদলে ফলাফলের ওপর হিসাব করা হয়েছে। গ্রুপিংয়ের পর report হলো শাখা দিয়ে ইনডেক্স করা একটি সাধারণ DataFrame, তাই এতে সাধারণ কলামের গাণিতিক হিসাব চলে। এর শতকরা হার যোগ করলে ঠিক ১০০.০ হয়।
  • শীর্ষ পণ্য নির্ধারণে দুই-কি গ্রুপিং এবং সাজানো ব্যবহার করা হয়েছে। groupby(["branch", "product"]) প্রতিটি শাখা-পণ্য জোড়ার বিক্রি দেয়; quantity-র অবরোহী ক্রমে সাজিয়ে প্রতি শাখার প্রথম সারিটি রাখলে (drop_duplicates("branch")) প্রতিটি শাখার সেরা বিক্রেতা পণ্যটি থেকে যায়। top.set_index("branch")["product"] অ্যাসাইন করার ফলে পণ্যের নামগুলো অবস্থানের বদলে শাখার লেবেল ধরে মিলে যায় — ইনডেক্স নিজেই মেলানোর দায়িত্ব নেয়।
  • শেষ দুটি লাইন হলো সঠিকতার প্রমাণ। গ্রুপগুলোর মোট রাজস্ব ও অর্ডারের সংখ্যা মূল ইনপুটের সমান। কোনো শাখা হারিয়ে গেলে বা ফাইলে ফাঁকা শাখা থাকলে সংখ্যা মিলত না, এবং রিপোর্টটি পাঠানোর আগেই আপনি তা জেনে যেতেন।

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

KeyError: 'Branch' কি কলামটির বানান মূল ফাইলের সাথে মেলেনি — এখানে বড় হাতের B লেখা হয়েছে। কলামের নামগুলো কেস-সংবেদনশীল, এবং groupby সাথে সাথে তা পরীক্ষা করে। list(orders.columns) প্রিন্ট করে হুবহু নাম কপি করুন; লিস্টের কোটেশন চিহ্নগুলো 'branch '-এর মতো অনাকাঙ্ক্ষিত ফাঁকা স্পেসও ধরিয়ে দেয়।

KeyError: 'Column not found: Quantity' এক ধাপ পরের একই ভুল: কি কলামটি ঠিক ছিল, কিন্তু groupby("branch")["Quantity"]-এ ভ্যালু কলামটির অস্তিত্ব নেই। ত্রুটির বার্তায় কেবল নামের বদলে "Column not found" বলা হয়, যা বুঝিয়ে দেয় যে groupby-এর পরবর্তী সিলেকশনে সমস্যা হয়েছে।

KeyError: "Label(s) ['qty'] do not exist" Named aggregation-এ প্রতিটি জোড়ার প্রথম উপাদানটি অবশ্যই বাস্তব কলাম হতে হবে: units=("qty", "sum") ব্যর্থ হবে কারণ qty নামে কোনো কলাম নেই। বাম পাশের আউটপুটের নাম (units) যা খুশি হতে পারে; কিন্তু ডান পাশের ইনপুটটি টেবিলে থাকতে হবে।

ValueError: Cannot subset columns with a tuple with more than one element. Use a list instead. আপনি লিখেছেন orders.groupby("branch")["quantity", "price"]। এক জোড়া বন্ধনীর ভেতর কমা দিয়ে দুটি নাম লিখলে তা একটি টাপল তৈরি করে। একাধিক কলাম নির্বাচন করতে লিস্ট প্রয়োজন — অর্থাৎ ডাবল বন্ধনী, ঠিক চতুর্থ অধ্যায়ের মতো: orders.groupby("branch")[["quantity", "price"]].sum()।

TypeError: dtype 'str' does not support operation 'mean' কোনো টেক্সট কলামের ওপর এগ্রিগেশন চালানো হয়েছে — হয় কোনো কলাম বেছে নেওয়া হয়নি (groupby("branch").mean()), নয়তো আপনি যে কলামটি বেছে নিয়েছেন তা টেক্সট হিসেবে লোড হয়েছে। দ্বিতীয় ক্ষেত্রে info() চালালে দেখা যাবে যেখানে সংখ্যা প্রত্যাশিত সেখানে str রয়েছে; ষষ্ঠ অধ্যায়ের pd.to_numeric(..., errors="coerce") দিয়ে এটি ঠিক করা যায়।

ফলাফল পড়ার সময় KeyError: 'quantity' units = orders.groupby("branch")["quantity"].sum() চালানোর পর শাখাগুলো হলো units-এর ইনডেক্স, আর quantity কেবল তার নাম। units["quantity"] লিখলে কোড quantity নামের একটি শাখা খোঁজে। নির্দিষ্ট শাখা দেখতে units["north"] লিখুন, অথবা মোট মানের জন্য units.sum() ব্যবহার করুন।