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

Groupby আর aggregation — split, apply, combine

pandas Groupby and Aggregation — Split-Apply-Combine, Named Aggregation, transform, filter, apply and Simpson's Paradox

Taught in Bangla, this lesson teaches pandas groupby through split-apply-combine: count versus size, named aggregation, as_index, transform, filter and the real cost of apply measured in a local run. It also covers grouping by month, bins and categoricals, top-n per group, pivot tables, the SQL GROUP BY mapping, and Simpson's paradox with a courier example.

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

  • Split-apply-combine ধারণা, আর এক বা একাধিক key-তে groupby
  • sum, mean, count, size, nunique, তালিকা আর named aggregation দিয়ে agg, as_index=False আর reset_index
  • transform দিয়ে দলের ফল প্রতিটি সারিতে, filter দিয়ে পুরো দল বাছাই, আর apply কখন এড়াবেন (সময় মেপে)
  • মাস, দামের পরিসর (pd.cut) আর categorical key দিয়ে গ্রুপ, observed, প্রতি দলের সেরা n, pivot table আর SQL-এর সঙ্গে মিল
  • Simpson's paradox চিনে ফেলা, আর একই মিশ্রণে তুলনা করে বিভ্রান্তি এড়ানো

আগের পর্বগুলোতে (পর্ব ২০-এ Series, DataFrame আর index; পর্ব ২১-এ ফাইল পড়া; পর্ব ২২-এ বাছাই আর filter; পর্ব ২৩-এ ডেটা পরিষ্কার) বাজারঘরের (এই কোর্সের কাল্পনিক অনলাইন দোকান) অর্ডারের টেবিল হাতে এসেছে আর পরিষ্কার হয়েছে। এবার ব্যবসার আসল প্রশ্নগুলো: কোন জেলায় বিক্রি সবচেয়ে বেশি? প্রতিটি জেলায় কতজন আলাদা ক্রেতা? কোন অর্ডারটি নিজের জেলার গড়ের চেয়ে অনেক বড়? এসব প্রশ্নের উত্তর একটি সাধারণ ছাঁচে আসে: দলে ভাগ, প্রতিটি দলে হিসাব, তারপর ফলগুলো জোড়া। পর্ব ৯-এ এই কাজ খাঁটি Python-এ defaultdict দিয়ে করেছিলেন, আর পর্ব ১৬-এ NumPy-র axis দিয়ে পুরো টেবিলের সারাংশ নিয়েছিলেন। groupby এই দুটিকে মেলায়: দল ঠিক হয় একটি কলামের মান দিয়ে, আর হিসাব চলে compiled কোডে। সব চালানো যায় এমন ব্লক পাতাতেই চলে (ব্রাউজারের Python, pandas 3.0.2); প্রথমবার pandas নামতে কয়েক সেকেন্ড লাগতে পারে।

১ · Split-apply-combine — দলে ভাগ, হিসাব, জোড়া

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

নামটি জনপ্রিয় করেন পরিসংখ্যানবিদ Hadley Wickham, ২০১১ সালে Journal of Statistical Software-এ প্রকাশিত “The Split-Apply-Combine Strategy for Data Analysis” প্রবন্ধে, R-এর plyr package-এর সঙ্গে। ধারণাটি নতুন নয় (SQL-এর GROUP BY আর spreadsheet-এর pivot table অনেক আগের), কিন্তু Wickham তিনটি ধাপকে আলাদা নাম দিয়ে দেখান যে অনেক আলাদা চেহারার কাজ আসলে একই ছাঁচ। pandas-এর অফিসিয়াল docs-এর groupby অধ্যায়ের শিরোনামেও “split-apply-combine” শব্দগুচ্ছটি আছে, যদিও সেখানে Wickham-এর নাম নেই। pandas-এ এক লাইনে:

df.groupby("district")["amount"].sum()
#  └─ split ─┘  └ কোন কলাম ┘  └ apply ┘   combine হয় নিজে থেকে

এখানে district হলো groupby-এর key: যে কলামের মান দেখে ঠিক হয় কোন সারি কোন দলে যাবে। নিচের stepper-এ ছয়টি অর্ডারের একটি ছোট টেবিলে তিনটি ধাপ আলাদা করে দেখুন (দাম বোঝার সুবিধার জন্য টাকায়)।

