Introduction
Spam filtering is the perfect first machine-learning project: the data is real, the problem is binary, and the classic algorithm — Naive Bayes — is simple enough to understand completely. This app trains a classifier on the famous SMS Spam Collection (5,574 real messages), reports honest accuracy metrics, and lets you test any message live with a spam probability.
It is the trained-model counterpart to the rule-based sentiment analyzer: VADER looks words up in a lexicon; this model learns which words mean spam from data.
Features
- Real training data — the SMS Spam Collection, downloaded automatically.
- TF-IDF features — words weighted by distinctiveness, not just counts.
- Live testing — type any message, get a spam probability.
- Word-level explanations — see which words pushed the verdict.
- Honest metrics — precision, recall, and confusion matrix on held-out data.
Prerequisites
- Python 3.9+ — from python.org.
- Dependencies:
pip install streamlit scikit-learn pandas numpy
Step 1: Create the Script
Save as spam_detector.py:
import streamlit as st
import pandas as pd
import numpy as np
import re
import zipfile
import urllib.request
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.naive_bayes import MultinomialNB
from sklearn.model_selection import train_test_split
from sklearn.metrics import precision_score, recall_score
st.set_page_config(page_title="Spam Detector", page_icon="🚫")
st.title("🚫 Spam Message Detector")
@st.cache_data
def load_data():
url = "https://archive.ics.uci.edu/static/public/228/sms+spam+collection.zip"
path = "sms_spam.zip"
urllib.request.urlretrieve(url, path)
with zipfile.ZipFile(path) as z:
with z.open("SMSSpamCollection") as f:
df = pd.read_csv(f, sep="\t", names=["label", "message"], encoding="latin-1")
return df
def clean(text):
return re.sub(r"[^a-z0-9 ]", " ", text.lower())
df = load_data()
st.caption(f"Trained on {len(df):,} messages ({(df.label == 'spam').sum()} spam)")
vec = TfidfVectorizer(stop_words="english", ngram_range=(1, 2))
X = vec.fit_transform(df["message"].map(clean))
y = (df["label"] == "spam").astype(int)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42, stratify=y)
model = MultinomialNB()
model.fit(X_train, y_train)
pred = model.predict(X_test)
c1, c2, c3 = st.columns(3)
c1.metric("Precision", f"{precision_score(y_test, pred):.0%}")
c2.metric("Recall", f"{recall_score(y_test, pred):.0%}")
c3.metric("Spam rate", f"{y.mean():.1%}")
st.divider()
st.subheader("Test a message")
msg = st.text_area("Message", height=90, placeholder="CONGRATULATIONS! You've won a free prize...")
if st.button("🔎 Analyze", type="primary") and msg.strip():
prob = model.predict_proba(vec.transform([clean(msg)]))[0][1]
verdict = "🚫 SPAM" if prob > 0.5 else "✅ Not spam"
st.markdown(f"### {verdict} — {prob:.0%} spam probability")
st.progress(float(prob))
words = clean(msg).split()
spam_words = ["free", "win", "winner", "prize", "claim", "urgent", "congratulations",
"offer", "cash", "bonus", "limited", "click", "txt", "mobile"]
hits = [w for w in words if w in spam_words]
if hits:
st.caption(f"Spammy words spotted: {', '.join(hits)}")
Step 2: Run the App
streamlit run spam_detector.py
Test a genuine message (“hey, running late, be there in 10”) versus a classic scam — watch the probability swing.
How It Works
TF-IDF converts messages to numbers: term frequency (how often a word appears) times inverse document frequency (how rare it is across all messages). “Free” appearing everywhere gets a low score; “ringtone” appearing mostly in spam gets a high one. The ngram_range=(1, 2) adds word pairs, capturing phrases like “call now” that single words miss.
Multinomial Naive Bayes then applies Bayes’ theorem with a deliberate simplification — it treats words as independent. That’s wrong about language and right about spam: the evidence piles up so strongly that the naive assumption barely hurts. It’s also blazingly fast, which is why it powered production spam filters for a decade.
The metrics matter more than accuracy: spam is only ~13% of the data, so a model saying “never spam” scores 87% accuracy while being useless. Precision (when we say spam, are we right?) and recall (of all spam, how much did we catch?) are the honest numbers — the same lesson the forecasting app’s backtest teaches.
The word-spotting footer is a transparency bonus: it lists known spam markers in your message, giving users an intuitive hook for why the model decided what it did.
Common Errors & Fixes
- Download fails (403/timeout) — UCI occasionally rate-limits; host the zip yourself or load a local copy in
load_data. empty vocabularyerror — your message cleaned down to nothing (only punctuation/stopwords); guard withif not clean(msg).strip().- Everything classifies as ham — check that labels mapped correctly (
spam→ 1); a flipped mapping inverts every verdict. - Slow on every rerun — training reruns each time; move the vectorizer+fit into
@st.cache_resourcekeyed on nothing, since the dataset is static.
Key Concepts
- TF-IDF — frequency weighted by rarity; the classic text feature.
- Naive Bayes — independent-evidence classification, fast and strong.
- Precision vs recall — the two ways to be wrong, and why accuracy lies on imbalanced data.
- N-grams — word pairs capture phrase-level signals.
What to Try Next
- Swap in LogisticRegression and compare precision/recall — one line change.
- Show top spammy features by coefficient — the model’s own explanation.
- Add a threshold slider trading recall against precision live.
- Classify your real inbox exports — the pipeline handles email text unchanged, and pairs with the email automation tutorial.
FAQ
Why Naive Bayes over deep learning?
For short-text spam with ~5K examples, NB matches complex models while training in milliseconds on CPU. Deep learning earns its complexity on bigger, messier problems.
What does the probability actually mean?
The model’s calibrated confidence given its training data — not a guarantee. Treat 0.7 as “likely spam” and review borderline cases manually.
Can spammers defeat it?
Yes — deliberate misspellings (‘fr3e’) evade word features. Production filters fight back with character n-grams and continuous retraining; that arms race is the field.