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

Naive Bayes — বাংলা SMS-কে spam না ham বলা

Naive Bayes — Classifying Bangla and Banglish SMS as Spam or Ham, from Hand Counting to scikit-learn

Taught in Bangla, this lesson builds a Naive Bayes spam filter for 60 fictional Bangla and Banglish SMS: Bangla tokenization and why scikit-learn's default token pattern breaks vowel signs, hand-worked Laplace smoothing, log probabilities against underflow, a from-scratch classifier matched with MultinomialNB, Bernoulli and char n-gram comparisons with leave-one-out, and real runs of adversarial spam tests.

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

  • বাংলা ও মিশ্র লেখাকে token-এ ভাঙা, bag of words আর Naive Bayes-এর সূত্র
  • ৬টি SMS-এর খেলনা corpus-এ হাতে হিসাব, শূন্যের সমস্যা, Laplace আর add-α smoothing
  • Log probability দিয়ে underflow এড়ানো, আর পুরো classifier নিজের হাতে Python-এ লেখা
  • scikit-learn-এর CountVectorizer, MultinomialNB, BernoulliNB আর char_wb n-gram দিয়ে যাচাই, confusion matrix আর adversarial spam

মডিউল ৯-এ আমরা অনিশ্চয়তার সঙ্গে যুক্তি করছি। পর্ব ৩৫-এ conditional probability আর Bayes-এর নিয়মের প্রথম পরিচয় পেয়েছেন, আর পর্ব ৫০ (Bayes-এর উপপাদ্য — প্রমাণ পেলে বিশ্বাস হালনাগাদ)-এ prior, likelihood আর posterior বিস্তারিত। এবার সেই নিয়মে অনেক শব্দের প্রমাণ মিলিয়ে একটি বার্তার শ্রেণি বলব। চালানো যায় এমন ব্লকগুলো পাতাতেই চলে (ব্রাউজারের Python, pandas 3.0.2, scikit-learn 1.8.0). scikit-learn প্রথমবার নামতে প্রায় ১০ সেকেন্ড লাগে; পরের run দ্রুত।

১ · কাজটি — বাজারঘরের গ্রাহকের SMS, আর আমাদের ৬০টি বার্তা

বাজারঘর (এই কোর্সের কাল্পনিক অনলাইন দোকান) তার গ্রাহকদের SMS পাঠায়: অর্ডার নিশ্চিত, রাইডার পথে, টাকা ফেরত, লগইন কোড। একই ফোনে আসে প্রতারকদের বার্তাও: “আপনি লটারিতে ৫০০০০ টাকা জিতেছেন”, “আপনার অ্যাকাউন্ট আজ রাতে বন্ধ হবে, OTP পাঠান”, “৯৯ টাকায় স্মার্টফোন”. গ্রাহকসেবা দল চায় বাজারঘরের অ্যাপে একটি সতর্কবার্তা দেখাতে: “এই SMS সন্দেহজনক মনে হচ্ছে”. এর জন্য একটি binary classification লাগবে: প্রতিটি SMS-এর label হয় spam, নয় ham. “Ham” শব্দটি spam-এর উল্টো বোঝাতে ইমেইল ফিল্টারের জগতে চালু হয়েছিল; মানে সাধারণ, চাওয়া বার্তা।

পাড়ার ডাকপিয়নের কথা ভাবুন। বছরের পর বছর চিঠি বিলি করে তিনি খাম দেখেই আন্দাজ করেন কোনটা বিজ্ঞাপনের লিফলেট আর কোনটা আত্মীয়ের চিঠি। তিনি কোনো নিয়মের বই মুখস্থ করেননি; “পুরস্কার”, “বিনামূল্যে”, “আজই শেষ” শব্দগুলো লিফলেটে বারবার দেখেছেন, আর “মা”, “ভর্তি”, “দাওয়াত” দেখেছেন চিঠিতে। Naive Bayes ঠিক এভাবে কাজ করে: কোন শ্রেণিতে কোন শব্দ কতবার এসেছে, তা গুনে রাখে, আর নতুন বার্তায় সেই গোনা দিয়ে ভোট করে।

সত্যিকারের গ্রাহকের SMS আমরা ব্যবহার করব না; তাতে গোপনীয়তার সমস্যা আছে। তাই নিচের ৬০টি বার্তা এই পর্বের জন্য বানানো: ২৫টি spam, ৩৫টি ham. Spam-এ আছে ভুয়া পুরস্কার ও লটারি, OTP চাওয়া বা “অ্যাকাউন্ট বন্ধ হবে” ধরনের ভয় দেখানো, অবিশ্বাস্য ছাড়, ভুয়া পার্সেল-ফি আর ঘরে বসে আয়ের প্রলোভন। Ham-এর ১৬টি বাজারঘরের আসল ধাঁচের অর্ডার ও ডেলিভারি বার্তা, ১৯টি ব্যক্তিগত বার্তা। ৩২টি শুধু বাংলা হরফে, ২০টি ইংরেজি বা Banglish-এ (রোমান হরফে বাংলা), ৮টিতে দুই হরফ মেশানো। সব ফোন নম্বর 01X00-000000 আর সব লিংক example.com-এর, কোনো আসল ব্র্যান্ড, ব্যাংক বা মোবাইল অপারেটরের নাম নেই।

ইচ্ছা করে কিছু কঠিন উদাহরণ রাখা হয়েছে: বাজারঘরের নিজের ১০% ছাড়ের বার্তা (ham, অথচ “ছাড়” শব্দটি spam-এ বেশি), লগইন কোডের বার্তা (ham, অথচ “কোড” আছে), ক্রিকেট ম্যাচে “জিতেছি” (ham). ফাইলটি পাতার ভেতরে sms.csv নামে রাখা, কোড একে নাম দিয়ে পড়ে।

পুরো ফাইল যেকোনো ব্লকে pd.read_csv("sms.csv") দিয়ে পড়ে দেখতে পারেন।

সৎ সতর্কতা: ৬০টি বার্তা, তাও একজন লেখকের বানানো, বাস্তবের তুলনায় অনেক সহজ আর একঘেয়ে। এখানে যে accuracy পাবেন, বাস্তবের লাখো SMS-এ তার চেয়ে খারাপ হওয়ার সম্ভাবনাই বেশি। লক্ষ্য পদ্ধতি শেখা, কোনো “৯০% নির্ভুল” দাবি নয়।

২ · বাংলা লেখাকে token-এ ভাঙা — NFC, অঙ্ক আর যতিচিহ্ন

Model লেখা পড়ে না, সংখ্যা পড়ে। প্রথম কাজ তাই একটি SMS-কে ছোট ছোট টুকরোয় ভাঙা, যাকে বলে tokenization; প্রতিটি টুকরো একটি token. ইংরেজিতে ফাঁকা জায়গায় ভাঙলেই অনেকটা কাজ হয়। বাংলায় চারটি বাড়তি সাবধানতা লাগে।

  1. একই অক্ষরের দুই রূপ: “য়”, “ড়”, “ঢ়” এক code point-এও লেখা যায় (যেমন U+09DF), দুইটিতেও (য + নুক্তা ়)। চোখে এক, কিন্তু Python-এর কাছে দুটি আলাদা string, তাই “টাকায়” দুটি আলাদা শব্দ হয়ে যেতে পারে। Unicode normalization-এর NFC রূপে আনলে দুটি মেলে; পূর্ণ ব্যাখ্যা পর্ব ১০-এ, আর pandas-এ str.normalize("NFC") পর্ব ২৩-এ।
  2. বড়-ছোট হাতের অক্ষর: “FREE” আর “free” একই শব্দ ধরতে lower(). বাংলায় এর কোনো প্রভাব নেই।
  3. বাংলা আর ইংরেজি অঙ্ক: বাংলা keyboard-এ “৫০০” আসে, ইংরেজিতে “500”. দুটিকে এক token বানাতে str.maketrans("০১২৩৪৫৬৭৮৯", "0123456789") দিয়ে বাংলা অঙ্ক ইংরেজিতে বদলাই।
  4. যতিচিহ্ন: দাঁড়ি “।”, “!”, “:”, “%”, “#”, লিংকের “.” আর “-” সব token-এর সীমা। 01X00-000000 ভেঙে হবে 01x00 আর 000000; mega-deal.example.com ভেঙে চারটি token.

