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

ক্যাপস্টোন — বাংলা পণ্য-রিভিউ থেকে একটি পূর্ণ AI প্রকল্প

Capstone — An End-to-End AI Project on Bangla and Banglish Product Reviews: Sentiment, Issue Triage, Similar-Review Search, Honest Evaluation and a Reproducible Package

Taught in Bangla, this practice page builds a complete project on fictional Bangla and Banglish reviews: cleaning log, de-duplication before a time-based split, finding wrong labels, a TF-IDF pipeline compared with baselines by cross-validation, error analysis, a one-time test with bootstrap intervals and per-script recall, similar-review search including a real desktop run of e5-small, and a reproducible uv and pytest package.

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

  • ডেটা দেখার আগেই সাফল্যের মাপ, baseline আর test-এর নিয়ম লিখে ফেলা
  • নকল বাদ দিয়ে সময়ভিত্তিক ভাগ, আর লুকোনো ভুল label খুঁজে বের করা
  • একটি pipeline-এ শব্দ, অক্ষর আর সংখ্যার feature জুড়ে তিনটি model-এর ন্যায্য তুলনা
  • Error analysis-এর loop, একবারের test, bootstrap CI আর লিপিভিত্তিক দলের তুলনা
  • Model সংরক্ষণ, model card লেখা, আর uv, lockfile ও pytest দিয়ে পুনরুৎপাদনযোগ্য প্রকল্প
প্রকল্পের ব্রিফ

বাজারঘরের গ্রাহকসেবা দলে প্রতিদিন কয়েকশো রিভিউ আসে, বাংলায়, Banglish-এ, কখনো ইংরেজিতে। দলটি চায় প্রতিটি নতুন রিভিউয়ের পাশে তিনটি জিনিস: (ক) মনোভাব, (খ) সমস্যা ডেলিভারির, পণ্যের মানের, নাকি কোনোটিই নয়, আর (গ) আগের ৩টি সবচেয়ে কাছের রিভিউ, যাতে আগে কী উত্তর দেওয়া হয়েছিল দেখে নেওয়া যায়। ডেলিভারির সমস্যা ধরতে না পারা সবচেয়ে ব্যয়বহুল, কারণ পার্সেল তখনো রাস্তায়, আর দ্রুত ব্যবস্থা নিলে গ্রাহক বাঁচে। শেষ সিদ্ধান্ত মানুষের।

এটি অনুশীলনের পর্ব: আগের ৭২টি পর্বের অস্ত্র একটি প্রকল্পে। প্রতিটি অংশের শেষে একটি যাচাই-বিন্দু, যা মিলিয়ে নিলে বুঝবেন আপনি ঠিক পথে আছেন। পাতার ১২টি ব্লক ব্রাউজারের Python-এ (Pyodide, scikit-learn 1.8.0) চলে আর একই namespace ভাগ করে (data-shared), তাই ক্রমে চালাতে হবে; পরের ব্লক আগে চাপলে আগের না-চলা ব্লকগুলো নিজেই আগে চলে। প্রথমবার scikit-learn নামতে প্রায় ১০ সেকেন্ড লাগে। ফাইল তিনটি: reviews.csv, queries.csv আর module bazar_text.py. Pretrained model আর uv প্রকল্প ডেস্কটপের সত্যিকারের run.

১ · প্রকল্পের ছক — কাজ, সাফল্যের মাপ আর baseline-এর প্রতিশ্রুতি

হাসপাতালের জরুরি বিভাগে ঢুকেই একজন নার্স রোগীদের ভাগ করেন: কে এখনই, কে একটু পরে। এই ভাগের নাম triage. ভালো triage-এ একজন গুরুতর রোগীকে “পরে” বলা অনেক বড় ভুল, একজন সাধারণ রোগীকে “এখনই” বলার চেয়ে। আমাদের রিভিউ-triage-এও তাই: ডেলিভারির সমস্যা মিস করা বেশি দামি।

ডেটার একটি সারিও দেখার আগে চারটি সিদ্ধান্ত লিখে ফেলা হলো, যাতে পরে ফল দেখে নিয়ম বদলানোর লোভ না থাকে:

কাজমাপকেন
(ক) sentiment, ২ শ্রেণিmacro F1negative বেশি থাকলেও positive উপেক্ষিত হবে না
(খ) issue, ৩ শ্রেণি (multi-class)macro F1, আর ডেলিভারির recallডেলিভারির অভিযোগ মিস করার দাম সবচেয়ে বেশি
(গ) কাছের ৩টি রিভিউrecall@3, label দেওয়া ২০টি প্রশ্নেদেখানো ৩টির কয়টি সত্যিই একই বিষয়ের
সব কাজদুটি baseline ছাড়াতে হবেDummyClassifier আর হাতে লেখা keyword নিয়ম

চতুর্থ সিদ্ধান্ত test নিয়ে: শেষ দুই মাস, অর্থাৎ ১ জুলাই ২০২৬ থেকে, তালাবন্ধ। কেন সময় দিয়ে, এলোমেলো নয়? বাস্তবে model শিখবে অতীতে, চলবে ভবিষ্যতে; নতুন পণ্য, নতুন কুরিয়ার, নতুন বানান আসবে। Time-based split সেই অবস্থার মহড়া (পর্ব ৫৬)। পুরো ব্যবস্থার ছবি:

বাজারঘর রিভিউ-triage: একটি রিভিউ থেকে তিনটি উত্তর

One review → clean → two classifiers + one search index → predict_review() → a human decides

System diagram of the Bazarghar review triage project নতুন রিভিউ parcel 9 din por ashlo 😡 clean() · tok() · bazar_text.py sentiment LinearSVC + calibration issue LinearSVC + calibration খোঁজ char TF-IDF cosine top-3 predict_review(text) দুই label + আস্থা + ৩টি পুরোনো রিভিউ গ্রাহকসেবা কর্মী · শেষ সিদ্ধান্ত মানুষের সময়রেখা (২০২৬) Jan–Jun · train 193 Jul–Aug · 90 🔒 শেখা, CV, বাছাই, error analysis একবার খোলা

যাচাই-বিন্দু ১: আপনার খাতায় চারটি লাইন: তিনটি কাজের মাপ, দুটি baseline, test-এর তারিখ (১ জুলাই), আর “test একবার”. এগুলো পরে বদলাবেন না।

২ · ডেটার জন্মবৃত্তান্ত আর পরিষ্কার — datasheet আর cleaning log

প্রথমে পর্ব ৭২-এর ধাঁচে একটি সংক্ষিপ্ত datasheet, যাতে যে কেউ জানেন ডেটা কোথা থেকে, কী মাপে, আর কোথায় দুর্বল।

ভাগreviews.csv (কাল্পনিক)
উদ্দেশ্যরিভিউ-triage-এর দুটি classifier আর খোঁজ শেখানো ও মাপা
গঠন৩০৭ সারি, ৭ কলাম: id, date (৫ জানুয়ারি–৩০ আগস্ট ২০২৬), customer, text, sentiment, issue, আর খোঁজ মাপার জন্য সূক্ষ্ম বিষয় topic (late, rider, size … ১২টি)
সংগ্রহএই পর্বের জন্য seed দেওয়া কোড দিয়ে বানানো; কোনো আসল গ্রাহক, ব্র্যান্ড বা ফোন নম্বর নেই। ধারাবাহিকতার জন্য কয়েকটি বাক্য পর্ব ৬৭ থেকে নেওয়া, যেমন “দাম হিসেবে খারাপ না, কাজ চলবে ভালোভাবেই”
Label-এর নিয়মsentiment = সামগ্রিক মনোভাব; issue = যে সমস্যার কথা আছে: delivery (দেরি, পার্সেল না পাওয়া, রাইডারের আচরণ), quality (ভাঙা, নষ্ট, মাপ, রং, নকল, খাবার), none (প্রশংসা, দাম, অ্যাপ)। “পণ্য দারুণ, শুধু ডেলিভারি দেরি” = positive + delivery
জানা সমস্যাইচ্ছা করে রাখা: বানান-ভেদ, Banglish, emoji, negation, ব্যঙ্গ, এক-অক্ষরের “য়”, অদৃশ্য অক্ষর, নকল আর প্রায়-নকল, label-এর বানান, খালি লেখা, আর কয়েকটি ভুল label (সংখ্যা এখন বলছি না)
ব্যবহার ও সীমাশুধু শেখার জন্য। একজন লেখকের বানানো, তাই বাস্তবের চেয়ে অনেক বেশি নিয়মিত; সংখ্যাগুলো এই ডেটার, সাধারণ সত্য নয়

পরিষ্কারের নিয়মগুলো পর্ব ৬৭ আর পর্ব ২৩ থেকে: NFC (“য়”-এর দুই রূপ এক করা), zero-width character মোছা, বাংলা অঙ্ক 0–9, বাড়তি space আর lower(). এগুলো একটি ছোট module-এ রাখা হয়েছে, কারণ অংশ ৯-এ model সংরক্ষণের সময় দেখবেন, pipeline-এর ভেতরের function কোনো importable module-এ থাকা জরুরি।

# bazar_text.py — বাজারঘরের রিভিউ পরিষ্কার আর feature বানানোর ছোট module (ক্যাপস্টোন, পর্ব ৭৩)
import re, unicodedata
import numpy as np
import pandas as pd

BN = f"{chr(0x0980)}-{chr(0x09FF)}"                         # পুরো বাংলা Unicode block
EMO = f"{chr(0x1F300)}-{chr(0x1FAFF)}{chr(0x2600)}-{chr(0x27BF)}{chr(0x2B50)}"
ZW = dict.fromkeys([0x200B, 0x200C, 0x200D, 0xFEFF])       # অদৃশ্য অক্ষর → মুছে ফেলা
BN2EN = str.maketrans("০১২৩৪৫৬৭৮৯", "0123456789")
TOK = re.compile(rf"[\w{BN}]+|[{EMO}]")                    # একটি শব্দ, নয়তো একটি emoji

def clean(t):
    """NFC, zero-width মোছা, বাংলা অঙ্ক → 0-9, বাড়তি space বাদ, lower."""
    t = unicodedata.normalize("NFC", t).translate(ZW).translate(BN2EN)
    return re.sub(r"\s+", " ", t).strip().lower()

def tok(t):
    return TOK.findall(t)

def extra_feats(s):
    """দৈর্ঘ্য (log), '!' আর emoji-র সংখ্যা, কোনো অঙ্ক আছে কি না।"""
    s = pd.Series(s)
    return np.c_[np.log1p(s.str.len()), s.str.count("!"), s.str.count(f"[{EMO}]"),
                 s.str.contains(r"\d")].astype(float)

def script_of(t):
    return "bangla" if re.search(f"[{BN}]", t) else "latin"

প্রথম ব্লক ফাইলটি পড়ে, প্রতিটি নিয়ম কয়টি সারি বদলাল তা একটি cleaning log-এ লেখে।

import re
import numpy as np
import pandas as pd
from bazar_text import clean, tok, extra_feats, script_of, BN, EMO   # পাতার ভেতরের module

raw = pd.read_csv("reviews.csv", keep_default_na=False)   # ফাঁকা ঘর "" থাকুক, NaN নয়
log, df = [], raw.copy()
def step(name, n): log.append((name, int(n)))

new = df["text"].map(clean)
step("text: NFC, zero-width, space, অঙ্ক, lower", (new != df["text"]).sum())
df["text"] = new
new = df["issue"].str.strip().str.lower()
step("issue: বড়-ছোট হাতের অক্ষর এক করা", (new != df["issue"]).sum())
df["issue"] = new
bad = df["text"].eq(""); step("খালি লেখা, বাদ", bad.sum()); df = df[~bad]
bad = df["sentiment"].eq(""); step("sentiment নেই, বাদ", bad.sum()); df = df[~bad]
df["date"] = pd.to_datetime(df["date"])

print("কাঁচা:", raw.shape, list(raw.columns))
print("cleaning log:")
for name, n in log: print(f"  {n:3d}  {name}")
print("এখন সারি:", len(df), "| এক-অক্ষরের য় (U+09DF):",
      raw["text"].str.count(chr(0x09DF)).sum(), "→", df["text"].str.count(chr(0x09DF)).sum())
print("label:", sorted(df["sentiment"].unique()), sorted(df["issue"].unique()))
print("তারিখ:", df["date"].min().date(), "থেকে", df["date"].max().date())

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

কাঁচা: (307, 7) ['id', 'date', 'customer', 'text', 'sentiment', 'issue', 'topic']
cleaning log:
   73  text: NFC, zero-width, space, অঙ্ক, lower
    4  issue: বড়-ছোট হাতের অক্ষর এক করা
    2  খালি লেখা, বাদ
    1  sentiment নেই, বাদ
এখন সারি: 304 | এক-অক্ষরের য় (U+09DF): 16 → 0
label: ['negative', 'positive'] ['delivery', 'none', 'quality']
তারিখ: 2026-01-07 থেকে 2026-08-30

৭৩টি লেখা বদলেছে: বাংলা অঙ্ক ২৩টিতে, বড় হাতের অক্ষর ২০টিতে, বাড়তি space ১৪টিতে, NFC ১৩টিতে, অদৃশ্য অক্ষর ৮টিতে (একটি লেখায় একাধিক হতে পারে)। চারটি issue-label-এ “Delivery”/“Quality”-র মতো বড় হাতের অক্ষর ছিল, যা না সারালে model চারটি শ্রেণি দেখত। দুটি খালি লেখা আর label ছাড়া একটি “Ok” বাদ। এক-অক্ষরের “য়” ১৬ থেকে ০: NFC-র পর সব এক রূপে। কিছুই নীরবে বাদ যায়নি; প্রতিটি সংখ্যা log-এ।

যাচাই-বিন্দু ২: ৩০৭ → ৩০৪ সারি, issue-এর ঠিক তিনটি মান, আর U+09DF শূন্য।

৩ · আগে নকল বাদ, তারপর সময় দিয়ে তালাবন্ধ test

একই রিভিউ দুবার থাকলে, একটি train-এ আর একটি test-এ পড়লে, model পরীক্ষায় নিজের মুখস্থ উত্তর দেখে: এটি data leakage. তাই নকল বাদ আগে, ভাগ পরে। দুই স্তরে খোঁজা: (১) যতিচিহ্ন, emoji আর space বাদ দিয়ে একটি key; key এক মানে লেখা এক (duplicate). (২) Key আলাদা কিন্তু প্রায় একই লেখা, near-duplicate: char TF-IDF-এর cosine ≥ ০.৮৫ এবং একই গ্রাহক। দ্বিতীয় শর্তটি জরুরি, কারণ “rider bhai khub vodro” দুজন আলাদা গ্রাহক লিখতেই পারেন; সেটি নকল নয়, দুটি আলাদা মত।

