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

বড় ডেটায় pandas — dtype, category, chunk আর Polars-এর পরিচয়

pandas at Scale — Measuring Memory, Smaller dtypes, Categoricals, Chunked Aggregation, Vectorisation, and an Introduction to Polars and DuckDB

Taught in Bangla, this lesson measures real DataFrame memory, shrinks tables with downcasting, nullable dtypes, the pandas 3 str dtype and category, reads less with usecols, dtype and chunksize while combining partial sums correctly, compares vectorised code with apply and iterrows, and introduces Polars lazy queries and DuckDB with real benchmark runs on a five-million-row file.

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

  • একটি DataFrame-এর আসল মেমরি মাপা, আর কোন কলাম সবচেয়ে বেশি খাচ্ছে তা বের করা
  • downcast, category, pandas 3-এর নতুন str dtype আর nullable dtype দিয়ে টেবিল ছোট করা, ঝুঁকিসহ
  • usecols, dtype, nrows আর chunksize দিয়ে বড় ফাইল পড়া, আর আংশিক যোগফল ঠিকভাবে জোড়া
  • vectorised হিসাব কেন apply আর iterrows-এর চেয়ে অনেক দ্রুত, আর eval/query কোথায় খাটে
  • Polars-এর eager আর lazy পথ, query plan, DuckDB-এর SQL, আর কখন কোন টুল বাছবেন

মডিউল ৪-এর আগের পর্বগুলোতে pandas দিয়ে বাজারঘরের টেবিল পড়া, বাছাই, পরিষ্কার, groupby, join আর সময়ের হিসাব দেখেছেন (পর্ব ২০ থেকে পর্ব ২৬)। সেখানে টেবিল ছিল কয়েকশো বা কয়েক হাজার সারির। কিন্তু দোকান বড় হলে এক বছরের অর্ডার হয় কয়েক কোটি সারি, আর একদিন ল্যাপটপ বলে বসে MemoryError, অথবা একটি ছোট হিসাব চলে দশ মিনিট। এই পর্ব সেই মুহূর্তের জন্য। NumPy-র দিক থেকে গতি আর মেমরির মূল নিয়ম (মাপার নিয়ম, dtype, overflow, temporary array) আছে পর্ব ১৯-এ; এখানে সেগুলো আবার শেখানো হবে না, শুধু pandas-এর টেবিলে প্রয়োগ, আর তার পরের ধাপ: Polars আর DuckDB.

১ · “বড় ডেটা” আসলে কত বড় — RAM আর মেমরি মাপা

চায়ের দোকানের হিসাব খাতা পকেটে ধরে। পাইকারি আড়তের দশ বছরের খাতা ধরে না; তার জন্য আলমারি লাগে, আর সব খাতা একসঙ্গে টেবিলে খুলে বসা যায় না। কম্পিউটারে “টেবিল” হলো RAM. pandas পুরো DataFrame RAM-এ রাখে, তাই ডেটা “বড়” কি না তার প্রথম মাপকাঠি ফাইলের আকার নয়, RAM-এ তোলার পর তার আকার। আর এই দুটি প্রায়ই মেলে না: ৩০০ MB-এর একটি CSV RAM-এ ২০০ MB-ও হতে পারে, ১ GB-ও হতে পারে, নির্ভর করে কলামের dtype-এর ওপর।

pandas-এর স্রষ্টা Wes McKinney ২০১৭ সালের একটি লেখায় বলেছিলেন, তাঁর অভিজ্ঞতায় pandas-এ আরামে কাজ করতে ডেটার আকারের প্রায় ৫ থেকে ১০ গুণ RAM লাগত (সেটি তখনকার pandas-এর জন্য একটি মোটা দাগের অভিজ্ঞতা, কোনো নিয়ম নয়)। কারণ শুধু টেবিল নয়: প্রতিটি filter, নতুন কলাম বা merge সাময়িকভাবে নতুন কপি বানাতে পারে। pandas 3-এ Copy-on-Write একমাত্র আচরণ হওয়ায় অনেক অপ্রয়োজনীয় কপি কমেছে, তবু বাড়তি জায়গা লাগে। তাই প্রথম কাজ: মাপা।

pandas-এ মাপার দুটি পথ। df.memory_usage() প্রতিটি কলামের byte দেয়; df.info() সারাংশ দেয়। দুটিরই একটি গুরুত্বপূর্ণ বিকল্প আছে: deep. পুরোনো ধাঁচের object কলামে প্রতিটি ঘরে থাকে শুধু একটি pointer (৬৪-বিট যন্ত্রে ৮ byte), আর আসল লেখাটি থাকে আলাদা একটি Python object-এ (boxed object). deep=True না দিলে pandas শুধু pointer-গুলো গোনে, লেখাগুলো নয়, তাই হিসাব অনেক কম দেখায়। deep=True দিলে প্রতিটি object-এর আকারও যোগ হয়; এটি একটু ধীর, কারণ সত্যিই প্রতিটি ঘর ঘুরে দেখতে হয়।

df.memory_usage(deep=True)          # কলাম ধরে byte, index-সহ
df.memory_usage(deep=True).sum() / 1e6   # মোট, MB-তে (10^6 byte)
df.info(memory_usage="deep")        # সারাংশ; শেষ লাইনে মোট

একটি সূক্ষ্মতা: info()-এর শেষ লাইনে “MB” লেখা থাকলেও pandas সেখানে ১০২৪ দিয়ে ভাগ করে (আসলে MiB). তাই লেখকের ল্যাপটপে ২ লাখ সারির একটি টেবিলে memory_usage(deep=True).sum() / 1e6 দিল ১৩.৯৩, আর info দেখাল “13.3 MB”; দুটিই ঠিক, একক আলাদা। ছোট টেবিলে এটি নগণ্য, কিন্তু রিপোর্টে দুটি সংখ্যা পাশাপাশি লিখলে বিভ্রান্তি হয়।

টেবিল একাই RAM-এর অর্ধেকের বেশি নিলে প্রথম groupby বা merge-এই সমস্যা হতে পারে। তখন এই পর্বের ক্রমে ভাবুন: ছোট করা, কম পড়া, ভাগ করে পড়া, তারপর টুল বদলানো।

২ · সংখ্যার কলাম ছোট করা — downcast আর nullable dtype

বাজারঘরের অর্ডারে qty (পরিমাণ) ১ থেকে ৫, delivery_days ১ থেকে ৭. অথচ pandas এগুলো ডিফল্টে রাখে int64-এ, প্রতিটি সংখ্যায় ৮ byte, যা প্রায় ৯২ হাজার কোটি কোটি (৯.২ × ১০১৮) পর্যন্ত ধরে। ১ থেকে ৭ রাখতে int8 (−১২৮ থেকে ১২৭, ১ byte) যথেষ্ট। এক কোটি সারিতে এই এক কলামেই ৮০ MB বনাম ১০ MB. বাজারের ব্যাগে এক কেজি চাল আনতে বস্তা লাগে না।

হাতে হাতে dtype না লিখে pandas-কে বলা যায় “সবচেয়ে ছোট যেটিতে ধরে, সেটি দাও”: এর নাম downcasting.

df["qty"] = pd.to_numeric(df["qty"], downcast="integer")        # int8/16/32
df["order_id"] = pd.to_numeric(df["order_id"], downcast="unsigned")  # uint8/16/32
df["ratio"] = pd.to_numeric(df["ratio"], downcast="float")      # float32

downcast-এর চারটি মান: "integer" (চিহ্নসহ পূর্ণসংখ্যা), "signed" (একই), "unsigned" (শুধু শূন্য বা ধনাত্মক) আর "float". pandas কলামের সর্বনিম্ন আর সর্বোচ্চ মান দেখে বাছে। তিনটি সাবধানতা আছে, আর তিনটিই চুপচাপ ঘটে।

  • ভবিষ্যতের হিসাবে overflow: downcast এখনকার মান দেখে। পরে সেই কলামে গুণ করলে ফল সেই ছোট dtype-এই থাকে। লেখকের ল্যাপটপে সত্যিকারের run (pandas 3.1.1): pd.Series([2, 5], dtype="int8") * 100 দিল [-56, -12], কোনো সতর্কবার্তা ছাড়া, কারণ ৫০০ int8-এ ধরে না (পর্ব ১৯-এর integer overflow). sum() অবশ্য নিরাপদ: pandas যোগফল int64-এ করে, তাই একই কলামের ১০০ + ১০০ দিল 200. নিয়ম: গুণ বা টাকার হিসাবের আগে .astype("int64").
  • float32-এর নির্ভুলতা: downcast="float" দিলে প্রায় ৭ অঙ্ক থাকে। টাকা কখনো float-এ নয়; এই কোর্সে টাকা সবসময় পূর্ণসংখ্যার পয়সা।
  • ফাঁকা ঘর থাকলে কাজ হয় না: NumPy-র integer-এ “নেই” রাখার জায়গা নেই, তাই [3, None, 5] pandas রাখে float64-এ ([3.0, nan, 5.0]), আর downcast="integer" দিলেও সেটি float64-ই থাকে (লেখকের যন্ত্রে সত্যিকারের run).

শেষ সমস্যার সমাধান nullable dtype: বড় হাতের নামের integer, যেমন "Int8", "Int64", আর "boolean". এগুলো মানের পাশে একটি আলাদা “আছে/নেই” mask রাখে, আর ফাঁকা ঘর দেখায় <NA> হিসেবে (pd.NA). লেখকের যন্ত্রে pd.Series([3, None, 5], dtype="Int64") দিল [3, <NA>, 5], যোগফল 8, আর মেমরি ২৭ byte: তিনটি ৮-byte মান আর তিনটি ১-byte mask. "Int8"-এ একই তিনটি মান ৬ byte. df.convert_dtypes() পুরো টেবিলকে এক লাইনে nullable dtype-এ বদলায়। পূর্ণসংখ্যার কলামে ফাঁকা থাকলে (যেমন “ডেলিভারি এখনো হয়নি”) float-এর nan-এর চেয়ে এটি অনেক সৎ: ৩ দিন ৩.০ দিন হয়ে যায় না।

৩ · লেখার কলাম — object, pandas 3-এর str আর category

বাজারঘরের ২ লাখ অর্ডারে জেলার নাম মাত্র ৮টি, পেমেন্টের ধরন ৩টি (COD, bKash, card). অথচ প্রতিটি সারিতে নামটি আবার লেখা। স্কুলের হাজিরা খাতায় প্রতিদিন প্রতিটি ছাত্রের পুরো নাম লেখা হয় না; রোল নম্বর লেখা হয়, আর খাতার প্রথম পাতায় একবার রোল থেকে নামের তালিকা। pandas-এর category dtype ঠিক এটি করে: প্রতিটি আলাদা মান একবার একটি তালিকায় (categories), আর প্রতিটি সারিতে শুধু একটি ছোট পূর্ণসংখ্যার code. Parquet ফাইলের dictionary encoding-ও একই ধারণা (পর্ব ১০)।

pandas 3-এ গল্পে একটি নতুন চরিত্র এসেছে। আগে লেখার কলাম ডিফল্টে object ছিল। pandas 3.0 থেকে ডিফল্ট হলো নতুন str dtype: pyarrow install থাকলে সেটি লেখাগুলো Apache Arrow-এর বিন্যাসে রাখে, একটি টানা byte-এর buffer আর প্রতিটি সারি কোথায় শুরু তার একটি offset-তালিকায়; pyarrow না থাকলে Python object-এই রাখে। নিচের ছবিতে একই চারটি সারি তিনভাবে।

একই district কলাম, মেমরিতে তিন রকম

object (pointers to Python strings) vs str (Arrow buffer + offsets) vs category (int8 codes + categories)

