এই পর্বে যা শিখবেন
- বাংলা ও মিশ্র লেখা পরিষ্কার করা আর একই বাক্য তিনভাবে token-এ ভাঙা: whitespace, বাংলা-বান্ধব regex আর character n-gram
- Subword tokenizer-এর ধারণা, stopword তালিকার ঝুঁকি আর বাংলায় stemming কেন কঠিন
- হাতে, NumPy-তে আর
TfidfVectorizer-এ TF-IDF মেলানো, আর cosine similarity দিয়ে মিল খোঁজা - তিনটি classifier আর দুই রকম feature-এর সৎ তুলনা, error analysis আর bigram দিয়ে negation ধরা
মডিউল ১১-এর শেষ পর্ব এটি। এতক্ষণ বাজারঘরের টেবিলের ডেটা নিয়ে কাজ করেছি: সংখ্যা, শ্রেণি, তারিখ। কিন্তু দোকানের সবচেয়ে সমৃদ্ধ ডেটার একটি হলো গ্রাহকের লেখা রিভিউ, আর তা আসে বাংলায়, Banglish-এ, ইংরেজিতে, প্রায়ই এক বাক্যেই তিনটি। পর্ব ৫১-এ বাংলা SMS দিয়ে bag of words, scikit-learn-এর default token_pattern-এর বাংলা-সমস্যা আর char_wb n-gram দেখেছেন। এই পর্ব সেখান থেকে এগিয়ে পুরো কাজের ধারা দেখায়: পরিষ্কার, token, ওজন, খোঁজা, শ্রেণিবিভাগ আর ভুলের ময়নাতদন্ত। চালানো যায় এমন চারটি ব্লক পাতাতেই চলে (ব্রাউজারের Python, scikit-learn 1.8.0); প্রথমবার scikit-learn নামতে প্রায় ১০ সেকেন্ড লাগে। চারটি ব্লক একই namespace ভাগ করে (data-shared), তাই পরের ব্লক আগে চালালে আগেরগুলো নিজে থেকে আগে চলে।
১ · কাজটি — বাজারঘরের ১১৯টি রিভিউ, আর কেন এগুলো কঠিন
বাজারঘর চায় প্রতিদিনের নতুন রিভিউ থেকে নেতিবাচকগুলো আলাদা করে গ্রাহকসেবা দলের কাছে আগে পাঠাতে, যাতে নষ্ট পণ্য বা দেরির অভিযোগ দ্রুত মেটানো যায়। এটি sentiment analysis-এর একটি সরল রূপ: প্রতিটি রিভিউয়ের label হয় positive, নয় negative. সত্যিকারের গ্রাহকের লেখা আমরা ব্যবহার করব না; নিচের ১১৯টি রিভিউ এই পর্বের জন্য বানানো, কোনো আসল ব্র্যান্ড বা মানুষের নাম নেই।
পাড়ার দোকানের হিসাবের খাতায় ক্রেতারা নিজের হাতে মন্তব্য লিখে যান। কেউ লেখেন “খুব ভালো”, কেউ “valo na”, কেউ শুধু একটা রাগী মুখ আঁকেন, কেউ লেখেন “বাহ! তিন সপ্তাহে মাল এল, কী দ্রুত”. দোকানদার পড়েই বোঝেন শেষেরটি প্রশংসা নয়। যন্ত্রকে এই খাতা পড়াতে হলে আগে হাতের লেখা এক ছাঁচে আনতে হবে, তারপর শব্দ গুনতে হবে, আর মেনে নিতে হবে যে কিছু কথা গোনা দিয়ে ধরা যায় না।
ডেটাকে ইচ্ছা করে বাস্তবের মতো এলোমেলো রাখা হয়েছে:
- তিন লিপি-রূপ: বাংলা হরফ, রোমান হরফে বাংলা (Banglish: “khub valo”, “Bhalo na”) আর ইংরেজি; একই বাক্যে মেশানো লেখাকে বলে code-mixing. ৩৮টি রিভিউয়ে কোনো বাংলা হরফই নেই।
- বানানের বৈচিত্র্য: valo, bhalo, ভালো; দারুণ আর দারুন; “bajey” আর “baje”.
- অঙ্ক আর emoji: “৫ স্টার” আর “5 star”, “১/৫”, 👍 😡 🙄 ⭐.
- অদৃশ্য সমস্যা: ১৩টি “য়” আর ৪টি “ড়” এক code point-এ (U+09DF, U+09DC) লেখা, আর পাঁচটি জায়গায় copy-paste থেকে আসা zero-width অক্ষর।
- Negation আর ব্যঙ্গ: “ভালো না”, “valo laglo na”, উল্টো দিকে “দাম হিসেবে খারাপ না” (positive), আর তিনটি sarcasm: “বাহ! মাত্র তিন সপ্তাহে ডেলিভারি…”, “অসাধারণ! অর্ডার দিলাম লাল, পেলাম সবুজ…”, “ধন্যবাদ বাজারঘর, পচা সবজি পাঠানোর জন্য 👏”.
ফাইলটি পাতার ভেতরে reviews.csv নামে রাখা (দুটি কলাম: label, text); যেকোনো ব্লকে pd.read_csv("reviews.csv") দিয়ে পড়া যায়।
একটি রিভিউয়ের যাত্রা: কাঁচা লেখা থেকে label
From raw review to label: clean → tokens → TF-IDF vector → linear classifier
সৎ সতর্কতা: ১১৯টি রিভিউ, একজন লেখকের বানানো। এখানে যত সংখ্যা পাবেন, সবই এই ছোট সংগ্রহের এক-দুটি রান; বাস্তবের হাজারো রিভিউয়ে ফল অন্যরকম হবে। লক্ষ্য পদ্ধতি আর সন্দেহ করার অভ্যাস শেখা, কোনো “৯০% নির্ভুল” দাবি নয়।
২ · আগে পরিষ্কার — NFC, অদৃশ্য অক্ষর আর য়/ড়-এর দুই রূপ
সংক্ষেপে মনে করিয়ে দিই (পূর্ণ ব্যাখ্যা পর্ব ১০-এ, pandas-এ পর্ব ২৩-এ): “য়”, “ড়”, “ঢ়” দুইভাবে লেখা যায়, এক code point-এ (য় = U+09DF) বা দুইটিতে (য U+09AF + নুক্তা U+09BC). চোখে এক, Python-এর কাছে আলাদা string. Unicode normalization-এর NFC রূপ দুটিকে এক করে। মজার ব্যাপার, বাংলার এই তিনটি অক্ষরের ক্ষেত্রে NFC দুই-code-point রূপটিই বেছে নেয়, কারণ Unicode এগুলোকে “composition exclusion” তালিকায় রেখেছে; তাই NFC-র পর লেখা একটু লম্বা হয়।
দ্বিতীয় সমস্যা চোখে অদৃশ্য অক্ষর, zero-width character: U+200B (zero-width space), U+200C (ZWNJ), U+200D (ZWJ), আর U+FEFF. ওয়েবপাতা বা চ্যাট থেকে copy-paste করলে এগুলো শব্দের মাঝে ঢুকে যায়, আর “ভা + U+200B + লো” তখন “ভালো”-র সঙ্গে মেলে না। শব্দ মেলানোর কাজে আমরা এগুলো মুছে ফেলি। সাবধান: কিছু বাংলা বানানে ZWNJ আর ZWJ দেখানোর ধরন বদলায় (যেমন যুক্তাক্ষর ভেঙে দেখানো, “র্যাব”-এর মতো র-ফলা), তাই মোছা হয় শুধু মডেলের জন্য বানানো কপিতে, মূল লেখায় নয়।
আমাদের clean() চারটি কাজ করে: NFC, zero-width মোছা, lower() (ইংরেজি আর Banglish-এর জন্য; বাংলায় প্রভাব নেই), আর বাংলা অঙ্ক ০–৯-কে 0–9. পরের ব্লক এর প্রভাব গুনে দেখায়, তারপর পাঁচটি এলোমেলো রিভিউ তিনভাবে ভাঙে (অংশ ৩-এ ব্যাখ্যা)।
import re, unicodedata
import pandas as pd
from sklearn.feature_extraction.text import CountVectorizer
df = pd.read_csv("reviews.csv")
ZW = dict.fromkeys([0x200B, 0x200C, 0x200D, 0xFEFF]) # অদৃশ্য অক্ষর → মুছে ফেলা
BN2EN = str.maketrans("০১২৩৪৫৬৭৮৯", "0123456789")
def clean(t):
return unicodedata.normalize("NFC", t).translate(ZW).lower().translate(BN2EN)
BNB = f"{chr(0x0980)}-{chr(0x09FF)}" # পুরো বাংলা Unicode block
EMO = f"{chr(0x1F300)}-{chr(0x1FAFF)}{chr(0x2600)}-{chr(0x27BF)}{chr(0x2B50)}"
TOK = re.compile(rf"[\w{BNB}]+|[{EMO}]") # একটি শব্দ, নয়তো একটি emoji
def tok(t): return TOK.findall(clean(t))
CHAR = CountVectorizer(analyzer="char_wb", ngram_range=(3, 3),
preprocessor=clean).build_analyzer()
raw = " ".join(df["text"])
nfc = unicodedata.normalize("NFC", raw)
print("code point: কাঁচা", len(raw), "| NFC", len(nfc), "| zero-width মুছে", len(nfc.translate(ZW)))
print("U+09DF (এক-অক্ষরের য়):", raw.count(chr(0x09DF)), "→", nfc.count(chr(0x09DF)))
v_raw = {w for t in df["text"] for w in t.lower().split()}
v_cln = {w for t in df["text"] for w in clean(t).split()}
print("আলাদা whitespace token:", len(v_raw), "→", len(v_cln))
print("‘ভালো’-র কাঁচা রূপ:", sorted({w for w in v_raw if clean(w) == "ভালো"}, key=len))
print(r"\w কী ধরে:", re.findall(r"\w+", "ভালো না"), "| বাংলা-বান্ধব:", tok("ভালো না"))
for i in [0, 3, 6, 49, 97]:
t = df["text"][i]
print(f"\n#{i} {t}")
for name, toks in [("whitespace", clean(t).split()), ("regex", tok(t)), ("char_wb 3", CHAR(t))]:
print(f" {name:<10} {len(toks):>2}", toks[:9], "…" if len(toks) > 9 else "")আসল আউটপুট (ব্রাউজারের Python, pandas 3.0.2, scikit-learn 1.8.0):
code point: কাঁচা 5114 | NFC 5131 | zero-width মুছে 5126
U+09DF (এক-অক্ষরের য়): 13 → 0
আলাদা whitespace token: 532 → 523
‘ভালো’-র কাঁচা রূপ: ['ভালো', 'ভালো\u200b', 'ভা\u200bলো']
\w কী ধরে: ['ভ', 'ল', 'ন'] | বাংলা-বান্ধব: ['ভালো', 'না']
#0 খুব ভালো শাড়ি, রং ছবির মতোই। ডেলিভারিও দ্রুত হয়েছে
whitespace 9 ['খুব', 'ভালো', 'শাড়ি,', 'রং', 'ছবির', 'মতোই।', 'ডেলিভারিও', 'দ্রুত', 'হয়েছে']
regex 9 ['খুব', 'ভালো', 'শাড়ি', 'রং', 'ছবির', 'মতোই', 'ডেলিভারিও', 'দ্রুত', 'হয়েছে']
char_wb 3 44 [' খু', 'খুব', 'ুব ', ' ভা', 'ভাল', 'ালো', 'লো ', ' শা', 'শাড'] …
#3 product valo na, 2 diner moddhe noshto hoye geche 😡
whitespace 10 ['product', 'valo', 'na,', '2', 'diner', 'moddhe', 'noshto', 'hoye', 'geche'] …
regex 10 ['product', 'valo', 'na', '2', 'diner', 'moddhe', 'noshto', 'hoye', 'geche'] …
char_wb 3 42 [' pr', 'pro', 'rod', 'odu', 'duc', 'uct', 'ct ', ' va', 'val'] …
#6 হেডফোনের সাউন্ড দারুণ, বেজ ভালো। ৫ স্টার দিলাম ⭐⭐⭐⭐⭐
whitespace 9 ['হেডফোনের', 'সাউন্ড', 'দারুণ,', 'বেজ', 'ভালো।', '5', 'স্টার', 'দিলাম', '⭐⭐⭐⭐⭐']
regex 13 ['হেডফোনের', 'সাউন্ড', 'দারুণ', 'বেজ', 'ভালো', '5', 'স্টার', 'দিলাম', '⭐'] …
char_wb 3 44 [' হে', 'হেড', 'েডফ', 'ডফো', 'ফোন', 'োনে', 'নের', 'ের ', ' সা'] …
#49 পণ্য ভালো না, আর গ্রাহকসেবা উত্তরই দেয় না
whitespace 8 ['পণ্য', 'ভালো', 'না,', 'আর', 'গ্রাহকসেবা', 'উত্তরই', 'দেয়', 'না']
regex 8 ['পণ্য', 'ভালো', 'না', 'আর', 'গ্রাহকসেবা', 'উত্তরই', 'দেয়', 'না']
char_wb 3 35 [' পণ', 'পণ্', 'ণ্য', '্য ', ' ভা', 'ভাল', 'ালো', 'লো ', ' না'] …
#97 মান ভালো না, দামও বেশি 😞
whitespace 6 ['মান', 'ভালো', 'না,', 'দামও', 'বেশি', '😞']
regex 6 ['মান', 'ভালো', 'না', 'দামও', 'বেশি', '😞']
char_wb 3 19 [' মা', 'মান', 'ান ', ' ভা', 'ভাল', 'ালো', 'লো ', ' না', 'না,'] …
প্রথম তিন লাইন normalization-এর হিসাব। NFC-র পর লেখা ১৭টি code point লম্বা হয়েছে, আর এক-অক্ষরের “য়” ১৩টি থেকে ০: প্রতিটি U+09DF (আর চারটি U+09DC “ড়”) ভেঙে দুই code point হয়েছে, ১৩ + ৪ = ১৭. Zero-width মোছায় আরও ৫টি কমেছে। ফলে আলাদা whitespace token ৫৩২ থেকে ৫২৩, অর্থাৎ ৯টি “ভুয়া আলাদা শব্দ” মিশে গেছে। চতুর্থ লাইন দেখায় কেন: “ভালো” কাঁচা ডেটায় তিনটি রূপে আছে, দুটির ভেতরে বা পাশে \u200b. ছাপা repr-এ এটি দেখা যায়, পাতায় দেখা যায় না।
পঞ্চম লাইনটি পর্ব ৫১-এর দাবির সরাসরি যাচাই, এই ব্রাউজারের Python 3.14-এ: \w “ভালো না” থেকে দেয় ['ভ', 'ল', 'ন']. কারণ Python-এর \w সেই অক্ষর মেলায় যাদের str.isalnum() সত্য, আর কার-চিহ্ন (া Unicode category Mc, ো Mc), হসন্ত (্ Mn), নুক্তা (় Mn), চন্দ্রবিন্দু (ঁ Mn) সবই combining mark, অক্ষর নয় (ডেস্কটপে unicodedata.category দিয়ে যাচাই করা)। আমাদের regex [\w{BNB}]+ তাই \w-এর সঙ্গে পুরো বাংলা Unicode block (U+0980–U+09FF) যোগ করে (block-টি chr() দিয়ে বানানো, যাতে কপিতে কিছু না হারায়)। Standard re-তে combining mark-এর জন্য \p{M} নেই; বাইরের regex package-এ আছে।
৩ · তিনভাবে token — whitespace, বাংলা-বান্ধব regex আর character n-gram
পরিষ্কার লেখা থেকে token বানানোর (tokenization) তিনটি সাধারণ পথ, একই বাক্যে পাশাপাশি:
␣ Whitespace
শুধু ফাঁকা জায়গায় কাটা। সবচেয়ে সরল, কিন্তু যতিচিহ্ন আর emoji শব্দের গায়ে লেগে থাকে: “দারুণ,” আর “দারুণ” আলাদা, “na😡” এক token.
str.split()🔤 বাংলা-বান্ধব regex
শব্দের অক্ষর (কার-চিহ্ন আর হসন্তসহ) একটানা নেওয়া, আর প্রতিটি emoji আলাদা token. যতিচিহ্ন বাদ। এই পর্বের প্রধান tokenizer, tok().
🧩 Character n-gram
প্রতিটি শব্দের দুই প্রান্তে ফাঁকা জুড়ে ২–৪টি code point-এর টুকরো (char_wb). বানান-ভুল আর বিভক্তি অনেকটা সামলায়, দাম: অনেক বেশি feature.
সংজ্ঞায়: character n-gram মানে পরপর n-টি অক্ষরের টুকরো; শব্দের ক্ষেত্রে একই ধারণা n-gram. নিচের stepper একটি নতুন Banglish রিভিউ চারভাবে ভাঙে; সব ফল ডেস্কটপে একই clean, tok আর scikit-learn 1.8.0 দিয়ে চালানো সত্যিকারের run থেকে।
| ধাপ | |
| token | |
| “দিনে” আছে? |
- কাঁচা রিভিউ। ইংরেজি, বাংলা অঙ্ক, বাংলা শব্দ, Banglish আর একটি emoji, যা শেষ শব্দের গায়ে ফাঁকা ছাড়াই লেগে আছে।
- পরিষ্কার। “Delivery” হলো “delivery”, ২ হলো 2. NFC বা zero-width-এর কিছু বদলায়নি, কারণ এখানে সেগুলো ছিল না।
- Whitespace. ৭টি token, কিন্তু দুটি ত্রুটিপূর্ণ: “দিনে,” কমাসহ, আর “na😡” একসঙ্গে। Training-এ “না”-র Banglish রূপ “na” আলাদা থাকলে এটি তার সঙ্গে মিলবে না।
- scikit-learn-এর default
(?u)\b\w\w+\b: ৫টি token. “দিনে” পুরো হারিয়েছে (ি আর ে বাদ পড়ে একা দ আর ন থাকে, দুই অক্ষরের কম বলে বাদ), এক অঙ্কের “2” বাদ, emoji বাদ। Banglish অংশ টিকে গেছে, কারণ রোমান হরফে কার-চিহ্ন নেই। - বাংলা-বান্ধব regex. ৮টি token: যতিচিহ্ন বাদ, “দিনে” পুরো, “na” আর “😡” আলাদা। Emoji-টি নিজেই একটি শক্তিশালী মনোভাবের সংকেত।
- char_wb, n = 3. ৩০টি টুকরো। “দিনে,”-এর টুকরোয় কমা চিহ্নটিও আছে (char_wb শুধু ফাঁকায় শব্দ আলাদা করে), আর “ি” দিয়ে শুরু হওয়া “িনে” টুকরোর মতো অদ্ভুত অংশও আসে, কারণ এখানে একক হলো code point, চোখে দেখা অক্ষর (grapheme cluster) নয়। তবু “valo” আর ভুল বানানের “vallo” অনেক টুকরো ভাগ করবে।
ব্লক ১-এও একই ছবি: #৬-এ whitespace “⭐⭐⭐⭐⭐”-কে এক token ধরে, regex পাঁচটি আলাদা “⭐” দেয় (মোট ১৩টি)। কোনটি সেরা, তা ডেটা আর কাজ ঠিক করে; তাই অংশ ৭-এ শব্দ আর char_wb দুটিই একই মাপে যাচাই করব।
৪ · Subword, stopword আর stemming — তিনটি প্রলোভন, তিনটি সাবধানতা
Subword tokenizer: শব্দ আর অক্ষরের মাঝামাঝি
শব্দ-token-এ নতুন শব্দ পুরো অচেনা; char n-gram-এ feature অগণিত। আধুনিক ভাষা model দুটির মাঝখানে থাকে: subword tokenization, যেখানে ডেটা থেকে শেখা হয় কোন টুকরোগুলো রাখার মতো। সবচেয়ে পরিচিত byte pair encoding (BPE): শুরুতে প্রতিটি অক্ষর একটি প্রতীক; তারপর বারবার সবচেয়ে বেশি পাশাপাশি আসা জোড়াটি জুড়ে নতুন প্রতীক। একটি খুদে উদাহরণ (Banglish, যাতে অক্ষর গোনা সহজ হয়; “_” মানে শব্দের শেষ): valo ৬ বার, kalo ৩ বার, bhalo ২ বার, valoi ২ বার। ডেস্কটপে ছোট একটি Python script-এর সত্যিকারের run থেকে:
| ধাপ | সবচেয়ে বেশি জোড়া | গোনা | ফলাফল |
|---|---|---|---|
| শুরু | — | — | v a l o _ · k a l o _ · b h a l o _ · v a l o i _ |
| ১ | a + l | ১৩ | v al o _ … (l o-ও ১৩, সমতায় একটি বাছা হয়েছে) |
| ২ | al + o | ১৩ | v alo _ · k alo _ · b h alo _ · v alo i _ |
| ৩ | alo + _ | ১১ | v alo_ · k alo_ · b h alo_ · v alo i _ |
| ৪ | v + alo_ | ৬ | valo_ · k alo_ · b h alo_ · v alo i _ |
চার ধাপেই ঘন ঘন আসা “valo” একটি পুরো token, আর “kalo”, “bhalo” ভাগ করে নিয়েছে “alo_” টুকরো। নতুন একটি শব্দ “halo” এলে তা হবে h alo_: অচেনা নয়, চেনা টুকরোর জোড়া। WordPiece একই ধাঁচের, কিন্তু কোন জোড়া জুড়বে তা শুধু গোনা দিয়ে নয়, জুড়লে ডেটার likelihood কতটা বাড়ে এমন score দিয়ে বাছে, আর শব্দের ভেতরের টুকরোয় “##” চিহ্ন দেয়। বাস্তবে এগুলো হাজারো merge শেখে, প্রায়ই byte-স্তর থেকে, আর বাংলার মতো যে ভাষার ভাগ training ডেটায় কম, তার শব্দ প্রায়ই অনেক ছোট টুকরোয় ভাঙে। ভাষা model কীভাবে token দেখে, তা পর্ব ৭০ (LLM কীভাবে কাজ করে — token, পরের শব্দের অনুমান আর সীমা)-এ।
Stopword: “না” মুছবেন না
অনেক টিউটোরিয়াল প্রথমেই stopword মুছে ফেলতে বলে: খুব ঘন ঘন আসা ছোট শব্দ, যেমন আর, ও, এই, the, is. ইংরেজির জন্য scikit-learn-এ একটি তৈরি তালিকা আছে, বাংলার কোনো অফিসিয়াল তালিকা নেই। ইন্টারনেট থেকে পাওয়া তালিকায় প্রায়ই “না”, “নয়”, “নেই” থাকে, কারণ এগুলো সত্যিই ঘন ঘন আসে। আমাদের নিজের একটি ছোট, কাল্পনিক তালিকা দিয়ে ডেস্কটপে পরীক্ষা (সত্যিকারের run, scikit-learn 1.8.0, LinearSVC, unigram + bigram, ৫-fold × ১০ বার):
STOP = {"আর", "ও", "এই", "একটি", "তো", "কিন্তু", "খুব", "ta", "e", "o",
"the", "is", "না", "na", "nai"}
def tok_stop(keep_neg):
drop = STOP - ({"না", "na", "nai"} if keep_neg else set())
return lambda t: [w for w in tok(t) if w not in drop]~/bazarghar-ai$ python stopwords.py
['পণ্য', 'ভালো', 'দামও', 'বেশি'] ['পণ্য', 'ভালো', 'না', 'দামও', 'বেশি']
no stopword removal macro F1 0.694 ± 0.075
drop list, keep না/na macro F1 0.711 ± 0.079
drop list incl. না/na macro F1 0.623 ± 0.075
“পণ্য ভালো না, আর দামও বেশি” থেকে “না” মুছলে বাকি থাকে “পণ্য ভালো দামও বেশি”: অর্থ উল্টে গেছে। গড় macro F1 ০.৬৯৪ থেকে নেমে ০.৬২৩. “না” রেখে বাকিগুলো মুছলে সামান্য বেশি, ০.৭১১, তবে পার্থক্যটি ওঠানামার (±০.০৮) তুলনায় ছোট, তাই এটিকে উন্নতি বলা যায় না। শিক্ষা: TF-IDF নিজেই সব জায়গায় থাকা শব্দের ওজন কমায়, তাই stopword মোছা প্রায়ই জরুরি নয়; আর মুছলে তালিকাটি হাতে পড়ে দেখতে হবে।
Stemming: বাংলায় কেন কঠিন
Stemming মানে শব্দের শেষ ছেঁটে মূল রূপ আনা, যাতে “টাকা”, “টাকায়”, “টাকার” একটি feature হয়। ইংরেজিতে Martin Porter-এর ১৯৮০ সালের নিয়মভিত্তিক stemmer বহুল ব্যবহৃত। বাংলার morphology সমৃদ্ধ: বিভক্তি (-এ, -র, -কে, -তে), নির্দেশক (-টা, -টি, -গুলো), আর ক্রিয়ার অসংখ্য রূপ (করি, করেন, করেছিলাম, করব)। কিন্তু একই শেষাংশ কখনো বিভক্তি, কখনো শব্দেরই অংশ। একটি খেলনা suffix-stripper (দীর্ঘতম মেলা suffix ছাঁটে, তবে মূলে অন্তত দুই code point রাখে), ডেস্কটপে সত্যিকারের run:
SUFFIXES = গুলো গুলি দের টা টি ের য়ে য় র কে তে ে ও (দীর্ঘতম আগে)
টাকায় → টাকা টাকার → টাকা বাজারে → বাজার বাজারের → বাজার
বাজার → বাজা বাজাতে → বাজা সময় → সম করে → কর
কর → কর নয় → নয় খেলনাগুলো → খেলনা প্যাকেটটা → প্যাকেট
প্রথম সারি ঠিক কাজ। দ্বিতীয় সারিতে বিপদ: “বাজার” (শেষের র শব্দেরই অংশ) ছেঁটে “বাজা”, আর “বাজাতে” (বাজানো ক্রিয়া)-ও “বাজা”. দুই ভিন্ন অর্থ এক মূলে মিশে গেল; একে বলে over-stemming. “সময়” হলো “সম”, “করে” (ক্রিয়া) আর “কর” (খাজনা) এক। “নয়” বেঁচে গেছে শুধু দৈর্ঘ্যের শর্তের কারণে; শর্তটি না থাকলে negation-এর শব্দ “ন” হয়ে যেত। প্রতিটি নতুন নিয়ম নতুন ব্যতিক্রম আনে। অভিধান আর ব্যাকরণ দেখে মূল রূপে ফেরানোকে বলে lemmatization; বাংলার জন্য গবেষণা আর কিছু খোলা হাতিয়ার আছে, তবে ইংরেজির মতো পরিণত হাতিয়ার কম বলেই মনে হয়, তাই যেকোনো stemmer আগে নিজের ডেটায় মেপে নিন। এই পর্বে stemming-এর বদলে char_wb মাপব।
৫ · TF-IDF হাতে — তিনটি ছোট রিভিউ
Bag of words-এ (পর্ব ৫১) প্রতিটি শব্দের ওজন তার গোনা। সমস্যা: “দাম” প্রায় সব রিভিউতেই আছে, তাই এর বড় গোনা কোনো রিভিউকে আলাদা করে না। TF-IDF দুটি সংখ্যা গুণ করে: term frequency tf (এই রিভিউয়ে শব্দটি কতবার) আর inverse document frequency idf (সব রিভিউতে শব্দটি কত বিরল)। idf-এর ভিত্তি document frequency df: কতগুলো রিভিউতে শব্দটি অন্তত একবার আছে। scikit-learn-এর TfidfVectorizer-এর default সূত্র (অফিসিয়াল docs অনুযায়ী, smooth_idf=True):
idf(t) = ln((1 + n) / (1 + df(t))) + 1 · w(t, d) = tf(t, d) × idf(t)
এখানে n হলো মোট রিভিউ, ln স্বাভাবিক log. লব আর হরে ১ যোগ (smoothing) মানে যেন একটি কাল্পনিক রিভিউ সব শব্দ একবার করে ধারণ করে, তাই শূন্য দিয়ে ভাগ হয় না; শেষের “+ 1” নিশ্চিত করে যে সব রিভিউতে থাকা শব্দের ওজন একেবারে শূন্য হয় না (idf তখন ঠিক ১)। পাঠ্যবইয়ের সরল রূপ ln(n/df), যেখানে সব জায়গার শব্দের idf শূন্য; scikit-learn-এর রূপটি তাই একটু আলাদা, আর পরীক্ষায় কোন সূত্র ধরা হচ্ছে তা দেখে নিতে হয়। শেষ ধাপে প্রতিটি রিভিউয়ের vector-কে তার দৈর্ঘ্য দিয়ে ভাগ করা হয় (L2 normalization), যাতে লম্বা রিভিউ শুধু দৈর্ঘ্যের জোরে এগিয়ে না যায়।
তিনটি খুদে রিভিউ: (১) “ভালো দাম ভালো প্যাকেট”, (২) “দাম বেশি ভালো না”, (৩) “দাম বেশি ডেলিভারি দেরি”. n = ৩. df: দাম ৩ (সবগুলোতে), ভালো ২, বেশি ২, বাকিগুলো ১. তাই idf: দাম = ln(৪/৪) + ১ = ১; ভালো আর বেশি = ln(৪/৩) + ১ ≈ ১.২৮৭৭; প্যাকেট, না, ডেলিভারি, দেরি = ln(৪/২) + ১ ≈ ১.৬৯৩১.
| এই ধাপ | |
| ভালো | |
| দাম | |
| প্যাকেট | |
| সারির হিসাব |
- tf গোনা। রিভিউ ১-এ “ভালো” দুবার, “দাম” আর “প্যাকেট” একবার করে। scikit-learn-এর default-এ tf কাঁচা গোনা।
- df গোনা। “ভালো” আছে রিভিউ ১ আর ২-এ, তাই ২; “দাম” তিনটিতেই, তাই ৩; “প্যাকেট” শুধু রিভিউ ১-এ, তাই ১. একই রিভিউয়ে দুবার থাকলেও df-এ একবার গোনা।
- idf. সূত্র ln((1 + n)/(1 + df)) + 1. সবখানে থাকা “দাম” পায় সর্বনিম্ন ১, বিরল “প্যাকেট” সবচেয়ে বেশি ১.৬৯৩১.
- গুণ। “ভালো” দুবার আসায় তার কাঁচা ওজন ২.৫৭৫৪, বিরল কিন্তু একবারের “প্যাকেট” ১.৬৯৩১, আর “দাম” মাত্র ১.
- দৈর্ঘ্য। তিনটি বর্গের যোগফল ১০.৪৯৯২, বর্গমূল ৩.২৪০৩. এটিই রিভিউ ১-এর vector-এর L2 norm (পর্ব ৩৩)।
- ভাগ। “ভালো” = ২.৫৭৫৪ ÷ ৩.২৪০৩ ≈ ০.৭৯৪৮. যাচাই: ০.৭৯৪৮² + ০.৩০৮৬² + ০.৫২২৫² ≈ ১. পরের ব্লকে NumPy আর scikit-learn ঠিক এই সংখ্যাই দেবে।
Hans Peter Luhn ১৯৫০-এর দশকের শেষে IBM-এ লেখার ভেতরে শব্দের পুনরাবৃত্তিকে গুরুত্বের সংকেত ধরার প্রস্তাব দেন। Karen Spärck Jones ১৯৭২ সালে Journal of Documentation-এ “term specificity” নিয়ে যে প্রবন্ধ লেখেন, তা থেকেই idf-এর ধারণা: যে শব্দ কম নথিতে আছে, সেটি খোঁজে বেশি কাজের। Gerard Salton ও সহকর্মীদের SMART ব্যবস্থা আর ১৯৭৫ সালের “vector space model” প্রবন্ধ নথিকে শব্দ-ওজনের vector হিসেবে দেখার পথ জনপ্রিয় করে, যা information retrieval-এর ভিত্তি হয়ে ওঠে।
৬ · TF-IDF কোডে — NumPy বনাম TfidfVectorizer, আর মিল খোঁজা
এবার একই হিসাব NumPy-তে, তারপর TfidfVectorizer-এ, আমাদের tok দিয়ে। দুটি খুঁটিনাটি জরুরি: নিজের tokenizer দিলে token_pattern=None দিন, নইলে scikit-learn একটি UserWarning দেখায় যে pattern-টি ব্যবহার হবে না; আর scikit-learn vocabulary বর্ণানুক্রমে (Unicode code point-এর ক্রমে) সাজায়, তাই NumPy-তেও sorted. শেষ অংশ পুরো ১১৯টি রিভিউয়ের TF-IDF থেকে cosine similarity দিয়ে সবচেয়ে কাছের রিভিউ খোঁজে। প্রতিটি সারির দৈর্ঘ্য ১, তাই cosine = dot product (পর্ব ৩৩), আর পুরো matrix গুণ R @ R.T সব জোড়ার মিল দেয়; R একটি sparse matrix.
import numpy as np
from sklearn.feature_extraction.text import TfidfVectorizer
docs = ["ভালো দাম ভালো প্যাকেট", "দাম বেশি ভালো না", "দাম বেশি ডেলিভারি দেরি"]
toks = [tok(d) for d in docs]
vocab = sorted({w for t in toks for w in t}) # scikit-learn-এর মতো সাজানো
tf = np.array([[t.count(w) for w in vocab] for t in toks], dtype=float)
n = len(docs)
dfreq = (tf > 0).sum(axis=0) # কতটি review-এ শব্দটি আছে
idf = np.log((1 + n) / (1 + dfreq)) + 1 # smooth_idf=True-এর সূত্র
W = tf * idf
W = W / np.linalg.norm(W, axis=1, keepdims=True) # প্রতিটি সারির L2 দৈর্ঘ্য 1
vec = TfidfVectorizer(tokenizer=tok, token_pattern=None, smooth_idf=True, norm="l2")
S = vec.fit_transform(docs).toarray()
print("শব্দ:", vocab)
print("df :", dfreq, "\nidf :", idf.round(4))
j = vocab.index("ভালো")
print(f"‘ভালো’, review 1: NumPy {W[0, j]:.15f} | scikit-learn {S[0, j]:.15f}")
print("একই ক্রম?", list(vec.get_feature_names_out()) == vocab,
"| সব ঘরের সর্বোচ্চ পার্থক্য:", np.abs(W - S).max())
np.set_printoptions(precision=3, suppress=True)
print("cosine (L2 = 1, তাই শুধু dot):\n", W @ W.T)
R = TfidfVectorizer(tokenizer=tok, token_pattern=None).fit_transform(df["text"])
sim = (R @ R.T).toarray()
for q in (0, 97):
best = [k for k in np.argsort(-sim[q], kind="stable") if k != q][:3]
print(f"\nখোঁজা #{q}: {df['text'][q]}")
for k in best:
print(f" {sim[q, k]:.3f} {df['label'][k]:<8} {df['text'][k]}")আসল আউটপুট (ব্রাউজারের Python, NumPy 2.4.6, scikit-learn 1.8.0):
শব্দ: ['ডেলিভারি', 'দাম', 'দেরি', 'না', 'প্যাকেট', 'বেশি', 'ভালো']
df : [1 3 1 1 1 2 2]
idf : [1.6931 1. 1.6931 1.6931 1.6931 1.2877 1.2877]
‘ভালো’, review 1: NumPy 0.794803097421835 | scikit-learn 0.794803097421835
একই ক্রম? True | সব ঘরের সর্বোচ্চ পার্থক্য: 0.0
cosine (L2 = 1, তাই শুধু dot):
[[1. 0.497 0.107]
[0.497 1. 0.342]
[0.107 0.342 1. ]]
খোঁজা #0: খুব ভালো শাড়ি, রং ছবির মতোই। ডেলিভারিও দ্রুত হয়েছে
0.306 negative ডেলিভারি দ্রুত হয়েছে কিন্তু পণ্য ভালো না
0.213 negative শাড়ির রং ছবির সাথে মেলে না, খুবই হতাশ
0.210 positive গ্রাহকসেবা দ্রুত উত্তর দিয়েছে, খুব ভালো সার্ভিস
খোঁজা #97: মান ভালো না, দামও বেশি 😞
0.414 negative দাম বেশি, মান খারাপ। আর কিনব না
0.336 positive দাম একটু বেশি, তবে মান সত্যিই ভালো
0.285 negative দাম অনুযায়ী মান একদমই ভালো না
df আর idf stepper-এর সংখ্যা। “ভালো”-র মান NumPy আর scikit-learn-এ ১৫ দশমিক পর্যন্ত হুবহু এক, আর সব ঘরের সর্বোচ্চ পার্থক্য 0.0: আমাদের সূত্রই scikit-learn-এর সূত্র। Cosine matrix-এ রিভিউ ১ আর ২-এর মিল ০.৪৯৭ (দুটিতেই “ভালো” আর “দাম”), ১ আর ৩-এর ০.১০৭ (শুধু “দাম”, যার idf সবচেয়ে কম)।
খোঁজার ফল সাবধানে পড়ুন। রিভিউ #০ (“খুব ভালো শাড়ি…”, positive)-এর সবচেয়ে কাছের রিভিউটি negative: “ডেলিভারি দ্রুত হয়েছে কিন্তু পণ্য ভালো না”. ভাগ করা শব্দ “ভালো”, “দ্রুত” আর “হয়েছে”; “ডেলিভারিও” আর “ডেলিভারি” ও-এর কারণে দুটি আলাদা token, তাই মেলেনি। TF-IDF-এর মিল মানে শব্দের মিল, মনোভাব বা অর্থের মিল নয়। #৯৭-এর ক্ষেত্রে প্রথম তিনটিই দাম আর মান নিয়ে, যা খোঁজার জন্য ভালো, কিন্তু দ্বিতীয়টি positive. অর্থ দিয়ে খোঁজার পথ, embedding, পর্ব ৬৯ (Embedding আর vector search — অর্থ দিয়ে খোঁজা)-এ।
৭ · তিনটি classifier, দুই রকম feature — stratified CV আর macro F1
এবার মূল কাজ। তিনটি model, প্রতিটি TF-IDF feature-এর ওপর:
- MultinomialNB: পর্ব ৫১-এর baseline, শব্দ-গোনার Naive Bayes. এখানে TF-IDF-এর ভগ্নাংশ মানও গোনার মতো ব্যবহার হয়, যা scikit-learn মেনে নেয়, যদিও তত্ত্বে NB গোনার জন্য।
- LogisticRegression (পর্ব ৫৯): শব্দের ওজনগুলো একসঙ্গে শেখে log loss কমিয়ে, default L2 regularization (C = ১) সহ। scikit-learn 1.8-এ
penaltyবাmulti_classপাঠালে সতর্কবার্তা বা error আসে, তাই আমরা শুধুmax_iter=1000দিই। - LinearSVC: linear support vector machine. এটিও w·x + b-এর চিহ্ন দিয়ে সিদ্ধান্ত নেয়, কিন্তু ওজন শেখে এমনভাবে যাতে দুই শ্রেণির সবচেয়ে কাছের উদাহরণ সীমারেখা থেকে যথাসম্ভব দূরে থাকে (চওড়া margin); যে উদাহরণ ঠিক দিকে আর যথেষ্ট দূরে, সেটি শেখায় আর ভূমিকা রাখে না। scikit-learn-এর default loss হলো squared hinge, আর ভেতরে LIBLINEAR library চলে।
দুই পাড়ার মাঝে রাস্তা আঁকার কথা ভাবুন। Logistic regression প্রতিটি বাড়ির জন্য “কোন পাড়ার কতটা সম্ভাবনা” হিসাব করে রেখা টানে, দূরের বাড়িও একটু টান দেয়। SVM শুধু সীমান্তের কাছের বাড়িগুলো দেখে, আর রাস্তাটা যতটা সম্ভব চওড়া রাখে, যাতে নতুন বাড়ি একটু এদিক-ওদিক হলেও ভুল পাড়ায় না পড়ে।
মূল্যায়নের নিয়ম পর্ব ৫৬ আর পর্ব ৬৬ (Model বাছাই — cross-validation আর hyperparameter search) থেকে: stratified k-fold (৫ ভাগ, প্রতিটিতে positive-negative অনুপাত পুরো ডেটার মতো), সব model-এ একই fold, আর vectorizer একটি pipeline-এর ভেতরে, যাতে প্রতিটি fold-এ vocabulary আর idf শুধু train অংশ থেকে শেখা হয়; নইলে test রিভিউয়ের শব্দ idf-এ ঢুকে data leakage ঘটায়। মাপ হলো macro F1 (পর্ব ৬০): দুই শ্রেণির F1 আলাদা হিসাব করে সরল গড় (macro average), যাতে কম থাকা শ্রেণিটি উপেক্ষিত না হয়। সঙ্গে সবসময় বড় শ্রেণি বলা DummyClassifier.
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.naive_bayes import MultinomialNB
from sklearn.linear_model import LogisticRegression
from sklearn.svm import LinearSVC
from sklearn.dummy import DummyClassifier
from sklearn.pipeline import make_pipeline
from sklearn.model_selection import StratifiedKFold, cross_val_score
X, y = df["text"].to_numpy(), df["label"].to_numpy()
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) # সব model-এ একই fold
feats = {
"word ": lambda: TfidfVectorizer(tokenizer=tok, token_pattern=None),
"char_wb": lambda: TfidfVectorizer(analyzer="char_wb", ngram_range=(2, 4),
preprocessor=clean),
}
models = {
"MultinomialNB": lambda: MultinomialNB(),
"LogisticReg ": lambda: LogisticRegression(max_iter=1000),
"LinearSVC ": lambda: LinearSVC(),
}
print("শ্রেণি:", df["label"].value_counts().to_dict())
base = cross_val_score(DummyClassifier(), X, y, cv=cv, scoring="f1_macro")
print(f"baseline (সবসময় বড় শ্রেণি): macro F1 {base.mean():.3f}")
for fname, F in feats.items():
for mname, M in models.items():
pipe = make_pipeline(F(), M()) # vectorizer প্রতিটি fold-এ শুধু train-এ fit
f1 = cross_val_score(pipe, X, y, cv=cv, scoring="f1_macro")
print(f"{fname} {mname} {f1.mean():.3f} ± {f1.std():.3f} [{' '.join(f'{v:.2f}' for v in f1)}]")আসল আউটপুট (ব্রাউজারের Python, scikit-learn 1.8.0; কোনো warning নেই):
শ্রেণি: {'positive': 64, 'negative': 55}
baseline (সবসময় বড় শ্রেণি): macro F1 0.350
word MultinomialNB 0.554 ± 0.117 [0.42 0.57 0.61 0.43 0.73]
word LogisticReg 0.574 ± 0.118 [0.42 0.50 0.70 0.53 0.73]
word LinearSVC 0.634 ± 0.079 [0.50 0.62 0.66 0.66 0.74]
char_wb MultinomialNB 0.544 ± 0.109 [0.33 0.56 0.59 0.61 0.63]
char_wb LogisticReg 0.560 ± 0.108 [0.35 0.62 0.64 0.56 0.62]
char_wb LinearSVC 0.627 ± 0.103 [0.50 0.62 0.75 0.53 0.74]
Baseline ০.৩৫০: সব রিভিউকে positive বললে positive-এর F1 প্রায় ০.৭০, negative-এর ০, গড় ০.৩৫. ছয়টি model-ই এর ওপরে, কিন্তু খুব বেশি নয়। শব্দ-feature-এ LinearSVC সেরা (০.৬৩৪), তারপর LogisticRegression (০.৫৭৪) আর MultinomialNB (০.৫৫৪)। char_wb প্রায় একই ক্রম দেয়, সামান্য কম। কিন্তু বন্ধনীর পাঁচটি fold-এর মান দেখুন: LinearSVC শব্দে ০.৫০ থেকে ০.৭৪; NB-এর এক fold ০.৪২, আরেকটি ০.৭৩. Fold-ভেদে ওঠানামা (sd প্রায় ০.০৮–০.১২) model-গুলোর গড়ের পার্থক্যের সমান বা বেশি। এই ১১৯টি রিভিউয়ে “LinearSVC সবচেয়ে ভালো” একটি দুর্বল ইঙ্গিত, প্রমাণ নয়।
কেন সব সংখ্যা এত সাধারণ? ৯৫টির মতো training রিভিউ, তার ওপর তিন লিপি-রূপে ভাগ: “ভালো”, “valo”, “bhalo”, “good” চারটি আলাদা feature, প্রতিটি অল্প কয়েকবার দেখা। LogisticRegression-এর C = ১ এত ছোট ডেটায় ওজনগুলোকে বেশ চেপে রাখে; C বাড়ালে ফল বদলায়, কিন্তু সেটি বাছতে হবে nested CV দিয়ে (পর্ব ৬৬), এই একই fold-এর দিকে তাকিয়ে নয়। পুরো ডেটায় fit করলে LinearSVC ২২টি iteration-এ converge করে (ডেস্কটপে n_iter_ দেখে), তাই কোনো ConvergenceWarning আসেনি; ইচ্ছা করে max_iter=3 দিলে ডেস্কটপে সত্যিই আসে: ConvergenceWarning: Liblinear failed to converge, increase the number of iterations. এমন warning কখনো চেপে রাখবেন না; তার মানে ওজন অসম্পূর্ণ।
৮ · ভুলের ময়নাতদন্ত — negation, ব্যঙ্গ আর bigram
একটি সংখ্যা model-এর গল্পের শেষ নয়, শুরু। Error analysis মানে ভুলগুলো একে একে খুলে দেখা। Linear model-এ এটি সহজ: সিদ্ধান্তের score = b + ∑ xj wj, যেখানে xj রিভিউয়ে শব্দ j-এর TF-IDF আর wj তার শেখা ওজন (positive-এর দিকে ধনাত্মক)। প্রতিটি পদ xj wj বলে কোন শব্দ কতটা টেনেছে। নিচের ব্লক একই ৫টি fold-এ LinearSVC চালায়; প্রতিটি ভুল রিভিউয়ের জন্য সেই fold-এর model দিয়ে সবচেয়ে বড় তিনটি পদ দেখায় (যে model রিভিউটি দেখেনি)। তারপর দুটি টুকরো (slice) আলাদা করে ভুলের হার মাপে, আর শেষে bigram যোগ করে (ngram_range=(1, 2)) একই হিসাব আবার করে।
import numpy as np
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.svm import LinearSVC
def run(ngram): # একই ৫টি fold; প্রতিটি ভুলের জন্য সেই fold-এর model
wrong = {}
for tr, te in cv.split(X, y):
vec = TfidfVectorizer(tokenizer=tok, token_pattern=None, ngram_range=ngram)
clf = LinearSVC().fit(vec.fit_transform(X[tr]), y[tr])
A, names = vec.transform(X[te]), vec.get_feature_names_out()
for r, (i, p) in enumerate(zip(te, clf.predict(A))):
if p != y[i]:
part = A[r].toarray()[0] * clf.coef_[0] # + মানে positive-এর দিকে
top = np.argsort(-np.abs(part), kind="stable")[:3]
wrong[i] = ", ".join(f"{names[k]} {part[k]:+.2f}" for k in top if part[k])
return wrong
w1, w2 = run((1, 1)), run((1, 2))
neg = np.array([bool({"না", "na"} & set(tok(t))) for t in X])
latin = np.array([not re.search(f"[{BNB}]", t) for t in X])
err = np.isin(np.arange(len(X)), list(w1))
print(f"ভুল {len(w1)}/{len(X)} | ভুলের হার: ‘না/na’ আছে (n={neg.sum()}) {err[neg].mean():.2f},"
f" নেই {err[~neg].mean():.2f} | শুধু রোমান হরফ (n={latin.sum()}) {err[latin].mean():.2f},"
f" বাংলা হরফ {err[~latin].mean():.2f}")
for i in [14, 64, 97, 19, 73]:
print(f"\n#{i} [{y[i]}] {X[i]}\n unigram: {w1.get(i, 'ঠিক')}\n +bigram: {w2.get(i, 'ঠিক')}")
print(f"\nbigram ঠিক করল {len(set(w1) - set(w2))}টি, নতুন ভুল {len(set(w2) - set(w1))}টি"
f" | মোট ভুল {len(w1)} → {len(w2)}")আসল আউটপুট (ব্রাউজারের Python, scikit-learn 1.8.0):
ভুল 41/119 | ভুলের হার: ‘না/na’ আছে (n=24) 0.54, নেই 0.29 | শুধু রোমান হরফ (n=38) 0.37, বাংলা হরফ 0.33
#14 [positive] দাম হিসেবে খারাপ না, কাজ চলবে ভালোভাবেই
unigram: না -0.49, হিসেবে +0.25, খারাপ -0.10
+bigram: ঠিক
#64 [positive] খারাপ না, দাম হিসেবে বেশ ভালোই 🙂
unigram: না -0.58, হিসেবে +0.30, খারাপ -0.14
+bigram: ঠিক
#97 [negative] মান ভালো না, দামও বেশি 😞
unigram: না -0.46, ভালো +0.19, মান +0.12
+bigram: ঠিক
#19 [negative] বাহ! মাত্র তিন সপ্তাহে ডেলিভারি, এত দ্রুত সার্ভিস আর কোথায় পাব 🙄
unigram: দ্রুত +0.19, ডেলিভারি +0.13, আর +0.12
+bigram: দ্রুত +0.17, ডেলিভারি +0.13, আর +0.11
#73 [negative] ধন্যবাদ বাজারঘর, পচা সবজি পাঠানোর জন্য 👏
unigram: ধন্যবাদ +0.30, বাজারঘর +0.20
+bigram: ধন্যবাদ +0.20, ধন্যবাদ বাজারঘর +0.15, বাজারঘর +0.15
bigram ঠিক করল 8টি, নতুন ভুল 2টি | মোট ভুল 41 → 35
Slice থেকে প্রথম সূত্র: ১১৯টির মধ্যে ৪১টি ভুল (accuracy প্রায় ০.৬৬)। যে ২৪টি রিভিউয়ে “না” বা “na” আছে, তার অর্ধেকের বেশি (০.৫৪) ভুল; বাকিগুলোতে ০.২৯. Negation-ই প্রধান দুর্বলতা। রোমান হরফ বনাম বাংলা হরফের পার্থক্য ছোট (০.৩৭ বনাম ০.৩৩), কিন্তু n = ৩৮-এ এ নিয়ে কোনো সিদ্ধান্ত টানা যায় না।
#১৪ আর #৬৪, “খারাপ না” (positive). Unigram model-এ “না”-র ওজন খুব ঋণাত্মক, কারণ training-এ “না” প্রায় সবসময় negative রিভিউতে; আর “খারাপ”-ও negative. দুটি মিলে positive রিভিউকে negative বানিয়েছে। Bigram-এ “খারাপ না” একটি নিজস্ব feature, ডেস্কটপে fold-এর ওজন খুলে দেখা গেছে এটি +০.১৬ থেকে +০.১৮ টেনেছে (সঙ্গে “দাম হিসেবে” +০.১০ থেকে +০.১২); দুটিই ঠিক হয়েছে। #৯৭, “মান ভালো না” (negative). Unigram-এ “না” −০.৪৬ টানলেও “ভালো”, “মান”, “বেশি”, “দামও” মিলে score একটু ধনাত্মক (+০.১৩); bigram “ভালো না” (−০.৩১) তা ঋণাত্মক করেছে।
ব্যঙ্গ রয়ে গেছে। #১৯-এ “দ্রুত”, “ডেলিভারি” positive-এর দিকে টানে; “তিন সপ্তাহে” যে ব্যঙ্গ, তা কোনো শব্দে নেই, আছে বাস্তব জ্ঞানে (ডেলিভারিতে তিন সপ্তাহ অনেক)। #৭৩-এ “ধন্যবাদ বাজারঘর” bigram হয়ে আরও positive হয়েছে; “পচা” শব্দটি training fold-এ ছিল না। Bigram মোট ৮টি ভুল সারিয়েছে, ২টি নতুন ভুল এনেছে: ৪১ থেকে ৩৫. ডেস্কটপে ৫০টি ভিন্ন ভাগে (অংশ ৯) bigram গড়ে +০.০৩০ macro F1 এনেছে, ৩১টিতে ভালো, ১৩টিতে খারাপ: সত্যিকারের, কিন্তু ছোট উন্নতি।
নিচের stepper দুটি কাল্পনিক রিভিউ দিয়ে কারণটি সংখ্যায় দেখায়। দুটির শব্দ হুবহু এক, অর্থ উল্টো। ওজনগুলো পুরো ১১৯টি রিভিউয়ে train করা দুটি LinearSVC-এর, ডেস্কটপে সত্যিকারের run থেকে।
| এই ধাপ | |
| A | |
| B |
- একই থলি। যতিচিহ্ন বাদ দিলে দুটি রিভিউয়েই তিনটি token: ভালো, না, খারাপ, প্রতিটি একবার। ক্রম ছাড়া (bag of words) দুটি অভিন্ন।
- Unigram TF-IDF. idf পুরো ডেটা থেকে, তারপর L2. দুটি vector হুবহু এক: খারাপ ০.৬৭৮, না ০.৫৬৪, ভালো ০.৪৭১.
- Unigram score. ওজন: খারাপ −০.২০২, না −১.৭৪৭, ভালো +০.৯১৭, b = ০.০৭৮. পদগুলো −০.১৩৭, −০.৯৮৫, +০.৪৩২; মোট −০.৬১২. দুটিই negative: A ঠিক, B ভুল। একই vector থেকে দুই উত্তর অসম্ভব।
- Bigram যোগ। A-তে “ভালো না”, B-তে “খারাপ না”: training-এ দুটিই দেখা, ওজন −০.৯৪৫ আর +০.৫৩৫. কমা বাদ পড়ায় “না খারাপ”, “না ভালো”-ও তৈরি হয়, কিন্তু training-এ ছিল না বলে বাদ। এখন দুটি vector আলাদা।
- A, bigram model. নতুন ওজন আর নতুন TF-IDF (বেশি feature, তাই প্রতিটির মান একটু ছোট): খারাপ −০.১৪১, না −০.৫৪৭, ভালো +০.২৮২, ভালো না −০.৫৬৬, b ০.০৭৪. মোট −০.৮৯৮: আরও দৃঢ় negative.
- B, bigram model. খারাপ −০.১৩৪, খারাপ না +০.৩৪৮, না −০.৫১৯, ভালো +০.২৬৮, b ০.০৭৪. মোট +০.০৩৭: positive, তবে খুব অল্পের জন্য। Bigram ক্রম কিছুটা ফেরায়, কিন্তু “না”-র একক ওজন এখনো ভারী; বেশি ডেটা ছাড়া এটি বদলাবে না।
৯ · সৎ সীমা, ন্যায্যতা আর পরের পথ
সংখ্যা কতটা নড়ে? একটি ৫-fold ভাগ একটি মাত্র ভাগ্য। ডেস্কটপে RepeatedStratifiedKFold দিয়ে ৫-fold দশবার, ভিন্ন এলোমেলো ভাগে (সত্যিকারের run):
cv = RepeatedStratifiedKFold(n_splits=5, n_repeats=10, random_state=0)
for name, pipe in setups.items():
s = cross_val_score(pipe, df["text"], df["label"], cv=cv, scoring="f1_macro")
print(f"{name} mean {s.mean():.3f} sd {s.std():.3f} min {s.min():.2f} max {s.max():.2f}")~/bazarghar-ai$ python cv_repeat.py
Python 3.14.4, scikit-learn 1.8.0, 119 reviews, 5-fold x 10 repeats
MultinomialNB, unigram mean 0.589 sd 0.089 min 0.37 max 0.78
LinearSVC, unigram mean 0.664 sd 0.086 min 0.50 max 0.87
LinearSVC, unigram + bigram mean 0.694 sd 0.075 min 0.52 max 0.87
bigram - unigram on the same 50 splits: mean +0.030, better in 31, equal in 6, worse in 13
একই model-এর একটি fold ০.৫০ পায়, আরেকটি ০.৮৭. ব্লক ৩-এর ভাগে LinearSVC শব্দে ০.৬৩৪ ছিল, ৫০টি ভাগের গড়ে ০.৬৬৪. ২৪টি করে test রিভিউয়ে একটি ভুল মানে F1-এ প্রায় ০.০৪-এর লাফ। তাই রিপোর্টে গড়ের পাশে sd বা পরিসর আর n লিখুন, আর দুই model-কে একই ভাগে জোড়ায় তুলনা করুন (পর্ব ৬০-এর bootstrap-এর মতো)।
Code-mixing আর transliteration: ডেস্কটপে “চার্জার গরম হয়” দিয়ে খুঁজলে (অনুশীলন ২) প্রথম ফল ইস্ত্রির রিভিউ, কারণ চার্জারের রিভিউগুলো রোমান হরফে (“charger gorom hoye jay”). “চার্জার” আর “charger”, “ভালো” আর “valo/bhalo” model-এর কাছে সম্পর্কহীন। Char n-gram লিপির ভেতরের বানান-ভেদ সামলায়, লিপির বাইরে নয়। প্রতিকারের পথ: লিপি এক করা (Banglish থেকে বাংলায় transliteration, যা নিজেই কঠিন কারণ রোমান বানানের প্রমিত নিয়ম নেই), দুই লিপিতেই যথেষ্ট label দেওয়া ডেটা, বা বহুভাষিক model.
বড় পথের দিকনির্দেশ (সতর্কভাবে): বাংলা NLP-তে কিছু প্রকাশিত খোলা কাজ আছে, যেমন BUET-এর গবেষকদের BanglaBERT (২০২২ সালে প্রকাশিত একটি বাংলা ভাষা model), গোলমেলে সামাজিক মাধ্যমের বাংলা মন্তব্যের sentiment dataset SentNoB (২০২১), আর Python-এর বাংলা NLP toolkit BNLP. বহুভাষিক pretrained model, যেমন mBERT আর XLM-RoBERTa, training-এ বাংলাও অন্তর্ভুক্ত করেছে। এগুলোর নাম গবেষণা-প্রবন্ধে পরিচিত, তবে লাইসেন্স, বর্তমান অবস্থা আর মান নিজে যাচাই না করে কোনোটিকে সুপারিশ বলে ধরবেন না; এখানে কোনো লিংক দেওয়া হয়নি। একটি multilingual model-এ বাংলার ভাগ সাধারণত ছোট, আর Banglish-এ মান আরও অনিশ্চিত। Pretrained model খুঁজে পাওয়া, model card আর লাইসেন্স পড়া পর্ব ৭১ (Pretrained model ব্যবহার — Hugging Face, model card আর লাইসেন্স)-এ।
বানান আর আঞ্চলিক ভাষা মানে মানুষ। যে গ্রাহক প্রমিত বানানে লেখেন না, আঞ্চলিক ভাষায় (“ভালা না”, “বালা নায়”) বা শুধু রোমান হরফে লেখেন, তাঁর রিভিউ model কম চেনে। ফলে তাঁর অভিযোগ ভুল করে positive বলে গ্রাহকসেবায় না পৌঁছানোর ঝুঁকি বেশি। ব্লক ৪-এর মতো লিপি, অঞ্চল বা বানান-রীতি অনুযায়ী আলাদা ভুলের হার মাপা দরকার, যথেষ্ট n নিয়ে। দলভিত্তিক মূল্যায়ন আর নথিপত্র পর্ব ৭২ (দায়িত্বশীল AI-এর অভ্যাস — bias যাচাই, গোপনীয়তা আর নথিপত্র)-এ।
১০ · AI-এর যুগে — AI assistant-এর লেখা বাংলা text pipeline যাচাই
“বাংলা রিভিউয়ের sentiment classifier লিখে দাও” বললে AI assistant দ্রুত কোড দেয়, যা চলে আর একটি সংখ্যা ছাপে। বাংলা লেখায় তার ভুলগুলো প্রায়ই নীরব:
| AI যা প্রায়ই করে | কীভাবে ধরবেন |
|---|---|
Default TfidfVectorizer(), কার-চিহ্ন হারায়; বা tokenizer-এর সঙ্গে token_pattern=None দেয় না, warning চাপা পড়ে | get_feature_names_out()-এর প্রথম ৩০টি পড়ুন; সব warning পড়ুন |
| ইন্টারনেটের stopword তালিকা, যাতে “না”, “নয়”, “নেই” | তালিকা হাতে পড়ুন; negation রেখে আর বাদ দিয়ে দুবার মাপুন |
পুরো ডেটায় vectorizer fit, তারপর ভাগ | Pipeline-এর ভেতরে vectorizer; fold-এর বাইরে কোনো fit নয় |
| একটি ভাগের accuracy দিয়ে “model A সেরা” ঘোষণা | একই fold-এ macro F1, sd, baseline; প্রয়োজনে repeated CV |
বানানো function, যেমন অস্তিত্বহীন “bangla stemmer” import, বা LogisticRegression(multi_class=…) | নিজের version-এর অফিসিয়াল docs; কোড চালিয়ে দেখুন |
| “model বাংলা বোঝে” ধরনের দাবি | ব্যঙ্গ আর negation-এর কয়েকটি রিভিউ নিজে পরীক্ষা করুন |
নির্দিষ্ট হোন, আর যাচাই করা যায় এমন আউটপুট চান। আসল গ্রাহকের রিভিউ, নাম বা ফোন নম্বর পেস্ট করবেন না; কয়েকটি বানানো উদাহরণই যথেষ্ট।
AI-এর লেখা text pipeline হাতে পেলে — যাচাইয়ের চেকলিস্ট
আরও গভীরে: Bo Pang, Lillian Lee আর Shivakumar Vaithyanathan ২০০২ সালে (EMNLP) সিনেমার রিভিউয়ে Naive Bayes, maximum entropy (logistic regression) আর SVM তুলনা করে দেখান যে শব্দভিত্তিক model-এ sentiment বিষয়ভিত্তিক শ্রেণিবিভাগের চেয়ে কঠিন, আর negation-এর জন্য বিশেষ কৌশল লাগে। Sida Wang আর Christopher Manning ২০১২ সালের “Baselines and Bigrams”-এ দেখান, ছোট লেখায় bigram আর NB-এর ওজন মেশানো SVM (NBSVM) শক্ত baseline. SVM-এর ভিত্তি Corinna Cortes আর Vladimir Vapnik-এর ১৯৯৫ সালের কাজ; text-এ তার জনপ্রিয়তায় Thorsten Joachims-এর ১৯৯৮ সালের গবেষণা বড় ভূমিকা রাখে। BPE-কে ভাষার subword-এ আনেন Rico Sennrich ও সহকর্মীরা (২০১৬), মূল ধারণা Philip Gage-এর ১৯৯৪ সালের compression algorithm.
পরীক্ষা ও ইন্টারভিউতে:
- scikit-learn-এর smooth idf-এর সূত্র? ln((1 + n)/(1 + df)) + 1; n = ৩, df = ২ হলে ln(৪/৩) + ১ ≈ ১.২৮৮.
- TF-IDF-এ L2 normalization কেন? লম্বা লেখার সুবিধা দূর হয়, আর dot product সরাসরি cosine similarity হয়।
- Python-এর
\wবাংলায় কী সমস্যা করে? কার-চিহ্ন, হসন্ত আর নুক্তা combining mark,\wনয়; তাই শব্দ টুকরো হয়ে যায়। - Stopword মুছলে sentiment-এ ঝুঁকি? “না”, “নয়” মুছলে negation হারায়, অর্থ উল্টে যায়।
- Bigram কেন? Bag of words-এ ক্রম নেই; “ভালো না” আর “খারাপ না” জোড়া হিসেবে নিজস্ব ওজন পায়।
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন, তারপর “→ উত্তর” চাপুন।
প্র ০১TF-IDF পঞ্চাশ বছরের পুরোনো ধারণা। embedding আর বড় ভাষা model-এর যুগে এটি এখনো কেন শেখা আর ব্যবহার করা হয়?
idf-এর ধারণা Karen Spärck Jones ১৯৭২ সালে দেন, আর Gerard Salton-এর দল ১৯৬০ ও ৭০-এর দশকে SMART ব্যবস্থায় শব্দ-ওজনের vector দিয়ে নথি খোঁজার পথ তৈরি করেন। তারপর থেকে খোঁজার জগতে বহু পদ্ধতি এসেছে, যেমন BM25 (TF-IDF-এরই একটি পরিশীলিত আত্মীয়, যা ১৯৯০-এর দশকে Stephen Robertson ও সহকর্মীদের কাজ থেকে আসে) আর সাম্প্রতিক dense embedding. তবু TF-IDF টিকে আছে কয়েকটি বাস্তব কারণে।
এক, এটি স্বচ্ছ। এই পর্বের ভুল বিশ্লেষণে প্রতিটি সিদ্ধান্ত শব্দ ধরে ধরে ভাঙা গেছে: “না” −০.৪৬, “খারাপ না” +০.১৮. একটি embedding-এর শত শত সংখ্যার কোনটি কেন, তা এভাবে পড়া যায় না। গ্রাহকসেবা দলকে “কেন এই রিভিউ negative ধরা হলো” বোঝাতে এটি কাজে লাগে। দুই, এটি সস্তা ও দ্রুত: GPU লাগে না, model ফাইল কয়েক কিলোবাইট, ব্রাউজারেই চলে। তিন, নির্দিষ্ট শব্দের খোঁজে এটি প্রায়ই ভালো: পণ্যের কোড, নাম বা বিরল শব্দ (“ভেজাল”) ঠিক সেই শব্দ দিয়েই খোঁজা হয়, যেখানে embedding কখনো কাছাকাছি অর্থের অন্য কিছু এনে দেয়। এ কারণে বাস্তব খোঁজ-ব্যবস্থায় শব্দভিত্তিক আর embedding-ভিত্তিক খোঁজ প্রায়ই একসঙ্গে ব্যবহার হয় (hybrid search).
সীমাও স্পষ্ট, এই পর্বেই দেখেছেন: “খুব ভালো শাড়ি” আর “পণ্য ভালো না” শব্দে কাছাকাছি, মনোভাবে উল্টো; “চার্জার” আর “charger” সম্পর্কহীন। আর ছোট ডেটায় নতুন যেকোনো পদ্ধতির তুলনার জন্য TF-IDF + linear model একটি সৎ baseline; তাকে না হারাতে পারলে জটিল model-এর দাবি দুর্বল।
মূল উপলব্ধি: পুরোনো মানে বাতিল নয়; TF-IDF ব্যাখ্যাযোগ্য, সস্তা আর শক্ত baseline, আর অর্থের মিলের জন্য নতুন পদ্ধতি তার পাশে বসে, তার জায়গা নেয় না।
প্র ০২বাংলা লেখার জন্য whitespace, regex, char n-gram আর subword tokenizer: কোনটি কখন বাছবেন, আর বাছাইয়ের দায় কেন model-এর চেয়েও বড়?
Tokenizer হলো model-এর চোখ: সে যা দেখায় না, model তা কখনো শিখতে পারে না। পর্ব ৫১-এ scikit-learn-এর default pattern “আপনার”-কে “আপন” বানিয়েছিল, এই পর্বে “দিনে” পুরো মুছে দিয়েছে, অথচ কোনো error ছিল না। এমন নীরব ক্ষতির পর যে model-ই বসান, ক্ষতি পূরণ হয় না।
বাছাইয়ের একটি ব্যবহারিক নিয়ম: whitespace শুধু দ্রুত অনুসন্ধানের জন্য, কারণ যতিচিহ্ন আর emoji গায়ে লেগে থাকে। বাংলা-বান্ধব regex হলো ভালো শুরু: পরিষ্কার, ব্যাখ্যাযোগ্য শব্দ-feature, যা error analysis-এ পড়া যায়। Char n-gram বানান-ভুল, বিভক্তি আর লুকোনো অক্ষরে শক্ত (পর্ব ৫১-এ zero-width আক্রমণে টিকে ছিল), কিন্তু feature বহুগুণ, আর “িনে”-র মতো টুকরো পড়ে কিছু বোঝা কঠিন। এই পর্বের ছোট ডেটায় দুটির ফল প্রায় সমান ছিল। Subword tokenizer (BPE, WordPiece, SentencePiece) বড় ডেটা থেকে শেখা হয়, আর pretrained ভাষা model-এর সঙ্গে আসে; সেই model ব্যবহার করলে তার নিজের tokenizer ছাড়া চলবে না।
গবেষণায় একটি চিন্তার বিষয় আলোচিত হয়: বহুভাষিক tokenizer-এ যে ভাষার ভাগ ছোট, তার একই অর্থের বাক্য বেশি token-এ ভাঙে। এর মানে একই কাজে বেশি হিসাব, বেশি খরচ (যেখানে token প্রতি দাম), আর model-এর context-এ কম লেখা আঁটে। বাংলার ক্ষেত্রে এটি নিজে মেপে দেখা উচিত: একই বাক্য বাংলায় আর ইংরেজিতে কত token, তা গুনুন।
মূল উপলব্ধি: tokenizer বাছাই ডেটা, কাজ আর ব্যাখ্যার প্রয়োজন দেখে করুন, আর যেকোনো tokenizer আগে আসল বাক্যে চালিয়ে token নিজের চোখে দেখুন; ভুল এখানে হলে পরের সব ধাপ সেই ভুল বহন করে।
প্র ০৩ব্যঙ্গ আর negation শব্দ-গোনার model-এর জন্য কেন মৌলিকভাবে কঠিন? বাজারঘর বাস্তবে কী করতে পারে?
Bag of words আর TF-IDF ধরে নেয় যে প্রতিটি শব্দের একটি নিজস্ব দিক আছে, আর লেখার মনোভাব সেই দিকগুলোর যোগফল। Negation এই অনুমান ভাঙে: “না” নিজে কোনো দিক নয়, পাশের শব্দের দিক উল্টে দেয়। Unigram model তাই “না”-কে একটি বড় ঋণাত্মক ওজন দেয় (এখানে −১.৭৪৭), কারণ training-এ সে বেশিরভাগ negative রিভিউতে ছিল; ফলে “খারাপ না” positive হলেও ভুল হয়। Pang, Lee আর Vaithyanathan ২০০২ সালে সিনেমার রিভিউয়ে একটি সরল কৌশল ব্যবহার করেছিলেন: negation শব্দের পর থেকে যতিচিহ্ন পর্যন্ত প্রতিটি শব্দে “NOT_” জুড়ে দেওয়া। Bigram এর আরেক রূপ। কিন্তু বাংলায় negation-এর রূপ অনেক: আলাদা শব্দ (না, নয়, নেই), ক্রিয়ার সঙ্গে জোড়া (লাগেনি, দেয়নি, পাইনি), আর Banglish-এ “na”, “nai”. এই রূপগুলোর প্রতিটি আলাদা feature, আর ছোট ডেটায় প্রতিটি অল্প দেখা।
ব্যঙ্গ আরও কঠিন। Ellen Riloff ও সহকর্মীরা ২০১৩ সালে টুইটে ব্যঙ্গকে “ইতিবাচক মনোভাব + নেতিবাচক পরিস্থিতি”-র বৈপরীত্য হিসেবে ধরার চেষ্টা করেন, যেমন “love” আর “being ignored”. আমাদের “বাহ! তিন সপ্তাহে ডেলিভারি” রিভিউয়ে নেতিবাচক পরিস্থিতিটি বুঝতে জানতে হয় যে সাধারণ ডেলিভারি দুই-তিন দিনের; সেই জ্ঞান কোনো শব্দে নেই। বড় ভাষা model প্রেক্ষাপট থেকে এমন কিছু উদাহরণ ধরতে পারে, তবে নির্ভরযোগ্যভাবে নয়, আর মানুষও প্রায়ই ব্যঙ্গ নিয়ে একমত হন না।
বাস্তব পথ তাই মিশ্র: rating-এর সংখ্যা (১/৫) থাকলে তা লেখার পাশে আলাদা সংকেত হিসেবে ব্যবহার; 🙄 😒 👏-এর মতো emoji আর “বাহ!” ধরনের শুরুকে সন্দেহজনক হিসেবে চিহ্নিত করা; model-এর score সীমার কাছে হলে মানুষের কাছে পাঠানো; আর ভুল ধরা রিভিউগুলো নতুন training ডেটা হিসেবে সংগ্রহ করা।
মূল উপলব্ধি: শব্দ-গোনার model শব্দের যোগফল দেখে, প্রেক্ষাপট দেখে না; negation কিছুটা n-gram দিয়ে ধরা যায়, ব্যঙ্গ প্রায় যায় না, তাই অনিশ্চিত রিভিউ মানুষের চোখে পাঠানোই সৎ নকশা।
প্র ০৪১১৯টি রিভিউয়ে LinearSVC ০.৬৩৪, LogisticRegression ০.৫৭৪. বাজারঘরের ব্যবস্থাপক বললেন, “তাহলে SVM চালু করুন।” আপনি কী বলবেন?
প্রথমে ওঠানামা দেখাব। ব্লক ৩-এ প্রতিটি model-এর পাঁচটি fold-এর মান প্রায় ০.৪২ থেকে ০.৭৪-এর মধ্যে ছড়ানো, sd প্রায় ০.০৮–০.১২; দুই model-এর গড়ের পার্থক্য ০.০৬, যা এই ছড়ানোর চেয়ে ছোট। ডেস্কটপে ৫০টি ভাগে LinearSVC-এর গড় ০.৬৬৪, সর্বনিম্ন ০.৫০, সর্বোচ্চ ০.৮৭. পর্ব ৫১-এর অভিজ্ঞতাও মনে করাব: সেখানে ১৫টি test SMS-এ Bernoulli NB সেরা, অথচ সব ৬০টি ধরে সবচেয়ে খারাপ ছিল। ছোট ডেটায় ক্রম উল্টে যাওয়া স্বাভাবিক।
দ্বিতীয়ত, তুলনাটি পুরোপুরি ন্যায্য ছিল না। LogisticRegression-এর C = ১ বাছাই করা হয়নি; LinearSVC-এর C-ও না। ন্যায্য তুলনায় প্রতিটি model-এর hyperparameter ভেতরের CV-তে বাছতে হয়, বাইরের CV-তে মাপতে হয় (nested CV, পর্ব ৬৬)। একই fold-এর দিকে তাকিয়ে C বদলাতে থাকলে সেই fold-গুলোই overfit হয়।
তৃতীয়ত, সংখ্যাটি ব্যবসার প্রশ্নের উত্তর দেয় না। বাজারঘর চায় negative রিভিউ যেন হারিয়ে না যায়; তাহলে negative শ্রেণির recall আলাদা করে দেখা দরকার, আর ভুলগুলো কোন ধরনের (negation, ব্যঙ্গ, Banglish), তা জানা দরকার। ৬৬% accuracy-র একটি model দিয়ে স্বয়ংক্রিয়ভাবে রিভিউ লুকানো বা উত্তর পাঠানো ঝুঁকিপূর্ণ; বরং “সম্ভাব্য negative” হিসেবে অগ্রাধিকার সাজানো, যেখানে মানুষ শেষ সিদ্ধান্ত নেন।
আমার প্রস্তাব: আরও কয়েকশো রিভিউয়ে দুজন মানুষ দিয়ে label দেওয়া (তাদের মতের মিলও মাপা, কারণ ব্যঙ্গে মানুষও ভিন্নমত করেন), তারপর nested CV, আলাদা একটি test set একবার, আর লিপি অনুযায়ী ভুলের হার। ততদিন TF-IDF + linear model একটি পরীক্ষামূলক সহকারী, সিদ্ধান্তদাতা নয়।
মূল উপলব্ধি: ছোট ডেটায় গড়ের ছোট পার্থক্য প্রায়ই শব্দ, সংকেত নয়; model বাছাইয়ের আগে ওঠানামা, ন্যায্য hyperparameter বাছাই আর ব্যবসার আসল মাপ দেখুন।
প্র ০৫বাজারঘরের রিভিউ-classifier যদি আঞ্চলিক বানান বা Banglish-এ লেখা অভিযোগ বেশি ভুল করে, তার দায় কার, আর ডেটা সংগ্রহের নকশা কেমন হওয়া উচিত?
লেখার ধরন মানুষের পরিচয়ের সঙ্গে জড়িত। কেউ বাংলা keyboard ব্যবহার করেন না বলে রোমান হরফে লেখেন, কেউ আঞ্চলিক উচ্চারণ ধরে লেখেন, কারও প্রমিত বানানে অভ্যাস কম। ইংরেজির ক্ষেত্রে গবেষণায় দেখা গেছে, প্রমিত নয় এমন উপভাষার লেখায় কিছু ভাষা-হাতিয়ার বেশি ভুল করে; যেমন Su Lin Blodgett ও সহকর্মীদের কাজে (২০১৬ ও পরে) আফ্রিকান-আমেরিকান ইংরেজির টুইটে ভাষা শনাক্ত করার হাতিয়ারের দুর্বলতা আলোচিত হয়েছে। বাংলা নিয়ে এমন নির্দিষ্ট পরিমাপ আমাদের জানা নেই, তাই কোনো সংখ্যা দাবি করছি না; কিন্তু কারণটি একই: training ডেটায় যে রূপ কম, model তাকে কম চেনে।
দায় model-এর নয়, যাঁরা নকশা করেন তাঁদের। প্রথম কাজ মাপা: ব্লক ৪-এর মতো লিপি, বানান-রীতি আর সম্ভব হলে অঞ্চল অনুযায়ী ভুলের হার, বিশেষ করে negative রিভিউ positive বলা (যে ভুলে অভিযোগ হারায়)। n ছোট হলে দলগুলোর তুলনা অনিশ্চিত, তাই প্রতিটি দলে যথেষ্ট উদাহরণ লাগবে। দ্বিতীয় কাজ ডেটা সংগ্রহ: সম্মতি নিয়ে, বেনামি করে, বিভিন্ন অঞ্চল আর লেখার ধরনের রিভিউ জেনেশুনে অন্তর্ভুক্ত করা; label দেওয়ার নির্দেশিকায় আঞ্চলিক শব্দের উদাহরণ রাখা; আর label দেন এমন মানুষদের মধ্যেও বৈচিত্র্য। তৃতীয় কাজ নকশা: স্বয়ংক্রিয় সিদ্ধান্ত যেন কোনো দলের অভিযোগ চুপচাপ ফেলে না দেয়, তাই অনিশ্চিত রিভিউ মানুষের কাছে পাঠানো, আর গ্রাহককে নিজের রিভিউয়ে “এটি অভিযোগ” চিহ্ন দেওয়ার সুযোগ।
শেষে নথিপত্র: model কোন ডেটায় train, কোন দলে কত ভুল, কোন ব্যবহারে নিষেধ, তা লিখে রাখা (পর্ব ৭২-এ model card ও নথিপত্রের অভ্যাস)।
মূল উপলব্ধি: বানানের বৈচিত্র্য মানুষের বৈচিত্র্য; দলভিত্তিক ভুলের হার না মাপলে একটি গড় সংখ্যা কিছু গ্রাহকের নিয়মিত বঞ্চনা লুকিয়ে রাখতে পারে।
অনুশীলন
হাতে হিসাব, কোড, সমালোচনা আর আউটপুট অনুমান; প্রতিটির উত্তর নিচে লুকানো আছে।
-
হাতে TF-IDF (হিসাব): তিনটি রিভিউ: (১) “দাম কম মান ভালো”, (২) “মান খারাপ দাম বেশি”, (৩) “ভালো ভালো ডেলিভারি”. ফাঁকা জায়গায় ভেঙে scikit-learn-এর smooth idf (ln((1 + n)/(1 + df)) + 1), কাঁচা tf আর L2 normalization দিয়ে রিভিউ ৩-এর “ভালো” আর “ডেলিভারি”-র TF-IDF বের করুন।
- n = ৩. df(ভালো) = ২ (রিভিউ ১, ৩), df(ডেলিভারি) = ১.
- idf(ভালো) = ln(৪/৩) + ১ ≈ ১.২৮৭৭; idf(ডেলিভারি) = ln(৪/২) + ১ ≈ ১.৬৯৩১.
- tf × idf: ভালো ২ × ১.২৮৭৭ = ২.৫৭৫৪; ডেলিভারি ১ × ১.৬৯৩১ = ১.৬৯৩১.
- দৈর্ঘ্য √(২.৫৭৫৪² + ১.৬৯৩১²) = √(৬.৬৩২৫ + ২.৮৬৬৭) = √৯.৪৯৯২ ≈ ৩.০৮২১.
- ভালো ≈ ০.৮৩৫৬, ডেলিভারি ≈ ০.৫৪৯৪.
ডেস্কটপে
TfidfVectorizer(token_pattern=r"\S+")দিয়ে সত্যিকারের run-এ হুবহু এই দুটি সংখ্যা এসেছে, আর রিভিউ ১ ও ৩-এর cosine ০.৩৮৪ (শুধু “ভালো” ভাগ করা)। -
মিল দিয়ে খোঁজা (কোড): পুরো
reviews.csv-এ fit করা একটিTfidfVectorizer(tokenizer=tok, token_pattern=None)দিয়েtop_k(query, k=3)লিখুন, যা একটি নতুন খোঁজা-বাক্যের সবচেয়ে কাছের তিনটি রিভিউ (মিল, label, লেখা) দেয়। “চার্জার গরম হয়”, “taka ferot chai” আর “জামার রং উঠে গেছে” দিয়ে চালিয়ে ফল ব্যাখ্যা করুন।vec = TfidfVectorizer(tokenizer=tok, token_pattern=None) R = vec.fit_transform(df["text"]) # প্রতিটি সারি L2 = 1 def top_k(query, k=3): q = vec.transform([query]) # fit নয়, শুধু transform sim = (R @ q.T).toarray().ravel() # cosine = dot best = np.argsort(-sim, kind="stable")[:k] return [(round(float(sim[i]), 3), df["label"][i], df["text"][i]) for i in best]সত্যিকারের run (ডেস্কটপ, Python 3.14.4, scikit-learn 1.8.0), সংক্ষেপে:
চার্জার গরম হয় (0.545, 'positive', 'ইস্ত্রিটা দ্রুত গরম হয়, ব্যবহার সহজ। ভালো পণ্য') (0.264, 'negative', 'ইস্ত্রি গরমই হয় না, ফেরত চাই') (0.22, 'negative', 'ফোনের কভার ফিট হয় না, ক্যামেরার জায়গা ভুল') taka ferot chai (0.711, 'negative', 'product ta baje, taka ferot chai 😡') (0.251, 'negative', 'kombol ta patla, taka noshto') (0.0, 'positive', 'খুব ভালো শাড়ি, রং ছবির মতোই। …') জামার রং উঠে গেছে (0.565, 'negative', 'চাদরের রং উঠে বাকি কাপড় নষ্ট হয়ে গেছে') (0.522, 'negative', 'টি-শার্ট একবার ধুতেই রং উঠে গেছে, ভালো লাগেনি') (0.247, 'negative', 'ব্লেন্ডার দুই দিনেই নষ্ট হয়ে গেছে, মোটর পুড়ে গেছে')“চার্জার” vocabulary-তে নেই (চার্জারের রিভিউ রোমান হরফে), তাই মিল এসেছে শুধু “গরম” আর “হয়” থেকে: প্রথম ফল ইস্ত্রি। “taka ferot chai”-এর তৃতীয় ফলের মিল
0.0: কোনো শব্দ ভাগ করা নেই, তবু তালিকায় এসেছে, কারণ আমরা শুধু ক্রম নিয়েছি; বাস্তবে একটি ন্যূনতম সীমা (যেমন ০.১) দেওয়া উচিত। “জামা” নেই, তবু “রং উঠে গেছে” ঠিক রিভিউ খুঁজে এনেছে। খেয়াল করুন, query-তেtransform,fitনয়: নতুন বাক্য idf বদলাবে না। -
সমালোচনা ও নতুন নকশা: একজন সহকর্মী লিখেছেন: “ইন্টারনেট থেকে একটি বাংলা stopword তালিকা নিয়ে (তাতে না, নয়, নেই আছে) মুছে দিয়েছি, ইংরেজির Porter stemmer চালিয়েছি, পুরো ডেটায়
TfidfVectorizer()fit করে তারপর ভাগ করেছি। Training accuracy ৯৭%. এখন থেকে negative বলা রিভিউ ওয়েবসাইট থেকে স্বয়ংক্রিয়ভাবে লুকিয়ে ফেলা হবে।” কী কী ভুল, আর আপনি কীভাবে সাজাবেন?- Negation মোছা: “ভালো না” হয়ে যায় “ভালো”; ডেস্কটপের পরীক্ষায় এতে macro F1 ০.৬৯৪ থেকে ০.৬২৩. তালিকা হাতে পড়ে negation রাখতে হবে।
- ইংরেজির stemmer বাংলায়: Porter-এর নিয়ম ইংরেজির suffix-এর জন্য; বাংলা শব্দে অর্থহীন। বাংলায় stemming দরকার হলে নিজের ডেটায় মেপে দেখা।
- Default
TfidfVectorizer(): কার-চিহ্ন ভেঙে যায়; নিজের tokenizer বা char_wb. - ভাগের আগে fit: test-এর শব্দ vocabulary আর idf-এ ঢুকেছে, data leakage. Pipeline আর stratified CV.
- Training accuracy: নতুন রিভিউয়ে কেমন করবে তা বলে না; macro F1, sd আর baseline লাগবে।
- নেতিবাচক রিভিউ লুকানো: শুধু model-এর ভুলের ঝুঁকি নয়, নৈতিক সমস্যা: নেতিবাচক মত লুকানো ক্রেতাদের বিভ্রান্ত করে, আর অনেক দেশে ভোক্তা-সুরক্ষার প্রশ্ন তোলে। Model-এর কাজ হওয়া উচিত গ্রাহকসেবায় অগ্রাধিকার দেওয়া, রিভিউ সেন্সর করা নয়।
নতুন নকশা: NFC + zero-width মোছা, বাংলা-বান্ধব tokenizer, unigram + bigram, Pipeline-এ stratified CV, macro F1 আর negative-এর recall আলাদা করে, লিপি অনুযায়ী ভুলের হার, আর model-এর score অনুযায়ী রিভিউ গ্রাহকসেবা দলের তালিকায় ওপরে-নিচে সাজানো।
-
আউটপুট অনুমান: নিচের কোড কী ছাপবে? (
"\u09DF"হলো এক-code-point-এর “য়”.)import re, unicodedata from sklearn.feature_extraction.text import TfidfVectorizer print(re.findall(r"\w+", "দাম কম ভালো")) print(len("\u09DF"), len(unicodedata.normalize("NFC", "\u09DF"))) v = TfidfVectorizer(token_pattern=r"\S+").fit(["ভালো না", "ভালো"]) print(v.get_feature_names_out(), v.idf_.round(3))সত্যিকারের run (ডেস্কটপ, Python 3.14.4, scikit-learn 1.8.0):
['দ', 'ম', 'কম', 'ভ', 'ল'] 1 2 ['না' 'ভালো'] [1.405 1. ]প্রথম লাইন: “দাম”-এর া combining mark, তাই দ আর ম আলাদা; “কম”-এ কোনো কার-চিহ্ন নেই, তাই পুরো টিকেছে; “ভালো” ভেঙে ভ আর ল. দ্বিতীয় লাইন: NFC এক-code-point “য়”-কে য + নুক্তা, অর্থাৎ দুই code point-এ ভাঙে (composition exclusion). তৃতীয় লাইন: n = ২; “না” একটি রিভিউতে, idf = ln(৩/২) + ১ ≈ ১.৪০৫; “ভালো” দুটিতেই, ln(৩/৩) + ১ = ১. scikit-learn-এর vocabulary code point-এর ক্রমে সাজানো, আর “ন” (U+09A8) “ভ” (U+09AD)-এর আগে।
নিজেকে যাচাই করুন
ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।
ক্রমবাংলা ও Banglish রিভিউয়ের sentiment classifier বানানো আর যাচাইয়ের ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)
- লেখা পরিষ্কার: NFC, zero-width মোছা, lower আর বাংলা অঙ্ক থেকে ইংরেজি অঙ্ক
- বাংলা-বান্ধব tokenizer কয়েকটি বাক্যে চালিয়ে token চোখে দেখা
- Vectorizer আর classifier একটি Pipeline-এ রাখা
- একই stratified fold-এ কয়েকটি model-এর macro F1, sd আর baseline মাপা
- ভুল রিভিউ শব্দ ধরে খুলে দেখা আর লিপি অনুযায়ী ভুলের হার মাপা
- ভুল থেকে পাওয়া পরিবর্তন (যেমন bigram) একই fold-এ আবার যাচাই করা
তিনটি রিভিউয়ের একটি সংগ্রহে (n = ৩) একটি শব্দ দুটি রিভিউতে আছে। scikit-learn-এর smooth_idf=True সূত্রে তার idf প্রায় কত?
- ০.৪০৫
- ১.২৮৮
- ১.৪০৫
- ১.০০০
Python-এর standard re-তে re.findall(r"\w+", "ভালো না") কী দেয়, আর কেন?
['ভালো', 'না'], কারণ\wসব Unicode অক্ষর ধরে[], কারণ বাংলা অক্ষর\wনয়['ভ', 'ল', 'ন'], কারণ কার-চিহ্ন combining mark,\wনয়- তিন অক্ষরের টুকরো, কারণ
\wcharacter n-gram বানায়
একটি sentiment classifier-এর জন্য বাংলা stopword তালিকায় “না”, “নয়”, “নেই” রাখার প্রধান ঝুঁকি কী?
- Negation হারিয়ে রিভিউয়ের অর্থ উল্টে যেতে পারে
- Feature-এর সংখ্যা অনেক বেড়ে যায়
- Model train হতে অনেক বেশি সময় নেয়
- NFC normalization কাজ করা বন্ধ করে
“ভালো না, খারাপ” আর “খারাপ না, ভালো” দুটি রিভিউ। যতিচিহ্ন বাদ দেওয়া unigram TF-IDF-এ একটি linear classifier দুটিকে কী বলবে?
- প্রথমটি negative, দ্বিতীয়টি positive
- সবসময় দুটিই positive
- এলোমেলো, প্রতিবার আলাদা
- দুটিকে একই শ্রেণি, কারণ দুটির feature vector হুবহু এক
TF-IDF-এর প্রতিটি সারি L2 normalization করা থাকলে দুটি রিভিউয়ের cosine similarity বের করতে কী লাগে?
- দুটির Euclidean দূরত্ব
- শুধু দুটির dot product
- Dot product, তারপর আবার দুই দৈর্ঘ্যের বর্গ দিয়ে ভাগ
- ভাগ করা শব্দের সংখ্যা
১১৯টি রিভিউয়ে ৫-fold CV-তে model A-র macro F1 ০.৬৩ ± ০.০৮, model B-র ০.৫৭ ± ০.১২. কোন সিদ্ধান্ত সবচেয়ে যুক্তিসঙ্গত?
- A নিশ্চিতভাবে ভালো, তাই A চালু করা
- Macro F1 বাদ দিয়ে accuracy দিয়ে আবার মাপা
- পার্থক্যটিকে দুর্বল ইঙ্গিত ধরা, আর একই ভাগে repeated CV, ন্যায্য hyperparameter বাছাই ও বেশি ডেটা দিয়ে যাচাই করা
- Test set-এ দুটি চালিয়ে যেটি বেশি পায় সেটি বাছা, তারপর আবার test-এ মাপা
শব্দকোষ
লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন, অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:
মনে রাখুন
- আগে পরিষ্কার: NFC (য়/ড়/ঢ় দুই code point-এ), zero-width মোছা, lower, বাংলা অঙ্ক → 0–9. আগে-পরে আলাদা token গুনে প্রভাব মাপুন।
- Tokenizer বাছাই: Python-এর
\wকার-চিহ্ন ধরে না; বাংলা block যোগ করা regex, বা char_wb. Subword tokenizer pretrained model-এর সঙ্গে আসে। “না” stopword নয়; বাংলা stemming সহজেই over-stem করে। - TF-IDF: tf × (ln((1 + n)/(1 + df)) + 1), তারপর L2. L2-এর পর cosine = dot. মিল মানে শব্দের মিল, মনোভাবের নয়।
- সৎ তুলনা: Pipeline, একই stratified fold, macro F1, sd আর baseline. ১১৯টি রিভিউয়ে fold-ভেদে ওঠানামা model-এর পার্থক্যের সমান।
- ভুল পড়ুন: ওজন × TF-IDF দেখায় কোন শব্দ টেনেছে। Bigram negation-এর কিছুটা ধরে (৪১ → ৩৫ ভুল); ব্যঙ্গ, লিপি-ভেদ আর আঞ্চলিক বানান রয়ে যায়, আর সেগুলো ন্যায্যতার প্রশ্ন।
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পর্ব ৬৮ · Neural network-এর ধারণা — perceptron থেকে ছোট network, NumPy দিয়েপরবর্তী পর্বআধুনিক AI মডিউল শুরু: perceptron থেকে NumPy-তে নিজে লেখা ছোট neural network.
- AI-এর ভিত্তি আর Python for AI · কোর্স হোম৭৯টি পর্বপুরো কোর্সের রোডম্যাপ; পরের পর্ব ৬৮ · Neural network-এর ধারণা — perceptron থেকে ছোট network, NumPy দিয়ে.
- পর্ব ৫১ · Naive Bayes — বাংলা SMS-কে spam না ham বলাআগের ভিত্তিBag of words, default token_pattern-এর বাংলা-সমস্যা, char_wb আর adversarial spam.
- পর্ব ৩৩ · Vector আর similarity — dot product, cosine আর embedding-এর ধারণাআগের ভিত্তিNorm, unit vector আর cosine, যার ওপর TF-IDF খোঁজা দাঁড়িয়ে।
- পর্ব ১০ · ডেটার ফাইল — CSV, JSON, Parquet আর বাংলা লেখার encodingআগের ভিত্তিUTF-8, code point আর “য়”-এর দুই রূপ।
- পর্ব ৬০ · মূল্যায়ন — accuracy, precision, recall, F1 আর ROCআগের ভিত্তিMacro F1, bootstrap আর দুই model-এর ন্যায্য তুলনা।
- পর্ব ৬৬ · Model বাছাই — cross-validation আর hyperparameter searchআগের পর্বStratifiedKFold, nested CV আর একই fold-এ model তুলনা।
- Python · regular expressionপাশের ট্র্যাকCharacter class,
\w,findallআর tokenizer-এর regex.