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

ডেটার ফাইল — CSV, JSON, Parquet আর বাংলা লেখার encoding

Data Files for AI Work — CSV, JSON Lines, Parquet/Arrow, UTF-8 and Bangla Text

Taught in Bangla, this lesson explains text versus binary files, how a CSV parser handles quotes, and the traps of lost leading zeros, Excel and the BOM. It compares JSON Lines, Parquet, Arrow and xlsx with a real local size and speed run on a million rows, then covers UTF-8 for Bangla, mojibake, Unicode normalization, ISO 8601 dates and the Asia/Dhaka time zone.

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

  • Text আর binary ফাইলের পার্থক্য, আর কোন ফাইল কোন কাজে
  • CSV-এর delimiter, header, quote-এর নিয়ম, RFC 4180, আর Excel কীভাবে ডেটা নষ্ট করে — নিজে চালিয়ে দেখা
  • JSON, JSON Lines, Parquet, Arrow আর xlsx — প্রতিটির শক্তি ও দুর্বলতা, একটি সত্যিকারের আকার-ও-সময়ের তুলনাসহ
  • Byte থেকে বাংলা লেখা: UTF-8, mojibake কেন হয় ও কীভাবে সারানো যায়, পুরোনো Bijoy লেখা আর Unicode normalization
  • ISO 8601 তারিখ, Asia/Dhaka time zone, ডেটার আকারের হিসাব আর কাজ অনুযায়ী format বাছাই

১ · ফাইল মানে byte — text আর binary

AI-এর যেকোনো প্রকল্পে প্রথম কাজটি প্রায় সবসময় একই: কোথাও থেকে একটি ফাইল আসে, আর সেটি ঠিকমতো পড়তে হয়। বাজারঘরের (এই কোর্সের কাল্পনিক অনলাইন দোকান) হিসাব বিভাগ পাঠায় Excel ফাইল, ওয়েবসাইট লেখে log, ডেলিভারির অংশীদার পাঠায় CSV, আর ডেটা দল রাখে Parquet. Model যত ভালোই হোক, ফাইল পড়ার ধাপে একটি ফোন নম্বরের শূন্য হারালে বা একটি বাংলা নাম ভেঙে গেলে পরের সব কাজ ভুল ডেটায় চলে।

Python-এ ফাইল খোলা, with আর pathlib আপনি Python কোর্সের File handling পর্বে শিখেছেন, আর csv ও json module-এর ব্যবহার আছে CSV ও JSON পর্বে. এখানে সেগুলো আবার শেখানো হবে না। এই পর্বের প্রশ্ন অন্য: ফাইলের ভেতরে ডেটা কীভাবে সাজানো থাকে, আর AI-এর কাজে কোন format কেন বেছে নেবেন।

চায়ের দোকানের মালিকের দুটি খাতা ভাবুন। একটি হাতে লেখা হিসাব খাতা: “রহিম — ২ কাপ চা — ২০ টাকা”। যে কেউ পড়তে পারেন, কিন্তু “২০” সংখ্যা নাকি লেখা, তা খাতা বলে না; হাতের লেখা খারাপ হলে ভুলও হয়। আরেকটি হলো দোকানের ক্যাশ মেশিনের মেমোরি: সেখানে সংখ্যা সংখ্যা হিসেবেই রাখা, সময় সময় হিসেবে — কিন্তু মেশিন ছাড়া আপনি সেটি পড়তে পারবেন না। প্রথমটি text ফাইল, দ্বিতীয়টি binary ফাইল.

দুই ক্ষেত্রেই ডিস্কে থাকে শুধু byte — ০ থেকে ২৫৫-এর সংখ্যা। Text ফাইলে byte-গুলো একটি character encoding (যেমন UTF-8) মেনে অক্ষর বোঝায়; সংখ্যা 350 আসলে তিনটি অক্ষর 3, 5, 0. Binary ফাইলে byte-গুলো একটি নির্দিষ্ট গঠন মেনে চলে: “প্রথম ৮ byte একটি ৬৪-বিটের পূর্ণসংখ্যা”, “এরপর একটি তারিখ”, ইত্যাদি। তাই binary ফাইলে type হারায় না, কিন্তু নোটপ্যাডে খুললে অর্থহীন চিহ্ন দেখা যায়।

দিকText (CSV, JSON, JSONL)Binary (Parquet, Arrow, xlsx)
চোখে পড়ানোটপ্যাডেই খোলেবিশেষ টুল লাগে
Typeসব কিছু লেখা; পড়ার সময় অনুমান করতে হয়Schema-য় type লেখা থাকে
আকারবড় (সংখ্যাও অক্ষরে)ছোট (সাধারণত compression-সহ)
Git-এ পার্থক্য দেখালাইন ধরে দেখা যায়দেখা যায় না
ভাঙলে কী হয়ক্ষতিগ্রস্ত লাইন বাদ দিয়ে বাকিটা প্রায়ই পড়া যায়কাঠামো ভাঙলে পুরো ফাইল অপাঠ্য হতে পারে

কোনোটি সবসময় ভালো নয়: ছোট, আদান-প্রদানের ডেটায় text-এর সুবিধা বেশি; বড়, বারবার পড়া ডেটায় binary columnar format এগিয়ে।

২ · CSV-এর ভেতরে — delimiter, header আর quote

CSV (Comma-Separated Values) সবচেয়ে পুরোনো আর সবচেয়ে প্রচলিত ডেটা ফাইলগুলোর একটি। নিয়মগুলো শুনতে সরল: প্রতিটি লাইন একটি সারি (record), আর একটি delimiter — সাধারণত কমা — দিয়ে সারির মানগুলো (field) আলাদা করা। প্রথম লাইনে প্রায়ই কলামের নাম থাকে, যাকে বলে header.

order_id,customer,phone,amount
1001,রহিমা,01700000000,350
1002,কামাল,01800000001,1200