object, str আর category কলামের মেমরি বিন্যাস rows: ঢাকা, খুলনা, ঢাকা, সিলেট ক) object — pointer + Python object ptr 8B ptr 8B ptr 8B ptr 8B str object "ঢাকা" ≈ 80B str object "খুলনা" ≈ 80B str object "ঢাকা" ≈ 80B str object "সিলেট" ≈ 80B deep=True ছাড়া শুধু বাঁয়ের ৪ × 8B গোনা হয় খ) str (pyarrow) — offsets + টানা UTF-8 0 12 27 39 54 offsets ঢাকা 12B খুলনা 15B ঢাকা 12B সিলেট 15B একটি buffer, ৫৪ byte; সারি i = buffer[off[i]:off[i+1]] গ) category — int8 codes + categories 1 0 1 2 codes, প্রতিটি 1B 0 → খুলনা 1 → ঢাকা 2 → সিলেট categories, একবারই সারি বাড়লে বাড়ে শুধু এটি
ছবি ২৭.১ — (ক) object-এ প্রতিটি সারি একটি আলাদা Python object, “ঢাকা” দুবার আলাদা করে গোনা হয়। (খ) pyarrow-সহ str-এ সব লেখা একটি টানা UTF-8 buffer-এ (বাংলার প্রতিটি অক্ষর-চিহ্ন ৩ byte), আর offsets বলে কোন সারি কোথায়। (গ) category-তে প্রতিটি সারি একটি ১-byte code; নামগুলো একবারই। (Object-এর ≈ ৮০ byte একটি মোটা দাগের মান: ৬৪-বিট desktop-এ sys.getsizeof("ঢাকা") Python 3.11-এ ৮২, আর 3.13 ও 3.14-এ ৬৬; ব্রাউজারের ৩২-বিট Python-এ আরও কম।)

এবার ধাপে ধাপে দেখুন, ছয় সারির একটি জেলা-কলাম কীভাবে category হয়, আর তুলনার সময় pandas আসলে কী তুলনা করে।

1s = pd.Series(["ঢাকা", "খুলনা", "ঢাকা", "সিলেট", "ঢাকা", "খুলনা"])2c = s.astype("category")3c.cat.categories4c.cat.codes5c == "ঢাকা"6# 200,000 rows, same idea
মান (Watch)
categories
codes
প্রতি সারিতে জায়গা
তুলনা
  1. শুরুর কলাম। ছয়টি সারি, কিন্তু আলাদা মান মাত্র তিনটি। প্রতিটি সারিতে পুরো লেখাটি রাখা, “ঢাকা” তিনবার।
  2. Categories বানানো। pandas আলাদা মানগুলো খুঁজে সাজায়: খুলনা, ঢাকা, সিলেট. সাজানো হয় Unicode code point-এর ক্রমে (খ U+0996, ঢ U+09A2, স U+09B8), বর্ণমালার মুখস্থ ক্রমে নয়।
  3. Code বসানো। প্রতিটি সারিতে তার নামের ক্রমিক নম্বর: ঢাকা = 1, খুলনা = 0, সিলেট = 2. তাই codes হলো 1, 0, 1, 2, 1, 0.
  4. Code-এর dtype. মান মাত্র তিনটি, তাই pandas code রাখে int8-এ, প্রতি সারি ১ byte. ১২৭টির বেশি আলাদা মান হলে int16, এভাবে।
  5. তুলনা। c == "ঢাকা" লিখলে pandas একবার দেখে নেয় “ঢাকা”-র code 1, তারপর পূর্ণসংখ্যার তুলনা চালায় codes == 1. লেখা মেলানোর চেয়ে এটি অনেক সস্তা। ফল: True, False, True, False, True, False.
  6. বড় টেবিলে। ২ লাখ সারি আর ৮টি জেলা হলে codes ২ লাখ byte, প্রায় ০.২ MB; categories-এর আটটি নাম তার পাশে নগণ্য। নিচের কোডে ঠিক এই সংখ্যাটিই আসে।

এবার বাজারঘরের ২ লাখ সারির বানানো অর্ডার-টেবিল (seed 42) নিয়ে আগে-পরে মাপুন: চারটি সংখ্যার কলাম downcast, দুটি লেখার কলাম category. প্রথমবার চালালে NumPy আর pandas নামাতে কয়েক সেকেন্ড লাগতে পারে।

import numpy as np
import pandas as pd
pd.set_option("display.width", 60)

rng = np.random.default_rng(42)
n = 200_000
districts = ["ঢাকা", "চট্টগ্রাম", "খুলনা", "রাজশাহী",
             "সিলেট", "বরিশাল", "রংপুর", "ময়মনসিংহ"]
df = pd.DataFrame({
    "order_id": np.arange(1, n + 1, dtype=np.int64),
    "district": rng.choice(districts, n, p=[.35, .2, .1, .1, .08, .05, .06, .06]),
    "payment": rng.choice(["COD", "bKash", "card"], n, p=[.5, .35, .15]),
    "qty": rng.integers(1, 6, n, dtype=np.int64),
    "price_paisa": rng.integers(5_000, 500_000, n, dtype=np.int64),
    "delivery_days": rng.integers(1, 8, n, dtype=np.int64),
})
print("str dtype:", repr(df["district"].dtype))
print("shallow MB:", round(df.memory_usage().sum() / 1e6, 2),
      "| deep MB:", round(df.memory_usage(deep=True).sum() / 1e6, 2))

small = df.copy()
for c in ["order_id", "qty", "price_paisa", "delivery_days"]:
    small[c] = pd.to_numeric(small[c], downcast="integer")
for c in ["district", "payment"]:
    small[c] = small[c].astype("category")
mem = pd.DataFrame({"before": df.memory_usage(deep=True, index=False),
                    "after": small.memory_usage(deep=True, index=False)}) / 1e6
mem["dtype"] = small.dtypes.astype(str)
print(mem.round(2))
print("total MB:", round(mem["before"].sum(), 2), "->", round(mem["after"].sum(), 2))
print("district as object MB:",
      round(df["district"].astype(object).memory_usage(deep=True) / 1e6, 2))
print("codes:", small["district"].cat.codes.dtype, small["district"].cat.codes.head(3).tolist())
print("same data?", (small["district"].astype(str) == df["district"]).all(),
      int(small["price_paisa"].sum()) == int(df["price_paisa"].sum()))

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

str dtype: <StringDtype(storage='python', na_value=nan)>
shallow MB: 8.0 | deep MB: 21.35
               before  after     dtype
order_id         1.60    0.8     int32
district        12.94    0.2  category
payment          5.77    0.2  category
qty              1.60    0.2      int8
price_paisa      1.60    0.8     int32
delivery_days    1.60    0.2      int8
total MB: 25.11 -> 2.4
district as object MB: 12.94
codes: int8 [7, 1, 3]
same data? True True

প্রথম লাইনটি আগে দেখুন: এখানে str dtype-এর storage 'python'. এই পাতার ব্রাউজার-Python শুধু কোডে import করা package নামায়, তাই pandas pyarrow পায়নি, আর লেখাগুলো রেখেছে Python object-এ। ফল: shallow হিসাব ৮.০ MB, কিন্তু deep হিসাব ২১.৩৫ MB, কারণ shallow শুধু pointer গুনেছে; আর district কলাম str হিসেবে যা (১২.৯৪ MB), object হিসেবেও ঠিক তাই (১২.৯৪ MB). Downcast আর category-র পর মোট ২৫.১১ MB থেকে ২.৪ MB, প্রায় ১০ ভাগের এক ভাগ: সংখ্যার চারটি কলাম ৮ byte থেকে ১ বা ৪ byte-এ, আর দুটি লেখার কলাম ১ byte-এর code-এ (int8, প্রথম তিনটি code 7, 1, 3, মানে সিলেট, চট্টগ্রাম, বরিশাল)। শেষ লাইন নিশ্চিত করে ডেটা একটুও বদলায়নি। একটি জিনিস চোখে পড়ার মতো: পুরো টেবিলের deep হিসাব (২১.৩৫ MB) আর কলামগুলো আলাদা করে যোগ করা হিসাব (২৫.১১ MB) এই run-এ মেলেনি; কারণটি আমরা নিশ্চিত করিনি, তাই তুলনা করার সময় সবসময় একই পদ্ধতির দুটি সংখ্যা পাশাপাশি রাখুন। লেখকের ল্যাপটপে একই কোড (pandas 3.1.1, pyarrow 26 install করা) দিয়েছে <StringDtype(na_value=nan)>, অর্থাৎ Arrow-ভিত্তিক str; সেখানে district str হিসেবে ৫.১৬ MB, object হিসেবে ১৫.৫৮ MB, category হিসেবে ০.২ MB, আর shallow ও deep দুটিই ১৩.৯৩ MB (Arrow-এর buffer সরাসরি গোনা যায়)। তাই pandas 3-এর str dtype-এর সাশ্রয় নির্ভর করে pyarrow install আছে কি না তার ওপর; category দুই জায়গাতেই সবচেয়ে ছোট। সংখ্যাগুলো যন্ত্রভেদে আলাদা, কারণ ব্রাউজারের ৩২-বিট Python-এ pointer ৪ byte.

মূল ধারণা

category তখনই, যখন আলাদা মান কম আর সারি অনেক। জেলা, পেমেন্টের ধরন, পণ্যের শ্রেণি, মাসের নাম: ভালো প্রার্থী। order_id, ফোন নম্বর, রিভিউর লেখা: খারাপ, কারণ প্রায় প্রতিটি মান আলাদা, তাই categories-এর তালিকা মূল কলামের মতোই বড় হয়ে যায়, সঙ্গে code-এর বাড়তি জায়গা। একটি সহজ পরীক্ষা: s.nunique() / len(s) খুব ছোট (ধরুন ৫%-এর কম) হলে category বিবেচনা করুন, তারপর মেপে নিশ্চিত হন।

category-র আরও দুটি আচরণ মনে রাখুন। প্রথমত, pandas 3-এ category কলামে groupby-এর observed ডিফল্টে True: শুধু ডেটায় থাকা category-গুলোর ফল আসে (আগে সব category আসত, খালিগুলোসহ, আর pandas 2.x সতর্ক করত)। দ্বিতীয়ত, আলাদা categories-ওয়ালা দুটি কলাম pd.concat করলে ফল আর category থাকে না; লেখকের যন্ত্রে pandas 3.1.1-এ ফল হলো str. বড় ডেটা টুকরো টুকরো পড়ে জোড়া লাগালে (অংশ ৫) এই কারণে আবার astype("category") লাগতে পারে, অথবা আগে থেকে একই categories ঠিক করে দিতে হয় (pd.CategoricalDtype([...]))।

৪ · কম পড়া — usecols, পড়ার সময়েই dtype, আর nrows

বাজার থেকে শুধু ডিম আনতে বললে পুরো দোকান কিনে আনার দরকার নেই। টেবিল ছোট করার সবচেয়ে সস্তা পথ হলো যা লাগবে না, তা RAM-এ না তোলা। pd.read_csv-এর তিনটি parameter এখানে কাজের (পড়া-লেখার সাধারণ পরিচয় আছে পর্ব ২১-এ):

  • usecols: শুধু এই কলামগুলো পড়া হয়। CSV-তে ফাইলের প্রতিটি লাইন তবু পড়তে হয়, কিন্তু বাকি কলাম থেকে কোনো Python বা pandas মান তৈরি হয় না। Parquet-এ (কলাম ধরে রাখা ফাইল) বাদ দেওয়া কলামগুলো ডিস্ক থেকে পড়াই হয় না।
  • dtype: পড়ার সময়েই বলে দিন কোন কলাম কোন dtype. পরে বদলালে আগে বড় dtype-এ পুরো কলাম তৈরি হয়, তারপর ছোটটি; চূড়ায় দুটিই একসঙ্গে থাকে। পড়ার সময় বললে সেই চূড়া এড়ানো যায়।
  • nrows: প্রথম কয়েকটি সারি পড়ে কলামের নাম, ধরন আর অদ্ভুত মান দেখে নিন, তারপর পুরো ফাইলের পরিকল্পনা করুন।

