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

ডেটা পরিষ্কার — missing value, duplicate, type আর বাংলা লেখা

Data Cleaning with pandas — Missing Values, Duplicates, Types and Bangla Text

Taught in Bangla, this lesson cleans a deliberately messy order file with pandas: profiling, missing values with dropna, fillna, group medians and ffill, MCAR, MAR and MNAR, exact and partial duplicates, and fixing prices, dates and phone numbers. It covers Bangla digits, Unicode NFC and spelling maps, and ends with an assert-checked pipeline and cleaning log from real runs.

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

  • বাস্তবের ডেটা কেন এলোমেলো হয়, আর একটি এলোমেলো টেবিলের “স্বাস্থ্য পরীক্ষা” কীভাবে করবেন
  • Missing value খোঁজা, বাদ দেওয়া বা পূরণ করা — আর কোন পছন্দে ফল কীভাবে পক্ষপাতদুষ্ট হতে পারে
  • হুবহু আর আংশিক duplicate ধরা, subset আর keep-এর অর্থ
  • দাম, তারিখ, ফোন নম্বরের type ঠিক করা; বাংলা অঙ্ক, Unicode NFC আর জেলার নামের প্রমিত রূপ
  • Assertion দিয়ে নিয়ম যাচাই, ধাপভিত্তিক cleaning log আর raw ফাইল অক্ষত রাখার অভ্যাস

১ · বাস্তবের ডেটা কেন এলোমেলো — বাজারঘরের অর্ডার খাতা

বইয়ের উদাহরণে ডেটা সবসময় পরিপাটি: প্রতিটি ঘর ভরা, প্রতিটি সংখ্যা সংখ্যা, প্রতিটি নাম এক বানানে। বাস্তবের ডেটা এমন হয় না। বাজারঘরের (এই কোর্সের কাল্পনিক অনলাইন দোকান) অর্ডার আসে তিন পথে: ওয়েবসাইট, ফেসবুক পেজের মেসেজ থেকে হাতে তোলা অর্ডার, আর একটি পুরোনো Excel খাতা। প্রতিটি পথের নিজের অভ্যাস। কেউ দামের সামনে “৳” লেখেন, কেউ কমা দেন; কেউ জেলা লেখেন “ঢাকা”, কেউ “Dhaka”, কেউ ভুল করে শেষে একটি ফাঁকা জায়গা রেখে দেন; বাংলা keyboard-এ ফোন নম্বর টাইপ করলে অঙ্কগুলো আসে বাংলায়।

পাড়ার মুদি দোকানের বাকির খাতা ভাবুন। তিনজন কর্মচারী তিন রকম লেখেন: একজন “রহিম ভাই — ২ কেজি চাল”, আরেকজন “Rahim — chal 2kg”, তৃতীয়জন একই লেনদেন দুবার লিখে ফেলেছেন। মাস শেষে হিসাব মেলাতে বসলে প্রথম কাজ অঙ্ক কষা নয় — প্রথম কাজ খাতা পরিষ্কার করা: কে কোন জন, কোন লেখা দুবার, কোন ঘরে দাম লেখাই হয়নি। ডেটা পরিষ্কার (data cleaning) ঠিক এই কাজ, শুধু হাতের বদলে কোডে — যাতে প্রতিটি সিদ্ধান্ত লেখা থাকে আর আবার চালানো যায়।

নিচে বাজারঘরের এক সপ্তাহের অর্ডারের একটি ছোট CSV, ইচ্ছা করে এলোমেলো রাখা। এতে বাস্তবে যা যা দেখা যায় তার প্রায় সবই আছে। ফাইলটি পাতার ভেতরেই রাখা, কোড একে orders.csv নামে পড়ে। (সব ফোন নম্বর স্পষ্টতই বানানো।)

order_id,date,district,product,qty,price,payment,phone
1001,2026-03-05,ঢাকা,চা পাতা,2,৳350,bKash,01700000001
1002,05/03/2026,Dhaka,হেডফোন,1,"৳1,250",COD,০১৮০০০০০০০২
1003,2026-03-06,dhaka ,চাল ৫kg,1,N/A,card,01900000003
1004,2026-03-06,চট্টগ্রাম,হেডফোন,1,"2,400",COD,01500000004
1005,7 Mar 2026,Chattogram,চা পাতা,3,-,bKash,০১৬০০০০০০০৫
1006,2026-03-07,Chittagong,মগ,-2,৳180,COD,01700000006
1006,2026-03-07,Chittagong,মগ,-2,৳180,COD,01700000006
1007,2026-03-08,খুলনা,চাল ৫kg,2,৳ 720,card,01800000007
1008,2026-03-08,Khulna,হেডফোন,1,,COD,01900000008
1009,2026-03-09,ময়মনসিংহ,চা পাতা,1,৳340,bKash,01500000009
1010,2026-03-09,ময়মনসিংহ,মগ,4,৳170,COD,01600000010
1011,2026-13-09,DHAKA,হেডফোন,1,"৳2,450",card,0170000001
1004,2026-03-06,চট্টগ্রাম ,হেডফোন,1,"2,400",bKash,01500000004
1012,2026-03-10,,চা পাতা,1,৳360,,01800000012

মাত্র ১৪টি সারিতে কতগুলো সমস্যা লুকিয়ে আছে, নিচের তালিকায় দেখুন। প্রতিটি সমস্যার জন্য এই পর্বের একটি অংশ।

সমস্যাউদাহরণঅংশ
দাম নেই, বা “N/A” আর “-” লেখা১০০৩, ১০০৫, ১০০৮২, ৩
পুরো সারি দুবার১০০৬৪
একই order_id, অন্য তথ্য আলাদা১০০৪ (শেষ সারিতে payment bKash)৪
দামে “৳”, কমা আর ফাঁকা জায়গা"৳1,250", ৳ 720৫
তিন রকম তারিখ, একটি অসম্ভব05/03/2026, 7 Mar 2026, 2026-13-09৫
বাংলা অঙ্কে ফোন, একটি ১০ অঙ্কের০১৮০০০০০০০২, 0170000001৫, ৬
জেলার নাম নানা বানানে, শেষে ফাঁকাঢাকা, Dhaka, dhaka␠, DHAKA৬
দেখতে এক, byte-এ আলাদা বাংলাময়মনসিংহ (দুই রূপ)৬
অসম্ভব মানqty = −2৭
জেলা ফাঁকা১০১২৩, ৭

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

এই পর্বে ধরে নেওয়া হচ্ছে আপনি DataFrame, Series আর index চেনেন (পর্ব ২০), আর loc ও boolean mask দিয়ে সারি বাছতে পারেন (পর্ব ২২)। Boolean mask-এর মূল ধারণা NumPy-তেই দেখেছেন, পর্ব ১৪-এ।

এই পাতার Python কোড আপনার ব্রাউজারেই চলে (Pyodide, লেখার সময়ে pandas 3.0.2). প্রথমবার “চালান” চাপলে Python আর pandas লোড হতে কয়েক সেকেন্ড লাগে; পরের বার অনেক দ্রুত। প্রতিটি কোড-বাক্স নিজে থেকে orders.csv নতুন করে পড়ে, তাই যেকোনো ক্রমে চালাতে পারেন।

২ · আগে মাপুন — টেবিলের স্বাস্থ্য পরীক্ষা আর missing value খোঁজা

ডাক্তার ওষুধ লেখার আগে রোগীর জ্বর, রক্তচাপ আর ওজন মাপেন। ডেটাতেও প্রথম ধাপ profiling: কিছু না বদলে শুধু দেখা — কয়টি সারি, প্রতিটি কলামের dtype কী, কোথায় মান নেই, কয়টি সারি দুবার আছে, একটি কলামে কয়টি আলাদা মান। এই মাপগুলো পরে আরেকটি কাজেও লাগে: পরিষ্কারের পরে আবার মেপে তুলনা করা যায়, কী বদলাল।

pandas-এ “মান নেই” বোঝাতে কয়েকটি চিহ্ন আছে: সংখ্যার কলামে NaN, তারিখের কলামে NaT, আর nullable কলামে <NA>. সবগুলোকে একসঙ্গে বলে missing value. খোঁজার মূল হাতিয়ার isna(): প্রতিটি ঘরের জন্য True বা False. এর ওপর .sum() চালালে প্রতিটি কলামে কয়টি ঘর ফাঁকা, তা আসে — কারণ যোগ করার সময় True মানে ১।

একটি সূক্ষ্ম বিষয় মনে রাখুন: pandas কোন লেখাকে “মান নেই” ধরবে, তা ঠিক হয় পড়ার সময়. read_csv নিজে থেকে একটি তালিকা চেনে — ফাঁকা ঘর, NA, N/A, NaN, null ইত্যাদি। কিন্তু অফিসের খাতায় যে “-” বা “নেই” লেখা হয়, তা ওই তালিকায় নেই। সেগুলো চেনাতে na_values দিতে হয়। নিচের কোডে দুটোই দেখুন।

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

raw = pd.read_csv("orders.csv")
print(raw.shape, "| pandas", pd.__version__)
print(raw.dtypes.astype(str).to_dict())
print("\nnulls (default read):")
print(raw.isna().sum()[lambda s: s > 0].to_dict())
print("price of 1003, 1005:", raw.loc[[2, 4], "price"].tolist())
print("exact duplicate rows:", raw.duplicated().sum())
print("repeated order_id   :", raw["order_id"].duplicated().sum())
print("district spellings  :", raw["district"].nunique())
print(raw["district"].dropna().unique().tolist())

df = pd.read_csv("orders.csv", dtype={"phone": str},
                 na_values=["-"])
print("\nwith na_values=['-']:")
print(df.isna().sum()[lambda s: s > 0].to_dict())
print("rows with any null:", df.isna().any(axis=1).sum())
print("qty < 0:", (df["qty"] < 0).sum(), "rows")

আসল আউটপুট (পাতার Pyodide-এ):

(14, 8) | pandas 3.0.2
{'order_id': 'int64', 'date': 'str', 'district': 'str', 'product': 'str', 'qty': 'int64', 'price': 'str', 'payment': 'str', 'phone': 'str'}

nulls (default read):
{'district': 1, 'price': 2, 'payment': 1}
price of 1003, 1005: [nan, '-']
exact duplicate rows: 1
repeated order_id   : 2
district spellings  : 12
['ঢাকা', 'Dhaka', 'dhaka ', 'চট্টগ্রাম', 'Chattogram', 'Chittagong', 'খুলনা', 'Khulna', 'ময়মনসিংহ', 'ময়মনসিংহ', 'DHAKA', 'চট্টগ্রাম ']

with na_values=['-']:
{'district': 1, 'price': 3, 'payment': 1}
rows with any null: 4
qty < 0: 2 rows

প্রথম লাইন: ১৪টি সারি, ৮টি কলাম। dtype-এর তালিকায় সবচেয়ে জরুরি তথ্যটি হলো price এসেছে str হিসেবে — “৳” আর কমার কারণে pandas একে সংখ্যা ভাবতে পারেনি। pandas 3-এ লেখার কলামের ডিফল্ট dtype নতুন str dtype, আগের version-গুলোর মতো object নয়। qty ঠিকই int64, কারণ সেখানে সব মান পূর্ণসংখ্যা, এমনকি −২-ও।