সমস্যা শুরু হয় যখন মানের ভেতরেই কমা থাকে। ঠিকানা “আগ্রাবাদ, চট্টগ্রাম” লিখলে কমাটি কি field-এর অংশ, নাকি নতুন field-এর শুরু? এর সমাধান quoting: পুরো field-টি দুটি double quote (")-এর মধ্যে রাখা। Quote-এর ভেতরে কমা, এমনকি নতুন লাইনও, সাধারণ অক্ষর। আর field-এর ভেতরেই যদি " থাকে? তখন সেটি পরপর দুবার লেখা হয়: "".

নিচের একটি লাইনে এই তিন নিয়মই আছে। একটি CSV parser লাইনটি কীভাবে বাম থেকে ডানে পড়ে, “পরে” চেপে দেখুন। লক্ষ রাখুন, parser প্রতিটি মুহূর্তে একটি অবস্থায় থাকে: হয় সাধারণ field-এ, নয়তো quote-এর ভেতরে।

1002, " আগ্রাবাদ, চট্টগ্রাম ", "বললেন ""ভালো"" ",1200
Parser-এর খাতা
অবস্থা
এখন জমছে
তৈরি field
  1. প্রথম field. Parser সাধারণ অবস্থায় 1002 পড়ল, তারপর কমা পেল। সাধারণ অবস্থায় কমা মানে “field শেষ”। প্রথম field তৈরি: 1002.
  2. Quote খুলল। নতুন field-এর একদম শুরুতে " এসেছে। Parser এখন “quote-এর ভেতরে” অবস্থায় গেল; quote চিহ্নটি নিজে field-এর অংশ নয়।
  3. ভেতরের কমা সাধারণ অক্ষর। “আগ্রাবাদ, চট্টগ্রাম” পুরোটাই জমছে। Quote-এর ভেতরে থাকায় এই কমা field ভাঙে না। এখানে একটি নতুন লাইন থাকলেও সেটিও field-এর অংশ হতো।
  4. Quote বন্ধ, field শেষ। "-এর পরে কমা এসেছে, তাই quote বন্ধ হলো আর দ্বিতীয় field তৈরি হলো: আগ্রাবাদ, চট্টগ্রাম.
  5. দ্বিগুণ quote. তৃতীয় field আবার quote দিয়ে শুরু। ভেতরে "" পেলে parser বোঝে এটি quote বন্ধ নয়, একটি আসল " চিহ্ন। জমছে: বললেন "ভালো".
  6. লাইন শেষ। শেষ quote বন্ধ হলো, তারপর কমার পরে 1200, তারপর লাইনের শেষ। মোট ৪টি field. অথচ শুধু কমা দেখে ভাঙলে (line.split(",")) টুকরো হতো ৫টি — ঠিকানাটি দুই ভাগ হয়ে যেত।

এই কারণেই CSV পড়তে কখনো নিজে split(",") লেখা উচিত নয়। Python-এর csv module বা pandas-এর read_csv ঠিক উপরের মতো অবস্থা ধরে ধরে পড়ে।

CSV-এর মতো কমা-দিয়ে-আলাদা ডেটা কম্পিউটারে বহু দশক ধরে চলে আসছে, স্প্রেডশিট আসার অনেক আগে থেকেই; কিন্তু অনেক বছর এর কোনো লিখিত সাধারণ নিয়ম ছিল না। ২০০৫ সালে Y. Shafranovich-এর লেখা RFC 4180 (“Common Format and MIME Type for CSV Files”) প্রকাশিত হয়। এটি text/csv নামটি নথিভুক্ত করে আর বেশিরভাগ প্রোগ্রাম যে নিয়ম মানে বলে মনে হয়, তা লিখে রাখে: CRLF লাইন-শেষ, ঐচ্ছিক header, quote আর "". তবে এটি একটি “Informational” RFC — বাধ্যতামূলক মান (standard) নয়। ফলে বাস্তবে নানা “উপভাষা” চলে: কোথাও delimiter সেমিকোলন, কোথাও tab, কোথাও quote-এর বদলে backslash. তাই প্রায়ই বলা হয়, “CSV কোনো একক standard নয়”।

মূল ধারণা

CSV ফাইল হাতে পেলে চারটি প্রশ্ন করুন: (১) delimiter কী — কমা, সেমিকোলন না tab? (২) header আছে কি? (৩) quote কোন চিহ্নে, আর ভেতরের quote কীভাবে লেখা? (৪) encoding কী — UTF-8, BOM-সহ UTF-8, নাকি পুরোনো কোনো codepage? চারটির উত্তর না জেনে পড়া মানে অনুমানের ওপর ডেটা দাঁড় করানো।

৩ · CSV-এর ফাঁদ — হারানো শূন্য, Excel আর অদৃশ্য BOM

CSV-তে type লেখা থাকে না। পড়ার প্রোগ্রাম প্রতিটি কলাম দেখে অনুমান করে: সব মান অঙ্ক হলে সংখ্যা, তারিখের মতো দেখালে তারিখ। এই অনুমানই বাংলাদেশের ডেটায় সবচেয়ে বেশি ক্ষতি করে, কারণ এখানে মোবাইল নম্বর শুরু হয় 01 দিয়ে। 01700000000-কে সংখ্যা ধরলে প্রথম শূন্যটি অর্থহীন, তাই বাদ পড়ে: 1700000000. এখন সেটি আর কোনো নম্বর নয়, আর SMS পাঠানোর কোডও ভুল জায়গায় যাবে। (এই পর্বের সব ফোন নম্বর স্পষ্টতই বানানো।)

নিচে বাজারঘরের ডেলিভারি অংশীদারের পাঠানো একটি এলোমেলো CSV আছে। এতে চারটি ফাঁদ: ঠিকানার ভেতরে কমা, একটি রিভিউর ভেতরে নতুন লাইন, দ্বিগুণ quote, আর ফাইলের একদম শুরুতে একটি অদৃশ্য BOM. ফাইলটি পাতার ভেতরেই রাখা, কোড তাকে orders_messy.csv নামে পড়ে।

order_id,customer,phone,address,review,amount
1001,রহিমা,01700000000,"মিরপুর ১০, ঢাকা",ভালো,350
1002,কামাল,01800000001,"আগ্রাবাদ, চট্টগ্রাম","দাম ঠিক, ডেলিভারি দেরি",1200
1003,সুমি,01900000002,খুলনা,"প্যাকেট ভালো
তবে দেরি",780
1004,জামাল,01500000003,সিলেট,"বললেন ""আবার কিনব""",95

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

import csv
import pandas as pd

raw = open("orders_messy.csv", encoding="utf-8").read()
lines = raw.splitlines()
print("text lines in file:", len(lines))
print("naive split(',') on line 3:", len(lines[2].split(",")), "pieces")

with open("orders_messy.csv", encoding="utf-8", newline="") as f:
    first = next(csv.DictReader(f))
print("csv + utf-8     first key:", repr(list(first)[0]))
with open("orders_messy.csv", encoding="utf-8-sig", newline="") as f:
    rows = list(csv.DictReader(f))
print("csv + utf-8-sig first key:", repr(list(rows[0])[0]))
print("records:", len(rows), "| fields in record 2:", len(rows[1]))

df = pd.read_csv("orders_messy.csv")
print("\npandas default :", df["phone"].dtype, df["phone"].tolist()[:2])
df = pd.read_csv("orders_messy.csv", dtype={"phone": str})
print("dtype phone=str:", df["phone"].dtype, df["phone"].tolist()[:2])
print(df[["order_id", "address", "amount"]].to_string(index=False))
print(repr(df.loc[2, "review"]), "|", df.loc[3, "review"])

আসল আউটপুট:

text lines in file: 6
naive split(',') on line 3: 8 pieces
csv + utf-8     first key: '\ufefforder_id'
csv + utf-8-sig first key: 'order_id'
records: 4 | fields in record 2: 6

pandas default : int64 [1700000000, 1800000001]
dtype phone=str: str ['01700000000', '01800000001']
 order_id             address  amount
     1001     মিরপুর ১০, ঢাকা     350
     1002 আগ্রাবাদ, চট্টগ্রাম    1200
     1003               খুলনা     780
     1004               সিলেট      95
'প্যাকেট ভালো\nতবে দেরি' | বললেন "আবার কিনব"

লাইন ধরে গুনলে ফাইলে ৬টি লাইন, অথচ record মাত্র ৪টি আর header ১টি — কারণ সুমির রিভিউর ভেতরে একটি নতুন লাইন আছে। শুধু কমা দেখে ভাঙলে দ্বিতীয় অর্ডারের লাইনে ৮টি টুকরো আসে, যদিও আসল field ৬টি। csv module ঠিকঠাক ৬টি field দিয়েছে।

এবার BOM. encoding="utf-8" দিয়ে খুললে প্রথম কলামের নাম হয় '\ufefforder_id' — চোখে “order_id”, কিন্তু row["order_id"] লিখলে KeyError! encoding="utf-8-sig" দিলে Python শুরুর BOM ফেলে দেয়। খেয়াল করুন, pandas-এর read_csv নিজে থেকেই BOM সরিয়ে দিয়েছে (কলামের নাম ঠিক এসেছে) — কিন্তু সব টুল এমন দয়ালু নয়।

শেষে ফোন নম্বর: pandas নিজে অনুমান করে কলামটিকে int64 বানিয়েছে, শূন্য হারিয়েছে। dtype={"phone": str} বলে দিলে নম্বর লেখা হিসেবেই থাকে। Quote-এর ভেতরের কমা আর নতুন লাইন (\n) pandas ঠিকঠাক রেখেছে, আর "" হয়ে গেছে একটি ".

Excel — সবচেয়ে বেশি ব্যবহৃত, সবচেয়ে বেশি “সাহায্যকারী”

অনেক অফিসে CSV মানেই Excel-এ ডাবল-ক্লিক করে খোলা। Excel খোলার সময় নিজে থেকে মান “ঠিক” করে দেয়, আর আবার সেভ করলে সেই বদল ফাইলে স্থায়ী হয়ে যায়। সাধারণত দেখা যায় এমন কয়েকটি ঘটনা (Excel-এর version আর কম্পিউটারের অঞ্চল-সেটিং অনুযায়ী আচরণ বদলায়):

ফাইলে ছিলExcel-এ যা হতে পারেকেন
017000000001700000000সংখ্যা ভেবে শুরুর শূন্য ফেলে দেয়
১৫ অঙ্কের বেশি লম্বা IDশেষের অঙ্কগুলো 0 হয়ে যায়, বা 1.23E+16Excel সংখ্যায় প্রায় ১৫টি significant অঙ্ক রাখে
3/5, 1-2৩ মার্চ বা ৫ মার্চ, ১ ফেব্রুয়ারিতারিখ ভেবে রূপ বদলায়, অঞ্চলভেদে মাস-দিন উল্টো
BOM ছাড়া UTF-8 বাংলাদাম-এর মতো ভাঙা লেখা (অনেক Windows সেটআপে)encoding না জেনে Windows-এর পুরোনো codepage ধরে নেয়
কমা-দিয়ে-আলাদা ফাইলসব একটি কলামেকিছু অঞ্চলে Excel সেমিকোলনকে delimiter ধরে

শেষ দুটির সমাধান উল্টো দিক থেকে আসে: যে CSV Excel ব্যবহারকারী খুলবেন, তা লেখার সময় df.to_csv("out.csv", encoding="utf-8-sig") দিন। শুরুর তিনটি byte EF BB BF (BOM) দেখে Excel বোঝে এটি UTF-8. অর্থাৎ BOM একই সঙ্গে ফাঁদ (Python-এর csv-এ অদৃশ্য অক্ষর) আর সমাধান (Excel-এ বাংলা ঠিক দেখানো)। আর ফোন নম্বর বা ID কলাম ঠিক রাখতে Excel-এ ফাইল খোলার সময় Data → From Text/CSV দিয়ে কলামের type “Text” বেছে নেওয়া নিরাপদ।

৪ · JSON আর JSON Lines — record, nested ডেটা আর log

CSV একটি সমতল টেবিল: প্রতিটি সারিতে একই কলাম। কিন্তু বাজারঘরের একটি অর্ডারে কয়েকটি পণ্য থাকে, প্রতিটি পণ্যের নিজের দাম আর পরিমাণ। এমন ডেটা টেবিলে আঁটে না। JSON (JavaScript Object Notation) এখানে স্বাভাবিক: object ({}), list ([]), string, সংখ্যা, true/false আর null — এক অপরের ভেতরে যত খুশি।

{"order_id": 1002, "district": "চট্টগ্রাম",
 "ordered_at": "2026-11-04T09:30:00+06:00",
 "items": [{"sku": "TEA-250", "qty": 2, "price": 180.0},
           {"sku": "MUG-01",  "qty": 1, "price": 350.0}]}

JSON-এর type ব্যবস্থা ছোট, আর সেখানেই কয়েকটি ফাঁদ: তারিখের কোনো type নেই — তারিখ আসে লেখা হিসেবে, তাই ISO 8601 (অংশ ৯) মেনে লেখা জরুরি। NaN বা Infinity মান নেই (JSON-এর মান RFC 8259); pandas missing মানকে null লেখে। আর খুব বড় পূর্ণসংখ্যা (২৫৩-এর বেশি) অনেক JavaScript-ভিত্তিক টুলে নিখুঁত থাকে না, কারণ সেখানে সব সংখ্যা ৬৪-বিট floating point. তাই বড় ID প্রায়ই string হিসেবে পাঠানো ভালো।

JSON Lines — প্রতিটি লাইনে একটি record

একটি বড় JSON list-এ লাখো অর্ডার রাখলে দুটি সমস্যা: পুরো ফাইল একসঙ্গে মেমরিতে না নিয়ে পড়া কঠিন, আর নতুন অর্ডার যোগ করতে শেষের ] সরিয়ে আবার লিখতে হয়। JSON Lines (JSONL, প্রায় একই ধারণার আরেক নাম NDJSON) এর সহজ সমাধান: প্রতিটি লাইনে একটি সম্পূর্ণ JSON record, আর কিছু নয়।

{"ts": "2026-11-04T09:30:12+06:00", "event": "search", "q": "চা পাতা"}
{"ts": "2026-11-04T09:30:40+06:00", "event": "add_to_cart", "sku": "TEA-250"}
{"ts": "2026-11-04T09:31:05+06:00", "event": "checkout", "amount": 360}

Log লেখার প্রোগ্রাম শুধু শেষে একটি লাইন জুড়ে দেয়; মাঝপথে বন্ধ হলে বড়জোর শেষ লাইনটি ভাঙে; আর লাইন ধরে পড়া যায় বলে ফাইল মেমরির চেয়ে বড় হলেও সমস্যা নেই। অনেক ML আর ভাষা-model-এর ডেটাসেট (প্রশ্ন–উত্তরের জোড়া, লেবেল দেওয়া রিভিউ) এই format-এ বিতরণ করা হয়। pandas-এ পড়া-লেখা এক লাইনে: pd.read_json("log.jsonl", lines=True) আর df.to_json("out.jsonl", orient="records", lines=True). JSON Lines কোনো আনুষ্ঠানিক মান নয়, jsonlines.org-এ লেখা একটি প্রচলিত রীতি: UTF-8, প্রতি লাইনে একটি বৈধ JSON মান, লাইন শেষ \n.

JSON আর JSONL দুটোই প্রতিটি record-এ কলামের নাম আবার লেখে ("order_id":, "price": …)। ফলে সমতল টেবিলের জন্য এরা CSV-এর চেয়েও বড় হয় — অংশ ৫-এর সত্যিকারের run-এ ১০ লাখ সারি JSONL-এ ১৩৯ MB, CSV-তে ৫৭ MB. Nested বা log ডেটায় JSONL ভালো; বড় সমতল টেবিলে নয়।

৫ · Parquet আর Arrow — কলাম ধরে রাখা

CSV আর JSONL সারি ধরে লেখে: একটি অর্ডারের সব মান পাশাপাশি, তারপর পরের অর্ডার। একে বলে row-oriented রাখা। কিন্তু বিশ্লেষণ আর ML-এর প্রশ্ন সাধারণত কলাম ধরে: “জেলাভিত্তিক মোট বিক্রি কত?” — এতে লাগে শুধু district আর price, বাকি কলাম নয়। Parquet একটি binary format, যা ডেটা কলাম ধরে রাখে (columnar storage).

একই টেবিল, ফাইলে দুইভাবে সাজানো

Row-oriented (CSV, JSONL) vs column-oriented (Parquet, Arrow)