নিচে বাজারঘরের ১২০টি অর্ডারের একটি ছোট CSV পাতার ভেতরেই রাখা আছে (orders.csv নামে)। এত ছোট ফাইলে সংখ্যাগুলো KB-তে, কিন্তু অনুপাতটিই আসল কথা, আর সেটি বড় ফাইলেও প্রায় একই থাকে।

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

def kb(frame):
    return round(frame.memory_usage(deep=True).sum() / 1024, 1)

full = pd.read_csv("orders.csv")                      # সব কলাম, অনুমিত dtype
print("all columns:", full.shape, kb(full), "KB")
print({str(k): v for k, v in full.dtypes.astype(str).value_counts().items()})

lean = pd.read_csv(
    "orders.csv",
    usecols=["district", "payment", "qty", "price_paisa"],
    dtype={"district": "category", "payment": "category",
           "qty": "int8", "price_paisa": "int32"},
)
print("usecols + dtype:", lean.shape, kb(lean), "KB")
print({c: str(t) for c, t in lean.dtypes.items()})

peek = pd.read_csv("orders.csv", nrows=5)              # শুধু প্রথম ৫ সারি
print("nrows=5:", peek.shape)
print(peek[["order_id", "district", "qty"]].head(3))

a = (full["qty"] * full["price_paisa"]).sum()
b = (lean["qty"].astype("int64") * lean["price_paisa"]).sum()
print("same total paisa?", a == b, a)

ব্রাউজারে (Pyodide) একটি run-এর আসল আউটপুট:

all columns: (120, 10) 26.3 KB
{'int64': 5, 'str': 5}
usecols + dtype: (120, 4) 1.4 KB
{'district': 'category', 'qty': 'int8', 'price_paisa': 'int32', 'payment': 'category'}
nrows=5: (5, 10)
   order_id district  qty
0      1001     ঢাকা    4
1      1002    সিলেট    3
2      1003  রাজশাহী    2
same total paisa? True 88188000

সব কলাম পড়লে ১২০টি সারি RAM-এ ২৬.৩ KB: পাঁচটি int64 আর পাঁচটি str কলাম (ব্রাউজারে str আবার Python object-এ)। দরকারি চারটি কলাম, পড়ার সময়েই ছোট dtype দিয়ে, নেয় মাত্র ১.৪ KB, প্রায় ১৯ ভাগের এক ভাগ। Category দুটি এসেছে সরাসরি পড়া থেকে, পরে বদলাতে হয়নি। nrows=5 পাঁচটি সারি পড়েছে কিন্তু দশটি কলামই, তাই এটি কলাম আর মান চেনার জন্য। শেষ লাইন দেখায় হিসাব বদলায়নি: দুই পথেই মোট ৮৮,১৮৮,০০০ পয়সা, মানে ৮,৮১,৮৮০ টাকা। লক্ষ করুন, গুণের আগে qty-কে int64 করা হয়েছে, অংশ ২-এর overflow-এর সাবধানতা মেনে।

বড় ফাইলে একই কৌশল কেমন দেখায়? লেখকের ল্যাপটপে ৫০ লাখ সারি আর ১০ কলামের একটি বানানো অর্ডার-ফাইল (CSV ৩১২ MB) নিয়ে সত্যিকারের run (Python 3.14.4, pandas 3.1.1, প্রতিটি সারি একবার মাপা):

~/bazarghar-ai$ python read_less.py
pandas 3.1.1
all columns             0.21 s    539 MB
all, engine=pyarrow     0.30 s    649 MB
usecols (4 columns)     0.48 s    215 MB
usecols + dtype         0.62 s     35 MB

শেষ লাইনের dtype হলো কোডের মতোই: দুটি category, int8 আর int32. মেমরিতে ফল পরিষ্কার: ৫৩৯ MB থেকে ৩৫ MB, প্রায় ১৫ ভাগের এক ভাগ। কিন্তু সময়ের দিকটি সৎভাবে দেখুন: এই run-এ usecols আর dtype দিয়ে পড়া একটু ধীর হয়েছে, কারণ লেখা থেকে category বানাতে বাড়তি কাজ লাগে। তাই “কম পড়া” মূলত মেমরির ওষুধ; সময় বাঁচাতে আসল লাফ আসে ফাইলের ধরন বদলালে (অংশ ৮)। আর লক্ষ করুন, এই যন্ত্রে ৩১২ MB CSV পড়তে মাত্র ০.২ সেকেন্ড লেগেছে; আপনার যন্ত্রে, বা পুরোনো pandas-এ, সংখ্যাটি অনেক আলাদা হতে পারে। এটি একটি যন্ত্রের একটি run, কোনো সাধারণ ফল নয়।

৫ · টুকরো টুকরো পড়া — chunksize আর আংশিক যোগফল ঠিকভাবে জোড়া

ফাইল যদি RAM-এই না ধরে? তখন বোর্ড পরীক্ষার খাতা দেখার মতো করুন: পরীক্ষকেরা খাতা বান্ডিলে ভাগ করে দেখেন, প্রতিটি বান্ডিলের মোট নম্বর আর খাতার সংখ্যা একটি কাগজে লেখেন, শেষে সব কাগজ জুড়ে মোট গড় বের করেন। pandas-এ এর নাম chunking: pd.read_csv(..., chunksize=100_000) পুরো ফাইল একবারে না দিয়ে ১ লাখ সারির একেকটি DataFrame একে একে দেয়। যেকোনো সময় RAM-এ থাকে শুধু একটি টুকরো আর আপনার জমানো ছোট হিসাব।

with pd.read_csv("orders.csv", chunksize=100_000) as reader:
    for chunk in reader:          # প্রতিটি chunk একটি সাধারণ DataFrame
        ...                       # ছোট আংশিক ফল জমান

ফাঁদটি এখানে। ধরুন প্রশ্ন “গড় ডেলিভারি কত দিন?”. সহজ মনে হয় প্রতিটি টুকরোর গড় বের করে, শেষে সেই গড়গুলোর গড় নেওয়া। এটি ভুল, যদি না সব টুকরোর আকার সমান হয় (আর groupby-তে প্রতিটি দলের সারি প্রতিটি টুকরোয় সমান হয়, যা প্রায় কখনো হয় না)। শেষ টুকরো প্রায় সবসময় ছোট। গড় এমন একটি হিসাব যা সরাসরি জোড়া যায় না; জোড়া যায় sum আর count. তাই প্রতিটি টুকরো থেকে sum আর count রাখুন, শেষে মোট sum ÷ মোট count. এমন জোড়া-যোগ্য হিসাবকে বলে partial aggregate.

সঠিক গড় = (S1 + S2 + … + Sk) ÷ (n1 + n2 + … + nk)

এখানে Si হলো i-তম টুকরোর যোগফল আর ni তার সারির সংখ্যা। আর গড়ের গড় হলো (S1/n1 + … + Sk/nk) ÷ k, যা প্রতিটি টুকরোকে সমান গুরুত্ব দেয়, তার সারি যত কমই হোক। পাতার orders.csv-এর ১২০টি অর্ডারকে ৫০টি করে ভাগ করলে টুকরো হয় ৫০, ৫০ আর ২০টির; আর শেষ ২০টি অর্ডার ঈদের ভিড়ের, ডেলিভারি অনেক দেরিতে। ধাপে ধাপে দেখুন।

1total, count, means = 0, 0, []2for ch in pd.read_csv(..., chunksize=50):3    total += ch["delivery_days"].sum(); count += len(ch)4    means.append(ch["delivery_days"].mean())5right = total / count6wrong = sum(means) / len(means)
মান (Watch)
এই chunk
chunk-এর গড়
total
count
means
  1. শুরু। মোট যোগফল আর সারির সংখ্যা শূন্য; তুলনার জন্য প্রতিটি টুকরোর গড়ও একটি তালিকায় রাখা হবে।
  2. Chunk 0. প্রথম ৫০টি অর্ডারের ডেলিভারি-দিনের যোগফল 109, গড় 2.180. total = 109, count = 50.
  3. Chunk 1. পরের ৫০টির যোগফল 102, গড় 2.040. total = 211, count = 100. এই টুকরো ফেলে দেওয়া যায়; দরকার শুধু দুটি সংখ্যা।
  4. Chunk 2. শেষ টুকরোয় মাত্র ২০টি অর্ডার, ঈদের ভিড়ে যোগফল 141, গড় 7.050. total = 352, count = 120.
  5. সঠিক গড়। 352 ÷ 120 = 2.933 দিন. প্রতিটি অর্ডার একবার করে, সমান গুরুত্বে গোনা হয়েছে; পুরো ফাইল একবারে পড়লেও ঠিক এই উত্তর আসে।
  6. গড়ের গড়। (2.180 + 2.040 + 7.050) ÷ 3 = 11.27 ÷ 3 = 3.757 দিন. মাত্র ২০টি অর্ডারের টুকরো এখানে ৫০টির টুকরোর সমান ওজন পেয়েছে, তাই উত্তর প্রায় ২৮% বেশি; কোনো error বা সতর্কবার্তা ছাড়া।

এবার একই হিসাব সত্যি চালিয়ে দেখুন, আর জেলা ধরেও। জেলা ধরে প্রতিটি টুকরো থেকে groupby(...).agg(["sum", "count"]) (পর্ব ২৪) রাখা হয়, শেষে সব টুকরোর ছোট টেবিল pd.concat করে জেলা ধরে আবার যোগ করা হয়।

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

cols = ["district", "delivery_days"]
parts, means = [], []
for i, ch in enumerate(pd.read_csv("orders.csv", usecols=cols, chunksize=50)):
    m = ch["delivery_days"].mean()
    print(f"chunk {i}: {len(ch)} rows, sum {ch['delivery_days'].sum()}, mean {m:.3f}")
    means.append(m)
    parts.append(ch.groupby("district")["delivery_days"].agg(["sum", "count"]))

total = pd.concat(parts).groupby(level=0).sum()       # আংশিক যোগফল জোড়া
full = pd.read_csv("orders.csv", usecols=cols)

print("ভুল : chunk-এর গড়ের গড় =", round(sum(means) / len(means), 3))
print("ঠিক : মোট sum / মোট count =",
      round(total["sum"].sum() / total["count"].sum(), 3))
print("যাচাই: পুরো ফাইলের গড়   =", round(full["delivery_days"].mean(), 3))

wrong = pd.concat([p["sum"] / p["count"] for p in parts]).groupby(level=0).mean()
by = pd.DataFrame({
    "combined": total["sum"] / total["count"],
    "full": full.groupby("district")["delivery_days"].mean(),
    "mean_of_means": wrong,
}).round(3)
print(by)

ব্রাউজারে (Pyodide) একটি run-এর আসল আউটপুট:

chunk 0: 50 rows, sum 109, mean 2.180
chunk 1: 50 rows, sum 102, mean 2.040
chunk 2: 20 rows, sum 141, mean 7.050
ভুল : chunk-এর গড়ের গড় = 3.757
ঠিক : মোট sum / মোট count = 2.933
যাচাই: পুরো ফাইলের গড়   = 2.933
           combined   full  mean_of_means
district
খুলনা         2.688  2.688          3.405
চট্টগ্রাম     2.826  2.826          3.494
ঢাকা          2.891  2.891          3.967
রাজশাহী       3.333  3.333          4.222
সিলেট         3.050  3.050          3.583

