Part 02 · Machine Learning
Project: Spam Classifier from Scratch
শুরু থেকে শেষ পর্যন্ত একটা spam filter বানাও: message কে feature বানাও, নিজের হাতে logistic regression train করো, তারপর আসল message block হওয়ার খরচ দেখে threshold ঠিক করো।
⏱ 4-5 ঘণ্টা · Station 14 / 92
কী বানাবে
একটা phone company চায় scam SMS ("your bKash account is blocked, send your PIN") customer পড়ার আগেই spam folder এ সরিয়ে দিতে। তুমি পুরো filter টা বানাবে, শুধু NumPy দিয়ে: প্রতিটা message কে feature বানাও, হাতে logistic regression train করো, লুকানো message এ evaluate করো, আর প্রতিটা ভুলের খরচ দেখে threshold বাছো।
44 টা message এ accuracy কত আসলো সেটা আসল কথা না। আসল কথা কাজের কাঠামোটা: data → feature → model → evaluation → ভেবেচিন্তে বাছা threshold। পরে যত classifier বানাবে, LLM দিয়ে বানানো গুলো সহ, সবগুলো এই কাঠামোই মানে।
যা আগে জানা লাগবে
- Regression and Classification: sigmoid, cross-entropy, (p − y)·x gradient।
- Model Training and Evaluation: precision, recall আর threshold।
- Overfitting and Regularization: কেন L2 জরিমানা চালু।
- NumPy সহ Python।
Architecture
| File | কাজ |
|---|---|
data.py | label দেওয়া 44 টা SMS, শেষ 14 টা test set হিসেবে আলাদা |
spam.py | tokenizer, feature বানানো, logistic regression, precision/recall |
main.py | train করে, loss আর সবচেয়ে বলার মতো শব্দগুলো print করে, চারটা threshold এ evaluate করে |
test_spam.py | আচরণের test |
ধাপে ধাপে বানাও
Step 1: data। Label দেওয়া message জোগাড় করো আর অন্য কিছু করার আগেই ভাগ করো। এখানে ভাগটা fixed; আসল কাজে random ভাবে (বা সময় ধরে) ভাগ করো, আর দুই ভাগেই দুইটা class রাখো।
"""SMS messages labelled spam (1) or not (0). The last 14 are held out as the test set."""
MESSAGES = [
("Congratulations! You won a free iPhone, click the link to claim now", 1),
("Are we still meeting at 6 near Dhanmondi lake?", 0),
("URGENT: your bKash account is blocked, send your PIN to unlock", 1),
("Can you send me the notes from today's class", 0),
("WIN 50000 taka cash prize! Reply YES to claim your free gift", 1),
("Mom says dinner is ready, come home soon", 0),
("Limited offer: free recharge 500 taka, click here now", 1),
("The meeting moved to 3pm, please update the team", 0),
("You have been selected for a free holiday, call now to claim", 1),
("I'll be late, stuck in traffic at Farmgate", 0),
("Claim your prize now! Free entry to win cash every week", 1),
("Did you finish the assignment? Deadline is tomorrow", 0),
("Your number won our lottery, send bank details to claim", 1),
("Let's get tea after office, my treat", 0),
("FREE data pack! Click the link and win more bonus", 1),
("Please call me when you're free, it's about the flat", 0),
("Exclusive deal just for you, cash back offer expires today, click now", 1),
("Happy birthday! Have a great year ahead", 0),
("Urgent: verify your account now or it will be closed, click link", 1),
("Can you pick up some eggs and bread on the way", 0),
("Congratulations you are our lucky winner, call to claim cash", 1),
("The doctor's appointment is on Sunday at 10", 0),
("Get rich fast! Free investment tips, reply now", 1),
("Thanks for the help yesterday, really appreciate it", 0),
("Win a free trip, text WIN to 2222 now", 1),
("Are you coming to the wedding on Friday?", 0),
("Your parcel is waiting, pay the fee at this link to release it", 1),
("I sent the money for the electricity bill", 0),
("Free gift card for you, claim before midnight", 1),
("Meeting notes are in the shared folder now", 0),
# test set
("You won a cash prize, click the link to claim it", 1),
("See you at the station at 8", 0),
("Free bonus offer: reply YES now", 1),
("Please send the report before the meeting", 0),
("Your account will be blocked, verify now", 1),
("Dinner at my place tonight?", 0),
("Lucky winner! Call now for your free gift", 1),
("Can you call me back, it's urgent about mom", 0),
("Claim your free recharge today", 1),
("The class is cancelled tomorrow", 0),
("Click here for a free holiday", 1),
("I'm free after 5, want to meet?", 0),
("Send me the link to the class notes", 0),
("Hurry, limited cash offer ends tonight", 1),
]
TRAIN, TEST = MESSAGES[:30], MESSAGES[30:]
Step 2: feature। Vocabulary বানাও শুধু training message থেকে, তারপর প্রতিটা শব্দ আছে (1) নাকি নেই (0) চিহ্ন দাও। 10 শব্দের message এ কতবার এলো গোনায় তেমন লাভ নেই। যে শব্দ শুধু test message এ আছে সেগুলো বাদ পড়ে, ঠিক production এর মতো, যেখানে নতুন শব্দ সবসময় আসে।
Step 3: model। Logistic regression: p = sigmoid(X·w + b), cross-entropy তে gradient descent দিয়ে train, সাথে ছোট একটা L2 জরিমানা (0.01), কারণ নইলে 30 টা message থেকে 163 টা weight সেগুলো মুখস্থ করে ফেলবে।
Step 4: evaluation। 14 টা test message এর score বের করো আর কয়েকটা threshold এ precision আর recall হিসাব করো।
"""Spam filter: words -> binary features -> logistic regression trained by hand with NumPy."""
import re
import numpy as np
def tokenize(text):
return re.findall(r"[a-z0-9]+", text.lower())
class SpamFilter:
def fit(self, messages, labels, lr=0.5, steps=2000, l2=0.01):
words = sorted({w for m in messages for w in tokenize(m)})
self.vocab = {w: i for i, w in enumerate(words)}
X = self.features(messages)
y = np.array(labels, dtype=float)
self.w = np.zeros(X.shape[1])
self.b = 0.0
self.losses = []
for step in range(steps):
p = 1 / (1 + np.exp(-(X @ self.w + self.b)))
err = p - y # cross-entropy gradient through the sigmoid
self.w -= lr * (X.T @ err / len(y) + l2 * self.w)
self.b -= lr * err.mean()
if step in (0, 99, 499, 1999):
eps = 1e-9
loss = -np.mean(y * np.log(p + eps) + (1 - y) * np.log(1 - p + eps))
self.losses.append((step + 1, float(loss)))
return self
def features(self, messages):
X = np.zeros((len(messages), len(self.vocab)))
for r, m in enumerate(messages):
for w in tokenize(m):
if w in self.vocab: # words never seen in training are ignored
X[r, self.vocab[w]] = 1 # present or not; counts add little for SMS
return X
def score(self, messages):
return 1 / (1 + np.exp(-(self.features(messages) @ self.w + self.b)))
def precision_recall(scores, labels, threshold):
flagged = scores >= threshold
y = np.array(labels) == 1
tp = int((flagged & y).sum())
fp = int((flagged & ~y).sum())
fn = int((~flagged & y).sum())
return tp, fp, fn, tp / max(tp + fp, 1), tp / max(tp + fn, 1)
from data import TEST, TRAIN
from spam import SpamFilter, precision_recall
texts, labels = zip(*TRAIN)
model = SpamFilter().fit(list(texts), list(labels))
print(f"vocabulary: {len(model.vocab)} words, {len(TRAIN)} training messages")
for step, loss in model.losses:
print(f"step {step:>4} loss {loss:.3f}")
words = sorted(model.vocab, key=lambda w: model.w[model.vocab[w]])
print("most spammy words:", ", ".join(words[-6:][::-1]))
print("most normal words:", ", ".join(words[:6]))
test_texts, test_labels = zip(*TEST)
scores = model.score(list(test_texts))
print("\nthreshold TP FP FN precision recall")
for t in (0.3, 0.5, 0.7, 0.9):
tp, fp, fn, p, r = precision_recall(scores, test_labels, t)
print(f"{t:>9} {tp:>2} {fp:>2} {fn:>2} {p:>9.2f} {r:>6.2f}")
print("\nscores on the test set (threshold 0.5):")
for (text, y), s in sorted(zip(TEST, scores), key=lambda x: -x[1]):
mark = "spam" if s >= 0.5 else "ok "
wrong = " <- wrong" if (s >= 0.5) != bool(y) else ""
print(f" {s:.2f} {mark} {text}{wrong}")
Output:
vocabulary: 163 words, 30 training messages
step 1 loss 0.693
step 100 loss 0.070
step 500 loss 0.055
step 2000 loss 0.055
most spammy words: free, now, to, claim, your, click
most normal words: the, s, meeting, on, please, i
threshold TP FP FN precision recall
0.3 7 1 0 0.88 1.00
0.5 6 1 1 0.86 0.86
0.7 6 0 1 1.00 0.86
0.9 2 0 5 1.00 0.29
scores on the test set (threshold 0.5):
0.96 spam Lucky winner! Call now for your free gift
0.90 spam You won a cash prize, click the link to claim it
0.89 spam Claim your free recharge today
0.87 spam Free bonus offer: reply YES now
0.80 spam Your account will be blocked, verify now
0.80 spam Click here for a free holiday
0.60 spam I'm free after 5, want to meet? <- wrong
0.48 ok Hurry, limited cash offer ends tonight <- wrong
0.29 ok Send me the link to the class notes
0.16 ok Dinner at my place tonight?
0.13 ok Can you call me back, it's urgent about mom
0.10 ok See you at the station at 8
0.09 ok The class is cancelled tomorrow
0.08 ok Please send the report before the meeting
কী দেখলাম:
- Training: loss 0.693 থেকে (টস করার সমান, কারণ সব weight 0 থেকে শুরু) নেমে 0.055 এ থামে। L2 জরিমানা এটাকে 0 তে যেতে দেয় না।
- কী শিখল: সবচেয়ে spam ঘেঁষা শব্দ free, now, to, claim, your, click; সবচেয়ে "সাধারণ" the, meeting, please, i। কেউ এই rule লেখেনি; 30 টা উদাহরণ থেকে এসেছে। "to" আর "your" একটু সন্দেহজনক বাছাই: spam এ এগুলো বেশি আসে ("click to claim your prize") কিন্তু নিজেরা নিরীহ, বোঝা যায় model এর আরো data দরকার।
- Test এর result: threshold 0.5 এ 7 টা spam এর 6 টা ধরা পড়ে (recall 0.86), একটা ভুল alarm (precision 0.86)।
- দুইটা ভুল থেকে শেখার আছে। "I'm free after 5, want to meet?" পায় 0.60, কারণ "free" আর "to" spam এর শব্দ; model "free" (ফাঁকা আছি) আর "free" (বিনা পয়সায়) আলাদা করতে পারে না, কারণ bag of words এ কোনো context নেই। "Hurry, limited cash offer ends tonight" পায় 0.48, কারণ এর বেশিরভাগ শব্দ ("hurry", "ends", "tonight") training এ কখনো আসেনি।
- Threshold বাছা: 0.7 এ কোনো ভুল alarm নেই, তবু 7 এর 6 টা spam ধরা পড়ে। SMS এ মায়ের একটা আসল message লুকিয়ে ফেলা একটা scam ঢুকে পড়ার চেয়ে খারাপ, তাই 0.7 ভালো default। 0.9 এ precision perfect থাকে কিন্তু recall ধসে 0.29 এ নামে।
Test করো
import numpy as np
from data import TEST, TRAIN
from spam import SpamFilter, precision_recall
texts, labels = zip(*TRAIN)
model = SpamFilter().fit(list(texts), list(labels))
def test_loss_goes_down():
losses = [l for _, l in model.losses]
assert losses == sorted(losses, reverse=True)
def test_unknown_and_empty_messages_get_a_valid_score():
s = model.score(["zxqv ploo", ""])
assert np.all((s > 0) & (s < 1)) and s[0] == s[1] # both fall back to the bias alone
def test_lower_threshold_never_lowers_recall():
scores = model.score([t for t, _ in TEST])
recalls = [precision_recall(scores, [y for _, y in TEST], t)[4] for t in (0.9, 0.7, 0.5, 0.3)]
assert recalls == sorted(recalls)
def test_obvious_cases():
s = model.score(["free prize click now to claim", "see you at the meeting"])
assert s[0] > 0.9 and s[1] < 0.2
if __name__ == "__main__":
for name, fn in list(globals().items()):
if name.startswith("test_"):
fn()
print("pass", name)
pass test_loss_goes_down
pass test_unknown_and_empty_messages_get_a_valid_score
pass test_lower_threshold_never_lowers_recall
pass test_obvious_cases
- Loss প্রতিটা checkpoint এ কমে: gradient এর sign ভুল বা learning rate বেশি হলে ধরা পড়ে।
- অজানা আর খালি message 0 আর 1 এর মধ্যে একটা ঠিকঠাক score পায়, দুইটাই একই (শুধু bias)। নতুন শব্দে ভরা message এ crash বা NaN হলে পুরো filter বসে যেত।
- Threshold নামালে recall কখনো কমে না। যেকোনো threshold এর ধর্ম, assert করা সস্তা, আর তুলনার bug (
>বনাম<) ধরে ফেলে। - স্পষ্ট case স্পষ্টই থাকে। এগুলো ছোট্ট regression test: কোনো change এর পরে "free prize click now to claim" যদি সাধারণ দেখায়, কিছু একটা ভেঙেছে।
Trade-offs
| Choice | ভালো দিক | খরচ |
|---|---|---|
| Bag of words, 0/1 | সহজ, দ্রুত, প্রতিটা weight পড়া যায় | context নেই: "free time" আর "free prize" একরকম দেখায়; ভুল বানান ("fr33") ফসকে যায় |
| Logistic regression | millisecond এ train, ব্যাখ্যা করা যায়, শক্ত baseline | linear: "free শুধু click এর সাথে থাকলেই spam" শিখতে পারে না; tree বা neural net পারে |
| L2 জরিমানা 0.01 | 163 টা weight কে 30 টা message মুখস্থ করতে দেয় না | validation data তে tune করার মতো আরো একটা hyperparameter |
| Threshold 0.7 | test set এ কোনো ভুল alarm নেই | বেশি spam ঢুকে পড়ে; মাত্র 14 টা test message, তাই হিসাবটা খুবই মোটা দাগের |
| শুধু training এর vocabulary | সৎ: production এও নতুন শব্দ আসে | আবার train না করা পর্যন্ত নতুন spam শব্দের কোনো weight নেই |
আসল scale এ লাখ লাখ message এ train করতে, "fr33" ধরতে character n-gram যোগ করতে, scammer রা ভাষা বদলায় বলে প্রতি সপ্তাহে retrain করতে, আর প্রতি সপ্তাহে নতুন label দেওয়া sample এ precision আর recall দেখতে।
আরো এগিয়ে নাও
- Bigram ("click here", "free time") বাড়তি feature হিসেবে যোগ করো, দেখো "I'm free after 5" এর flag হওয়া বন্ধ হয় কিনা।
- Character 3-gram যোগ করো যাতে "FR33 PR1ZE" ও মেলে।
- Fixed ভাগের বদলে 5-fold cross-validation নাও, আর precision আর recall জানাও গড় ± ছড়ানো হিসেবে।
- বাংলা হরফে আর Banglish এ 20 টা spam message লেখো, আবার train করো, আর দেখো model কোন শব্দগুলো শেখে। খেয়াল করো tokenizer এর
[a-z0-9]pattern চুপচাপ বাংলা অক্ষর ফেলে দেয়: ঠিক করো।
Station stamp
Chapter শেষ করো
পড়া শেষ হলে Complete চাপো, progress save হবে। XP শুধু Pro তে — Pro থাকলে এই chapter এ +20 XP পেতে।
Pro নিয়ে XP পাও →