from sklearn.feature_extraction.text import TfidfVectorizer

df["key"] = df["text"].map(lambda t: re.sub(rf"[^\w{BN}]+", "", t))  # যতিচিহ্ন, emoji, space বাদ
same = df[df.duplicated("key", keep=False)]
odd = same.groupby("key").filter(lambda g: g["sentiment"].nunique() > 1 or g["issue"].nunique() > 1)
print("একই key-র সারি", len(same), "| বাদ যাবে", int(df.duplicated("key").sum()))
print("label মেলে না:"); print(odd[["id", "date", "text", "sentiment"]].to_string(index=False, header=False))
CUT = pd.Timestamp("2026-07-01")                       # অংশ ১-এ, ডেটা দেখার আগে ঠিক করা
span = same.groupby("key")["date"].agg(lambda s: s.min() < CUT <= s.max()).sum()
d1 = df.drop_duplicates("key", keep="first").reset_index(drop=True)   # আগেরটি থাকে

E = TfidfVectorizer(analyzer="char_wb", ngram_range=(2, 4)).fit_transform(d1["text"])
S = (E @ E.T).toarray(); np.fill_diagonal(S, 0)       # সারি L2 = 1, তাই dot = cosine
cust = d1["customer"].to_numpy(dtype=object)
a, b = np.where(np.triu(S >= 0.85) & (cust[:, None] == cust[None, :]))
for i, j in zip(a, b):
    print(f"near {S[i, j]:.2f} {d1.id[i]} {d1.date[i]:%d %b} → {d1.id[j]} {d1.date[j]:%d %b}: {d1.text[j]}")
span += int(sum((d1.date[i] < CUT) & (d1.date[j] >= CUT) for i, j in zip(a, b)))
d2 = d1.drop(index=b).reset_index(drop=True)

train = d2[d2["date"] < CUT].reset_index(drop=True)
test = d2[d2["date"] >= CUT].reset_index(drop=True)    # 🔒 অংশ ৭-এর আগে আর খোলা হবে না
print(f"সারি {len(df)} → {len(d1)} → {len(d2)} | July-র দুই পাশে ছড়ানো নকল: {span}")
print(f"train {len(train)} ({train.date.min():%d %b}–{train.date.max():%d %b})"
      f" | test {len(test)} ({test.date.min():%d %b}–{test.date.max():%d %b})")

আসল আউটপুট:

একই key-র সারি 39 | বাদ যাবে 20
label মেলে না:
r006 2026-01-09 chal e pathor ar poka!! negative
r011 2026-01-10 chal e pathor ar poka!! positive
near 0.96 r206 28 Jun → r219 05 Jul: ammur jonno panjabi nilam, khb posondo, recommend korbo!!
সারি 304 → 284 → 283 | July-র দুই পাশে ছড়ানো নকল: 4
train 193 (07 Jan–30 Jun) | test 90 (02 Jul–30 Aug)

৩৯টি সারি key ভাগ করে, তাদের ২০টি বাড়তি কপি। একটি দলে label মেলে না: “chal e pathor ar poka!!” একবার negative, একদিন পরে positive. পড়লেই বোঝা যায় প্রথমটি ঠিক, আর keep="first" সেটিই রাখে; যেখানে তা না হয়, সেখানে হাতে ঠিক করতে হবে। একটি প্রায়-নকল ধরা পড়ল: একই গ্রাহক ২৮ জুন আর ৫ জুলাই প্রায় একই কথা লিখেছেন, শুধু “khub” বদলে “khb”. মোট ৪টি নকল-দল জুলাইয়ের দুই পাশে ছড়ানো ছিল। আগে ভাগ করলে এই চারটির উত্তর test-এ আগেই জানা থাকত। শেষে train ১৯৩, test ৯০.

এখন সবচেয়ে কঠিন নিয়ম: test-এর দিকে আর তাকানো চলবে না, যতক্ষণ না চূড়ান্ত model লেখা হয়। নিচের stepper পুরো প্রকল্পে test-এর জীবন দেখায় (সংখ্যাগুলো এই পাতার run থেকে)।

বাজারঘরের রিভিউ, ২০২৬প্রতি ধাপে একটি কাজ

            
  1. নিয়ম আগে। ডেটা খোলার আগেই test-এর তারিখ আর মাপ লেখা হলো। পরে ফল দেখে “আচ্ছা, ১৫ জুলাই থেকে ধরি” বলার সুযোগ থাকল না।
  2. নকল বাদ পুরো ডেটায়। এটি label দেখে না, তাই পুরো ডেটায় করা নিরাপদ, আর ভাগের আগে করাই জরুরি: চারটি নকল-দল দুই পাশে ছড়ানো ছিল।
  3. সময় দিয়ে কাটা। জুনের শেষ পর্যন্ত train (১৯৩), জুলাই–আগস্ট test (৯০)। ভাগ এলোমেলো নয়, তাই seed লাগে না; একই তারিখ, একই ভাগ।
  4. সব কাজ train-এ। EDA, ভুল label খোঁজা, model তুলনা, grid search, error analysis: সবকিছু ১৯৩টিতে, cross-validation দিয়ে।
  5. সিদ্ধান্ত লিখে ফেলা। কোন feature, কোন model, কোন সেটিং: সব লেখা, তারপর পুরো train-এ fit. এই মুহূর্তের পরে আর কোনো বদল নেই।
  6. একবার খোলা। Test-এর সংখ্যাই রিপোর্ট, ভালো হোক বা খারাপ। ডেলিভারির recall আশার চেয়ে কম এল (অংশ ৭); তবু এই model আর বদলাবে না, পরের সংস্করণ মাপা হবে নতুন মাসে।

যাচাই-বিন্দু ৩: train ১৯৩ (৭ জানুয়ারি–৩০ জুন), test ৯০ (২ জুলাই–৩০ আগস্ট), আর দুই ভাগে কোনো key ভাগ করা নয়।

৪ · EDA আর লুকোনো ভুল label

পর্ব ৬২-এর নিয়মে EDA শুধু train-এ: শ্রেণির ভারসাম্য, দুই কাজের সম্পর্ক, লিপির মিশ্রণ, দৈর্ঘ্য, negation আর ব্যঙ্গের ইঙ্গিত, মাসে মাসে সমস্যার ভাগ।

import matplotlib.pyplot as plt

tr = train.copy()                                      # EDA শুধু train-এ
tr["script"] = tr["text"].map(script_of)
tr["n_char"] = tr["text"].str.len()
print(pd.crosstab(tr["sentiment"], tr["issue"], margins=True))
print("\nলিপি:", tr["script"].value_counts().to_dict())
print(tr.groupby("script")["n_char"].agg(["count", "mean", "min", "max"]).round(1))
neg = tr["text"].str.contains(rf"(?<![\w{BN}])(?:না|নেই|na|nai)(?![\w{BN}])")
print("\n'না/নেই/na/nai' আছে:", int(neg.sum()), "| তার positive:",
      int((neg & (tr["sentiment"] == "positive")).sum()))
print("emoji 🙄/👏 (ব্যঙ্গের সন্দেহ):", tr.loc[tr["text"].str.contains("🙄|👏"), "sentiment"].value_counts().to_dict())
month = tr.groupby(tr["date"].dt.month)["issue"].value_counts(normalize=True).unstack()

fig, ax = plt.subplots(1, 2, figsize=(9, 3.2))
for s, c in [("bangla", "#1f77b4"), ("latin", "#f59e0b")]:
    ax[0].hist(tr.loc[tr.script == s, "n_char"], bins=12, alpha=0.6, color=c, label=s)
ax[0].set(title="Review length (train)", xlabel="characters", ylabel="reviews"); ax[0].legend()
month.plot(ax=ax[1], marker="o", color=["#dc2626", "#64748b", "#16a34a"])
ax[1].set(title="Issue share by month (train)", xlabel="month (2026)", ylabel="share")
plt.show()

আসল আউটপুট (সঙ্গে দুটি chart):

issue      delivery  none  quality  All
sentiment                              
negative         38    18       55  111
positive          5    76        1   82
All              43    94       56  193

লিপি: {'bangla': 113, 'latin': 80}
        count  mean  min  max
script                       
bangla    113  40.1   11   73
latin      80  36.0   15   73

'না/নেই/na/nai' আছে: 54 | তার positive: 14
emoji 🙄/👏 (ব্যঙ্গের সন্দেহ): {'negative': 7}

ভারসাম্য: negative ১১১, positive ৮২; issue-এ none ৯৪, quality ৫৬, delivery ৪৩, অর্থাৎ ডেলিভারি সবচেয়ে ছোট শ্রেণি (class imbalance). দুই কাজ জড়ানো: quality প্রায় সবসময় negative (৫৫ বনাম ১), কিন্তু ৫টি delivery positive, যেমন “পণ্য দারুণ, শুধু ডেলিভারি দেরি”. তাই দুটি আলাদা model দরকার। লিপি: ১১৩টি বাংলা হরফে, ৮০টি শুধু রোমান হরফে (Banglish আর কিছু ইংরেজি); দৈর্ঘ্য কাছাকাছি। ফাঁদ: ৫৪টিতে “না/নেই/na/nai”, তার ১৪টি positive (“কোনো সমস্যা নেই”), তাই “না মানেই negative” নিয়ম ভুল করবে। 🙄 বা 👏 আছে এমন ৭টিই negative: ব্যঙ্গ। ডান chart-এ delivery-র ভাগ মাসে মাসে ০.১২ থেকে ০.৩০-এর মধ্যে ওঠানামা করে, স্পষ্ট কোনো ধারা নেই; মাসে মাত্র ৩০টির মতো রিভিউ, তাই এই নড়াচড়া ছোট n-এর।

যে label নিজেই ভুল

মানুষের দেওয়া label-এও ভুল থাকে: তাড়াহুড়ো, ক্লান্তি, ভুল বোতাম। ২০২১ সালে Curtis Northcutt ও সহকর্মীরা confident learning নামে এর একটি পদ্ধতিগত রূপ প্রকাশ করেন, আর আরেকটি কাজে দেখান যে জনপ্রিয় কয়েকটি benchmark-এর test set-এও ভুল label আছে। মূল ভাবনা: যে model সারিটি দেখেনি (out-of-fold), সে নিজের label-কে যত কম সম্ভাবনা দেয়, সারিটি তত সন্দেহজনক; সেগুলো মানুষ আবার পড়বেন। এখানে দ্রুত আর সরল MultinomialNB, word আর char TF-IDF একটি FeatureUnion-এ জুড়ে।

from sklearn.pipeline import FeatureUnion, make_pipeline
from sklearn.naive_bayes import MultinomialNB
from sklearn.model_selection import StratifiedKFold, cross_val_predict

cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)   # সব জায়গায় একই fold

def suspects(task, k=6):
    words_chars = FeatureUnion([
        ("w", TfidfVectorizer(tokenizer=tok, token_pattern=None)),
        ("c", TfidfVectorizer(analyzer="char_wb", ngram_range=(2, 4)))])
    P = cross_val_predict(make_pipeline(words_chars, MultinomialNB()), train["text"],
                          train[task], cv=cv, method="predict_proba")   # প্রতিটি সারি না-দেখা model-এ
    classes = np.array(sorted(train[task].unique()))
    p_given = P[np.arange(len(train)), np.searchsorted(classes, train[task])]
    top = np.argsort(p_given, kind="stable")[:k]
    return pd.DataFrame({"id": train["id"][top], "p": p_given[top].round(2),
                         "label": train[task][top], "text": train["text"][top].str[:44]})

for task in ["sentiment", "issue"]:
    print(f"\n{task}: নিজের label-এ সবচেয়ে কম আস্থা")
    print(suspects(task).to_string(index=False))

আসল আউটপুট:

sentiment: নিজের label-এ সবচেয়ে কম আস্থা
  id    p    label                                     text
r009 0.08 negative         দাম অনুযায়ী হেডফোন একদম ঠিক আছে
r202 0.14 positive                          কোনো অভিযোগ নেই
r203 0.19 positive 2 মাস ধরে ব্যবহার করছি, কোনো সমস্যা নেই!
r063 0.20 negative               charger peye khub khushi 👍
r205 0.21 positive                    rider bhai khub vodro
r211 0.25 positive            fast delivery, packaging valo

issue: নিজের label-এ সবচেয়ে কম আস্থা
  id    p    label                                         text
r021 0.04  quality         kono shomossa nai, recommend korbo!!
r148 0.09 delivery juta er selai prothom dhoyay khule gese, qua
r200 0.14     none               ডেলিভারি এত দেরিতে কেন? 8 দিন!
r146 0.14 delivery              rider er behavior khub kharap 😡
r096 0.15  quality                        fake product, asol na
r171 0.16 delivery কুরিয়ারে চার্জার আটকে ছিল 6 দিন। আর কিনব না

বারোটি সন্দেহভাজন পড়ে দেখা হলো। পাঁচটি সত্যিই ভুল: r009 “দাম অনুযায়ী হেডফোন একদম ঠিক আছে” আর r063 “charger peye khub khushi 👍” positive; r021 “kono shomossa nai” none; r148-এ সেলাই খুলে যাওয়া quality; r200 “ডেলিভারি এত দেরিতে কেন?” delivery. বাকি সাতটির label ঠিক, model-ই কঠিন উদাহরণে দ্বিধায়, যেমন “কোনো অভিযোগ নেই”. তাই model সন্দেহ দেখায়, সিদ্ধান্ত মানুষ নেন, আর সব বদল লেখা থাকে (পরের ব্লকের FIX).

লেখক হিসেবে আমরা জানি ডেটায় ৬টি ভুল label রাখা হয়েছিল; এই তালিকা পাঁচটি ধরেছে। ষষ্ঠটি অংশ ৬-এ।

যাচাই-বিন্দু ৪: sentiment-এর তালিকায় r009 আর r063, issue-এর তালিকায় r021, r148 আর r200. আপনি পাঁচটি ঠিক করবেন, সাতটি অক্ষত রাখবেন।

৫ · Feature, pipeline আর তিনটি model — stratified CV আর ছোট grid search

একটি রিভিউ থেকে তিন ধরনের সংকেত, একটি ColumnTransformer-এ (পর্ব ৬৩, পর্ব ৬৪): শব্দের TF-IDF (বাংলা-বান্ধব tok), অক্ষরের char n-gram TF-IDF (বানান-ভেদ “valo/vlo”, “দারুণ/দারুন” সামলাতে), আর চারটি সংখ্যা: দৈর্ঘ্যের log, “!” আর emoji-র সংখ্যা, অঙ্ক আছে কি না (“৯ দিন”-এর মতো দেরির ইঙ্গিত)। সংখ্যাগুলো MaxAbsScaler দিয়ে ০–১-এ, যাতে MultinomialNB-ও চলে। পুরোটা pipeline-এ, তাই প্রতিটি fold-এ vocabulary, idf আর scaler শুধু সেই fold-এর train অংশ থেকে শেখে।