Row-oriented বনাম column-oriented রাখা টেবিল (মেমরিতে যেমন ভাবি) id district price 1 ঢাকা 350 2 ঢাকা 1200 3 খুলনা 780 Row-oriented: সারি ধরে পাশাপাশি CSV, JSON Lines 1 ঢাকা 350 2 ঢাকা 1200 3 খুলনা 780 সারি ১ সারি ২ সারি ৩ Column-oriented: কলাম ধরে একসঙ্গে Parquet (ফাইলে), Arrow (মেমরিতে) 1 2 3 ঢাকা ঢাকা খুলনা 350 1200 780 শুধু price চাইলে এটুকুই পড়া হয়
ছবি ১০.১ — একই নয়টি মান, শুধু ক্রম আলাদা। Column-oriented ফাইলে একই type-এর মান পাশাপাশি থাকে বলে শুধু দরকারি কলাম পড়া যায়, আর মানগুলো ভালোভাবে চাপা (compress) যায়।

কলাম ধরে রাখার তিনটি সুবিধা নিচের ছোট টেবিলে ধাপে ধাপে দেখুন। টেবিলে বাজারঘরের চারটি অর্ডার: id, district, qty, price.

টেবিল৪টি অর্ডার, ৪টি কলাম
id  district  qty  price
1   Dhaka     2    350
2   Dhaka     1    1200
3   Khulna    3    780
4   Dhaka     1    95
Row-oriented (CSV)header বাদে ৫৭ byte
1,Dhaka,2,350⏎        14 byte
2,Dhaka,1,1200⏎       15 byte
3,Khulna,3,780⏎       15 byte
4,Dhaka,1,95⏎         13 byte
Column-oriented (Parquet)কলাম ধরে খণ্ড
id:       1 2 3 4               int64
district: Dhaka Dhaka Khulna Dhaka
qty:      2 1 3 1               int64
price:    350 1200 780 95       double
প্রশ্ন: মোট price কত?কত পড়তে হলো
CSV:     ৪টি লাইনের সব ৫৭ byte
         পড়ে, ভেঙে, তারপর ৪র্থ field
Parquet: শুধু price খণ্ড (৪টি মান)
উত্তর:   350+1200+780+95 = 2425
district খণ্ড চাপাdictionary encoding
dictionary:
  0 = Dhaka
  1 = Khulna
codes: 0 0 1 0
"Dhaka" ৩ বারের বদলে একবার,
তারপর শুধু ছোট সংখ্যা
  1. ছক হিসেবে টেবিল। আমরা টেবিলকে সারি আর কলামের ছক হিসেবে ভাবি। কিন্তু ফাইল একটি লম্বা byte-এর সারি — ছকটিকে কোনো এক ক্রমে “এক লাইনে” সাজাতেই হবে।
  2. সারি ধরে। CSV প্রতিটি অর্ডার পুরোটা লেখে, তারপর পরেরটি। চারটি লাইনে মোট ১৪ + ১৫ + ১৫ + ১৩ = ৫৭ byte (header বাদে)। সংখ্যাও এখানে অক্ষর, 1200 মানে চারটি byte.
  3. কলাম ধরে। Parquet একই কলামের সব মান একসঙ্গে রাখে, প্রতিটি খণ্ডের সঙ্গে type লেখা: id আর qty পূর্ণসংখ্যা, price double. পড়ার সময় আর অনুমান লাগে না।
  4. প্রশ্নের খরচ। “মোট price” জানতে CSV-র পুরো ফাইল পড়ে প্রতিটি লাইন ভাঙতে হয়। Parquet শুধু price খণ্ডটি পড়ে — চারটি কলামের একটি। উত্তর ২৪২৫ দুই পথেই এক, কিন্তু বড় ফাইলে পড়ার পরিমাণে বিশাল পার্থক্য।
  5. চাপা যায় ভালো। একই কলামে মান প্রায়ই পুনরাবৃত্ত হয়। Parquet dictionary encoding-এ প্রতিটি আলাদা মান একবার লেখে, তারপর শুধু ছোট code: ০ ০ ১ ০। এর ওপরে snappy বা zstd-এর মতো compression বসে।

Parquet-এর সঙ্গী Apache Arrow. Parquet হলো ডিস্কে রাখার format; Arrow হলো মেমরিতে একই কলাম-ভিত্তিক সাজানোর একটি মান, যাতে pandas, Polars, DuckDB, Spark-এর মতো আলাদা টুল একই ডেটা কপি না করেই ভাগ করতে পারে। Python-এ দুটির কাজই করে pyarrow package; pandas-এর to_parquet আর read_parquet ভেতরে pyarrow ব্যবহার করে।

এবার নিজে চালিয়ে দেখুন। নিচের কোড seed দিয়ে বাজারঘরের ২০,০০০ অর্ডারের একটি টেবিল বানায় (অর্ডারের সময় Asia/Dhaka time zone-সহ), তারপর একই টেবিল চারভাবে লেখে আর আকার তুলনা করে। Pyodide-এ pyarrow আছে, তাই Parquet আপনার ব্রাউজারেই লেখা হয়। একটি সৎ নোট: লেখার সময়ের Pyodide-এ pandas আর pyarrow-এর version-অমিলে df.to_parquet error দেয়, তাই এখানে সরাসরি pyarrow-এর pa.Table.from_pandas আর pq.write_table ব্যবহার করা হয়েছে — এতে Arrow টেবিলটিও চোখে পড়ে। নিজের কম্পিউটারে df.to_parquet(...) ঠিকঠাক চলে (নিচের সত্যিকারের run).

import os
import numpy as np, pandas as pd
import pyarrow as pa, pyarrow.parquet as pq
import tzdata   # IANA time-zone rules (Pyodide needs it for Asia/Dhaka)

rng = np.random.default_rng(42)
N = 20_000
districts = ["Dhaka", "Chattogram", "Khulna", "Rajshahi",
             "Sylhet", "Barishal", "Rangpur", "Mymensingh"]
start = pd.Timestamp("2026-01-01", tz="Asia/Dhaka")
df = pd.DataFrame({
    "order_id": np.arange(1, N + 1),
    "ordered_at": start + pd.to_timedelta(rng.integers(0, 300 * 86400, N), unit="s"),
    "district": pd.Categorical(rng.choice(districts, N)),
    "qty": rng.integers(1, 6, N),
    "price": rng.integers(50, 5000, N).astype("float64"),
})
df.to_csv("o.csv", index=False)
df.to_json("o.jsonl", orient="records", lines=True, date_format="iso")
table = pa.Table.from_pandas(df, preserve_index=False)   # pandas -> Arrow
pq.write_table(table, "o.parquet")                        # snappy by default
pq.write_table(table, "o_zstd.parquet", compression="zstd")
print("pyarrow", pa.__version__, "| rows", N)
for f in ["o.csv", "o.jsonl", "o.parquet", "o_zstd.parquet"]:
    print(f"{f:15}{os.path.getsize(f) / 1024:8.0f} KB")

print("\nCSV line 2  :", open("o.csv").readlines()[1].strip())
print("JSONL line 1:", open("o.jsonl").readline()[:66])
back_csv = pd.read_csv("o.csv")
back_pq = pq.read_table("o.parquet").to_pandas()          # Arrow -> pandas
print("csv  ->", back_csv["ordered_at"].dtype, back_csv["district"].dtype)
print("parq ->", back_pq["ordered_at"].dtype, back_pq["district"].dtype)
print("schema:", pq.read_schema("o.parquet").field("price"))

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

pyarrow 22.0.0 | rows 20000
o.csv               952 KB
o.jsonl            2007 KB
o.parquet           364 KB
o_zstd.parquet      305 KB