ধাপে ধাপে stepper-এর সংখ্যাগুলোই: টুকরো ৫০, ৫০ আর ২০টি সারির, যোগফল 109, 102, 141. গড়ের গড় 3.757, আর মোট sum ÷ মোট count 2.933, যা পুরো ফাইল একবারে পড়ার গড়ের সঙ্গে হুবহু মেলে। জেলা ধরে টেবিলে combined আর full প্রতিটি সারিতে সমান, আর mean_of_means প্রতিটি জেলায় বেশি: খুলনায় ২.৬৮৮-এর জায়গায় ৩.৪০৫, ঢাকায় ২.৮৯১-এর জায়গায় ৩.৯৬৭. জেলা ধরে ভুলটি আরও বড় হতে পারে, কারণ শেষ টুকরোয় কোনো জেলার মাত্র দুই-তিনটি অর্ডার থাকলেও তার গড় বাকি টুকরোর সমান ওজন পায়। পুরো হিসাবে RAM-এ একবারে ছিল সর্বোচ্চ ৫০টি সারি আর কয়েকটি ছোট sum-count টেবিল।

বড় ফাইলে একই পদ্ধতি: লেখকের ল্যাপটপে ৫০ লাখ সারির CSV থেকে দুটি কলাম, ১৫ লাখ সারির টুকরোয় (সত্যিকারের run, pandas 3.1.1). প্রতিটি টুকরো RAM-এ প্রায় ৩৫ MB, পুরো কাজ প্রায় ১.১ সেকেন্ড, আর জোড়া লাগানো ফল পুরো ফাইলের ফলের সঙ্গে হুবহু মিলেছে:

~/bazarghar-ai$ python chunks.py
chunk 0: rows 1,500,000  MB  34.6
chunk 1: rows 1,500,000  MB  34.6
chunk 2: rows 1,500,000  MB  34.6
chunk 3: rows   500,000  MB  11.5
time s: 1.12
            combined    full  mean_of_means
district
Barishal      4.0006  4.0006         4.0039
Chattogram    4.0002  4.0002         4.0000
Dhaka         3.9995  3.9995         3.9998
combined == full: True

এখানে এলোমেলো ডেটায় গড়ের গড়ের ভুল চোখে পড়ার মতো নয় (৪.০০০৬ বনাম ৪.০০৩৯), আর ঠিক সেটিই বিপজ্জনক: ছোট পরীক্ষায় “প্রায় মিলে গেছে” দেখে কোড বিশ্বাস করলে, যেদিন টুকরোগুলো সময় ধরে সাজানো থাকবে আর শেষ টুকরোয় ঈদ পড়বে, সেদিন ভুল বড় হবে। কোন হিসাব সরাসরি জোড়া যায়, মনে রাখুন: sum, count, min, max জোড়া যায়; mean জোড়া যায় sum আর count দিয়ে; variance জোড়া যায় sum, sum of squares আর count দিয়ে (বা আরও স্থিতিশীল সূত্রে); median আর nunique সরাসরি জোড়া যায় না, তার জন্য সব মান, বা আনুমানিক কৌশল লাগে।

৬ · Vectorised বনাম apply আর iterrows — আর eval, query

পর্ব ১৫-এ দেখেছেন, NumPy-তে পুরো array-এর ওপর একবারে কাজ (vectorization) Python loop-এর চেয়ে অনেক দ্রুত। pandas-এর কলামও ভেতরে array, তাই একই নিয়ম খাটে। কিন্তু pandas-এ দুটি জনপ্রিয় পথ দেখতে “pandas-এর” হলেও ভেতরে Python loop:

  • df.apply(f, axis=1): প্রতিটি সারির জন্য একটি Series বানিয়ে আপনার Python function ডাকে। সারি যত, Python call তত, আর প্রতিটি সারির Series বানানোর খরচ আলাদা।
  • df.iterrows(): প্রতিটি সারি একটি Series হিসেবে দেয়; আরও ধীর, আর মিশ্র dtype-এর সারিতে সংখ্যাগুলোর dtype বদলে যেতে পারে (pandas-এর নথিতে সতর্ক করা)। দরকার হলে itertuples() অনেক দ্রুত।

বাজারঘরের নিয়ম: মোট = পরিমাণ × দাম + ডেলিভারি চার্জ, ঢাকায় ৬০ টাকা (৬০০০ পয়সা), বাইরে ১২০ টাকা। ১০ হাজার সারিতে চারটি পথ মেপে দেখুন। প্রতিটি পথ একবারই মাপা; আগে পর্ব ১৯-এর নিয়ম মনে রাখুন, একবারের সময় শুধু মাত্রার আন্দাজ দেয়।

import time
import numpy as np
import pandas as pd

rng = np.random.default_rng(42)
n = 10_000
df = pd.DataFrame({
    "qty": rng.integers(1, 6, n),
    "price_paisa": rng.integers(5_000, 500_000, n),
    "district": rng.choice(["ঢাকা", "খুলনা", "সিলেট"], n),
})
# নিয়ম: মোট = qty × দাম + ডেলিভারি চার্জ (ঢাকায় 6000 পয়সা, বাইরে 12000)

def charge(d):
    return 6_000 if d == "ঢাকা" else 12_000

def timed(label, f):
    t0 = time.perf_counter()
    out = f()
    print(f"{label:22s} {(time.perf_counter() - t0) * 1000:8.1f} ms")
    return np.asarray(out, dtype=np.int64)

v = timed("vectorised column", lambda: df["qty"] * df["price_paisa"]
          + np.where(df["district"] == "ঢাকা", 6_000, 12_000))
m = timed("Series.map + vector", lambda: df["qty"] * df["price_paisa"]
          + df["district"].map({"ঢাকা": 6_000, "খুলনা": 12_000, "সিলেট": 12_000}))
a = timed("apply(axis=1)", lambda: df.apply(
    lambda r: r["qty"] * r["price_paisa"] + charge(r["district"]), axis=1))
it = timed("iterrows loop", lambda: [r["qty"] * r["price_paisa"] + charge(r["district"])
                                     for _, r in df.iterrows()])
print("all equal?", all(np.array_equal(v, x) for x in (m, a, it)))
print("first 3:", v[:3])

ব্রাউজারে (Pyodide) একটি run-এর আসল আউটপুট:

vectorised column           1.6 ms
Series.map + vector         1.7 ms
apply(axis=1)             121.1 ms
iterrows loop             329.0 ms
all equal? True
first 3: [ 178655  463932 1056268]

চারটি পথের ফল হুবহু এক, কিন্তু সময় একেবারে আলাদা। এই run-এ vectorised কলাম-হিসাব ১.৬ ms, map দিয়ে চার্জ বসানো ১.৭ ms, apply(axis=1) ১২১ ms (প্রায় ৭৫ গুণ ধীর) আর iterrows ৩২৯ ms (প্রায় ২০০ গুণ)। ১০ হাজার সারিতে ৩২৯ ms সহনীয় মনে হতে পারে, কিন্তু সময় সারির সঙ্গে প্রায় সমানুপাতে বাড়ে: এক কোটি সারিতে একই অনুপাতে iterrows-এ লাগত কয়েক মিনিট, vectorised-এ দুই সেকেন্ডের কম। আবার চালালে সংখ্যা বদলাবে (একই কোডের আরেকটি run-এ এসেছিল ২.৪, ৩.০, ৩০৯ আর ৯৩৭ ms), কিন্তু ক্রম একই থাকে। np.where আর map দুটিই “শর্ত অনুযায়ী মান” বসানোর vectorised পথ; শর্ত বেশি হলে np.select.

দুটি ছোট হাতিয়ারের কথা নোট হিসেবে। df.eval("amount = qty * price_paisa") একটি লেখা expression-কে পুরো কলামে চালায়, আর df.query("payment == 'bKash' and qty > 2") boolean mask-এর একটি পড়তে সহজ রূপ (বাছাইয়ের বিস্তারিত পর্ব ২২-এ)। numexpr package install থাকলে pandas এগুলো তার মাধ্যমে চালাতে পারে, যা বড় টেবিলে অস্থায়ী array কমিয়ে কিছু গতি দেয়; pandas-এর নথি নিজেই বলে, ছোট টেবিলে বা সরল expression-এ এর লাভ নেই, বরং ধীর হতে পারে। তাই এগুলো মূলত পড়তে সহজ লেখার জন্য বাছুন, গতির জন্য হলে নিজে মেপে।

৭ · pandas যখন যথেষ্ট নয় — Polars, eager আর lazy

সব কৌশলের পরেও কখনো pandas ধীর থাকে: ফাইল RAM-এর কাছাকাছি, groupby চলে মিনিটের পর মিনিট, আর আপনার যন্ত্রের অনেকগুলো CPU core বসে থাকে, কারণ pandas-এর বেশির ভাগ কাজ এক core-এ চলে। তখন একটি জনপ্রিয় বিকল্প Polars: Rust ভাষায় লেখা একটি DataFrame library, Ritchie Vink ২০২০ সালের দিকে শুরু করেন। Polars ডেটা রাখে Apache Arrow-এর কলাম-বিন্যাসে, কাজ ভাগ করে সব core-এ, আর তার সবচেয়ে বড় পার্থক্য: সে আপনার পুরো প্রশ্নটি আগে দেখে, তারপর চালায়।

লেখার সময়ে PyPI-তে Polars-এর সর্বশেষ স্থির version 2.1.0 (2.0 এসেছে সম্প্রতি, আর তাতে কিছু ভাঙা পরিবর্তন আছে; পুরোনো টিউটোরিয়ালের কোড না-ও চলতে পারে)। Polars ব্রাউজারের এই Python-এ নেই, তাই এই অংশের সব আউটপুট লেখকের ল্যাপটপের সত্যিকারের run (Python 3.14.4, Polars 2.1.0), ৫০ লাখ সারির সেই বানানো অর্ডার-ফাইল দিয়ে (ইংরেজি জেলার নামে, যাতে টেবিল সরু থাকে)। Install: uv pip install polars (পর্ব ৭)।

Polars-এ দুই রকম কাজ। Eager পথে pl.read_parquet বা pl.read_csv সঙ্গে সঙ্গে পুরো ডেটা পড়ে একটি DataFrame দেয়, আর প্রতিটি লাইন তখনই চলে, pandas-এর মতো। কাজ লেখা হয় expression দিয়ে: pl.col("qty") * pl.col("price_paisa") একটি “কলামের হিসাবের বর্ণনা”, যা Polars নিজে সমান্তরালে চালায়। pandas-এর মতো df["col"] দিয়ে সারি-সারি কাজের বদলে এই expression-ই Polars-এর ভাষা।

# eager.py
import polars as pl

df = pl.read_parquet("orders_5m.parquet",
                     columns=["district", "payment", "qty", "price_paisa"])
print(type(df).__name__, df.shape)
out = (df.filter(pl.col("payment") == "bKash")
         .group_by("district")
         .agg(pl.len().alias("orders"),
              (pl.col("qty") * pl.col("price_paisa")).sum().alias("total_paisa"))
         .sort("total_paisa", descending=True))
print(out.head(3))
~/bazarghar-ai$ python eager.py
DataFrame (5000000, 4)
shape: (3, 3)
┌────────────┬────────┬──────────────┐
│ district   ┆ orders ┆ total_paisa  │
│ ---        ┆ ---    ┆ ---          │
│ str        ┆ u32    ┆ i64          │
╞════════════╪════════╪══════════════╡
│ Dhaka      ┆ 613043 ┆ 465377500781 │
│ Chattogram ┆ 350995 ┆ 266146716184 │
│ Rajshahi   ┆ 174837 ┆ 132192024106 │
└────────────┴────────┴──────────────┘