ডিফল্ট পড়ায় price-এ ২টি ফাঁকা: ১০০৮-এর ফাঁকা ঘর আর ১০০৩-এর “N/A”. কিন্তু ১০০৫-এর “-” রয়ে গেছে একটি সাধারণ লেখা হিসেবে — আউটপুটে [nan, '-']. na_values=["-"] দেওয়ার পর সংখ্যা হলো ৩. অর্থাৎ শুধু isna().sum() দেখে “৩টি দাম নেই” বলা যেত না; ফাঁকা বোঝানোর সব রীতি আগে খুঁজে বের করতে হয়। জেলার কলামে ১২টি আলাদা বানান — অথচ জেলা আসলে মাত্র ৪টি। আর হুবহু duplicate ১টি, কিন্তু একই order_id দ্বিতীয়বার এসেছে ২ বার — এই পার্থক্যের রহস্য অংশ ৪-এ।

মূল ধারণা

Missing value খোঁজার তিন স্তর: (১) pandas যা নিজে চেনে (ফাঁকা, NA, N/A …); (২) উৎসের নিজস্ব রীতি (“-”, “নেই”, “0”, “999”) — এগুলো na_values দিয়ে, বা পরে replace দিয়ে NaN বানাতে হয়; (৩) লুকোনো ফাঁকা — যেমন “ফাঁকা জায়গা” ভরা একটি ঘর, যা isna()-এ ধরা পড়ে না, কিন্তু অর্থে ফাঁকা। প্রতিটি কলামের unique() বা value_counts() চোখ বুলিয়ে দেখলে তৃতীয় স্তরটি সাধারণত ধরা পড়ে।

৩ · Missing value সামলানো — বাদ দেওয়া, পূরণ করা আর তার দাম

ফাঁকা ঘর পেলে হাতে মূলত দুটি পথ: সারিটি বাদ দেওয়া, অথবা কোনো যুক্তিসংগত মান বসানো। দ্বিতীয়টিকে বলে imputation. দুটোরই pandas-এ সহজ রূপ আছে, আর দুটোরই লুকোনো দাম আছে।

বাদ দেওয়া — dropna, subset আর thresh

df.dropna() সবচেয়ে কড়া: যেকোনো কলামে একটি ফাঁকা থাকলেই সারি বাদ। বাস্তবে প্রায়ই এটি অতিরিক্ত, কারণ একটি অপ্রয়োজনীয় কলামের ফাঁকার জন্য একটি ভালো সারি হারায়। তাই দুটি নিয়ন্ত্রণ আছে: subset — শুধু এই কলামগুলোতে ফাঁকা থাকলে বাদ; আর thresh — কমপক্ষে এতগুলো ঘর ভরা থাকলে রাখা। নিচে চারটি সারির একটি ছোট টেবিলে তিনটির পার্থক্য দেখুন (সত্যিকারের run, নিজের ল্যাপটপে, Python 3.14.4, pandas 3.1.1):

import numpy as np
import pandas as pd

df = pd.DataFrame({"order_id": [1, 2, 3, 4],
                   "district": ["ঢাকা", None, "খুলনা", None],
                   "price": [35000, np.nan, np.nan, np.nan],
                   "phone": ["01700000001", "01800000002", None, None]})
print("rows:", len(df))
print("dropna()                  :", len(df.dropna()))
print("dropna(subset=['district']):", len(df.dropna(subset=["district"])))
print("dropna(thresh=2)          :", len(df.dropna(thresh=2)))
print("fillna('অজানা')           :", df["district"].fillna("অজানা").tolist())

stock = pd.Series([50, np.nan, np.nan, 42, np.nan],
                  index=pd.date_range("2026-03-01", periods=5, freq="D"))
print("ffill()       :", stock.ffill().tolist())
print("ffill(limit=1):", stock.ffill(limit=1).tolist())
~/bazarghar-ai$ python na_demo.py
rows: 4
dropna()                  : 1
dropna(subset=['district']): 2
dropna(thresh=2)          : 3
fillna('অজানা')           : ['ঢাকা', 'অজানা', 'খুলনা', 'অজানা']
ffill()       : [50.0, 50.0, 50.0, 42.0, 42.0]
ffill(limit=1): [50.0, 50.0, nan, 42.0, 42.0]

চারটি সারির মধ্যে কড়া dropna() রেখেছে মাত্র ১টি — ৭৫% ডেটা গেল। শুধু জেলা দরকার হলে subset=["district"] রাখে ২টি। আর thresh=2 মানে “কমপক্ষে ২টি ঘর ভরা থাকলে রাখা হবে”: সারি ২ (order_id আর phone) আর সারি ৩ (order_id আর district) টিকে গেল, শুধু সারি ৪ (শুধু order_id) বাদ। কোন নিয়ম ঠিক, তা নির্ভর করে পরের কাজে কোন কলাম লাগবে।

পূরণ করা — ধ্রুবক, দলের median আর forward fill

ধ্রুবক: fillna("অজানা") — লেখার কলামে সৎ একটি বিকল্প, কারণ “অজানা” নিজেই একটি তথ্য: এটি পরে আলাদা দল হিসেবে দেখা যায়। কিন্তু সংখ্যার কলামে fillna(0) প্রায়ই বিপজ্জনক: দাম ০ বসালে গড় দাম কমে যায়, আর model শেখে “কিছু পণ্য বিনা পয়সায় বিক্রি হয়”।

দলের median: দাম পণ্যের ওপর নির্ভর করে, তাই একটি হারানো দামের সবচেয়ে ভালো অনুমান সাধারণত একই পণ্যের অন্য দামগুলোর median — পুরো টেবিলের নয়। pandas-এ এর রূপ groupby("product")["price"].transform("median"): প্রতিটি সারির পাশে তার নিজের দলের median বসে, তারপর fillna শুধু ফাঁকা ঘরে সেটি বসায়। groupby নিজে পর্ব ২৪-এর বিষয়; এখানে শুধু এই একটি ব্যবহার। Mean-এর বদলে median কেন? কারণ median একটি-দুটি অস্বাভাবিক মানে (outlier) কম নড়ে। নিচে বাজারঘরের পরিষ্কার-প্রায় টেবিলে ধাপে ধাপে দেখুন।

দাম (পয়সা)৩টি ফাঁকা
order  product   price
1001   চা পাতা    35000
1005   চা পাতা    <NA>
1009   চা পাতা    34000
1003   চাল ৫kg    <NA>
1007   চাল ৫kg    72000
1002   হেডফোন    125000
1004   হেডফোন    240000
1008   হেডফোন    <NA>
1011   হেডফোন    245000
1010   মগ         17000
ভুল পথ: পুরো টেবিলের median৭টি জানা দাম
সাজানো: 17000 34000 35000
        72000
        125000 240000 245000
median = মাঝের মান = 72000
চা পাতা পেত ৭২০ টাকা,
হেডফোন পেত ৭২০ টাকা
split: পণ্য ধরে দলফাঁকা বাদ দিয়ে
চা পাতা : 35000, 34000
চাল ৫kg : 72000
হেডফোন : 125000, 240000, 245000
মগ      : 17000
প্রতিটি দলের medianmean-এর সঙ্গে তুলনা
চা পাতা : (34000+35000)/2 = 34500
চাল ৫kg : 72000
হেডফোন : মাঝের মান 240000
          mean হতো 203333.33
মগ      : 17000
transform + fillnaশুধু ফাঁকা ঘর বদলায়
order  product   price
1001   চা পাতা    35000
1005   চা পাতা    34500
1009   চা পাতা    34000
1003   চাল ৫kg    72000
1007   চাল ৫kg    72000
1002   হেডফোন    125000
1004   হেডফোন    240000
1008   হেডফোন    240000
1011   হেডফোন    245000
1010   মগ         17000
  1. শুরুর টেবিল। Duplicate, জেলা-ফাঁকা আর অসম্ভব পরিমাণের সারি বাদ দেওয়ার পর ১০টি অর্ডার বাকি। দাম পয়সায়; তিনটি ঘর ফাঁকা: ১০০৫ (চা পাতা), ১০০৩ (চাল), ১০০৮ (হেডফোন)।
  2. ভুল পথ আগে দেখি। সাতটি জানা দামের median ৭২০০০ পয়সা, মানে ৭২০ টাকা। এটি সব ফাঁকায় বসালে ৩৪০–৩৫০ টাকার চা পাতা হতো ৭২০ টাকা, আর ২৪০০ টাকার হেডফোন হতো ৭২০ টাকা — দুটোই অর্থহীন।
  3. পণ্য ধরে ভাগ। একই পণ্যের দামগুলো একসঙ্গে রাখা হলো, ফাঁকা ঘর বাদ দিয়ে। মগের কোনো ফাঁকা নেই, তবু তার দলও তৈরি হয়।
  4. প্রতিটি দলের median. দুটি মান হলে median তাদের গড়: চা পাতা (৩৪০০০ + ৩৫০০০) ÷ ২ = ৩৪৫০০. হেডফোনের তিনটি মানের মাঝেরটি ২৪০০০০. সেখানে mean হতো ২০৩৩৩৩.৩৩ — একটি সস্তা মডেলের (১২৫০ টাকা) টানে নিচে নামা।
  5. ফিরিয়ে বসানো। transform("median") প্রতিটি সারির পাশে তার দলের median রাখে; fillna শুধু তিনটি ফাঁকা ঘরে তা বসায়, বাকি সাতটি মান অক্ষত। এই সংখ্যাগুলোই অংশ ৭-এর pipeline-এর আসল আউটপুটে আসবে।

Forward fill: সময়ের ধারায় (time series) স্বাভাবিক নিয়ম হলো “শেষ জানা মানটিই এখনো চলছে”। গুদামের মজুদ রোজ গোনা না হলে আগের দিনের সংখ্যাই সবচেয়ে ভালো অনুমান — উপরের run-এ ffill() ৫০ দিয়ে পরের দুটি ফাঁকা ভরেছে। অনেক দিনের পুরোনো সংখ্যা টানা বিপজ্জনক, তাই limit=1 দিলে শুধু একটি ফাঁকা ভরে। ffill অর্থবহ শুধু সময় অনুযায়ী সাজানো ডেটায়; এলোমেলো ক্রমের টেবিলে এটি পাশের অপরিচিত সারির মান টেনে আনে। Time series-এর বিস্তারিত পর্ব ২৬-এ।

কেন এটি modelling সিদ্ধান্ত — MCAR, MAR আর MNAR