from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import FunctionTransformer, MaxAbsScaler

FIX = {"r009": ("sentiment", "positive"), "r063": ("sentiment", "positive"),   # হাতে পড়ে নিশ্চিত
       "r021": ("issue", "none"), "r148": ("issue", "quality"), "r200": ("issue", "delivery")}
for rid, (col, val) in FIX.items():
    train.loc[train["id"] == rid, col] = val

def make_features(word_ngrams=(1, 1)):
    return ColumnTransformer([
        ("word", TfidfVectorizer(tokenizer=tok, token_pattern=None, ngram_range=word_ngrams), "text"),
        ("char", TfidfVectorizer(analyzer="char_wb", ngram_range=(2, 4)), "text"),
        ("num", make_pipeline(FunctionTransformer(extra_feats), MaxAbsScaler()), "text")])

F = make_features().fit(train[["text"]])               # এখানে শুধু দেখার জন্য; CV-তে প্রতি fold-এ নতুন fit
X = F.transform(train[["text"]])
parts = {n: len(t.vocabulary_) for n, t in F.named_transformers_.items() if n != "num"}
print("ঠিক করা label:", list(FIX), "| X:", X.shape, parts, "+ num 4")
print(f"শূন্য নয় এমন ঘর: {X.nnz / (X.shape[0] * X.shape[1]):.1%}")
ex = pd.DataFrame({"text": ["Product ta ভালো না!! 😡"]})
x = F.transform(ex)
print("\nউদাহরণ:", ex.text[0], "→ token", tok(ex.text[0].lower()))
print("word ঘর", F.named_transformers_["word"].transform(ex.text).nnz, "| char ঘর",
      F.named_transformers_["char"].transform(ex.text).nnz, "| num", x[:, -4:].toarray().round(3)[0])

আসল আউটপুট:

ঠিক করা label: ['r009', 'r063', 'r021', 'r148', 'r200'] | X: (193, 4480) {'word': 476, 'char': 4000} + num 4
শূন্য নয় এমন ঘর: 2.3%

উদাহরণ: Product ta ভালো না!! 😡 → token ['product', 'ta', 'ভালো', 'না', '😡']
word ঘর 5 | char ঘর 54 | num [0.728 0.667 0.2   0.   ]

১৯৩টি রিভিউ, ৪,৪৮০টি কলাম: ৪৭৬টি শব্দ, ৪,০০০টি অক্ষর-খণ্ড আর ৪টি সংখ্যা; ঘরের মাত্র ২.৩% অশূন্য, তাই sparse matrix. উদাহরণের পাঁচটি token-ই vocabulary-তে আছে (word ঘর ৫), অক্ষর-খণ্ড ৫৪টি। সংখ্যার ঘর: দৈর্ঘ্যের log ০.৭২৮, দুটি “!” ০.৬৬৭ (train-এ সর্বোচ্চ ৩টি), একটি emoji ০.২ (সর্বোচ্চ ৫টি তারা), অঙ্ক নেই ০.

এবার তুলনা। দুটি baseline আর পর্ব ৬৬-এর তিন প্রার্থী: MultinomialNB, LogisticRegression আর LinearSVC (linear SVM; তিন শ্রেণিতে one-vs-rest কৌশলে)। সবাই একই feature, একই ৫টি stratified fold; ডেলিভারির recall আলাদা scorer দিয়ে। Keyword নিয়ম কিছু শেখে না, তাই পুরো train-এ একবার মাপা।

from sklearn.linear_model import LogisticRegression
from sklearn.svm import LinearSVC
from sklearn.dummy import DummyClassifier
from sklearn.model_selection import cross_validate
from sklearn.metrics import f1_score, recall_score, make_scorer

KW = {"delivery": {"ডেলিভারি", "দেরি", "কুরিয়ার", "রাইডার", "পার্সেল", "delivery", "deri", "late", "courier", "rider", "parcel"},
      "quality": {"মান", "নষ্ট", "ভাঙা", "সাইজ", "মাপ", "রং", "নকল", "পচা", "পোকা", "quality", "noshto", "size", "rong", "fake"},
      "negative": {"খারাপ", "নষ্ট", "দেরি", "না", "নেই", "ভুল", "kharap", "noshto", "deri", "late", "na", "nai", "😡", "😞"}}
def rule(t, task):                                     # শেখে না, তাই পুরো train-এ একবার মাপা
    w = set(tok(t))
    if task == "sentiment": return "negative" if w & KW["negative"] else "positive"
    return "delivery" if w & KW["delivery"] else "quality" if w & KW["quality"] else "none"

R_DEL = make_scorer(recall_score, labels=["delivery"], average="macro")
models = {"Dummy": DummyClassifier, "MultinomialNB": MultinomialNB,
          "LogisticReg": lambda: LogisticRegression(max_iter=2000), "LinearSVC": LinearSVC}
for task in ["sentiment", "issue"]:
    y = train[task]
    sc = {"F1": "f1_macro"} | ({"R_del": R_DEL} if task == "issue" else {})
    p = train["text"].map(lambda t: rule(t, task))
    extra = f"  R_del {recall_score(y, p, labels=['delivery'], average='macro'):.3f}" if task == "issue" else ""
    print(f"\n{task:9}  {'keyword rule':13}  F1 {f1_score(y, p, average='macro'):.3f}{extra}")
    for name, M in models.items():
        r = cross_validate(make_pipeline(make_features(), M()), train[["text"]], y, cv=cv, scoring=sc)
        print(f"{task:9}  {name:13}  " + "  ".join(
            f"{k} {r['test_' + k].mean():.3f} ± {r['test_' + k].std():.3f}" for k in sc))

আসল আউটপুট (কোনো warning নেই):

sentiment  keyword rule   F1 0.694
sentiment  Dummy          F1 0.361 ± 0.003
sentiment  MultinomialNB  F1 0.921 ± 0.033
sentiment  LogisticReg    F1 0.919 ± 0.030
sentiment  LinearSVC      F1 0.958 ± 0.035

issue      keyword rule   F1 0.686  R_del 0.674
issue      Dummy          F1 0.218 ± 0.004  R_del 0.000 ± 0.000
issue      MultinomialNB  F1 0.791 ± 0.011  R_del 0.764 ± 0.110
issue      LogisticReg    F1 0.805 ± 0.055  R_del 0.739 ± 0.191
issue      LinearSVC      F1 0.888 ± 0.047  R_del 0.906 ± 0.085

Baseline: Dummy সবসময় বড় শ্রেণি বলে: sentiment-এ ০.৩৬১, issue-এ ০.২১৮, ডেলিভারি recall ০. হাতে লেখা keyword নিয়ম অনেক ভালো (০.৬৯৪, ০.৬৮৬), কিন্তু “দেরি হয়নি”-র মতো negation আর “দ্রুত ডেলিভারি”-র মতো প্রশংসায় ঠকে। Model: তিনটিই নিয়মকে ছাড়িয়েছে; LinearSVC দুই কাজেই এগিয়ে (০.৯৫৮, ০.৮৮৮), আর ডেলিভারি recall ০.৯০৬. তবে sd প্রায় ০.০৩–০.০৫, আর NB বনাম LogisticRegression-এর পার্থক্য তার চেয়েও ছোট। এত উঁচু সংখ্যা একটি সতর্কবার্তাও: বানানো ডেটা বাস্তবের চেয়ে অনেক বেশি নিয়মিত।

LinearSVC-এর দুটি সেটিং ছোট একটি grid search-এ: C (০.৩, ১, ৩) আর class_weight (None বা “balanced”, যা ছোট শ্রেণির ভুলকে বেশি ওজন দেয়)।

from sklearn.model_selection import GridSearchCV

grid = {"linearsvc__C": [0.3, 1, 3], "linearsvc__class_weight": [None, "balanced"]}
best = {}
for task in ["sentiment", "issue"]:
    gs = GridSearchCV(make_pipeline(make_features(), LinearSVC()), grid, cv=cv,
                      scoring="f1_macro").fit(train[["text"]], train[task])
    res = pd.DataFrame(gs.cv_results_)
    res["setting"] = [f"C={p['linearsvc__C']}, {p['linearsvc__class_weight']}" for p in res["params"]]
    print(f"\n{task}")
    print(res[["setting", "mean_test_score", "std_test_score", "rank_test_score"]]
          .round(3).to_string(index=False, header=["setting", "F1", "sd", "rank"]))
    best[task] = {k.split("__")[1]: v for k, v in gs.best_params_.items()}
print("\nবাছাই:", best)

আসল আউটপুট:

sentiment
        setting    F1    sd rank
    C=0.3, None 0.942 0.031    6
C=0.3, balanced 0.953 0.030    5
      C=1, None 0.958 0.035    1
  C=1, balanced 0.958 0.035    1
      C=3, None 0.958 0.035    1
  C=3, balanced 0.953 0.039    4

issue
        setting    F1    sd rank
    C=0.3, None 0.862 0.048    6
C=0.3, balanced 0.888 0.048    5
      C=1, None 0.888 0.047    2
  C=1, balanced 0.893 0.046    1
      C=3, None 0.888 0.047    2
  C=3, balanced 0.888 0.047    2

বাছাই: {'sentiment': {'C': 1, 'class_weight': None}, 'issue': {'C': 1, 'class_weight': 'balanced'}}

Sentiment-এ C = ১ আর ৩ সমান (০.৯৫৮), তাই ছোটটি। Issue-এ “balanced” সামান্য এগিয়ে (০.৮৯৩ বনাম ০.৮৮৮), আর ছোট ডেলিভারি শ্রেণির জন্য যুক্তিও আছে। কিন্তু সব সেটিংয়ের পার্থক্য sd-এর চেয়ে ছোট; আর যে fold-এ বাছাই, সেই fold-এর score আশাবাদী। সৎ অনুমানের জন্য দরকার nested CV (পর্ব ৬৬), নয়তো তালাবন্ধ test-এর একবারের মাপ।

যাচাই-বিন্দু ৫: X-এর আকার (১৯৩, ৪৪৮০); LinearSVC দুই baseline-এর ওপরে; বাছাই sentiment {C: 1, None}, issue {C: 1, balanced}.

৬ · ভুলের ময়নাতদন্ত আর একটি লক্ষ্যভেদী চেষ্টা — test খোলার আগে

Error analysis-এর loop চলে train-এর out-of-fold অনুমানে, test-এ নয়: test-এর ভুল দেখে model বদলালে test অনুশীলনের খাতা হয়ে যায়। তাই এই অংশ test খোলার আগে, যদিও অনেক টিউটোরিয়ালে উল্টো ক্রম দেখবেন।

পর্ব ৬৭-এ “খারাপ না”-র মতো negation সারাতে bigram কাজে লেগেছিল (৫০টি ভাগে গড়ে +০.০৩০)। এখানেও কি লাগবে? একই fold-এ দুই সংস্করণ, ভুলগুলো tag দিয়ে।

folds = list(cv.split(train, train["issue"]))          # একই ৫টি ভাগ, দুই সংস্করণেই
def oof(task, ngrams):                                  # প্রতিটি সারির অনুমান, না-দেখা model থেকে
    pipe = make_pipeline(make_features(ngrams), LinearSVC(**best[task]))
    return cross_val_predict(pipe, train[["text"]], train[task], cv=folds)
def tags(t):
    out = [n for n, pat in [("negation", rf"(?<![\w{BN}])(?:না|নেই|na|nai|hoyni)(?![\w{BN}])"),
                            ("sarcasm?", "🙄|👏"), ("mixed", "শুধু|তবে|কিন্তু|kintu|shudhu")] if re.search(pat, t)]
    return ",".join(out + ([] if re.search(f"[{BN}]", t) else ["latin"])) or "-"

for task in ["sentiment", "issue"]:
    y = train[task].to_numpy()
    p1, p2 = oof(task, (1, 1)), oof(task, (1, 2))
    wrong = np.flatnonzero(p1 != y)
    print(f"\n{task}: unigram-এ ভুল {len(wrong)}টি  →  tag:",
          pd.Series([t for i in wrong for t in tags(train.text[i]).split(",")]).value_counts().to_dict())
    for i in wrong[:6]:
        print(f"  {train.id[i]} {y[i][:3]}→{p1[i][:3]} [{tags(train.text[i])}] {train.text[i][:40]}"
              + (f"  (sentiment: {train.sentiment[i]})" if task == "issue" else ""))
    f = lambda p: np.array([f1_score(y[te], p[te], average="macro") for _, te in folds])
    d = f(p2) - f(p1)
    print(f"  +bigram: সারাল {int(((p1 != y) & (p2 == y)).sum())}, নতুন ভুল {int(((p1 == y) & (p2 != y)).sum())}"
          f" | fold-ভেদে F1 পার্থক্য {np.round(d, 3)} গড় {d.mean():+.3f}")
    if task == "issue":
        r = lambda p: recall_score(y, p, labels=["delivery"], average="macro")
        print(f"  delivery recall (সব OOF): {r(p1):.3f} → {r(p2):.3f}")

আসল আউটপুট:

sentiment: unigram-এ ভুল 11টি  →  tag: {'latin': 7, 'negation': 2, 'sarcasm?': 1, 'mixed': 1, '-': 1}
  r031 pos→neg [latin] size perfect hoise, thanks bazarghar!!
  r074 neg→pos [sarcasm?] ধন্যবাদ বাজারঘর, পচা সবজি পাঠানোর জন্য 👏
  r092 pos→neg [latin] size perfect hoise, abar kinbo
  r093 pos→neg [mixed] শাড়ি পছন্দ হয়েছে, কিন্তু আসতে 7 দিন লা
  r096 neg→pos [negation,latin] fake product, asol na
  r123 pos→neg [latin] very good quality, will buy again
  +bigram: সারাল 0, নতুন ভুল 1 | fold-ভেদে F1 পার্থক্য [-0.028  0.     0.     0.     0.   ] গড় -0.006