লক্ষ করুন: Polars-এ index নেই, groupby-র নাম group_by, আর pl.len() প্রতিটি দলের সারি গোনে (Polars 2-এ group_by(...).count() সরানো হয়েছে)। ঢাকার bKash অর্ডারে মোট ৪৬৫,৩৭৭,৫০০,৭৮১ পয়সা, মানে প্রায় ৪৬৫ কোটি টাকা; বানানো ডেটা, তাই সংখ্যাটি শুধু উদাহরণ।

Lazy পথে pl.scan_parquet বা pl.scan_csv কিছুই পড়ে না; শুধু একটি LazyFrame দেয়, যা আসলে “কী করতে হবে” তার একটি পরিকল্পনা (query plan). প্রতিটি .filter, .with_columns, .group_by সেই পরিকল্পনায় একটি ধাপ যোগ করে। .collect() ডাকলে Polars-এর query optimizer পুরো পরিকল্পনাটি দেখে আগে সাজায়, তারপর চালায়। Polars 2-এ lazy query-র collect() ডিফল্টে streaming engine-এ চলে, যা ডেটা টুকরো টুকরো প্রক্রিয়া করে, অনেকটা অংশ ৫-এর chunk-এর মতো, কিন্তু নিজে থেকেই। নিচের ধাপে দেখুন একটি প্রশ্ন scan থেকে collect পর্যন্ত কীভাবে এগোয়।

scan_parquetকিছুই পড়া হয়নি
q = pl.scan_parquet(
      "orders_5m.parquet")
type(q) → LazyFrame
plan: Parquet SCAN
      10/10 columns
.filter(...)পরিকল্পনায় একটি ধাপ
q = q.filter(
  pl.col("payment") == "bKash")
plan: FILTER payment == bKash
        Parquet SCAN
এখনো ০টি সারি পড়া
.with_columns + group_byআরও ধাপ
.with_columns(
   (qty * price_paisa)
   .alias("amount_paisa"))
.group_by("district").agg(
   len, sum, mean)
.sort("total_paisa")
optimizer: projection pushdownকোন কলাম লাগবে
লাগবে: district, payment,
  qty, price_paisa,
  delivery_days
plan: Parquet SCAN
  PROJECT 5/10 COLUMNS
বাকি ৫টি কলাম পড়াই হবে না
optimizer: predicate pushdownfilter নামল scan-এ
Parquet SCAN
  PROJECT 5/10 COLUMNS
  SELECTION:
  col("payment") == "bKash"
  ESTIMATED ROWS: 5000000
filter এখন পড়ার ধাপেই
.collect()এখন সত্যিই চলে
streaming engine, সব core
৫০ লাখ সারি → শুধু bKash
→ DataFrame, 8 rows
  (প্রতি জেলা ১টি)
Dhaka  613043 orders
       465377500781 paisa
  1. Scan. scan_parquet শুধু ফাইলের schema দেখে একটি LazyFrame দেয়। কোনো সারি RAM-এ আসেনি; পরিকল্পনায় আছে শুধু “এই ফাইলের ১০টি কলাম পড়ো”।
  2. Filter যোগ। .filter সঙ্গে সঙ্গে চলে না, পরিকল্পনার ওপরে একটি ধাপ হয়ে বসে। এখনো একটি সারিও পড়া হয়নি।
  3. আরও ধাপ। নতুন কলাম amount_paisa, জেলা ধরে তিনটি হিসাব (সারির সংখ্যা, মোট টাকা, গড় ডেলিভারি দিন), তারপর সাজানো। সবই এখনো শুধু বর্ণনা।
  4. Projection pushdown. Optimizer পুরো প্রশ্ন দেখে বোঝে মাত্র ৫টি কলাম লাগবে। আসল explain()-এ লেখা PROJECT 5/10 COLUMNS: Parquet কলাম ধরে রাখা, তাই বাকি ৫টি ডিস্ক থেকে পড়াই হয় না (projection pushdown).
  5. Predicate pushdown. Filter-টিও নেমে গেছে scan-এর ভেতরে, SELECTION: col("payment") == "bKash": পড়ার সময়েই অপ্রয়োজনীয় সারি বাদ (predicate pushdown). pandas-এ আপনি হাতে usecols দেন; এখানে optimizer নিজেই দেয়।
  6. Collect. এবার কাজ সত্যিই চলে, ডিফল্টে streaming engine-এ, সব core-এ। ফল একটি সাধারণ Polars DataFrame: ৮টি জেলার ৮টি সারি। ঢাকায় 613043টি bKash অর্ডার, ঠিক eager পথের সংখ্যাটিই।

Stepper-এর পরিকল্পনাটি বানানো নয়; Polars নিজেই q.explain() দিয়ে দেখায়। লেখকের ল্যাপটপে সত্যিকারের run (Polars 2.1.0):

~/bazarghar-ai$ python polars_lazy.py
Polars 2.1.0
LazyFrame
SORT BY [descending: [true]] [col("total_paisa")]
  AGGREGATE[maintain_order: false]
    [len().alias("orders"), col("amount_paisa").sum().alias("total_paisa"), col("delivery_days").mean().round().alias("avg_days")] BY [col("district")]
    FROM
    simple π 3/3 ["district", "amount_paisa", ... 1 other column]
       WITH_COLUMNS:
       [(col("qty") * col("price_paisa")).alias("amount_paisa")]
        simple π 4/4 ["district", "qty", ... 2 other columns]
          Parquet SCAN [orders_5m.parquet]
          PROJECT 5/10 COLUMNS
          SELECTION: col("payment") == "bKash"
          ESTIMATED ROWS: 5000000
…  (এরপর collect()-এর ফল, ৮টি সারির টেবিল)

পরিকল্পনাটি নিচ থেকে ওপরে পড়ুন: scan (কলাম আর filter-সহ), তারপর নতুন কলাম, তারপর aggregate, শেষে sort. π (pi) চিহ্নটি relational algebra-র projection, মানে কলাম বাছাই; database-এর query optimizer ঠিক এভাবেই কাজ করে (DBMS কোর্সের query optimization পর্ব)। একটি সাবধানতা Polars 2-এর নথি থেকে: streaming engine-এ group_by আর join-এর ফলের সারির ক্রম নিশ্চিত নয়, তাই ক্রম দরকার হলে স্পষ্ট .sort দিন, যেমন এখানে দেওয়া।

৮ · একই প্রশ্ন, আট পথে — pandas, Polars আর DuckDB-এর একটি মাপ

তুলনার আগে আরেকটি টুল: DuckDB. এটি একটি in-process analytical database: আলাদা server লাগে না, Python-এর ভেতরেই চলে, আর CSV বা Parquet ফাইলের ওপর সরাসরি SQL চালায়। SQL যাঁরা জানেন (DBMS কোর্সের GROUP BY পর্ব), তাঁদের জন্য এটি সবচেয়ে কম নতুন শেখার পথ। লেখার সময়ে PyPI-তে DuckDB-এর version 2.0.0, আর এটি লেখকের যন্ত্রে uv pip install duckdb-এ সহজেই install হয়েছে।

# duck.py
import duckdb

print(duckdb.sql("""
    SELECT district, COUNT(*) AS orders, SUM(qty * price_paisa) AS total_paisa
    FROM 'orders_5m.parquet' WHERE payment = 'bKash'
    GROUP BY district ORDER BY total_paisa DESC LIMIT 3
"""))
~/bazarghar-ai$ python duck.py
┌────────────┬────────┬──────────────┐
│  district  │ orders │ total_paisa  │
│  varchar   │ int64  │    int128    │
├────────────┼────────┼──────────────┤
│ Dhaka      │ 613043 │ 465377500781 │
│ Chattogram │ 350995 │ 266146716184 │
│ Rajshahi   │ 174837 │ 132192024106 │
└────────────┴────────┴──────────────┘

একই তিনটি সংখ্যা, Polars-এর মতোই। DuckDB যোগফল রেখেছে int128-এ, overflow-এর ভয় ছাড়া। এবার পুরো প্রশ্নটি (ফাইল পড়া + bKash বাছাই + জেলা ধরে মোট আর সংখ্যা) আটটি পথে মাপা হলো। প্রতিটি পথ একবার মাপা, ফাইল আগে একবার পড়ে নেওয়ার পর (তাই অপারেটিং সিস্টেমের file cache গরম); সত্যিকারের run:

~/bazarghar-ai$ python bench.py
Python 3.14.4 | pandas 3.1.1 | polars 2.1.0 | duckdb 2.0.0
pandas read_csv (all columns)        0.21 s
groupby mean only: pandas           0.150 s
groupby mean only: polars           0.007 s
in-memory MB: pandas 539 | polars 379
--- whole query (read + filter + groupby) ---
pandas CSV, all columns              0.51 s
pandas CSV, usecols+dtype+pyarrow    0.55 s
pandas Parquet, 4 columns            0.29 s
polars eager read_csv                0.22 s
polars lazy scan_csv                 0.11 s
polars lazy scan_parquet             0.02 s
duckdb SQL on CSV                    0.27 s
duckdb SQL on Parquet                0.03 s
same totals: True

কী বলা যায়, আর কী বলা যায় না:

  • শুধু groupby-তে (ডেটা আগে থেকেই RAM-এ) এই run-এ Polars প্রায় ২০ গুণ দ্রুত (০.১৫০ বনাম ০.০০৭ সেকেন্ড)। এর বড় অংশ সব core ব্যবহার; কম core-এর যন্ত্রে পার্থক্য কম হবে।
  • পুরো প্রশ্নে সবচেয়ে বড় লাফ এসেছে ফাইলের ধরন থেকে: Parquet-এর ওপর lazy Polars বা DuckDB প্রায় ০.০২–০.০৩ সেকেন্ড, কারণ তারা শুধু দরকারি কলাম পড়ে, আর Parquet-এর ভেতরের পরিসংখ্যান দেখে টুকরো বাদ দিতে পারে। pandas-ও Parquet-এ (৪ কলাম) CSV-র চেয়ে দ্রুত হয়েছে।
  • সব পথের ফল হুবহু এক (শেষ লাইন)। মাপার আগে এটিই প্রথম শর্ত।
  • যা বলা যায় না: “Polars সবসময় ২০ গুণ দ্রুত”। এটি একটি যন্ত্রের একটি run, একটি বানানো টেবিলে, একটি প্রশ্নে। ছোট ডেটায় পার্থক্য প্রায় অদৃশ্য। নিজের ডেটায়, নিজের প্রশ্নে মাপুন।

pandas-এর ভেতরেই Arrow. pandas-ও Arrow ব্যবহার করতে পারে। pd.read_csv(path, engine="pyarrow", dtype_backend="pyarrow") প্রতিটি কলামকে Arrow-ভিত্তিক dtype দেয়, যেমন int64[pyarrow], date32[day][pyarrow], string[pyarrow]. লেখকের যন্ত্রে ৫০ লাখ সারির ফাইলে এতে মেমরি ৫৩৯ থেকে ৪০৯ MB হয়েছে (সত্যিকারের run), মূলত তারিখ কলাম লেখার বদলে আসল তারিখ হওয়ায়। তবে সব pandas কাজ এগুলোতে সমান দ্রুত নয়; বদলের আগে নিজের কাজে মেপে নিন।

৯ · সিদ্ধান্তের চেকলিস্ট — pandas-এ থাকা, ভাগ করা, না টুল বদলানো

pandas বনাম Polars নিয়ে আলোচনা প্রায়ই দলাদলিতে গড়ায়। বাস্তবে প্রশ্নটি “কোনটি ভালো” নয়, “আমার এই কাজে কোনটি যথেষ্ট”। দুই পক্ষের যুক্তি ভাবনার প্রশ্ন ৪-এ। নিচের ক্রমে ভাবুন; আগের ধাপে কাজ হলে পরের ধাপে যাওয়ার দরকার নেই।