মান কেন হারাল, তার ওপর নির্ভর করে কোন পথ নিরাপদ। পরিসংখ্যানবিদ Donald Rubin ১৯৭৬ সালের একটি গবেষণাপত্রে যে শ্রেণিবিভাগ দেন, সেটি আজও প্রচলিত (সংজ্ঞাগুলো নিয়ে বিশেষজ্ঞদের মধ্যে খুঁটিনাটি তর্ক আছে, তাই এখানে সরল রূপ)। MCAR (Missing Completely At Random): হারানোটা কোনো কিছুর সঙ্গে সম্পর্কহীন — যেমন সার্ভারের একটি এলোমেলো ত্রুটিতে কিছু দাম মুছে গেছে; তখন সারি বাদ দিলে ডেটা কমে, কিন্তু সাধারণত বাঁকে না। MAR (Missing At Random): হারানোটা দেখা যায় এমন অন্য তথ্যের ওপর নির্ভর করে — যেমন ফেসবুক থেকে হাতে তোলা অর্ডারে দাম বেশি হারায়; তখন উৎস বা পণ্য ধরে দলভিত্তিক পূরণ যুক্তিসংগত। MNAR (Missing Not At Random): হারানোটা নির্ভর করে হারানো মানটির নিজের ওপর — যেমন অসন্তুষ্ট গ্রাহক রেটিং দেন না, বা ব্যর্থ ডেলিভারিতে ডেলিভারির দিন লেখাই হয় না; তখন বাদ দেওয়া বা পূরণ দুটোই ফলকে এক দিকে টেনে নিতে পারে, আর ডেটা দেখে একে প্রমাণ করাও প্রায়ই সম্ভব নয়। বাস্তবে কোনটি ঘটছে তা প্রায়ই নিশ্চিত জানা যায় না; তাই ব্যবসার মানুষের কাছে জিজ্ঞেস করা, আর “মান ছিল না” তথ্যটিকে আলাদা একটি কলামে রেখে দেওয়া (যেমন price_was_missing) একটি ভালো অভ্যাস।

Model শেখানোর ডেটায় পূরণের সংখ্যা (median, mean) হিসাব করুন শুধু training অংশ থেকে, তারপর একই সংখ্যা test অংশে বসান। পুরো ডেটার median নিয়ে পূরণ করলে test-এর তথ্য training-এ ঢুকে যায় — একে বলে data leakage. এর পূর্ণ আলোচনা পর্ব ৫৬ আর পর্ব ৬৪-এ।

৪ · Duplicate — হুবহু এক সারি আর একই order_id

একই অর্ডার দুবার থাকলে বিক্রির হিসাব ফুলে যায়, আর model একই উদাহরণ দুবার দেখে তাকে বেশি গুরুত্ব দেয়। Duplicate সাধারণত আসে ওয়েবসাইটে “অর্ডার দিন” বোতাম দুবার চাপা, একই ফাইল দুবার জোড়া, বা দুই উৎসে একই অর্ডার থাকা থেকে।

pandas-এর duplicated() প্রতিটি সারির জন্য বলে “এটি কি আগের কোনো সারির পুনরাবৃত্তি?” দুটি parameter একে নিয়ন্ত্রণ করে। subset: কোন কলামগুলো মিললে duplicate ধরা হবে (না দিলে সব কলাম)। keep: পুনরাবৃত্তির মধ্যে কোনটিকে “আসল” ধরে ছেড়ে দেওয়া হবে — "first" (ডিফল্ট), "last", অথবা False, অর্থাৎ কাউকেই না, সব কপি চিহ্নিত। drop_duplicates একই parameter নিয়ে চিহ্নিত সারিগুলো বাদ দেয়। বাজারঘরের খাতার চারটি সন্দেহজনক সারিতে দেখুন।

চারটি সন্দেহজনক সারিindex · order · district · payment
 3  1004  চট্টগ্রাম    COD
 5  1006  Chittagong   COD
 6  1006  Chittagong   COD
12  1004  চট্টগ্রাম␠   bKash
␠ = শেষে একটি ফাঁকা জায়গা
duplicated()সব কলাম, keep="first"
 3  1004  ...           False
 5  1006  ...           False
 6  1006  ...           True
12  1004  ...           False
মোট ১টি
duplicated(subset="order_id")keep="first"
 3  1004   False  (প্রথম 1004)
 5  1006   False  (প্রথম 1006)
 6  1006   True
12  1004   True
মোট ২টি
subset="order_id", keep="last"শেষটি আসল
 3  1004   True
 5  1006   True
 6  1006   False  (শেষ 1006)
12  1004   False  (শেষ 1004)
subset="order_id", keep=Falseসব কপি দেখা
 3  1004   True
 5  1006   True
 6  1006   True
12  1004   True
df[mask] দিয়ে চারটিই পাশাপাশি দেখুন
সিদ্ধান্তবাদ দেওয়ার আগে কারণ
1006: সব কলাম এক
      → নিশ্চিন্তে একটি বাদ
1004: payment আলাদা
      COD বনাম bKash
      → কোনটি সত্য? উৎস দেখা,
        বা নিয়ম লিখে রাখা
  1. চারটি সারি। Index ৩ আর ১২-তে একই অর্ডার ১০০৪, কিন্তু ১২-এর জেলার শেষে একটি ফাঁকা আর payment “bKash”. Index ৫ আর ৬-এ অর্ডার ১০০৬, সব ঘর হুবহু এক।
  2. সব কলাম মিলিয়ে। duplicated() শুধু index ৬ ধরেছে। Index ১২ ধরা পড়েনি, কারণ দুটি ঘর আলাদা — জেলার ফাঁকা জায়গা আর payment. এ কারণেই profiling-এ “exact duplicate rows: 1”.
  3. শুধু order_id মিলিয়ে। subset="order_id" দিলে index ৬ আর ১২ দুটোই True — প্রতিটি order_id-এর প্রথম দেখা সারি ছাড় পায়। Profiling-এর “repeated order_id: 2” এখান থেকেই।
  4. keep="last". এবার শেষ কপিকে আসল ধরা হলো, তাই আগের দুটি (৩ আর ৫) চিহ্নিত। কোনটি রাখবেন, তা নির্ভর করে কোন কপি বেশি বিশ্বাসযোগ্য — যেমন পরে আসা সংশোধিত রেকর্ড হলে শেষটি।
  5. keep=False. চারটিই True: প্রতিটি পুনরাবৃত্ত order_id-এর সব কপি। বাদ দেওয়ার জন্য নয়, দেখার জন্য সবচেয়ে কাজের — df[df.duplicated("order_id", keep=False)] সব কপি পাশাপাশি দেখায়।
  6. সিদ্ধান্ত। ১০০৬-এর দুই কপি হুবহু এক, তাই একটি বাদ দেওয়া নিরাপদ। ১০০৪-এর কপি দুটি payment-এ আলাদা — এটি বিরোধ, শুধু “duplicate” নয়। এখানে নিয়ম লিখে রাখতে হয় (যেমন “প্রথমটি রাখি, কারণ ওয়েবসাইটের রেকর্ড আগে আসে”) অথবা উৎসের সঙ্গে মিলিয়ে দেখতে হয়।

একটি ক্রমের প্রশ্নও আছে। জেলার নামের ফাঁকা জায়গা আর বানান আগে ঠিক করলে কিছু “প্রায়-duplicate” হুবহু duplicate হয়ে যায়, তাই সাধারণত লেখা পরিষ্কার আগে, duplicate খোঁজা পরে. আর মানুষের নামে বা ঠিকানায় duplicate খোঁজা (একই গ্রাহক দুই বানানে) একটি আলাদা, কঠিন সমস্যা — একে বলে record linkage বা entity resolution; এর ইতিহাস ভাবনার প্রশ্নে।

৫ · Type ঠিক করা — দাম পয়সায়, ফোন লেখায়, তারিখ তারিখে

লেখা হিসেবে পড়া দাম দিয়ে যোগ করা যায় না, আর লেখা হিসেবে রাখা তারিখ দিয়ে “মার্চের অর্ডার” বাছা যায় না। তাই পরিষ্কারের বড় অংশ হলো প্রতিটি কলামকে তার সঠিক type-এ আনা। pandas-এ তিনটি প্রধান হাতিয়ার:

হাতিয়ারকাজভুল মান পেলে
astype("int64")নির্দিষ্ট dtype-এ রূপান্তরError দিয়ে থামে
pd.to_numeric(s, errors="coerce")লেখা থেকে সংখ্যাNaN বানায়
pd.to_datetime(s, format=..., errors="coerce")লেখা থেকে তারিখNaT বানায়

errors="coerce" খুব সুবিধার, আবার খুব বিপজ্জনক। সুবিধা: একটি খারাপ মানের জন্য পুরো কাজ থামে না। বিপদ: খারাপ মান নীরবে NaN হয়ে যায়, আর কেউ টের পায় না — একটি নীরব bug. তাই নিয়ম: coerce-এর আগে কয়টি ফাঁকা ছিল আর পরে কয়টি, গুনে দেখুন। বাড়তি প্রতিটি NaN একটি লেখা, যা সংখ্যা হতে পারেনি — সেগুলো চোখে দেখা দরকার। এই ধরনের রূপান্তরকে বলে type coercion.

দামের জন্য আরেকটি সিদ্ধান্ত: টাকা নয়, পূর্ণসংখ্যার পয়সা. ১২৫০.৫০ টাকার মতো দশমিক float-এ নিখুঁত থাকে না (float-এর ফাঁদ দেখেছেন পর্ব ০৯-এ), কিন্তু ১২৫০৫০ পয়সা নিখুঁত। আর ফাঁকা ঘর থাকলে সাধারণ int64 NaN রাখতে পারে না, তাই nullable integer dtype "Int64" (বড় হাতের I) ব্যবহার হয়, যেখানে ফাঁকা দেখায় <NA>. প্রথমে একটি সারি ধরে পুরো কাজটি দেখুন।

কাঁচা সারিসব লেখা (str)
1002,05/03/2026,Dhaka,
হেডফোন,1,"৳1,250",COD,
০১৮০০০০০০০২
অর্ডার ১০০২-এর ঘরগুলো
date
district
price
phone
  1. কাঁচা অবস্থা। CSV থেকে পড়া চারটি ঘরই লেখা। চোখে আমরা তারিখ, জেলা, দাম, ফোন দেখি; pandas দেখে শুধু চারটি string.
  2. তারিখ। ISO format %Y-%m-%d-এ “05/03/2026” মেলে না, তাই প্রথম চেষ্টায় NaT. দ্বিতীয় চেষ্টা %d/%m/%Y (দিন আগে, বাংলাদেশের রীতি) — ফল ৫ মার্চ ২০২৬, এখন আসল datetime64.
  3. জেলা। strip (দুই পাশের ফাঁকা সরানো), lower (ছোট হাতের অক্ষর), তারপর dictionary দিয়ে “dhaka” → “ঢাকা”. এটি অংশ ৬-এর বিষয়।
  4. দাম। “৳” আর কমা সরিয়ে “1250”, তারপর to_numeric দিয়ে 1250.0 টাকা, ×১০০ করে round, শেষে Int64: ১২৫০০০ পয়সা।
  5. ফোন। বাংলা অঙ্ক ০–৯ ইংরেজি 0–9-এ বদলানো (str.translate), তারপর নিয়ম যাচাই: ০১ দিয়ে শুরু, মোট ১১ অঙ্ক — মিলেছে। ফোন সংখ্যা নয়, লেখা; তাই এটি str-ই থাকে।

এবার পুরো টেবিলে একই কাজ। মিশ্র তারিখের জন্য কৌশলটি লক্ষ করুন: একবারে একটি স্পষ্ট format, আর যা মেলেনি শুধু সেগুলো পরের format দিয়ে fillna.