issue: unigram-এ ভুল 20টি  →  tag: {'latin': 10, 'negation': 7, '-': 4, 'sarcasm?': 1}
  r031 non→qua [latin] size perfect hoise, thanks bazarghar!!  (sentiment: positive)
  r058 qua→non [-] ব্লেন্ডার চালু করতেই গরম হয়ে বন্ধ, মান   (sentiment: negative)
  r065 qua→non [-] চার্জার ভাঙা অবস্থায় পেয়েছি, ফেরত চাই!  (sentiment: negative)
  r069 non→qua [-] রং ছবির মতোই  (sentiment: negative)
  r074 qua→non [sarcasm?] ধন্যবাদ বাজারঘর, পচা সবজি পাঠানোর জন্য 👏  (sentiment: negative)
  r084 qua→non [-] সিল খোলা ছিল, পুরোনো জিনিস দিয়েছে  (sentiment: negative)
  +bigram: সারাল 1, নতুন ভুল 2 | fold-ভেদে F1 পার্থক্য [ 0.    -0.026  0.    -0.026  0.021] গড় -0.006
  delivery recall (সব OOF): 0.907 → 0.907

ভুল কোথায়: sentiment-এর ১১টি ভুলের ৭টি রোমান হরফে (“size perfect hoise”), negation মাত্র ২টি; issue-এর ২০টির ১০টি রোমান হরফে। বড় সমস্যা Banglish-এর অল্প উদাহরণ: “size”, “quality” train-এ প্রায় সবসময় অভিযোগে, তাই প্রশংসাও অভিযোগ মনে হয়। ব্যঙ্গ দুই কাজেই ভুল।

Bigram-এর ফল: sentiment-এ একটিও সারায়নি, নতুন একটি ভুল; issue-এ একটি সারিয়ে দুটি নতুন। দুই কাজেই গড় −০.০০৬. ডেস্কটপে একই version-এ (scikit-learn 1.8.0) ৫-fold দশবার চালিয়ে দেখা হলো, সত্যিকারের run:

sentiment  unigram 0.943  +bigram 0.938  diff -0.005  better 2  equal 36  worse 12
issue      unigram 0.877  +bigram 0.873  diff -0.003  better 9  equal 30  worse 11

উন্নতির প্রমাণ নেই, তাই bigram বাদ. আর issue-এর তালিকার r069 “রং ছবির মতোই” দেখুন: issue ঠিক, কিন্তু sentiment label negative! এটিই ষষ্ঠ লুকোনো ভুল। Model-ও এটিকে negative ভেবেছিল, তাই ব্লক ৪-এ ওঠেনি: model আর ভুল label একমত হলে confident learning অন্ধ।

  1. OOF অনুমানcross_val_predictনা-দেখা model
  2. ভুল পড়াtags()দলে ভাগ
  3. অনুমানbigram?একটি বদল
  4. মাপাএকই foldজোড়ায় তুলনা
  5. সিদ্ধান্তরাখা/বাদলিখে রাখা
৫টি fold, LinearSVC, unigramপ্রতিটি সারি একবার পরীক্ষায়
sentiment: ভুল 11 / 193
issue: ভুল 20 / 193
delivery recall: 0.907
ভুলগুলোর tagএকটি ভুলে একাধিক tag হতে পারে
sentiment: latin 7,
  negation 2, sarcasm? 1,
  mixed 1
issue: latin 10,
  negation 7, sarcasm? 1
r031 pos→neg [latin]
  size perfect hoise …
একটিমাত্র বদলবাকি সব হুবহু
TfidfVectorizer(
  ngram_range=(1, 1))
  → (1, 2)
লক্ষ্য: “খারাপ না”,
  “দেরি হয়নি”
একই ৫টি foldপাতার run
sentiment: সারাল 0,
  নতুন ভুল 1, গড় −0.006
issue: সারাল 1,
  নতুন ভুল 2, গড় −0.006
ডেস্কটপ 10×5:
  −0.005 আর −0.003
সিদ্ধান্তপরের ঘুরে ডেটার দিকে
bigram: বাদ (প্রমাণ নেই)
ডেটা: r069 “রং ছবির মতোই”
  sentiment negative
  → positive
পরের ঘুর: আরও Banglish
  প্রশংসার label
  1. OOF অনুমান। প্রতিটি train রিভিউয়ের অনুমান এমন model থেকে, যে তাকে দেখেনি: test না ছুঁয়েই ১৯৩টি “পরীক্ষার উত্তর”.
  2. ভুল পড়া আর দলে ভাগ। প্রতিটি ভুল পড়া, তারপর tag: রোমান হরফ, negation, ব্যঙ্গের emoji, মিশ্র কথা। সবচেয়ে বড় দল রোমান হরফ।
  3. একটি অনুমান, একটি বদল। একসঙ্গে পাঁচটি বদলে কোনটি কাজ করল বোঝা যায় না। এখানে বদল একটি: শব্দের bigram.
  4. একই fold-এ মাপা। দুই সংস্করণ একই ভাগে, জোড়ায়। পাতায় গড় −০.০০৬; ডেস্কটপে ৫০টি ভাগে sentiment-এ মাত্র ২টিতে ভালো, ১২টিতে খারাপ।
  5. সিদ্ধান্ত আর পরের ঘুর। Bigram বাদ। ভুল পড়তে গিয়ে ষষ্ঠ ভুল label (r069) পাওয়া গেল, সারানো হলো। পরের ঘুর: আরও Banglish প্রশংসার label, যা নতুন ডেটার কাজ।

যাচাই-বিন্দু ৬: bigram-এর গড় পার্থক্য দুই কাজেই −০.০০৬, আর ভুলের তালিকায় r069-এর পাশে “sentiment: negative”.

৭ · Test একবার — confusion matrix, শ্রেণিভিত্তিক রিপোর্ট, bootstrap CI আর দলভিত্তিক যাচাই

চূড়ান্ত model: অংশ ৫-এর feature (unigram), grid-এর সেটিংয়ে LinearSVC, আর তার চারপাশে CalibratedClassifierCV. LinearSVC সম্ভাবনা দেয় না, অথচ দল “কতটা নিশ্চিত” জানতে চায়; এটি ভেতরে ৫টি fold-এ শিখে score-কে sigmoid দিয়ে সম্ভাবনায় আনে (John Platt, ১৯৯৯; scikit-learn-এর default): calibration. ষষ্ঠ label-টি ঠিক করে পুরো train-এ fit, তারপর test খোলা হলো একবার। সঙ্গে পর্ব ৬০-এর bootstrap: test-এর ৯০টি সারি থেকে ফেরত-সহ ৯০টি তুলে ২,০০০ বার মাপা, আর মাঝের ৯৫% দিয়ে confidence interval.

from sklearn.calibration import CalibratedClassifierCV
from sklearn.metrics import confusion_matrix, classification_report

train.loc[train["id"] == "r069", "sentiment"] = "positive"       # অংশ ৬-এর loop-এ পাওয়া
final = {t: make_pipeline(make_features(), CalibratedClassifierCV(LinearSVC(**best[t]), cv=5))
         .fit(train[["text"]], train[t]) for t in ["sentiment", "issue"]}

pred = {t: final[t].predict(test[["text"]]) for t in final}     # 🔒 test খোলা হলো — একবারই
rng = np.random.default_rng(42)
def boot(y, p, cls, n=2000):                            # macro F1 আর একটি শ্রেণির recall, n বার
    idx = rng.integers(0, len(y), (n, len(y))); Y, P = y[idx], p[idx]
    f1s = [2 * ((Y == c) & (P == c)).sum(1) / np.maximum((Y == c).sum(1) + (P == c).sum(1), 1)
           for c in np.unique(y)]
    rec = ((Y == cls) & (P == cls)).sum(1) / np.maximum((Y == cls).sum(1), 1)
    return np.percentile(np.mean(f1s, 0), [2.5, 97.5]), np.percentile(rec, [2.5, 97.5])

for t, cls in [("sentiment", "negative"), ("issue", "delivery")]:
    y, p = test[t].to_numpy(), pred[t]
    labs = sorted(set(y))
    (lo, hi), (rlo, rhi) = boot(y, p, cls)
    print(f"\n{t} (test n={len(y)}): macro F1 {f1_score(y, p, average='macro'):.3f}"
          f"  95% CI [{lo:.3f}, {hi:.3f}] | {cls} recall CI [{rlo:.3f}, {rhi:.3f}]")
    print(pd.DataFrame(confusion_matrix(y, p, labels=labs), index=labs, columns=labs))
    print(classification_report(y, p, digits=3).split("\n\n")[1])

আসল আউটপুট:

sentiment (test n=90): macro F1 0.936  95% CI [0.877, 0.986] | negative recall CI [0.889, 1.000]
          negative  positive
negative        59         3
positive         2        26
    negative      0.967     0.952     0.959        62
    positive      0.897     0.929     0.912        28

issue (test n=90): macro F1 0.884  95% CI [0.816, 0.945] | delivery recall CI [0.634, 0.912]
          delivery  none  quality
delivery        28     8        0
none             0    30        2
quality          0     1       21
    delivery      1.000     0.778     0.875        36
        none      0.769     0.938     0.845        32
     quality      0.913     0.955     0.933        22

Sentiment ০.৯৩৬, CV-র ০.৯৫৮-এর কাছাকাছি; ৯০টির ৫টি ভুল। Issue ০.৮৮৪, এটিও CV-র কাছাকাছি। কিন্তু confusion matrix-এর প্রথম সারি দেখুন: ৩৬টি ডেলিভারি-অভিযোগের ৮টি “none” হয়ে গেছে। ডেলিভারির recall ২৮/৩৬ = ০.৭৭৮, CV-তে যা ছিল ০.৯১-এর মতো; আর CI [০.৬৩৪, ০.৯১২] চওড়া। উল্টো দিকে ডেলিভারির precision ১.০০০: model যখন “delivery” বলে, ঠিকই বলে, কিন্তু অনেক অভিযোগ চুপচাপ ছেড়ে দেয়। যে মাপটি অংশ ১-এ সবচেয়ে জরুরি বলেছিলাম, সেটিই দুর্বলতম। আর test-এ ডেলিভারির ভাগ ৩৬/৯০ = ০.৪০, train-এর ৪৩/১৯৩ ≈ ০.২২-এর প্রায় দ্বিগুণ: শেষ দুই মাসে অভিযোগের ধরন বদলেছে।

এবার পর্ব ৭২-এর প্রশ্ন: সবার জন্য কি সমান ভালো? এখানে দল মানে লিপি, কারণ যিনি রোমান হরফে লেখেন আর যিনি বাংলা হরফে, দুজনের অভিযোগই সমান জরুরি। সঙ্গে test-এর ভুলগুলো পড়া, শুধু বোঝার জন্য।

te = test.assign(script=test["text"].map(script_of))
rows = []
for g, part in te.groupby("script"):
    m = te["script"].eq(g).to_numpy()
    for t in final:
        y, p = part[t].to_numpy(), pred[t][m]
        rows.append({"group": g, "n": len(part), "task": t,
                     "F1": f1_score(y, p, average="macro"),
                     "acc": (y == p).mean(), "ভুল": int((y != p).sum())})
    y, p = part["issue"].to_numpy(), pred["issue"][m]
    rows[-1]["R_del"] = recall_score(y, p, labels=["delivery"], average="macro")
    rows[-1]["n_del"] = int((y == "delivery").sum())
print(pd.DataFrame(rows).round(3).to_string(index=False))

print("\ntest-এর ভুলগুলো (শুধু পড়া, model আর বদলাবে না):")
for t in final:
    for i in np.flatnonzero(test[t].to_numpy() != pred[t]):
        print(f"  {t[:4]} {test[t][i][:3]}→{pred[t][i][:3]} [{tags(test.text[i])}] {test.text[i][:44]}")

আসল আউটপুট (sentiment-এর সারিতে ডেলিভারির ঘর প্রযোজ্য নয়, তাই NaN):

 group  n      task    F1   acc  ভুল  R_del  n_del
bangla 55 sentiment 0.921 0.927    4    NaN    NaN
bangla 55     issue 0.853 0.836    9  0.636   22.0
 latin 35 sentiment 0.961 0.971    1    NaN    NaN
 latin 35     issue 0.935 0.943    2  1.000   14.0

test-এর ভুলগুলো (শুধু পড়া, model আর বদলাবে না):
  sent neg→pos [-] রাইডার খুব খারাপ ব্যবহার করলেন, খুব অপমানিত 
  sent pos→neg [negation] মান খারাপ না, দামও কম
  sent pos→neg [-] মায়ের জন্য এয়ার কুলার নিয়েছিলাম, খুব পছন্
  sent neg→pos [negation,latin] panjabi er map thik nai
  sent neg→pos [-] রাইডার খুব খারাপ ব্যবহার করলেন!!
  issu del→non [mixed] পণ্যের মান দারুণ, তবে ডেলিভারি একটু দেরীতে এ
  issu del→non [-] রাইডার খুব খারাপ ব্যবহার করলেন, খুব অপমানিত 
  issu del→non [mixed] পণ্যের মান দারুন, তবে ডেলিভারী একটু দেরিতে এ
  issu del→non [negation,mixed] অ্যাপে লেখা ডেলিভারড, কিন্তু আমি স্কুল ব্যাগ
  issu non→qua [negation] এত দাম দেওয়ার মতো না। আর কিনব না
  issu qua→non [negation,latin] panjabi er map thik nai
  issu del→non [mixed] অ্যাপে লেখা ডেলিভারড, কিন্তু আমি শাড়ি পাইনি
  issu del→non [negation] কুরিয়ার ফোন ধরে না, পার্সেল এখনো পাইনি, ঝটপ
  issu del→non [-] রাইডার খুব খারাপ ব্যবহার করলেন!!
  issu del→non [negation] কুরিয়ার ফোন ধরে না, পার্সেল এখনো পাইনি, তিন
  issu non→qua [latin] darun quality!!

EDA দেখে কেউ এটি আশা করতেন না: বাংলা হরফে ডেলিভারির recall ০.৬৩৬ (২২টির ৮টি মিস), রোমান হরফে ১.০০০ (১৪টি)। n ছোট, তাই পার্থক্যের মাপ অনিশ্চিত, কিন্তু ভুলগুলো পড়লে কারণ দেখা যায়: “রাইডার খুব খারাপ ব্যবহার করলেন” দুবার none, এমনকি positive, কারণ train-এ বাংলা হরফের “রাইডার” প্রায় সবসময় প্রশংসায় (“রাইডার ভাই খুব ভদ্র ছিলেন”)। লম্বা, মিশ্র বাক্যও হারিয়েছে; আর “ঝটপট” নামটি জুলাইয়ের আগে ডেটাতেই ছিল না, যা distribution shift-এর একটি ছোট নমুনা।

যা করবেন না: এই ভুল দেখে model বদলে আবার এই test-এ মাপা। যা করবেন: ফলটি model card-এ লেখা, বাংলা হরফের ডেলিভারি-অভিযোগ আরও label করা, ডেলিভারির সীমা train-এর OOF-এ বাছা (অনুশীলন ২), আর নতুন সংস্করণ সেপ্টেম্বরের রিভিউয়ে মাপা। ততদিন সব রিভিউ মানুষ দেখবেন; model শুধু ক্রম সাজাবে।