Split-apply-combine: ছয় সারি থেকে তিন সারি id district ৳ 1ঢাকা500 2খুলনা300 3ঢাকা700 4সিলেট200 5খুলনা400 6ঢাকা300 ঢাকা #1 · 500#3 · 700#6 · 300 খুলনা #2 · 300#5 · 400 সিলেট #4 · 200 ∑ 1500 ∑ 700 ∑ 200 district sum খুলনা700 ঢাকা1500 সিলেট200
  1. শুরুর টেবিল। ছয়টি অর্ডার, প্রতিটির একটি id, একটি district আর দাম (টাকায়)। প্রশ্ন: প্রতিটি জেলার মোট বিক্রি কত?
  2. Split. pandas district কলামের মান দেখে সারিগুলোকে তিনটি দলে ভাগ করে: ঢাকা (#1, #3, #6), খুলনা (#2, #5), সিলেট (#4). সারিগুলো টেবিলের যেখানেই থাকুক, একই মান মানে একই দল। এই ধাপে কোনো হিসাব হয়নি।
  3. Apply. প্রতিটি দলে আলাদাভাবে sum(): ঢাকা 500 + 700 + 300 = 1500, খুলনা 300 + 400 = 700, সিলেট 200. প্রতিটি দল নিজের সারি ছাড়া আর কিছু দেখে না।
  4. Combine. তিনটি ফল একটি নতুন Series-এ জোড়া লাগে, যার index হলো দলের key (district). default-এ key সাজানো থাকে (sort=True), তাই খুলনা, ঢাকা, সিলেট ক্রমে আসে (বাংলা অক্ষরের Unicode ক্রম)।
  5. এক লাইনে। তিনটি ধাপই এক লাইনের কোড। ছয় সারি থেকে তিন সারি হলো, কিন্তু মোট বিক্রি 1500 + 700 + 200 = 2400, মূল ছয়টি দামের যোগফলের সমান। এই যাচাই সবসময় করে নিন: দলের মোটগুলোর যোগ পুরো টেবিলের মোটের সমান হওয়া উচিত।

বোর্ড পরীক্ষার ফলের কথা ভাবুন। সারা দেশের লাখো পরীক্ষার্থীর নম্বর একটি তালিকায়। শিক্ষা বোর্ড জানতে চায় প্রতিটি জেলার পাসের হার। কেউ প্রতিটি জেলার জন্য আলাদা খাতা খোলেন না; নিয়ম একটি (“জেলা ধরে ভাগ, প্রতিটি ভাগে পাসের হার”), প্রয়োগ সব জেলায়। groupby ঠিক সেই নিয়ম লেখার ভাষা।

একটি গুরুত্বপূর্ণ খুঁটিনাটি: df.groupby("district") লিখলে সঙ্গে সঙ্গে কোনো হিসাব হয় না। ফল একটি DataFrameGroupBy object, যা শুধু মনে রাখে কোন key-তে ভাগ হবে। হিসাব হয় তখনই, যখন আপনি তার ওপর sum, agg বা transform ডাকেন। তাই একই GroupBy object একটি নামে রেখে বারবার ব্যবহার করা যায়।

২ · প্রথম হিসাবগুলো — sum, mean, count, size আর nunique

প্রতিটি দলে একটি সংখ্যা ফেরত দেয় এমন হিসাবকে বলে aggregation, যা পর্ব ১৬-এ NumPy-তে দেখেছেন। groupby-তে সবচেয়ে বেশি লাগে এগুলো:

methodপ্রতিটি দলে কী দেয়NaN-এর সঙ্গে আচরণ
sum(), mean(), median()যোগফল, গড়, মাঝের মানNaN বাদ দিয়ে হিসাব (skipna); সব ঘর NaN হলে sum দেয় 0, mean দেয় NaN
min(), max(), std()সবচেয়ে ছোট, বড়, ছড়ানোNaN বাদ; std-এর default ddof=1 (NumPy-র 0)
size()দলের সারির সংখ্যাNaN-সহ সব সারি গোনে; কলাম লাগে না
count()প্রতিটি কলামের ভরা ঘরের সংখ্যাNaN গোনে না
nunique()কতগুলো আলাদা মানdefault-এ NaN-কে মান ধরে না
first(), last()দলের প্রথম/শেষ ভরা মানNaN এড়িয়ে প্রথম ভরা মান

count আর size-এর পার্থক্য সাক্ষাৎকারের প্রিয় প্রশ্ন, আর বাস্তবে ভুলের বড় উৎস। বাজারঘরের অর্ডারের টেবিলে delivery_days কলামে দুটি ঘর ফাঁকা (অর্ডার ৫ আর ৮ এখনো পৌঁছায়নি), আর payment-এ একটি (অর্ডার ৮)। “প্রতিটি জেলায় কয়টি অর্ডার?” প্রশ্নের উত্তর size; “কয়টি অর্ডারের ডেলিভারির সময় জানা আছে?” প্রশ্নের উত্তর count. নিচের কোডে দুটি পাশাপাশি, সঙ্গে আরও দুটি ফাঁদ: key-তে NaN থাকলে কী হয়, আর nunique বনাম count.

import pandas as pd
pd.set_option("display.width", 60)
pd.set_option("display.precision", 2)

df = pd.read_csv("orders.csv")
print("ফাঁকা ঘর:", df[["delivery_days", "payment"]].isna().sum().tolist())

g = df.groupby("district")
side = pd.DataFrame({
    "size": g.size(),                        # সারি গোনে, NaN-সহ
    "count": g["delivery_days"].count(),     # শুধু ভরা ঘর গোনে
    "mean_days": g["delivery_days"].mean(),  # NaN বাদ দিয়ে গড়
})
print(side)

print(g.count().loc["ঢাকা"].tolist())   # প্রতিটি কলামের আলাদা count

print(df.groupby("payment").size().to_dict())
print(df.groupby("payment", dropna=False).size().to_dict())

print(g["customer_id"].nunique().to_dict())
print(g["customer_id"].count().to_dict())

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

ফাঁকা ঘর: [2, 1]
           size  count  mean_days
district
খুলনা         2      2       4.50
চট্টগ্রাম     3      3       2.67
ঢাকা          5      4       1.50
সিলেট         2      1       3.00
[5, 5, 5, 5, 5, 4, 4, 5]
{'COD': 4, 'bKash': 4, 'card': 3}
{'COD': 4, 'bKash': 4, 'card': 3, nan: 1}
{'খুলনা': 1, 'চট্টগ্রাম': 2, 'ঢাকা': 3, 'সিলেট': 1}
{'খুলনা': 2, 'চট্টগ্রাম': 3, 'ঢাকা': 5, 'সিলেট': 2}

ঢাকায় ৫টি অর্ডার (size), কিন্তু ডেলিভারির দিন জানা ৪টির (count); সিলেটে ২টির মধ্যে ১টির। গড় দিন হাতে মেলান: ঢাকার জানা দিনগুলো 2, 1, 2, 1, যোগ 6, ভাগ 4 = 1.5. গড়টি ৫ দিয়ে নয়, ৪ দিয়ে ভাগ, মানে না-পৌঁছানো অর্ডারটি হিসাবেই নেই। পরের লাইনে g.count() পুরো DataFrame-এ ডাকলে প্রতিটি কলামের আলাদা count আসে: ঢাকার delivery_days আর payment-এ 4, বাকিগুলোতে 5. তাই “কয়টি অর্ডার” জানতে count() ডাকলে কোন কলামের count নিচ্ছেন, তা না দেখলে ভুল সংখ্যা পাবেন।

এরপর একটি চুপচাপ বাদ পড়া: payment ধরে গ্রুপ করলে ৪ + ৪ + ৩ = ১১টি অর্ডার, ১২টি নয়। key-এর ঘর NaN হলে pandas সেই সারি default-এ বাদ দেয় (dropna=True); dropna=False দিলে NaN একটি আলাদা দল হয়ে ফিরে আসে। শেষ দুই লাইনে ঢাকার ৫টি অর্ডার (count) এসেছে মাত্র ৩ জন আলাদা ক্রেতার কাছ থেকে (nunique: C01, C02, C03).

ফাঁকা ঘর বাদ দেওয়াও একটি সিদ্ধান্ত। ওপরের গড় ডেলিভারির দিনে না-পৌঁছানো অর্ডারগুলো নেই। যে অর্ডারগুলো সবচেয়ে বেশি সময় নিচ্ছে, সেগুলোই হয়তো এখনো পৌঁছায়নি; তাদের বাদ দিলে গড় আসলের চেয়ে ভালো দেখায়। রিপোর্টে গড়ের পাশে সবসময় count আর size দুটিই দিন, যাতে পাঠক জানেন গড়টি কতগুলো সারির ওপর দাঁড়িয়ে।

একাধিক key

key একটি তালিকাও হতে পারে: df.groupby(["district", "category"]) প্রতিটি (জেলা, পণ্যের ধরন) জোড়াকে একটি দল ধরে। ফলের index তখন দুই স্তরের, যার নাম MultiIndex: প্রথম স্তরে জেলা, দ্বিতীয় স্তরে category. যে জোড়া ডেটায় নেই (যেমন সিলেট আর ইলেকট্রনিক্স), সেটি ফলেও নেই। পরের অংশের কোডে এর আউটপুট দেখবেন, আর পর্ব ২৫-এ unstack দিয়ে এমন ফলকে চওড়া টেবিলে রূপ দেওয়া হবে।

৩ · agg — অনেক হিসাব একসঙ্গে, নিজের দেওয়া নামে

একটি রিপোর্টে প্রায়ই একসঙ্গে কয়েকটি সংখ্যা লাগে: অর্ডারের সংখ্যা, মোট বিক্রি, গড় পরিমাণ, আলাদা ক্রেতা। প্রতিটির জন্য আলাদা groupby লিখে জোড়া দেওয়ার বদলে agg (aggregate) একবারে সব করে। এর তিনটি রূপ:

  • তালিকা: g["amount"].agg(["min", "max", "mean"]) একটি কলামে তিনটি হিসাব, ফলের কলামের নাম হয় function-এর নাম।
  • dict: g.agg({"amount": "sum", "qty": "mean"}) কোন কলামে কোন হিসাব।
  • Named aggregation: g.agg(total=("amount", "sum")). বাঁয়ে ফলের কলামের নাম, ডানে (কোন কলাম, কোন হিসাব) জোড়া। pandas 0.25 (২০১৯) থেকে আছে, আর রিপোর্টের জন্য সবচেয়ে পরিষ্কার রূপ, কারণ ফলের কলামের নাম আপনিই ঠিক করেন।

নিচের কোডে বাজারঘরের জেলাভিত্তিক রিপোর্ট। টাকা পয়সায় পূর্ণসংখ্যা হিসেবে রাখা (পর্ব ৯), শুধু ছাপার সময় টাকায়।

import pandas as pd
pd.set_option("display.width", 60)
pd.set_option("display.precision", 2)

df = pd.read_csv("orders.csv")
df["amount"] = df["qty"] * df["price_paisa"]      # পয়সায়, পূর্ণসংখ্যা
print(df[["district", "qty", "amount"]].head(3))

g = df.groupby("district")
print(type(g).__name__, "| গ্রুপ:", g.ngroups)
print(g["amount"].sum())

summary = g.agg(
    orders=("order_id", "count"),
    total=("amount", "sum"),
    avg_qty=("qty", "mean"),
    customers=("customer_id", "nunique"),
).sort_values("total", ascending=False)
summary["total_tk"] = summary["total"] // 100     # শুধু ছাপার জন্য টাকা
print(summary)

flat = df.groupby("district", as_index=False).agg(total=("amount", "sum"))
print(flat.columns.tolist(), flat.shape)
print(df.groupby(["district", "category"])["amount"].sum().head(4))
print(df.groupby("district")["amount"].agg(["min", "max"]).loc["ঢাকা"].tolist())

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

    district  qty  amount
0       ঢাকা    1  120000
1  চট্টগ্রাম    2   70000
2       ঢাকা    1  450000
DataFrameGroupBy | গ্রুপ: 4
district
খুলনা         460000
চট্টগ্রাম     265000
ঢাকা         1090000
সিলেট         160000
Name: amount, dtype: int64
           orders    total  avg_qty  customers  total_tk
district
ঢাকা            5  1090000     1.60          3     10900
খুলনা           2   460000     1.50          1      4600
চট্টগ্রাম       3   265000     1.33          2      2650
সিলেট           2   160000     1.50          1      1600
['district', 'total'] (4, 2)
district   category
খুলনা      ইলেকট্রনিক্স    300000
           পোশাক           160000
চট্টগ্রাম  পোশাক           150000
           বই              115000
Name: amount, dtype: int64
[90000, 450000]

হাতে মেলান: ঢাকার ৫টি অর্ডারের মূল্য 120000 + 450000 + 90000 + 180000 + 250000 = 1090000 পয়সা, মানে ৳10,900; চারটি জেলার মোট 1090000 + 460000 + 265000 + 160000 = 1975000, যা পুরো টেবিলের মোট। ঢাকার পরিমাণ 1, 1, 3, 2, 1, গড় 8 ÷ 5 = 1.6. প্রথম sum()-এর ফলে জেলাগুলো key-এর ক্রমে সাজানো; sort_values("total") দেওয়ার পর বিক্রির ক্রমে। Named aggregation-এর কলামগুলো ঠিক যে নাম দিয়েছেন সেই নামে এসেছে। দুই key-এর ফলে খুলনার নিচে দুটি category, চট্টগ্রামের বই 70000 + 45000 = 115000. শেষ লাইনে ঢাকার সবচেয়ে ছোট অর্ডার 90000 (৩টি বই, প্রতিটি ৳300), সবচেয়ে বড় 450000.

key কি index হবে, নাকি সাধারণ কলাম? — as_index আর reset_index

default-এ groupby-এর key ফলের index হয়ে যায় (ওপরের summary-তে district বাঁ দিকের index, কলাম নয়)। এটি .loc["ঢাকা"] দিয়ে খোঁজার জন্য সুবিধাজনক। কিন্তু ফলটি CSV-তে লিখতে, আরেকটি টেবিলের সঙ্গে জুড়তে, বা chart-এ পাঠাতে প্রায়ই key-কে সাধারণ কলাম হিসেবে চাই। দুটি উপায়:

  • df.groupby("district", as_index=False): শুরু থেকেই key কলাম হিসেবে থাকে; ওপরে flat-এর কলাম ['district', 'total'], shape (4, 2), index সাধারণ 0, 1, 2, 3.
  • summary.reset_index(): হিসাবের পরে index-কে কলামে নামিয়ে আনে। একাধিক key-এর MultiIndex থাকলে সব স্তরই কলাম হয়।

দুটির ফল একই; কোনটি পড়তে সহজ, সেটিই বেছে নিন। একটি খুঁটিনাটি: as_index=False দিয়ে size() ডাকলে ফল একটি DataFrame, যার গণনার কলামের নাম size; আর as_index=True-তে Series.

পুরোনো একটি রূপ আর নেই। অনেক পুরোনো টিউটোরিয়াল (আর সেখান থেকে শেখা AI সহকারী) নাম বদলাতে নেস্টেড dict লেখে: g.agg({"amount": {"total": "sum"}}). এই রূপটি pandas 0.20-এ deprecated হয়ে 1.0-তে সরানো হয়েছে, আর এখন SpecificationError দেয়। নাম চাইলে named aggregation লিখুন।

৪ · transform — দলের ফল প্রতিটি সারিতে ফেরত

agg-এর ফল ছোট: চারটি জেলায় চারটি সারি। কিন্তু অনেক প্রশ্নের উত্তর প্রতিটি অর্ডারের জন্য আলাদা চাই: “এই অর্ডারটি নিজের জেলার মোট বিক্রির কত ভাগ?” বা “এই অর্ডারটি জেলার গড় অর্ডারের চেয়ে কত বড়?” এখানে দলের ফলটিকে দলের প্রতিটি সারির পাশে বসাতে হয়। transform ঠিক এটি করে: হিসাব দলের ভেতরে, কিন্তু ফল মূল টেবিলের মতোই লম্বা আর একই index-এ। তাই ফলটি সরাসরি নতুন কলাম হিসেবে বসানো যায়।

এটি আসলে পর্ব ১৫-এর broadcasting-এর দলভিত্তিক রূপ: সেখানে একটি সারির মোট পুরো সারিতে ছড়িয়ে দিয়েছিলেন, এখানে একটি দলের গড় দলের সব সারিতে। নিচে ধাপে ধাপে দেখুন।

transform দিয়ে দলের গড় সারিতে ফেরত iddistrict৳জেলার গড়৳ − গড় 1ঢাকা 2খুলনা 3ঢাকা 4সিলেট 5খুলনা 6ঢাকা 500300700 200400300 agg: খুলনা 350 · ঢাকা 500 · সিলেট 200
  1. আগে agg. জেলাভিত্তিক গড়: ঢাকা (500 + 700 + 300) ÷ 3 = 500, খুলনা (300 + 400) ÷ 2 = 350, সিলেট 200. এগুলো তিনটি সংখ্যা, কিন্তু টেবিলে সারি ছয়টি; সরাসরি নতুন কলাম হিসেবে বসানো যায় না।
  2. ঢাকার দল। transform("mean") ঢাকার গড় 500 ঢাকার তিনটি সারির (1, 3, 6) প্রতিটিতে বসায়।
  3. খুলনার দল। খুলনার গড় 350 সারি 2 আর 5-এ। সারিগুলো টেবিলে পাশাপাশি না থাকলেও pandas index দেখে ঠিক জায়গায় বসায়।
  4. সিলেটের দল। সারি 4-এ 200. এখন ফলটি ছয়টি মানের একটি Series, মূল টেবিলের একই index-এ। তাই df["avg"] = g["tk"].transform("mean") সরাসরি কাজ করে।
  5. পার্থক্য। দাম থেকে গড় বিয়োগ: 0, −50, +200, 0, +50, −200. অর্ডার 3 নিজের জেলার গড়ের চেয়ে ৳200 বড়। প্রতিটি দলে পার্থক্যের যোগ শূন্য (ঢাকা 0 + 200 − 200, খুলনা −50 + 50), যা দেখায় গড় ঠিক দলের ভেতরেই নেওয়া হয়েছে।

এবার বাজারঘরের ১২টি অর্ডারে একই কাজ: প্রতিটি অর্ডার নিজের জেলার মোট বিক্রির কত ভাগ, আর জেলার গড় অর্ডার থেকে কত টাকা দূরে। শেষ চার লাইনে পরের অংশের filter.

import pandas as pd
pd.set_option("display.width", 60)
pd.set_option("display.precision", 3)

df = pd.read_csv("orders.csv")
df["amount"] = df["qty"] * df["price_paisa"]
g = df.groupby("district")["amount"]

print("agg:", g.sum().shape, "| transform:", g.transform("sum").shape)

df["district_total"] = g.transform("sum")         # প্রতিটি সারিতে নিজের জেলার মোট
df["share"] = df["amount"] / df["district_total"]
df["vs_avg_tk"] = (df["amount"] - g.transform("mean")) / 100  # গড় থেকে দূরত্ব, টাকা
cols = ["order_id", "district", "amount", "share", "vs_avg_tk"]
print(df[cols].head(6).round(3))

print(df.groupby("district")["share"].sum().round(6).tolist())

big = df.groupby("district").filter(lambda t: len(t) >= 3)
print("৩+ অর্ডারের জেলা:", big["district"].unique().tolist(), len(big))
keep = df.groupby("district")["order_id"].transform("size") >= 3
print("একই ফল mask দিয়ে:", df[keep].shape == big.shape)

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

agg: (4,) | transform: (12,)
   order_id   district  amount  share  vs_avg_tk
0         1       ঢাকা  120000  0.110   -980.000
1         2  চট্টগ্রাম   70000  0.264   -183.333
2         3       ঢাকা  450000  0.413   2320.000
3         4      খুলনা  160000  0.348   -700.000
4         5      সিলেট   40000  0.250   -400.000
5         6       ঢাকা   90000  0.083  -1280.000
[1.0, 1.0, 1.0, 1.0]
৩+ অর্ডারের জেলা: ['ঢাকা', 'চট্টগ্রাম'] 8
একই ফল mask দিয়ে: True

প্রথম লাইনেই মূল পার্থক্য: একই sum, কিন্তু agg-এ shape (4,) (প্রতি জেলায় একটি), transform-এ (12,) (প্রতি অর্ডারে একটি)। অর্ডার 3-এর ভাগ 450000 ÷ 1090000 ≈ 0.413, মানে ঢাকার বিক্রির প্রায় ৪১% এই একটি অর্ডারে। ঢাকার গড় অর্ডার 1090000 ÷ 5 = 218000 পয়সা (৳2,180), তাই অর্ডার 3 গড়ের চেয়ে ৳2,320 বড়, অর্ডার 1 ৳980 ছোট। প্রতিটি জেলার ভাগগুলোর যোগ 1.0, যা প্রমাণ করে ভাগ ঠিক নিজের জেলার মোট দিয়েই হয়েছে। শেষ অংশে filter খুলনা আর সিলেটের (২টি করে অর্ডার) সব সারি বাদ দিয়েছে; থেকেছে ঢাকার ৫ আর চট্টগ্রামের ৩, মোট ৮টি সারি।

agg আর transform-এর পার্থক্য এক নজরে

নিচের ছবিতে একই ছয় সারির টেবিল থেকে তিনটি পথের ফলের আকার। কোন পথ নেবেন, তা নির্ভর করে উত্তরটি কার জন্য: দলের জন্য (agg), প্রতিটি সারির জন্য (transform), নাকি দল বাছাইয়ের জন্য (filter).

agg, transform আর filter-এর ফলের আকার df · ৬ সারি ঢাকা 500 খুলনা 300 ঢাকা 700 সিলেট 200 খুলনা 400 ঢাকা 300 agg(mean)transformfilter ৩ সারি৬ সারি৫ সারি index = জেলামূল indexমূল index খুলনা 350 ঢাকা 500 সিলেট 200 500 350 500 200 350 500 #1 #2 #3 বাদ #5 #6 filter শর্ত: দলে অন্তত ২টি অর্ডার
একই groupby, তিনটি আলাদা আকারের ফল। agg দলকে সংক্ষেপ করে, transform দলের ফল সারিতে ফেরত দেয়, filter মূল সারিগুলোই রাখে বা পুরো দল বাদ দেয়।
মূল ধারণা

transform-এর ফলের দৈর্ঘ্য মূল টেবিলের সমান, agg-এর ফলের দৈর্ঘ্য দলের সংখ্যার সমান। “প্রতিটি সারির পাশে দলের একটি সংখ্যা” চাইলে transform; অনেকে agg করে তারপর মূল টেবিলের সঙ্গে merge করেন, যা কাজ করে কিন্তু লম্বা আর ভুলের সুযোগ বেশি। একই কাজ SQL-এ window function দিয়ে হয় (অংশ ৮)।

৫ · filter — পুরো দল রাখা বা বাদ দেওয়া

পর্ব ২২-এর boolean mask সারি ধরে বাছাই করে: “৳১,০০০-এর বেশি অর্ডারগুলো”। কিন্তু কখনো শর্তটি দলের ওপর: “যে জেলাগুলোতে অন্তত ৩টি অর্ডার এসেছে, সেগুলোর সব অর্ডার”। অল্প ডেটার জেলার গড় অনির্ভরযোগ্য, তাই বিশ্লেষণের আগে এমন জেলা বাদ দেওয়া একটি সাধারণ কাজ। GroupBy filter প্রতিটি দলকে (একটি ছোট DataFrame) একটি function-এ দেয়; function True ফেরত দিলে দলের সব সারি থাকে, False দিলে সব বাদ। ফল মূল টেবিলের একটি অংশ, মূল index আর ক্রমসহ।

big = df.groupby("district").filter(lambda t: len(t) >= 3)

এখানে lambda (নামহীন ছোট function, Python কোর্সে বিস্তারিত) প্রতিটি দলে একবার চলে। দল কম হলে এটি ঠিক আছে। দল হাজার হাজার হলে ওপরের কোডের শেষ লাইনের পথটি দ্রুত: transform("size") দিয়ে প্রতিটি সারিতে নিজের দলের আকার বসিয়ে একটি সাধারণ boolean mask বানানো। দুটির ফল একই (True), কিন্তু mask-এর পথে কোনো Python function প্রতিটি দলে ডাকা হয় না। এটিই পরের অংশের বিষয়।

filter বনাম “agg-এর পর বাছাই”। filter মূল সারিগুলো ফেরত দেয়। আপনি যদি শুধু দলের সারাংশ চান (“৩+ অর্ডারের জেলাগুলোর মোট বিক্রি”), তাহলে আগে agg, তারপর ফলের ওপর সাধারণ mask: summary[summary["orders"] >= 3]. SQL-এ এই দ্বিতীয় কাজটির নাম HAVING.

৬ · apply — সবচেয়ে নমনীয়, সবচেয়ে ধীর

GroupBy apply সবচেয়ে সাধারণ হাতিয়ার: প্রতিটি দলকে (DataFrame বা Series হিসেবে) আপনার যেকোনো Python function-এ দেয়, আর ফল যা-ই আসুক (একটি সংখ্যা, একটি Series, একটি পুরো DataFrame) pandas জোড়া লাগানোর চেষ্টা করে। নমনীয়তার দাম গতি। sum, mean বা transform("sum")-এর মতো built-in হিসাব pandas-এর ভেতরে compiled কোডে সব দল একবারে সেরে ফেলে; apply-এ প্রতিটি দলের জন্য একটি ছোট DataFrame বা Series তৈরি হয় আর একবার Python function ডাকা হয়। দল ৮টি হলে খরচ সামান্য; দল ২০,০০০টি হলে ২০,০০০ বার।

লেখকের যন্ত্রে সত্যিকারের run (Python 3.14.4, pandas 3.1.1, NumPy 2.5.4, Windows): seed 42 দিয়ে বানানো ২,০০,০০০টি কাল্পনিক অর্ডার, ৮টি জেলা আর প্রায় ২০,০০০ ক্রেতা। প্রতিটি কাজ দুইভাবে করে ফল মিলিয়ে দেখা হয়েছে, আর কয়েকবার চালিয়ে সবচেয়ে কম সময়টি নেওয়া হয়েছে (পর্ব ১৯-এর benchmark-এর নিয়ম)।

~/bazarghar-ai$ python apply_timing.py
g = df.groupby("district")["amount"]                   # ৮টি দল
g.apply(lambda s: s.sum())      বনাম   g.sum()

gc = df.groupby("customer_id")["amount"]               # ~২০,০০০ দল
gc.apply(lambda s: s / s.sum()) বনাম   df["amount"] / gc.transform("sum")
gc.apply(lambda s: s.max() - s.min())  বনাম   gc.max() - gc.min()

cols = df.head(6).groupby("district").apply(lambda t: list(t.columns))

সত্যিকারের run-এর আউটপুট (pandas 3.1.1; সময় একটি যন্ত্রের একটি run, সাধারণ ফল নয়):

(200000, 3) | গ্রুপ: 8 জেলা, 19999 ক্রেতা
৮ গ্রুপ, apply(lambda s: s.sum())     1.5 ms | sum()    0.4 ms | মিল: True
২০০০০ গ্রুপ, apply দিয়ে ভাগ   704.3 ms | transform দিয়ে    2.1 ms | মিল: True
২০০০০ গ্রুপ, apply(max-min)   292.0 ms | max()-min()    2.0 ms | মিল: True
ধীর / দ্রুত: 3 342 144
apply-এর ভেতরে কলাম: ['customer_id', 'amount']

তিনটি শিক্ষা। প্রথমত, ৮টি দলে apply মাত্র প্রায় ৩ গুণ ধীর, আর পুরো সময় ২ ms-এরও কম; এখানে পড়তে সহজ হলে apply লিখলেও ক্ষতি নেই। দ্বিতীয়ত, প্রায় ২০,০০০ দলে একই ধরনের কাজে পার্থক্য কয়েকশো গুণ (৭০৪ বনাম ২ ms, ২৯২ বনাম ২ ms), কারণ খরচ বাড়ে দলের সংখ্যার সঙ্গে, সারির সংখ্যার সঙ্গে নয়। তৃতীয়ত, শেষ লাইন: pandas 3-এ DataFrameGroupBy.apply-এর ভেতরের DataFrame-এ key কলামটি (district) থাকে না। pandas 2.2-এ এর জন্য include_groups parameter আসে সতর্কবার্তাসহ, আর pandas 3-এ default include_groups=False; True দিলে ValueError: include_groups=True is no longer allowed (লেখকের যন্ত্রে দেখা)। পুরোনো কোড যা ভেতরে key কলাম পড়ত, তা pandas 3-এ KeyError দেবে।

সিদ্ধান্তের একটি সহজ ক্রম: আগে খুঁজুন built-in নাম আছে কি না ("sum", "mean", "nunique", "first"), তারপর agg/transform-এ সেই নাম, তারপর দুই-তিনটি built-in হিসাবের সাধারণ গণিত (gc.max() - gc.min())। এর কোনোটিতে না হলে, যেমন প্রতিটি দলে একটি ছোট model fit করা বা জটিল নিয়ম, তখন apply; তবে দলের সংখ্যা দেখে নিন আর সময় মেপে নিন। NumPy-তে loop বনাম vectorization-এর যে হিসাব (পর্ব ১৫, পর্ব ১৯) শিখেছেন, এখানেও তা-ই: Python-এর প্রতিটি ডাকের নিজস্ব খরচ আছে।

৭ · key বানানো, সাজানো আর সেরা n — মাস, দামের পরিসর, categorical আর pivot table

groupby-এর key টেবিলে আগে থেকে থাকা কলাম হতে হবে, এমন নয়। মূল টেবিলের সমান দৈর্ঘ্যের যেকোনো Series দিলেই চলে। তাই নতুন কলাম না বানিয়েই “মাস ধরে” বা “দামের পরিসর ধরে” গ্রুপ করা যায়:

  • তারিখের অংশ: df.groupby(df["date"].dt.to_period("M")) প্রতিটি মাসকে একটি দল ধরে (তারিখ কলামটি datetime হতে হবে; parse_dates পর্ব ২১-এ)। dt.month-ও চলে, কিন্তু তাতে ভিন্ন বছরের সেপ্টেম্বর মিশে যায়। পুরো সময়-সারির কাজ (resample, rolling) পর্ব ২৬-এ।
  • সংখ্যার পরিসর: pd.cut একটি সংখ্যার কলামকে সীমা (bins) দিয়ে ভাগ করে প্রতিটি মানকে একটি পরিসরের নাম দেয়; একে বলে binning. default-এ পরিসর ডান দিকে বন্ধ: bins=[0, 1000, 2000] মানে (0, 1000] আর (1000, 2000], অর্থাৎ ঠিক ১০০০ প্রথম পরিসরে।
  • Categorical key: category dtype-এর কলামে সম্ভাব্য মানের একটি নির্দিষ্ট তালিকা থাকে, ডেটায় না থাকলেও (যেমন এখনো কোনো অর্ডার না আসা জেলা “বরিশাল”)। observed=True দিলে শুধু ডেটায় আসা দলগুলো ফলে থাকে; observed=False দিলে তালিকার সব দল, ফাঁকাগুলো 0 বা NaN নিয়ে। pandas 3-এ default observed=True; pandas 2.x-এ default ছিল False, আর 2.1 থেকে স্পষ্ট না লিখলে সতর্কবার্তা আসত। দুই key-এর categorical-এ observed=False সব সম্ভাব্য জোড়া বানায়, যা অনেক বড় আর ফাঁকা ফল দিতে পারে। category dtype-এর মেমরির সুবিধা পর্ব ২৭-এ।

সাজানো আর প্রতি দলের সেরা n

“প্রতিটি জেলার সবচেয়ে বড় ২টি অর্ডার” একটি অতি সাধারণ প্রশ্ন (top-k). দুটি পরিচিত পথ: (১) পুরো টেবিল দামের উল্টো ক্রমে সাজিয়ে groupby("district").head(2); head প্রতিটি দলের প্রথম n সারি রাখে, মূল index আর কলামসহ। (২) একটি কলামের জন্য groupby("district")["amount"].nlargest(2), যার ফলে দুই স্তরের index (জেলা, মূল index). দলের সারাংশ সাজাতে sort_values, আর key-এর ক্রম ছাড়া দ্রুত ফল চাইলে groupby(..., sort=False), যা দলগুলো ডেটায় প্রথম দেখার ক্রমে রাখে।

Pivot table মানে দুই key-এর grouped aggregation

Spreadsheet-এর pivot table-এর সঙ্গে পরিচিত হলে জেনে রাখুন, pd.pivot_table আসলে দুই key-এর groupby, যার একটি key সারিতে আর আরেকটি কলামে ছড়ানো। pivot_table(df, index="district", columns="category", values="amount", aggfunc="sum") আর groupby(["district", "category"])["amount"].sum().unstack() একই টেবিল দেয়। pivot, melt, stack আর টেবিলের আকার বদলের পুরো আলোচনা পর্ব ২৫-এ।

নিচে এই অংশের সবকিছু বাজারঘরের ১২টি অর্ডারে, লেখকের যন্ত্রে সত্যিকারের run (Python 3.14.4, pandas 3.1.1). কোডটি ওপরের যেকোনো ব্লকে বসিয়ে ব্রাউজারেও চালাতে পারেন; সংখ্যাগুলো একই আসার কথা।

import pandas as pd
pd.set_option("display.width", 60)
df = pd.read_csv("orders.csv", parse_dates=["date"])
df["amount"] = df["qty"] * df["price_paisa"]

print(df.groupby(df["date"].dt.to_period("M"))["amount"].sum())     # মাস
band = pd.cut(df["amount"] // 100, bins=[0, 1000, 2000, 5000],
              labels=["<=1000", "1001-2000", "2001-5000"])
print(df.groupby(band)["order_id"].count())                         # দামের পরিসর

df["district_c"] = pd.Categorical(df["district"],
                    categories=["ঢাকা", "চট্টগ্রাম", "খুলনা", "সিলেট", "বরিশাল"])
print(df.groupby("district_c", observed=True).size().to_dict())
print(df.groupby("district_c", observed=False).size().to_dict())
print(df.groupby("district_c").size().index.tolist())               # default

top2 = df.sort_values("amount", ascending=False).groupby("district").head(2)
print(top2[["district", "order_id", "amount"]]
      .sort_values(["district", "amount"], ascending=[True, False]))
print(df.groupby("district")["amount"].nlargest(1))

pt = pd.pivot_table(df, index="district", columns="category",
                    values="amount", aggfunc="sum", fill_value=0)
print(pt // 100)                                                     # টাকায়
same = df.groupby(["district", "category"])["amount"].sum().unstack(fill_value=0)
print("groupby + unstack একই?", same.equals(pt))

সত্যিকারের run-এর আউটপুট:

date
2026-09    1080000
2026-10     895000
Freq: M, Name: amount, dtype: int64
amount
<=1000       4
1001-2000    5
2001-5000    3
Name: order_id, dtype: int64
{'ঢাকা': 5, 'চট্টগ্রাম': 3, 'খুলনা': 2, 'সিলেট': 2}
{'ঢাকা': 5, 'চট্টগ্রাম': 3, 'খুলনা': 2, 'সিলেট': 2, 'বরিশাল': 0}
['ঢাকা', 'চট্টগ্রাম', 'খুলনা', 'সিলেট']
     district  order_id  amount
8       খুলনা         9  300000
3       খুলনা         4  160000
6   চট্টগ্রাম         7  150000
1   চট্টগ্রাম         2   70000
2        ঢাকা         3  450000
10       ঢাকা        11  250000
11      সিলেট        12  120000
4       সিলেট         5   40000
district
খুলনা      8     300000
চট্টগ্রাম  6     150000
ঢাকা       2     450000
সিলেট      11    120000
Name: amount, dtype: int64
category   ইলেকট্রনিক্স  পোশাক    বই
district
খুলনা              3000   1600     0
চট্টগ্রাম             0   1500  1150
ঢাকা               7000   3000   900
সিলেট                 0   1200   400
groupby + unstack একই? True

সেপ্টেম্বরের ৭টি অর্ডারের মোট 1080000 আর অক্টোবরের ৫টির 895000, যোগে আবার 1975000. দামের পরিসরে ৳১,০০০ বা কম ৪টি (৳700, ৳400, ৳900, ৳450), ৳১,০০১–২,০০০ ৫টি, বাকি ৩টি বড়। categorical key-তে observed=False “বরিশাল” দলটি 0 নিয়ে দেখিয়েছে, আর default (pandas 3) “বরিশাল” বাদ দিয়েছে; লক্ষ করুন, এখানে দলের ক্রম category-র তালিকার ক্রম (ঢাকা আগে), বর্ণের ক্রম নয়। top-2-এর টেবিলে প্রতিটি জেলার দুটি সবচেয়ে বড় অর্ডার, মূল index (8, 3, …) সহ; nlargest(1)-এর index-এ জেলা আর মূল index দুটোই। Pivot table-এ যে জোড়া ডেটায় নেই (খুলনার বই), সেখানে fill_value=0; আর শেষ লাইন নিশ্চিত করে pivot table আর groupby + unstack হুবহু একই।

৮ · SQL-এর GROUP BY আর pandas — একই ধারণা, দুই ভাষা

ডেটাবেসে একই প্রশ্ন SQL-এ লেখা হয়, আর অনেক কাজে দুই ভাষার মধ্যে যাতায়াত লাগে (পর্ব ২১-এ SQL থেকে DataFrame-এ পড়া)। GROUP BY-এর পূর্ণ আলোচনা আছে ABCL TECH-এর DBMS ও SQL কোর্সের aggregation আর GROUP BY পর্বে, আর transform-এর SQL রূপ আছে window function পর্বে। এখানে শুধু মিলের তালিকা:

SELECT district,
       COUNT(*)                    AS orders,
       SUM(qty * price_paisa)      AS total,
       COUNT(DISTINCT customer_id) AS customers
FROM orders
WHERE payment IS NOT NULL
GROUP BY district
HAVING COUNT(*) >= 3
ORDER BY total DESC;
SQLpandasখেয়াল রাখুন
WHERE …groupby-এর আগে boolean maskসারি বাছাই, দল তৈরির আগে
GROUP BY districtgroupby("district")SQL-এ NULL key একটি দল হয়; pandas default-এ NaN key বাদ দেয় (dropna=True)
COUNT(*)size()সব সারি
COUNT(col)count()দুটিই ফাঁকা ঘর বাদ দেয়
COUNT(DISTINCT col)nunique()দুটিই ফাঁকা বাদ দেয়
SUM(...) AS totalagg(total=(..., "sum"))সব ঘর ফাঁকা হলে SQL দেয় NULL, pandas দেয় 0
HAVING …agg-এর পর mask, বা filterদল বাছাই, দল তৈরির পরে
SUM(x) OVER (PARTITION BY district)transform("sum")প্রতিটি সারি থাকে
ROW_NUMBER() … <= 2sort_values + head(2)প্রতি দলের সেরা n
ORDER BY total DESCsort_values("total", ascending=False)groupby নিজে key দিয়ে সাজায়

দুটি পার্থক্য বাস্তবে সবচেয়ে বেশি কামড়ায়: NULL/NaN key (SQL-এর রিপোর্টে “payment অজানা” সারি থাকে, pandas-এ থাকে না, ফলে মোট মেলে না), আর সব-ফাঁকা দলের যোগফল (SQL দেয় NULL, pandas দেয় 0; min_count=1 দিলে NaN). দুই হাতিয়ারের ফল তুলনার আগে এ দুটি দেখে নিন।

৯ · Simpson's paradox — সারাংশ যখন উল্টো কথা বলে

বাজারঘর দুটি কাল্পনিক কুরিয়ার প্রতিষ্ঠানের সঙ্গে কাজ করে: “দ্রুতি” আর “পথিক”। গত মাসে প্রত্যেকে ৫০০টি করে অর্ডার পৌঁছেছে, দুটি জেলায়: ঢাকা (শহর, সহজ রাস্তা) আর বান্দরবান (পাহাড়ি, কঠিন রাস্তা)। ম্যানেজার একটি groupby চালালেন: সময়মতো পৌঁছানোর হার দ্রুতির 75%, পথিকের 84%. সিদ্ধান্ত প্রায় নেওয়া হয়ে গেছে: সামনের মাস থেকে সব অর্ডার পথিককে। তারপর একজন বিশ্লেষক জেলা ধরে আবার groupby চালালেন, আর ফল উল্টে গেল।

জেলাদ্রুতি: সময়মতো / মোটপথিক: সময়মতো / মোট
ঢাকা95 / 100 = 95%360 / 400 = 90%
বান্দরবান280 / 400 = 70%60 / 100 = 60%
সব মিলিয়ে375 / 500 = 75%420 / 500 = 84%

প্রতিটি জেলায় আলাদাভাবে দ্রুতি এগিয়ে (95 বনাম 90, 70 বনাম 60), অথচ সব মিলিয়ে পথিক এগিয়ে। কোনো হিসাবে ভুল নেই। এই ঘটনার নাম Simpson's paradox: দলে দলে যে প্রবণতা দেখা যায়, সব দল মেলালে তা দুর্বল হয়ে যায় বা উল্টে যায়। কারণ লুকিয়ে আছে মিশ্রণে: দ্রুতির ৮০% অর্ডার কঠিন বান্দরবানে, পথিকের ৮০% সহজ ঢাকায়। “সব মিলিয়ে” হার আসলে দুই জেলার হারের ওজনসহ গড়, আর ওজন হলো কে কোন জেলায় কত অর্ডার নিয়েছে। এখানে জেলা একটি confounding variable: এটি একসঙ্গে ঠিক করছে কোন কুরিয়ার অর্ডারটি পাবে আর অর্ডারটি সময়মতো পৌঁছানো কতটা কঠিন।

1orders.groupby("courier")["on_time"].mean()2orders.groupby(["district", "courier"])["on_time"].mean()3orders.groupby("courier")["district"].value_counts(normalize=True)4rate.mul(weights, axis=0).sum()   # একই ৫০:৫০ মিশ্রণ
মান (Watch)
কোন দল
দ্রুতি
পথিক
এগিয়ে
  1. সব মিলিয়ে। কুরিয়ার ধরে একটি groupby: দ্রুতি 375 ÷ 500 = 0.75, পথিক 420 ÷ 500 = 0.84. প্রথম দেখায় পথিক ৯ শতাংশ বিন্দু এগিয়ে।
  2. শুধু ঢাকা। জেলা আর কুরিয়ার দুই key-তে groupby. ঢাকায় দ্রুতি 95 ÷ 100 = 0.95, পথিক 360 ÷ 400 = 0.90. দ্রুতি এগিয়ে।
  3. শুধু বান্দরবান। দ্রুতি 280 ÷ 400 = 0.70, পথিক 60 ÷ 100 = 0.60. এখানেও দ্রুতি এগিয়ে। দুই জেলাতেই দ্রুতি ভালো, অথচ মোটে খারাপ।
  4. মিশ্রণ। কে কোথায় কত অর্ডার নিয়েছে: দ্রুতির ২০% ঢাকায়, ৮০% বান্দরবানে; পথিকের উল্টো। মোট হার = ওজনসহ গড়: দ্রুতি 0.2 × 0.95 + 0.8 × 0.70 = 0.19 + 0.56 = 0.75; পথিক 0.8 × 0.90 + 0.2 × 0.60 = 0.72 + 0.12 = 0.84. মোট হার কুরিয়ারের দক্ষতার চেয়ে বেশি মাপছে কে কোন রাস্তা পেয়েছে।
  5. একই মিশ্রণে তুলনা। দুজনকেই একই ওজন (ঢাকা ৫০%, বান্দরবান ৫০%) দিলে দ্রুতি 0.825, পথিক 0.75. যেকোনো সাধারণ মিশ্রণেই দ্রুতি এগিয়ে থাকবে, কারণ প্রতিটি জেলায় সে এগিয়ে। এই কৌশলের নাম standardization: তুলনার আগে মিশ্রণ সমান করা।

নিচের কোডটি এই হিসাব প্রতিটি অর্ডারের একটি সারি বানিয়ে groupby দিয়ে করে, যাতে আসল কাজের মতো দেখায়: ১,০০০টি সারি, প্রতিটিতে কুরিয়ার, জেলা আর সময়মতো পৌঁছেছে কি না (True/False). boolean কলামের mean হলো True-র ভাগ, মানে হার।

import pandas as pd
pd.set_option("display.width", 60)
pd.set_option("display.precision", 2)

# (কুরিয়ার, জেলা, মোট অর্ডার, সময়মতো পৌঁছেছে)
cells = [("দ্রুতি", "ঢাকা", 100, 95), ("দ্রুতি", "বান্দরবান", 400, 280),
         ("পথিক", "ঢাকা", 400, 360), ("পথিক", "বান্দরবান", 100, 60)]
parts = [pd.DataFrame({"courier": c, "district": d,
                       "on_time": [True] * ok + [False] * (n - ok)})
         for c, d, n, ok in cells]
orders = pd.concat(parts, ignore_index=True)     # প্রতিটি সারি একটি অর্ডার
print(len(orders), "টি অর্ডার")

overall = orders.groupby("courier")["on_time"].mean()
print("সব মিলিয়ে:", overall.round(3).to_dict())

by_d = orders.groupby(["district", "courier"])["on_time"].mean()
print(by_d.round(3))

mix = orders.groupby("courier")["district"].value_counts(normalize=True)
print(mix.unstack().round(2))            # কার অর্ডার কোথায়

# দুই কুরিয়ারকে একই জেলা-মিশ্রণে (৫০:৫০) তুলনা
rate = by_d.unstack()                     # সারি = জেলা, কলাম = কুরিয়ার
weights = pd.Series({"ঢাকা": 0.5, "বান্দরবান": 0.5})
print("একই মিশ্রণে:", rate.mul(weights, axis=0).sum().round(3).to_dict())

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

1000 টি অর্ডার
সব মিলিয়ে: {'দ্রুতি': 0.75, 'পথিক': 0.84}
district   courier
ঢাকা       দ্রুতি     0.95
           পথিক       0.90
বান্দরবান  দ্রুতি     0.70
           পথিক       0.60
Name: on_time, dtype: float64
district  ঢাকা  বান্দরবান
courier
দ্রুতি     0.2        0.8
পথিক       0.8        0.2
একই মিশ্রণে: {'দ্রুতি': 0.825, 'পথিক': 0.75}

Stepper-এর প্রতিটি সংখ্যা এসেছে। এক key-এর groupby বলছে পথিক ভালো (0.84 বনাম 0.75); দুই key-এর groupby বলছে প্রতিটি জেলায় দ্রুতি ভালো; মিশ্রণের টেবিল ব্যাখ্যা দিচ্ছে কেন; আর একই ৫০:৫০ মিশ্রণে দ্রুতি 0.825, পথিক 0.75. value_counts(normalize=True) প্রতিটি দলের ভেতরে ভাগ দেয় (প্রতিটি সারির যোগ 1), আর unstack (পর্ব ২৫) দুই স্তরের index-কে টেবিলে ছড়ায়।

তাহলে কোন সংখ্যাটি “ঠিক”?

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

ইতিহাস থেকে। ঘটনাটি Karl Pearson আর Udny Yule উনিশ শতকের শেষ আর বিশ শতকের শুরুতে লক্ষ করেছিলেন বলে জানা যায়; Edward H. Simpson ১৯৫১ সালে Journal of the Royal Statistical Society-এ contingency table-এর interaction নিয়ে একটি প্রবন্ধে এটি পরিষ্কারভাবে আলোচনা করেন, আর “Simpson's paradox” নামটি পরে, ১৯৭০-এর দশকের শুরুতে Colin Blyth-এর লেখা থেকে প্রচলিত বলে সাধারণত উল্লেখ করা হয়। সবচেয়ে বেশি উদ্ধৃত বাস্তব উদাহরণ University of California, Berkeley-র ১৯৭৩ সালের স্নাতকোত্তর ভর্তির ডেটা, যা Bickel, Hammel আর O'Connell ১৯৭৫ সালে Science-এ বিশ্লেষণ করেন। সব মিলিয়ে পুরুষ আবেদনকারীদের ভর্তির হার (প্রায় ৪৪%) নারীদের (প্রায় ৩৫%) চেয়ে বেশি দেখাচ্ছিল, কিন্তু বিভাগ ধরে দেখলে বেশিরভাগ বিভাগে নারীদের বিরুদ্ধে পক্ষপাতের প্রমাণ মেলেনি; লেখকদের ব্যাখ্যায়, নারীরা বেশি আবেদন করেছিলেন যেসব বিভাগে ভর্তি হওয়া কঠিন। সূত্রভেদে সংখ্যা আর ব্যাখ্যার খুঁটিনাটিতে কিছু পার্থক্য আছে, আর এই উদাহরণ পক্ষপাতের প্রশ্নটিকে পুরোপুরি মীমাংসা করে বলে ধরে নেওয়াও ঠিক নয়।

আরও গভীরে। pandas-এর groupby ভেতরে প্রথমে প্রতিটি key-কে একটি পূর্ণসংখ্যার দল-নম্বরে রূপান্তর করে (factorize), যা মূলত একটি hash table-এর কাজ; তারপর built-in হিসাবগুলো Cython-এ লেখা loop-এ সব দলের ফল একবারে জমায়, তাই খরচ মোটামুটি সারির সংখ্যার সমানুপাতিক। apply এই পথ এড়িয়ে প্রতিটি দলের জন্য Python-এ ফিরে আসে, যা অংশ ৬-এর সময়ের পার্থক্যের মূল কারণ। Wickham-এর ধারণা R-এ plyr থেকে dplyr-এ গেছে, আর Polars-এর group_by().agg() একই ছাঁচ expression দিয়ে, প্রায়ই একাধিক CPU core-এ চালায় (পর্ব ২৭)। Simpson's paradox-এর কারণ-ফলভিত্তিক ব্যাখ্যা Judea Pearl-এর causal inference-এর কাজে বিস্তারিত।

পরীক্ষা ও ইন্টারভিউতে (বিশ্ববিদ্যালয়, চাকরি বা ইন্টারভিউর পরীক্ষায় এ ধরনের প্রশ্ন সাধারণ):

  • count() আর size()-এর পার্থক্য? size NaN-সহ সব সারি গোনে, count শুধু ভরা ঘর, প্রতিটি কলামে আলাদা।
  • agg আর transform-এর পার্থক্য? agg প্রতি দলে এক সারি দেয়; transform মূল টেবিলের সমান দৈর্ঘ্যের ফল দেয়, একই index-এ।
  • প্রতিটি দলের সবচেয়ে বড় ২টি সারি কীভাবে বের করবেন? sort_values(col, ascending=False).groupby(key).head(2), বা একটি কলামে nlargest(2).
  • SQL-এর HAVING-এর pandas রূপ? agg-এর পর ফলের ওপর boolean mask; মূল সারি চাইলে filter.
  • Simpson's paradox কী? দলে দলে যে প্রবণতা, সব দল মেলালে তা উল্টে যাওয়া; কারণ দলগুলোর অসম মিশ্রণ (confounding).

১০ · AI-এর যুগে — AI সহকারীর লেখা groupby যাচাই

“জেলাভিত্তিক রিপোর্ট বানাও” লিখলে একটি AI সহকারী কয়েক সেকেন্ডে groupby-এর কোড দেবে, আর তা প্রায়ই চলবেও। সমস্যা হলো, groupby-এর ভুল সাধারণত error দেয় না; সংখ্যা আসে, শুধু ভুল সংখ্যা। AI সহকারীরা প্রায়ই যা করে:

  • পুরোনো API লেখে: নেস্টেড dict দিয়ে নাম বদল (SpecificationError), সরিয়ে দেওয়া df.append, apply-এর ভেতরে key কলাম পড়া (pandas 3-এ KeyError), বা observed-এর পুরোনো default ধরে নেওয়া; কখনো এমন parameter বানায় যা নেই।
  • “কয়টি অর্ডার” জানতে count() ডাকে ফাঁকা ঘরওয়ালা কলামে, বা NaN key চুপচাপ বাদ পড়ার কথা বলে না; ফাঁকা গড়কে fillna(0) দিয়ে “ঠিক” করে।
  • যেখানে transform লাগে সেখানে agg করে তারপর ভুল key-তে merge করে, বা প্রতিটি দলে apply(lambda …) লেখে যেখানে built-in হিসাব আছে।
  • সব মিলিয়ে একটি হার দেখিয়ে সিদ্ধান্ত দেয় (“পথিক ভালো”), দলগুলোর মিশ্রণ না দেখে; আর দলের আকার (n) ছাড়া ছোট দলের গড়কে সমান গুরুত্ব দেয়।
  • মডেলের feature বানাতে পুরো ডেটার দলভিত্তিক গড় (যেমন জেলার গড় বিক্রি) ব্যবহার করে, train আর test ভাগের আগে; এতে test-এর তথ্য train-এ ঢোকে (data leakage; পর্ব ৫৬-এ বিস্তারিত)।

কোড চাওয়ার সময় key, ফাঁকা ঘরের অর্থ, ফলের আকার আর যাচাইয়ের উপায় নিজেই বলে দিন। নিচের প্রম্পটে আসল ক্রেতার ডেটা নেই, শুধু কলামের নাম।

আমি Python 3.14 আর pandas 3.x ব্যবহার করছি। আমার orders DataFrame-এর কলাম: order_id, date, district, category, qty, price_paisa (পূর্ণসংখ্যা পয়সা), delivery_days (না পৌঁছালে NaN), payment (কিছু NaN), customer_id. ১. district ধরে named aggregation দিয়ে একটি রিপোর্ট বানাও: orders (size), delivered (delivery_days-এর count), total (qty * price_paisa-এর sum), customers (nunique). key কলাম হিসেবে রাখবে (as_index=False). ২. প্রতিটি অর্ডারের তার জেলার মোটের কত ভাগ, তা transform দিয়ে নতুন কলামে দাও; apply ব্যবহার করবে না। ৩. যাচাই: জেলার মোটগুলোর যোগ পুরো টেবিলের মোটের সমান, আর প্রতিটি জেলায় ভাগের যোগ 1, assert দিয়ে দেখাও। ৪. payment-এ NaN থাকলে groupby কী করে, এক লাইনে বলো, আর dropna=False দিয়ে একটি গণনাও দেখাও। ৫. pandas 3-এ বদলে যাওয়া কোনো default (observed, include_groups) কাজে লাগলে স্পষ্ট লিখবে। কোনো function বা parameter নিয়ে নিশ্চিত না হলে বলো, বানিয়ে লিখবে না।

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

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

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

প্র ০১Split-apply-combine যদি পুরোনো ধারণা হয়, তাহলে Wickham-এর ২০১১ সালের প্রবন্ধ এত প্রভাবশালী হলো কেন?

দলে ভাগ করে হিসাবের ধারণা কম্পিউটারের চেয়েও পুরোনো: আদমশুমারির টেবিল, দোকানের খাতার জেলাভিত্তিক হিসাব। কম্পিউটারে SQL-এর GROUP BY ১৯৭০-এর দশকে IBM-এর relational database গবেষণা থেকে আসা ভাষার অংশ, আর spreadsheet-এর pivot table ১৯৯০-এর দশকে জনপ্রিয় হয়। ২০০৪ সালে Google-এর Jeffrey Dean আর Sanjay Ghemawat-এর MapReduce প্রবন্ধও একই ছাঁচ বিশাল ডেটায় দেখায়: map ধাপে প্রতিটি রেকর্ডকে একটি key দেওয়া, shuffle ধাপে একই key একসঙ্গে আনা, reduce ধাপে প্রতিটি key-এর হিসাব।

তাহলে নতুন কী ছিল? Wickham-এর অবদান মূলত একটি নাম আর একটি শব্দভাণ্ডার। তিনি দেখান, অনেক আলাদা চেহারার কাজ (জেলার মোট, প্রতিটি দলে একটি model fit, প্রতিটি দলের সেরা সারি) একই তিন ধাপে ভাঙা যায়, আর ধাপগুলোকে আলাদা করে ভাবলে কোড ছোট আর পরিষ্কার হয়। plyr package-এ ইনপুট আর আউটপুটের ধরন (array, data frame, list) ধরে function-এর নামও রাখা হয় এই ভাবনায়। একটি নাম থাকলে মানুষ ধারণাটি নিয়ে কথা বলতে, শেখাতে আর অন্য ভাষায় নিতে পারে।

প্রায় একই সময়ে Wes McKinney একটি বিনিয়োগ প্রতিষ্ঠানে আর্থিক ডেটার কাজের জন্য pandas লিখছিলেন (২০০৮ সালে শুরু, ২০০৯ সালে open source); pandas-এর groupby-এর docs-এর শিরোনামে আজও “split-apply-combine” শব্দগুচ্ছটি আছে। পরে R-এর dplyr-এ group_by() |> summarise(), Spark-এর groupBy().agg(), Polars-এর group_by().agg(): সবগুলোর গঠন প্রায় একই। মানে একটি ভালো বিমূর্ত ধারণা ভাষা আর হাতিয়ার পেরিয়ে টিকে থাকে।

ব্যবহারিক দিক: নতুন হাতিয়ারে সিনট্যাক্স মুখস্থ না করে জিজ্ঞেস করুন, “split কোথায়, apply কোথায়, combine-এর ফলের আকার কী?”

মূল উপলব্ধি: ধারণাটি পুরোনো, কিন্তু তিন ধাপকে নাম দিয়ে আলাদা করাই একে শেখার আর ভাষা পেরিয়ে নেওয়ার যোগ্য করেছে; নতুন হাতিয়ারে আগে তিনটি ধাপ খুঁজুন।

প্র ০২Simpson's paradox দেখা দিলে কি সবসময় দলে ভাগ করা ফলটিই সঠিক? কখন সব মিলিয়ে হারটিই ঠিক প্রশ্নের উত্তর?

অনেকে নিয়ম হিসেবে মনে রাখেন: “paradox দেখলে দলে ভাগ করা ফলটি বিশ্বাস করো।” এটি প্রায়ই কাজ করে, কিন্তু সবসময় নয়। কুরিয়ারের উদাহরণে জেলা কুরিয়ার বাছাইয়ের আগেই ঠিক থাকে আর রাস্তার কাঠিন্য সরাসরি সময়ে প্রভাব ফেলে; জেলা এখানে confounder, তাই জেলা ধরে (বা একই মিশ্রণে) তুলনাই দক্ষতার ন্যায্য মাপ।

চিকিৎসাবিজ্ঞানের একটি বহুল উদ্ধৃত উদাহরণ কিডনির পাথরের দুই ধরনের চিকিৎসার তুলনা (Charig ও সহকর্মীদের ১৯৮৬ সালের একটি গবেষণা থেকে নেওয়া সংখ্যা প্রায়ই পাঠ্যবইয়ে দেখানো হয়): পাথরের আকার ধরে দেখলে একটি চিকিৎসা দুই দলেই ভালো, সব মিলিয়ে অন্যটি। কারণ ডাক্তাররা বড় পাথরের কঠিন রোগীদের বেশি পাঠাতেন একটি নির্দিষ্ট চিকিৎসায়। এখানেও পাথরের আকার চিকিৎসার আগে থেকে থাকা বৈশিষ্ট্য, তাই দলে ভাগ করে তুলনা অর্থপূর্ণ।

কিন্তু ধরুন দলের পরিচয়টি নিজেই সিদ্ধান্তের ফল। একটি ওষুধ রক্তচাপ কমিয়ে রোগীকে সুস্থ রাখে; যদি “চিকিৎসার পরের রক্তচাপ” ধরে দলে ভাগ করেন, তাহলে আপনি ওষুধের আসল কাজের পথটিকেই আটকে দিচ্ছেন, আর দলে ভাগ করা তুলনা ভুল দিকে নিতে পারে। Judea Pearl তাঁর causal inference-এর লেখায় (জনপ্রিয় বই The Book of Why, ২০১৮, সহলেখক Dana Mackenzie) এই পার্থক্যের ওপর জোর দেন: কোন চলককে ধরে ভাগ করবেন, তা ঠিক হয় কারণ-ফলের চিত্র থেকে, শুধু ডেটার সংখ্যা থেকে নয়। একই সংখ্যার টেবিল দুটি আলাদা কারণ-গঠন থেকে আসতে পারে, আর দুটিতে সঠিক উত্তর আলাদা।

ব্যবহারিক অভ্যাস: একটি তুলনার আগে লিখে ফেলুন “এই দল ভাগের চলকটি কি সিদ্ধান্তের আগে ঠিক হয়, নাকি পরে?” আগে হলে (জেলা, পাথরের আকার, বয়স) সাধারণত ভাগ করে বা মিশ্রণ সমান করে তুলনা; পরে হলে (সিদ্ধান্তের ফলে বদলায় এমন কিছু) সাবধান। আর সম্ভব হলে random ভাবে অর্ডার ভাগ করে (A/B test) পরীক্ষা, যাতে মিশ্রণ নিজে থেকেই সমান হয়।

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

প্র ০৩apply এত ধীর হলে pandas এটি রেখেছে কেন? কখন এটি ব্যবহার করা পুরোপুরি ঠিক?

pandas-এর নকশায় দুই ধরনের ব্যবহারকারীর চাহিদা মেলাতে হয়। একদিকে বড় ডেটার দ্রুত হিসাব, যার জন্য built-in aggregation-গুলো Cython-এ লেখা আর সব দল একবারে প্রক্রিয়া করে। অন্যদিকে এমন কাজ, যা কোনো built-in নামে ধরা যায় না: প্রতিটি জেলার ডেটায় একটি ছোট regression fit করা, প্রতিটি ক্রেতার অর্ডারের ধারায় একটি জটিল নিয়ম চালানো, প্রতিটি দল থেকে একটি নির্দিষ্ট আকারের ফল বানানো। apply হলো সেই “শেষ দরজা”, যা না থাকলে ব্যবহারকারীকে নিজে loop লিখে দলগুলো ভাগ করতে আর ফল জোড়া দিতে হতো, যা আরও ধীর আর ভুলপ্রবণ।

অংশ ৬-এর run-এ দেখেছেন, খরচ নির্ভর করে দলের সংখ্যার ওপর। ৮টি জেলায় apply আর sum-এর পার্থক্য এক মিলিসেকেন্ডের মতো, যা কোনো মানুষ টেরই পাবে না। তাই একটি notebook-এ ৮টি জেলার জন্য একটি পরিষ্কার apply লেখা, যদি পড়তে সহজ হয়, পুরোপুরি ঠিক সিদ্ধান্ত। সমস্যা শুরু হয় যখন key-টি customer_id বা order_id-এর মতো, যেখানে দল হাজার বা লাখ। তখন এটি প্রোডাকশন পাইপলাইনে মিনিট বা ঘণ্টা খেতে পারে।

মাঝামাঝি পথও আছে। agg আর transform-এ নিজের function দিলেও প্রতি দলে Python ডাকা হয়, কিন্তু pandas ফলের আকার আগে থেকে জানে বলে apply-এর চেয়ে সাধারণত কম অনুমান করতে হয়। কিছু UDF-এর জন্য pandas engine="numba" বিকল্পও দেয়, যা function-টি compile করে (পর্ব ১৯-এর Numba); তবে এর শর্ত আর সীমা নিজের version-এর docs-এ দেখে নিতে হয়। আর Polars-এর মতো library এই সমস্যা এড়ায় expression দিয়ে: আপনি কী চান তা বর্ণনা করেন, library নিজে compiled পথে চালায় (পর্ব ২৭)।

ভালো অভ্যাস তাই দুই ধাপের: আগে সবচেয়ে পরিষ্কার কোড লিখুন, তারপর দলের সংখ্যা আর ডেটার আকার দেখে মাপুন। মাপ ছাড়া “apply সবসময় খারাপ” বলাও এক ধরনের কুসংস্কার।

মূল উপলব্ধি: apply নমনীয়তার জন্য রাখা শেষ উপায়; এর খরচ দলের সংখ্যার সঙ্গে বাড়ে, তাই কম দলে পড়ার সুবিধার জন্য ঠিক, অনেক দলে built-in বা transform খুঁজুন আর সময় মাপুন।

প্র ০৪ফাঁকা ঘর বাদ দিয়ে দলের গড় নেওয়া কীভাবে একটি রিপোর্টকে পদ্ধতিগতভাবে মিথ্যা বলাতে পারে?

বাজারঘরের টেবিলে delivery_days ফাঁকা মানে অর্ডারটি এখনো পৌঁছায়নি। pandas-এর mean এই ঘরগুলো বাদ দেয়, আর ঢাকার গড় 1.5 দিন আসে চারটি পৌঁছানো অর্ডার থেকে। এখন ভাবুন, কোন অর্ডারগুলো এখনো পৌঁছায়নি? স্বাভাবিকভাবেই যেগুলো বেশি সময় নিচ্ছে। মানে বাদ পড়া মানগুলো random নয়, সবচেয়ে বড় মানগুলোই বাদ পড়ছে। ফল: গড় পদ্ধতিগতভাবে আসলের চেয়ে ছোট, আর যত বেশি অর্ডার আটকে থাকবে, রিপোর্ট তত “ভালো” দেখাবে। একটি খারাপ সপ্তাহ রিপোর্টে সেরা সপ্তাহ হয়ে যেতে পারে।

পরিসংখ্যানে এর নাম censoring আর survivorship bias-এর আত্মীয়: যা আমরা দেখি তা শুধু “টিকে থাকা” বা “শেষ হওয়া” অংশ। সময় মাপার ক্ষেত্রে (ডেলিভারি, রোগীর সুস্থ হওয়া, ক্রেতার চলে যাওয়া) এর জন্য আলাদা পদ্ধতি আছে, survival analysis, যা “এখনো ঘটেনি” তথ্যকেও কাজে লাগায়।

SQL-ও একই আচরণ করে: AVG(col) NULL বাদ দেয়। তাই হাতিয়ার বদলালে সমস্যা যায় না। সমাধান রিপোর্টের নকশায়। প্রথমত, গড়ের পাশে size আর count দিন, যাতে বাদ পড়ার অনুপাত চোখে পড়ে। দ্বিতীয়ত, “এখনো পৌঁছায়নি” অর্ডারের জন্য এখন পর্যন্ত পেরোনো দিন (আজ − অর্ডারের তারিখ) একটি আলাদা কলামে রাখুন, আর ন্যূনতম সীমা হিসেবে দেখান। তৃতীয়ত, ফাঁকা ঘরের অর্থ কী (পৌঁছায়নি, নাকি রেকর্ড হয়নি), তা ডেটার নথিতে লিখুন; পর্ব ২৩-এ missing value-এর ধরন নিয়ে যা দেখেছেন, এটি তারই বাস্তব রূপ।

AI মডেলের দিক থেকেও এটি জরুরি: এমন ডেটায় শেখা model “ডেলিভারি দ্রুত হয়” শিখবে, কারণ ধীরগুলো train ডেটায় নেই।

মূল উপলব্ধি: ফাঁকা ঘর random না হলে বাদ দেওয়া গড় পক্ষপাতদুষ্ট; রিপোর্টে সবসময় size আর count পাশাপাশি দিন আর ফাঁকার অর্থ লিখে রাখুন।

প্র ০৫Machine learning-এর feature বানাতে groupby().transform("mean") খুব কাজের। এটি কীভাবে data leakage আনতে পারে, আর কীভাবে নিরাপদে করবেন?

ধরুন বাজারঘর একটি model বানাতে চায় যা অনুমান করবে একটি অর্ডার ফেরত আসবে কি না। একটি স্বাভাবিক feature: “এই ক্রেতার আগের অর্ডারগুলোর গড় মূল্য” বা “এই জেলার ফেরতের হার”। df.groupby("district")["returned"].transform("mean") এক লাইনে প্রতিটি সারিতে এটি বসিয়ে দেয়। সমস্যা: এই গড়ে সেই সারির নিজের উত্তরটিও (returned) আছে, আর আছে test ডেটার সারিগুলোর উত্তরও। Model তখন উত্তরের একটি ছায়া দেখে শেখে, train-এ দারুণ ফল দেখায়, আর নতুন ডেটায় ভেঙে পড়ে। এর নাম data leakage, যা পর্ব ৫৬-এর মূল বিষয়।

ছোট দলে সমস্যা আরও তীব্র। যে জেলায় মাত্র একটি অর্ডার, সেখানে “জেলার ফেরতের হার” মানে ঠিক সেই অর্ডারের উত্তর, শতভাগ ফাঁস। এই ধরনের feature-কে সাধারণত target encoding বলা হয়, আর এর নিরাপদ রূপগুলো সুপরিচিত: শুধু train অংশ থেকে দলের গড় নেওয়া আর সেই মান test-এ বসানো; train-এর ভেতরেও cross-validation-এর ভাঁজ ধরে (out-of-fold) গড় নেওয়া, যাতে কোনো সারি নিজের উত্তর না দেখে; আর ছোট দলের গড়কে সামগ্রিক গড়ের দিকে টেনে আনা (smoothing). scikit-learn 1.3 থেকে TargetEncoder এই কাজটি ভেতরে cross-fitting দিয়ে করে; বিস্তারিত পর্ব ৬৩-৬৪-এ।

সময়ের ডেটায় আরেকটি ফাঁদ: “ক্রেতার গড় অর্ডার মূল্য” যদি পুরো বছরের ডেটা থেকে নেওয়া হয়, তাহলে মার্চের একটি অর্ডারের feature-এ ডিসেম্বরের তথ্য ঢুকে যায়, যা বাস্তবে মার্চে জানা সম্ভব ছিল না। এখানে দরকার শুধু আগের সারিগুলোর গড়, যেমন সময় ধরে সাজিয়ে groupby(...).expanding() বা shift-সহ cumsum (পর্ব ২৬)।

সাধারণ নিয়ম: groupby-এর যেকোনো feature বানানোর আগে নিজেকে জিজ্ঞেস করুন, “prediction-এর মুহূর্তে এই সংখ্যাটি কি সত্যিই জানা থাকত?” উত্তর “না” হলে সেটি ফাঁস।

মূল উপলব্ধি: দলভিত্তিক feature শক্তিশালী, কিন্তু শুধু train অংশ আর prediction-এর আগের তথ্য থেকে বানাতে হবে; নাহলে transform নিজের উত্তরই model-কে দেখিয়ে দেয়।

অনুশীলন

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

  1. হাতে হিসাব: পাঁচটি অর্ডার: জেলা [রাজশাহী, রংপুর, রাজশাহী, রাজশাহী, রংপুর], টাকা [400, 250, 100, 300, 750], ডেলিভারির দিন [2, NaN, 3, NaN, 4]. (ক) জেলাভিত্তিক মোট আর গড় টাকা। (খ) প্রতিটি জেলায় size, দিনের কলামের count আর গড় দিন। (গ) transform("sum") দিয়ে প্রতিটি অর্ডারের নিজের জেলার কত ভাগ। (ঘ) filter(lambda t: t["tk"].sum() >= 900)-এর পর কোন কোন সারি (০ থেকে গোনা index) থাকে?

    (ক) রাজশাহী: 400 + 100 + 300 = 800, গড় 800 ÷ 3 ≈ 266.67. রংপুর: 250 + 750 = 1000, গড় 500.

    (খ) রাজশাহী: size 3, count 2 (দিন 2 আর 3; একটি NaN), গড় (2 + 3) ÷ 2 = 2.5. রংপুর: size 2, count 1, গড় 4.0. গড় ভাগ হয় count দিয়ে, size দিয়ে নয়।

    (গ) 400 ÷ 800 = 0.5, 250 ÷ 1000 = 0.25, 100 ÷ 800 = 0.125, 300 ÷ 800 = 0.375, 750 ÷ 1000 = 0.75. প্রতিটি জেলায় যোগ 1.

    (ঘ) রাজশাহীর মোট 800 < 900, তাই পুরো দল বাদ; রংপুরের 1000, তাই থাকে: index 1 আর 4.

    লেখকের যন্ত্রে pandas 3.1.1 দিয়ে মেলানো: {'রংপুর': 1000, 'রাজশাহী': 800}, গড় {'রংপুর': 500.0, 'রাজশাহী': 266.67}, count {'রংপুর': 1, 'রাজশাহী': 2}, size {'রংপুর': 2, 'রাজশাহী': 3}, ভাগ [0.5, 0.25, 0.125, 0.375, 0.75], filter [1, 4].

  2. আউটপুট অনুমান করুন: চালানোর আগে প্রতিটি লাইনের আউটপুট লিখুন, তারপর যেকোনো ব্লকে বসিয়ে মিলিয়ে দেখুন।
    import pandas as pd
    df = pd.DataFrame({"d": ["a", "b", "a", "a"], "x": [1.0, None, 3.0, None],
                       "c": ["u", "v", "u", "w"]})
    g = df.groupby("d")
    print(g.size().tolist(), g["x"].count().tolist(), g["x"].sum().tolist())
    print(g["c"].nunique().tolist(), g["x"].transform("mean").tolist())
    print(df.groupby("d", as_index=False)["x"].sum().shape, g["x"].mean().tolist())
    [3, 1] [2, 0] [4.0, 0.0]
    [2, 1] [2.0, nan, 2.0, 2.0]
    (2, 2) [2.0, nan]

    দল "a"-তে ৩টি সারি, "b"-তে ১টি (size). x-এর ভরা ঘর "a"-তে ২টি, "b"-তে ০টি। ফাঁদটি sum-এ: "b"-এর একমাত্র ঘর NaN, তবু যোগফল 0.0, NaN নয় (pandas-এর min_count=0 default); অথচ mean সেখানে NaN. nunique: "a"-তে u, u, w মানে ২টি আলাদা। transform("mean") চারটি সারিতে নিজের দলের গড়: "a" = (1 + 3) ÷ 2 = 2.0, "b" = NaN. as_index=False-এ key একটি কলাম, তাই shape (2, 2) (লেখকের যন্ত্রে মেলানো, pandas 3.1.1).

  3. নকশা ও সমালোচনা: একজন সহকর্মী (বা একটি AI সহকারী) কুরিয়ার বাছাইয়ের রিপোর্টের জন্য নিচের কোড লিখেছেন। orders-এ courier, district, on_time (True/False), delivery_days (না পৌঁছালে NaN) আছে। অন্তত পাঁচটি সমস্যা চিহ্নিত করে একটি ভালো সংস্করণ লিখুন।
    rows = []
    for c, t in orders.groupby("courier"):
        rows.append({"courier": c, "n": t["delivery_days"].count(),
                     "rate": t["on_time"].mean(),
                     "days": t["delivery_days"].fillna(0).mean()})
    report = pd.DataFrame(rows)
    best = report.sort_values("rate").iloc[-1]["courier"]
    orders["avg_rate"] = orders.groupby("courier").apply(lambda t: t["on_time"].mean())
    • হাতে loop: groupby-এর ওপর for loop দিয়ে dict জমানো named aggregation-এর ধীর আর লম্বা রূপ।
    • ভুল n: delivery_days.count() না-পৌঁছানো অর্ডার বাদ দেয়; অর্ডারের সংখ্যা হলো size.
    • ফাঁকাকে 0 দিন: fillna(0) না-পৌঁছানো অর্ডারকে “০ দিনে পৌঁছেছে” বানায়, গড়কে কৃত্রিমভাবে ছোট করে।
    • শুধু মোট হার দিয়ে সিদ্ধান্ত: জেলার মিশ্রণ দেখা হয়নি; Simpson's paradox-এ উল্টো সিদ্ধান্ত আসতে পারে।
    • ভুল আকারের ফল বসানো: apply-এর ফল প্রতি কুরিয়ারে একটি (index = courier), মূল টেবিলের index-এর সঙ্গে মেলে না; কলামটি প্রায় সব NaN হবে। লাগবে transform("mean").
    report = (orders.groupby(["courier", "district"], as_index=False)
                    .agg(n=("on_time", "size"),
                         delivered=("delivery_days", "count"),
                         rate=("on_time", "mean"),
                         days=("delivery_days", "mean")))
    mix = orders.groupby("courier")["district"].value_counts(normalize=True)
    orders["avg_rate"] = orders.groupby("courier")["on_time"].transform("mean")

    এখন প্রতিটি (কুরিয়ার, জেলা) দলে n, পৌঁছানোর সংখ্যা, হার আর গড় দিন আছে; মিশ্রণ আলাদা দেখা যায়; আর avg_rate প্রতিটি সারিতে ঠিকভাবে বসে। সিদ্ধান্ত হবে জেলাভিত্তিক হার বা একই মিশ্রণে তুলনা থেকে, ছোট n-এর দলে সাবধানে।

  4. কোড লিখুন: বাজারঘরের ১২টি অর্ডারের orders.csv (এই পাতার ব্লকগুলো যেটি পড়ে) থেকে: শুধু যে জেলাগুলোতে অন্তত ৩টি অর্ডার, সেগুলোর জন্য প্রতিটি জেলার সবচেয়ে বেশি বিক্রির category আর জেলার মোট বিক্রিতে তার ভাগ বের করুন। apply ব্যবহার করবেন না।
    import pandas as pd
    df = pd.read_csv("orders.csv")
    df["amount"] = df["qty"] * df["price_paisa"]
    df = df.groupby("district").filter(lambda t: len(t) >= 3)
    cat = df.groupby(["district", "category"], as_index=False)["amount"].sum()
    cat["share"] = cat["amount"] / cat.groupby("district")["amount"].transform("sum")
    top = cat.sort_values("amount", ascending=False).groupby("district").head(1)
    print(top.sort_values("district").round(3))

    লেখকের যন্ত্রে সত্যিকারের run (pandas 3.1.1):

        district      category  amount  share
    0  চট্টগ্রাম         পোশাক  150000  0.566
    2       ঢাকা  ইলেকট্রনিক্স  700000  0.642

    চারটি ধাপ: filter খুলনা আর সিলেট বাদ দেয়; দুই key-এর groupby প্রতিটি (জেলা, category)-এর মোট দেয়; transform("sum") প্রতিটি সারিতে জেলার মোট বসিয়ে ভাগ বানায়; সাজিয়ে head(1) প্রতিটি জেলার সেরা category রাখে। হাতে মেলান: ঢাকার ইলেকট্রনিক্স 450000 + 250000 = 700000, ভাগ 700000 ÷ 1090000 ≈ 0.642. এখানে filter-এর lambda চলে মাত্র ৪টি দলে, তাই ঠিক আছে; অনেক দলে transform("size") >= 3 mask নিন।

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

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

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

  1. ❓ প্রশ্ন আর ফলের আকার ঠিক করা: প্রতি জেলায় এক সারি, নাকি প্রতি অর্ডারে
  2. 🔍 key কলামে NaN আর মান-কলামে ফাঁকা ঘর গোনা
  3. ✂️ groupby দিয়ে key ধরে দলে ভাগ
  4. 🧮 named aggregation-এ size, count, sum আর nunique
  5. ✅ দলের মোটের যোগ পুরো টেবিলের মোটের সমান কি না যাচাই
  6. 🧭 একটি সম্ভাব্য confounding key ধরে দলে দলে দেখে সিদ্ধান্ত

pandas-এ একটি DataFrame-এর district কলামে "ঢাকা" ৫টি সারিতে আছে, আর তাদের মধ্যে ২টিতে delivery_days NaN. df.groupby("district").size() আর df.groupby("district")["delivery_days"].count() ঢাকার জন্য কত দেবে?

  • ৩ আর ৩
  • ৩ আর ৫
  • ৫ আর ৩
  • ৫ আর ৫

pandas-এ ১,০০০ সারি আর ২০টি জেলার একটি DataFrame-এ df.groupby("district")["amount"].transform("sum")-এর ফলের দৈর্ঘ্য কত, আর তার index কী?

  • ২০, index = জেলা
  • ১,০০০, index = মূল DataFrame-এর index
  • যতগুলো সারির amount NaN নয়, ততগুলো
  • ১, পুরো টেবিলের মোট

pandas-এ g = df.groupby("district") (একটি DataFrameGroupBy). জেলাভিত্তিক রিপোর্টে কলামের নাম total চাই, যা amount-এর যোগফল। কোনটি বর্তমান pandas-এ সঠিক রূপ?

  • g.agg({"amount": {"total": "sum"}})
  • g.agg(total="sum")
  • g["amount"].sum(name="total")
  • g.agg(total=("amount", "sum"))

দুই কুরিয়ারের সময়মতো পৌঁছানোর হার: ঢাকায় A 95%, B 90%; বান্দরবানে A 70%, B 60%. A-এর ৮০% অর্ডার বান্দরবানে, B-এর ৮০% ঢাকায়। সব মিলিয়ে হার কত, আর কেন?

  • A 75%, B 84%; অসম জেলা-মিশ্রণের কারণে মোটে B এগিয়ে
  • A 82.5%, B 75%; প্রতিটি জেলায় এগিয়ে, তাই মোটেও A এগিয়ে
  • A 82.5%, B 75%; দুই জেলার হারের সাধারণ গড়
  • হিসাব করা সম্ভব নয়, মোট অর্ডারের সংখ্যা লাগবে

pandas-এ ১০ লাখ সারি আর প্রায় ২ লাখ customer_id-এর একটি DataFrame-এ প্রতিটি অর্ডার নিজের ক্রেতার মোট খরচের কত ভাগ, তা বের করতে হবে। কোনটি সবচেয়ে ভালো?

  • df.groupby("customer_id")["amount"].apply(lambda s: s / s.sum())
  • iterrows দিয়ে প্রতিটি সারিতে ক্রেতার মোট খুঁজে ভাগ
  • df["amount"] / df.groupby("customer_id")["amount"].transform("sum")
  • df["amount"] / df.groupby("customer_id")["amount"].agg("sum")

pandas-এ একটি DataFrame-এর payment কলামে ১২টি সারির মধ্যে ১টি NaN. df.groupby("payment").size().sum() কত দেবে, আর SQL-এ একই টেবিলে SELECT payment, COUNT(*) … GROUP BY payment-এর সারিগুলোর যোগ কত?

  • pandas ১২, SQL ১২
  • pandas ১১, SQL ১২
  • pandas ১১, SQL ১১
  • pandas ১২, SQL ১১

শব্দকোষ

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

মনে রাখুন

  1. Split-apply-combine: key ধরে ভাগ, প্রতিটি দলে হিসাব, ফল জোড়া; groupby নিজে হিসাব করে না, হিসাব হয় method ডাকলে।
  2. size সব সারি, count শুধু ভরা ঘর; NaN key default-এ বাদ পড়ে (dropna=False দিয়ে ফেরান); রিপোর্টে গড়ের পাশে দুটিই দিন।
  3. named aggregation (total=("amount", "sum")) দিয়ে পরিষ্কার রিপোর্ট; key কলাম চাইলে as_index=False বা reset_index.
  4. ফলের আকার দিয়ে পথ বাছাই: দলে এক সারি agg, প্রতিটি সারিতে দলের মান transform, পুরো দল রাখা-বাদ filter, আর apply শেষ উপায়, দলের সংখ্যা দেখে।
  5. সব মিলিয়ে হার দলের মিশ্রণের ওপর নির্ভর করে: Simpson's paradox এড়াতে সম্ভাব্য confounding key ধরে দলে দলে দেখুন, বা একই মিশ্রণে তুলনা করুন।

পরের পর্বে: Merge, join আর reshape. বাজারঘরের অর্ডার, পণ্য আর ক্রেতার টেবিল key ধরে জোড়া দেওয়া, join-এর ধরন আর সারি বেড়ে যাওয়ার ফাঁদ, আর pivot, melt, stack দিয়ে লম্বা আর চওড়া টেবিলের মধ্যে যাতায়াত।

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

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