import pandas as pd
df = pd.read_csv("orders.csv", dtype={"phone": str}, na_values=["-"])

# price text -> integer paisa
p = (df["price"].str.replace("৳", "", regex=False)
                .str.replace(",", "", regex=False)
                .str.strip())
taka = pd.to_numeric(p, errors="coerce")
print("text that was not a number:", (p.notna() & taka.isna()).sum())
df["price_paisa"] = (taka * 100).round().astype("Int64")
print(df[["price", "price_paisa"]].head(5).to_string())

# Bangla digits -> ASCII digits
bn2en = str.maketrans("০১২৩৪৫৬৭৮৯", "0123456789")
df["phone"] = df["phone"].str.translate(bn2en)
print("\nphones:", df.loc[[1, 4], "phone"].tolist())
bad = ~df["phone"].str.fullmatch(r"01\d{9}")
print("bad phone:", df.loc[bad, ["order_id", "phone"]].values.tolist())

# mixed date formats: one explicit format at a time
d = pd.to_datetime(df["date"], format="%Y-%m-%d", errors="coerce")
for fmt in ["%d/%m/%Y", "%d %b %Y"]:
    d = d.fillna(pd.to_datetime(df["date"], format=fmt, errors="coerce"))
df["date"] = d
print("\ndate dtype:", df["date"].dtype)
print("unparsed:", df.loc[df["date"].isna(), "order_id"].tolist())
print(df["date"].dt.strftime("%d %b").head(5).tolist())

আসল আউটপুট (পাতার Pyodide-এ):

text that was not a number: 0
    price  price_paisa
0    ৳350        35000
1  ৳1,250       125000
2     NaN         <NA>
3   2,400       240000
4     NaN         <NA>

phones: ['01800000002', '01600000005']
bad phone: [[1011, '0170000001']]

date dtype: datetime64[us]
unparsed: [1011]
['05 Mar', '05 Mar', '06 Mar', '06 Mar', '07 Mar']

প্রথম লাইনটি coerce-এর পাহারা: যেসব লেখা ফাঁকা ছিল না অথচ সংখ্যা হতে পারেনি, তাদের সংখ্যা ০ — অর্থাৎ কোনো দাম নীরবে হারায়নি; NaN শুধু সেই তিনটি, যেগুলো আগেই ফাঁকা ছিল। “৳1,250” হয়েছে ১২৫০০০ পয়সা, আর কমা-ওয়ালা কিন্তু “৳”-ছাড়া “2,400”-ও ঠিকঠাক ২৪০০০০. ফাঁকা দাম দেখাচ্ছে <NA>, কারণ কলামটি Int64.

বাংলা অঙ্কের দুটি ফোন এখন ইংরেজি অঙ্কে। Regular expression 01\d{9} (০১-এর পরে ঠিক ৯টি অঙ্ক) যাচাইয়ে একটিমাত্র নম্বর ধরা পড়েছে: ১০১১-এর ১০ অঙ্কের নম্বর। একে আমরা “ঠিক” করতে পারি না — কোন অঙ্কটি হারিয়েছে জানি না — তাই চিহ্নিত করে রাখাই সৎ কাজ। তারিখের কলাম এখন datetime64; তিন রকম লেখাই এক রূপে এসেছে, আর শুধু ১০১১-এর “2026-13-09” কোনো format-এ মেলেনি — ১৩ নম্বর মাস হয় না।

কেন একবারে একটি format? pandas-এ format="mixed" নামে একটি বিকল্প আছে, যা প্রতিটি মান আলাদাভাবে অনুমান করে। দেখতে সুবিধাজনক, কিন্তু সত্যিকারের একটি run-এ (নিজের ল্যাপটপে, pandas 3.1.1, python-dateutil 2.9.0) ফলটি দেখুন:

import pandas as pd

s = pd.Series(["1", "2", "x"])
try:
    s.astype("int64")
except ValueError as e:
    print("astype ValueError:", e)
print("to_numeric coerce:", pd.to_numeric(s, errors="coerce").tolist())
print("Int64 with a gap :", pd.Series([3, None]).astype("Int64").tolist())

print("python int('১২৩') :", int("১২৩"))
print("pandas to_numeric:", pd.to_numeric(pd.Series(["১২৩"]), errors="coerce").tolist())

d = pd.Series(["2026-03-05", "05/03/2026", "7 Mar 2026"])
print(pd.to_datetime(d, format="mixed", dayfirst=True).dt.date.tolist())
print(pd.to_datetime(d.iloc[[1]]).dt.date.tolist())
~/bazarghar-ai$ python types_demo.py
astype ValueError: invalid literal for int() with base 10: 'x'
to_numeric coerce: [1.0, 2.0, nan]
Int64 with a gap : [3, <NA>]
python int('১২৩') : 123
pandas to_numeric: [nan]
[datetime.date(2026, 5, 3), datetime.date(2026, 3, 5), datetime.date(2026, 3, 7)]
[datetime.date(2026, 5, 3)]

চারটি শিক্ষা এক run-এ। এক, astype কড়া: একটি “x” পেলেই থামে — ভুল লুকোয় না। দুই, Python-এর int("১২৩") বাংলা অঙ্ক বোঝে (Unicode-এ এগুলো “decimal digit”), কিন্তু pandas-এর to_numeric বোঝে না, NaN দেয় — তাই আগে translate. তিন, format="mixed", dayfirst=True “2026-03-05”-কে বানিয়েছে ৩ মে! ভেতরের dateutil library dayfirst-কে ISO-র মতো লেখাতেও প্রয়োগ করেছে। চার, format না দিলে “05/03/2026” হয়েছে ৩ মে (মাস আগে ধরে নেওয়া) — বাংলাদেশের খাতায় প্রায় নিশ্চিত ভুল। স্পষ্ট format দিলে এসব অনুমান থাকে না।

৬ · লেখা পরিষ্কার — strip, lower, NFC আর একটি প্রমিত নাম

জেলার কলামে ১২টি বানান, অথচ জেলা ৪টি। groupby দিয়ে জেলাভিত্তিক বিক্রি হিসাব করলে ১২টি দল আসত, আর “ঢাকার বিক্রি” ছড়িয়ে থাকত চারটি সারিতে। লেখার কলাম পরিষ্কারের জন্য pandas-এর .str accessor আছে: প্রতিটি মানে একই লেখার কাজ, loop ছাড়াই। কাজটি কয়েকটি স্তরে করা হয়, সহজ থেকে কঠিন:

  • str.strip() — দুই পাশের ফাঁকা জায়গা সরায়: “dhaka␠” → “dhaka”, “চট্টগ্রাম␠” → “চট্টগ্রাম”.
  • str.lower() — ইংরেজি বড় হাতের অক্ষর ছোট করে: “DHAKA”, “Dhaka” → “dhaka”. বাংলায় বড়-ছোট হাতের অক্ষর নেই, তাই বাংলা লেখা অক্ষত থাকে।
  • str.normalize("NFC") — দেখতে এক কিন্তু byte-এ আলাদা বাংলা অক্ষর এক রূপে আনে। “য়” এক code point-এ (U+09DF) বা দুইটিতে (য + নুক্তা) লেখা যায় — এই ফাঁদ আর NFC-এর পূর্ণ ব্যাখ্যা দেখেছেন পর্ব ১০-এ।
  • str.replace(old, new) — লেখার ভেতরের অংশ বদলায়; regex=True দিলে নকশা ধরে, যেমন r"[৳,\s]" = “৳, কমা বা যেকোনো ফাঁকা”।
  • dictionary দিয়ে mapping — শেষ স্তর: “dhaka”, “chattogram”, “chittagong”-এর মতো আলাদা শব্দ, যাদের কোনো যান্ত্রিক নিয়ম এক করতে পারে না, একটি হাতে লেখা তালিকায় এক প্রমিত নাম-এ আনা।
import unicodedata as ud
import pandas as pd
df = pd.read_csv("orders.csv", dtype={"phone": str})
s = df["district"]
print("before:", s.nunique(), "spellings")

s = s.str.strip().str.lower().str.normalize("NFC")
print("after strip+lower+NFC:", s.nunique())
print(s.value_counts().to_dict())

canon = {"dhaka": "ঢাকা",
         "chattogram": "চট্টগ্রাম", "chittagong": "চট্টগ্রাম",
         "khulna": "খুলনা"}
df["district"] = s.replace(canon)
print("\nafter mapping:", df["district"].nunique())
print(df["district"].value_counts(dropna=False).to_dict())

# replace() keeps unknown values, map() turns them into NaN
test = pd.Series(["dhaka", "dacca"])
print("\nreplace:", test.replace(canon).tolist())
print("map    :", test.map(canon).tolist())

# an allow-list catches spellings nobody mapped yet
allowed = {ud.normalize("NFC", a)
           for a in ["ঢাকা", "চট্টগ্রাম", "খুলনা", "ময়মনসিংহ"]}
odd = df["district"].notna() & ~df["district"].isin(allowed)
print("outside allow-list:", odd.sum(), "| missing:",
      df["district"].isna().sum())

আসল আউটপুট (পাতার Pyodide-এ):

before: 12 spellings
after strip+lower+NFC: 8
{'dhaka': 3, 'চট্টগ্রাম': 2, 'chittagong': 2, 'ময়মনসিংহ': 2, 'ঢাকা': 1, 'chattogram': 1, 'খুলনা': 1, 'khulna': 1}

after mapping: 4
{'চট্টগ্রাম': 5, 'ঢাকা': 4, 'খুলনা': 2, 'ময়মনসিংহ': 2, nan: 1}

replace: ['ঢাকা', 'dacca']
map    : ['ঢাকা', nan]
outside allow-list: 0 | missing: 1

১২ থেকে ৮: strip সরিয়েছে দুটি শেষের ফাঁকা, lower এক করেছে Dhaka/dhaka/DHAKA, আর NFC দুই রূপের “ময়মনসিংহ” মিলিয়ে একটি দল বানিয়েছে (২টি অর্ডার)। বাকি ৮-এর মধ্যে এখনো একই জেলা দুই-তিন নামে: “chittagong” (পুরোনো ইংরেজি বানান), “chattogram” (২০১৮ সালে সরকারিভাবে গৃহীত বানান) আর “চট্টগ্রাম”. এগুলো কোনো যান্ত্রিক নিয়মে এক হয় না, তাই dictionary. Mapping-এর পরে ৪টি জেলা, আর ফাঁকা জেলাটি (nan: 1) আলাদা দেখা যাচ্ছে — একে চুপচাপ “ঢাকা” ধরে নেওয়া হয়নি।

replace আর map-এর পার্থক্যটি জরুরি। replace dictionary-তে না থাকা মান (“dacca”) যেমন ছিল তেমন রাখে; map সেটিকে NaN বানায়। কোনটি ভালো? map কড়া — নতুন অচেনা বানান এলে NaN হয়ে ধরা পড়ে — কিন্তু তখন dictionary-তে বাংলা নামগুলোও নিজেদের দিকে map করতে হয়। replace নরম, তাই তার সঙ্গে একটি allow-list (অনুমোদিত নামের তালিকা) দিয়ে যাচাই দরকার, শেষ লাইনের মতো। খেয়াল করুন, allow-list-এর নামগুলোকেও NFC করা হয়েছে; না করলে কোডে টাইপ করা “ময়মনসিংহ” আর ডেটার NFC রূপ না-ও মিলতে পারত।

