অধ্যায় 4 · Python ও ডেটা হ্যান্ডলিং
pandas দিয়ে ডেটা পরিষ্কার
- পৃষ্ঠা 15 / 22
- 15 মিনিট পড়া
স্প্রেডশিট, এক্সপোর্ট, ফর্ম আর অন্য টিমের কাছ থেকে যে ডেটা আসে, তা কখনোই পরিষ্কার থাকে না। একই শহরের নাম চার রকম বানানে, টাকার অঙ্কের সাথে মুদ্রার চিহ্ন, তারিখ দুই ফরম্যাটে, একটা সারি দুবার পেস্ট করা, টাইপোতে একটা বাড়তি শূন্য। এই ডেটা সরাসরি মডেল বা ড্যাশবোর্ডে দিলে সে হুবহু এই জঞ্জালটাই শিখবে বা রিপোর্টে দেখাবে। ডেটা বা ML প্রজেক্টের আসল সময়ের বেশিরভাগটাই যায় ডেটা পরিষ্কারে, আর কাজটা ভালোভাবে করা মানে জেনে-বুঝে করা: প্রতিটা পরিবর্তন চোখে দেখা যায়, তার কারণ বলা যায়, আর পরে হুবহু আবার চালানো যায়।
ডেটা হ্যান্ডলিংয়ের মূল কথা: ফরম্যাট, মান আর উৎস পাতায় মূল ধারণাগুলো পেয়েছেন (বৈধতা, সম্পূর্ণতা, সামঞ্জস্য, স্বতন্ত্রতা, আর পরিষ্কারের ধাপগুলো)। এই পাতায় সেগুলোই হাতে-কলমে করবেন, pandas দিয়ে। আপনি ইচ্ছে করে একটা এলোমেলো অর্ডারের ফাইল বানাবেন, ধাপে ধাপে সেটা পরিষ্কার করবেন, আর শেষ করবেন একটা পুনর্ব্যবহারযোগ্য clean() ফাংশন দিয়ে, যা ভালো সারিগুলোকে বাতিল সারিগুলো থেকে আলাদা করে।
যা শিখবেন
- প্রথম নজরেই দ্রুত সমস্যা খোঁজা:
head,info,describe,value_counts। - নাম, লেখা, সংখ্যা আর তারিখ ঠিক করা:
strমেথড, ম্যাপিং,to_numeric(errors="coerce"),to_datetime(format=…)। - অনুপস্থিত মান, ডুপ্লিকেট, আউটলায়ার (IQR নিয়ম) আর সীমা যাচাই, আর প্রতিটার বেলায় কী করবেন তা ঠিক করা।
- ডেটাবেসের টেবিল দিয়ে তথ্য যোগ করা (
validateআরindicator-সহmerge),pivot_tableআরmeltদিয়ে আকার বদলানো, আরgroupbyদিয়ে সারাংশ। assertদিয়ে মান যাচাই, এক্সপোর্ট, আর কোয়ারেন্টাইনসহ একটা পুনর্ব্যবহারযোগ্য পরিষ্কারের পাইপলাইন।
এলোমেলো ফাইলটা
এটা একটা দোকানের অর্ডার-স্প্রেডশিটের সাধারণ এক্সপোর্ট। পাতাটা এটাকে orders_messy.csv-এ লিখে নেয়, যাতে আপনার হাতে হুবহু একই ফাইল থাকে:
messy = """ Order ID,Customer ,Order Date,City,Product,Qty,Amount (BDT),Status
1001,1,2026-01-12,Dhaka,Python Crash Course,1,"৳1,200",Delivered
1002,2,20/01/2026,chattogram,Mechanical Keyboard,1,4500,delivered
1003,1,2026-02-03, Dhaka ,SQL Masterclass,1,3000,DELIVERED
1004,3,2026-02-14,DHAKA,Laptop Stand,1,1500,delivered
1005,4,25/02/2026,Sylhet,27-inch Monitor,1,18500,cancelled
1006,2,2026-03-08,Ctg,AI Engineering Bootcamp,1,15000,delivered
1007,5,2026-03-15,,Wireless Mouse,2,1800,delivered
1008,3,2026-03-28,Dhaka,Python Crash Course,2,2400,Delivered
1008,3,2026-03-28,Dhaka,Python Crash Course,2,2400,Delivered
1009,6,2026-04-02,Khulna,Mechanical Keyboard,1,4050,delivered
1010,1,2026-04-19,Dhaka,27-inch Monitor,1,185000,delivered
1011,7,2026-05-06,Dhaka,SQL Masterclass,1,N/A,delivered
1012,2,2026-05-21,Chittagong,Laptop Stand,2,3000,shipped
1013,6,2026-06-02,khulna,AI Engineering Bootcamp,1,15000,Pending
1014,3,2026-06-09,Dhaka,USB-C Hub,-1,-2200,delivered
1015,,2026-06-11,Dhaka,Wireless Mouse,1,900,delivered
1016,9,2026-06-12,Rajshahi,Gift Card,1,1000,canceled
"""
with open("orders_messy.csv", "w", encoding="utf-8") as f:
f.write(messy)
print(len(messy.splitlines()) - 1, "data rows written")17 data rows writtenকিছু ছোঁয়ার আগে দেখুন
import pandas as pd
raw = pd.read_csv("orders_messy.csv")
print(raw.shape)
raw.info()(17, 8)
<class 'pandas.DataFrame'>
RangeIndex: 17 entries, 0 to 16
Data columns (total 8 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 Order ID 17 non-null int64
1 Customer 16 non-null float64
2 Order Date 17 non-null str
3 City 16 non-null str
4 Product 17 non-null str
5 Qty 17 non-null int64
6 Amount (BDT) 16 non-null str
7 Status 17 non-null str
dtypes: float64(1), int64(2), str(5)
memory usage: 1.9 KBinfo() এখনই চারটা সমস্যা দেখাচ্ছে। প্রথম কলামের নাম " Order ID", শুরুতে একটা স্পেস, তাই raw["Order ID"] ব্যর্থ হবে। একটা মান নেই বলে Customer হয়ে গেছে float64। Amount (BDT) লেখা (str, pandas 3-এর স্ট্রিং dtype), কারণ একটা মানে ৳ আর কমা আছে। আর City ও টাকার কলামে একটা করে মান নেই। describe() সংখ্যার কলামগুলোর সীমা দেখায়:
print(raw.describe().round(2))
print(raw["Status"].value_counts()) Order ID Customer Qty
count 17.00 16.00 17.00
mean 1008.47 3.62 1.12
std 4.61 2.36 0.70
min 1001.00 1.00 -1.00
25% 1005.00 2.00 1.00
50% 1008.00 3.00 1.00
75% 1012.00 5.25 1.00
max 1016.00 9.00 2.00
Status
delivered 9
Delivered 3
DELIVERED 1
cancelled 1
shipped 1
Pending 1
canceled 1
Name: count, dtype: int64পরিমাণ −১ অসম্ভব, আর status কলামে চারটা মান সাত রকম বানানে লেখা। প্রতিটা লেখার কলামে value_counts() চালানোই অসামঞ্জস্যপূর্ণ ক্যাটাগরি খোঁজার সবচেয়ে দ্রুত উপায়।
লেখা হিসেবে পড়ুন, বুঝেশুনে বদলান
pandas টাইপগুলো আন্দাজ করেছে, আর আন্দাজ সমস্যা লুকায় (চুপচাপ N/A-কে অনুপস্থিত মান আর customer id-গুলোকে float বানিয়ে ফেলেছে)। পরিষ্কারের সময় সবকিছু লেখা হিসেবে পড়ুন আর প্রতিটা কলাম নিজে রূপান্তর করুন, যাতে প্রতিটা রূপান্তর হয় আপনার নিজের, চোখে দেখা সিদ্ধান্ত। কলামের নাম দিয়ে শুরু করুন:
df = pd.read_csv("orders_messy.csv", dtype=str, keep_default_na=False) # সব লেখা হিসেবে, কোনো আন্দাজ নয়
df.columns = (
df.columns.str.strip()
.str.lower()
.str.replace(r"[^a-z0-9]+", "_", regex=True) # বাকি যেকোনো কিছু হয়ে যায় _
.str.strip("_")
)
df = df.rename(columns={"customer": "customer_id", "amount_bdt": "amount_text"})
print(list(df.columns))['order_id', 'customer_id', 'order_date', 'city', 'product', 'qty', 'amount_text', 'status']লেখা: strip, বড়-ছোট হাতের অক্ষর আর ম্যাপিং
for col in ["city", "product", "status"]:
df[col] = df[col].str.strip() # " Dhaka " -> "Dhaka"
print(sorted(df["city"].unique()))
df["city"] = df["city"].str.title().replace({
"Ctg": "Chattogram", "Chittagong": "Chattogram", # প্রতি শহরের একটাই নাম
"": None, # খালি লেখা মানে অনুপস্থিত
})
df["status"] = df["status"].str.lower().replace({"canceled": "cancelled"})
print(df["city"].value_counts(dropna=False).sort_index())
print(sorted(df["status"].unique()))['', 'Chittagong', 'Ctg', 'DHAKA', 'Dhaka', 'Khulna', 'Rajshahi', 'Sylhet', 'chattogram', 'khulna']
city
Chattogram 3
Dhaka 9
Khulna 2
Rajshahi 1
Sylhet 1
None 1
Name: count, dtype: int64
['cancelled', 'delivered', 'pending', 'shipped']বড়-ছোট হাতের অক্ষর আর স্পেস ঠিক হয় নিয়ম দিয়ে; সমার্থক শব্দ আর বানানভেদের জন্য লাগে একজন মানুষের ঠিক করা ম্যাপিং। এমন ম্যাপিং এক জায়গায় রাখুন (কোডে একটা ডিকশনারি, বা ডেটাবেসে একটা লুকআপ টেবিল), যাতে পরের ফাইলও একইভাবে পরিষ্কার হয়।
সংখ্যা: errors="coerce"-সহ to_numeric
amount = df["amount_text"].str.replace("৳", "").str.replace(",", "")
df["amount"] = pd.to_numeric(amount, errors="coerce") # "N/A" -> এররের বদলে NaN
df["qty"] = pd.to_numeric(df["qty"], errors="coerce").astype("Int64")
df["customer_id"] = pd.to_numeric(df["customer_id"], errors="coerce").astype("Int64")
print(df.loc[df["amount"].isna(), ["order_id", "amount_text"]])
print(df[["customer_id", "qty", "amount"]].dtypes) order_id amount_text
11 1011 N/A
customer_id Int64
qty Int64
amount float64
dtype: objecterrors="coerce" পার্স করা যায় না এমন যেকোনো কিছুকে এরর দিয়ে থামার বদলে NaN বানায়। এটা কাজের, আবার বিপজ্জনকও: এটা টাইপোও লুকিয়ে ফেলে। ওপরের মতো সবসময় যে সারিগুলো coerce হলো সেগুলো প্রিন্ট করুন, যাতে নিশ্চিত হন যে সেগুলো সত্যিই অনুপস্থিত মান, ইউরোপীয় ফরম্যাটের "1.200,00" জাতীয় কিছু নয়। Int64 (বড় হাতের I) হলো pandas-এর nullable পূর্ণসংখ্যা টাইপ: int64 যা পারে না, এটা তা পারে — অনুপস্থিত মান রাখতে, তাই গ্রাহক ১ থাকে 1, 1.0 হয়ে যায় না।
তারিখ: একবারে একটা ফরম্যাট
iso = pd.to_datetime(df["order_date"], format="%Y-%m-%d", errors="coerce")
dmy = pd.to_datetime(df["order_date"], format="%d/%m/%Y", errors="coerce")
df["order_date"] = iso.fillna(dmy)
print(df["order_date"].isna().sum(), "dates could not be parsed")
print(df.loc[[1, 4], ["order_id", "order_date"]])0 dates could not be parsed
order_id order_date
1 1002 2026-01-20
4 1005 2026-02-25to_datetime-কে স্পষ্ট একটা format দিন। না দিলে pandas আন্দাজ করে, আর 03/04/2026 বাংলাদেশে ৩ এপ্রিল, যুক্তরাষ্ট্রে ৪ মার্চ। জানা প্রতিটা ফরম্যাট আলাদাভাবে পার্স করে জোড়া দিলে অস্পষ্টতার সিদ্ধান্তটা আপনি নেন, pandas নয়।
ডুপ্লিকেট
print(df.duplicated().sum(), "exact duplicate row(s)")
print(df["order_id"].duplicated().sum(), "repeated order_id(s)")
df = df.drop_duplicates().reset_index(drop=True)
assert df["order_id"].is_unique
print(len(df), "rows left")1 exact duplicate row(s)
1 repeated order_id(s)
16 rows leftএখানে বারবার আসা অর্ডারটা হুবহু কপি ছিল, তাই বাদ দেওয়া নিরাপদ। একই কি (key) যখন ভিন্ন মান নিয়ে আসে (অর্ডার ১০০৮-এর দুটো সংস্করণ, ভিন্ন টাকার অঙ্কে), তখন অন্ধভাবে বাদ দিলে যেটা আগে এসেছে সেটাই থাকে। df[df["order_id"].duplicated(keep=False)] দিয়ে সেগুলো দেখুন, আর এমন একটা নিয়ম খাটান যার পক্ষে যুক্তি দিতে পারবেন, যেমন "সর্বশেষ হালনাগাদটাই টিকবে"।
অনুপস্থিত মান: বাদ, পূরণ, নাকি চিহ্নিত
print(df[["customer_id", "city", "amount"]].isna().sum())customer_id 1
city 1
amount 1
dtype: int64একটাই সঠিক উত্তর নেই; মানটার অর্থ দেখে প্রতিটা কলামের জন্য আলাদা সিদ্ধান্ত নিন:
| কলাম | সিদ্ধান্ত | কেন |
|---|---|---|
customer_id | সারিটা বাতিল | যে অর্ডার কেউ দেয়নি, তা কারও নামে লেখা বা জয়েন করা যায় না; আন্দাজ মানে ডেটা বানিয়ে ফেলা |
city | customers টেবিল থেকে পূরণ, না হলে "Unknown" | আরও ভালো একটা উৎস আছে; "Unknown" ভান না করে সারিটা মোট হিসাবে রাখে |
amount | পরিমাণ × তালিকা-মূল্য দিয়ে ইমপিউট, আর চিহ্নিত | কলামের গড়ের চেয়ে ব্যবসার নিয়ম ভালো; চিহ্নটা মডেল বা বিশ্লেষককে একে আলাদাভাবে দেখার সুযোগ দেয় |
ML-এর প্রিপ্রসেসিংয়ে সংখ্যার ফাঁক কলামের গড় বা মিডিয়ান দিয়ে পূরণ করা খুব চলে (গড়টা হিসাব করুন শুধু ট্রেনিংয়ের সারি থেকে, SQL ও pandas দিয়ে ML-এর উপযোগী ডেটা তৈরি পাতায় যেমন বলা আছে), কিন্তু আগে সবসময় জিজ্ঞেস করুন আরও ভালো উৎস আছে কি না, আর একটা চিহ্নের কলাম রাখুন, যাতে ইমপিউট করা মানকে কখনো পর্যবেক্ষিত মান বলে ভুল না হয়।
ডেটাবেস থেকে তথ্য যোগ: merge
shop ডেটাবেস প্রত্যেক গ্রাহকের শহর আর প্রত্যেক প্রোডাক্টের তালিকা-মূল্য জানে। merge হলো pandas-এর JOIN। দুটো আর্গুমেন্ট একে নিরাপদ করে: ডান দিকের টেবিলে কি ডুপ্লিকেট থাকলে validate="many_to_one" এরর তোলে (নইলে চুপচাপ আপনার সারি বহুগুণ হয়ে যেত: সেই fan-out ফাঁদ), আর indicator=True একটা _merge কলাম যোগ করে, যা বলে প্রতিটা সারি কোথায় মিল পেয়েছে:
import sqlite3
con = sqlite3.connect("shop.db")
customers = pd.read_sql("SELECT customer_id, city AS home_city FROM customers", con)
catalogue = pd.read_sql("SELECT name AS product, price AS list_price FROM products", con)
customers["customer_id"] = customers["customer_id"].astype("Int64") # দুই দিকে একই dtype
df = df.merge(customers, on="customer_id", how="left", validate="many_to_one", indicator=True)
df = df.merge(catalogue, on="product", how="left", validate="many_to_one")
print(df["_merge"].value_counts())
print(df.loc[df["_merge"] == "left_only", ["order_id", "customer_id"]])
df["city"] = df["city"].fillna(df["home_city"]).fillna("Unknown")
df["amount_imputed"] = df["amount"].isna()
df["amount"] = df["amount"].fillna(df["qty"] * df["list_price"])
print(df.loc[df["order_id"].isin(["1007", "1011"]), ["order_id", "city", "amount", "amount_imputed"]])_merge
both 14
left_only 2
right_only 0
Name: count, dtype: int64
order_id customer_id
14 1015 <NA>
15 1016 9
order_id city amount amount_imputed
6 1007 Unknown 1800.0 False
10 1011 Dhaka 3000.0 Trueদুটো অর্ডার কোনো গ্রাহক পায়নি: ১০১৫-তে গ্রাহকই নেই, আর ১০১৬ দেখাচ্ছে গ্রাহক ৯-কে, যাঁর কোনো অস্তিত্ব নেই (একটা অনাথ বা orphan সারি, ফরেন কি থাকলে যে রেফারেনশিয়াল ইন্টিগ্রিটির সমস্যা আটকে যেত)। অর্ডার ১০০৭-এর শহরও পূরণ হয়নি, কারণ ডেটাবেসেও গ্রাহক ৫-এর কোনো শহর নেই, তাই সেটা সৎভাবেই হয়েছে "Unknown"।
আউটলায়ার আর সীমা যাচাই
আউটলায়ারের প্রচলিত নিয়ম হলো IQR: Q1 আর Q3 যদি ২৫তম আর ৭৫তম পার্সেন্টাইল হয় আর IQR = Q3 − Q1, তাহলে Q1 − 1.5 × IQR-এর নিচের বা Q3 + 1.5 × IQR-এর ওপরের মান সন্দেহজনক:
q1, q3 = df["amount"].quantile([0.25, 0.75])
iqr = q3 - q1
low, high = q1 - 1.5 * iqr, q3 + 1.5 * iqr
print(f"Q1={q1:.0f} Q3={q3:.0f} IQR={iqr:.0f} -> normal range {low:.0f} to {high:.0f}")
print(df.loc[(df["amount"] < low) | (df["amount"] > high), ["order_id", "product", "amount"]])Q1=1425 Q3=7125 IQR=5700 -> normal range -7125 to 15675
order_id product amount
4 1005 27-inch Monitor 18500.0
9 1010 27-inch Monitor 185000.0নিয়মটা ১,৮৫,০০০-এর টাইপোটা ধরেছে, কিন্তু সাথে অর্ডার ১০০৫-কেও, যা তালিকা-মূল্যে একদম আসল একটা মনিটর বিক্রি, আর ১৫,০০০ টাকার Bootcamp বিক্রিগুলো অল্পের জন্য বেঁচে গেছে। অর্ডারের টাকার অঙ্ক স্বাভাবিকভাবেই একদিকে হেলানো: কয়েকটা দামি প্রোডাক্ট মানেই ভুল নয়। আউটলায়ারের নিয়ম পরীক্ষা করে দেখার মতো প্রার্থী খুঁজে দেয়, কখনোই আপনাআপনি মুছে ফেলার মতো সারি নয়। ব্যবসার নিয়মে যাচাই আরও ধারালো: একক দামকে তালিকার দামের সাথে মেলান, আর কঠিন সীমার নিয়মগুলো যোগ করুন (পরিমাণ আর টাকা ধনাত্মক হতেই হবে):
df["price_ratio"] = (df["amount"] / df["qty"] / df["list_price"]).round(2)
checks = {
"no customer": df["_merge"] == "left_only",
"qty must be positive": ~(df["qty"] > 0).fillna(False),
"price far from list": (df["price_ratio"] < 0.5) | (df["price_ratio"] > 1.5),
}
df["problem"] = None
for reason, bad in checks.items():
df.loc[bad & df["problem"].isna(), "problem"] = reason # প্রথম পাওয়া কারণটাই রাখা
print(df.loc[df["problem"].notna(), ["order_id", "qty", "amount", "price_ratio", "problem"]]) order_id qty amount price_ratio problem
9 1010 1 185000.0 10.0 price far from list
13 1014 -1 -2200.0 1.0 qty must be positive
14 1015 1 900.0 1.0 no customer
15 1016 1 1000.0 1.0 no customerঅর্ডার ১০০৯ বিক্রি হয়েছে তালিকা-মূল্যের ০.৯ গুণে (একটা আসল ছাড়) আর সেটা পাস করেছে; ১,৮৫,০০০-এর টাইপোটা তালিকা-মূল্যের দশ গুণ, তাই ধরা পড়েছে। ~(df["qty"] > 0).fillna(False) খেয়াল করুন। nullable Int64 dtype-এ অনুপস্থিত পরিমাণের তুলনা করলে আসে <NA>, False নয়, আর মাস্কে <NA> থাকলে .loc সেই সারি বাছে না। ~-এর আগে <NA>-কে False বানালে অনুপস্থিত পরিমাণও সমস্যা হিসেবে গোনা হয়; সাধারণ df["qty"] <= 0 সেটাকে ছেড়ে দিত।
এই পাতার প্রতিটা সংশোধন একটা অ্যাসাইনমেন্ট:
df[col] = …বাdf.loc[mask, col] = …। pandas 3-এ Copy-on-Write সবসময় চালু, তাইdf["amount"][mask] = 0-এর মতো চেইন করা অ্যাসাইনমেন্টdf-কে কখনো বদলায় না (pandas শুধু সতর্ক করে),df["city"].fillna("Unknown", inplace=True)-ও বদলায় না, কারণdf["city"]একটা কপির মতো আচরণ করে। পুরোনো টিউটোরিয়াল আর Stack Overflow-এর উত্তরে দুটোই প্রচুর দেখবেন; তার বদলে লিখুনdf.loc[mask, "amount"] = 0আরdf["city"] = df["city"].fillna("Unknown")।
ভাগ, যাচাই, সারাংশ আর এক্সপোর্ট
বাতিল সারি মুছে ফেলা হয় না; সেগুলো কারণসহ যায় একটা কোয়ারেন্টাইন ফাইলে, যাতে কেউ উৎসে গিয়ে ঠিক করতে পারে। ভালো সারিগুলোর ওপর চলে শেষ কয়েকটা assert চেক: একটাও ব্যর্থ হলে খারাপ ডেটা সেভ হওয়ার আগেই স্ক্রিপ্ট থেমে যায়।
keep = ["order_id", "customer_id", "order_date", "city", "product", "qty", "amount", "amount_imputed", "status"]
rejected = df[df["problem"].notna()]
clean = df.loc[df["problem"].isna(), keep].reset_index(drop=True)
assert clean["order_id"].is_unique
assert clean[["customer_id", "order_date", "amount"]].notna().all().all()
assert (clean["qty"] > 0).all() and (clean["amount"] > 0).all()
assert set(clean["status"]) <= {"pending", "shipped", "delivered", "cancelled"}
print(len(clean), "clean rows,", len(rejected), "rejected")
clean.to_csv("orders_clean.csv", index=False)
rejected[["order_id", "problem"]].to_csv("orders_rejected.csv", index=False)
clean.to_excel("orders_clean.xlsx", index=False, sheet_name="orders")
print(pd.read_excel("orders_clean.xlsx", sheet_name="orders").shape)12 clean rows, 4 rejected
(12, 9)এবার ডেটা প্রশ্নের উত্তর দিতে পারে। শহরভিত্তিক আয়ে গোনা হয় শুধু বাতিল না হওয়া অর্ডার। pivot_table লম্বা ডেটাকে (প্রতি অর্ডারে এক সারি) চওড়া মাস × শহর টেবিলে বদলায়, আর melt সেটাকে আবার লম্বা রূপে ফেরায়, যা বেশিরভাগ চার্ট আর ML টুল চায়:
sold = clean[clean["status"] != "cancelled"]
print(sold.groupby("city")["amount"].agg(["count", "sum"]).sort_values("sum", ascending=False))
sold = sold.assign(month=sold["order_date"].dt.strftime("%Y-%m"))
wide = sold.pivot_table(index="month", columns="city", values="amount", aggfunc="sum", fill_value=0)
print(wide)
long = wide.reset_index().melt(id_vars="month", var_name="city", value_name="revenue")
print(long[long["revenue"] > 0].sort_values(["month", "city"]).head(4)) count sum
city
Chattogram 3 22500.0
Khulna 2 19050.0
Dhaka 5 11100.0
Unknown 1 1800.0
city Chattogram Dhaka Khulna Unknown
month
2026-01 4500.0 1200.0 0.0 0.0
2026-02 0.0 4500.0 0.0 0.0
2026-03 15000.0 2400.0 0.0 1800.0
2026-04 0.0 0.0 4050.0 0.0
2026-05 3000.0 3000.0 0.0 0.0
2026-06 0.0 0.0 15000.0 0.0
month city revenue
0 2026-01 Chattogram 4500.0
6 2026-01 Dhaka 1200.0
7 2026-02 Dhaka 4500.0
2 2026-03 Chattogram 15000.0সব একসাথে: একটা পুনর্ব্যবহারযোগ্য clean()
নোটবুকের ধাপগুলো সমস্যা আবিষ্কারের জন্য। সমস্যাগুলো জানা হয়ে গেলে সিদ্ধান্তগুলো এমন একটা ফাংশনে সরিয়ে নিন, যা কাঁচা ডেটা নিয়ে পরিষ্কার আর বাতিল সারি ফেরত দেয়। পরের মাসের ফাইল হুবহু একই নিয়মের মধ্য দিয়ে যায়, ফাংশনটা টেস্ট করা যায়, আর এর গণনাগুলোই হয় প্রতিটা রানের লগ:
CITY_MAP = {"Ctg": "Chattogram", "Chittagong": "Chattogram", "": None}
STATUS_MAP = {"canceled": "cancelled"}
def clean_orders(path, customers, catalogue):
"""কাঁচা অর্ডার CSV থেকে (clean, rejected) DataFrame ফেরত দেয়।"""
df = pd.read_csv(path, dtype=str, keep_default_na=False)
df.columns = (df.columns.str.strip().str.lower()
.str.replace(r"[^a-z0-9]+", "_", regex=True).str.strip("_"))
df = df.rename(columns={"customer": "customer_id", "amount_bdt": "amount_text"})
df = df.apply(lambda s: s.str.strip())
df["city"] = df["city"].str.title().replace(CITY_MAP)
df["status"] = df["status"].str.lower().replace(STATUS_MAP)
df["amount"] = pd.to_numeric(df["amount_text"].str.replace("৳", "").str.replace(",", ""), errors="coerce")
for col in ["qty", "customer_id"]:
df[col] = pd.to_numeric(df[col], errors="coerce").astype("Int64")
df["order_date"] = (pd.to_datetime(df["order_date"], format="%Y-%m-%d", errors="coerce")
.fillna(pd.to_datetime(df["order_date"], format="%d/%m/%Y", errors="coerce")))
df = df.drop_duplicates().reset_index(drop=True)
df = df.merge(customers, on="customer_id", how="left", validate="many_to_one", indicator=True)
df = df.merge(catalogue, on="product", how="left", validate="many_to_one")
df["city"] = df["city"].fillna(df["home_city"]).fillna("Unknown")
df["amount_imputed"] = df["amount"].isna()
df["amount"] = df["amount"].fillna(df["qty"] * df["list_price"])
ratio = df["amount"] / df["qty"] / df["list_price"]
df["problem"] = None
for reason, bad in {
"duplicate order_id": df["order_id"].duplicated(keep=False),
"no customer": df["_merge"] == "left_only",
"bad date": df["order_date"].isna(),
"unknown product": df["list_price"].isna(),
"qty must be positive": ~(df["qty"] > 0).fillna(False),
"price far from list": (ratio < 0.5) | (ratio > 1.5),
}.items():
df.loc[bad & df["problem"].isna(), "problem"] = reason
good = df["problem"].isna()
return df.loc[good, keep].reset_index(drop=True), df.loc[~good, ["order_id", "problem"]]
clean2, rejected2 = clean_orders("orders_messy.csv", customers, catalogue)
print(len(clean2), "clean,", len(rejected2), "rejected")
print(rejected2.to_string(index=False))
print("same result as the step-by-step version:", clean2.equals(clean))12 clean, 4 rejected
order_id problem
1010 price far from list
1014 qty must be positive
1015 no customer
1016 no customer
same result as the step-by-step version: Trueশেষ লাইনটা একটা ছোট রিগ্রেশন টেস্ট: ইন্টারঅ্যাকটিভ ধাপগুলো যা বানিয়েছিল, ফাংশনটা হুবহু তা-ই বানায়। পরিষ্কারের কোড নতুন করে সাজানোর (refactor) সময় এমন একটা চেক সবসময় রাখুন।
বাস্তবে কোথায় দেখবেন
- ট্রেনিং ডেটা: অ্যানোটেটরদের লেবেল ফাইলে ক্লাসের নাম অসামঞ্জস্যপূর্ণ থাকে ("Spam", "spam ", "SPAM"); ট্রেনিংয়ের আগে একটা ম্যাপিং ধাপ মডেলকে তিনটা আলাদা ক্লাস দেখা থেকে বাঁচায়।
- RAG আর LLM পাইপলাইন: চাঙ্ক আর এমবেড করার আগে ডকুমেন্টগুলো ডুপ্লিকেটমুক্ত, ছাঁটাই আর একরূপ করা হয়, নইলে রিট্রিভার একই অনুচ্ছেদ তিনবার ফেরত দেয়।
- অ্যানালিটিক্স আর ফিন্যান্স: পার্টনারদের পাঠানো ডেটা প্রতি রাতে ঠিক এই পরিষ্কার / বাতিল / কোয়ারেন্টাইন ধাপ পেরোয়, আর বাতিল ফাইলটা পার্টনারকে ফেরত পাঠানো হয়।
সাধারণ ভুল
- Excel-এ হাতে পরিষ্কার করা। এটা আবার করা, রিভিউ করা বা টেস্ট করা যায় না, আর পরের মাসের ফাইলে একই খাটুনি আবার। প্রতিটা সিদ্ধান্ত কোডে রাখুন।
- কী coerce হলো তা না দেখে
errors="coerce"ব্যবহার। ভুল ফরম্যাটের একটা পুরো কলাম চুপচাপNaNহয়ে যায়। রূপান্তরের ঠিক পরেই নতুন অনুপস্থিত মান গুনুন:pd.to_numeric(s, errors="coerce").isna().sum()-কেs.eq("").sum()-এর সাথে মিলিয়ে দেখুন। - তারিখের ফরম্যাট pandas-কে আন্দাজ করতে দেওয়া। ১ থেকে ১২ তারিখে দিন আর মাস চুপচাপ জায়গা বদলায়। সবসময়
format=দিন। - আউটলায়ার আপনাআপনি মুছে ফেলা। ওপরে IQR নিয়ম একটা আসল বিক্রিকে চিহ্নিত করেছে। চিহ্নিত করুন, পরীক্ষা করুন, তারপর সিদ্ধান্ত নিন।
validateছাড়া merge। লুকআপ টেবিলে একটা ডুপ্লিকেট কি সারিগুলোকে বহুগুণ করে প্রতিটা যোগফল ফুলিয়ে দেয়।validate="many_to_one"সেই নিঃশব্দ বাগকে তাৎক্ষণিক এররে বদলে দেয়:bad_catalogue = pd.concat([catalogue, catalogue.head(1)]) # "Python Crash Course" দুবার try: clean.merge(bad_catalogue, on="product", how="left", validate="many_to_one") except pd.errors.MergeError as exc: print("MergeError:", str(exc).splitlines()[0])MergeError: Merge keys are not unique in right dataset; not a many-to-one merge
নিজে চেষ্টা করুন
- সহজ:
cleanথেকে বাতিল নয় এমন অর্ডারে প্রত্যেক গ্রাহকের মোট খরচ বের করুন, সবচেয়ে বেশি থেকে শুরু করে। - মাঝারি: যাচাই করুন যে কোনো পরিষ্কার অর্ডারের তারিখ গ্রাহকের যোগদানের আগের নয়।
shop.dbথেকেjoined_on(তারিখ হিসেবে) লোড করুন,validate-সহclean2-এর সাথে merge করুন, আর যেসব order id ব্যর্থ হয় সেগুলো প্রিন্ট করুন, একটাও না থাকলে একটা বার্তা। - কঠিন:
quality_report(df)লিখুন, যা প্রতি কলামে এক সারির একটা DataFrame ফেরত দেয়: dtype, অনুপস্থিত মানের সংখ্যা, আলাদা মানের সংখ্যা, আর সবচেয়ে বেশি আসা মান।rawআরclean-এর ওপর চালিয়ে তুলনা করুন।
উত্তর
# ১. সহজ
spend = (clean[clean["status"] != "cancelled"]
.groupby("customer_id")["amount"].sum()
.sort_values(ascending=False))
print(spend)
# ২. মাঝারি
joined = pd.read_sql("SELECT customer_id, joined_on FROM customers", con, parse_dates=["joined_on"])
joined["customer_id"] = joined["customer_id"].astype("Int64")
checked = clean2.merge(joined, on="customer_id", how="left", validate="many_to_one")
too_early = checked[checked["order_date"] < checked["joined_on"]]
print(too_early["order_id"].tolist() or "no order is dated before its customer joined")
# ৩. কঠিন
def quality_report(df):
return pd.DataFrame({
"dtype": df.dtypes.astype(str),
"missing": df.isna().sum(),
"distinct": df.nunique(),
"most_common": df.mode().iloc[0],
})
print(quality_report(raw))
print(quality_report(clean[["city", "status", "amount"]]))সারসংক্ষেপ
- আগে দেখুন (
info,describe,value_counts), তারপর লেখা হিসেবে পড়ে প্রতিটা কলাম বুঝেশুনে বদলান। - লেখা: strip, case ঠিক করা, বিভিন্ন রূপকে একটা মানে ম্যাপ করা। সংখ্যা:
to_numeric(errors="coerce"), আর কীNaNহলো তা দেখা। তারিখ: স্পষ্টformat-সহto_datetime। - প্রতিটা কলামের জন্য ঠিক করুন অনুপস্থিত মান মানে বাতিল, ভালো উৎস থেকে পূরণ, নাকি চিহ্নসহ ইমপিউট। হুবহু ডুপ্লিকেট বাদ দিন; পরস্পরবিরোধীগুলো একটা নিয়ম দিয়ে মেটান।
- আউটলায়ারের নিয়ম প্রার্থী খুঁজে দেয়; সিদ্ধান্ত নেয় ব্যবসার নিয়ম আর সীমা যাচাই। merge করুন
validateআরindicator-সহ। - শেষ করুন
assertচেক, বাতিল সারির জন্য কোয়ারেন্টাইন, আর এমন একটা পুনর্ব্যবহারযোগ্য ফাংশন দিয়ে, যা পুরো পরিষ্কারের কাজটা আবার করার মতো করে তোলে।
এরপর: ইমপোর্ট ও এক্সপোর্ট: CSV, JSON, Excel আর Parquet পাতা এই প্রক্রিয়ার দুই প্রান্তের ফাইলগুলো নিয়ে: প্রতিটা প্রচলিত ফরম্যাট ঠিকভাবে পড়া-লেখা (এনকোডিং, ডিলিমিটার, নেস্টেড JSON, Parquet), সেগুলো ডেটাবেসে বাল্কে লোড করা, আর ঢোকার পথে ব্যর্থ সারিগুলো কোয়ারেন্টাইনে রাখা।