নিয়মটি একটি ছোট regular expression-এ লেখা যায়: [\s.,!?;:।#%()"'/\-]+, অর্থাৎ ফাঁকা জায়গা (\s) বা এই চিহ্নগুলোর যেকোনো ধারাবাহিকতায় কাটা হবে। Python-এ regex-এর বিস্তারিত দেখুন Python কোর্সের regular expression পর্বে। একটি প্রশ্ন বাকি: “শব্দের অক্ষর” খুঁজে খুঁজে token নিলে কী হয়? scikit-learn-এর default নিয়ম ঠিক তাই করে, (?u)\b\w\w+\b: দুই বা তার বেশি “word character”. সমস্যা হলো Python-এর \w বাংলার কার-চিহ্ন (vowel sign, যেমন া ি ু ে) আর হসন্ত (্)-কে word character মানে না, কারণ Unicode-এ এগুলো অক্ষর নয়, combining mark. নিচের stepper একটি নতুন কাল্পনিক SMS দিয়ে ধাপগুলো দেখায়; শেষ ধাপের ফল ডেস্কটপে scikit-learn 1.8.0-এর একটি সত্যিকারের run থেকে।

SMS-এর অবস্থাপ্রতি ধাপে একটি কাজ

            
মাপ
ধাপ
code point
token
  1. কাঁচা SMS. ধরুন এটি এমন একটি ফোন থেকে এসেছে যার keyboard “য়” আর “ড়”-কে এক code point-এ লেখে। মোট ৬৭টি code point.
  2. NFC. দেখতে কিছু বদলায়নি, কিন্তু এখন ৭০টি code point: “য়” আর “ড়” Unicode-এর নিয়মে NFC-তে দুই code point-এ (অক্ষর + নুক্তা) রাখা হয়। যেকোনো keyboard থেকে আসা “টাকায়” এখন হুবহু একই string.
  3. ছোট হাতের অক্ষর। “Mega-Deal” হলো “mega-deal”. বাংলা অংশ অপরিবর্তিত।
  4. অঙ্ক এক করা। ৯০ → 90, ৯৯ → 99. এখন বাংলা আর ইংরেজি keyboard-এর দামের অঙ্ক একই token দেবে।
  5. ভাঙা। ফাঁকা জায়গা আর যতিচিহ্নে কেটে ১৩টি token. “ছাড়”, “টাকায়”, “ঘড়ি” পুরো আছে; লিংক চার টুকরো।
  6. তুলনা: scikit-learn-এর default. একই লেখায় (?u)\b\w\w+\b দেয় মাত্র ১০টি, আর বাংলাগুলো ভাঙা: “অর্ডার” → “অর” (অ, র টিকে থাকে; ্, া বাদ, একা “ড” আর “র” দুই অক্ষরের কম বলে বাদ), “করুন” → “কর”, “ঘড়ি” → “ঘড”. “ছাড়”, “মাত্র”, “টাকায়” পুরো হারিয়ে গেছে, কারণ প্রতিটি কার-চিহ্নের পর মাত্র এক অক্ষর বাকি থাকে। শুধু কার-চিহ্নহীন “এখনই” বেঁচে গেছে।

এই পর্বের সব কোড তাই নিজের tokenize() ব্যবহার করে। তবু আমাদের সরল ভাঙারও সীমা আছে, আর তা সৎভাবে মানা দরকার। বাংলা শব্দের শেষে বিভক্তি আর প্রত্যয় জোড়া লাগে: “টাকা”, “টাকায়”, “টাকার” আমাদের ৬০টি SMS-এ তিনটি আলাদা token (১২, ৩ আর ১ বার)। একইভাবে “জিতেছেন” আর “জিতেছি”, “লিংক” আর “লিংকে” আলাদা। ফলে model-কে একই অর্থের শব্দ তিনবার আলাদাভাবে শিখতে হয়, আর ছোট ডেটায় প্রতিটির গোনা কমে যায়। শব্দের মূল রূপ খুঁজে বের করাকে বলে stemming; বাংলার জন্য stemming আর আরও ভালো tokenizer পর্ব ৬৭ (বাংলা লেখার ডেটা — tokenization, TF-IDF আর একটি classifier)-এ আসবে। এই পর্বে আমরা অন্য একটি পথও দেখব: পুরো শব্দের বদলে অক্ষরের ছোট টুকরো (অংশ ৭)।

৩ · Bag of words — শব্দের থলি

Token পাওয়ার পর সবচেয়ে সরল প্রতিনিধিত্ব হলো bag of words: প্রতিটি SMS-কে একটি থলি ভাবুন, যার ভেতরে শব্দগুলো ঢেলে দেওয়া হয়েছে। ক্রম হারিয়ে যায়, থাকে শুধু “কোন শব্দ কতবার”. Training-এর সব বার্তায় দেখা আলাদা শব্দের তালিকাকে বলে vocabulary; আমাদের ৬০টি SMS-এ ৪২০টি আলাদা token, মোট ৭২২টি token. প্রতিটি SMS তখন ৪২০ ঘরের একটি feature vector, যার প্রায় সব ঘর ০, কারণ একটি SMS-এ গড়ে ১২টির মতো token থাকে।

SMS (সংক্ষেপে)ফ্রিক্লিকঅর্ডাররাইডার… বাকি ঘর
ফ্রি! ফ্রি! ফ্রি! … লিংকে ক্লিক করুন4100প্রায় সব 0
অর্ডার #4512 পাঠানো হয়েছে। রাইডার …0011প্রায় সব 0
শেষ সুযোগ! … এখনই অর্ডার করুন0010প্রায় সব 0

এত শূন্য জমিয়ে রাখা অপচয়, তাই scikit-learn এমন টেবিল রাখে sparse matrix-এ: শুধু শূন্য নয় এমন ঘরের অবস্থান আর মান। এই গোনাটি গুরুত্বপূর্ণ কি না, নাকি শুধু “আছে কি নেই” জানলেই চলে, সেই প্রশ্ন থেকেই multinomial আর Bernoulli Naive Bayes-এর পার্থক্য (অংশ ৭)।

দুর্বলতা স্পষ্ট: ক্রম হারায়, তাই “বলবেন না”-র “না” কোন শব্দের সঙ্গে, তা জানা যায় না। শব্দের জোড়া feature হিসেবে নিলে কিছুটা ক্রম ফেরে; এই ধারণার নাম n-gram.

৪ · Naive Bayes-এর সূত্র — আর “naive” অনুমান কেন তবু কাজ করে

প্রশ্নটি Bayes-এর ভাষায়: SMS-এর শব্দগুলো w1, w2, …, wn দেখা গেছে; শ্রেণি c (spam বা ham) হওয়ার সম্ভাবনা কত? Bayes-এর নিয়ম বলে:

P(c | w1, …, wn) = P(c) · P(w1, …, wn | c) / P(w1, …, wn)

এখানে P(c) হলো prior, অর্থাৎ কোনো শব্দ দেখার আগে spam-এর ভাগ; P(w1, …, wn | c) হলো likelihood, অর্থাৎ শ্রেণি c হলে ঠিক এই শব্দগুলো আসার সম্ভাবনা; বাঁ পাশেরটি posterior. হরটি দুই শ্রেণির জন্য একই, তাই কোন শ্রেণি বড় তা ঠিক করতে হরটি লাগে না; “∝” (সমানুপাতিক) দিয়ে লিখি।

মুশকিল হলো মাঝের likelihood. ১২টি শব্দের ঠিক এই সমাহার training-এ হয়তো একবারও আসেনি, তাই সরাসরি গোনা অসম্ভব। Naive Bayes এখানে একটি সাহসী সরলীকরণ করে: শ্রেণি জানা থাকলে প্রতিটি শব্দ অন্য শব্দগুলো থেকে স্বাধীন। এটিই conditional independence (পর্ব ৩৫-এ পরিচয়)। তখন যৌথ likelihood ভেঙে এক শব্দের likelihood-এর গুণফল হয়:

P(c | শব্দগুলো) ∝ P(c) × P(w1 | c) × P(w2 | c) × … × P(wn | c) = P(c) · ∏i P(wi | c)

∏ মানে সব i-এর ওপর গুণ। প্রতিটি P(w | c) সহজে গোনা যায়: spam-এর সব token-এর মধ্যে “ফ্রি” কত ভাগ। এই সংখ্যাগুলো অনুমান করাই training; শিক্ষার আর কোনো ধাপ নেই। যে শ্রেণির score বড়, সেটিই উত্তর।

Naive Bayes-এর ছবি: শ্রেণি থেকে প্রতিটি শব্দে তীর, শব্দে-শব্দে কোনো তীর নেই

Naive Bayes as a generative model: the class generates each word independently

Naive Bayes: P(C | w1..wn) ∝ P(C) × Π P(wi | C) শ্রেণি C spam / ham P(w|C) P(w|C) ফ্রি পুরস্কার ক্লিক করুন … wₙ ✕ শব্দে-শব্দে তীর নেই: C জানা থাকলে স্বাধীন P(C|w₁…wₙ) ∝ P(C)·Π P(wᵢ|C)

ছবিটি একটি generative model-এর গল্পও বলে: আগে একটি শ্রেণি বেছে নেওয়া হয় (P(c) অনুযায়ী), তারপর সেই শ্রেণির শব্দ-বণ্টন থেকে একটি একটি করে শব্দ তোলা হয়, যেন একটি থলি থেকে লটারির কাগজ। এই গল্প স্পষ্টতই সত্য নয়। বাস্তবের spam-এ “অভিনন্দন” এলে “জিতেছেন” আসার সম্ভাবনা অনেক বেশি; “লিংকে” আর “ক্লিক” প্রায় সবসময় একসঙ্গে আসে। Naive Bayes এগুলোকে স্বাধীন প্রমাণ ধরে দুবার গোনে, তাই তার দেওয়া সম্ভাবনা প্রায়ই অতিরিক্ত চরম হয়: ০.৯৯৯ বা ০.০০১.

তবু কাজ করে কেন?

১৯৯৭ সালে Pedro Domingos আর Michael Pazzani Machine Learning জার্নালে দেখান যে শ্রেণি বাছাইয়ের জন্য (zero-one loss, অর্থাৎ শুধু ঠিক বা ভুল গোনা) সম্ভাবনাগুলো নির্ভুল হওয়ার দরকার নেই; শুধু ঠিক শ্রেণিটি সবচেয়ে বড় score পেলেই হলো। দুবার গোনা প্রমাণ সাধারণত সঠিক শ্রেণির দিকেই ঠেলে, তাই score অতিরঞ্জিত হলেও ক্রম প্রায়ই ঠিক থাকে; তবে সম্ভাবনার মান নিজে ভরসাযোগ্য নয়, তাকে calibration দিয়ে যাচাই করতে হয় (ভাবনার প্রশ্ন ১)। তাই Naive Bayes প্রায়ই একটি দ্রুত, শক্ত baseline, শেষ কথা নয়।

৫ · হাতে হিসাব — ৬টি SMS, শূন্যের সমস্যা আর Laplace smoothing

বিশ্ববিদ্যালয়, চাকরি বা ইন্টারভিউর পরীক্ষায় Naive Bayes প্রায়ই হাতে কষতে দেওয়া হয়। তাই একটি খুব ছোট, কাল্পনিক corpus নিই (corpus মানে লেখার সংগ্রহ):

শ্রেণিSMS
spamফ্রি পুরস্কার জিতুন
spamফ্রি অফার লিংকে ক্লিক
spamপুরস্কার পেতে ক্লিক
hamঅর্ডার পাঠানো হয়েছে
hamঅর্ডার আজ ডেলিভারি
hamআজ বাসায় দাওয়াত

নতুন SMS: “আজ ফ্রি ডেলিভারি অফার”. এটি বাজারঘরের আসল অফারও হতে পারে, spam-ও হতে পারে। ধাপগুলো: (১) prior: P(spam) = P(ham) = ৩/৬ = ০.৫. (২) গোনা: spam-এ মোট ১০টি token, ham-এ ৯টি; সব মিলিয়ে আলাদা শব্দ V = ১৪. (৩) প্রতিটি নতুন শব্দের likelihood, P(w | c) = গোনা ÷ শ্রেণির মোট token, যা আসলে maximum likelihood অনুমান। (৪) গুণ করে তুলনা।

এখানেই একটি ফাঁদ। “আজ” spam-এ একবারও আসেনি, তাই P(আজ | spam) = ০/১০ = ০. গুণফলে একটি শূন্য থাকলে পুরো গুণফল শূন্য, অন্য শব্দ যতই spam-এর দিকে ইঙ্গিত দিক। একে বলে zero-frequency problem. আর ham-এ “ফ্রি” আর “অফার” নেই, তাই ham-এর score-ও শূন্য। Model কোনো সিদ্ধান্তই নিতে পারে না। সমাধান Laplace smoothing: প্রতিটি শব্দের গোনায় ১ যোগ, আর হরে V যোগ (যাতে সম্ভাবনার যোগফল ১ থাকে):

P(w | c) = (count(w, c) + α) / (Nc + α · V)

Nc হলো শ্রেণি c-এর মোট token, V হলো vocabulary-র আকার, আর α = ১ হলে Laplace বা add-one. α যেকোনো ধনাত্মক সংখ্যা হলে নাম additive (add-α) smoothing, যাকে প্রায়ই Lidstone smoothing বলা হয়। ছোট α মানে দেখা গোনার ওপর বেশি ভরসা।

নতুন SMS-এর চারটি শব্দspam │ ham

            
  1. গোনার টেবিল। নতুন SMS-এর চারটি শব্দ প্রতিটি শ্রেণিতে কতবার এসেছে। খেয়াল করুন তিনটি শূন্য। spam-এ মোট ১০, ham-এ ৯টি token; vocabulary-তে ১৪টি আলাদা শব্দ।
  2. Smoothing ছাড়া spam. “আজ” আর “ডেলিভারি” spam-এ নেই, তাই দুটি গুণনীয়ক ০, আর পুরো score ০. “ফ্রি”-র ০.২-এর কোনো মূল্য রইল না।
  3. Smoothing ছাড়া ham. “ফ্রি” আর “অফার” ham-এ নেই, তাই এখানেও ০. দুই শ্রেণির score সমান শূন্য: model চুপ। একটি অদেখা শব্দ একাই ভেটো দিচ্ছে।
  4. এক যোগ। প্রতিটি শব্দের গোনায় ১ যোগ করি, যেন প্রতিটি শব্দ প্রতিটি শ্রেণিতে অন্তত একবার “কল্পনায়” দেখা গেছে। ১৪টি শব্দে ১৪টি বাড়তি গোনা, তাই হর ১০ + ১৪ = ২৪ আর ৯ + ১৪ = ২৩.
  5. নতুন likelihood. spam-এ “আজ” এখন ১/২৪ ≈ ০.০৪১৭, শূন্য নয়; ham-এ “ফ্রি” ১/২৩ ≈ ০.০৪৩৫. দেখা শব্দের মান একটু কমেছে (“ফ্রি” ০.২ থেকে ০.১২৫), কারণ সম্ভাবনার কিছু অংশ অদেখা শব্দকে দেওয়া হয়েছে।
  6. সিদ্ধান্ত। লবের গুণফল দুই দিকেই ১ × ৩ × ১ × ২ = ৬ আর ৩ × ১ × ২ × ১ = ৬, কিন্তু হর ২৪⁴ বনাম ২৩⁴. তাই ham সামান্য এগিয়ে: P(ham | SMS) ≈ ০.৫৪২. Spam-এর token বেশি বলে তার প্রতিটি শব্দের ভাগ একটু ছোট, এটুকুই পার্থক্য গড়েছে।

নিচের ব্লক একই হিসাব α = ০, ১ আর ০.১ দিয়ে করে। শুধু Python-এর standard library, তাই দ্রুত চলে।

from collections import Counter
import math

# ৬টি কাল্পনিক SMS: হাতে হিসাব মেলানোর জন্য
train = [("spam", "ফ্রি পুরস্কার জিতুন"), ("spam", "ফ্রি অফার লিংকে ক্লিক"),
         ("spam", "পুরস্কার পেতে ক্লিক"), ("ham", "অর্ডার পাঠানো হয়েছে"),
         ("ham", "অর্ডার আজ ডেলিভারি"), ("ham", "আজ বাসায় দাওয়াত")]
counts = {"spam": Counter(), "ham": Counter()}
for y, text in train:
    counts[y].update(text.split())
vocab = sorted(set(counts["spam"]) | set(counts["ham"]))
V = len(vocab)
print("V =", V, "| spam-এ token:", counts["spam"].total(), "| ham-এ token:", counts["ham"].total())

new = "আজ ফ্রি ডেলিভারি অফার".split()
for alpha in (0, 1, 0.1):
    print(f"\nalpha = {alpha}")
    score = {}
    for c in ("spam", "ham"):
        prior = sum(y == c for y, _ in train) / len(train)
        N = counts[c].total()
        lik = [(counts[c][w] + alpha) / (N + alpha * V) for w in new]
        score[c] = prior * math.prod(lik)
        print(c, "prior", prior, "|", " ".join(f"{w}:{p:.4f}" for w, p in zip(new, lik)),
              f"| score {score[c]:.3e}")
    total = sum(score.values())
    if total == 0:
        print("দুই শ্রেণির score-ই 0: সিদ্ধান্ত নেওয়া যায় না")
    else:
        print("P(ham | SMS) =", round(score["ham"] / total, 3), "→", max(score, key=score.get))

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

V = 14 | spam-এ token: 10 | ham-এ token: 9

alpha = 0
spam prior 0.5 | আজ:0.0000 ফ্রি:0.2000 ডেলিভারি:0.0000 অফার:0.1000 | score 0.000e+00
ham prior 0.5 | আজ:0.2222 ফ্রি:0.0000 ডেলিভারি:0.1111 অফার:0.0000 | score 0.000e+00
দুই শ্রেণির score-ই 0: সিদ্ধান্ত নেওয়া যায় না

alpha = 1
spam prior 0.5 | আজ:0.0417 ফ্রি:0.1250 ডেলিভারি:0.0417 অফার:0.0833 | score 9.042e-06
ham prior 0.5 | আজ:0.1304 ফ্রি:0.0435 ডেলিভারি:0.0870 অফার:0.0435 | score 1.072e-05
P(ham | SMS) = 0.542 → ham

alpha = 0.1
spam prior 0.5 | আজ:0.0088 ফ্রি:0.1842 ডেলিভারি:0.0088 অফার:0.0965 | score 6.839e-07
ham prior 0.5 | আজ:0.2019 ফ্রি:0.0096 ডেলিভারি:0.1058 অফার:0.0096 | score 9.873e-07
P(ham | SMS) = 0.591 → ham

α = ০-তে দুই score-ই হুবহু 0: শূন্যের সমস্যা। α = ১-এ stepper-এর সংখ্যাগুলোই এসেছে, ham জেতে ০.৫৪২-এ। α = ০.১-এ দেখা গোনার ওপর ভরসা বেশি, তাই অদেখা শব্দের শাস্তি কড়া (spam-এ “আজ” মাত্র ০.০০৮৮), আর ham-এর জয় একটু বড়, ০.৫৯১. α হলো একটি hyperparameter: ডেটা থেকে শেখা হয় না, মানুষ বেছে দেন, আর সৎভাবে বাছতে আলাদা validation ডেটা লাগে (পর্ব ৬৬, model বাছাই — cross-validation আর hyperparameter search).

Pierre-Simon Laplace ১৮শ শতকের শেষে “কাল সূর্য উঠবে কি না” ধরনের প্রশ্নে rule of succession দেন: n বার চেষ্টায় s বার সাফল্য হলে পরেরবার সাফল্যের সম্ভাবনা (s + 1)/(n + 2). দুই সম্ভাব্য ফলের প্রতিটিতে একটি কাল্পনিক গোনা, ঠিক add-one-এর মতো। add-α রূপটি সাধারণত George Lidstone-এর (১৯২০) নামে পরিচিত। পর্ব ৫০-এর ভাষায়, smoothing মানে একটি prior বিশ্বাস: “যেকোনো শব্দ যেকোনো শ্রেণিতে আসতে পারে”.

৬ · Log probability — গুণ নয়, যোগ; আর নিজের হাতে পুরো classifier

খেলনা SMS-এ মাত্র চারটি শব্দ, তবু score নেমে গেছে 10−5-এ। প্রতিটি শব্দের সম্ভাবনা ১-এর অনেক কম, তাই ৩০০ শব্দের একটি লম্বা বার্তায় গুণফল হতে পারে 10−700-এর মতো। কিন্তু float64 মোটামুটি 10−308-এর চেয়ে ছোট সাধারণ মান রাখতে পারে না (subnormal ধরলেও প্রায় 10−324), তাই ফল চুপচাপ 0.0 হয়ে যায়: underflow, যা পর্ব ৩৫-এ জন্মদিনের সমস্যায় দেখেছেন। দুই শ্রেণিরই score 0.0 হলে তুলনা অর্থহীন।

সমাধান হলো log নেওয়া। log গুণকে যোগ বানায়, আর log একটি বর্ধমান function, তাই যে শ্রেণির গুণফল বড়, তার log-এর যোগফলও বড়:

score(c) = log P(c) + ∑i log P(wi | c)

প্রতিটি পদ একটি ঋণাত্মক সংখ্যা, আর যোগফল −১৬০০ হলেও float সহজে রাখতে পারে। এটিই log probability-র কৌশল; পর্ব ৩৮-এ log loss-এও একই কারণে log ছিল। দুই শ্রেণির score-এর পার্থক্য, score(spam) − score(ham), হলো log-odds: ধনাত্মক হলে spam, ঋণাত্মক হলে ham, আর মান যত বড় তত দৃঢ় সিদ্ধান্ত। সম্ভাবনা ফিরে পেতে চাইলে P(ham) = 1 / (1 + e−(score(ham) − score(spam))).

“আজ ফ্রি ডেলিভারি অফার”, α = 1 (ln = স্বাভাবিক log)
এই ধাপের পদ
spam: যোগ হলো
spam: মোট
ham: যোগ হলো
ham: মোট
log-odds (spam − ham)
  1. শুরু prior দিয়ে। দুই শ্রেণির prior ০.৫, তাই দুই দিকেই ln ০.৫ = −০.৬৯৩. Log-odds ০: কোনো দিকে ঝোঁক নেই।
  2. “আজ”। spam-এ ১/২৪, ham-এ ৩/২৩. ham-এর পদ কম ঋণাত্মক (−২.০৩৭ বনাম −৩.১৭৮), তাই log-odds −১.১৪১: ham-এর দিকে।
  3. “ফ্রি”। এবার উল্টো: spam-এ ৩/২৪, ham-এ ১/২৩. spam প্রায় পুরোটা পুষিয়ে নিল, log-odds −০.০৮৫.
  4. “ডেলিভারি”। ham-এ দুবারের সমান (২/২৩), spam-এ অদেখা (১/২৪): আবার ham-এর দিকে, −০.৮২১.
  5. “অফার”। spam-এ ২/২৪, ham-এ ১/২৩; log-odds ০.৬৫০ বাড়ল। গোল করার আগের মানে মোট পার্থক্য দাঁড়ায় ham-এর দিকে ০.১৭০; চার দশমিকে গোল করা পদ যোগ করলে শেষ অঙ্কে ০.০০১-এর তফাত আসতে পারে।
  6. সিদ্ধান্ত। ham-এর মোট −১১.৪৪৩ বড় (কম ঋণাত্মক), তাই ham. e−১১.৬১৪ ≈ ৯.০৪ × ১০−৬, অর্থাৎ stepper-এর আগের গুণফলই; আর 1/(1 + e−0.170) ≈ ০.৫৪২. Log space-এ উত্তর একই, শুধু underflow-এর ভয় নেই।

৬০টি SMS-এ নিজের হাতে লেখা Naive Bayes

এবার পুরো ডেটা। আগে ডেটা ভাগ: ৭৫% train, ২৫% test. stratify দিলে দুই ভাগে spam আর ham-এর অনুপাত মূল ডেটার মতো থাকে (stratified ভাগ), আর random_state=42 দিলে প্রতিবার একই ভাগ (seed). Test-এর বার্তা vocabulary-তেও ঢোকে না; কেন, তা পর্ব ৫৬ (Train, validation আর test — data leakage-এর ফাঁদ)-এ। Test-এ যে শব্দ training-এ নেই, তাকে আমরা বাদ দিই; scikit-learn-ও তাই করে। শেষ চার লাইন ৩৫টি ham জোড়া দিয়ে একটি লম্বা বার্তা বানিয়ে underflow দেখায়। এই ব্লক আর পরের দুটি একই namespace ভাগ করে (data-shared); পরের কোনো ব্লক আগে চালালে এটি আপনা থেকে আগে চলে।

import re, math, unicodedata
from collections import Counter
import pandas as pd
from sklearn.model_selection import train_test_split

df = pd.read_csv("sms.csv")
SPLIT = re.compile(r"[\s.,!?;:।#%()\"'/\-]+")      # ফাঁকা জায়গা আর যতিচিহ্ন
BN2EN = str.maketrans("০১২৩৪৫৬৭৮৯", "0123456789")
def clean(t): return unicodedata.normalize("NFC", t).lower().translate(BN2EN)
def tokenize(t): return [w for w in SPLIT.split(clean(t)) if w]

tr, te = train_test_split(df, test_size=0.25, stratify=df["label"], random_state=42)
counts = {"ham": Counter(), "spam": Counter()}
for t, y in zip(tr["text"], tr["label"]):
    counts[y].update(tokenize(t))
vocab = set(counts["ham"]) | set(counts["spam"])
logprior = {c: math.log((tr["label"] == c).mean()) for c in counts}
def loglik(w, c, a=1.0):
    return math.log((counts[c][w] + a) / (counts[c].total() + a * len(vocab)))
def score(text):
    toks = [w for w in tokenize(text) if w in vocab]   # অচেনা শব্দ বাদ
    return {c: logprior[c] + sum(loglik(w, c) for w in toks) for c in counts}

pred = [max(s := score(t), key=s.get) for t in te["text"]]
print("train:", len(tr), "test:", len(te), te["label"].value_counts().to_dict())
print("vocab:", len(vocab), "| token:", {c: counts[c].total() for c in counts})
print("accuracy:", round(sum(p == y for p, y in zip(pred, te["label"])) / len(te), 3))

long_msg = " ".join(df.loc[df["label"] == "ham", "text"])   # ৩৫টি ham জোড়া
toks = [w for w in tokenize(long_msg) if w in vocab]
raw = {c: math.exp(logprior[c]) * math.prod(math.exp(loglik(w, c)) for w in toks)
       for c in counts}
print(len(toks), "টি token, সরাসরি গুণ:", raw)
print("log-এ যোগ:", {c: round(v, 1) for c, v in score(long_msg).items()})

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

train: 45 test: 15 {'ham': 9, 'spam': 6}
vocab: 340 | token: {'ham': 270, 'spam': 274}
accuracy: 0.8
309 টি token, সরাসরি গুণ: {'ham': 0.0, 'spam': 0.0}
log-এ যোগ: {'ham': -1665.1, 'spam': -1879.7}

Stratify-এর ফলে test-এ ৯টি ham আর ৬টি spam, মূল ৩৫:২৫ অনুপাতের কাছাকাছি। Training-এর ৪৫টি SMS-এ ৩৪০টি আলাদা শব্দ, দুই শ্রেণিতে মোট token প্রায় সমান (২৭০ আর ২৭৪)। Training-এর পুরো কাজ ছিল কয়েকটি Counter ভরা; কোনো loop করে শেখা, কোনো gradient নেই। ১৫টির মধ্যে ১২টি ঠিক, accuracy ০.৮; কোন তিনটি ভুল, তা অংশ ৮-এ দেখব।

Underflow-এর লাইনটি দেখুন: ৩০৯টি চেনা token-এর গুণফল দুই শ্রেণিতেই 0.0, কোনো error বা warning ছাড়া। আর log-এ যোগ করলে ham −১৬৬৫.১ আর spam −১৮৭৯.৭; পার্থক্য প্রায় ২১৫, অর্থাৎ ham অবিশ্বাস্য রকম বেশি সম্ভাব্য (e২১৫ গুণ)। এত চরম সংখ্যা নিজেই naive অনুমানের একটি লক্ষণ: ৩০৯টি শব্দকে স্বাধীন প্রমাণ ধরা হয়েছে। (এই লম্বা বার্তাটি training-এর ham থেকেই বানানো, তাই এটি শুধু underflow দেখানোর জন্য, মূল্যায়নের জন্য নয়।)

৭ · scikit-learn দিয়ে যাচাই — Multinomial, Bernoulli আর char n-gram

নিজের লেখা কোড ঠিক কি না, তা জানার সবচেয়ে ভালো উপায় একটি পরীক্ষিত library-র সঙ্গে মেলানো। scikit-learn-এ দুটি অংশ লাগে: CountVectorizer লেখাকে bag-of-words-এর sparse matrix বানায়, আর MultinomialNB সেই গোনা থেকে শেখে। আমাদের tokenize সরাসরি analyzer হিসেবে দেওয়া যায়; তখন vectorizer নিজে কোনো preprocessing বা lowercase করে না, পুরো দায়িত্ব আমাদের function-এর। MultinomialNB(alpha=1.0)-এর সূত্র হুবহু অংশ ৫-এর (Ncw + α) / (Nc + αV), সঙ্গে training-এর শ্রেণি-অনুপাত prior হিসেবে।

দুই রকম Naive Bayes

🎲 Multinomial NB

SMS-কে শব্দের একটি ক্রম ভাবে, যেন প্রতিটি শব্দ শ্রেণির শব্দ-থলি থেকে আলাদাভাবে তোলা। “ফ্রি” চারবার থাকলে চারবার গোনে। অনুপস্থিত শব্দ কিছু যোগ করে না। McCallum আর Nigam ১৯৯৮ সালে লেখা শ্রেণিবিভাগে দুই ধরনের তুলনা করে দেখান, বড় vocabulary-তে সাধারণত multinomial ভালো করে।

MultinomialNB

🔘 Bernoulli NB

প্রতিটি vocabulary শব্দের জন্য একটি হ্যাঁ/না প্রশ্ন: SMS-এ আছে কি নেই। গোনা ভুলে যায় (চারটি “ফ্রি” = একটি)। অনুপস্থিতিও প্রমাণ: P(d | c) = ∏w∈V pwxw (1 − pw)1 − xw, যেখানে xw ১ বা ০, আর pw = (শব্দটি আছে এমন SMS + α) / (শ্রেণির SMS + 2α)।

BernoulliNB

সংজ্ঞা দুটি: multinomial Naive Bayes আর Bernoulli Naive Bayes. সংখ্যার feature-এর জন্য তৃতীয় ধরন GaussianNB, যা পর্ব ৪-এ ডেলিভারির দূরত্ব আর ওজন দিয়ে দেখেছেন; শব্দের গোনায় সেটি মানায় না।

শব্দের বদলে অক্ষরের টুকরো

অংশ ২-এর বিভক্তির সমস্যা এড়ানোর একটি সহজ পথ: শব্দকে পুরো না নিয়ে তার ভেতরের ২ থেকে ৪ অক্ষরের টুকরো নেওয়া, যাকে বলে character n-gram. analyzer="char_wb" শুধু শব্দের ভেতরে টুকরো বানায়, শব্দের দুই প্রান্তে একটি ফাঁকা জায়গা জুড়ে। তখন “টাকা”, “টাকায়” আর “টাকার” অনেক টুকরো (“টা”, “টাকা” ইত্যাদি) ভাগ করে নেয়, আর একটি নতুন বিভক্তিও পুরো অচেনা হয় না। দাম: feature অনেক বেশি। এখানে “অক্ষর” মানে code point, চোখে দেখা একক (grapheme cluster) নয়, তবে গোনার কাজে তাতে সমস্যা কম। preprocessor=clean দিয়ে আমাদের পরিষ্কারের নিয়ম এখানেও খাটে।

import numpy as np
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.naive_bayes import MultinomialNB, BernoulliNB
from sklearn.metrics import confusion_matrix

# scikit-learn-এর default token_pattern বাংলায় কী করে?
print(CountVectorizer().fit(["আপনার অর্ডার পাঠানো হয়েছে"]).get_feature_names_out())

vec = CountVectorizer(analyzer=tokenize)          # নিজের tokenizer
Xtr, Xte = vec.fit_transform(tr["text"]), vec.transform(te["text"])
mnb = MultinomialNB(alpha=1.0).fit(Xtr, tr["label"])
j = vec.vocabulary_["ফ্রি"]
print("vocab:", len(vec.vocabulary_), "| নিজের NB-এর সঙ্গে prediction মিল:",
      list(mnb.predict(Xte)) == pred, "| ফ্রি-র log P মিল:",
      bool(np.isclose(mnb.feature_log_prob_[1, j], loglik("ফ্রি", "spam"))))

cw = CountVectorizer(analyzer="char_wb", ngram_range=(2, 4), preprocessor=clean)
dv = CountVectorizer()
setups = {
    "Multinomial, শব্দ      ": (MultinomialNB(), vec),
    "Bernoulli, শব্দ        ": (BernoulliNB(), vec),
    "Multinomial, char_wb   ": (MultinomialNB(), cw),
    "Multinomial, default   ": (MultinomialNB(), dv),
}
y_true = te["label"].to_numpy()
for name, (model, v) in setups.items():
    A, B = v.fit_transform(tr["text"]), v.transform(te["text"])
    yp = model.fit(A, tr["label"]).predict(B)
    cm = confusion_matrix(y_true, yp, labels=["ham", "spam"])
    print(f"{name} features {A.shape[1]:>4}  accuracy {np.mean(yp == y_true):.3f}  "
          f"[[TN, FP], [FN, TP]] = {cm.tolist()}")

আসল আউটপুট (ব্রাউজারের Python, scikit-learn 1.8.0):

['অর' 'আপন' 'হয']
vocab: 340 | নিজের NB-এর সঙ্গে prediction মিল: True | ফ্রি-র log P মিল: True
Multinomial, শব্দ       features  340  accuracy 0.800  [[TN, FP], [FN, TP]] = [[6, 3], [0, 6]]
Bernoulli, শব্দ         features  340  accuracy 0.933  [[TN, FP], [FN, TP]] = [[9, 0], [1, 5]]
Multinomial, char_wb    features 3160  accuracy 0.867  [[TN, FP], [FN, TP]] = [[8, 1], [1, 5]]
Multinomial, default    features  227  accuracy 0.867  [[TN, FP], [FN, TP]] = [[7, 2], [0, 6]]

প্রথম লাইন অংশ ২-এর দাবির প্রমাণ: default নিয়মে চারটি বাংলা শব্দ থেকে তিনটি ভাঙা টুকরো, “আপন”, “অর”, “হয়”-এর অর্ধেক “হয”; “পাঠানো” পুরো উধাও। দ্বিতীয় লাইন: আমাদের হাতে লেখা classifier আর MultinomialNB ১৫টি test SMS-এ হুবহু একই উত্তর দেয়, আর “ফ্রি”-র log সম্ভাবনাও এক। (feature_log_prob_-এর সারি ১ হলো spam, কারণ scikit-learn শ্রেণিগুলো বর্ণানুক্রমে সাজায়: ham, spam.)

Confusion matrix-এ spam-কে “positive” ধরা হয়েছে: TN = ঠিকভাবে ham, FP = আসল বার্তাকে spam বলা, FN = spam-কে ham বলা, TP = ঠিকভাবে spam. এই ১৫টি বার্তায় Bernoulli সেরা (০.৯৩৩), multinomial-শব্দ সবচেয়ে খারাপ (০.৮০০, তিনটি আসল বার্তা spam). কিন্তু ১৫টিতে একটি ভুল মানে ০.০৬৭-এর লাফ; এই ক্রমে কি ভরসা করা যায়?

ছোট test set-এর ফাঁদ: leave-one-out

একটি ভাগের ফল ভাগ্যের ওপর অনেকটা নির্ভর করে। আরও স্থির মাপ পেতে ডেস্কটপে leave-one-out চালানো হয়েছে: ৬০ বার, প্রতিবার একটি SMS বাদ রেখে বাকি ৫৯টিতে পুরো pipeline (vectorizer সহ) নতুন করে train, তারপর বাদ রাখা SMS-টির উত্তর। তাতে প্রতিটি বার্তা একবার করে test-এ যায়। কোডের মূল অংশ (বাকিটা ওপরের ব্লকের মতো):

from sklearn.pipeline import make_pipeline
from sklearn.model_selection import LeaveOneOut, cross_val_predict

setups = {
    "Multinomial, words, alpha=1  ": make_pipeline(words(), MultinomialNB(alpha=1.0)),
    "Multinomial, words, alpha=0.1": make_pipeline(words(), MultinomialNB(alpha=0.1)),
    "Bernoulli, words, alpha=1    ": make_pipeline(words(), BernoulliNB(alpha=1.0)),
    "Multinomial, char_wb 2-4     ": make_pipeline(chars(), MultinomialNB(alpha=1.0)),
    "Multinomial, default pattern ": make_pipeline(CountVectorizer(), MultinomialNB()),
}
for name, pipe in setups.items():
    yp = cross_val_predict(pipe, df["text"], df["label"], cv=LeaveOneOut())
    tn, fp, fn, tp = confusion_matrix(df["label"], yp, labels=["ham", "spam"]).ravel()
    print(f"{name} accuracy {accuracy_score(df['label'], yp):.3f}  TN {tn} FP {fp} FN {fn} TP {tp}")

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

~/bazarghar-ai$ python nb_compare.py
scikit-learn 1.8.0 | 60 SMS, leave-one-out
Multinomial, words, alpha=1   accuracy 0.900  TN 29 FP 6 FN 0 TP 25
Multinomial, words, alpha=0.1 accuracy 0.867  TN 28 FP 7 FN 1 TP 24
Bernoulli, words, alpha=1     accuracy 0.733  TN 35 FP 0 FN 16 TP 9
Multinomial, char_wb 2-4      accuracy 0.867  TN 30 FP 5 FN 3 TP 22
Multinomial, default pattern  accuracy 0.817  TN 29 FP 6 FN 5 TP 20

ক্রম প্রায় উল্টে গেছে। ৬০টি বার্তা ধরে multinomial-শব্দ সেরা (০.৯০০, কোনো spam ছাড়া পায়নি, কিন্তু ৬টি আসল বার্তা spam), আর Bernoulli সবচেয়ে খারাপ (০.৭৩৩): এটি একটিও আসল বার্তা হারায় না, কারণ প্রায় সবকিছুকে ham বলে, ২৫টি spam-এর ১৬টি ছেড়ে দেয়। একটি সম্ভাব্য ব্যাখ্যা: Bernoulli প্রতিটি অনুপস্থিত শব্দের জন্যও log(1 − pw) যোগ করে। Spam-এর উদাহরণ কম, তাই spam-এ দেখা শব্দের pw বড়, আর একটি ছোট spam SMS-এ অন্য spam-এর বেশিরভাগ শব্দ থাকে না; সেই অনুপস্থিতি spam-এর score কমায়। ডেস্কটপে ভুল-বলা spam-গুলোর score ভেঙে দেখা গেছে, এই টান সত্যিই উপস্থিত শব্দের টানকে ছাপিয়ে যাচ্ছিল। ভাঙা default নিয়মও ০.৮১৭ পেয়েছে, তবে নিজের tokenizer-এর চেয়ে স্পষ্টতই খারাপ।

মূল ধারণা

১৫টি বার্তার একটি test set দিয়ে দুই পদ্ধতির তুলনা প্রায় অর্থহীন। একই ডেটায় একটি ভাগে Bernoulli প্রথম, সব বার্তা ধরে শেষ। ছোট ডেটায় cross-validation বা অনেক ভাগের গড় নিন (পর্ব ৬৬), প্রতিটি সংখ্যার পাশে n লিখুন, আর মনে রাখুন এই ৬০টি বার্তা একজনের বানানো: বাস্তবের বৈচিত্র্যে সব সংখ্যাই নামবে।

৮ · Model কী শিখল — সবচেয়ে তথ্যবহ শব্দ আর দুটি কঠিন বার্তা

Naive Bayes-এর একটি বড় গুণ: পুরো model খুলে পড়া যায়। log-odds লিখলে দেখা যায় এটি আসলে প্রতিটি শব্দের একটি ওজনের যোগফল:

log-odds = [log P(spam) − log P(ham)] + ∑w count(w) × [log P(w | spam) − log P(w | ham)]

বন্ধনীর ভেতরের দ্বিতীয় পদটি প্রতিটি শব্দের log-probability ratio: ধনাত্মক হলে শব্দটি spam-এর দিকে টানে, ঋণাত্মক হলে ham-এর দিকে। Score তাই শব্দ-গোনার একটি রৈখিক যোগফল, অর্থাৎ multinomial NB আসলে একটি linear classifier; logistic regression-ও (পর্ব ৫৯) একই রূপের, শুধু ওজনগুলো অন্যভাবে শেখে। নিচের ব্লক ওজন অনুযায়ী শব্দ সাজায়, তারপর test set-এর যে আসল বার্তাগুলোকে spam বলা হয়েছিল, তার দুটি শব্দ ধরে ধরে খুলে দেখায়।

import numpy as np

# প্রতিটি শব্দের log P(w | spam) − log P(w | ham)
ratio = mnb.feature_log_prob_[1] - mnb.feature_log_prob_[0]
words = vec.get_feature_names_out()
up = np.argsort(-ratio, kind="stable")[:8]; down = np.argsort(ratio, kind="stable")[:8]
print("spam-এর দিকে:", [(words[i], round(float(ratio[i]), 2)) for i in up])
print("ham-এর দিকে: ", [(words[i], round(float(ratio[i]), 2)) for i in down])

# test set-এর যে ham গুলো spam বলা হয়েছে, শব্দ ধরে ধরে
wrong = [t for t, y, p in zip(te["text"], te["label"], pred) if y == "ham" and p == "spam"]
for text in wrong[:2]:
    print("\nSMS:", text)
    total = logprior["spam"] - logprior["ham"]
    print(f"  {'prior':<10} {total:+.2f}")
    for w in tokenize(text):
        if w not in vocab:
            print(f"  {w:<10}  অচেনা, বাদ")
            continue
        d = loglik(w, "spam") - loglik(w, "ham")
        total += d
        print(f"  {w:<10} {d:+.2f}  (spam-এ {counts['spam'][w]}, ham-এ {counts['ham'][w]} বার)")
    print(f"  মোট log-odds {total:+.2f} → {'spam' if total > 0 else 'ham'}")

আসল আউটপুট (ব্রাউজারের Python, scikit-learn 1.8.0):

spam-এর দিকে: [('otp', 2.07), ('ফ্রি', 1.94), ('এখনই', 1.6), ('করুন', 1.6), ('click', 1.38), ('আপনি', 1.38), ('কার্ড', 1.38), ('জিতেছেন', 1.38)]
ham-এর দিকে:  [('বাজারঘর', -2.4), ('bazarghar', -1.39), ('hoyeche', -1.39), ('অনেক', -1.39), ('কোড', -1.39), ('ডেলিভারি', -1.39), ('রাইডার', -1.39), ('er', -1.11)]

SMS: kal sokal e bazar e jabo. kichu lagle janaben
  prior      -0.31
  kal        -0.70  (spam-এ 0, ham-এ 1 বার)
  sokal       অচেনা, বাদ
  e          +1.09  (spam-এ 2, ham-এ 0 বার)
  bazar       অচেনা, বাদ
  e          +1.09  (spam-এ 2, ham-এ 0 বার)
  jabo        অচেনা, বাদ
  kichu       অচেনা, বাদ
  lagle       অচেনা, বাদ
  janaben    -0.70  (spam-এ 0, ham-এ 1 বার)
  মোট log-odds +0.47 → spam

SMS: ছোট ভাইয়ের ভর্তির ফি জমা দেওয়া হয়েছে। রসিদের ছবি পাঠালাম
  prior      -0.31
  ছোট         অচেনা, বাদ
  ভাইয়ের     অচেনা, বাদ
  ভর্তির      অচেনা, বাদ
  ফি         +1.09  (spam-এ 2, ham-এ 0 বার)
  জমা         অচেনা, বাদ
  দেওয়া      অচেনা, বাদ
  হয়েছে     -0.29  (spam-এ 2, ham-এ 3 বার)
  রসিদের      অচেনা, বাদ
  ছবি         অচেনা, বাদ
  পাঠালাম     অচেনা, বাদ
  মোট log-odds +0.48 → spam

শব্দের তালিকা মানুষের অন্তর্দৃষ্টির সঙ্গে বেশ মেলে: spam-এর দিকে “otp”, “ফ্রি”, “এখনই”, “করুন”, “জিতেছেন”; ham-এর দিকে “বাজারঘর”, “রাইডার”, “ডেলিভারি”. লক্ষ করার মতো “কোড”: বাজারঘরের লগইন-কোডের বার্তায় তিনবার আছে বলে ham-এর দিকে। আবার “করুন” spam-এর দিকে, অথচ এটি একটি সাধারণ ভদ্র ক্রিয়া; model অর্থ বোঝে না, শুধু গোনে। ছোট ডেটায় এমন কাকতালীয় ওজন সহজেই আসে, আর তা overfitting-এর একটি রূপ।

দুটি ভুলই আসলে এক-দুটি শব্দের কারসাজি। প্রথম বার্তায় Banglish particle “e” দুবার; training-এ “e” শুধু spam-এ এসেছে (“link e click”, “ekhon e”), তাই প্রতিবার +১.০৯. দুটি মিলে prior আর বাকি দুই শব্দের ham-এর টান ছাপিয়ে যায়: মোট +০.৪৭. দ্বিতীয়টিতে ১০টির মধ্যে ৮টি শব্দই অচেনা (training-এ নেই), সিদ্ধান্ত নিয়েছে শুধু “ফি” (spam-এর ভুয়া পার্সেল-ফি থেকে শেখা, +১.০৯) আর “হয়েছে”. অর্থাৎ model খুব কম প্রমাণে দৃঢ় সিদ্ধান্ত দিয়েছে। প্রতিকার হলো আরও আর বৈচিত্র্যময় ডেটা: বেশি Banglish ham, বেশি ব্যক্তিগত বার্তা যেখানে “ফি” আছে।

দুই ভুলের দাম এক নয়

একটি false positive মানে আসল বার্তাকে spam বলা: “অর্ডার #4512 পাঠানো হয়েছে” বা লগইন কোড আড়ালে গেলে গ্রাহক রাইডারের ফোন ধরবেন না বা অ্যাপে ঢুকতে পারবেন না। একটি false negative মানে spam-কে ham বলা: একটি প্রতারণার SMS সতর্কবার্তা ছাড়াই পৌঁছায়, আর অসতর্ক গ্রাহক OTP দিয়ে টাকা হারাতে পারেন। কোনটি বেশি ক্ষতিকর, তা ব্যবসা ও নিরাপত্তা দলকে মিলে ঠিক করতে হয়। একটি সাধারণ কৌশল: spam ঘোষণার শর্ত কড়া করা, যেমন log-odds > ০-এর বদলে log-odds > ১. ডেস্কটপে একই ১৫টি test SMS-এ এটি চালিয়ে দেখা গেছে (সত্যিকারের run): সীমা ০-তে FP ৩, FN ০; সীমা ১-এ FP ১, FN ০; সীমা ৩-এ FP ০, FN ১. সীমা সরালে এক ভুল কমে, অন্যটি বাড়ে। এই হিসাবের পূর্ণ ভাষা (precision, recall, ROC) পর্ব ৬০ (মূল্যায়ন — accuracy, precision, recall, F1 আর ROC)-এর বিষয়.

Spam মুছে ফেলবেন না, আলাদা করুন: একটি filter যত ভালোই হোক, কিছু আসল বার্তা ভুল শ্রেণিতে যাবে। তাই সন্দেহজনক বার্তা “সতর্কবার্তাসহ দেখানো” বা আলাদা folder-এ রাখা নিরাপদ, আর গ্রাহককে “এটি spam নয়” বলার সুযোগ দেওয়া উচিত; সেই মতামতই পরের training-এর সবচেয়ে দামি ডেটা।

৯ · Adversarial spam, Naive Bayes-এর সীমা আর একটু ইতিহাস

Spam ছাঁকা সাধারণ classification নয়, কারণ অপর পক্ষ চুপচাপ বসে থাকে না। প্রতারক জানেন filter শব্দ গোনে, তাই তিনি শব্দ বদলান। এমন ইচ্ছাকৃতভাবে বদলানো input-কে বলে adversarial example. তিনটি পরিচিত কৌশল আমাদের ৬০টি বার্তায় পুরো train করা দুটি model-এ পরীক্ষা করা হয়েছে: অক্ষরের মাঝে ফাঁকা জায়গা, শব্দের ভেতরে চোখে অদৃশ্য zero-width character (এখানে U+200C, zero-width non-joiner) ঢোকানো, আর বার্তার শেষে বাজারঘরের নিরীহ শব্দ জুড়ে দেওয়া। শেষটিকে গবেষণায় “good word attack” বলা হয়; Daniel Lowd আর Christopher Meek ২০০৫ সালে statistical spam filter-এর ওপর এর প্রভাব নিয়ে লিখেছিলেন।

for m in (word_nb, char_nb):
    m.fit(df["text"], df["label"])          # এখানে পুরো ৬০টি দিয়ে train

ZW = chr(0x200C)                            # চোখে অদৃশ্য zero-width non-joiner
tests = {
    "original": "অভিনন্দন! আপনি ফ্রি পুরস্কার জিতেছেন। এখনই লিংকে ক্লিক করুন",
    "spaced":   "অ ভি ন ন্দ ন! আপনি ফ ্রি পু র স্কা র জিতেছেন। এখনই লি ং কে ক্লি ক করুন",
    "zw-char":  f"অভি{ZW}নন্দন! আপনি ফ্{ZW}রি পুর{ZW}স্কার জিতে{ZW}ছেন। এখ{ZW}নই …",
    "padded":   "অভিনন্দন! … ক্লিক করুন বাজারঘর অর্ডার ডেলিভারি রাইডার ধন্যবাদ বাজারঘর রাইডার",
}
for name, text in tests.items():
    pw = word_nb.predict_proba([text])[0, spam_col]
    pc = char_nb.predict_proba([text])[0, spam_col]
    print(f"{name:<9} P(spam): words {pw:.3f} | char_wb {pc:.3f}")

সত্যিকারের run (ডেস্কটপ, Python 3.14.4, scikit-learn 1.8.0; word_nb আর char_nb হলো অংশ ৭-এর দুই pipeline, কোডে “…” অংশে পুরো বাক্য):

~/bazarghar-ai$ python adversarial.py
original  P(spam): words 1.000 | char_wb 1.000
spaced    P(spam): words 0.998 | char_wb 1.000
zw-char   P(spam): words 0.742 | char_wb 1.000
padded    P(spam): words 0.817 | char_wb 0.000

ফাঁকা দেওয়ায় শব্দ-model টিকে গেছে, কারণ “আপনি”, “জিতেছেন”, “এখনই”, “করুন” অক্ষত ছিল; প্রতারক সব শব্দ ভাঙলে এটি ব্যর্থ হতো। অদৃশ্য অক্ষরে শব্দ-model-এর নিশ্চয়তা ০.৭৪২-এ নেমেছে: “ফ্রি” আর “পুরস্কার” এখন অচেনা token, অথচ মানুষের চোখে লেখা হুবহু একই। char n-gram দুই ক্ষেত্রেই অটল, কারণ ভাঙা শব্দের ভেতরেও অনেক চেনা টুকরো থাকে। কিন্তু ভালো শব্দ জুড়ে দিতেই char n-gram ভেঙে পড়েছে (০.০০০)! প্রতিটি জুড়ে দেওয়া শব্দ বহু ham-ঘেঁষা টুকরো আনে, আর naive অনুমানে প্রতিটি টুকরো আলাদা প্রমাণ, তাই ham-এর পক্ষে প্রমাণ ফুলে ওঠে। শব্দ-model-ও ১.০০০ থেকে ০.৮১৭-এ নেমেছে। predict_proba-র ১.০০০ আর ০.০০০ নিজেও সতর্কবার্তা: Naive Bayes-এর সম্ভাবনা সাধারণত calibrated নয়।

প্রতিরক্ষার সহজ ধাপ: clean()-এ zero-width অক্ষর মুছে ফেলা (অনুশীলন ২), শব্দ আর char feature মিলিয়ে ব্যবহার, লিংকের domain-এর মতো লেখার বাইরের সংকেত যোগ, আর নিয়মিত নতুন spam দিয়ে আবার train. তবু মৌলিক সীমা থাকে: Naive Bayes শব্দের ক্রম আর অর্থ দেখে না (“OTP কাউকে বলবেন না” আর “OTP বলুন” তার কাছে প্রায় এক), আর ছোট ডেটায় কাকতালীয় শব্দে ভোলে। ফোনে ঢোকা লিংকে কী করবেন না, সেই মানবিক দিক দেখুন Cybersecurity কোর্সের phishing প্রতিরক্ষা পর্বে।

শব্দ গুনে Bayes-এর নিয়মে লেখা শ্রেণিবিভাগের ধারণা অন্তত ১৯৬০-এর দশকের; M. E. Maron-এর ১৯৬১ সালের লেখা নথির স্বয়ংক্রিয় শ্রেণিবিভাগের গবেষণাকে প্রায়ই প্রথম দিকের উদাহরণ ধরা হয়। ইমেইল spam-এ এর পরিচিত প্রয়োগ ১৯৯৮ সালে Mehran Sahami, Susan Dumais, David Heckerman আর Eric Horvitz-এর “A Bayesian approach to filtering junk e-mail” (AAAI-এর একটি workshop-এ)। ২০০২ সালের আগস্টে Paul Graham-এর প্রবন্ধ “A Plan for Spam” এই পথকে ব্যাপক জনপ্রিয় করে; তাঁর পদ্ধতিটি অবশ্য পাঠ্যবইয়ের Naive Bayes হুবহু ছিল না (প্রতি বার্তার সবচেয়ে “আকর্ষণীয়” ১৫টি শব্দের সম্ভাবনা মিলিয়ে), আর তাতে প্রতিটি ব্যবহারকারীর নিজের ইমেইলে train করার ওপর জোর ছিল। আজকের বড় ফিল্টার সাধারণত অনেক রকম সংকেত আর model একসঙ্গে ব্যবহার করে।

১০ · AI-এর যুগে — AI assistant-এর লেখা text classifier যাচাই

“একটা spam classifier লিখে দাও” বললে AI assistant কয়েক সেকেন্ডে চলনসই কোড দেয়। কিন্তু বাংলা লেখায় তার কয়েকটি ভুল খুব নিয়মিত, আর সেগুলো চোখে পড়ে না, কারণ কোড চলে এবং একটি সংখ্যাও ছাপে।

AI যা প্রায়ই করেকীভাবে ধরবেন
বাংলায় default CountVectorizer() রেখে দেয়, কার-চিহ্ন হারিয়ে যায়get_feature_names_out()-এর প্রথম ২০টি শব্দ চোখে পড়ুন
পুরো ডেটায় vectorizer fit করে, তারপর ভাগ করে (vocabulary-তে leakage)আগে ভাগ, তারপর শুধু train-এ fit; বা Pipeline
Training set-এর accuracy রিপোর্ট করেআলাদা test বা cross-validation-এর ফল চান, সঙ্গে confusion matrix
পুরোনো API দেয়, যেমন get_feature_names() (scikit-learn 1.2-এ সরানো হয়েছে)নিজের version-এর অফিসিয়াল docs মিলিয়ে দেখুন
শব্দের গোনায় GaussianNB চালায়, বা দুই শ্রেণির অসম ভাগ (class imbalance) উপেক্ষা করে শুধু accuracy বলেসবচেয়ে বেশি শ্রেণি বলার baseline-এর সঙ্গে তুলনা করুন
অনেক সম্ভাবনা সরাসরি গুণ করে, বা predict_proba-র ০.৯৯৯-কে সত্যিকারের নিশ্চয়তা বলেlog-space; calibration যাচাই; লম্বা বার্তায় পরীক্ষা

নির্দিষ্ট করে চান, আর পরীক্ষাযোগ্য আউটপুট চান। আসল গ্রাহকের SMS কখনো পেস্ট করবেন না; কয়েকটি কাল্পনিক উদাহরণই যথেষ্ট।

আমার একটি CSV আছে sms.csv: কলাম label (spam/ham) আর text (বাংলা, ইংরেজি আর Banglish মেশানো SMS). Python 3.14, pandas 3.0 আর scikit-learn 1.8 দিয়ে কোড লেখো যা: ১. নিজের tokenize() function ব্যবহার করবে: Unicode NFC, lower(), বাংলা অঙ্ক থেকে ইংরেজি অঙ্ক, zero-width অক্ষর মুছে ফেলা, তারপর ফাঁকা জায়গা আর যতিচিহ্নে ভাঙা; default token_pattern ব্যবহার করবে না, আর কেন, তা comment-এ লিখবে। ২. আগে train_test_split(stratify, random_state=42) দিয়ে ভাগ করবে, তারপর শুধু train-এ CountVectorizer(analyzer=tokenize) আর MultinomialNB একটি Pipeline-এ fit করবে। ৩. test-এর accuracy, confusion matrix আর সবচেয়ে বেশি শ্রেণি বলার baseline ছাপাবে; সঙ্গে StratifiedKFold দিয়ে ৫-fold cross-validation-এর গড় আর পরিসর। ৪. প্রতিটি শ্রেণির সবচেয়ে তথ্যবহ ১০টি শব্দ feature_log_prob_ থেকে দেখাবে। ৫. কোনো deprecated API ব্যবহার করবে না, আর কোথাও বলবে না যে model লেখার অর্থ বোঝে।

AI-এর লেখা classifier হাতে পেলে — যাচাইয়ের চেকলিস্ট

আরও গভীরে: Andrew Ng আর Michael Jordan ২০০১ সালে logistic regression (discriminative) আর Naive Bayes (generative)-এর তুলনা করে যুক্তি দেন যে NB সাধারণত অল্প ডেটায় দ্রুত নিজের সেরা অবস্থায় পৌঁছায়, আর বেশি ডেটায় logistic regression প্রায়ই কম ভুল করে। Jason Rennie ও সহকর্মীরা ২০০৩ সালে text-এ NB-এর কিছু দুর্বলতা (অসম শ্রেণি, শব্দের বারবার আসা) সারাতে Complement NB প্রস্তাব করেন, যা scikit-learn-এ ComplementNB. আর সম্ভাবনাগুলো ব্যবহারযোগ্য করতে CalibratedClassifierCV দিয়ে calibration করা যায়।

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

  • Naive Bayes-এর “naive” অনুমান কী? শ্রেণি জানা থাকলে feature-গুলো (শব্দ) পরস্পর স্বাধীন: P(w1, …, wn | c) = ∏ P(wi | c).
  • Laplace smoothing কেন? Training-এ না দেখা শব্দের সম্ভাবনা শূন্য হলে পুরো গুণফল শূন্য হয়; (count + 1)/(Nc + V) তা ঠেকায়।
  • spam-এ ৪০টি token, V = ৬০, একটি শব্দ spam-এ ৩ বার: P(w | spam)? (৩ + ১)/(৪০ + ৬০) = ০.০৪.
  • Log কেন? অনেক ছোট সম্ভাবনার গুণফল underflow করে 0.0 হয়; log-এর যোগফল স্থিতিশীল আর সিদ্ধান্ত একই।
  • Multinomial আর Bernoulli-র পার্থক্য? প্রথমটি শব্দের গোনা ব্যবহার করে আর অনুপস্থিতি উপেক্ষা করে; দ্বিতীয়টি শুধু আছে/নেই দেখে আর অনুপস্থিত শব্দকেও প্রমাণ ধরে।

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

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

প্র ০১যে অনুমান প্রায় কখনো সত্য নয়, তার ওপর দাঁড়ানো একটি model কীভাবে এত দশক ধরে কাজের থাকল? “ভুল model, ঠিক সিদ্ধান্ত” কথাটির মানে কী?

শব্দগুলো যে শ্রেণি জানা থাকলেও স্বাধীন নয়, তা যেকোনো SMS পড়লেই বোঝা যায়: “লিংকে” এলে “ক্লিক” প্রায় নিশ্চিত, “অভিনন্দন” এলে “জিতেছেন”. তবু Naive Bayes বহু বছর ধরে লেখা শ্রেণিবিভাগের একটি শক্ত baseline. এর ব্যাখ্যায় সবচেয়ে উদ্ধৃত কাজগুলোর একটি Pedro Domingos আর Michael Pazzani-র, ১৯৯৭ সালে Machine Learning জার্নালে প্রকাশিত। তাঁদের মূল কথা: শ্রেণি বাছাইয়ের জন্য সঠিক সম্ভাবনা লাগে না, লাগে সঠিক ক্রম। যদি আসল posterior spam-এর ০.৭ হয় আর NB বলে ০.৯৯৯, সিদ্ধান্ত একই। তাঁরা দেখান যে zero-one loss-এ অনুমান ভাঙলেও অনেক পরিস্থিতিতে NB সর্বোত্তম সিদ্ধান্ত দিতে পারে। ২০০১ সালে David Hand আর Keming Yu “Idiot's Bayes — not so stupid after all?” শিরোনামে একই প্রশ্নের পর্যালোচনা করেন।

আরেকটি কারণ bias আর variance-এর বিনিময়: পূর্ণ যৌথ বণ্টন শিখতে বিপুল ডেটা লাগে, NB প্রতি শব্দে মাত্র দুটি সংখ্যা শেখে। Model ভুল হলেও স্থির, তাই অল্প ডেটায় জটিল model-এর চেয়ে প্রায়ই কম ভুল করে।

দাম দিতে হয় সম্ভাবনায়। লম্বা বার্তায় log-odds প্রায় ২১৫, adversarial পরীক্ষায় predict_proba ১.০০০ বা ০.০০০; এগুলো দুবার গোনা প্রমাণের ফল, বাস্তব আত্মবিশ্বাস নয়। তাই threshold ঠিক করার আগে calibration লাগে।

মূল উপলব্ধি: একটি model “সত্য” না হয়েও “কাজের” হতে পারে, যদি তার ভুলগুলো সিদ্ধান্তের ক্রম না বদলায়; কিন্তু সেই model-এর দেওয়া সম্ভাবনাকে সংখ্যা হিসেবে বিশ্বাস করার আগে আলাদাভাবে যাচাই করতে হয়।

প্র ০২Spam filter আর spammer-এর লড়াইকে “অস্ত্র প্রতিযোগিতা” বলা হয় কেন? সাধারণ classification-এর সঙ্গে এর মৌলিক পার্থক্য কোথায়?

সাধারণ classification-এ আমরা ধরে নিই ভবিষ্যতের ডেটা অতীতের মতোই আসবে: আম আর কলার ছবির বণ্টন আমাদের model দেখে বদলায় না। Spam-এ এই অনুমান ভেঙে যায়, কারণ ডেটার একটি অংশ এমন মানুষের লেখা, যাঁরা ঠিক model-কে ফাঁকি দিতে চান। ১৯৯৮ সালে Sahami ও সহকর্মীদের ইমেইলে Bayesian filter-এর কাজ আর ২০০২ সালে Paul Graham-এর “A Plan for Spam” প্রবন্ধের পর শব্দ-গোনার filter ব্যাপকভাবে ছড়ায়। প্রায় সঙ্গে সঙ্গেই spammer-রা প্রতিক্রিয়া দেখান: শব্দে বিন্দু বা ফাঁকা দেওয়া, বানান বদলানো, লেখার বদলে ছবিতে বার্তা, আর বার্তার শেষে সাধারণ ভালো শব্দ বা কবিতার লাইন জুড়ে দেওয়া। Daniel Lowd আর Christopher Meek ২০০৫ সালে এই “good word attack” নিয়ে লিখে দেখান, অল্প কিছু ভালো শব্দেই অনেক spam পার পেতে পারে। এই পর্বের ছোট পরীক্ষাতেও একই ছবি: বাজারঘরের কয়েকটি শব্দ জুড়ে দিতেই char n-gram model ১.০০০ থেকে ০.০০০-এ নেমেছে।

এর মানে, একটি spam filter-এর test accuracy একটি মুহূর্তের ছবি। প্রকাশের পর প্রতিপক্ষ শেখে, আর বণ্টন বদলায়। Filter-কে তাই নিয়মিত নতুন ডেটায় train করতে হয়, আর শুধু লেখা নয়, প্রেরকের আচরণ, লিংকের domain আর ব্যবহারকারীর রিপোর্টও মেলাতে হয়; filter-এর খুঁটিনাটি নিয়ম প্রকাশ না করাও নিরাপদ।

মূল উপলব্ধি: যেখানে প্রতিপক্ষ model দেখে নিজের আচরণ বদলায়, সেখানে মূল্যায়ন এককালীন নয়, চলমান; model-এর পাশাপাশি পর্যবেক্ষণ, ব্যবহারকারীর মতামত আর নিয়মিত হালনাগাদই আসল প্রতিরক্ষা।

প্র ০৩Naive Bayes generative, logistic regression discriminative. বাজারঘরের spam filter-এর জন্য কোনটি বাছবেন, আর ডেটার পরিমাণ এই সিদ্ধান্তে কী ভূমিকা রাখে?

Generative model ডেটা কীভাবে তৈরি হয় তার গল্প শেখে: P(c) আর P(শব্দ | c), তারপর Bayes দিয়ে P(c | শব্দ) বের করে। Discriminative model সরাসরি P(c | শব্দ) বা শ্রেণির সীমারেখা শেখে, ডেটার জন্মকাহিনি নিয়ে মাথা ঘামায় না। মজার ব্যাপার হলো, multinomial NB-এর log-odds শব্দ-গোনার একটি রৈখিক function (অংশ ৮), আর logistic regression-ও (পর্ব ৫৯) ঠিক এই রূপের model. পার্থক্য ওজন শেখার উপায়ে: NB প্রতিটি শব্দের ওজন আলাদাভাবে গুনে বের করে, স্বাধীনতা ধরে; logistic regression সব ওজন একসঙ্গে ঠিক করে যাতে training ডেটার log loss কমে, তাই একসঙ্গে আসা শব্দের দুবার গোনা কিছুটা শুধরে নিতে পারে।

২০০১ সালে Andrew Ng আর Michael Jordan এই জোড়ার একটি পরিচিত তাত্ত্বিক ও পরীক্ষামূলক তুলনা প্রকাশ করেন। তাঁদের যুক্তি অনুযায়ী, অনেক ক্ষেত্রে NB অল্প ডেটায় দ্রুত তার সেরা ভুলের হারে পৌঁছায়, আর ডেটা বাড়লে logistic regression প্রায়ই কম ভুলের হারে থামে। অর্থাৎ ছোট ডেটায় NB জিততে পারে, বড় ডেটায় logistic regression. এটি একটি প্রবণতা, নিয়ম নয়; ডেটাভেদে উল্টোও দেখা যায়।

বাজারঘরের জন্য তাই বাস্তব পথ: শুরুতে কয়েকশো label দেওয়া SMS থাকলে NB একটি দ্রুত, ব্যাখ্যা করা যায় এমন প্রথম সংস্করণ; ডেটা বাড়লে logistic regression বা অন্য model-এর সঙ্গে একই cross-validation-এ তুলনা (পর্ব ৬৬)। NB-এর বাড়তি সুবিধা: নতুন SMS এলে শুধু গোনা হালনাগাদ করলেই model হালনাগাদ (scikit-learn-এ partial_fit).

মূল উপলব্ধি: generative বনাম discriminative কোনো ধর্মযুদ্ধ নয়; ডেটা কম হলে generative-এর দৃঢ় অনুমান সাহায্য করে, বেশি হলে discriminative-এর নমনীয়তা; একই সৎ মূল্যায়নে দুটি চালিয়ে বাছাই করুন।

প্র ০৪বাংলা লেখার জন্য ইংরেজিতে বানানো text-এর হাতিয়ার কেন প্রায়ই নীরবে ভুল করে? বাংলা tokenization-এর সামনে মূল চ্যালেঞ্জগুলো কী?

এই পর্বের সবচেয়ে বড় শিক্ষা একটি লাইনে: scikit-learn-এর default CountVectorizer() “আপনার অর্ডার পাঠানো হয়েছে” থেকে বানায় “আপন”, “অর”, “হয”. কোনো error নেই, কোনো warning নেই, আর model তবু ০.৮১৭ accuracy দেয়, তাই ভুলটা ধরা পড়ে না। কারণ নিয়মটি ইংরেজির জন্য ভাবা: “word character”-এর ধারণায় বাংলার কার-চিহ্ন আর হসন্ত combining mark, অক্ষর নয়।

বাংলার নিজস্ব চ্যালেঞ্জগুলো স্তরে স্তরে। এক, encoding আর normalization: য়/ড়/ঢ়-এর দুই রূপ, অদৃশ্য ZWNJ/ZWJ, আর পুরোনো non-Unicode font-এ লেখা ডেটা (পর্ব ১০)। দুই, morphology: বাংলায় বিভক্তি আর প্রত্যয় শব্দের সঙ্গে জোড়া লাগে (টাকা, টাকায়, টাকার, টাকাগুলো), ক্রিয়ার রূপ পুরুষ ও কাল অনুযায়ী বদলায় (জিতেছেন, জিতেছি, জিতুন)। তিন, code-mixing: একই বার্তায় বাংলা, ইংরেজি আর প্রমিত বানানহীন Banglish. চার, লেবেল দেওয়া বাংলা ডেটাসেট তুলনামূলক কম। Stemming বা lemmatization বিভক্তি এক করতে পারে, তবে বাংলার জন্য নির্ভরযোগ্য হাতিয়ার ইংরেজির তুলনায় কম, আর ভুল ছাঁটাই অর্থও মুছে দিতে পারে, যেমন “করুন” আর “করুণ” এক হয়ে যাওয়া।

সমাধানের পথ: character n-gram বানান-বৈচিত্র্য কিছুটা সামলায়; আধুনিক ভাষা model subword tokenizer (যেমন BPE) ব্যবহার করে (পর্ব ৭০); বাংলার stemming আর TF-IDF পর্ব ৬৭-এ। সব পথেই একটি অভ্যাস অপরিবর্তিত: কয়েকটি আসল বাক্যে tokenizer চালিয়ে ফল নিজের চোখে দেখা।

মূল উপলব্ধি: ভাষার হাতিয়ার ভাষা-নিরপেক্ষ নয়; বাংলায় যেকোনো text pipeline-এর প্রথম পরীক্ষা হলো token চোখে দেখা, কারণ সবচেয়ে বিপজ্জনক ভুল হলো যেটি চলে আর একটি ভালো-দেখতে সংখ্যা দেয়।

প্র ০৫বাজারঘর যদি গ্রাহকের ফোনের সব SMS পড়ে spam ছাঁকতে চায়, তাহলে নৈতিক আর ব্যবহারিক দিক থেকে কী কী ভাবতে হবে?

প্রথম প্রশ্ন গোপনীয়তার। SMS-এ থাকে OTP, ব্যাংকের লেনদেন, ব্যক্তিগত কথা। একটি দোকানের অ্যাপ সব SMS পড়লে তা গ্রাহকের প্রত্যাশার অনেক বাইরে, আর ফোনের অনুমতির ব্যবস্থাতেও এমন প্রবেশাধিকার সাধারণত কড়াভাবে নিয়ন্ত্রিত। বিকল্প নকশা: শুধু বাজারঘরের নিজের পাঠানো বার্তা যাচাইযোগ্য করে দেওয়া (যেমন প্রতিটি আসল বার্তায় অ্যাপে মেলানো যায় এমন অর্ডার নম্বর), বা গ্রাহক নিজে কোনো বার্তা পেস্ট করে জিজ্ঞেস করলে তখনই, ফোনের ভেতরে (on-device) classifier চালানো, বার্তা সার্ভারে না পাঠিয়ে। Naive Bayes-এর ছোট গোনার টেবিল ফোনেই চলে।

দ্বিতীয় প্রশ্ন ভুলের দায়। একটি false positive-এ আসল ডেলিভারি বা লগইন বার্তা লুকিয়ে গেলে কে দায়ী? একটি false negative-এ গ্রাহক প্রতারিত হলে? তাই সতর্কবার্তা হওয়া উচিত পরামর্শ, সিদ্ধান্ত নয়: “এই বার্তা সন্দেহজনক, কারণ এতে OTP চাওয়া হয়েছে আর অচেনা লিংক আছে”. NB-এর শব্দ-ওজন এমন ব্যাখ্যা দেওয়া সহজ করে। তৃতীয় প্রশ্ন সংখ্যার সততা: এই পর্বের ৯০% (leave-one-out) ৬০টি বানানো বার্তার, বাস্তবের নয়। প্রকাশের আগে বাস্তবের মতো ডেটায়, সম্মতি নিয়ে সংগ্রহ করা আর বেনামি করা নমুনায়, আলাদা test-এ মাপতে হবে, আর spam-এর বাস্তব ভাগ কম হলে precision কীভাবে নামে তা Bayes দিয়ে হিসাব করতে হবে (পর্ব ৩৫)। চতুর্থত, ন্যায্যতা: Banglish বা আঞ্চলিক বানানে লেখা মানুষের আসল বার্তা যদি বেশি spam হয় (এই পর্বের test set-এ তিনটি ভুলের তিনটিই ব্যক্তিগত বার্তা, তার দুটি Banglish), তাহলে কিছু গ্রাহক নিয়মিত বেশি ক্ষতির শিকার হবেন। প্রতিটি দলের জন্য আলাদা ভুলের হার মাপা দরকার (পর্ব ৭২)।

মূল উপলব্ধি: একটি spam filter-এর নকশা শুধু accuracy-র প্রশ্ন নয়; কোন ডেটা পড়া হচ্ছে, কোথায় চলছে, ভুলের দাম কে দিচ্ছে আর কোন দল বেশি ভুল পাচ্ছে, এসব আগে ঠিক করে তারপর model বাছুন।

অনুশীলন

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

  1. হাতে Naive Bayes (হিসাব): ছয়টি কাল্পনিক SMS-এর একটি corpus: spam: “ফ্রি পুরস্কার জিতুন”, “ফ্রি অফার লিংকে ক্লিক”, “পুরস্কার পেতে ক্লিক”; ham: “অর্ডার পাঠানো হয়েছে”, “অর্ডার আজ ডেলিভারি”, “আজ বাসায় দাওয়াত”. ফাঁকা জায়গায় ভেঙে, Laplace smoothing (α = ১) দিয়ে multinomial Naive Bayes-এ নতুন SMS “ফ্রি ডেলিভারি”-র শ্রেণি আর P(spam | SMS) বের করুন।

    Prior দুটিই ৩/৬ = ০.৫. spam-এ মোট token ১০, ham-এ ৯; আলাদা শব্দ V = ১৪, তাই হর spam-এ ২৪, ham-এ ২৩.

    • spam: “ফ্রি” ২ বার → ৩/২৪; “ডেলিভারি” ০ বার → ১/২৪. Score = ০.৫ × ৩/২৪ × ১/২৪ = ১.৫/৫৭৬ ≈ ০.০০২৬০৪.
    • ham: “ফ্রি” ০ বার → ১/২৩; “ডেলিভারি” ১ বার → ২/২৩. Score = ০.৫ × ১/২৩ × ২/২৩ = ১/৫২৯ ≈ ০.০০১৮৯০.
    • P(spam | SMS) = ০.০০২৬০৪ ÷ (০.০০২৬০৪ + ০.০০১৮৯০) ≈ ০.৫৭৯ → spam.

    মজার তুলনা: “আজ ফ্রি ডেলিভারি অফার” ছিল ham (০.৫৪২), কিন্তু “আজ” আর “অফার” বাদ দিলে spam. “আজ” ham-এর দিকে বেশি টানছিল (ham-এ দুবার)। Log-space-এ: spam −০.৬৯৩ − ২.০৭৯ − ৩.১৭৮ = −৫.৯৫০, ham −০.৬৯৩ − ৩.১৩৫ − ২.৪৪২ = −৬.২৭০; পার্থক্য ০.৩২০ spam-এর দিকে, আর 1/(1 + e−0.320) ≈ ০.৫৭৯, একই উত্তর।

  2. Threshold আর অদৃশ্য অক্ষর (কোড): অংশ ৬-এর nb_scratch.py বদলে দুটি কাজ করুন: (ক) clean()-এ zero-width চিহ্ন (U+200B, U+200C, U+200D, U+FEFF) মুছে ফেলুন, আর যাচাই করুন যে "ফ্" + chr(0x200C) + "রি"-এর token “ফ্রি”-র সমান; (খ) spam ঘোষণার শর্ত log-odds > t রাখুন, আর t = ০, ১, ৩-এ test set-এর FP আর FN গুনুন।
    ZW = dict.fromkeys([0x200B, 0x200C, 0x200D, 0xFEFF])          # zero-width চিহ্ন
    def clean(t): return unicodedata.normalize("NFC", t).translate(ZW).lower().translate(BN2EN)
    
    def log_odds(text):
        toks = [w for w in tokenize(text) if w in vocab]
        return logprior["spam"] - logprior["ham"] + sum(
            loglik(w, "spam") - loglik(w, "ham") for w in toks)
    
    for t in (0.0, 1.0, 3.0):
        pred = ["spam" if log_odds(x) > t else "ham" for x in te["text"]]
        fp = sum(p == "spam" and y == "ham" for p, y in zip(pred, te["label"]))
        fn = sum(p == "ham" and y == "spam" for p, y in zip(pred, te["label"]))
        print(f"threshold {t}: FP {fp}, FN {fn}")
    zw = "ফ্" + chr(0x200C) + "রি"
    print(tokenize(zw), tokenize(zw) == tokenize("ফ্রি"))

    সত্যিকারের run (ডেস্কটপ, Python 3.14.4, scikit-learn 1.8.0; একই seed-এর ভাগ):

    threshold 0.0: FP 3, FN 0
    threshold 1.0: FP 1, FN 0
    threshold 3.0: FP 0, FN 1
    ['ফ্রি'] True

    str.translate-এ একটি dictionary দিলে যে code point-এর মান None, তা মুছে যায়; dict.fromkeys ঠিক তাই বানায়। Threshold ১-এ তিনটি ভুল-spam-এর দুটি (log-odds +০.৪৭ আর +০.৪৮) ফিরে ham হয়, কোনো spam হারায় না; ৩-এ সব আসল বার্তা বাঁচে, কিন্তু একটি spam (ব্যাংকের নামে পিন চাওয়া বার্তা, log-odds প্রায় +২.৪৬) ছাড়া পায়। মাত্র ১৫টি বার্তায় বেছে নেওয়া threshold নিজেই overfit হতে পারে; সঠিক পথে threshold বাছা হয় আলাদা validation ডেটায়। খেয়াল করুন, “ফ্রি” মোছার পরেও ঠিক থাকে, কারণ ZWNJ হসন্তের পরে বসেছিল, কোনো অক্ষর বদলায়নি; তবে কিছু বাংলা বানানে ZWNJ বা ZWJ-এর দৃশ্যমান কাজ আছে (যুক্তাক্ষর ভেঙে দেখানো), তাই এগুলো মুছে ফেলা সব কাজে নিরাপদ নয়, শুধু শব্দ মেলানোর কাজে।

  3. সমালোচনা ও নতুন নকশা: একজন সহকর্মী লিখেছেন: “আমি ৬০০০টি SMS নিয়ে পুরো ডেটায় CountVectorizer() fit করেছি, তারপর ভাগ করে MultinomialNB train করেছি। Training set-এ accuracy ৯৯.৮%, তাই এটি সরাসরি চালু করে spam বলা সব SMS গ্রাহকের ফোন থেকে মুছে দেওয়া হবে।” কী কী ভুল, আর আপনি কীভাবে নতুন করে সাজাবেন?
    • বাংলায় default token_pattern: কার-চিহ্নসহ শব্দ ভেঙে বা হারিয়ে যায়; নিজের tokenizer বা char n-gram লাগবে, আর token চোখে দেখতে হবে।
    • ভাগের আগে fit: test-এর শব্দ vocabulary-তে ঢুকেছে; এটি data leakage. ভাগ আগে, তারপর Pipeline-এ শুধু train-এ fit.
    • Training accuracy: model যা দেখেছে, তাতেই মাপা; নতুন SMS-এ কেমন করবে, তা বলে না। আলাদা test বা cross-validation, confusion matrix আর baseline দরকার।
    • মুছে ফেলা: false positive-এ আসল অর্ডার বা OTP বার্তা চিরতরে হারাবে। আলাদা করে রাখা আর সতর্কবার্তা দেখানো নিরাপদ।

    নতুন নকশা: stratified ভাগ বা cross-validation; FP আর FN আলাদা করে রিপোর্ট, সঙ্গে Banglish আর বাংলা বার্তার আলাদা ভুলের হার; ব্যবসার সঙ্গে মিলে threshold ঠিক করা (FP কম রাখতে কড়া); প্রকাশের পর গ্রাহকের “এটি spam নয়” মতামত সংগ্রহ আর নিয়মিত আবার train; আর SMS-এর গোপনীয়তা রক্ষায় সম্ভব হলে ফোনের ভেতরেই classifier চালানো।

  4. আউটপুট অনুমান: নিচের কোড কী ছাপবে? শেষ লাইনের ফল কেন অদ্ভুত?
    import math, re
    from collections import Counter
    
    spam = Counter("ফ্রি ফ্রি অফার".split())
    ham = Counter("অর্ডার অফার".split())
    V = 3
    p = lambda w, c: (c[w] + 1) / (c.total() + V)
    print(p("ফ্রি", spam), p("ফ্রি", ham))
    print(round(math.log(p("অফার", spam)) - math.log(p("অফার", ham)), 3))
    print(re.findall(r"\w+", "টাকা ফ্রি"))

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

    0.5 0.2
    -0.182
    ['ট', 'ক', 'ফ', 'র']

    প্রথম লাইন: spam-এ “ফ্রি” ২ বার, মোট ৩টি token: (২ + ১)/(৩ + ৩) = ০.৫; ham-এ ০ বার, মোট ২টি: (০ + ১)/(২ + ৩) = ০.২. দ্বিতীয় লাইন: “অফার” দুই শ্রেণিতেই একবার, কিন্তু হর আলাদা: ln(২/৬) − ln(২/৫) = ln(৫/৬) ≈ −০.১৮২. একই গোনা, তবু ham-এর দিকে সামান্য টান, কারণ spam-এ মোট token বেশি। তৃতীয় লাইন: Python-এর \w কার-চিহ্ন (া) আর হসন্ত (্) মেলায় না, তাই “টাকা” ভেঙে “ট” আর “ক”, “ফ্রি” ভেঙে “ফ” আর “র”; ি-কারও বাদ। scikit-learn-এর default token_pattern এর ওপরেই দাঁড়ানো, আর তা এক অক্ষরের টুকরোও ফেলে দেয়।

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

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

ক্রমবাংলা SMS-এর জন্য একটি Naive Bayes spam filter বানানো আর যাচাইয়ের ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. লেখা পরিষ্কার: NFC, lowercase আর বাংলা অঙ্ক থেকে ইংরেজি অঙ্ক, তারপর token-এ ভাঙা
  2. Seed আর stratify দিয়ে ডেটা train আর test-এ ভাগ করা
  3. শুধু train-এর বার্তায় প্রতিটি শ্রেণির শব্দ গোনা আর vocabulary বানানো
  4. Log prior আর smoothing দেওয়া log likelihood হিসাব করা
  5. প্রতিটি test SMS-এর জন্য দুই শ্রেণির log score যোগ করে বড়টি বাছা
  6. Confusion matrix দেখে FP আর FN আলাদা করে মূল্যায়ন করা

একটি multinomial Naive Bayes-এ spam শ্রেণিতে মোট ৪০টি token, আর vocabulary-তে ৬০টি আলাদা শব্দ। “বোনাস” শব্দটি spam-এ একবারও আসেনি। Laplace (add-one) smoothing-এ P(বোনাস | spam) কত?

  • ০
  • ০.০১
  • ১/৪০
  • ১/৬০

Naive Bayes-এর “naive” অনুমানটি কোনটি?

  • সব শব্দ শ্রেণি নির্বিশেষে পরস্পর স্বাধীন
  • সব শ্রেণির prior সমান
  • শ্রেণি জানা থাকলে শব্দগুলো পরস্পর স্বাধীন
  • শব্দের ক্রম সিদ্ধান্তে সবচেয়ে গুরুত্বপূর্ণ

৩০০ শব্দের একটি বার্তায় Naive Bayes-এর score হিসাবে প্রতিটি শব্দের সম্ভাবনা সরাসরি গুণ না করে log-এর যোগফল নেওয়া হয় কেন?

  • গুণফল underflow করে 0.0 হয়ে যায়, আর log নিলে সিদ্ধান্ত বদলায় না
  • log নিলে model-এর accuracy বাড়ে
  • log-এর যোগফল সরাসরি posterior probability দেয়
  • log নিলে smoothing-এর দরকার থাকে না

একটি SMS-এ “ফ্রি” শব্দটি ৩ বার আছে, আর “অর্ডার” একবারও নেই। Multinomial আর Bernoulli Naive Bayes এই তথ্য কীভাবে ব্যবহার করে?

  • দুটিই “ফ্রি”-কে ৩ বার গোনে আর “অর্ডার”-কে উপেক্ষা করে
  • Multinomial শুধু আছে/নেই দেখে, Bernoulli ৩ বার গোনে
  • দুটিই “অর্ডার”-এর অনুপস্থিতিকে প্রমাণ ধরে
  • Multinomial “ফ্রি”-কে ৩ বার গোনে; Bernoulli শুধু “আছে” ধরে আর “অর্ডার”-এর অনুপস্থিতিও হিসাবে নেয়

scikit-learn-এর CountVectorizer()-এর default token_pattern হলো (?u)\b\w\w+\b. বাংলা বাক্য “আপনার অর্ডার” দিলে vocabulary-তে কী আসে?

  • “আপনার” আর “অর্ডার”
  • “অর” আর “আপন”
  • কিছুই না, কারণ বাংলা অক্ষর word character নয়
  • প্রতিটি অক্ষর আলাদা token

একটি দোকানের spam filter-এর test set-এ confusion matrix (spam = positive): TN = 90, FP = 6, FN = 2, TP = 22. দোকান চায় আসল অর্ডারের বার্তা যেন কম হারায়। কোন পদক্ষেপ সবচেয়ে যুক্তিসঙ্গত?

  • শুধু accuracy বাড়ানোর চেষ্টা করা, কারণ সেটিই একমাত্র মাপ
  • spam ঘোষণার threshold কমানো
  • spam ঘোষণার threshold বাড়ানো, আর spam মুছে না ফেলে আলাদা রাখা
  • FN শূন্যে নামানোকে প্রধান লক্ষ্য করা

শব্দকোষ

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

মনে রাখুন

  1. বাংলা tokenization: NFC, lowercase, বাংলা অঙ্ক → 0–9, তারপর ফাঁকা জায়গা আর যতিচিহ্নে ভাঙা। default token_pattern কার-চিহ্ন ফেলে দেয়। বিভক্তি (টাকা, টাকায়, টাকার) সরল ভাঙায় আলাদা থাকে।
  2. সূত্র: P(c | শব্দ) ∝ P(c) · ∏ P(wi | c), শ্রেণি জানা থাকলে শব্দ স্বাধীন ধরে। Training মানে গোনা; সিদ্ধান্ত ঠিক থাকলেও সম্ভাবনা প্রায়ই অতিরিক্ত চরম।
  3. Smoothing: P(w | c) = (count + α)/(Nc + αV); α = ১ হলো Laplace. এটি না থাকলে একটি অদেখা শব্দ পুরো score শূন্য করে দেয়।
  4. Log space: score = log P(c) + ∑ log P(w | c); ৩০৯ token-এ সরাসরি গুণফল 0.0, log-এ −1665.1 বনাম −1879.7. Log-odds-এর সীমা বদলে FP আর FN-এর মধ্যে বিনিময় করা যায়।
  5. সৎ মূল্যায়ন: নিজের NB আর MultinomialNB হুবহু মেলে; Multinomial গোনে, Bernoulli আছে/নেই আর অনুপস্থিতিও দেখে। ১৫টি test SMS-এর ক্রম leave-one-out-এ উল্টে যায়; ছোট কাল্পনিক ডেটার সংখ্যা আশাবাদী।

পরের পর্বে: Naive Bayes ছিল সবচেয়ে সরল সম্ভাবনার জাল: একটি শ্রেণি থেকে সব শব্দে তীর। পর্ব ৫২ (Bayesian network — কারণ, ফল আর সম্ভাবনার জাল)-এ তীরগুলো যেকোনো চলকের মধ্যে আঁকা যাবে: বাজারঘরের দেরির ডেলিভারির পেছনে বৃষ্টি, যানজট আর রাইডারের জাল, conditional probability table আর inference.

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

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