Normalization বানান ঠিক করে না: “ময়মনসিং” আর “ময়মনসিংহ” আলাদাই থাকবে — সেটি mapping-এর কাজ। বাংলা লেখার আরও কিছু ফাঁদ (অদৃশ্য যোজক চিহ্ন, খণ্ড-ত) আছে ভাবনার প্রশ্ন ৫-এ, আর বাংলা লেখার ডেটার বড় আলোচনা পর্ব ৬৭-এ।

৭ · নিয়ম যাচাই আর cleaning log — একটি পূর্ণ pipeline

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

এক, নিয়ম লিখে যাচাই। কিছু মান “অসম্ভব”: পরিমাণ শূন্য বা ঋণাত্মক হতে পারে না, দাম শূন্য হতে পারে না, একটি order_id একবারই থাকবে। এই নিয়মগুলো কোডে assertion হিসেবে লিখলে, ভবিষ্যতের কোনো ফাইলে নিয়ম ভাঙলে pipeline চুপচাপ ভুল ফল না দিয়ে থেমে যায়। Assertion-এর মূল ধারণা আর সীমা (যেমন python -O দিয়ে চালালে assert বন্ধ হয়ে যায়) দেখেছেন পর্ব ১১-এ; বড় প্রকল্পে এর জন্য আলাদা data validation library-ও ব্যবহার হয়। মনে রাখুন, অসম্ভব মান পেলে কী করবেন সেটি আলাদা সিদ্ধান্ত: qty = −২ কি ফেরত দেওয়া পণ্য (return), নাকি টাইপের ভুল? এখানে আমরা সারিটি বাদ দিচ্ছি, কিন্তু সেটি log-এ লেখা থাকবে।

দুই, cleaning log. প্রতিটি ধাপে কয়টি সারি বাদ গেল বা কয়টি মান বদলাল, লিখে রাখা। চায়ের দোকানের হিসাব খাতার মতো: দিন শেষে শুধু “হাতে ৮০০ টাকা” নয়, কোথা থেকে কত এল আর গেল। Cleaning log থাকলে পরের সপ্তাহে হঠাৎ “৩০০টি সারি বাদ” দেখলে বোঝা যায়, উৎসে কিছু বদলেছে।

import pandas as pd
CANON = {"dhaka": "ঢাকা", "chattogram": "চট্টগ্রাম",
         "chittagong": "চট্টগ্রাম", "khulna": "খুলনা"}
log = []

def drop(df, bad, step):
    log.append((step, int(bad.sum()), int((~bad).sum())))
    return df[~bad]
def fix(df, col, new, step):
    same = df[col].eq(new).fillna(False) | (df[col].isna() & new.isna())
    log.append((step, int((~same).sum()), len(df)))
    return df.assign(**{col: new})

def clean(path):
    df = pd.read_csv(path, dtype={"phone": str}, na_values=["-"])
    log.append(("read raw file", 0, len(df)))
    d = df["district"].str.strip().str.lower().str.normalize("NFC")
    df = fix(df, "district", d.replace(CANON), "district spelling")
    df = drop(df, df.duplicated(), "exact duplicates")
    df = drop(df, df.duplicated(subset="order_id"), "repeated order_id")
    df = drop(df, df["district"].isna(), "no district")
    df = drop(df, df["qty"] <= 0, "qty <= 0")
    taka = pd.to_numeric(df["price"].str.replace(r"[৳,\s]", "", regex=True),
                         errors="coerce")
    df = df.assign(price=(taka * 100).round().astype("Int64"))
    med = df.groupby("product")["price"].transform("median").round()
    df = fix(df, "price", df["price"].fillna(med.astype("Int64")), "fill price")
    assert df["order_id"].is_unique, "order_id repeats"
    assert (df["qty"] > 0).all() and df["price"].gt(0).all(), "bad qty/price"
    return df
out = clean("orders.csv")
for step, n, left in log:
    print(f"{step:18}{n:>3} changed {left:>3} rows left")
print(out.loc[out["order_id"].isin([1003, 1005, 1008]),
              ["order_id", "product", "price"]].to_string(index=False))

আসল আউটপুট (পাতার Pyodide-এ):

read raw file       0 changed  14 rows left
district spelling   9 changed  14 rows left
exact duplicates    1 changed  13 rows left
repeated order_id   1 changed  12 rows left
no district         1 changed  11 rows left
qty <= 0            1 changed  10 rows left
fill price          3 changed  10 rows left
 order_id product  price
     1003 চাল ৫kg  72000
     1005 চা পাতা  34500
     1008  হেডফোন 240000

Log-টি একটি গল্প বলে। ১৪টি সারি দিয়ে শুরু। জেলার বানান ঠিক করতে ৯টি মান বদলেছে (সারি কমেনি)। তারপর হুবহু duplicate ১টি (১০০৬), একই order_id ১টি (bKash-ওয়ালা ১০০৪), জেলা-ফাঁকা ১টি (১০১২), আর অসম্ভব পরিমাণ ১টি (১০০৬-এর বাকি কপি, qty = −২)। শেষে ৩টি দাম পণ্যভিত্তিক median দিয়ে পূরণ — ১০০৩-এ ৭২০০০, ১০০৫-এ ৩৪৫০০, ১০০৮-এ ২৪০০০০, ঠিক উপরের stepper-এর মতো। দুটি assert পাস করেছে বলেই ফল ছাপা হয়েছে।

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

বাজারঘরের cleaning pipeline — প্রতিটি ধাপের হিসাব

Raw file is only read; every step is logged; the result goes to a new file

Cleaning pipeline with row counts data/raw/orders.csv 14 rows · read only, never written district spelling strip · lower · NFC · map: 9 values exact duplicates -1 row → 13 left repeated order_id -1 row → 12 left no district -1 row → 11 left qty <= 0 -1 row → 10 left fill price product median: 3 values assert rules unique id · qty > 0 · price > 0 data/processed/ 10 rows + cleaning log blue = values changed · red = rows dropped
ছবি ২৩.১ — উপরের pipeline-এর আসল log, ছবিতে। নীল ধাপে মান বদলায় কিন্তু সারি কমে না; লাল ধাপে সারি বাদ যায়; বেগুনি ধাপ শুধু যাচাই করে। Raw ফাইলে কখনো লেখা হয় না।

৮ · Raw ফাইল অক্ষত রাখা আর পরিষ্কারের ক্রম

পরিষ্কারের সবচেয়ে বড় নিয়মটি কোনো pandas function নয়: মূল ফাইলটি কখনো বদলাবেন না। Raw data আদালতের প্রমাণের মতো — পর্ব ১২-এ দেখেছেন, প্রকল্পে data/raw শুধু পড়া হয়, আর পরিষ্কারের script নতুন ফাইল লেখে data/processed-এ। কারণগুলো সরল: আজকের পরিষ্কারের নিয়মে ভুল থাকলে কাল raw থেকে আবার চালানো যায়; কেউ জিজ্ঞেস করলে মূল মান দেখানো যায়; আর একই script দুবার চালালে একই ফল আসে (idempotent). Excel-এ খুলে হাতে একটি দাম “ঠিক” করে সেভ করলে এর তিনটিই হারায়।

~/bazarghar-ai/
├── data/raw/orders_2026-03-w1.csv        ← শুধু পড়া
├── data/processed/orders_clean.parquet   ← script লেখে
├── data/processed/cleaning_log.csv       ← প্রতিটি ধাপের হিসাব
└── src/clean.py                          ← সব নিয়ম এখানে

pandas 3-এ Copy-on-Write একমাত্র আচরণ: একটি DataFrame থেকে বানানো নতুন DataFrame বদলালে মূলটি বদলায় না, আর chained assignment (df[mask]["price"] = 0) কখনো কাজ করে না — এক ধাপে df.loc[mask, "price"] = ... লিখতে হয়। এতে মেমরির DataFrame রক্ষা পায়, কিন্তু ডিস্কের raw ফাইল রক্ষা আপনার কাজ: to_csv বা to_parquet-এ কখনো raw ফাইলের path দেবেন না।

কোন ক্রমে পরিষ্কার করবেন

কোনো একক “সঠিক” ক্রম নেই, তবে একটি সাধারণ, যুক্তিসংগত ক্রম আছে, আর প্রতিটি ধাপের কারণ আছে:

  1. পড়ার সময়েই na_values আর dtype (ফোন আর ID লেখা হিসেবে) — যা পড়ার মুহূর্তে হারায় (যেমন শুরুর শূন্য), পরে ফেরানো যায় না।
  2. Profile — কিছু বদলানোর আগে মাপ নেওয়া, পরে তুলনার জন্য।
  3. লেখা আর type ঠিক করা — strip, NFC, mapping, সংখ্যা, তারিখ; এতে কিছু লুকোনো duplicate দৃশ্যমান হয়।
  4. Duplicate আর অসম্ভব মান — ভুল সারি বাদ, যাতে পরের হিসাব (median) দূষিত না হয়।
  5. Missing value পূরণ — পরিষ্কার সারি থেকে হিসাব করা মান দিয়ে, আর model-এর কাজে শুধু training অংশ থেকে।
  6. Assertion আর লেখা — নিয়ম যাচাই, তারপর নতুন ফাইল আর log.

আরও গভীরে: Missing data-র আধুনিক তত্ত্বের ভিত্তি Rubin-এর ১৯৭৬ সালের Biometrika গবেষণাপত্র “Inference and Missing Data”, আর পরে Little ও Rubin-এর বই Statistical Analysis with Missing Data; একটি মানের বদলে কয়েকটি সম্ভাব্য মান বসিয়ে অনিশ্চয়তা ধরে রাখার পদ্ধতি multiple imputation এই ধারা থেকেই এসেছে। ডেটা পরিষ্কারের একটি প্রভাবশালী আধুনিক লেখা Hadley Wickham-এর “Tidy Data” (২০১৪, Journal of Statistical Software), যা “প্রতিটি কলাম একটি variable, প্রতিটি সারি একটি পর্যবেক্ষণ” নীতি জনপ্রিয় করে। আর ডেটার নিয়ম কোডে বেঁধে রাখার জন্য pandera বা Great Expectations-এর মতো library আছে, যা assertion-কে schema হিসেবে লেখে।

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

  • dropna(thresh=3) কী করে? — কমপক্ষে ৩টি ঘর ভরা আছে এমন সারি রাখে, বাকিগুলো বাদ দেয়।
  • duplicated(keep=False) কেন কাজের? — পুনরাবৃত্ত প্রতিটি কপি চিহ্নিত করে, তাই বাদ দেওয়ার আগে সব কপি পাশাপাশি দেখা যায়।
  • Missing দাম mean দিয়ে না median দিয়ে? — দাম সাধারণত বাঁকা আর outlier-যুক্ত, তাই দলভিত্তিক median বেশি স্থির; তবে MNAR হলে দুটোই পক্ষপাত আনতে পারে।
  • errors="coerce"-এর ঝুঁকি? — খারাপ মান নীরবে NaN হয়; আগে-পরে NaN গুনে দেখতে হয়।
  • MCAR, MAR, MNAR-এর পার্থক্য এক লাইনে? — হারানো এলোমেলো; দেখা অন্য তথ্যের ওপর নির্ভরশীল; হারানো মানের নিজের ওপর নির্ভরশীল।