যাচাই-বিন্দু ৭: issue-এর matrix-এ প্রথম সারি ২৮, ৮, ০; বাংলা হরফে R_del ০.৬৩৬ (n_del ২২).

৮ · মিলে যাওয়া পুরোনো রিভিউ — TF-IDF, recall@3 আর e5-small

তৃতীয় কাজটি information retrieval: নতুন রিভিউয়ের সবচেয়ে কাছের ৩টি পুরোনো রিভিউ (পর্ব ৬৯)। “পুরোনো” মানে train-এর ১৯৩টি। মাপার জন্য queries.csv-এ ২০টি label দেওয়া প্রশ্ন, প্রতিটির সঙ্গে একটি topic; প্রাসঙ্গিক মানে একই topic-এর রিভিউ। প্রতিটি topic-এর অন্তত ৩টি রিভিউ আছে, তাই recall@3-এর ভাগফল min(৩, প্রাসঙ্গিকের সংখ্যা) = ৩: দেখানো ৩টির কয়টি প্রাসঙ্গিক (পর্ব ৬৯-এ প্রাসঙ্গিক ছিল ঠিক ৩টি, তাই সংজ্ঞা একই)।

Q = pd.read_csv("queries.csv")                         # ২০টি label দেওয়া প্রশ্ন (topic, query)
index = train[["id", "text", "topic"]].reset_index(drop=True)   # "অতীতের রিভিউ" = শুধু train
vecs = {"word": TfidfVectorizer(tokenizer=tok, token_pattern=None),
        "char": TfidfVectorizer(analyzer="char_wb", ngram_range=(2, 4))}
D = {n: v.fit_transform(index["text"]) for n, v in vecs.items()}    # প্রতিটি সারি L2 = 1

def top3(name, q):
    s = (D[name] @ vecs[name].transform([clean(q)]).T).toarray().ravel()   # cosine
    return np.argsort(-s, kind="stable")[:3], s

def recall_at_3(name):                                  # প্রাসঙ্গিক = একই topic-এর রিভিউ
    out = []
    for topic, q in zip(Q["topic"], Q["query"]):
        rel = index["topic"].to_numpy() == topic
        out.append(rel[top3(name, q)[0]].sum() / min(3, rel.sum()))
    return np.array(out)

R = pd.DataFrame({n: recall_at_3(n) for n in vecs}, index=Q["query"].str[:28])
print(R.round(2).to_string())
print("\nগড় recall@3:", R.mean().round(3).to_dict(), "| ০ পাওয়া প্রশ্ন:", (R == 0).sum().to_dict())
q = "কুরিয়ারকে ফোনে পাওয়া যায় না"
top, s = top3("char", q)
print("\n", q)
for i in top: print(f"  {s[i]:.3f} {index.topic[i]:10} {index.text[i][:44]}")

আসল আউটপুট:

                              word  char
query                                   
পার্সেল আসতে অনেক দেরি হচ্ছে  0.00  0.00
order dite 10 din lagse ekho  0.33  0.67
কুরিয়ারকে ফোনে পাওয়া যায়   0.00  0.00
delivered dekhacche kintu pa  0.00  0.33
ডেলিভারি ম্যান খারাপ আচরণ কর  0.00  0.33
rider beyadob chilo jhogra k  0.67  0.67
মেশিনটা চালু হয় না           0.67  0.67
charger kaj korche na         0.33  0.33
জামার মাপ বড় হয়ে গেছে       0.33  0.33
size milche na                0.67  1.00
ধোয়ার পর রং চলে গেছে         0.33  0.33
আসল পণ্য দেয়নি নকল দিয়েছে   1.00  1.00
চালে পোকা পেয়েছি             0.67  0.67
modhu te bhejal               0.00  0.00
দাম খুব চড়া                  0.00  0.67
অ্যাপে টাকা দিতে পারছি না     0.33  0.67
জিনিসটা খুব ভালো আবার নেব     0.67  1.00
quality khub valo             0.67  0.67
খুব দ্রুত হাতে পেয়েছি        1.00  1.00
rider khub bhodro chilo       0.33  0.33

গড় recall@3: {'word': 0.4, 'char': 0.533} | ০ পাওয়া প্রশ্ন: {'word': 6, 'char': 3}

 কুরিয়ারকে ফোনে পাওয়া যায় না
  0.392 price      দাম অনেক বেশি, বাজারে কম দামে পাওয়া যায়
  0.387 fastdeliv  কুরিয়ার ঠিক সময়ে ফোন করে দিয়ে গেছে, ধন্যব
  0.386 fastdeliv  কুরিয়ার ঠিক সময়ে ফোন করে দিয়ে গেছে, খুব খ

শব্দের TF-IDF ০.৪০০, অক্ষরের ০.৫৩৩; char-এর জয়ের বড় কারণ বানান-ভেদ (“size milche na” ১.০০, “দাম খুব চড়া” ০ থেকে ০.৬৭)। কিন্তু ৩টি প্রশ্নে দুটিই ০। “কুরিয়ারকে ফোনে পাওয়া যায় না”-র প্রথম ফল দামের অভিযোগ, শুধু “পাওয়া যায়” শব্দের জোরে; পরের দুটি “কুরিয়ার ঠিক সময়ে ফোন করে” — প্রশংসা, অভিযোগ নয়। শব্দ মিলেছে, অর্থ উল্টো।

ডেস্কটপে সত্যিকারের run: multilingual-e5-small

পর্ব ৬৯-এর একই model আর একই পিন করা revision: Hugging Face Hub-এর intfloat/multilingual-e5-small, revision 614241f, embedding-এর মাপ ৩৮৪; model card-এর metadata অনুযায়ী লাইসেন্স MIT (MIT License), ভাষার তালিকায় বাংলা আছে, Banglish-এর উল্লেখ নেই। Card-এর নিয়মে প্রশ্নে "query: ", রিভিউয়ে "passage: ". একই ১৯৩টি রিভিউ (পাতার ব্লক ১–২-এর হুবহু কোড), একই ২০টি প্রশ্ন, CPU only, cache থেকে অফলাইনে।

MODEL, REV = "intfloat/multilingual-e5-small", "614241f622f53c4eeff9890bdc4f31cfecc418b3"
m = SentenceTransformer(MODEL, revision=REV, device="cpu")
P = m.encode(["passage: " + t for t in index.text], normalize_embeddings=True)
def scores(name, q):
    if name == "e5": return P @ m.encode(["query: " + clean(q)], normalize_embeddings=True)[0]
    return (D[name] @ vecs[name].transform([clean(q)]).T).toarray().ravel()
~/bazarghar-ai$ HF_HUB_OFFLINE=1 python st_retrieval.py
Python 3.14.4, torch 2.15.0+cpu, sentence-transformers 6.2.0, scikit-learn 1.9.1
model intfloat/multilingual-e5-small rev 614241f | index (193, 384) float32 | 2 ms/review
word  recall@3 0.400  zero: 6
char  recall@3 0.533  zero: 3
e5    recall@3 0.567  zero: 2
                                          word  char    e5
পার্সেল আসতে অনেক দেরি হচ্ছে              0.00  0.00  0.33
কুরিয়ারকে ফোনে পাওয়া যায় না            0.00  0.00  0.67
ডেলিভারি ম্যান খারাপ আচরণ করেছে           0.00  0.33  1.00
মেশিনটা চালু হয় না                       0.67  0.67  0.00
charger kaj korche na                     0.33  0.33  0.00
আসল পণ্য দেয়নি নকল দিয়েছে               1.00  1.00  0.33
modhu te bhejal                           0.00  0.00  0.33
…
modhu te bhejal   (score range 0.749-0.841)
  0.841 praise     value for money, thank you
  0.834 food       mach theke gondho ashchilo
  0.830 fastdeliv  rider bhai khub vodro

সত্যিকারের run (word আর char-এর সংখ্যা পাতার সঙ্গে হুবহু মেলে; কিছু সারি “…” দিয়ে বাদ)। e5 গড়ে সামান্য এগিয়ে (০.৫৬৭ বনাম ০.৫৩৩), অর্থাৎ ২০টি প্রশ্নে মোট মাত্র দুটি বাড়তি সঠিক ফল। যেখানে শব্দ মেলে না, সেখানে জেতে (“ডেলিভারি ম্যান খারাপ আচরণ করেছে” ০.৩৩ থেকে ১.০০), কিন্তু “মেশিনটা চালু হয় না” আর “charger kaj korche na”-য় শূন্য, যেখানে TF-IDF ঠিক ফল পেয়েছিল। Banglish “modhu te bhejal”-এর প্রথম ফল “value for money”. সব score ০.৭৫–০.৮৪-এর সরু পরিসরে, card-এ যেমন বলা। সিদ্ধান্ত: ৪৭১ MB-র model এই মাপে স্পষ্ট লাভ দেয়নি; প্যাকেজে char TF-IDF, আর e5 পরের সংস্করণের প্রার্থী, বড় প্রশ্ন-তালিকায় মাপার পর।

যাচাই-বিন্দু ৮: পাতায় গড় recall@3 word ০.৪০০, char ০.৫৩৩.

৯ · প্যাকেজ, model card আর পুনরুৎপাদনযোগ্য প্রকল্প

শেষ ব্লক সবকিছু একটি joblib ফাইলে রাখে, আবার খোলে, আর predict_review(text) দুই label, আস্থা আর ৩টি কাছের রিভিউ ফেরত দেয়। পর্ব ৬৪-এর সতর্কতা মনে রাখুন: joblib ভেতরে pickle, আর অচেনা উৎসের pickle খোলা মানে অচেনা কোড চালানো। আরেকটি সূক্ষ্ম দিক: pickle function-কে নাম দিয়ে রাখে (bazar_text.tok), কোড দিয়ে নয়। tok যদি সরাসরি ব্লকে লেখা হতো, সংরক্ষণেই ব্যর্থ হতো (ডেস্কটপে সত্যিই দেখা: PicklingError: Can't pickle <function tok at …>: it's not found as __main__.tok); তাই function-গুলো module-এ রাখা।

import os, joblib

joblib.dump({"models": final, "search": vecs["char"], "D": D["char"], "index": index,
             "version": "triage-v1, 2026-11"}, "triage.joblib")
print("ফাইল:", round(os.path.getsize("triage.joblib") / 1024), "KB")
bundle = joblib.load("triage.joblib")                  # pickle: শুধু নিজের বানানো ফাইল খুলুন

def predict_review(text, k=3, b=bundle):
    t = clean(text)
    out = {}
    for task, m in b["models"].items():
        pr = m.predict_proba(pd.DataFrame({"text": [t]}))[0]
        out[task] = (m.classes_[pr.argmax()], round(float(pr.max()), 2))
    s = (b["D"] @ b["search"].transform([t]).T).toarray().ravel()
    out["similar"] = [(b["index"].id[i], round(float(s[i]), 2), b["index"].text[i][:34])
                      for i in np.argsort(-s, kind="stable")[:k]]
    return out

for r in ["parcel 9 din por ashlo, khub birokto 😡", "রাইডার খুব খারাপ ব্যবহার করলেন",
          "মাপ একদম ঠিক, রং ছবির মতোই 👍"]:
    o = predict_review(r)
    print(f"\n{r}\n  sentiment {o['sentiment']} | issue {o['issue']}")
    for sid, sc, txt in o["similar"]: print(f"   {sid} {sc:.2f} {txt}")

আসল আউটপুট:

ফাইল: 1202 KB

parcel 9 din por ashlo, khub birokto 😡
  sentiment ('negative', 0.98) | issue ('delivery', 0.95)
   r172 0.44 2 diner kotha bole 8 din lagaise, 
   r081 0.31 delivery khub late, 7 din por pela
   r035 0.30 delivary khub late, 11 din por pel

রাইডার খুব খারাপ ব্যবহার করলেন
  sentiment ('positive', 0.86) | issue ('none', 0.84)
   r070 0.38 রাইডার ভাই খুব ভদ্র ছিলেন, ধন্যবাদ
   r208 0.37 রাইডার বাসার নিচে আসতে রাজি হলেন ন
   r203 0.30 2 মাস ধরে ব্যবহার করছি, কোনো সমস্য

মাপ একদম ঠিক, রং ছবির মতোই 👍
  sentiment ('positive', 0.95) | issue ('none', 0.88)
   r069 0.71 রং ছবির মতোই
   r207 0.41 রং ছবির মতোই, পরিবারের সবাই খুশি 👍
   r004 0.33 শাড়ির রং ফ্যাকাশে, ছবির মতো না

প্রথম রিভিউ সহজ: negative ০.৯৮, delivery ০.৯৫, কাছের তিনটিই দেরির। দ্বিতীয়টি অংশ ৭-এর সেই ভুল, এবার উঁচু আস্থায়: positive ০.৮৬, none ০.৮৪. Calibration মানে গড়ে সম্ভাবনা বাস্তবের হারের কাছাকাছি, কোনো একটি রিভিউয়ে নয়; আর ১৯৩টিতে শেখা calibration নিজেও নড়বড়ে। তৃতীয়টি ঠিক, আর প্রথম কাছের রিভিউ r069, অংশ ৬-এ সারানো।

নিচের stepper প্রথম রিভিউটির পুরো যাত্রা দেখায় (সংখ্যা এই model থেকে, ডেস্কটপে একই version-এ মিলিয়ে দেখা)।

রিভিউ: “parcel 9 din por ashlo, khub birokto 😡”
ধাপ
এখন যা আছে
খেয়াল করুন
  1. কাঁচা লেখা। Banglish বাক্য, একটি অঙ্ক (৯), একটি রাগী emoji. মানুষ এক নজরে বোঝেন: দেরির অভিযোগ।
  2. পরিষ্কার। এখানে কিছু বদলায়নি, তবু ধাপটি বাদ যায় না: train আর নতুন রিভিউ একই নিয়মে না গেলে model নীরবে ভুল করে।
  3. Token. শব্দ আর emoji আলাদা token. “ashlo” train-এ ছিল না (সেখানে “ashe”, “pelam”), তাই শব্দের feature-এ সেটি হারায়; char n-gram-এ “ash”-এর মতো টুকরো তবু কাজে লাগে।
  4. Feature. ৪,৪৮০টি কলামের মাত্র ৯৬টি অশূন্য: ৭টি শব্দ, ৮৬টি অক্ষর-খণ্ড, ৩টি সংখ্যা (“!” নেই বলে একটি ০)। দৈর্ঘ্যের ঘর: log(১ + ৩৮) = ৩.৬৬৪, train-এর সর্বোচ্চ ৪.৩০৫ দিয়ে ভাগ করে ০.৮৫১.
  5. দুই model. Calibrated LinearSVC দুটি: negative ০.৯৮; delivery ০.৯৪৬. একটি রিভিউয়ের দুটি স্বাধীন উত্তর, তাই “positive + delivery”-র মতো জোড়াও সম্ভব।
  6. খোঁজ। Char TF-IDF-এ train-এর ১৯৩টির সঙ্গে cosine; সেরা তিনটি r172 (“2 diner kotha bole 8 din lagaise, khub birokto”), r081, r035, তিনটিই দেরির।