অবস্থাযা করবেন
টেবিল RAM-এর অনেক কম, কাজ কয়েক সেকেন্ডে শেষpandas-এ থাকুন। কিছু বদলাবেন না; পড়তে সহজ কোডই সেরা।
টেবিল RAM-এর কাছাকাছিmemory_usage(deep=True) দেখে downcast, category, usecols, পড়ার সময়ে dtype.
ধীর, কিন্তু মেমরি ঠিক আছেapply/iterrows খুঁজে vectorised কলাম-হিসাবে বদলান; profiling দিয়ে আসল ধীর জায়গা খুঁজুন।
ফাইল RAM-এ ধরে না, হিসাব জোড়া-যোগ্য (sum, count, min, max)chunksize, আংশিক sum আর count জমিয়ে শেষে জোড়া।
একই বড় CSV বারবার পড়তে হয়একবার Parquet-এ রূপান্তর (পর্ব ১০); পরে শুধু দরকারি কলাম পড়ুন।
কয়েক GB বা বেশি, জটিল groupby/join, সব core কাজে লাগাতে চানPolars (lazy, scan_parquet) বা DuckDB (SQL); ফল ছোট হলে আবার pandas-এ এনে chart বা model.
অনেক মানুষ বা app একসঙ্গে পড়ে-লেখে, লেনদেনের নিরাপত্তা দরকারআসল database (যেমন PostgreSQL); DataFrame library এর বিকল্প নয়।
এক যন্ত্রে কোনোভাবেই ধরে না (শত শত GB থেকে TB)বিতরিত (distributed) টুল বা ডেটা warehouse; এই কোর্সের বাইরে, System Design কোর্সের batch আর stream processing পর্ব দেখুন।

আরও গভীরে। Polars আর DuckDB দুটিই কলাম-ভিত্তিক, “vectorized” execution-এর ধারায়, যার শিকড় Amsterdam-এর CWI গবেষণাগারের MonetDB আর Peter Boncz-সহ লেখকদের ২০০৫ সালের MonetDB/X100 প্রবন্ধে: একবারে একটি সারি নয়, কয়েক হাজার মানের একটি টুকরো ধরে কাজ, যাতে CPU cache আর SIMD পুরো কাজে লাগে। DuckDB-ও CWI থেকে এসেছে (Mark Raasveldt আর Hannes Mühleisen, ২০১৯ সালের SIGMOD demo প্রবন্ধ)। আর projection ও predicate pushdown-এর ধারণা ১৯৭০-এর দশকের IBM System R-এর query optimizer থেকে database-এ প্রচলিত; Polars সেগুলোকে DataFrame-এর জগতে এনেছে।

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

  • memory_usage() আর memory_usage(deep=True)-এর পার্থক্য? object কলামে deep ছাড়া শুধু pointer গোনা হয়; deep দিলে প্রতিটি Python string-এর আকারও যোগ হয়।
  • কোন কলাম category করবেন? আলাদা মান কম, সারি অনেক (জেলা, পেমেন্টের ধরন); প্রায় সব মান আলাদা হলে (id, ফোন) নয়।
  • Chunk-এ গড় কীভাবে বের করবেন? প্রতিটি chunk থেকে sum আর count, শেষে মোট sum ÷ মোট count; গড়ের গড় নয়।
  • apply(axis=1) কেন ধীর? প্রতিটি সারির জন্য Python function call আর একটি Series তৈরি; vectorised কলাম-হিসাব পুরো কলামে একবারে compiled কোড চালায়।
  • Polars-এর lazy mode-এর সুবিধা? collect()-এর আগে পুরো পরিকল্পনা দেখে optimizer দরকারি কলাম আর সারি শুধু পড়ে (projection ও predicate pushdown), আর কাজ সব core-এ ভাগ করে।

১০ · AI-এর যুগে — বড় ডেটার কোডে AI সহকারী যাচাই

“আমার ২০ GB CSV pandas-এ ধীর, দ্রুত করে দাও” লিখলে একটি AI সহকারী সাধারণত চটপটে একটি উত্তর দেবে। সেই উত্তর আপনার ডেটা দেখেনি, আপনার যন্ত্রে চলেনি, আর প্রায়ই পুরোনো টিউটোরিয়ালের ওপর দাঁড়ানো। এই বিষয়ে AI সহকারীরা প্রায়ই যা করে:

  • chunk-এ ভাগ করে প্রতিটি chunk-এর mean() নেয়, তারপর সেগুলোর গড়: দেখতে ঠিক, উত্তর ভুল। median বা nunique-ও একইভাবে “জোড়া” দেয়।
  • পুরোনো API লেখে: pandas 2.0-এ সরানো df.append, pandas 3-এ আর কাজ না করা chained assignment (df[df.qty > 2]["flag"] = 1), বা Polars 2-এ সরানো group_by(...).count(); অথবা এমন parameter বানায় যা ওই function-এ নেই।
  • মেমরি বাঁচাতে সব integer-কে int8 বা int32 করে দেয়, পরের গুণে overflow-এর কথা না ভেবে; টাকাকে float32 করে।
  • “Polars ১০ গুণ দ্রুত” বা “category সবসময় ভালো” বলে না-মাপা দাবি করে; id-জাতীয় কলামেও category বসায়।
  • astype-এর warning বা SettingWithCopyWarning-এর পুরোনো সমাধান হিসেবে warnings.filterwarnings("ignore") বসিয়ে সমস্যা ঢেকে দেয়।

প্রম্পটে নিজের version, ডেটার আকার আর যাচাইয়ের শর্ত দিন; আসল গ্রাহকের ডেটা নয়, বানানো কয়েকটি সারি দিন।

আমি Python 3.14, pandas 3.x ব্যবহার করছি। একটি 12 GB CSV আছে (প্রায় 15 কোটি সারি), কলাম: order_id, date, district (64টি জেলা), payment (COD/bKash/card), qty, price_paisa, delivery_days. আমার RAM 16 GB. আমি জানতে চাই প্রতি জেলায় মোট টাকা আর গড় delivery_days. নিচে বানানো ৫টি সারির নমুনা দিলাম: [এখানে বানানো ছোট নমুনা] শর্ত: ১. pandas-এ chunksize দিয়ে লেখো; গড় বের করবে মোট sum ÷ মোট count দিয়ে, chunk-এর গড়ের গড় দিয়ে নয়। ২. usecols আর পড়ার সময়ের dtype দাও; প্রতিটি dtype-এর সর্বোচ্চ মান হিসাব করে overflow-এর ঝুঁকি বলো। টাকার গুণ int64-এ। ৩. ছোট একটি ফাইলে chunk-এর ফল আর পুরো ফাইলের ফল মেলানোর একটি test দাও। ৪. একই কাজ Polars-এর scan_csv দিয়েও দাও, আর লেখো তুমি কোন Polars version ধরে লিখছ। ৫. গতির কোনো সংখ্যা বানিয়ে বলবে না; কোনো function বা parameter নিয়ে নিশ্চিত না হলে বলো।

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

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

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

প্র ০১pandas-এর স্রষ্টা নিজেই কেন pandas-এর মেমরি নিয়ে অভিযোগ করেছিলেন, আর তার ফল কী হলো?

Wes McKinney pandas লিখতে শুরু করেন ২০০৮ সালে, একটি বিনিয়োগ প্রতিষ্ঠানে কাজের সময়, আর ২০০৯ সালে এটি open source হয়। তখনকার লক্ষ্য ছিল Python-এ R-এর data frame-এর মতো একটি সুবিধাজনক টেবিল, আর তার ভিত্তি হিসেবে বেছে নেওয়া হয় NumPy. সেই সিদ্ধান্ত pandas-কে দ্রুত জনপ্রিয় করেছিল, কিন্তু সঙ্গে কিছু সীমাও এনেছিল: NumPy-তে লেখার জন্য ভালো ব্যবস্থা ছিল না, তাই লেখার কলাম হলো Python object-এর array; integer-এ “ফাঁকা” রাখার জায়গা ছিল না, তাই ফাঁকা থাকলেই কলাম float হয়ে যেত; আর অনেক কাজ এক core-এ, নতুন কপি বানিয়ে চলত।

২০১৭ সালে McKinney “Apache Arrow and the 10 Things I Hate About pandas” নামে একটি লেখায় এই সীমাগুলো খোলাখুলি তালিকা করেন। সেখানেই তিনি বলেন, তাঁর অভিজ্ঞতায় pandas-এ ডেটার আকারের ৫–১০ গুণ RAM লাগত। এর সমাধান তিনি দেখেছিলেন নতুন একটি কলাম-ভিত্তিক মেমরি-মানে, যা আলাদা টুল ও ভাষার মধ্যে কপি ছাড়াই ডেটা ভাগ করতে দেবে: Apache Arrow, যার কাজ শুরু হয় ২০১৬ সালের দিকে, বহু প্রকল্পের মানুষ মিলে।

এর পরের দশকের গল্প এই পর্বেই দেখেছেন। pandas 0.24 আনল nullable integer, 1.0 আনল pd.NA; pandas 2.0 আনল Arrow-ভিত্তিক dtype-এর বিকল্প; pandas 3.0-তে লেখার কলামের ডিফল্ট হলো নতুন str dtype, যা pyarrow থাকলে Arrow-এর বিন্যাস ব্যবহার করে, আর Copy-on-Write হলো একমাত্র আচরণ। অন্যদিকে Arrow-এর ওপর দাঁড়িয়ে Polars আর DuckDB-এর মতো নতুন টুল এসেছে। স্রষ্টার নিজের সমালোচনা থেকেই পুরো ecosystem-এর একটি সাধারণ ভিত্তি তৈরি হলো।

সফল টুল হঠাৎ ভাঙতে পারে না, কারণ লাখো কোড তার ওপর নির্ভর করে; তাই পরিবর্তন আসে ধীরে, আগে বিকল্প হিসেবে, তারপর ডিফল্ট হিসেবে।

মূল উপলব্ধি: pandas-এর মেমরির সমস্যা কোনো ভুল নয়, একটি পুরোনো নকশার দাম; Arrow, নতুন str dtype আর Polars সেই দামের উত্তরে ধাপে ধাপে আসা সমাধান।

প্র ০২গড়ের গড় কেন ভুল, আর এর সঙ্গে MapReduce আর Simpson’s paradox-এর সম্পর্ক কী?

গড় একটি অনুপাত: যোগফল ভাগ সংখ্যা। দুটি দলের গড় জুড়তে হলে প্রতিটি দলের ওজন (তার সারির সংখ্যা) লাগবে। সাধারণ গড়ের গড় প্রতিটি দলকে সমান ওজন দেয়, তাই দলগুলোর আকার আলাদা হলেই ফল সরে যায়। এই পর্বের উদাহরণে ২০টি ঈদের অর্ডারের টুকরো ৫০টির টুকরোর সমান ওজন পেয়ে গড় ২.৯৩ থেকে ৩.৭৬ দিনে তুলেছে। ক্রিকেটে একই ভুল: পাঁচ ম্যাচের গড় স্ট্রাইক রেট গড় করলে ২ বলে ৮ রানের ইনিংস আর ৮০ বলের সেঞ্চুরি সমান ওজন পায়; আসল স্ট্রাইক রেট মোট রান ÷ মোট বল।