৯ · AI-এর যুগে — AI সহকারীর লেখা পরিষ্কারের কোড যাচাই

“এই CSV পরিষ্কার করে দাও” — AI সহকারীর কাছে খুব সাধারণ অনুরোধ, আর ফলটি প্রায়ই চলে, পরিপাটি দেখায়। বিপদ সেখানেই: পরিষ্কারের ভুল error দেয় না, শুধু সংখ্যা বদলে দেয়। AI সহকারী প্রায়ই যে ভুলগুলো করে:

AI সহকারীর ভুলকীভাবে ধরবেন
কোনো প্রশ্ন ছাড়াই df.dropna() — অর্ধেক ডেটা নীরবে উধাওপ্রতিটি ধাপের আগে-পরে len(df), cleaning log
fillna(0) বা পুরো টেবিলের mean দিয়ে দাম পূরণপূরণ করা মানগুলো পণ্য ধরে চোখে দেখা; MNAR-এর সম্ভাবনা ভাবা
পুরো ডেটার median দিয়ে পূরণ, তারপর train/test ভাগ — leakageআগে ভাগ, পরে শুধু training থেকে হিসাব
পুরোনো বা ভুল API: fillna(method="ffill") (এখন ffill()), inplace=True দিয়ে chained বদল, অস্তিত্বহীন parameterনিজের pandas version-এর docs দেখা; চালিয়ে warning পড়া
errors="coerce" বা format="mixed" — খারাপ মান আর উল্টো তারিখ লুকোনোcoerce-এর আগে-পরে NaN গোনা; কয়েকটি তারিখ হাতে মেলানো
বাংলা লেখায় শুধু lower(), NFC আর বাংলা অঙ্ক ভুলে যাওয়াnunique() আগে-পরে; বাংলা অঙ্কের ফোন দিয়ে test
Raw ফাইলের ওপরেই to_csv — মূল ডেটা হারানোলেখার path দেখা; raw ফোল্ডার শুধু-পড়া রাখা

AI সহকারীকে ডেটার কাঠামো আর সমস্যার ধরন বলুন, আসল গ্রাহকের ডেটা নয়। নিচের prompt-এ শুধু কলামের নাম আর বানানো উদাহরণ:

আমি pandas 3.x দিয়ে একটি অর্ডারের CSV পরিষ্কার করব। কলাম: order_id, date, district, product, qty, price, payment, phone. সমস্যা: price-এ "৳1,250" আর "-" লেখা; তারিখ তিন রকম (2026-03-05, 05/03/2026 দিন আগে, 7 Mar 2026); জেলা বাংলা-ইংরেজি মিশিয়ে, শেষে ফাঁকা; ফোনে বাংলা অঙ্ক; কিছু order_id দুবার। ১. প্রতিটি ধাপ আলাদা function-এ লেখো, আর প্রতিটি ধাপে কয়টি সারি বা মান বদলাল তার log রাখো। ২. dropna() বা fillna(0) অন্ধভাবে ব্যবহার করবে না; প্রতিটি পূরণ বা বাদ দেওয়ার কারণ মন্তব্যে লেখো। ৩. তারিখে format="mixed" নয়, প্রতিটি format স্পষ্ট করে দাও; errors="coerce" দিলে আগে-পরে NaN গোনো। ৪. শেষে assert দিয়ে নিয়ম যাচাই করো, আর raw ফাইলে কখনো লিখবে না। ৫. শুধু বর্তমান pandas-এর API ব্যবহার করো; কোনো parameter নিয়ে নিশ্চিত না হলে "নিশ্চিত নই" লেখো।

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

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

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

প্র ০১Missing value কেন হারাল, সেটি জানা এত জরুরি কেন? Rubin-এর শ্রেণিবিভাগ বাস্তবে কী কাজে লাগে?

দীর্ঘদিন পরিসংখ্যানের চর্চায় ফাঁকা মানকে মূলত একটি বিরক্তিকর ব্যবহারিক সমস্যা ভাবা হতো: ফাঁকা সারি বাদ দেওয়া বা গড় বসানো, তারপর বিশ্লেষণ। ১৯৭৬ সালে Donald Rubin Biometrika-তে “Inference and Missing Data” নামে একটি গবেষণাপত্রে প্রশ্নটিকে অন্যভাবে দাঁড় করান: মান হারানোর প্রক্রিয়াটিকেও ডেটার অংশ হিসেবে ভাবতে হবে। তাঁর কাজ থেকে আসা তিনটি নাম — MCAR, MAR, MNAR — আজও পাঠ্যবই আর গবেষণায় প্রচলিত, যদিও সংজ্ঞার সূক্ষ্ম খুঁটিনাটি নিয়ে পরিসংখ্যানবিদদের মধ্যে পরেও আলোচনা চলেছে।

ব্যবহারিক অর্থটি সহজ উদাহরণে বোঝা যায়। ধরুন বাজারঘর জানতে চায় গড় ডেলিভারি কত দিনে হয়। যদি সার্ভারের এলোমেলো ত্রুটিতে কিছু সারির ডেলিভারির দিন মুছে যায় (MCAR), বাকি সারির গড় মোটামুটি সৎ উত্তর দেবে — শুধু নিশ্চয়তা একটু কমবে। যদি দূরের জেলার অর্ডারে কুরিয়ার প্রায়ই দিন লেখে না, কিন্তু জেলা কলামটি আমাদের হাতে আছে (MAR), তাহলে জেলাভিত্তিক হিসাব বা জেলা ধরে পূরণ সমস্যা অনেকটা সামলাতে পারে। কিন্তু যদি সবচেয়ে দেরির ডেলিভারিগুলোতেই দিন লেখা হয় না — কারণ সেগুলো ঝামেলার, কেউ রেকর্ড শেষ করে না (MNAR) — তাহলে বাকি সারির গড় পদ্ধতিগতভাবে কম দেখাবে, আর কোনো সাধারণ পূরণ এটি ঠিক করবে না।

কঠিন সত্যটি হলো, শুধু ডেটা দেখে MAR আর MNAR আলাদা করা সাধারণত সম্ভব নয় — কারণ যা হারিয়েছে, সেটিই তো দেখা যায় না। তাই এখানে ডেটার বাইরের জ্ঞান লাগে: কুরিয়ার কোম্পানির সঙ্গে কথা বলা, প্রক্রিয়া বোঝা, কয়েকটি ফাঁকা কেস হাতে খোঁজ নেওয়া। পরিসংখ্যানে একে sensitivity analysis বলা হয়: “ফাঁকা মানগুলো যদি গড়ের চেয়ে ২ দিন বেশি হয়, তাহলে সিদ্ধান্ত কি বদলায়?”

মূল উপলব্ধি: ফাঁকা ঘর শুধু একটি অনুপস্থিত সংখ্যা নয়, একটি ঘটনার চিহ্ন। ঘটনাটি না বুঝে পূরণ করলে আমরা ডেটায় নিজের অনুমান বসাই, আর model সেটিকেই সত্য বলে শেখে।

প্র ০২“অসম্ভব” মান কি সবসময় ভুল? ওজোন স্তরের গর্ত আবিষ্কারের গল্প থেকে কী শেখা যায়?

১৯৮৫ সালে British Antarctic Survey-র তিন বিজ্ঞানী — Joseph Farman, Brian Gardiner আর Jonathan Shanklin — Nature-এ জানান, অ্যান্টার্কটিকার ওপরে বসন্তকালে ওজোন স্তর নাটকীয়ভাবে পাতলা হচ্ছে। তাঁদের তথ্য এসেছিল মাটিতে বসানো যন্ত্র থেকে। প্রশ্ন উঠল, NASA-র Nimbus-7 উপগ্রহের যন্ত্র (TOMS) তো বছরের পর বছর ওজোন মাপছিল — সেটি কেন আগে ধরতে পারেনি?

একটি জনপ্রিয় গল্প বলে, NASA-র software খুব কম মানগুলোকে “অসম্ভব” ধরে নিয়ে বাদ দিয়ে দিত, তাই গর্তটি ডেটা থেকে মুছে যাচ্ছিল। বাস্তব সম্ভবত আরেকটু জটিল: NASA-র বিজ্ঞানীদের পরের বিবরণ অনুযায়ী, প্রক্রিয়াকরণের software নির্দিষ্ট সীমার নিচের মানগুলোকে সন্দেহজনক হিসেবে চিহ্নিত করত, মুছে ফেলত না; আর দলটি সেগুলো নিয়ে খোঁজ নিচ্ছিল, কিন্তু মাটির যন্ত্রের সঙ্গে মেলানোর আগে প্রকাশ করেনি। সূত্রভেদে গল্পের বিবরণ আলাদা, তাই সতর্কভাবেই বলা ভালো। তবু মূল শিক্ষাটি দুই রূপেই একই।

শিক্ষাটি হলো: “অসম্ভব” একটি অনুমান, প্রকৃতির নিয়ম নয়। যে সীমা আমরা লিখি, সেটি আমাদের প্রত্যাশা থেকে আসে, আর সবচেয়ে মূল্যবান আবিষ্কার প্রায়ই সেই প্রত্যাশা ভাঙে। বাজারঘরের qty = −২ হতে পারে টাইপের ভুল, আবার হতে পারে ফেরত দেওয়া পণ্যের রেকর্ড — যা বাদ দিলে ফেরতের হার কখনো মাপা যাবে না। একটি পণ্যের দাম হঠাৎ দশ গুণ হলে সেটি ভুল হতে পারে, আবার হতে পারে নতুন প্রিমিয়াম পণ্য।

তাই পরিণত পথ হলো তিন স্তরে ভাবা। নিশ্চিতভাবে অসম্ভব (মাস ১৩) — বাদ বা ফাঁকা, কিন্তু log-এ। সন্দেহজনক (qty ঋণাত্মক, দাম অস্বাভাবিক) — আলাদা টেবিলে সরিয়ে রেখে মানুষকে দেখানো। আর নিয়মের সীমাগুলো নিয়মিত আবার যাচাই — ব্যবসা বদলালে “স্বাভাবিক”-ও বদলায়। একটি কলামে কতগুলো মান চিহ্নিত হচ্ছে, সেই সংখ্যা হঠাৎ বাড়লে সেটিই একটি সংকেত।

মূল উপলব্ধি: Validation নিয়ম ডেটাকে পাহারা দেয়, কিন্তু নিয়মগুলোও পাহারার দাবি রাখে। সন্দেহজনক মান মুছে না ফেলে চিহ্নিত করুন — আজকের “ভুল” কালকের আবিষ্কার হতে পারে।

প্র ০৩একই গ্রাহক দুই বানানে — duplicate খোঁজা কবে থেকে একটি আলাদা গবেষণার বিষয়, আর এটি কেন কঠিন?