Model card

পর্ব ৭২-এর ছাঁচে, ভালো খবরের চেয়ে সীমা বেশি:

# Model card — বাজারঘর রিভিউ-triage v1 (কাল্পনিক)
## উদ্দেশ্য
- রিভিউয়ে sentiment, issue আর ৩টি কাছের রিভিউ দেখিয়ে কর্মীকে সাহায্য; সিদ্ধান্ত মানুষের
## যে কাজে নিষেধ
- গ্রাহককে ব্লক, রিভিউ মোছা, কর্মী বা কুরিয়ারের মূল্যায়ন
## Model আর ডেটা
- word + char TF-IDF + 4টি সংখ্যা; LinearSVC + sigmoid calibration (scikit-learn 1.8.0)
- train: 193টি কাল্পনিক রিভিউ, Jan–Jun 2026, 6টি label হাতে সারানো (label_fixes.csv)
## Test (Jul–Aug 2026, n = 90, একবার; বন্ধনীতে 95% bootstrap CI)
- sentiment macro F1 0.936 [0.877, 0.986]
- issue macro F1 0.884 [0.816, 0.945]; delivery recall 0.778 [0.634, 0.912]
- খোঁজ: recall@3 0.533 (char TF-IDF, 20টি প্রশ্ন)
## দলভিত্তিক ফল (test)
- বাংলা হরফ n = 55: issue F1 0.853, delivery recall 0.636 (22টির মধ্যে)
- রোমান হরফ n = 35: issue F1 0.935, delivery recall 1.000 (14টির মধ্যে)
## জানা সীমা
- বাংলা হরফের রাইডার ও কুরিয়ারের অভিযোগ মিস করে, কখনো উঁচু আস্থায়
- ব্যঙ্গ, নতুন নাম, লম্বা মিশ্র বাক্যে দুর্বল; আস্থা মানে সঠিকতা নয়; ছোট, বানানো ডেটা
## নজরদারি
- প্রতি সপ্তাহে 50টি নমুনা হাতে label; delivery recall 0.85-এর নিচে → পর্যালোচনা
- version: triage-v1 · data sha256 87a595b822f4 · uv.lock · মালিক: গ্রাহকসেবা-ডেটা দল

পাতা থেকে প্রকল্পে — uv, lockfile আর pytest (ডেস্কটপে সত্যিকারের run)

পর্ব ১২-এর কাঠামোয় একই কোড একটি ছোট প্রকল্পে: src layout-এ package bazarghar_triage (পাতার bazar_text.py হুবহু text.py), train.py আর predict.py, হাতে সারানো label-এর তালিকা data/label_fixes.csv, সেটিং params.json-এ, আর tests/-এ চারটি pytest test (pytest নিয়ে বিস্তারিত Python কোর্সের testing পর্বে)। Python 3.14.4, uv 0.11.28:

~/bazarghar-ai$ uv init --lib --vcs none --author-from none bazarghar-triage
Initialized project `bazarghar-triage` at `~/bazarghar-ai/bazarghar-triage`
~/bazarghar-ai$ cd bazarghar-triage
$ uv add "scikit-learn==1.8.0" "pandas>=3.0" joblib
Resolved 10 packages in 409ms
Installed 10 packages in 1.19s
 + joblib==1.6.0
 + numpy==2.5.4
 + pandas==3.1.1
 + scikit-learn==1.8.0
 + scipy==1.18.1
 …
$ uv add --dev pytest
 + pytest==9.2.0
 …
$ uv run pytest -q
...s                                                     [100%]
3 passed, 1 skipped in 3.61s
$ uv run python -m bazarghar_triage.train
{
  "n_train": 193,
  "n_test": 90,
  "data_sha256": "87a595b822f4",
  "sklearn": "1.8.0",
  "sentiment_macro_f1": 0.936,
  "issue_macro_f1": 0.884,
  "delivery_recall": 0.778
}
$ uv run pytest -q
....                                                     [100%]
4 passed in 0.88s
$ uv run python -m bazarghar_triage.predict "রাইডার খুব খারাপ ব্যবহার করলেন"
{"review": "রাইডার খুব খারাপ ব্যবহার করলেন",
 "sentiment": {"label": "positive", "confidence": 0.86},
 "issue": {"label": "none", "confidence": 0.84}, "similar": [ … ]}

প্রথমবার একটি test বাদ (s = skipped), কারণ সেটি সংরক্ষিত model চায়; train-এর পর চারটিই পাস। সংখ্যাগুলো পাতার সঙ্গে হুবহু এক, যদিও numpy আর pandas-এর version আলাদা; scikit-learn ইচ্ছা করে 1.8.0-এ বাঁধা, কারণ pickle করা model অন্য version-এ নিরাপদে খোলার নিশ্চয়তা নেই, তাই predict.py খোলার সময় version মেলায়। (তার আউটপুট আসলে indent করা JSON, এখানে কয়েক লাইনে গোছানো।) এবার কাল্পনিক “Rahim” হিসেবে commit, তারপর নতুন clone-এ পুরো পথ:

$ git init -q && git add . && git commit -q -m "Bazarghar review triage v1: code, data, lockfile, metrics"
$ git log --format="%h %an <%ae> %s"
bcaf48c Rahim <rahim@example.com> Bazarghar review triage v1: code, data, lockfile, metrics
$ git ls-files
.gitignore  .python-version  README.md  data/label_fixes.csv  data/reviews.csv
params.json  pyproject.toml  reports/metrics.json  src/bazarghar_triage/__init__.py
src/bazarghar_triage/data.py  src/bazarghar_triage/model.py  src/bazarghar_triage/predict.py
src/bazarghar_triage/py.typed  src/bazarghar_triage/text.py  src/bazarghar_triage/train.py
tests/test_triage.py  uv.lock
$ git clone -q bazarghar-triage check && cd check
$ uv sync --locked
Creating virtual environment at: .venv
Resolved 16 packages in 0.80ms
Installed 16 packages in 1.19s
 …
$ uv run python -m bazarghar_triage.train > /dev/null
$ git diff --exit-code reports/metrics.json && echo "metrics.json: no difference"
metrics.json: no difference

সত্যিকারের run (path নিরপেক্ষ করা, কিছু লাইন “…” দিয়ে বাদ, git ls-files-এর তালিকা পাশাপাশি সাজানো); Git চলেছে শুধু একটি আলাদা পরীক্ষাগার-ফোল্ডারে, আলাদা config দিয়ে। .gitignore-এ .venv/ আর models/, কারণ model ফাইল code থেকে আবার বানানো যায়। নতুন ফোল্ডারে lockfile একই ১৬টি package বসাল, আর metrics অক্ষরে অক্ষরে মিলল।

যাচাই-বিন্দু ৯: predict_review একটি dict দেয়, যার তিনটি চাবি sentiment, issue আর similar; আর নিজের clone-এ metrics.json-এ কোনো পার্থক্য নেই।

১০ · AI-এর যুগে — AI assistant-এর সঙ্গে প্রকল্প, আর আপনার নিজের প্রকল্পে

“বাংলা রিভিউ classify করার প্রকল্প বানিয়ে দাও” বললে AI assistant কয়েক মিনিটে ঝকঝকে notebook দেয়। এই পর্বের প্রতিটি ধাপ তার কোনো নীরব ভুল ধরার অনুশীলন:

AI যা প্রায়ই করেকীভাবে ধরবেন
এলোমেলো train_test_split, নকল বাদের আগেনকল-দল ভাগের দুই পাশে পড়ছে কি না গুনুন; সময় থাকলে সময় দিয়ে ভাগ
Vectorizer পুরো ডেটায় fit, তারপর CVসব রূপান্তর pipeline-এর ভেতরে কি না দেখুন
শুধু accuracy, শুধু train বা CV-র সংখ্যাmacro F1, জরুরি শ্রেণির recall, confusion matrix আর CI
Test দেখে সেটিং বদলায়, তারপর আবার সেই test-এ মাপেTest-এর ব্যবহার কতবার, কোডে খুঁজুন; একবারের বেশি হলে সংখ্যাটি আশাবাদী
পুরোনো বা বানানো API (LogisticRegression(multi_class=…), scikit-learn 1.8-এ TypeError)নিজের version-এর docs; চালিয়ে দেখা, প্রতিটি warning পড়া
Label-কে নির্ভুল ধরে, বা model-এর সন্দেহে স্বয়ংক্রিয়ভাবে label বদলায়সন্দেহভাজন হাতে পড়া, বদলের তালিকা ফাইলে রাখা

কাজ ভাগ করে চান, আর প্রতিটি ভাগে যাচাইযোগ্য আউটপুট। আসল গ্রাহকের রিভিউ, নাম বা ফোন নম্বর পেস্ট করবেন না; কয়েকটি বানানো সারিই যথেষ্ট।

আমার reviews.csv-তে কলাম: id, date, customer, text, sentiment (positive/negative), issue (delivery/quality/none). লেখা বাংলা, Banglish আর ইংরেজি মেশানো। Python 3.14, scikit-learn 1.8.0, pandas 3 দিয়ে কোড লেখো যা: ১. আগে NFC, zero-width মোছা আর lower দিয়ে পরিষ্কার করবে, প্রতিটি ধাপে কয়টি সারি বদলাল ছাপাবে। ২. নকল বাদ দেবে ভাগের আগে; তারপর ১ জুলাই ২০২৬ দিয়ে সময়ভিত্তিক ভাগ। test-কে শেষ ধাপের আগে কোথাও ব্যবহার করবে না। ৩. word আর char_wb TF-IDF একটি ColumnTransformer-এ, Pipeline-এর ভেতরে; StratifiedKFold(5) দিয়ে DummyClassifier, MultinomialNB, LogisticRegression, LinearSVC তুলনা করবে, macro F1 আর delivery-র recall, mean ± sd. ৪. কোনো warning চাপা দেবে না, আর multi_class-এর মতো সরানো parameter ব্যবহার করবে না। ৫. শেষে test-এ একবার: confusion matrix, classification_report, আর bootstrap দিয়ে 95% CI. ৬. কোনো সংখ্যা অনুমানে লিখবে না; আমি নিজে চালিয়ে দেখব।

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

আপনার নিজের প্রকল্পে

এই কাঠামো অন্য ডেটাতেও চলে। কয়েকটি পরের ধাপ, সহজ থেকে কঠিন:

  • নতুন মাসের test: সেপ্টেম্বরের ৫০টি রিভিউ নিজে লিখে label দিন, তারপর v1-কে কোনো বদল ছাড়া সেখানে মাপুন; ডেলিভারির recall কোথায় দাঁড়ায়?
  • ডেলিভারির threshold: অনুশীলন ২-এর পদ্ধতিতে train-এর OOF সম্ভাবনায় সীমা বাছুন, নতুন test-এ মাপুন, precision-এর দাম লিখুন।
  • দ্বিতীয় label-দাতা: একজন বন্ধু test-এর ৯০টি label নিজে দিন; দুজনের মিল (Cohen-এর kappa) model-এর বাস্তব সীমা দেখায়।
  • Multi-label: একটি রিভিউয়ে দুটি সমস্যা থাকলে (“দেরি আর ভাঙা”) issue-কে multi-label করুন, প্রতিটি সমস্যার জন্য আলাদা হ্যাঁ/না।
  • Embedding feature: e5-এর ৩৮৪টি সংখ্যা LogisticRegression-এ দিন (পর্ব ৭১), একই fold-এ TF-IDF-এর সঙ্গে তুলনা করুন।

আরও গভীরে: Label-এর ভুল খোঁজার পদ্ধতিগত রূপ Northcutt, Jiang আর Chuang-এর “Confident Learning” (Journal of Artificial Intelligence Research, ২০২১); Northcutt ও সহকর্মীদের আরেকটি কাজ (২০২১) জনপ্রিয় benchmark-এর test set-এর label-এর ভুল নিয়ে। Sigmoid calibration-এর উৎস John Platt (১৯৯৯), আর bootstrap-এর Bradley Efron (১৯৭৯)। বারবার একই holdout ব্যবহারের বিপদ নিয়ে Cynthia Dwork ও সহকর্মীদের “reusable holdout”-এর কাজ (২০১৫) পড়ার মতো।

পরীক্ষা ও ইন্টারভিউতে:

  • নকল বাদ ভাগের আগে কেন? একই লেখার কপি দুই ভাগে পড়লে test-এর উত্তর train-এ জানা থাকে, score ফুলে ওঠে।
  • ৩৬টি ডেলিভারির ২৮টি ধরা পড়ল, model ২৮ বার “delivery” বলল; recall আর precision? recall ২৮/৩৬ ≈ ০.৭৭৮, precision ২৮/২৮ = ১.
  • Error analysis test-এ না train-এর OOF-এ? OOF-এ; test দেখে বদলালে test আর নিরপেক্ষ থাকে না।
  • LinearSVC-এ সম্ভাবনা কীভাবে? CalibratedClassifierCV (sigmoid বা isotonic) দিয়ে; সরাসরি predict_proba নেই।

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

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

প্র ০১Test-এ ডেলিভারির recall খারাপ এল। এখনই model সারিয়ে একই test-এ আবার মাপলে ক্ষতি কী? সবাই তো ভালো model চায়।

ক্ষতিটি চোখে পড়ে না, তাই বিপজ্জনক। Test-এর কাজ একটি প্রশ্নের উত্তর দেওয়া: “এই model নতুন ডেটায় কেমন করবে?” যে মুহূর্তে test-এর ভুল দেখে model বদলান, test-এর ৯০টি রিভিউ আপনার শেখার উপকরণ হয়ে যায়। পরের সংখ্যা তখন বলে “এই ৯০টিতে কেমন”, যা আর নতুন ডেটার অনুমান নয়। একবার করলে প্রভাব ছোট হতে পারে; কিন্তু অভ্যাস হলে, দশবার বদলে দশবার মাপলে, আপনি অজান্তেই test-এ fit করছেন।