CSV line 2  : 1,2026-01-27 18:36:24+06:00,Sylhet,4,4632.0
JSONL line 1: {"order_id":1,"ordered_at":"2026-01-27T12:36:24.000Z","district":"
csv  -> str str
parq -> datetime64[us, Asia/Dhaka] category
schema: pyarrow.Field<price: double>

একই ২০,০০০ সারি: JSONL সবচেয়ে বড়, কারণ প্রতিটি লাইনে কলামের নাম আবার লেখা; CSV তার প্রায় অর্ধেক; Parquet CSV-র প্রায় ৩৮% (৩৬৪ বনাম ৯৫২ KB), আর zstd compression-এ আরও ছোট। আকারের চেয়েও গুরুত্বপূর্ণ শেষ তিনটি লাইন। CSV থেকে ফিরিয়ে পড়লে তারিখ আর জেলা দুটোই সাধারণ লেখা (str) হয়ে গেছে — type হারিয়েছে। Parquet থেকে ফিরেছে আসল datetime64, Asia/Dhaka time zone-সহ, আর category. আর JSONL-এর লাইনটি দেখুন: pandas সময়টিকে UTC-তে (Z) বদলে লিখেছে — ঢাকার ১৮:৩৬ হয়ে গেছে ১২:৩৬Z. মুহূর্তটি একই, কিন্তু “ঢাকার সময়” তথ্যটি ফাইলে আর নেই।

বড় টেবিলে সত্যিকারের তুলনা

ব্রাউজারের ছোট টেবিলে সময় মাপা অর্থহীন, তাই একটি ল্যাপটপে একটি বাড়তি স্ক্রিপ্ট (bench.py) দিয়ে একই ধরনের টেবিল ১০ লাখ সারিতে বানিয়ে মাপা হয়েছে। প্রতিটি পড়া তিনবার চালিয়ে সবচেয়ে কম সময় নেওয়া হয়েছে। সত্যিকারের run, লেখার সময়ের version: Python 3.14.4, pandas 3.1.0, pyarrow 26.0.1; virtual environment uv দিয়ে বানানো (পর্ব ০৭-এ দেখেছেন)।

uv pip install pandas pyarrow openpyxl
python bench.py 1000000
~/bazarghar-ai$ python bench.py 1000000
python 3.14.4  pandas 3.1.0  pyarrow 26.0.1
rows 1000000 cols 7
in-memory MB 49.0
file                        MB  write s   read s
orders.csv                57.0     9.47     0.05
  csv dtypes back: str str
orders.jsonl             139.0     7.21     2.99
orders.parquet            15.8     0.15     0.02
  parquet dtypes back: datetime64[us, Asia/Dhaka] category
orders_zstd.parquet       12.0     0.13     0.02
csv + parse_dates + category: 0.59 s  -> datetime64[us, UTC+06:00]
one column: csv 0.07 s  parquet 0.006 s
row groups 1  columns 7
  order_id      INT64      SNAPPY    4.28 MB
  ordered_at    INT64      SNAPPY    7.63 MB
  district      BYTE_ARRAY SNAPPY    0.38 MB
  product_id    INT64      SNAPPY    1.13 MB
  qty           INT64      SNAPPY    0.38 MB
  price         DOUBLE     SNAPPY    1.65 MB
  delivery_days INT64      SNAPPY    0.38 MB

এই একটি local run থেকে যা পড়া যায় (অন্য মেশিনে সংখ্যা বদলাবে, প্রবণতা সাধারণত একই থাকে):

  • আকার: Parquet (snappy) CSV-র প্রায় ২৮%, zstd-তে প্রায় ২১%. JSONL CSV-র প্রায় আড়াই গুণ।
  • লেখা: CSV লিখতে ৯.৪৭ সেকেন্ড, Parquet-এ ০.১৫ — প্রায় ৬০ গুণ পার্থক্য। CSV-তে প্রতিটি সংখ্যা আর time zone-সহ তারিখকে লেখায় রূপান্তর করতে হয়।
  • পড়া: এখানে একটি সৎ বিস্ময় — pandas-এর CSV reader নিজেও খুব দ্রুত (০.০৫ সেকেন্ড)। কিন্তু তখন তারিখ আর জেলা শুধু লেখা। একই type পেতে (parse_dates, category) লাগল ০.৫৯ সেকেন্ড, আর তখনও zone-এর নাম হারিয়ে শুধু UTC+06:00 offset রইল। Parquet ০.০২ সেকেন্ডে সব type অক্ষতসহ দিল।
  • একটি কলাম: শুধু price পড়তে Parquet-এ ০.০০৬ সেকেন্ড, কারণ বাকি কলাম ছোঁয়াই হয়নি।
  • কোন কলাম কত জায়গা নেয়: district মাত্র ০.৩৮ MB — আটটি আলাদা মান, dictionary encoding-এ চাপা। সবচেয়ে বড় ordered_at, কারণ প্রায় প্রতিটি সময় আলাদা।

Parquet-এর দুর্বলতাও জানা দরকার: নোটপ্যাডে খোলা যায় না; একটি একটি সারি যোগ বা বদলানোর জন্য বানানো নয় (পুরো ফাইল বা অংশ আবার লিখতে হয়); আর খুব ছোট ফাইলে metadata-র বাড়তি খরচ লাভের চেয়ে বেশি হতে পারে। বড় টেবিল পড়ার আরও কৌশল (chunk, dtype কমানো, Polars) আসবে পর্ব ২৭-এ, আর pandas দিয়ে সব format পড়া-লেখার পূর্ণ রূপ পর্ব ২১-এ।

৬ · Excel ফাইল — xlsx আর openpyxl

অফিসের ডেটার বড় অংশ আসে Excel-এর xlsx ফাইলে। xlsx আসলে একটি ZIP ফাইল, যার ভেতরে কয়েকটি XML ফাইল (Office Open XML format) — প্রতিটি sheet, style আর শেয়ার করা লেখার জন্য আলাদা। এতে type থাকে (সংখ্যা, লেখা, তারিখ), একাধিক sheet থাকে, কিন্তু formula, merge করা cell, রং দিয়ে বোঝানো অর্থ — এগুলো ডেটা বিশ্লেষণে প্রায়ই ঝামেলা বাড়ায়। একটি sheet-এ সর্বোচ্চ ১০,৪৮,৫৭৬টি সারি ধরে, তাই বড় ডেটার জন্য xlsx ঠিক জায়গা নয়।

Python-এ xlsx পড়া-লেখার প্রধান package openpyxl; pandas-এর read_excel আর to_excel xlsx-এর জন্য ভেতরে এটিই ব্যবহার করে। openpyxl Pyodide-এ নেই, তাই এটি ল্যাপটপে চালানো হয়েছে। সত্যিকারের run, openpyxl 3.1.5:

import pandas as pd, openpyxl
df = pd.DataFrame({"customer": ["রহিমা", "কামাল"],
                   "phone": ["01700000000", "01800000001"],
                   "ordered_on": pd.to_datetime(["2026-03-05", "2026-11-04"])})
df.to_excel("out/customers.xlsx", index=False, sheet_name="orders")
print("openpyxl", openpyxl.__version__)
back = pd.read_excel("out/customers.xlsx", sheet_name="orders")
print(back)
print(back.dtypes.astype(str).to_dict())
wb = openpyxl.load_workbook("out/customers.xlsx")
ws = wb["orders"]
print([(c.value, c.data_type, c.number_format) for c in ws[2]])
~/bazarghar-ai$ python excel.py
openpyxl 3.1.5
  customer       phone ordered_on
0    রহিমা  1700000000 2026-03-05
1    কামাল  1800000001 2026-11-04
{'customer': 'str', 'phone': 'int64', 'ordered_on': 'datetime64[us]'}
[('রহিমা', 's', 'General'), ('01700000000', 's', 'General'),
 (datetime.datetime(2026, 3, 5, 0, 0), 'd', 'YYYY-MM-DD HH:MM:SS')]

এই ফলটি শিক্ষণীয়। শেষ লাইনে openpyxl দেখাচ্ছে, ফাইলের ভেতরে ফোন নম্বর ঠিকঠাক লেখা ('s' = string) হিসেবেই আছে — শূন্যসহ। তবুও pd.read_excel পড়ার সময় কলামটিকে সংখ্যা বানিয়ে শূন্য ফেলে দিয়েছে, ঠিক CSV-র মতো। প্রতিকারও একই: pd.read_excel(..., dtype={"phone": str}); একই run-এ এটি দিলে ফিরেছে ['01700000000', '01800000001']. শিক্ষা: binary format-এ type থাকলেও, পড়ার টুল নিজের অনুমান চাপাতে পারে — ফল সবসময় যাচাই করুন।

৭ · Encoding — byte থেকে বাংলা লেখা

Python-এ দুটি আলাদা জিনিস আছে: str (লেখা, অর্থাৎ অক্ষরের সারি) আর bytes (০–২৫৫ সংখ্যার সারি)। ফাইল, নেটওয়ার্ক আর ডিস্ক শুধু bytes বোঝে। লেখাকে bytes বানানো হলো encode, আর উল্টোটা decode; কোন নিয়মে বানানো হবে, সেটিই encoding.

প্রথম ধাপ Unicode: পৃথিবীর প্রায় সব লিপির প্রতিটি অক্ষর-চিহ্নকে একটি নম্বর দেয়, যার নাম code point. বাংলা লিপির জন্য বরাদ্দ U+0980 থেকে U+09FF. “দ” হলো U+09A6, আকার-চিহ্ন “া” U+09BE, “ম” U+09AE. লক্ষ করুন, চোখে “দা” একটি অক্ষর মনে হলেও Unicode-এ দুটি code point. দ্বিতীয় ধাপ UTF-8: প্রতিটি code point-কে ১ থেকে ৪ byte-এ লেখার নিয়ম। ইংরেজি অক্ষর (ASCII) ১ byte-এ, আর বাংলার প্রতিটি code point ৩ byte-এ। নিচে “দাম” শব্দটির যাত্রা দেখুন।

লেখা (str)৩টি code point
দ  →  U+09A6
া  →  U+09BE   (আকার-চিহ্ন)
ম  →  U+09AE
UTF-8-এর নিয়মU+0800–U+FFFF → ৩ byte
U+09A6 = 0000 1001 1010 0110
ছাঁচ:  1110xxxx 10xxxxxx 10xxxxxx
       11100000 10100110 10100110
     =   E0       A6       A6
ফাইলে যা যায় (bytes)৯ byte
দ: e0 a6 a6
া: e0 a6 be
ম: e0 a6 ae
৩টি অক্ষর → ৯ byte; "Dhaka" → ৫ byte
ঠিক decodeencoding="utf-8"
e0 a6 a6 | e0 a6 be | e0 a6 ae
   দ          া          ম
→ দাম
ভুল decodeencoding="cp1252"
e0→à  a6→¦  a6→¦  e0→à  a6→¦
be→¾  e0→à  a6→¦  ae→®
→ দাম  (৯টি "অক্ষর")
  1. লেখা। “দাম” Python-এর কাছে তিনটি code point: দ (U+09A6), আকার-চিহ্ন া (U+09BE) আর ম (U+09AE). len("দাম") তাই 3.
  2. UTF-8-এর ছাঁচ। U+0800 থেকে U+FFFF-এর code point তিন byte-এ লেখা হয়। U+09A6-এর ১৬টি bit তিন ভাগে ছাঁচে বসে: প্রথম ৪টি 1110-এর পরে, বাকি দুটি ৬-bit টুকরো 10-এর পরে। ফল E0 A6 A6.
  3. ফাইলে ৯ byte. তিনটি code point × ৩ byte = ৯ byte. ইংরেজি “Dhaka” মাত্র ৫ byte. তাই একই দৈর্ঘ্যের বাংলা লেখা UTF-8-এ ইংরেজির প্রায় তিন গুণ জায়গা নেয় — ডেটার আকার হিসাবের সময় এটি মনে রাখুন।
  4. ঠিক পড়া। পড়ার প্রোগ্রাম UTF-8 জানলে প্রতিটি E0 দেখে বোঝে “এখান থেকে ৩ byte-এর একটি অক্ষর”, আর তিনটি টুকরো জুড়ে আবার “দাম” পায়।
  5. ভুল পড়া। একই ৯ byte যদি Windows-1252 (cp1252) ধরে পড়া হয় — যেখানে প্রতিটি byte একটি করে পশ্চিম ইউরোপীয় অক্ষর — তাহলে আসে ৯টি অক্ষর: “দাম”. Byte একটিও বদলায়নি; বদলেছে শুধু অর্থ করার নিয়ম। এই ভাঙা লেখার নাম mojibake.

“Mojibake” শব্দটি জাপানি, মোটামুটি অর্থ “অক্ষরের রূপ বদলে যাওয়া”। বাংলা UTF-8 লেখা cp1252 বা Latin-1 ধরে পড়লে সবসময় ঠবা à§ দিয়ে শুরু হওয়া চিহ্নের ঝাঁক দেখা যায় — কারণ বাংলার সব code point-এর UTF-8 রূপ শুরু হয় E0 A6 বা E0 A7 দিয়ে। এই চেহারা চিনে রাখুন: “ঔ দেখলেই বুঝবেন, এখানে UTF-8 বাংলা ভুলভাবে পড়া হয়েছে। নিজে চালিয়ে দেখুন:

word = "দাম"
data = word.encode("utf-8")          # text -> bytes
print(len(word), "characters ->", len(data), "bytes")
for ch in word:
    print(" ", ch, f"U+{ord(ch):04X}", "->", ch.encode("utf-8").hex(" "))

print("read as utf-8 :", data.decode("utf-8"))
wrong = data.decode("cp1252")        # the classic mistake
print("read as cp1252:", wrong)

# mojibake can often be undone, if nothing was lost
fixed = wrong.encode("cp1252").decode("utf-8")
print("repaired      :", fixed, fixed == word)

# but some UTF-8 bytes have no cp1252 meaning at all
moon = "চাঁদ".encode("utf-8")
print("\nচাঁদ bytes:", moon.hex(" "))
try:
    moon.decode("cp1252")
except UnicodeDecodeError as e:
    print("UnicodeDecodeError at byte", hex(moon[e.start]))
lost = moon.decode("cp1252", errors="replace")
print("errors='replace':", lost, "-> back:",
      lost.encode("cp1252", errors="replace").decode("utf-8", errors="replace"))
print("bytes for 'Dhaka':", len("Dhaka".encode("utf-8")))

আসল আউটপুট:

3 characters -> 9 bytes
  দ U+09A6 -> e0 a6 a6
  া U+09BE -> e0 a6 be
  ম U+09AE -> e0 a6 ae
read as utf-8 : দাম
read as cp1252: দাম
repaired      : দাম True

চাঁদ bytes: e0 a6 9a e0 a6 be e0 a6 81 e0 a6 a6
UnicodeDecodeError at byte 0x81
errors='replace': চাà¦�দ -> back: চা�?দ
bytes for 'Dhaka': 5

প্রথম অংশ স্টেপারের সঙ্গে হুবহু মেলে: ৩টি অক্ষর, ৯ byte, প্রতিটি e0 a6 দিয়ে শুরু। ভুল decode-এ mojibake এলো, কিন্তু কোনো byte হারায়নি বলে উল্টো পথে (encode("cp1252") তারপর decode("utf-8")) পুরো “দাম” ফিরে এলো — True.

“চাঁদ”-এর চন্দ্রবিন্দুর (U+0981) শেষ byte 0x81, যার cp1252-এ কোনো অর্থই নেই — তাই কড়া decode সরাসরি error দিল। কেউ যদি error এড়াতে errors="replace" দেন, সেই byte-এর জায়গায় বসে � (replacement character), আর ফেরত আনার চেষ্টায় চন্দ্রবিন্দুটি চিরতরে হারায়। শিক্ষা: errors="ignore" বা "replace" দিয়ে error চাপা দেওয়া মানে ডেটা নীরবে নষ্ট করা। আগে সঠিক encoding খুঁজুন।

বাস্তব নিয়ম তিনটি। এক, লেখার সময় সবসময় encoding="utf-8" স্পষ্ট লিখুন; Windows-এ Python-এর পুরোনো version-গুলো open()-এ encoding না দিলে সিস্টেমের codepage ধরে নিত (Python 3.15 থেকে UTF-8 ডিফল্ট হওয়ার কথা, PEP 686) — স্পষ্ট লিখলে প্রশ্নই ওঠে না। দুই, শুরুতে BOM থাকতে পারে এমন ফাইল (Excel বা Notepad থেকে আসা) পড়তে encoding="utf-8-sig" — BOM থাকলে ফেলে দেয়, না থাকলে সাধারণ UTF-8-এর মতোই পড়ে। তিন, encoding অজানা হলে প্রথম কয়েকশো byte open(path, "rb").read(300) দিয়ে দেখুন; e0 a6-এর ঝাঁক মানে UTF-8 বাংলা।

পুরোনো বিজয় (Bijoy) লেখা — সতর্ক একটি নোট: বাংলাদেশে বহু বছর বাংলা লেখা হয়েছে Bijoy-এর মতো পুরোনো, Unicode-এর আগের পদ্ধতিতে, আর অনেক সরকারি নথি, পুরোনো Word ফাইল আর ছাপাখানার কাজে এখনো তা দেখা যায় বলে শোনা যায়। সাধারণভাবে এসব ফাইলে বাংলা অক্ষর আলাদা code point-এ থাকে না; ইংরেজি/Latin অক্ষরের code-এই লেখা থাকে, আর বিশেষ ফন্ট (যেমন SutonnyMJ) সেই code-গুলোর জায়গায় বাংলা আকৃতি আঁকে। ফলে ফন্ট ছাড়া খুললে লেখাটি অর্থহীন ইংরেজি অক্ষরের মতো দেখায়, আর encoding বদলে এটি ঠিক হয় না — দরকার একটি আলাদা রূপান্তর-টুল (Bijoy থেকে Unicode converter), যার ফল সবসময় নিখুঁত নয়। এমন ডেটা AI-এর কাজে আনার আগে কয়েকটি নমুনা মানুষ দিয়ে মিলিয়ে নিন।

৮ · Unicode normalization — “য়”-এর দুই রূপ

Encoding ঠিক থাকলেও বাংলা লেখায় আরেকটি সূক্ষ্ম ফাঁদ আছে। কিছু অক্ষর Unicode-এ দুইভাবে লেখা যায়। “য়” হতে পারে একটি code point U+09DF (BENGALI LETTER YYA), অথবা দুটি: “য” (U+09AF) আর তার নিচে নুক্তা চিহ্ন (U+09BC). স্ক্রিনে দুটি হুবহু এক দেখায়। কিন্তু Python-এর কাছে এরা আলাদা string, তাই == মিথ্যা হয়, খোঁজা ব্যর্থ হয়, আর groupby একই জেলাকে দুই দলে ভাগ করে। একই ব্যাপার “ড়” আর “ঢ়”-এর ক্ষেত্রেও। কে কোন রূপ পাবেন, তা অনেক সময় নির্ভর করে কোন keyboard, ফোন বা রূপান্তর-টুলে লেখা হয়েছে তার ওপর।

এর সমাধান Unicode normalization: সব লেখাকে একটি নির্দিষ্ট “প্রমিত রূপে” আনা। সবচেয়ে প্রচলিত রূপ NFC. নিচের কোডে বাজারঘরের অর্ডারে “ময়মনসিংহ” দুইভাবে টাইপ করা আছে:

import unicodedata as ud
import pandas as pd

one = "\u09df"            # য় as a single code point
two = "\u09af\u09bc"      # য followed by the nukta sign
for s in (one, two):
    print(s, len(s), [f"U+{ord(c):04X}" for c in s])
print("equal?", one == two)

# the same district typed on two different keyboards
city_a = "ম" + one + "মনসিংহ"
city_b = "ম" + two + "মনসিংহ"
orders = pd.DataFrame({"district": [city_a, city_b, city_b, "ঢাকা"],
                       "amount": [500, 300, 200, 900]})
print(orders.groupby("district")["amount"].sum())

nfc = lambda s: ud.normalize("NFC", s)
print("\nNFC of one:", [f"U+{ord(c):04X}" for c in nfc(one)])
print("NFC of two:", [f"U+{ord(c):04X}" for c in nfc(two)])
orders["district"] = orders["district"].map(nfc)
print(orders.groupby("district")["amount"].sum())
print("search 'ময়মনসিংহ' finds:",
      (orders["district"] == nfc("ম\u09dfমনসিংহ")).sum(), "rows")

আসল আউটপুট:

য় 1 ['U+09DF']
য় 2 ['U+09AF', 'U+09BC']
equal? False
district
ঢাকা         900
ময়মনসিংহ    500
ময়মনসিংহ     500
Name: amount, dtype: int64

NFC of one: ['U+09AF', 'U+09BC']
NFC of two: ['U+09AF', 'U+09BC']
district
ঢাকা          900
ময়মনসিংহ    1000
Name: amount, dtype: int64
search 'ময়মনসিংহ' finds: 3 rows

দুটি “য়” দেখতে এক, কিন্তু দৈর্ঘ্য ১ আর ২, আর equal? হলো False. প্রথম groupby-তে “ময়মনসিংহ” দুইবার এসেছে — ৫০০ আর ৫০০ — যেন দুটি আলাদা জেলা! কোনো dashboard-এ এটি নীরবে ভুল সংখ্যা দেখাত।

এবার একটি অপ্রত্যাশিত তথ্য: NFC-তে দুই রূপই হয়ে গেছে দুই code point-এর রূপ (U+09AF U+09BC), এক code point-এর নয়। কারণ Unicode-এর নিয়মে U+09DF (আর “ড়”, “ঢ়”-এর এক-code-point রূপও) “composition exclusion” তালিকায় আছে: normalization এদের ভেঙে দুই অংশে রাখে, কখনো জোড়া লাগায় না। কোন রূপে এলো তার চেয়ে জরুরি হলো, সব লেখা একই নিয়মে normalize করা। তারপর groupby একটি দল দিয়েছে (১০০০), আর খোঁজার লেখাটিও normalize করায় ৩টি সারিই পাওয়া গেছে।

মূল ধারণা

বাংলা লেখার ডেটা পড়ার পর প্রথম পরিষ্কারের কাজ: প্রতিটি লেখার কলামে unicodedata.normalize("NFC", s) (pandas-এ df[col].str.normalize("NFC")), আর খোঁজা বা তুলনার লেখাতেও একই কাজ। Normalization বানান ঠিক করে না — “ময়মনসিংহ” আর “ময়মনসিং” আলাদাই থাকবে; এটি শুধু একই অক্ষরের ভিন্ন byte-রূপ এক করে। বাংলা লেখা পরিষ্কারের বাকি কাজ আসবে পর্ব ২৩ আর পর্ব ৬৭-এ।

৯ · তারিখ, আকারের হিসাব আর format বাছাই

তারিখ আর time zone

“০৫/০৩/২০২৬” — এটি ৫ মার্চ, নাকি ৩ মে? বাংলাদেশে সাধারণত দিন আগে লেখা হয়, যুক্তরাষ্ট্রে মাস আগে। ফাইলে তারিখ লেখার নিরাপদ নিয়ম ISO 8601: বড় একক থেকে ছোট, 2026-03-05; সময়সহ 2026-03-05T18:36:24+06:00. লেখা হিসেবে সাজালেও ক্রম ঠিক থাকে।

শেষের +06:00 হলো UTC থেকে পার্থক্য (offset). বাংলাদেশের time zone-এর নাম IANA tz database-এ Asia/Dhaka, এখন UTC+06:00. Offset আর zone-এর নাম এক জিনিস নয়: zone-এর নাম ইতিহাসও বহন করে। যেমন, বাংলাদেশ ২০০৯ সালের মাঝামাঝি থেকে বছরের শেষ পর্যন্ত কয়েক মাস daylight saving time চালু করেছিল, তখন ঘড়ি এক ঘণ্টা এগোনো ছিল; Asia/Dhaka জানে সেই সময়ের offset ছিল +07:00. শুধু “+06:00” লিখে রাখলে এই তথ্য থাকে না। অংশ ৫-এর run-এ ঠিক এটিই দেখা গেছে: CSV থেকে পড়া তারিখে শুধু UTC+06:00, Parquet-এ পুরো Asia/Dhaka.

ব্যবহারিক নিয়ম: সার্ভার আর log-এ সময় রাখুন UTC-তে (ISO 8601, Z বা +00:00-সহ), আর মানুষকে দেখানোর বা “ঢাকার সকাল ৯টার অর্ডার” জাতীয় বিশ্লেষণের সময় Asia/Dhaka-তে রূপান্তর করুন। কখনো offset ছাড়া সময় (naive time) একাধিক উৎস থেকে মেশাবেন না — কোনটি ঢাকার সময় আর কোনটি UTC, পরে আর বোঝা যাবে না। Time series-এর কাজ বিস্তারিত আসবে পর্ব ২৬-এ।

ডেটার আকার আগে থেকে হিসাব

একটি ফাইল খোলার আগেই মোটামুটি বলা যায় মেমরিতে কত জায়গা লাগবে:

মেমরি ≈ সারির সংখ্যা × কলামের সংখ্যা × প্রতি মানের byte

এখানে প্রতি মানের byte নির্ভর করে type-এর ওপর: int64 আর float64 ৮ byte, int8 ১ byte, category-র code সাধারণত ১–২ byte; লেখার কলামে প্রতিটি মানের নিজের দৈর্ঘ্য আর বাড়তি খরচ। কষে দেখা উদাহরণ: অংশ ৫-এর টেবিলে ১০ লাখ সারি; ৬টি কলাম ৮-byte-এর (order_id, ordered_at, product_id, qty, price, delivery_days) আর ১টি category (৮টি জেলা, code ১ byte). হিসাব: ১০,০০,০০০ × ৬ × ৮ = ৪,৮০,০০,০০০ byte = ৪৮ MB, আর জেলার code ১০,০০,০০০ × ১ = ১ MB; মোট ≈ ৪৯ MB. Local run-এর মাপ: in-memory MB 49.0 — হুবহু মিলে গেছে।

CSV-র আকার অনুমানে দরকার গড় লাইনের দৈর্ঘ্য: ৫৭ MB ÷ ১০ লাখ লাইন ≈ ৫৭ byte প্রতি লাইন। বাংলা লেখার কলামে মনে রাখুন, প্রতিটি বাংলা code point UTF-8-এ ৩ byte: গড়ে ৪০ code point-এর একটি রিভিউ ≈ ১২০ byte, ১০ লাখ রিভিউ ≈ ১২০ MB শুধু লেখাতেই। আর pandas-এ পড়া ডেটা সাধারণত ফাইলের চেয়ে বেশি মেমরি নেয়, তাই ল্যাপটপের RAM-এর কাছাকাছি আকারের ফাইল একবারে খোলার আগে ভাবুন।

কোন কাজে কোন format

কাজভালো পছন্দকারণ
অন্য দল বা অফিসের সঙ্গে ছোট টেবিল আদান-প্রদানCSV (UTF-8, Excel-এর জন্য utf-8-sig)সবাই খুলতে পারে; সঙ্গে কলামের type লিখে দিন
Log, event, API-র উত্তর জমানোJSON Linesশেষে লাইন জোড়া যায়, nested ডেটা ধরে
ML-এর জন্য লেবেল দেওয়া লেখা বা প্রশ্ন–উত্তরJSON Linesপ্রতিটি উদাহরণ এক লাইনে, স্ট্রিম করে পড়া যায়
বড় টেবিল, বারবার বিশ্লেষণ, feature টেবিলParquetছোট, দ্রুত, type অক্ষত, শুধু দরকারি কলাম
টুলের মধ্যে মেমরিতে ডেটা ভাগ করাArrowকপি ছাড়াই একই কলাম-বিন্যাস
মানুষ হাতে দেখবেন বা সংশোধন করবেনxlsx (ছোট ডেটা)পরিচিত; কিন্তু ফল যাচাই জরুরি

আরও গভীরে: Parquet-এর নকশা এসেছে Google-এর ২০১০ সালের Dremel গবেষণাপত্র (Melnik ও সহকর্মী) থেকে, যেখানে nested record-কে কলামে ভাঙার একটি পদ্ধতি দেখানো হয়; ২০১৩ সালে Twitter আর Cloudera-র প্রকৌশলীরা Parquet শুরু করেন, পরে এটি Apache প্রকল্প হয়। একটি Parquet ফাইল কয়েকটি row group-এ ভাগ, প্রতিটিতে কলামভিত্তিক খণ্ড, আর ফাইলের শেষে (footer) schema ও প্রতিটি খণ্ডের min/max পরিসংখ্যান থাকে — তাই “শুধু নভেম্বরের অর্ডার” চাইলে টুল পুরো row group না পড়েই বাদ দিতে পারে। Unicode normalization-এর পূর্ণ নিয়ম আছে Unicode Standard Annex #15-এ, আর UTF-8-এর বর্তমান সংজ্ঞা RFC 3629 (২০০৩)-এ।

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

  • CSV আর Parquet-এর মূল পার্থক্য? — CSV সারি ধরে লেখা text, type নেই; Parquet কলাম ধরে রাখা binary, schema আর compression-সহ।
  • ফোন নম্বরের শুরুর শূন্য হারালে কী করবেন? — পড়ার সময় কলামটিকে লেখা হিসেবে চাইবেন: dtype={"phone": str}.
  • “দাম” UTF-8-এ কত byte? — ৩টি code point × ৩ byte = ৯ byte.
  • Mojibake কেন হয়? — এক encoding-এ লেখা byte অন্য encoding ধরে পড়লে; byte ঠিক থাকে, অর্থ ভুল হয়।
  • দুটি দেখতে-এক বাংলা শব্দ ==-এ মিথ্যা কেন? — একই অক্ষরের ভিন্ন code point-রূপ; তুলনার আগে NFC normalization দরকার।

১০ · AI-এর যুগে — ফাইল পড়ার কোডে AI সহকারী

CSV পড়ার কোড AI সহকারীর কাছে সবচেয়ে সাধারণ অনুরোধগুলোর একটি। বিপদ হলো, কোড “চলে”, কিন্তু ডেটা নীরবে বদলে যায়। AI সহকারী প্রায়ই যে ভুলগুলো করে:

AI সহকারীর ভুলকীভাবে ধরবেন
dtype না দিয়ে read_csv — ফোন নম্বর আর ID-র শূন্য হারায়পড়ার পর df.dtypes আর কয়েকটি সারি দেখা
UnicodeDecodeError এলে errors="ignore" বা encoding="latin-1" বসিয়ে error “সারানো”error-এর byte দেখে আসল encoding খোঁজা; বাংলা লেখা চোখে মিলিয়ে দেখা
পুরোনো বা বানানো API — যেমন read_csv-এর পুরোনো parameter বা অস্তিত্বহীন optionনিজের pandas version-এর অফিসিয়াল docs-এ parameter খোঁজা, চালিয়ে দেখা
Time zone উপেক্ষা — naive আর UTC সময় মিশিয়ে ফেলাতারিখ কলামের dtype-এ zone আছে কি না দেখা
Parquet বা Excel-এর জন্য দরকারি package (pyarrow, openpyxl) উল্লেখ না করাপরিষ্কার environment-এ চালিয়ে দেখা
বাংলা লেখা normalize না করে groupby বা মেলানোআলাদা মানের সংখ্যা (nunique) normalization-এর আগে-পরে তুলনা

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

আমি pandas 3.x দিয়ে একটি CSV পড়ব। কলাম: order_id, customer (বাংলা নাম), phone (যেমন 01700000000, শুরুর শূন্যসহ রাখতে হবে), address (ভেতরে কমা থাকতে পারে), ordered_at (Asia/Dhaka সময়)। ফাইলটি Excel থেকে সেভ করা, শুরুতে BOM থাকতে পারে। ১. ফাইলটি নিরাপদে পড়ার কোড দাও: dtype, encoding আর তারিখ পার্স স্পষ্ট করে। ২. পড়ার পর কী কী যাচাই করব (dtypes, সারির সংখ্যা, ফোনের দৈর্ঘ্য, বাংলা লেখা NFC), তার কোড দাও। ৩. শুধু বর্তমান pandas-এর API ব্যবহার করো, আর প্রতিটি parameter অফিসিয়াল docs-এর কোথায় আছে বলো। ৪. errors="ignore" ব্যবহার করবে না; কোনো কিছু নিয়ে নিশ্চিত না হলে "নিশ্চিত নই" লেখো।

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

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

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

প্র ০১RFC 4180 থাকার পরও কেন বলা হয় “CSV কোনো standard নয়”? এতে AI-এর কাজে কী ঝুঁকি?

CSV-এর জন্ম কোনো কমিটিতে হয়নি। কমা বা অন্য চিহ্ন দিয়ে আলাদা করা ডেটা কম্পিউটারে বহু দশক ধরে চলছে, আর প্রতিটি প্রোগ্রাম নিজের মতো নিয়ম বানিয়েছে: কেউ quote ব্যবহার করে, কেউ backslash দিয়ে escape করে, কেউ লাইন শেষে CRLF লেখে, কেউ শুধু LF. যেসব দেশে দশমিকের জন্য কমা ব্যবহার হয় (যেমন অনেক ইউরোপীয় দেশে “3,5” মানে সাড়ে তিন), সেখানে স্প্রেডশিট প্রোগ্রাম প্রায়ই সেমিকোলন দিয়ে field আলাদা করে।

২০০৫ সালে প্রকাশিত RFC 4180 এই বিশৃঙ্খলার মধ্যে একটি সাধারণ ভিত্তি লিখে রাখে আর text/csv MIME type নথিভুক্ত করে। কিন্তু এটি “Informational” শ্রেণির RFC — এটি জানায় বেশিরভাগ প্রোগ্রাম কী করে বলে মনে হয়, কাউকে মানতে বাধ্য করে না। এতে encoding-এর কথাও প্রায় নেই, অথচ বাংলা ডেটার জন্য সেটিই সবচেয়ে জরুরি প্রশ্ন। পরে W3C-র “CSV on the Web” উদ্যোগ (২০১৫ সালের দিকে সুপারিশ প্রকাশ) CSV-এর সঙ্গে আলাদা একটি metadata ফাইলে কলামের type আর অর্থ লেখার পদ্ধতি প্রস্তাব করে; তবে বাস্তবে এর ব্যবহার সীমিত বলেই মনে হয়। Python-এর csv module-এও তাই “dialect” ধারণা আছে, আর csv.Sniffer ফাইল দেখে delimiter অনুমানের চেষ্টা করে — নামেই বোঝা যায়, এটি অনুমান।

AI-এর কাজে ঝুঁকিটা নীরব। ভুল delimiter-এ পড়লে পুরো টেবিল একটি কলামে আসে — এটি সহজে চোখে পড়ে। কিন্তু quote-এর নিয়ম না মানা একটি ফাইলে কয়েকটি সারির field এক ঘর সরে গেলে, “দাম” কলামে ঠিকানার অংশ ঢোকে, model শেখে আবর্জনা থেকে, আর কেউ টের পায় না। তাই অন্য দলের কাছ থেকে CSV নিলে লিখিত চুক্তি করা ভালো: delimiter, quote, encoding, header, প্রতিটি কলামের type আর তারিখের রূপ। আর পড়ার পর সারির সংখ্যা, প্রতি সারির field-সংখ্যা আর কয়েকটি এলোমেলো সারি যাচাই করা।

মূল উপলব্ধি: CSV একটি ভাষা নয়, অনেক উপভাষার পরিবার। কোন উপভাষা, সেটি জেনে নিলে CSV নিরাপদ; অনুমান করলে নয়।

প্র ০২জিনের নাম তারিখ হয়ে যাওয়ার ঘটনা কী শেখায়? বাংলাদেশের অফিসের ডেটায় এর সমতুল্য কী হতে পারে?

জীববিজ্ঞানে কিছু মানব-জিনের সংক্ষিপ্ত নাম ছিল তারিখের মতো — যেমন SEPT1 বা MARCH1. গবেষকেরা এই তালিকা Excel-এ খুললে Excel সেগুলোকে তারিখ ভেবে “1-Sep” বা “1-Mar” বানিয়ে দিত। ২০১৬ সালে Ziemann, Eren ও El-Osta Genome Biology-তে একটি সমীক্ষা প্রকাশ করেন: প্রকাশিত গবেষণাপত্রের সঙ্গে দেওয়া Excel-এর জিন-তালিকার একটি উল্লেখযোগ্য অংশে (তাঁদের হিসাবে প্রায় এক-পঞ্চমাংশ) এমন ভুল পাওয়া যায়। ২০২১ সালের একটি অনুবর্তী গবেষণায় (Ziemann-ও যার লেখকদের একজন) দেখা যায়, সমস্যাটি তখনো থেকে গেছে। শেষে ২০২০ সালের দিকে মানব-জিনের নাম ঠিক করার কমিটি (HGNC) কয়েকটি জিনের নামই বদলে দেয় — SEPT1 হয় SEPTIN1, MARCH1 হয় MARCHF1 — যাতে স্প্রেডশিট আর ভুল না করে।

ঘটনাটির শিক্ষা কয়েকটি। এক, “সাহায্যকারী” স্বয়ংক্রিয় রূপান্তর সবচেয়ে বিপজ্জনক, কারণ এটি কোনো error দেয় না। দুই, ভুল একবার সেভ হলে তা পরের সব বিশ্লেষণে ছড়ায় — অন্য গবেষক সেই ফাইল থেকেই কাজ শুরু করেন।

বাংলাদেশের অফিসের ডেটায় এর সমতুল্য সহজেই কল্পনা করা যায়। মোবাইল নম্বরের শুরুর শূন্য হারানো সবচেয়ে চেনা। লম্বা পরিচয় নম্বর বা ট্র্যাকিং নম্বর, যা ১৫ অঙ্কের বেশি, Excel-এ শেষের অঙ্কগুলো হারাতে পারে — এবং সেই ভুল আর ফেরানো যায় না। “১-২” লেখা কোনো কক্ষ নম্বর তারিখ হয়ে যেতে পারে। আর দিন-মাসের ক্রম নিয়ে বিভ্রান্তি — ০৫/০৩ কি ৫ মার্চ না ৩ মে — বিদেশি সফটওয়্যারে প্রায়ই ঘটে।

প্রতিকার: পরিচয় নম্বর জাতীয় কলাম সবসময় লেখা হিসেবে রাখা; ডেটা আদান-প্রদানের মূল কপি কখনো Excel-এ খুলে সেভ না করা; Python-এ dtype=str দিয়ে পড়া; আর পড়ার পর দৈর্ঘ্য যাচাই (সব ফোন নম্বর ১১ অঙ্ক কি না)।

মূল উপলব্ধি: যে টুল নিজে থেকে ডেটা “ঠিক” করে, সে ভুলও নিজে থেকে করে — আর কিছু বলে না। পরিচয়বাহী মান কখনো সংখ্যা নয়, লেখা।

প্র ০৩বিশ্লেষণের জগতে columnar format কেন জিতল? Row-oriented রাখার কি এখনো জায়গা আছে?

ডেটাবেসের জগতে দুই ধরনের কাজ আলাদা: লেনদেন (OLTP) — “এই অর্ডারটি যোগ করো, এই গ্রাহকের ঠিকানা বদলাও” — আর বিশ্লেষণ (OLAP) — “গত বছর জেলাভিত্তিক গড় বিক্রি কত?” প্রথমটিতে একটি সারির সব কলাম একসঙ্গে দরকার, তাই সারি ধরে রাখা স্বাভাবিক; সাধারণ relational ডেটাবেসগুলো মূলত এভাবেই কাজ করে। দ্বিতীয়টিতে লাখো সারির মাত্র দু-তিনটি কলাম দরকার — তখন সারি ধরে রাখা মানে অপ্রয়োজনীয় ডেটা ডিস্ক থেকে টেনে আনা।

কলাম-ভিত্তিক ডেটাবেসের ধারণা পুরোনো। ২০১০ সালে Google-এর Dremel গবেষণাপত্র দেখায়, nested record-কেও কলামে ভাঙা যায়। এর অনুপ্রেরণায় ২০১৩ সালে Twitter আর Cloudera-র প্রকৌশলীরা Parquet শুরু করেন; প্রায় একই সময়ে Hadoop জগতে ORC নামে আরেকটি columnar format আসে। ২০১৬ সালে Apache Arrow প্রকল্প শুরু হয় — এর সহ-প্রতিষ্ঠাতাদের মধ্যে ছিলেন pandas-এর স্রষ্টা Wes McKinney — যার লক্ষ্য মেমরিতে একটি সাধারণ কলাম-বিন্যাস, যাতে আলাদা টুল ডেটা কপি বা রূপান্তর ছাড়াই ভাগ করতে পারে।

কলাম ধরে রাখার জয়ের কারণ তিনটি: দরকারি কলামটুকুই পড়া; একই type-এর মান পাশাপাশি থাকায় compression অনেক ভালো (এই পর্বের run-এ আটটি জেলার কলাম ১০ লাখ সারিতে মাত্র ০.৩৮ MB); আর আধুনিক CPU একই ধরনের অনেক সংখ্যা একসঙ্গে প্রক্রিয়া করতে পারে (vectorization, পর্ব ১৫), যা কলাম-বিন্যাসে সহজ।

তবু row-oriented রাখার জায়গা আছে। একটি একটি সারি যোগ করা — log বা streaming event — JSON Lines-এ অনেক সহজ; Parquet ফাইলে একটি সারি যোগ করতে নতুন ফাইল বা row group লিখতে হয়। বারবার বদলানো ডেটা ডেটাবেসেই ভালো। আর মানুষের পড়া, ছোট আদান-প্রদান আর Git-এ পরিবর্তন দেখার জন্য CSV এখনো অপরাজেয়। বাস্তবে অনেক দল দুটোই ব্যবহার করে: ঘটনা আসে JSONL-এ, রাতে জমিয়ে Parquet-এ রূপান্তর করা হয় বিশ্লেষণের জন্য।

মূল উপলব্ধি: ডেটা কীভাবে রাখবেন, তা ঠিক করে ডেটা কীভাবে ব্যবহার হবে। লেখা বেশি হলে সারি, পড়া আর বিশ্লেষণ বেশি হলে কলাম।

প্র ০৪বাংলা লেখা কম্পিউটারে কীভাবে এলো, আর সেই ইতিহাস আজকের AI ডেটায় কী রেখে গেছে?

কম্পিউটারের প্রথম যুগের encoding — ASCII — ছিল মাত্র ১২৮টি চিহ্নের, শুধু ইংরেজির জন্য। বাংলা লেখার প্রথম ব্যবস্থাগুলো তাই একটি কৌশল নিয়েছিল: ইংরেজি অক্ষরের code-ই রাখা, কিন্তু একটি বিশেষ ফন্ট দিয়ে সেগুলোর জায়গায় বাংলা আকৃতি আঁকা। বাংলাদেশে এর সবচেয়ে পরিচিত রূপ বিজয় কিবোর্ড ও সফটওয়্যার, যা ১৯৮০-এর দশকের শেষ দিকে আসে বলে জানা যায়, আর কয়েক দশক ধরে প্রকাশনা ও অফিসের কাজে ব্যাপকভাবে ব্যবহৃত হয়। ভারতে এর সমান্তরালে ISCII নামে একটি মান তৈরি হয়।

Unicode-এর প্রথম version (১৯৯১) থেকেই বাংলা লিপির জন্য আলাদা জায়গা ছিল, ISCII-এর বিন্যাস অনুসরণ করে। কিন্তু অপারেটিং সিস্টেমে বাংলা যুক্তাক্ষর ঠিকমতো দেখানোর ব্যবস্থা আর সহজে Unicode বাংলা টাইপ করার টুল আসতে আরও কয়েক বছর লেগেছে। ২০০০-এর দশকে অভ্রর মতো বিনামূল্যের Unicode কিবোর্ড জনপ্রিয় হলে ওয়েবে Unicode বাংলা দ্রুত ছড়ায় বলে সাধারণভাবে মনে করা হয়। তবু পুরোনো non-Unicode নথি আজও প্রচুর।

এই ইতিহাস AI ডেটায় কয়েকটি দাগ রেখে গেছে। প্রথমত, পুরোনো Bijoy-লেখা নথি ফন্ট ছাড়া অর্থহীন Latin অক্ষর দেখায়; কেউ যদি এমন লেখা না বুঝে model-এর training data-তে ঢোকান, সেটি বাংলা হিসেবে গণ্যই হবে না। দ্বিতীয়ত, একই অক্ষরের একাধিক Unicode রূপ — “য়”, “ড়”, “ঢ়”-এর এক বা দুই code point, আর খণ্ড-ত (ৎ), যার নিজস্ব code point (U+09CE) Unicode 4.1-এ (২০০৫) যোগ হওয়ার আগে অন্যভাবে (ত + হসন্ত + একটি অদৃশ্য যোজক চিহ্ন) লেখা হতো। ফলে পুরোনো আর নতুন লেখায় একই শব্দ ভিন্ন byte-এ থাকতে পারে। NFC খণ্ড-ত-এর পুরোনো রূপ ঠিক করে না, তাই এর জন্য আলাদা পরিষ্কারের নিয়ম লাগতে পারে।

তাই বাংলা লেখার ডেটাসেট বানাতে প্রথম কাজ: উৎস চেনা (Unicode না পুরোনো encoding), সব লেখা একই normalization-এ আনা, আর কয়েকটি নমুনা মানুষ দিয়ে পড়িয়ে যাচাই।

মূল উপলব্ধি: বাংলা লেখার প্রতিটি ফাইল তার ইতিহাস বহন করে। কোন যুগের, কোন টুলের লেখা — সেটি না জেনে model-কে দিলে model শিখবে টুলের ছাপ, ভাষা নয়।

প্র ০৫তারিখ আর সময় রাখা এত কঠিন কেন? “শুধু UTC-তে রাখুন” কি সব সমস্যার সমাধান?

সময় শুনতে সরল, কিন্তু তার পেছনে মানুষের তৈরি নিয়মের পাহাড়। ঘড়ির সময় দেশে দেশে আলাদা, আর দেশগুলো নিয়ম বদলায়। বাংলাদেশ নিজেই উদাহরণ: ২০০৯ সালের জুন থেকে ডিসেম্বর পর্যন্ত ঘড়ি এক ঘণ্টা এগিয়ে daylight saving time চালু ছিল, তারপর আবার আগের অবস্থায় ফেরে। তাই “২০০৯ সালের আগস্টে ঢাকার সকাল ৯টা” আর “২০২৬ সালের আগস্টে ঢাকার সকাল ৯টা” UTC-তে আলাদা মুহূর্তে পড়ে। এসব নিয়মের ইতিহাস রাখে IANA time zone database (একসময় এর মূল রক্ষণাবেক্ষণকারীর নামে “Olson database” বলা হতো), যা নিয়মিত হালনাগাদ হয়; Asia/Dhaka নামটি সেখান থেকেই।

স্প্রেডশিটের নিজস্ব অদ্ভুততাও আছে। Excel তারিখকে একটি ক্রমিক সংখ্যা হিসেবে রাখে, আর পুরোনো Lotus 1-2-3-এর সঙ্গে মিল রাখতে ১৯০০ সালকে ভুলভাবে অধিবর্ষ ধরে — অস্তিত্বহীন “২৯ ফেব্রুয়ারি ১৯০০” Excel-এ আছে। তাই Excel থেকে আসা সংখ্যা-রূপের তারিখ রূপান্তরে সতর্ক হতে হয়।

“শুধু UTC-তে রাখুন” — একটি ভালো ভিত্তি, বিশেষত log আর ঘটনার সময়ের জন্য: UTC-তে daylight saving নেই, সব সার্ভার একমত, আর তুলনা ও বিয়োগ সহজ। কিন্তু এটি সব সমাধান করে না। প্রথমত, ভবিষ্যতের স্থানীয় ঘটনা — “আগামী বছর ঈদের পরদিন সকাল ১০টায় দোকান খুলবে” — UTC-তে আগে থেকে রূপান্তর করলে, মাঝে দেশের নিয়ম বদলালে ভুল হবে; এমন ক্ষেত্রে স্থানীয় সময় আর zone-এর নাম রাখাই ঠিক। দ্বিতীয়ত, বিশ্লেষণে স্থানীয় প্রেক্ষাপট দরকার: “সন্ধ্যার কেনাকাটা” মানে ঢাকার সন্ধ্যা, UTC-র নয়। তৃতীয়ত, শুধু তারিখ — যেমন জন্মদিন — কোনো মুহূর্ত নয়; একে UTC-র মধ্যরাতে রূপান্তর করলে অন্য zone-এ দিন বদলে যেতে পারে।

তাই পরিণত নিয়ম: ঘটে যাওয়া মুহূর্ত UTC-তে (ISO 8601, offset-সহ), ভবিষ্যতের স্থানীয় পরিকল্পনা স্থানীয় সময় + zone-এর নামে, আর শুধু-তারিখ শুধু-তারিখ হিসেবে। Parquet zone-সহ সময় রাখতে পারে; CSV আর JSON-এ লেখার রূপটি নিজেকেই ঠিক করতে হয়।

মূল উপলব্ধি: সময় একটি সংখ্যা নয়, একটি সংখ্যা আর একটি নিয়মের জোড়া। নিয়মটি (zone) হারালে সংখ্যাটি অর্ধেক অর্থ হারায়।

অনুশীলন

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

  1. হিসাব করুন: বাজারঘরের একটি feature টেবিলে ৫০ লাখ সারি আর ২০টি float64 কলাম, সঙ্গে একটি বাংলা রিভিউ কলাম, যার গড় দৈর্ঘ্য ৫০ code point. (ক) সংখ্যার কলামগুলো মেমরিতে কত MB নেবে? (খ) রিভিউর লেখা UTF-8-এ মোটামুটি কত MB? (গ) সংখ্যার কলামগুলো float32-এ নিলে কত বাঁচবে?

    (ক) ৫০,০০,০০০ × ২০ × ৮ byte = ৮০,০০,০০,০০০ byte = ৮০০ MB.

    (খ) প্রতি রিভিউ ৫০ × ৩ = ১৫০ byte; ৫০,০০,০০০ × ১৫০ = ৭৫,০০,০০,০০০ byte ≈ ৭৫০ MB, শুধু লেখার byte. মেমরিতে প্রতিটি string-এর বাড়তি খরচ যোগ হয়ে আসল সংখ্যা আরও বেশি হতে পারে।

    (গ) float32 ৪ byte, তাই ৫০,০০,০০০ × ২০ × ৪ = ৪০০ MB — অর্ধেক, ৪০০ MB বাঁচে। তবে float32-এ প্রায় ৭টি significant অঙ্ক থাকে; টাকার হিসাব বা বড় ID-র জন্য তা যথেষ্ট কি না আগে ভাবুন। (dtype কমানোর কৌশল পর্ব ১৯ ও পর্ব ২৭-এ।)

  2. আগাম বলুন (predict the output): নিচের কোড চালানোর আগে লিখে ফেলুন কী ছাপা হবে, তারপর উপরের যেকোনো কোড-বাক্সে পেস্ট করে মিলিয়ে নিন।
    import csv, io, unicodedata as ud
    line = '7,"Sylhet, Zindabazar","say ""hi""",01900000002\n'
    row = next(csv.reader(io.StringIO(line)))
    print(len(row), row[1], row[2], len(row[3]))
    print(len("চা".encode("utf-8")), "\u09af\u09bc" == ud.normalize("NFC", "\u09df"))

    Quote-এর ভেতরের কমা field ভাঙে না, আর "" একটি " হয়। তাই ৪টি field: 7, Sylhet, Zindabazar, say "hi", 01900000002 (csv module সব field লেখা হিসেবে দেয়, তাই শূন্যসহ ১১টি অক্ষর)। দ্বিতীয় লাইনে “চা” = দুটি code point × ৩ byte = 6; আর U+09DF-এর NFC রূপ দুই code point-এর “য” + নুক্তা, তাই তুলনা True.

    4 Sylhet, Zindabazar say "hi" 11
    6 True
  3. কোড লিখুন: একটি function load_orders(path) লিখুন যা বাজারঘরের CSV পড়বে: BOM থাকলে সামলাবে, phone লেখা হিসেবে রাখবে, customer আর address NFC-তে normalize করবে, আর শেষে যাচাই করবে সব ফোন নম্বর ১১ অঙ্কের এবং 0 দিয়ে শুরু। পাতার orders_messy.csv দিয়ে চালিয়ে দেখুন।

    একটি সম্ভাব্য সমাধান (pandas BOM নিজেই সরায়, তবু utf-8-sig স্পষ্ট লেখা নিরাপদ, কারণ পরে কেউ এটি csv module-এ বদলাতে পারেন):

    import pandas as pd
    
    def load_orders(path):
        df = pd.read_csv(path, encoding="utf-8-sig", dtype={"phone": str})
        for col in ["customer", "address"]:
            df[col] = df[col].str.normalize("NFC")
        ok = df["phone"].str.fullmatch(r"0\d{10}")
        if not ok.all():
            raise ValueError(f"bad phone in rows: {df.index[~ok].tolist()}")
        return df
    
    df = load_orders("orders_messy.csv")
    print(df.shape, df["phone"].str.len().unique())

    এই ফাইলে ৪টি সারি আর ৬টি কলাম, সব ফোন ১১ অঙ্কের, তাই ছাপা হওয়ার কথা (4, 6) [11]. খেয়াল করুন, যাচাই ব্যর্থ হলে function চুপচাপ খারাপ সারি ফেলে দেয় না — error দিয়ে থামে, কোন সারি সমস্যার তা বলে।

  4. নকশা-সমালোচনা: বাজারঘরের একজন সহকর্মী প্রস্তাব দিলেন: “প্রতিদিনের সব অর্ডার, ওয়েবসাইটের click-log আর গ্রাহকের রিভিউ একটি বড় Excel ফাইলে জমাব — একেক দিনের জন্য একেকটি sheet. Model শেখানোর সময় পুরো ফাইল read_excel দিয়ে পড়ব।” এই নকশার সমস্যাগুলো কী, আর কোন বিকল্প প্রস্তাব করবেন?
    • আকারের সীমা: একটি sheet-এ সর্বোচ্চ ১০,৪৮,৫৭৬ সারি; click-log দ্রুত তা ছাড়াবে। xlsx পড়াও ধীর, কারণ ভেতরের XML খুলে পার্স করতে হয়।
    • Type-এর ঝুঁকি: ফোন নম্বর, তারিখ আর লম্বা ID Excel-এ খোলা-সেভের সময় বদলে যেতে পারে; read_excel নিজেও ফোনের শূন্য ফেলে দেয় (অংশ ৬-এর run).
    • একসঙ্গে লেখা: একাধিক প্রোগ্রাম বা মানুষ একই ফাইলে লিখলে ফাইল নষ্ট হওয়ার ঝুঁকি; log-এর জন্য শেষে লাইন জোড়ার মতো কিছু Excel-এ নেই।
    • Time zone: Excel-এর তারিখে zone থাকে না, তাই click-এর সময় UTC না ঢাকার, পরে বোঝা যাবে না।
    • বিকল্প: click-log আর রিভিউ JSON Lines-এ (UTC-তে ISO 8601 সময়, UTF-8, NFC); প্রতিদিন রাতে সেগুলো জমিয়ে তারিখ অনুযায়ী ভাগ করা Parquet ফাইলে; অর্ডার ডেটাবেসে। মানুষের দেখার জন্য দরকার হলে ছোট সারাংশ xlsx-এ রপ্তানি — মূল ডেটা হিসেবে নয়।

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

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

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

  1. 🔍 প্রথম কয়েকশো byte দেখে encoding, BOM আর delimiter চেনা
  2. 📋 প্রতিটি কলামের type ঠিক করা — ফোন ও ID লেখা হিসেবে, তারিখ zone-সহ
  3. 📥 আসল CSV parser দিয়ে dtype আর encoding স্পষ্ট করে পড়া
  4. ✅ সারির সংখ্যা, dtypes আর কয়েকটি সারি উৎসের সঙ্গে মেলানো
  5. 🧹 লেখার কলাম NFC-তে normalize করে Parquet-এ সংরক্ষণ

একটি CSV লাইন: 1002,"Agrabad, Chattogram",1200. RFC 4180-এর নিয়ম মানা একটি CSV parser এই লাইন থেকে কয়টি field পাবে?

  • ৪টি
  • ২টি
  • ৩টি
  • ৫টি

একটি CSV-তে ফোন নম্বর 01700000000. pd.read_csv(path) দিয়ে পড়ার পর কলামটি 1700000000 দেখাচ্ছে। সবচেয়ে ঠিক প্রতিকার কোনটি?

  • পড়ার পর প্রতিটি মানের সামনে "0" যোগ করা
  • পড়ার সময় dtype={"phone": str} দেওয়া
  • encoding="utf-8-sig" দেওয়া
  • ফাইলটি Excel-এ খুলে আবার সেভ করা

“দাম” শব্দটি (দ + া + ম, তিনটি Unicode code point) UTF-8-এ encode করলে মোট কত byte হয়?

  • ৩
  • ৬
  • ১২
  • ৯

একটি ফাইল খুলে বাংলার জায়গায় দেখা গেল বাজার. সবচেয়ে সম্ভাব্য কারণ কী?

  • UTF-8-এ লেখা বাংলা cp1252 বা Latin-1 ধরে পড়া হয়েছে
  • ফাইলটি ডিস্কে নষ্ট হয়ে গেছে
  • লেখাটি NFC-তে normalize করা হয়নি
  • লেখাটি Bijoy-তে লেখা

একটি pandas DataFrame-এর জেলা কলামে দুটি মান দেখতে হুবহু “ময়মনসিংহ”, কিন্তু groupby দুটি আলাদা দল দিচ্ছে। প্রথমে কী করা উচিত?

  • ফাইলটি অন্য encoding-এ আবার পড়া
  • কলামে Unicode normalization (NFC) প্রয়োগ করা
  • কলামে .str.strip() প্রয়োগ করা
  • কলামটিকে category type-এ রূপান্তর করা

১ কোটি সারির একটি বিক্রির টেবিল থেকে বারবার শুধু দুটি কলামের গড় বের করতে হবে, আর তারিখের time zone অক্ষত রাখতে হবে। কোন format সবচেয়ে উপযুক্ত?

  • CSV
  • JSON Lines
  • Parquet
  • xlsx

শব্দকোষ

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

মনে রাখুন

  1. প্রতিটি ফাইল byte; text ফাইলে type নেই, পড়ার টুল অনুমান করে — binary ফাইলে schema থাকে।
  2. CSV পড়তে আসল parser; quote-এর ভেতরের কমা ও নতুন লাইন field-এর অংশ। ফোন নম্বর আর ID-তে dtype=str, BOM-এর জন্য utf-8-sig.
  3. Log আর nested ডেটায় JSON Lines; বড় টেবিলে Parquet — ছোট, দ্রুত, type ও time zone অক্ষত।
  4. বাংলা = code point → UTF-8-এ প্রতিটি ৩ byte. “ঔ দেখলে mojibake; errors="ignore" দিয়ে চাপা নয়, সঠিক encoding খুঁজুন।
  5. তুলনার আগে NFC normalization; সময় ISO 8601-এ, ঘটে যাওয়া মুহূর্ত UTC-তে, বিশ্লেষণে Asia/Dhaka.

পরের পর্বে: ফাইল তো পড়া গেল — এবার সেই কোড পরিচ্ছন্ন রাখা। ছোট function, type hint, ছোট test আর seed, যাতে ডেটা পড়ার এই সাবধানতাগুলো কোডেই লেখা থাকে আর প্রতিবার একই ফল আসে।

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

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