বড় ডেটার জগতে এই সমস্যার সমাধান পুরোনো। ২০০৪ সালে Google-এর Jeffrey Dean আর Sanjay Ghemawat “MapReduce” প্রবন্ধে বিপুল ডেটাকে অনেক যন্ত্রে ভাগ করে প্রক্রিয়ার একটি কাঠামো বর্ণনা করেন। সেখানে “combiner” ধারণাটি আছে: প্রতিটি যন্ত্র নিজের অংশের আংশিক ফল বানিয়ে পাঠায়। এই আংশিক ফল তখনই কাজ করে, যখন হিসাবটি জোড়া-যোগ্য: যোগফল, সংখ্যা, সর্বোচ্চ, সর্বনিম্ন। গড়ের জন্য পাঠানো হয় (sum, count)-এর জোড়া। Variance-এর জন্যও স্থিতিশীল জোড়ার সূত্র আছে; Chan, Golub আর LeVeque ১৯৭৯ সালের দিকে এমন সূত্র নিয়ে কাজ করেছিলেন। Median-এর কোনো ছোট জোড়া-যোগ্য সারাংশ নেই, তাই বড় সিস্টেমে আনুমানিক কৌশল (যেমন t-digest) ব্যবহার হয়।

Simpson’s paradox একই জিনিসের আরও নাটকীয় রূপ: প্রতিটি দলে A ভালো, অথচ সব দল মিলিয়ে B ভালো দেখায়, কারণ দলগুলোর আকার আর মিশ্রণ আলাদা। ১৯৭০-এর দশকে UC Berkeley-র ভর্তির তথ্য নিয়ে একটি বিখ্যাত বিশ্লেষণে (Bickel আর সহলেখক, ১৯৭৫) সামগ্রিক হার আর বিভাগভিত্তিক হার উল্টো দিকে ইঙ্গিত করছিল। chunk-এর গড়ের গড় সবসময় paradox তৈরি করে না, কিন্তু মূল কারণ একই: ওজন ভুলে যাওয়া।

কোড পর্যালোচনায় তাই জিজ্ঞেস করুন: “এই সংখ্যাটি কি টুকরো থেকে জোড়া? জোড়ার নিয়ম কী?” তাড়াহুড়োর কোড এখানে প্রায়ই চুপচাপ ভুল করে।

মূল উপলব্ধি: ভাগ করে হিসাব করলে প্রতিটি টুকরো থেকে এমন সারাংশ রাখুন যা জোড়া যায় (sum, count); অনুপাত শেষে একবার নিন, মাঝে নয়।

প্র ০৩category এত মেমরি বাঁচায়, তবু pandas কেন সব লেখার কলামকে নিজে থেকে category বানায় না?

প্রথম কারণ: সব লেখা পুনরাবৃত্ত নয়। পণ্যের রিভিউ, ঠিকানা, ফোন নম্বর বা order_id-এ প্রায় প্রতিটি মান আলাদা। তখন categories-এর তালিকা মূল কলামের মতোই বড় হয়, সঙ্গে প্রতিটি সারির code-এর জায়গাও লাগে, ফলে মেমরি বাঁচে না, বাড়ে।

দ্বিতীয় কারণ: category-র অর্থ শুধু “জায়গা বাঁচানো” নয়। এটি বলে “এই কলামের সম্ভাব্য মান একটি নির্দিষ্ট তালিকা”। তালিকার বাইরের নতুন মান সরাসরি বসানো যায় না; আগে category যোগ করতে হয়, না হলে pandas error দেয়। ক্রম দেওয়া যায় (ordered=True, যেমন “ছোট < মাঝারি < বড়”), তখন তুলনা আর sort সেই ক্রমে চলে, বর্ণমালায় নয়। এটি শক্তিশালী, কিন্তু নিজে থেকে বসালে অনেক কোড অবাক করে ভাঙত।

তৃতীয় কারণ: আচরণের সূক্ষ্মতা। groupby-তে না-থাকা category দেখাবে কি না (observed; pandas 3-এ ডিফল্ট True), আলাদা categories-এর দুটি কলাম জুড়লে কী হবে (এই পর্বের লেখকের run-এ pandas 3.1.1-এ ফল str), কিছু string method আগে সাধারণ লেখায় বদলে নেয়। Machine learning-এর কাজে আরেকটি ফাঁদ: category-র code কোনো সংখ্যা নয়; “ঢাকা = 1, খুলনা = 0”-কে model-এ সংখ্যা হিসেবে দিলে model ধরে নেবে ঢাকা খুলনার “চেয়ে বেশি”, যা অর্থহীন। এর ঠিক পথ (one-hot বা অন্য encoding) আসবে পর্ব ৬৩-তে।

এই কারণে pandas-এর দর্শন: ডিফল্ট হবে নিরাপদ আর অনুমানযোগ্য, আর অপ্টিমাইজেশন হবে আপনার সচেতন সিদ্ধান্ত। pandas 3-এর নতুন str dtype সেই মাঝের পথ: অর্থ বদলায় না (যেকোনো লেখা রাখা যায়), কিন্তু pyarrow থাকলে object-এর চেয়ে অনেক কম জায়গা নেয়।

মূল উপলব্ধি: category একটি অর্থপূর্ণ সিদ্ধান্ত (“মান একটি জানা তালিকা থেকে”), শুধু compression নয়; তাই এটি বাছতে হয় ডেটা দেখে, মেপে।

প্র ০৪Polars যদি এত দ্রুত হয়, তাহলে সবাই কেন pandas ছেড়ে দিচ্ছে না? এই বিতর্কে দুই পক্ষের যুক্তি কী?

Polars-এর পক্ষের যুক্তি জোরালো। এটি শুরু থেকেই সব core-এ কাজের জন্য, Arrow-এর বিন্যাসে, Rust-এ লেখা; lazy mode-এ database-এর মতো optimizer আছে; আর expression-এর ভাষা পরিষ্কার: “কী চাই” বলেন, “কীভাবে” Polars ঠিক করে। index নেই, তাই pandas-এর index-জনিত অনেক বিভ্রান্তিও নেই। এই পর্বের মাপে groupby-তে বড় পার্থক্য দেখা গেছে, যদিও সেটি একটি যন্ত্রের একটি run.

pandas-এর পক্ষের যুক্তিও কম নয়। প্রায় পনেরো বছরের ব্যবহার, অসংখ্য উত্তর, বই আর টিউটোরিয়াল; scikit-learn, statsmodels, seaborn-এর মতো library-র সঙ্গে গভীর সংযোগ; time series, MultiIndex আর Excel-এর মতো জায়গায় বহু বছরের পরিপক্ব সুবিধা। বেশির ভাগ বিশ্লেষণে টেবিল কয়েক লাখ সারির, যেখানে pandas এক সেকেন্ডের কম সময়ে কাজ করে; সেখানে টুল বদলানোর লাভ সামান্য, শেখার খরচ বাস্তব। আর Polars দ্রুত বদলাচ্ছে: ২.০-তে কিছু ভাঙা পরিবর্তন এসেছে (যেমন streaming engine ডিফল্ট হওয়া আর কিছু method সরানো), যা পুরোনো কোড আর টিউটোরিয়ালকে অচল করে।

ecosystem-ও এগিয়ে আসছে। scikit-learn-এর সাম্প্রতিক version-গুলো transformer-এর output Polars DataFrame হিসেবে দিতে পারে (set_output(transform="polars")), আর অনেক library Arrow-এর মাধ্যমে দুটিই নিতে শিখছে। তাই বাস্তবে প্রায়ই একটি মিশ্র পথ দেখা যায়: ভারী পড়া, filter আর groupby Polars বা DuckDB-তে, তারপর ছোট ফল .to_pandas() করে chart বা model.

এই বিতর্কে চূড়ান্ত বিজয়ী নেই; দলের দক্ষতা, ডেটার আকার আর দরকারি library দেখে সিদ্ধান্ত নিন।

মূল উপলব্ধি: টুল বাছাই একটি বিনিময়: গতি আর optimizer বনাম পরিপক্বতা আর ecosystem; ছোট ডেটায় pandas-ই যথেষ্ট, বড় ডেটার ভারী অংশে Polars বা DuckDB, আর দুটি একসঙ্গেও চলে।

প্র ০৫DuckDB নিজেকে “in-process” database বলে। এটি PostgreSQL-এর মতো database থেকে কোথায় আলাদা, আর কখন কোনটি?

Database-এর জগতে দুই রকম কাজের ভাগ পুরোনো। OLTP (online transaction processing): অনেক ছোট লেনদেন, একসঙ্গে বহু মানুষ, যেমন বাজারঘরের ওয়েবসাইটে প্রতিটি অর্ডার জমা, bKash পেমেন্ট নিশ্চিত করা, স্টক এক কমানো। এখানে দরকার লেনদেনের নিরাপত্তা (ACID), lock, আর প্রতিটি সারি দ্রুত খুঁজে পাওয়া; PostgreSQL বা MySQL-এর মতো সারি-ভিত্তিক server database এর জন্য তৈরি। OLAP (online analytical processing): কম প্রশ্ন, কিন্তু প্রতিটি প্রশ্ন লাখো-কোটি সারি ঘোরে, যেমন “গত বছর জেলা ধরে মোট বিক্রি”. এখানে কলাম ধরে রাখা আর vectorized execution জেতে (DBMS কোর্সের data warehousing পর্ব)।

DuckDB-কে অনেকে “OLAP-এর SQLite” বলেন। SQLite-এর মতো এটি আলাদা server ছাড়া আপনার program-এর ভেতরেই চলে (in-process), একটি ফাইলে বা শুধু মেমরিতে। কিন্তু SQLite যেখানে সারি-ভিত্তিক আর ছোট লেনদেনে দক্ষ, DuckDB সেখানে কলাম-ভিত্তিক আর বড় বিশ্লেষণে দক্ষ। এটি এসেছে Amsterdam-এর CWI থেকে, Mark Raasveldt আর Hannes Mühleisen-এর কাজ, ২০১৯ সালে একটি গবেষণা-প্রদর্শনী প্রবন্ধে পরিচিত। একটি বড় সুবিধা: CSV বা Parquet ফাইলে সরাসরি SQL, আর pandas বা Polars-এর DataFrame-এর ওপরও।

তাই প্রশ্নটি “কোনটি ভালো” নয়, “কোন কাজ”। বাজারঘরের দোকানের চলমান অর্ডার, পেমেন্ট আর স্টক থাকবে PostgreSQL-জাতীয় database-এ, যেখানে একসঙ্গে শত ক্রেতা লিখতে পারে আর ভুল লেনদেন ফিরিয়ে নেওয়া যায়। রাতে সেই ডেটা Parquet-এ রপ্তানি করে বিশ্লেষক DuckDB বা Polars-এ পরের দিনের রিপোর্ট বানাবেন। DuckDB একসঙ্গে অনেক লেখক সামলানোর জন্য তৈরি নয়; আর PostgreSQL বড় বিশ্লেষণে সাধারণত কলাম-ভিত্তিক টুলের চেয়ে ধীর, যদিও তা কাজ আর কনফিগারেশনের ওপর নির্ভর করে।

মূল উপলব্ধি: লেনদেন আর বহু ব্যবহারকারী মানে server database (OLTP); বড় ফাইলের ওপর বিশ্লেষণ মানে DuckDB বা Polars (OLAP); দুটি একে অপরের বিকল্প নয়, একই ব্যবস্থার দুই অংশ।