এর বাস্তব প্রমাণ আছে। Kaggle-এর মতো প্রতিযোগিতায় public leaderboard-এ বারবার জমা দিয়ে ওপরে ওঠা দল প্রায়ই লুকোনো private test-এ নিচে নেমে যায়, যাকে leaderboard-এ overfit বলা হয়। Cynthia Dwork ও সহকর্মীরা ২০১৫ সালে Science-এ দেখান, একই holdout বারবার ব্যবহারে ভুল আবিষ্কারের ঝুঁকি গাণিতিকভাবে বাড়ে, আর তার প্রতিকারের একটি পদ্ধতি প্রস্তাব করেন। Benjamin Recht ও সহকর্মীরা ২০১৯ সালে ImageNet-এর জন্য নতুন করে একটি test set বানিয়ে দেখেন, বহু বছর ধরে পুরোনো test-এ মাপা model-গুলোর accuracy নতুনটিতে উল্লেখযোগ্যভাবে কমে, যদিও তাদের পারস্পরিক ক্রম মোটামুটি একই থাকে। ক্ষতির একটি অংশ সম্ভবত এই বারবার ব্যবহারের ফল, আর একটি অংশ দুই ডেটার সূক্ষ্ম পার্থক্যের।

তাহলে খারাপ ফল নিয়ে কী করবেন? প্রথমত, যা পেয়েছেন তা লিখে ফেলুন: model card-এ ০.৭৭৮, CI-সহ। দ্বিতীয়ত, train-এর ভেতরেই উন্নতি খুঁজুন, OOF দিয়ে। তৃতীয়ত, উন্নত সংস্করণ মাপুন নতুন ডেটায়: বাজারঘরে প্রতি মাসে নতুন রিভিউ আসে, তাই সেপ্টেম্বর একটি স্বাভাবিক নতুন test. ডেটা কম হলে শুরুতেই দুটি test রাখা যায়: একটি উন্নয়নের শেষে, আরেকটি সত্যিই শেষবারের জন্য।

মূল উপলব্ধি: Test একটি প্রশ্নের একবারের উত্তর; উত্তর পছন্দ না হলে প্রশ্ন নয়, model বদলান, আর নতুন প্রশ্নপত্রে আবার পরীক্ষা দিন।

প্র ০২এই প্রকল্পে ৬টি ভুল label-এর ৫টি model ধরেছে, একটি ধরেছে error analysis. বাস্তবে label-এর মান কীভাবে নিশ্চিত করা যায়?

প্রথম শিক্ষা: label-ও ডেটা, আর ডেটায় ভুল থাকে। ভুলের উৎস অনেক: তাড়াহুড়ো, একঘেয়েমি, অস্পষ্ট নিয়ম (“পণ্য ভালো কিন্তু দেরি” positive না negative?), আর ভুল বোতাম। Northcutt ও সহকর্মীরা ২০২১ সালে দেখান, এমনকি বহুল ব্যবহৃত benchmark-এর test set-এও ভুল label আছে, আর কিছু ক্ষেত্রে ভুল সারালে কোন model “সেরা”, সেই ক্রমও বদলাতে পারে।

দ্বিতীয় শিক্ষা: যন্ত্রের সন্দেহ কাজে লাগে, কিন্তু অন্ধ জায়গা আছে। আমাদের ব্লক ৪ পাঁচটি ধরেছে, কারণ সেগুলোতে লেখা আর label স্পষ্টতই বিপরীত। ষষ্ঠটি, “রং ছবির মতোই”-কে negative বলা, model-এর নিজের পক্ষপাতের সঙ্গে মিলে গিয়েছিল: train-এ “রং ছবির” প্রায় সবসময় অভিযোগে। Model আর ভুল label একমত হলে কোনো সম্ভাবনা-ভিত্তিক খোঁজ সেটি ধরবে না। আর বারোটি সন্দেহভাজনের সাতটি ঠিক label ছিল, তাই স্বয়ংক্রিয়ভাবে বদলালে নতুন ভুল ঢুকত।

বাস্তবে তাই কয়েক স্তরের ব্যবস্থা লাগে। এক, লিখিত label-নির্দেশিকা (annotation guideline), উদাহরণসহ, বিশেষ করে সীমান্তের ঘটনার জন্য। দুই, একটি অংশে দুজন আলাদা মানুষের label, আর তাঁদের মিলের মাপ; Jacob Cohen ১৯৬০ সালে এর জন্য kappa প্রস্তাব করেন, যা কাকতালীয় মিল বাদ দিয়ে হিসাব করে। মিল কম হলে নির্দেশিকা অস্পষ্ট, model-এর দোষ নয়। তিন, model-এর সন্দেহভাজন তালিকা নিয়মিত মানুষের পর্যালোচনায়। চার, প্রতিটি বদলের লিখিত রেকর্ড, যেমন আমাদের label_fixes.csv, যাতে পরে কেউ জানেন কোন label কেন বদলেছিল। আর সবচেয়ে কঠিন কথা: test set-এর label সবচেয়ে বেশি যত্ন পাওয়ার যোগ্য, কিন্তু তা সারাতে হবে model-এর অনুমান না দেখে, স্বাধীনভাবে।

মূল উপলব্ধি: Model ভুল label খুঁজতে সাহায্য করে, বিচার করে না; লিখিত নিয়ম, দুজনের মিল আর বদলের রেকর্ড ছাড়া label-এর মান নিশ্চিত হয় না।

প্র ০৩বাংলা হরফের ডেলিভারি-অভিযোগে recall ০.৬৩৬, রোমান হরফে ১.০০০. n মাত্র ২২ আর ১৪. এই ফলকে কতটা গুরুত্ব দেবেন, আর কী করবেন?

দুটি ভুল এড়াতে হবে: সংখ্যাটিকে অবহেলা করা, আর তাকে চূড়ান্ত সত্য ধরা। ২২টির মধ্যে ৮টি মিস, আর একটি ভুল কমবেশি হলেই recall প্রায় ০.০৫ নড়ে। Bootstrap করলে এই দলের CI অনেক চওড়া হবে, সম্ভবত পুরো test-এর [০.৬৩৪, ০.৯১২]-এর চেয়েও। তাই “বাংলা হরফে model ৩৬% কম ভালো” বলা ভুল নির্ভুলতা। কিন্তু দিকটির পক্ষে আরও প্রমাণ আছে: ভুলগুলো পড়লে একটি ধারা দেখা যায়, “রাইডার খুব খারাপ ব্যবহার করলেন” দুবার, “কুরিয়ার ফোন ধরে না” দুবার, “অ্যাপে লেখা ডেলিভারড, কিন্তু …” দুবার। কারণও বোধগম্য: train-এ বাংলা হরফের “রাইডার” প্রায় সবসময় প্রশংসায় এসেছিল।

এটি কেন শুধু সংখ্যার প্রশ্ন নয়: লিপি মানুষের সঙ্গে যুক্ত। কেউ বাংলা কিবোর্ডে স্বচ্ছন্দ, কেউ রোমান হরফে; বয়স, যন্ত্র আর অভ্যাস অনুযায়ী এই ভাগ হতে পারে বলে সাধারণভাবে মনে করা হয়, যদিও বাজারঘরের মতো কাল্পনিক দোকানে আমরা তা মাপিনি। যে দলের অভিযোগ model বেশি মিস করে, তাদের সমস্যা দেরিতে মেটে; এটিই fairness-এর বাস্তব রূপ, কোনো বিমূর্ত সূত্র নয় (পর্ব ৭২)।

করণীয়, ক্রমে: (১) model card-এ দলভিত্তিক সংখ্যা n-সহ লেখা। (২) বাংলা হরফের ডেলিভারি-অভিযোগ, বিশেষ করে রাইডার আর কুরিয়ারের, আরও label করা; সমস্যাটি মূলত ডেটার। (৩) ডেলিভারির জন্য কম সীমা বাছা train-এর OOF-এ; অনুশীলন ২-এ দেখবেন ০.৩-এ OOF recall ০.৯৩০ হয়, precision সামান্য কমে। (৪) Deploy-এর পর প্রতি সপ্তাহে দুই দলের নমুনা হাতে label করে recall নজরে রাখা। (৫) ততদিন model-কে শুধু ক্রম সাজানোর কাজে, প্রতিটি রিভিউ মানুষ দেখবেন। আর মনে রাখবেন, রোমান হরফের ১.০০০-ও ১৪টি থেকে; সেখানেও দুর্বলতা লুকিয়ে থাকতে পারে।

মূল উপলব্ধি: ছোট দলের সংখ্যা প্রমাণ নয়, সংকেত; সংকেতটি ভুলের ধারা দিয়ে যাচাই করুন, লিখে রাখুন, আর ডেটা ও নজরদারি দিয়ে সাড়া দিন।

প্র ০৪e5-small পেয়েছে ০.৫৬৭, char TF-IDF ০.৫৩৩. তবু প্যাকেজে TF-IDF রাখা হলো। এটি কি পুরোনো পদ্ধতির প্রতি অন্ধ টান?

না, এটি মাপা সিদ্ধান্ত, আর ভবিষ্যতে উল্টেও যেতে পারে। সংখ্যাটি দিয়ে শুরু করুন: ২০টি প্রশ্নে প্রতিটির সর্বোচ্চ ৩টি সঠিক, মোট ৬০টি সুযোগ। ০.৫৬৭ বনাম ০.৫৩৩ মানে ৩৪ বনাম ৩২টি সঠিক ফল, অর্থাৎ মাত্র দুটি বাড়তি। প্রশ্নভিত্তিক তালিকায় e5 কয়েকটিতে স্পষ্ট জিতেছে (“ডেলিভারি ম্যান খারাপ আচরণ করেছে”), কয়েকটিতে স্পষ্ট হেরেছে (“মেশিনটা চালু হয় না”, “charger kaj korche na”), আর Banglish-এ দুর্বল ছিল, পর্ব ৬৯-এর মতোই। ২০টি প্রশ্নে এমন পার্থক্য দৈবের সীমার ভেতরে; বড় প্রশ্ন-তালিকা ছাড়া একে জয় বলা যায় না।

অন্যদিকে খরচ: weight ফাইল প্রায় ৪৭১ MB, PyTorch-এর মতো ভারী library, প্রতিটি রিভিউয়ে কয়েক millisecond, model-এর version আর revision সামলানো, আর বাইরের সেবায় না পাঠিয়ে নিজের সার্ভারে চালানোর ব্যবস্থা। Char TF-IDF-সহ পুরো প্যাকেজ প্রায় ১.২ MB, ব্রাউজারেও চলে, আর কেন একটি ফল এল তা শব্দ ও অক্ষর দেখে ব্যাখ্যা করা যায়। ছোট দোকানের জন্য এটি গুরুতর বিবেচনা।

তবে embedding-এর পক্ষের যুক্তিও শক্ত। শব্দ না মিলেও অর্থ ধরতে পারে, আর ডেটা বড় ও বৈচিত্র্যময় হলে, যেখানে একই অভিযোগ শতভাবে লেখা হয়, এই সুবিধা বাড়ার কথা। অনেক বাস্তব ব্যবস্থা তাই hybrid খোঁজ ব্যবহার করে: TF-IDF বা BM25-এর score আর embedding-এর score মিলিয়ে, যাতে দুজনের শক্তি একসঙ্গে আসে। আমাদের পরিকল্পনা: বাংলা ও Banglish আলাদা করে অন্তত ১০০টি প্রশ্নের তালিকা, যা লিখবেন অন্য কেউ, তারপর তিন পদ্ধতি আর hybrid, প্রশ্নভিত্তিক জোড়া তুলনায়।

মূল উপলব্ধি: নতুন model বাছাইয়ের প্রশ্ন “কোনটি আধুনিক” নয়, “মাপা লাভ কি খরচের চেয়ে বড়”; ছোট পরীক্ষায় অল্প জয় নিয়ে ভারী ব্যবস্থা আনবেন না।

প্র ০৫পাতার ১২টি ব্লকই তো কাজ করে। কেন আলাদা src package, lockfile আর pytest-এর ঝামেলা? Notebook কি যথেষ্ট নয়?

এটি সম্প্রদায়ের পুরোনো তর্ক, আর দুই পক্ষেরই যুক্তি আছে। ২০১৮ সালের JupyterCon-এ Joel Grus-এর “I don't like notebooks” বক্তৃতা বহুল আলোচিত হয়: লুকোনো অবস্থা, ক্রম ছাড়া চালানো cell, test লেখার কষ্ট। উল্টো দিকে notebook-এর প্রবক্তারা বলেন, অনুসন্ধান, chart আর ব্যাখ্যা একসঙ্গে রাখতে এর তুলনা নেই (পর্ব ৮)। এই পর্বের পাতাটি আসলে একটি notebook: ক্রমে চলা ব্লক, ভাগ করা namespace. শেখা আর অনুসন্ধানে চমৎকার।

কিন্তু “চলে” আর “অন্য কেউ, অন্য দিন, অন্য কম্পিউটারে হুবহু চালাতে পারে” আলাদা কথা। এই পর্বেই তিনটি জায়গায় পার্থক্য স্পষ্ট হলো। এক, pickle: function এই পাতার ব্লকে (আলাদা namespace-এ) লিখলে model সংরক্ষণই ব্যর্থ, কারণ pickle শুধু নাম রাখে; module-এ রাখলে চলে, আর package-এ রাখলে যেকোনো জায়গা থেকে import হয়। দুই, version: পাতার scikit-learn 1.8.0, ডেস্কটপের অন্য venv-এ 1.9.1; pickle করা model version-এর মধ্যে নিরাপদে চলবে, এমন নিশ্চয়তা নেই, তাই lockfile আর predict.py-এর version-যাচাই। তিন, test: test_split_has_no_overlap_and_no_shared_text প্রতিবার নিশ্চিত করে যে কেউ ভবিষ্যতে নকল বাদের ক্রম বদলালেও leakage ফিরে আসবে না। এগুলো ছোট জিনিস, কিন্তু ছয় মাস পরে কেউ “সংখ্যাটি কোথা থেকে এল” জিজ্ঞেস করলে এরাই উত্তর।

বাস্তবের ভালো অভ্যাস তাই মাঝামাঝি: অনুসন্ধান notebook-এ, আর যে কোড টিকে থাকবে (পরিষ্কার, feature, train, predict) তা package-এ, test-সহ, notebook সেই package import করে। Git-এ code, ছোট ডেটা, lockfile আর metrics; model ফাইল code থেকে আবার বানানো যায়। আর শেষ পরীক্ষা একটিই: নতুন clone, uv sync --locked, train, আর metrics-এ কোনো পার্থক্য নেই। এই পরীক্ষাটি CI-তে বসালে প্রতিটি পরিবর্তনের পর নিজে থেকেই চলে।