একই order_id দুবার থাকলে খোঁজা সহজ — অংশ ৪-এ দেখেছেন। কিন্তু “মোঃ রহিম উদ্দিন, মিরপুর ১০” আর “Md. Rahim Uddin, Mirpur-10” কি একই মানুষ? এখানে কোনো অনন্য ID নেই, আছে শুধু ভিন্ন বানানের নাম আর ঠিকানা। এই সমস্যার নাম record linkage (ডেটাবেসের জগতে entity resolution বা deduplication), আর এর ইতিহাস কম্পিউটারের প্রায় সমান পুরোনো।

“Record linkage” শব্দটি ১৯৪৬ সালে Halbert Dunn-এর একটি লেখায় পাওয়া যায়, যেখানে তিনি একজন মানুষের জন্ম, বিয়ে, মৃত্যুর মতো জীবনের ঘটনাগুলোর আলাদা রেকর্ডকে একটি “জীবনের বই”-তে জোড়ার কথা বলেন। ১৯৫৯ সালে Howard Newcombe ও সহকর্মীরা Science-এ কম্পিউটার দিয়ে স্বয়ংক্রিয়ভাবে রেকর্ড জোড়ার একটি পদ্ধতি দেখান, যেখানে নামের মিল কতটা দুর্লভ, তা দিয়ে মিলের শক্তি মাপা হয়। ১৯৬৯ সালে Ivan Fellegi আর Alan Sunter একটি গাণিতিক কাঠামো দেন, যা আজও “Fellegi–Sunter model” নামে পরিচিত: প্রতিটি জোড়ার জন্য বিভিন্ন field-এর মিল থেকে একটি সম্ভাবনাভিত্তিক স্কোর, আর দুটি সীমা — নিশ্চিত মিল, নিশ্চিত অমিল, আর মাঝের “মানুষ দেখুক” অঞ্চল।

সমস্যাটি কঠিন কয়েকটি কারণে। সব জোড়া তুলনা ব্যয়বহুল: ১০ লাখ গ্রাহকে প্রায় ৫০,০০০ কোটি জোড়া, তাই “blocking” দরকার — যেমন শুধু একই জেলার মধ্যে তুলনা। ভুল দুই দিকে: দুইজন আলাদা মানুষকে এক করলে একজনের অর্ডার আরেকজনের হিসাবে যায়; এক মানুষকে দুই ধরলে গ্রাহক-সংখ্যা ফুলে ওঠে। আর বাংলাদেশের ডেটায় বাড়তি জটিলতা: একই নাম বাংলা আর নানা ইংরেজি বানানে (Rahim/Raheem), “মোঃ”-এর মতো উপাধি, আর পরিবারে একই ফোন নম্বর ভাগ করে ব্যবহার।

জেলার নামের mapping এই সমস্যার খুব ছোট রূপ: কয়েকটি মান, আর প্রতিটির সঠিক উত্তর আমরা জানি। গ্রাহকের নামে একই কাজে লাগে string-এর মিল মাপা, সম্ভাবনার স্কোর আর মানুষের যাচাই করা একটি নমুনা, যা দিয়ে ভুলের হার মাপা যায়।

মূল উপলব্ধি: Duplicate খোঁজা যত সহজ দেখায়, ID ছাড়া তত কঠিন। ভুল জোড়া আর ভুল ভাঙা দুটোই ক্ষতি করে, তাই সীমার কাছের সিদ্ধান্ত মানুষের হাতে রাখুন আর ভুলের হার মাপুন।

প্র ০৪হাতে পরিষ্কার করা স্প্রেডশিট আর script-এর পার্থক্য কী? Reinhart–Rogoff বিতর্ক কী শেখায়?

২০১০ সালে অর্থনীতিবিদ Carmen Reinhart আর Kenneth Rogoff একটি প্রভাবশালী গবেষণাপত্রে দেখান, যেসব দেশের সরকারি ঋণ জিডিপির ৯০%-এর বেশি, তাদের প্রবৃদ্ধি গড়ে উল্লেখযোগ্যভাবে কম। ফলটি বিভিন্ন দেশে সরকারি ব্যয় কমানোর নীতির পক্ষে যুক্তি হিসেবে আলোচিত হয়। ২০১৩ সালে University of Massachusetts Amherst-এর একজন স্নাতকোত্তর শিক্ষার্থী Thomas Herndon, তাঁর শিক্ষক Michael Ash আর Robert Pollin-এর সঙ্গে ফলটি পুনরুৎপাদন করতে গিয়ে মূল লেখকদের দেওয়া স্প্রেডশিটে কয়েকটি সমস্যা পান: একটি সূত্রে কয়েকটি দেশের সারি বাদ পড়েছিল, কিছু ডেটা বাদ দেওয়ার সিদ্ধান্ত স্পষ্ট ছিল না, আর গড় করার পদ্ধতি নিয়েও প্রশ্ন ছিল।

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

হাতে পরিষ্কার করা স্প্রেডশিটে প্রতিটি সিদ্ধান্ত — এই সারি মুছলাম, এই ঘর ঠিক করলাম — অদৃশ্য হয়ে যায়; শুধু ফলটি থাকে। পরে কেউ, এমনকি নিজেও, বলতে পারে না কী বদলানো হয়েছিল। Script-এ সব সিদ্ধান্ত লেখা থাকে: কোন নিয়মে কোন সারি বাদ, কোন মান কী দিয়ে পূরণ। Cleaning log সেই সিদ্ধান্তের ফল দেখায়: কত সারি, কোন ধাপে। আর raw ফাইল অক্ষত থাকলে যে কেউ পুরো পথ আবার হেঁটে দেখতে পারেন। Script-এও ভুল থাকে, কিন্তু সেটি খুঁজে পাওয়া যায়, কারণ সবকিছু লেখা — আর Git-এ রাখলে (পর্ব ১২) কবে কোন নিয়ম বদলাল, তাও দেখা যায়।

মূল উপলব্ধি: পরিষ্কারের প্রতিটি সিদ্ধান্ত একটি দাবি, আর দাবি যাচাইযোগ্য হতে হয়। Script, log আর অক্ষত raw ফাইল মিলে সেই যাচাইয়ের পথ খোলা রাখে।

প্র ০৫বাংলা লেখার ডেটা পরিষ্কার কেন ইংরেজির চেয়ে বেশি সাবধানতা দাবি করে? NFC কি সব সমাধান?

ইংরেজি লেখা পরিষ্কারের প্রচলিত টুল আর অভ্যাস — lower(), বিরামচিহ্ন সরানো, ASCII-তে আনা — অনেক দশকের চর্চায় তৈরি, মূলত Latin লিপির কথা ভেবে। বাংলায় এগুলোর কিছু অর্থহীন (বড়-ছোট হাতের অক্ষর নেই), আর কিছু ক্ষতিকর। যেমন ইংরেজির জন্য লেখা কোনো “শুধু অক্ষর আর অঙ্ক রাখা” নিয়ম যদি Unicode-এর বিভাগ না বুঝে লেখা হয়, তাহলে বাংলার কার-চিহ্ন (া, ি, ে), হসন্ত আর চন্দ্রবিন্দু বাদ পড়তে পারে, কারণ Unicode-এর হিসাবে এগুলো “combining mark”, “letter” নয়। ফলে “দাম” হয়ে যেতে পারে “দম”।

Normalization-এও সূক্ষ্মতা আছে। Unicode-এর চারটি রূপের মধ্যে NFC আর NFD শুধু “canonical” সমতা মেলায় — অর্থে হুবহু এক অক্ষর। NFKC আর NFKD “compatibility” সমতাও মেলায়, এতে কখনো অর্থের সূক্ষ্ম পার্থক্যও মুছে যায়, তাই এগুলো সাধারণত খোঁজার সূচকে ব্যবহার হয়, মূল লেখা রাখতে নয়। আর “য়”, “ড়”, “ঢ়” composition exclusion তালিকায় থাকায় NFC এদের দুই code point-এর রূপে রাখে (পর্ব ১০).

আর কিছু সমস্যা NFC একেবারেই ছোঁয় না। বাংলা অঙ্ক (U+09E6 থেকে U+09EF) NFC-তে বাংলাই থাকে; তাই translate লাগে। খণ্ড-ত-এর পুরোনো লেখার রূপ (ত + হসন্ত + zero-width joiner) আর নতুন নিজস্ব code point NFC-তে এক হয় না। Zero-width joiner আর non-joiner যুক্তাক্ষরের চেহারা বদলাতে বৈধভাবে ব্যবহার হয়, তাই অন্ধভাবে মোছা যায় না। পুরোনো Bijoy-এর মতো non-Unicode লেখা কোনো normalization-এ ঠিক হয় না, আলাদা রূপান্তর লাগে। আর বানানের বৈচিত্র্য — “ঈদ”/“ইদ”, “সরকারি”/“সরকারী” — ভাষার প্রশ্ন, encoding-এর নয়; এগুলো এক করা হবে কি না, তা কাজের উদ্দেশ্যের ওপর নির্ভর করে।

তাই বাংলা লেখার পরিষ্কারকে একটি তালিকা হিসেবে ভাবা ভালো: encoding ঠিক (UTF-8), NFC, অঙ্কের রূপান্তর, অদৃশ্য অক্ষরের খোঁজ, পরিচিত বানান-বৈচিত্র্যের mapping — আর প্রতিটি ধাপের আগে-পরে nunique() ও কয়েকটি নমুনা চোখে পড়া। ইংরেজির জন্য বানানো library বাংলায় চালালে ফল হাতে মিলিয়ে দেখা আবশ্যক।

মূল উপলব্ধি: NFC প্রয়োজনীয়, কিন্তু যথেষ্ট নয়। বাংলা লেখার প্রতিটি পরিষ্কারের নিয়ম আগে কয়েকটি আসল বাংলা শব্দে চালিয়ে দেখুন — ইংরেজির অভ্যাস বাংলায় নীরবে ক্ষতি করতে পারে।

অনুশীলন