অনুশীলন

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

  1. হিসাব: বাজারঘরের ১ কোটি (107) সারির টেবিল। (ক) qty int64-এ কত MB, int8-এ কত? (খ) ৬৪টি জেলার district category হলে codes-এর dtype আর আকার কত? (গ) তিনটি chunk থেকে ডেলিভারি দিনের (sum, count) এল (৪০০, ২০০), (৪৫০, ২০০), (৯০, ১০). সঠিক গড় আর গড়ের গড় কত? (ঘ) int8-এর qty = 5-কে 30 দিয়ে গুণ করলে কী হবে?

    (ক) 107 × 8 = 8 × 107 byte = ৮০ MB; int8-এ 107 byte = ১০ MB. (খ) ৬৪টি মান ১২৭-এর কম, তাই code int8, 107 byte ≈ ১০ MB; ৬৪টি নামের তালিকা কয়েক KB মাত্র। (গ) সঠিক: (400 + 450 + 90) ÷ (200 + 200 + 10) = 940 ÷ 410 ≈ ২.২৯ দিন. গড়ের গড়: (2.0 + 2.25 + 9.0) ÷ 3 = 13.25 ÷ 3 ≈ ৪.৪২ দিন, প্রায় দ্বিগুণ, শুধু ১০টি সারির একটি টুকরোর জন্য। (ঘ) 5 × 30 = 150, যা int8-এর সীমা 127 ছাড়ায়; ফল ঘুরে হয় 150 − 256 = −106, কোনো error ছাড়া। গুণের আগে .astype("int64") দিন।

  2. আউটপুট অনুমান করুন: চালানোর আগে প্রতিটি লাইনের আউটপুট লিখুন (pandas 3).
    import pandas as pd
    q = pd.Series([2, 5, 120], dtype="int64")
    q8 = pd.to_numeric(q, downcast="integer")
    print(q8.dtype, (q8 * 2).tolist())
    print(pd.to_numeric(pd.Series([4, None]), downcast="integer").dtype)
    c = pd.Series(["bKash", "COD", "bKash", "card"]).astype("category")
    print(c.cat.categories.tolist(), c.cat.codes.tolist())
    print(pd.Series([4, None], dtype="Int8").tolist())

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

    int8 [4, 10, -16]
    float64
    ['COD', 'bKash', 'card'] [1, 0, 1, 2]
    [4, <NA>]

    প্রথম লাইন: সর্বোচ্চ মান 120, তাই int8; কিন্তু 120 × 2 = 240 int8-এ ধরে না, ঘুরে 240 − 256 = −16. দ্বিতীয়: ফাঁকা ঘর থাকায় কলাম float64, আর downcast integer করতে পারে না, তাই float64-ই থাকে। তৃতীয়: categories Unicode ক্রমে সাজানো, আর বড় হাতের অক্ষর (C, U+0043) ছোট হাতের (b, U+0062)-এর আগে আসে; তাই COD প্রথমে, তারপর bKash, card. চতুর্থ: nullable Int8 ফাঁকা ঘর রাখে <NA> হিসেবে, 4 পূর্ণসংখ্যাই থাকে।

  3. নকশা ও সমালোচনা: একটি AI সহকারী ৮ GB-এর অর্ডার-ফাইল থেকে জেলাভিত্তিক গড় অর্ডারমূল্য বের করতে নিচের কোড দিয়েছে। অন্তত পাঁচটি সমস্যা চিহ্নিত করুন।
    import warnings; warnings.filterwarnings("ignore")
    res = pd.DataFrame()
    for ch in pd.read_csv("orders.csv", chunksize=1_000_000):
        ch["qty"] = ch["qty"].astype("int8")
        ch["price_paisa"] = ch["price_paisa"].astype("int32")
        ch["amount"] = ch.apply(lambda r: r.qty * r.price_paisa, axis=1)
        res = res.append(ch.groupby("district")["amount"].mean())
    print(res.groupby(level=0).mean())
    • গড়ের গড়: প্রতিটি chunk-এর জেলাভিত্তিক mean, তারপর আবার mean. প্রতিটি chunk থেকে sum আর count রেখে শেষে ভাগ করতে হবে।
    • পুরোনো API: DataFrame.append pandas 2.0-এ সরানো হয়েছে; AttributeError দেবে। আংশিক ফল একটি list-এ জমিয়ে শেষে একবার pd.concat.
    • সব কলাম পড়া: usecols নেই, তাই অপ্রয়োজনীয় কলামও প্রতিটি chunk-এ তৈরি হয়; আর dtype পড়ার পরে বদলানো হচ্ছে, পড়ার সময়ে নয়।
    • overflow: int8 × int32-এর ফল int32; যোগফলে নয়, কিন্তু বড় দাম আর পরিমাণে ঝুঁকি আছে, আর টাকার হিসাবে int64-ই নিরাপদ। qty কখনো ১২৭ ছাড়ালে astype নিজেই চুপচাপ ভুল মান দেবে।
    • apply(axis=1): সরল গুণের জন্য সারি-সারি Python call; ch["qty"].astype("int64") * ch["price_paisa"] বহু গুণ দ্রুত।
    • warning চাপা: filterwarnings("ignore") সব সতর্কবার্তা লুকায়, ফলে ভবিষ্যতের পরিবর্তনের সংকেতও হারায়।

    এই কাজের আরও সহজ পথ: একবার Parquet-এ রূপান্তর করে Polars-এর scan_parquet বা DuckDB-এর একটি SQL, অথবা নিচের অনুশীলন ৪-এর ধাঁচের chunk-কোড।

  4. কোড লিখুন: পাতার orders.csv থেকে ৪০ সারির chunk-এ পড়ে পেমেন্টের ধরন (payment) অনুযায়ী মোট অর্ডারমূল্য (পয়সা), অর্ডারের সংখ্যা আর গড় অর্ডারমূল্য (টাকায়, দুই দশমিক) বের করুন। ফলটি পুরো ফাইল একবারে পড়ার ফলের সঙ্গে মিলিয়ে দেখান।
    import pandas as pd
    parts = []
    with pd.read_csv("orders.csv", usecols=["payment", "qty", "price_paisa"],
                     chunksize=40) as reader:
        for ch in reader:
            ch = ch.assign(amount=ch["qty"] * ch["price_paisa"])
            parts.append(ch.groupby("payment")["amount"].agg(["sum", "count"]))
    t = pd.concat(parts).groupby(level=0).sum()
    t["avg_taka"] = (t["sum"] / t["count"] / 100).round(2)
    print(t)
    full = pd.read_csv("orders.csv")
    chk = (full["qty"] * full["price_paisa"]).groupby(full["payment"]).mean() / 100
    print((t["avg_taka"] == chk.round(2)).all())

    লেখকের যন্ত্রে একই orders.csv নিয়ে সত্যিকারের run (pandas 3.1.1):

                  sum  count  avg_taka
    payment
    COD      37989000     48   7914.38
    bKash    45678000     53   8618.49
    card      4521000     19   2379.47
    True

    মূল কৌশল অংশ ৫-এর মতো: প্রতিটি chunk থেকে sum আর count, pd.concat-এর পর groupby(level=0).sum(), শেষে একবার ভাগ। টাকা পয়সাতেই জমানো, শুধু ছাপার সময় ১০০ দিয়ে ভাগ। with ব্যবহার করায় পড়া শেষে ফাইলটি ঠিকভাবে বন্ধ হয়। পাতার পরিবেশে চালাতে চাইলে যেকোনো pyodide কোড-বক্সে এই কোড বসিয়ে দেখতে পারেন।

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

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

ক্রমRAM-এ পুরোপুরি না ধরা একটি বড় CSV থেকে জেলাভিত্তিক গড় বের করার ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. 🔍 nrows দিয়ে কয়েকটি সারি পড়ে কলাম আর মানের ধরন দেখা
  2. 📋 দরকারি কলাম (usecols) আর ছোট dtype ঠিক করা
  3. 📦 chunksize দিয়ে টুকরো টুকরো পড়া
  4. ➕ প্রতিটি টুকরো থেকে জেলা ধরে sum আর count জমানো
  5. 🧮 সব টুকরো জুড়ে মোট sum ÷ মোট count
  6. ✅ ছোট একটি নমুনা-ফাইলে পুরো হিসাবের সঙ্গে ফল মিলিয়ে দেখা

একটি pandas DataFrame-এ পুরোনো ধাঁচের object dtype-এর একটি লেখার কলাম আছে। df.memory_usage() আর df.memory_usage(deep=True)-এর সংখ্যা অনেক আলাদা কেন?

  • deep=True লেখাগুলো চাপিয়ে (compress করে) মাপে
  • deep ছাড়া শুধু pointer গোনা হয়, deep দিলে প্রতিটি string object-এর আকারও যোগ হয়
  • deep ছাড়া index গোনা হয় না
  • deep দিলে সংখ্যার কলাম দ্বিগুণ গোনা হয়

৫০ লাখ সারির একটি অর্ডার-টেবিলে কোন কলামকে category করলে সবচেয়ে বেশি লাভ হবে বলে আশা করা যায়?

  • order_id (প্রতিটি সারিতে আলাদা)
  • ক্রেতার রিভিউর লেখা
  • পেমেন্টের ধরন (COD, bKash, card)
  • পয়সায় দাম (price_paisa)

একটি ফাইল দুটি chunk-এ পড়া হলো। প্রথমটিতে ৫০টি অর্ডার, গড় ডেলিভারি ২.০ দিন; দ্বিতীয়টিতে ১০টি অর্ডার, গড় ৮.০ দিন। সব অর্ডারের সঠিক গড় ডেলিভারি কত দিন?

  • ৫.০
  • ৩.০
  • ২.০
  • ৮.০

pandas 3-এ q = pd.Series([2, 5], dtype="int8"); print((q * 100).tolist()) — আউটপুট কী?

  • [200, 500]
  • [127, 127]
  • OverflowError
  • [-56, -12]

Polars-এ q = pl.scan_parquet("orders.parquet").filter(pl.col("payment") == "bKash") লেখার পর ফাইল থেকে সারি আসলে কখন পড়া হয়?

  • q.collect() ডাকার সময়
  • scan_parquet লেখার সঙ্গে সঙ্গে
  • filter লেখার সঙ্গে সঙ্গে
  • q.explain() ডাকার সময়

একটি অনলাইন দোকানে দিনভর শত শত ক্রেতা একসঙ্গে অর্ডার জমা দেয়, পেমেন্ট নিশ্চিত হয় আর স্টক কমে; প্রতিটি লেনদেন নিরাপদ হওয়া জরুরি। এই চলমান ডেটার জন্য কোনটি সবচেয়ে উপযুক্ত?

  • একটি বড় CSV ফাইল, pandas দিয়ে পড়া-লেখা
  • Parquet ফাইল আর Polars
  • PostgreSQL-জাতীয় একটি server database
  • DuckDB-এর একটি ফাইল

শব্দকোষ

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

মনে রাখুন

  1. আগে মাপুন: memory_usage(deep=True) আর info(memory_usage="deep"); কোন কলাম বেশি খায়, তা দেখে তবে বদলান।
  2. ছোট dtype: downcast সংখ্যা ছোট করে, কিন্তু পরের গুণে চুপচাপ overflow হতে পারে; ফাঁকা ঘরসহ পূর্ণসংখ্যায় nullable Int64.
  3. লেখার কলাম: পুনরাবৃত্ত মানে category (১ byte-এর code); pandas 3-এর str pyarrow থাকলে object-এর চেয়ে অনেক কম জায়গা নেয়।
  4. কম পড়ুন, ভাগ করে পড়ুন: usecols, dtype, nrows, chunksize; chunk-এ গড় মানে মোট sum ÷ মোট count.
  5. পরের ধাপ: vectorised হিসাব, তারপর Parquet, তারপর Polars-এর lazy query বা DuckDB; লেনদেনের জন্য আসল database; আর প্রতিটি গতির দাবি নিজে মেপে।

পরের পর্বে: মডিউল ৫ শুরু: টেবিলের সংখ্যাকে ছবিতে আনা। matplotlib-এর figure আর axes, একটি plot-এর গঠন, আর chart-এ বাংলা লেখা নিয়ে সমস্যা ও তার সমাধান।

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

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