Part 02 · Machine Learning

Project: Spam Classifier from Scratch

Logistic regressionText featuresPrecision & recallThreshold tuning

শুরু থেকে শেষ পর্যন্ত একটা 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 দিয়ে বানানো গুলো সহ, সবগুলো এই কাঠামোই মানে।

যা আগে জানা লাগবে

Architecture

SMS text30 labelled"You won a cash prize, click the link to claim it"texttokenizelowercase wordsyou, won, a, cash, prize, click, the, link, to, claim, itwords0/1 features163 columns1 in each column whose word appears, 0 everywhere elsexw·x + bone weight per wordspammy words (free, claim, click) have big positive weightsscoresigmoidP(spam)the score becomes a probability: 0.900.90thresholdpick by cost0.90 ≥ 0.5: send to the spam foldertraining only learns the weights; the threshold is a product decision
একটা message শব্দে ভাঙে, 0 আর 1 এর একটা row হয় (প্রতিটা চেনা শব্দের একটা column), w·x + b হিসেবে score হয়, sigmoid চেপে P(spam) বানায়, আর একটা threshold এর সাথে তুলনা হয়। Training শুধু weight শেখে; threshold কোথায় বসবে সেটা product এর সিদ্ধান্ত।
Fileকাজ
data.pylabel দেওয়া 44 টা SMS, শেষ 14 টা test set হিসেবে আলাদা
spam.pytokenizer, feature বানানো, logistic regression, precision/recall
main.pytrain করে, 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 regressionmillisecond এ train, ব্যাখ্যা করা যায়, শক্ত baselinelinear: "free শুধু click এর সাথে থাকলেই spam" শিখতে পারে না; tree বা neural net পারে
L2 জরিমানা 0.01163 টা weight কে 30 টা message মুখস্থ করতে দেয় নাvalidation data তে tune করার মতো আরো একটা hyperparameter
Threshold 0.7test 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 দেখতে।

আরো এগিয়ে নাও

  1. Bigram ("click here", "free time") বাড়তি feature হিসেবে যোগ করো, দেখো "I'm free after 5" এর flag হওয়া বন্ধ হয় কিনা।
  2. Character 3-gram যোগ করো যাতে "FR33 PR1ZE" ও মেলে।
  3. Fixed ভাগের বদলে 5-fold cross-validation নাও, আর precision আর recall জানাও গড় ± ছড়ানো হিসেবে।
  4. বাংলা হরফে আর 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 পাও →