মূল উপলব্ধি: Notebook ভাবনার খাতা, package হস্তান্তরের দলিল; প্রকল্প শেষ হয় তখনই, যখন অন্য কেউ একটি কমান্ডে আপনার সংখ্যা হুবহু ফিরে পান।

অনুশীলন

হাতে হিসাব, কোড, নকশার সমালোচনা আর আউটপুট অনুমান; প্রতিটির উত্তর নিচে লুকানো আছে।

  1. Confusion matrix থেকে হাতে (হিসাব): একটি তিন-শ্রেণির test-এর matrix, সারি = আসল, কলাম = অনুমান, ক্রম delivery, none, quality: delivery [২৮, ৮, ০], none [০, ৩০, ২], quality [০, ১, ২১]. (ক) প্রতিটি শ্রেণির precision, recall আর F1; (খ) macro F1; (গ) model যদি ৮টি মিসের সবগুলো ধরত কিন্তু তার জন্য ৪টি none-কে ভুল করে delivery বলত, ডেলিভারির precision আর recall কত হতো?
    • delivery: precision ২৮/২৮ = ১.০০০, recall ২৮/৩৬ = ০.৭৭৮, F1 = ২ × ১ × ০.৭৭৮ / ১.৭৭৮ = ০.৮৭৫.
    • none: কলামে ৮ + ৩০ + ১ = ৩৯, precision ৩০/৩৯ = ০.৭৬৯; recall ৩০/৩২ = ০.৯৩৮; F1 ০.৮৪৫.
    • quality: precision ২১/২৩ = ০.৯১৩, recall ২১/২২ = ০.৯৫৫, F1 ০.৯৩৩.
    • macro F1 = (০.৮৭৫ + ০.৮৪৫ + ০.৯৩৩) / ৩ = ০.৮৮৪, অংশ ৭-এর সংখ্যা।
    • (গ): delivery-র সারি [৩৬, ০, ০], আর কলামে ৩৬ + ৪ = ৪০: recall ৩৬/৩৬ = ১.০০০, precision ৩৬/৪০ = ০.৯০০. বাজারঘরের জন্য এই লেনদেন সম্ভবত ভালো: চারটি অপ্রয়োজনীয় দ্রুত-দেখার দাম আটটি মিস করা অভিযোগের চেয়ে কম।
  2. ডেলিভারির সীমা বাছা (কোড): ব্লক ১–৮ চালানোর পরে একটি ব্লক লিখুন যা চূড়ান্ত issue pipeline-এর train-এ OOF সম্ভাবনা নেয় (cross_val_predict(..., method="predict_proba"), একই folds), তারপর নিয়ম “P(delivery) ≥ t হলে delivery, নইলে সবচেয়ে বড় সম্ভাবনার শ্রেণি” দিয়ে t = ০.৫ থেকে ০.১ পর্যন্ত ডেলিভারির recall, precision আর macro F1 ছাপায়। কোন t বাছবেন, আর কেন test দিয়ে নয়?
    from sklearn.metrics import precision_score
    from sklearn.calibration import CalibratedClassifierCV
    pipe = make_pipeline(make_features(), CalibratedClassifierCV(LinearSVC(**best["issue"]), cv=5))
    P = cross_val_predict(pipe, train[["text"]], train["issue"], cv=folds, method="predict_proba")
    classes = np.array(["delivery", "none", "quality"])        # sorted, যেমন classes_
    y = train["issue"].to_numpy()
    for t in [0.5, 0.4, 0.3, 0.2, 0.15, 0.1]:
        p = np.where(P[:, 0] >= t, "delivery", classes[P.argmax(1)])
        print(f"t={t:.2f}  delivery recall {recall_score(y, p, labels=['delivery'], average='macro'):.3f}"
              f"  precision {precision_score(y, p, labels=['delivery'], average='macro'):.3f}"
              f"  macro F1 {f1_score(y, p, average='macro'):.3f}")

    সত্যিকারের run (ডেস্কটপ, scikit-learn 1.8.0, পাতার ব্লক ১–৮-এর পরে; এখানে label সারানো হয়েছিল পাঁচটি, r069 তখনো আগের মতো):

    t=0.50  delivery recall 0.884  precision 0.927  macro F1 0.886
    t=0.40  delivery recall 0.907  precision 0.907  macro F1 0.887
    t=0.30  delivery recall 0.930  precision 0.889  macro F1 0.892
    t=0.20  delivery recall 0.930  precision 0.755  macro F1 0.855
    t=0.15  delivery recall 0.977  precision 0.689  macro F1 0.843
    t=0.10  delivery recall 1.000  precision 0.544  macro F1 0.760

    t = ০.৩ একটি যুক্তিসংগত বাছাই: recall ০.৮৮৪ থেকে ০.৯৩০, precision সামান্য কমে ০.৮৮৯, macro F1-ও কমে না। ০.১৫-এ recall ০.৯৭৭ কিন্তু প্রতি তিনটি “delivery”-র একটি ভুল; দলের সময় কতটা, তার ওপর নির্ভর করে। Test দিয়ে সীমা বাছলে test আর নিরপেক্ষ থাকে না; আসল মাপ হবে নতুন মাসের ডেটায়। ৪৩টি ডেলিভারিতে এক ধাপ মানে ০.০২৩, তাই সংখ্যাগুলোও নড়বড়ে।

  3. নকশার সমালোচনা: এক সহকর্মীর পরিকল্পনা: “সব ৩০৭টি রিভিউ একসঙ্গে TfidfVectorizer-এ fit করব। তারপর train_test_split(random_state=1), তারপর নকল বাদ। পাঁচটি model test-এ চালিয়ে যেটির accuracy বেশি, সেটি নেব। ভুল label থাকলে model-এর অনুমান দিয়ে স্বয়ংক্রিয়ভাবে বদলে দেব। ইন্টারনেটে একটি ‘bangla_sentiment.pkl’ পেয়েছি, সেটিও তুলনায় রাখব।” কী কী ভুল, আর কীভাবে সাজাবেন?
    • পুরো ডেটায় vectorizer: test-এর শব্দ idf-এ ঢোকে; pipeline-এর ভেতরে fit, শুধু train-এ।
    • এলোমেলো ভাগ, নকল বাদ পরে: নকল দুই পাশে পড়ে, আর ভবিষ্যৎ ডেটার মহড়া হয় না; আগে নকল বাদ, তারপর সময় দিয়ে ভাগ।
    • Test দিয়ে বাছাই: পাঁচটির সেরাটি test-এ বাছলে সেই সংখ্যা আশাবাদী; বাছাই CV-তে, test শেষে একবার।
    • শুধু accuracy: negative বেশি, আর ডেলিভারির মিস দামি; macro F1, ডেলিভারির recall, confusion matrix আর CI.
    • স্বয়ংক্রিয় label বদল: সন্দেহভাজনের অর্ধেকের বেশি ঠিক label ছিল; মানুষ পড়বেন, বদল লিখে রাখা হবে।
    • অচেনা .pkl: pickle খোলা মানে অচেনা কোড চালানো; পরিচিত hub, model card আর safetensors-এর মতো নিরাপদ ফরম্যাট (পর্ব ৭১)।
  4. আউটপুট অনুমান: নিচের কোড কী ছাপবে?
    import re
    import pandas as pd
    df = pd.DataFrame({"date": ["2026-06-29", "2026-07-02", "2026-06-10", "2026-07-15"],
                       "text": ["valo!!", "valo", "kharap", "Kharap 😡"]})
    df["key"] = df["text"].str.lower().map(lambda t: re.sub(r"[^\w]+", "", t))
    d = df.drop_duplicates("key")
    print(len(d), d["text"].tolist())
    print((pd.to_datetime(d["date"]) >= "2026-07-01").sum(), df["key"].tolist())

    সত্যিকারের run (ডেস্কটপ, Python 3.14.4, pandas 3.0.2):

    2 ['valo!!', 'kharap']
    0 ['valo', 'valo', 'kharap', 'kharap']

    “!!”, space আর emoji মুছে key হয় valo, valo, kharap, kharap (lower() আগে, তাই “Kharap” আর “kharap” এক)। drop_duplicates প্রথমটি রাখে: ২৯ জুনের “valo!!” আর ১০ জুনের “kharap”. ফলে জুলাইয়ের দুটি রিভিউই নকল হিসেবে বাদ, test-এ ০টি। আগে ভাগ করলে দুটি জুলাইয়ের কপি test-এ যেত, উত্তর train-এ জানা অবস্থায়। এখানে [^\w] চলেছে কারণ লেখা রোমান হরফে; বাংলায় \w মাত্রা চিহ্ন বাদ দেয়, তাই ব্লক ২-এ বাংলা block যোগ করা।

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

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

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

  1. মাপ, baseline আর test-এর তারিখ লিখে ফেলা
  2. পরিষ্কার করা আর cleaning log রাখা
  3. নকল বাদ দেওয়া, তারপর সময় দিয়ে test তালাবন্ধ করা
  4. Train-এ EDA, label-এর ভুল খোঁজা, CV দিয়ে model আর সেটিং বাছা
  5. চূড়ান্ত model-কে test-এ একবার মাপা, CI আর দলভিত্তিক ফলসহ
  6. সংরক্ষণ, model card আর lockfile-সহ প্রকল্প

৩০০টি রিভিউয়ের ডেটায় ২০টি হুবহু নকল আছে, আর ডেটা জানুয়ারি থেকে আগস্টের। শেষ দুই মাসকে test করতে হলে কোন ক্রম সবচেয়ে ঠিক?

  • আগে সময় দিয়ে ভাগ, তারপর প্রতিটি ভাগে আলাদা করে নকল বাদ
  • আগে পুরো ডেটায় নকল বাদ, তারপর তারিখ দিয়ে ভাগ
  • নকল রেখে দেওয়া, কারণ বাস্তবেও নকল আসে
  • এলোমেলো train_test_split, তারপর নকল বাদ

একটি issue-classifier-এর test-এ ৩৬টি ডেলিভারি-অভিযোগের ২৮টি ঠিক ধরা পড়েছে, আর model মোট ২৮ বার “delivery” বলেছে। ডেলিভারির recall আর precision কত?

  • recall ১.০০০, precision ০.৭৭৮
  • দুটিই ০.৮৭৫
  • recall ০.৭৭৮, precision ০.৭৭৮
  • recall ০.৭৭৮, precision ১.০০০

Train-এর out-of-fold সম্ভাবনা দিয়ে ভুল label খোঁজা হলো; সবচেয়ে সন্দেহজনক ১২টির মধ্যে ৫টি সত্যিই ভুল। পরের সঠিক পদক্ষেপ কোনটি?

  • ১২টি হাতে পড়ে যেগুলো সত্যিই ভুল শুধু সেগুলো ঠিক করা, আর বদলগুলো একটি ফাইলে লিখে রাখা
  • ১২টিরই label model-এর অনুমানে বদলে দেওয়া
  • কিছু না করা, কারণ model ভুল label-এও মানিয়ে নেয়
  • ১২টিই ডেটা থেকে মুছে ফেলা

Error analysis-এর পর bigram যোগ করে একই ৫টি fold-এ মাপা হলো: একটি ভুল সারল, দুটি নতুন ভুল, গড় macro F1 −০.০০৬. দশবার আলাদা ভাগে চালালে ৫০টির ৯টিতে ভালো, ১১টিতে খারাপ। কী করা উচিত?

  • bigram রাখা, কারণ একটি ভুল সেরেছে
  • দুই সংস্করণই test-এ চালিয়ে ভালোটি নেওয়া
  • bigram বাদ দিয়ে সরল unigram রাখা
  • ভালো ফল না আসা পর্যন্ত random_state বদলানো

একটি রিভিউ “রাইডার খুব খারাপ ব্যবহার করলেন” পেল sentiment positive, আস্থা ০.৮৬, যদিও model-টি sigmoid calibration-সহ। এর সবচেয়ে ঠিক ব্যাখ্যা কোনটি?

  • Calibration থাকলে এমন হতে পারে না; কোডে নিশ্চয়ই বাগ
  • Calibration গড়ে সম্ভাবনা বাস্তবের হারের কাছে আনে; একটি রিভিউয়ে উঁচু আস্থাও ভুল হতে পারে
  • ০.৮৬ মানে এই রিভিউটি ৮৬% নিশ্চিতভাবে positive
  • LinearSVC বাংলা হরফ পড়তে পারে না

Model ফাইলটি joblib.dump দিয়ে সংরক্ষণ করার সময় PicklingError: Can't pickle <function tok> এল; tok ছিল exec দিয়ে আলাদা namespace-এ চালানো একটি কোড-ব্লকে লেখা function. সবচেয়ে ভালো সমাধান কোনটি?

  • TfidfVectorizer থেকে tokenizer সরিয়ে আবার train না করেই সংরক্ষণ
  • tok-কে lambda বানিয়ে দেওয়া
  • compress=9 দিয়ে আবার dump করা
  • tok-কে একটি importable module-এ (যেমন package-এর text.py) রেখে সেখান থেকে import করা

শব্দকোষ

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

মনে রাখুন

  1. আগে নিয়ম: মাপ, baseline আর test-এর তারিখ ডেটা দেখার আগে লিখুন; ব্যয়বহুল ভুলের জন্য আলাদা মাপ রাখুন, যেমন এখানে ডেলিভারির recall.
  2. নকল বাদ, তারপর ভাগ: এখানে ৪টি নকল-দল জুলাইয়ের দুই পাশে ছিল। সময় দিয়ে ভাগ ভবিষ্যতের মহড়া দেয়; test একবারই খোলে।
  3. Label-ও ডেটা: OOF সম্ভাবনা ৬টির ৫টি ভুল label দেখিয়েছে, ষষ্ঠটি এসেছে error analysis-এ; মানুষ বিচার করেন, বদল লেখা থাকে।
  4. সৎ সংখ্যা: CV-তে ডেলিভারির recall ০.৯১, test-এ ০.৭৭৮, বাংলা হরফে ০.৬৩৬ (n = ২২). Bigram লাভ দেয়নি; সংখ্যা যেমন এসেছে, CI আর n-সহ।
  5. হস্তান্তর: function module-এ, version বাঁধা, lockfile, pytest, model card; নতুন clone-এ একই metrics মানে প্রকল্প শেষ।

পরের পর্বে: পর্ব ৭৪ (AI প্রকল্পের জীবনচক্র — সমস্যা বাছাই থেকে maintenance আর পরের পথ)-এ এই প্রকল্পকে বড় ছবিতে দেখবেন: সমস্যা আদৌ AI দিয়ে সমাধানযোগ্য কি না, বানানো না কেনা, deploy আর নজরদারি, drift এলে কী করবেন, আর এই কোর্সের পর কোন পথে এগোবেন।

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

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