Chapter 02 · Section III · 28 min read
A worked example: is this SMS spam?
The first real project of Chapter 2. Take a folder of SMS messages — some spam, some legitimate — train a classifier that can flag new messages with surprising accuracy. Covers text features, training, evaluation, and a small note on Nepali SMS.
You have met three classifiers in this chapter. Now we use them on a real problem — the kind of small AI tool you could build over a weekend and actually deploy on your own phone. The task: given an SMS, decide whether it’s spam. The dataset is small (around 5,500 messages). The features come from the text itself. By the end of this section you will have a working spam classifier and a clear picture of how text becomes machine-learning features.
The dataset
The classic teaching dataset is the SMS Spam Collection — about 5,572 English SMS messages, each labelled ham (not spam) or spam. Roughly 13% are spam. It’s available on Kaggle, the UCI Machine Learning Repository, and dozens of other places; search sms spam collection csv.
Save it as data/raw/sms_spam.csv. The format is one message per row, with two columns: label and message.
label,message
ham,"Hey, are we still on for chiya at 5?"
spam,"FREE entry in our weekly competition to win Rs 50000! Text WIN to 90909 to enter."
ham,"Don't forget the meeting tomorrow at 10am."
For a Nepali-context version, you can also build a small dataset of mixed Nepali/English SMS yourself — collecting maybe 200 spam and 800 ham from your own phone (with consent from anyone you collected from, of course; see Course 02 Chapter 6). The principles are identical; the trained model just learns from your local patterns.
Loading and looking
import pandas as pd
df = pd.read_csv("data/raw/sms_spam.csv", encoding="latin-1")
print(df.shape)
print(df.head())
print()
print(df["label"].value_counts())
print(df["label"].value_counts(normalize=True))
You should see something like:
(5572, 2)
label message
0 ham Go until jurong point, crazy.. Available only ...
1 ham Ok lar... Joking wif u oni...
2 spam Free entry in 2 a wkly comp to win FA Cup fina...
3 ham U dun say so early hor... U c already then say...
4 ham Nah I don't think he goes to usf, he lives aro...
ham 4825
spam 747
Name: label, dtype: int64
13% spam. Mildly imbalanced, but workable. Check with .head(20) and look at a few spam and ham messages. Spam tends to be:
- All caps or mixed caps in weird places.
- Mentions of FREE, WIN, prizes, large sums.
- Numbers to text or call.
These are intuitions. The model will discover them — and more — automatically.
Turning text into features: bag-of-words
Models eat numbers. SMS messages are text. We need a way to turn each message into a numeric vector. The simplest approach — surprisingly effective — is bag-of-words.
For each message, count how often each word appears. Then the message is a vector where each position corresponds to one word and the value is the count of that word in the message. The whole dataset becomes a matrix with one row per message and one column per word in the vocabulary.
scikit-learn does this with CountVectorizer:
from sklearn.feature_extraction.text import CountVectorizer
vectorizer = CountVectorizer()
X_counts = vectorizer.fit_transform(df["message"])
print(X_counts.shape) # (5572, ~8700)
print(vectorizer.get_feature_names_out()[:20])
The matrix is sparse — most messages contain only a handful of words from the 8,700-word vocabulary, so almost every entry is zero. scikit-learn handles this efficiently.
A close cousin is TfidfVectorizer, which weights the counts by how informative each word is across the dataset. Common words like “the” get down-weighted; rare words like “WIN” get up-weighted. For spam detection, TF-IDF usually beats raw counts:
from sklearn.feature_extraction.text import TfidfVectorizer
vectorizer = TfidfVectorizer(
lowercase=True,
stop_words="english",
max_features=5000,
ngram_range=(1, 2), # words and pairs of consecutive words
)
A few of those arguments are worth understanding:
lowercase=True— “FREE” and “free” are treated as the same token.stop_words="english"— common words like “the”, “is”, “and” are removed. They carry little signal.max_features=5000— keep only the 5000 most frequent words. Keeps the matrix manageable.ngram_range=(1, 2)— include both individual words and pairs of consecutive words. “win cash” as a pair is more informative than “win” and “cash” separately.
Training the classifier
The standard pipeline:
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import Pipeline
from sklearn.metrics import classification_report, confusion_matrix
X = df["message"]
y = (df["label"] == "spam").astype(int) # 1 for spam, 0 for ham
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, stratify=y, random_state=42,
)
model = Pipeline([
("tfidf", TfidfVectorizer(
lowercase=True,
stop_words="english",
max_features=5000,
ngram_range=(1, 2),
)),
("classifier", LogisticRegression(max_iter=1000, class_weight="balanced")),
])
model.fit(X_train, y_train)
predictions = model.predict(X_test)
print(classification_report(y_test, predictions, target_names=["ham", "spam"]))
print(confusion_matrix(y_test, predictions))
Output (illustrative — yours will vary slightly):
precision recall f1-score support
ham 0.99 0.99 0.99 965
spam 0.94 0.93 0.93 150
accuracy 0.98 1115
macro avg 0.97 0.96 0.96 1115
weighted avg 0.98 0.98 0.98 1115
[[955 10]
[ 11 139]]
Read the confusion matrix:
- 955 true ham (correctly labelled ham). The big diagonal.
- 139 true spam (correctly labelled spam). The other diagonal.
- 10 ham misclassified as spam. False alarms — annoying but tolerable.
- 11 spam misclassified as ham. Real spam that got through. Less tolerable in some applications.
98% accuracy, 93% recall on spam. For a one-day project, that is a working tool.
Reading what the model learned
The pipeline’s classifier is a logistic regression, so we can read its weights — and they are interpretable as “words that signal spam (high positive weight) and words that signal ham (high negative weight)”:
import numpy as np
vectorizer = model.named_steps["tfidf"]
classifier = model.named_steps["classifier"]
vocab = vectorizer.get_feature_names_out()
weights = classifier.coef_[0]
# Top 10 spam-indicating words/bigrams
top_spam_idx = np.argsort(weights)[-10:][::-1]
print("Top spam indicators:")
for i in top_spam_idx:
print(f" {vocab[i]:30s} {weights[i]:+.2f}")
print()
# Top 10 ham-indicating words/bigrams
top_ham_idx = np.argsort(weights)[:10]
print("Top ham indicators:")
for i in top_ham_idx:
print(f" {vocab[i]:30s} {weights[i]:+.2f}")
You’ll see classics: spam features include txt, free, claim, urgent, win, prize, call now. Ham features include home, meet, tonight, okay, lol. Reading these is one of the most fun ML experiences — the model has learned the language of spam and the language of friends, and it can show you both.
Trying it on your own messages
Save the trained model and use it interactively:
import joblib
joblib.dump(model, "spam_classifier.joblib")
# Later, in another script:
model = joblib.load("spam_classifier.joblib")
while True:
msg = input("Message: ")
if not msg:
break
proba = model.predict_proba([msg])[0]
print(f" ham: {proba[0]:.2f} spam: {proba[1]:.2f}")
Type a few messages. Type your favourite spam SMS from last week. Type a normal message from a friend. Watch the model assign probabilities. This is the moment AI stops being an abstraction.
A note on Nepali SMS
The English SMS Spam Collection works well as a teaching dataset, but real Nepali phones receive mixed Nepali/English/Romanised-Nepali spam — and the trained model may not generalise to those.
Two practical paths:
- Collect your own Nepali SMS dataset. Two hundred labelled examples is enough for a small project; we’ll cover small-data training tricks in Chapter 5.
- Use a multilingual embedding-based approach (in Course 04, when we meet sentence embeddings) — a model that handles English, Nepali, and Romanised Nepali interchangeably.
For now, the English version teaches the technique. Generalising it to Nepali is a Chapter 6 project waiting to happen.
Check your understanding
Quick check
—A teammate tries to train a spam classifier without `class_weight='balanced'` and reports 87% accuracy. They claim it's working. Looking at the confusion matrix, they notice 0 messages were predicted as spam. What's happening, and what's the fix?
Quick check
—A friend trained a spam classifier on English SMS and is deploying it on a Nepali phone where messages mix Nepali, English, and Romanised Nepali. Accuracy collapses. What's the most thoughtful first response, drawing on this section and Course 02?
What comes next
Chapter 2 is done. You’ve classified loans, used three different algorithms, and built a real spam classifier from scratch. Chapter 3 swaps classification for regression — predicting numbers instead of categories. The worked example will be Kathmandu house rents, and along the way we’ll see what linear regression really is, when it’s enough, and when you need to reach for something more flexible.