হিসাব, আগাম অনুমান, কোড আর নকশা-সমালোচনা — চার রকম কাজ।

  1. হিসাব করুন: বাজারঘরের দুটি পণ্যের দাম (পয়সায়): চা পাতা — ৩৫০০০, ৩৪০০০, ৩৬০০০, ফাঁকা; মগ — ১৭০০০, ১৮০০০, ফাঁকা, ৯০০০০. (ক) প্রতিটি পণ্যের median আর mean কত? (খ) পুরো টেবিলের (দুই পণ্য মিলিয়ে) median কত? (গ) মগের ফাঁকা ঘর কোন সংখ্যা দিয়ে পূরণ করবেন, আর কেন?

    (ক) চা পাতা: জানা তিনটি মান ৩৪০০০, ৩৫০০০, ৩৬০০০ — median ৩৫০০০, mean (৩৪০০০ + ৩৫০০০ + ৩৬০০০) ÷ ৩ = ৩৫০০০. মগ: সাজালে ১৭০০০, ১৮০০০, ৯০০০০ — median ১৮০০০, কিন্তু mean (১৭০০০ + ১৮০০০ + ৯০০০০) ÷ ৩ = ৪১৬৬৬.৬৭.

    (খ) জানা ছয়টি মান সাজালে: ১৭০০০, ১৮০০০, ৩৪০০০, ৩৫০০০, ৩৬০০০, ৯০০০০. জোড় সংখ্যা, তাই মাঝের দুটির গড়: (৩৪০০০ + ৩৫০০০) ÷ ২ = ৩৪৫০০.

    (গ) মগের নিজের median ১৮০০০ (১৮০ টাকা)। পুরো টেবিলের median ৩৪৫০০ বসালে মগের দাম প্রায় দ্বিগুণ হতো; mean ৪১৬৬৬.৬৭ বসালে একটি ৯০০ টাকার মগের (সম্ভবত outlier বা ভুল — যাচাই দরকার) টানে আরও বেশি। তাই দলভিত্তিক median: ১৮০০০. (একটি local run-এ np.nanmedian আর np.nanmean দিয়ে সংখ্যাগুলো মিলিয়ে দেখা হয়েছে।)

  2. আগাম বলুন (predict the output): নিচের কোড চালানোর আগে লিখে ফেলুন কী ছাপা হবে, তারপর উপরের যেকোনো কোড-বাক্সে পেস্ট করে মিলিয়ে নিন।
    import pandas as pd
    df = pd.DataFrame({"id": [7, 8, 7, 9, 8],
                       "district": [" Dhaka", "খুলনা", "dhaka", None, "খুলনা"],
                       "qty": [1, 2, 1, None, 2]})
    print(df.duplicated().sum(), df.duplicated("id").sum(),
          df.duplicated("id", keep=False).sum())
    d = df["district"].str.strip().str.lower()
    print(d.nunique(), len(df.dropna(thresh=3)))

    হুবহু duplicate শুধু index ৪ (index ১-এর সঙ্গে সব ঘর এক); index ২ ধরা পড়ে না, কারণ “ Dhaka” আর “dhaka” আলাদা লেখা — তাই ১. "id" ধরে দ্বিতীয়বার আসা index ২ আর ৪ — তাই ২. keep=False-এ id ৭ আর ৮-এর সব কপি: ৪টি। Strip আর lower-এর পরে আলাদা মান “dhaka” আর “খুলনা” — nunique ফাঁকা গোনে না, তাই ২. thresh=3: index ৩-এ শুধু id ভরা, বাকি চারটি সারির তিনটি ঘরই ভরা — তাই ৪.

    1 2 4
    2 4
  3. কোড লিখুন: একটি function clean_phone(s) লিখুন, যা একটি ফোন নম্বরের Series নেবে আর: বাংলা অঙ্ক ইংরেজিতে আনবে, ফাঁকা জায়গা আর হাইফেন সরাবে, শুরুর +880 বা 880-কে 0 বানাবে, আর যেসব নম্বর 01-এর পরে ৩–৯-এর একটি অঙ্ক দিয়ে শুরু হয়ে মোট ১১ অঙ্কের নয়, সেগুলোকে NaN করবে। এই নমুনায় চালান: "০১৭০০-০০০০০১", "+8801800000002", "01900 000003", "8801500000004", "0170000001", None.

    একটি সম্ভাব্য সমাধান (নিজের ল্যাপটপে pandas 3.1.1-এ চালানো):

    import pandas as pd
    BN = str.maketrans("০১২৩৪৫৬৭৮৯", "0123456789")
    
    def clean_phone(s: pd.Series) -> pd.Series:
        s = s.str.translate(BN).str.replace(r"[\s\-]", "", regex=True)
        s = s.str.replace(r"^(\+?880)", "0", regex=True)
        return s.where(s.str.fullmatch(r"01[3-9]\d{8}"))
    
    raw = pd.Series(["০১৭০০-০০০০০১", "+8801800000002", "01900 000003",
                     "8801500000004", "0170000001", None])
    print(clean_phone(raw).tolist())
    ['01700000001', '01800000002', '01900000003', '01500000004', nan, nan]

    প্রথম চারটি একই রূপে এসেছে। ১০ অঙ্কের নম্বরটি NaN — অনুমান করে একটি অঙ্ক জোড়া হয়নি। where শর্ত মিথ্যা হলে NaN বসায়, তাই খারাপ নম্বর পরে isna() দিয়ে গোনা যায়। চাইলে মূল কলাম রেখে নতুন phone_clean কলামে লিখুন, যাতে কোনগুলো বাদ পড়ল তা পাশাপাশি দেখা যায়।

  4. নকশা-সমালোচনা: একজন সহকর্মীর পরিষ্কারের script এমন: df = pd.read_csv("data/raw/orders.csv"); df = df.dropna(); df["price"] = pd.to_numeric(df["price"], errors="coerce").fillna(0); df = df.drop_duplicates(); শেষে df.to_csv("data/raw/orders.csv", index=False). কোনো print বা log নেই। এই নকশার সমস্যাগুলো কী, আর কীভাবে সাজাবেন?
    • Raw ফাইলের ওপর লেখা: মূল ডেটা চিরতরে হারায়; আবার চালালে পরিষ্কার-করা ডেটাকে আবার “পরিষ্কার” করা হয়। ফল লিখুন data/processed/-এ।
    • অন্ধ dropna(): যেকোনো কলামে ফাঁকা থাকলেই সারি বাদ — payment ফাঁকা বলে একটি বৈধ অর্ডার হারায়। subset দিয়ে শুধু দরকারি কলাম।
    • “৳” আর কমা না সরিয়ে coerce: “৳1,250”-এর মতো প্রায় সব দাম NaN হবে, তারপর fillna(0)-এ ০ — বিক্রির হিসাব প্রায় শূন্য, আর কোনো error নেই। আগে লেখা পরিষ্কার, coerce-এর আগে-পরে NaN গোনা, আর পূরণ দলভিত্তিক median-এ।
    • লেখা পরিষ্কারের আগে duplicate: শেষের ফাঁকা জায়গার জন্য কিছু duplicate ধরা পড়বে না; আর subset="order_id" না দিলে একই অর্ডারের বিরোধী কপি থেকে যায়।
    • ফোন dtype=str ছাড়া পড়া: সব অঙ্ক ইংরেজি হলে শুরুর শূন্য হারায়।
    • Log আর assertion নেই: কত সারি গেল কেউ জানে না। প্রতিটি ধাপে সারি ও মানের হিসাব, শেষে assert.

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

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

ক্রমএকটি এলোমেলো অর্ডারের CSV পরিষ্কারের ধাপগুলো যুক্তিসংগত ক্রমে সাজান (▲▼ চেপে সরান)

  1. 📥 na_values আর dtype দিয়ে raw ফাইল পড়া (ফাইলে কিছু না লিখে)
  2. 🔍 Profile: dtypes, isna().sum(), duplicated(), unique()
  3. 🔤 লেখা আর type ঠিক করা: strip, NFC, mapping, to_numeric, to_datetime
  4. 🗑️ Duplicate আর অসম্ভব মানের সারি বাদ
  5. 🧩 বাকি পরিষ্কার সারি থেকে দলভিত্তিক median দিয়ে ফাঁকা পূরণ
  6. ✅ assert দিয়ে নিয়ম যাচাই, তারপর নতুন ফাইল আর cleaning log লেখা

একটি CSV-র price কলামে কিছু ঘরে “N/A” আর কিছু ঘরে “-” লেখা। pd.read_csv(path) (কোনো parameter ছাড়া) দিয়ে পড়লে কোনটি ঘটবে?

  • দুটোই NaN হবে
  • “N/A” NaN হবে, কিন্তু “-” লেখা হিসেবে থাকবে
  • “-” NaN হবে, কিন্তু “N/A” লেখা হিসেবে থাকবে
  • দুটোই error দেবে

একটি DataFrame-এর চারটি সারির order_id: 1004, 1006, 1006, 1004 (শেষ 1004 সারির payment আগেরটির থেকে আলাদা, বাকি ঘর এক)। df.duplicated(subset="order_id", keep=False).sum() কত?

  • ১
  • ২
  • ৪
  • ০

হেডফোনের জানা তিনটি দাম ১২৫০০০, ২৪০০০০ আর ২৪৫০০০ পয়সা; একটি ঘর ফাঁকা। পণ্যভিত্তিক median দিয়ে fillna করলে ফাঁকা ঘরে কী বসবে?

  • ২৪০০০০
  • ২০৩৩৩৩.৩৩
  • ০
  • ২৪৫০০০

একটি ফর্মে সবচেয়ে অসন্তুষ্ট গ্রাহকেরাই সাধারণত রেটিং দেন না। রেটিং কলামের এই ফাঁকাগুলো কোন শ্রেণির?

  • MCAR
  • MAR
  • Outlier
  • MNAR

pandas-এ pd.to_numeric(pd.Series(["১২৩"]), errors="coerce")-এর ফল কী, আর বাংলা অঙ্কের দাম বা ফোন সংখ্যায় আনার নিরাপদ পথ কোনটি?

  • 123; আলাদা কিছু করার দরকার নেই
  • NaN; আগে str.translate দিয়ে বাংলা অঙ্ক ইংরেজিতে আনা
  • Error; errors="ignore" দেওয়া
  • NaN; আগে str.normalize("NFC") করা

একটি জেলার কলামে “Dhaka”, “dhaka␠” (শেষে ফাঁকা), “DHAKA” আর “ঢাকা”. কোন সমন্বয় এগুলোকে একটি মানে আনবে?

  • শুধু str.lower()
  • শুধু str.normalize("NFC")
  • str.strip(), তারপর str.lower(), তারপর replace({"dhaka": "ঢাকা"})
  • drop_duplicates(subset="district")

শব্দকোষ

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

মনে রাখুন

  1. আগে মাপুন: dtypes, isna().sum(), duplicated(), unique(); উৎসের নিজস্ব “ফাঁকা” চিহ্ন na_values দিয়ে চেনান।
  2. বাদ বা পূরণ একটি সিদ্ধান্ত: dropna(subset=..., thresh=...), দলভিত্তিক median, সময়ের ধারায় ffill; MNAR হলে দুটোই পক্ষপাত আনে।
  3. Duplicate: subset ঠিক করে কী মিললে এক, keep=False দিয়ে আগে সব কপি দেখুন; বিরোধী কপি বাদ দেওয়ার আগে কারণ লিখুন।
  4. Type আর লেখা: দাম পূর্ণসংখ্যার পয়সায়, তারিখ স্পষ্ট format-এ, বাংলা অঙ্ক translate-এ, জেলা strip + lower + NFC + dictionary-তে; coerce-এর আগে-পরে NaN গুনুন।
  5. নিয়ম, log আর raw: assert দিয়ে যাচাই, প্রতিটি ধাপের হিসাব লিখে রাখা, আর মূল ফাইলে কখনো না লেখা।

পরের পর্বে: পরিষ্কার টেবিল হাতে এল — এবার প্রশ্ন করার পালা। কোন জেলায় বিক্রি সবচেয়ে বেশি, কোন পণ্যের গড় ডেলিভারি কত দিন? Groupby-র split, apply, combine দিয়ে এক লাইনে উত্তর, আর আজকের transform("median")-এর পেছনের পুরো ধারণা।

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

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