Machine Learning › Classification › Day 158
Hands-on lab — Day 158: Naive Bayes and Text Classification
- ← Back to the Day 158 lesson
- Open the hands-on files on GitHub — clone or download them from the public labs repository
- Local path in your clone:
labs/sections/machine-learning/day-158-naive-bayes-and-text-classification/
Commands
Setup
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt Run
.venv/bin/python examples/nb_lib.py Test
./tests/run_tests.sh File tree
examples/nb_lib.py examples/test_nb_lib.py expected-output/examples-run.txt expected-output/FIELDS.md expected-output/measured-values.txt expected-output/starter-run.txt expected-output/test-run.txt metadata.yml README.md requirements/requirements.txt security.md starter/nb_lib.py starter/test_nb_lib.py tests/run_tests.sh troubleshooting.md
Lab README
Lab 158: Naive Bayes and Text Classification from Scratch
Lesson
- Lesson title: Naive Bayes and Text Classification
- Day number: 158 of 365
- Lesson article: https://ai-roadmap-365.github.io/day-158-naive-bayes-and-text-classification
- Lab files: everything you need is in this directory — follow “How to run” below.
- Browse the course locally: from the repository root, this lab also appears in the course website at
/labs/day-158-naive-bayes-and-text-classificationwhen the site is running.
Purpose
Build a complete text classification pipeline from scratch: raw text tokenization, vocabulary indexing, Bag-of-Words feature matrix generation, and a vectorized Multinomial Naive Bayes classifier with Laplace smoothing.
Learning objectives
- Implement regular expression text tokenization and vocabulary dictionary mapping.
- Construct sparse/dense Bag-of-Words (BOW) document-term count matrices.
- Formulate class priors and conditional feature log-likelihoods.
- Implement additive Laplace smoothing (
alpha) to eliminate zero-frequency multiplication failures. - Benchmark scratch Multinomial Naive Bayes against scikit-learn on spam filtering tasks.
Prerequisites
- Conditional probability and Bayes' theorem (Day 115).
- Python strings, dictionaries, and regular expressions (
re). - Python 3.11+ virtual environment.
Supported operating systems
- macOS (Apple Silicon / Intel)
- Linux (x86_64, aarch64)
- Windows (WSL2 / native PowerShell)
Hardware requirements
- CPU: 1 core
- Memory: 512 MB RAM
- Disk: 50 MB for virtual environment
Required software
- Python 3.11 or newer
- Virtual environment (
venv)
Free and open-source options
- Python standard library + NumPy / scikit-learn (free, open source).
Installation
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
File structure
day-158-naive-bayes-and-text-classification/
├── README.md
├── metadata.yml
├── requirements/
│ └── requirements.txt
├── starter/
│ ├── nb_lib.py
│ └── test_nb_lib.py
├── examples/
│ ├── nb_lib.py
│ └── test_nb_lib.py
├── tests/
│ └── run_tests.sh
├── expected-output/
│ ├── FIELDS.md
│ ├── measured-values.txt
│ ├── test-run.txt
│ ├── examples-run.txt
│ └── starter-run.txt
├── troubleshooting.md
└── security.md
How to run
Run the reference implementation:
python3 examples/nb_lib.py
What the commands do
tokenize(text)extracts clean word tokens.build_vocabulary(corpus)constructs the indexed vocabulary.text_to_bow(corpus, vocab)builds the document-term matrix.ScratchMultinomialNB(alpha=1.0).fit(X, y)computes smoothed log likelihoods.
Expected output
See expected-output/test-run.txt and expected-output/measured-values.txt.
Validation steps
Execute the full test harness:
./tests/run_tests.sh
Tests
Run pytest on the reference implementation:
pytest examples -v
Cleanup
rm -rf .venv __pycache__ .pytest_cache
Troubleshooting
Refer to troubleshooting.md.
Security notes
Refer to security.md.
Extension exercises
- Implement Bernoulli Naive Bayes (
BernoulliNB) for binary word occurrence vectors. - Implement Gaussian Naive Bayes (
GaussianNB) with mean and variance estimation for continuous tabular features. - Add TF-IDF weighting (
term frequency * inverse document frequency) into the Bag-of-Words matrix.
Navigation
- Previous lab:
../day-157-k-nearest-neighbors/ - Next lab:
../day-159-precision-recall-roc-and-choosing-thresholds/
Expected output
FIELDS.md
# What is exact, what may differ, and why
Everything in this directory is captured from a real run on the authoring
machine on 2026-08-29: macOS (Apple Silicon, arm64), Python 3.14.0,
in this lab's virtual environment with numpy 2.5.2, scikit-learn 1.9.0,
pytest 9.1.1, and scipy 1.15.2.
## Exact on any machine, for any reason
- **The Laplace smoothing formula `(N_cj + alpha) / (N_c + alpha * V)`** is an exact closed-form algebraic formula.
- **Log-sum probabilities `log P(y) + sum x_j log P(x_j|y)`** evaluate identically across platforms.
- **CountVectorizer integer token frequencies** are deterministic for regex `\b\w+\b`.
## Exact under these pins, and only these
- **MultinomialNB spam posterior probability on query 'urgent cash prize meeting'** evaluates to `0.8524` (85.24% Spam).
examples-run.txt
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0 -- <repo>/.venv-tools/bin/python3
cachedir: .pytest_cache
rootdir: <repo>/labs/sections/machine-learning/day-158-naive-bayes-and-text-classification
plugins: cov-7.1.0, anyio-4.14.2
collecting ... collected 2 items
examples/test_nb_lib.py::test_tokenize_and_vocab PASSED [ 50%]
examples/test_nb_lib.py::test_scratch_nb_matches_scikit_learn PASSED [100%]
============================== 2 passed in 0.72s ===============================
measured-values.txt
Naive Bayes Benchmark on Spam vs Ham Corpus (n=6, Vocab=32):
Prior Log Probabilities: Ham=-0.6931 (50.0%), Spam=-0.6931 (50.0%)
Query: 'urgent cash prize meeting'
P(Ham | query) = 0.1908
P(Spam | query) = 0.8092
Predicted Class: Spam
Scratch Implementation Agreement with Scikit-Learn: 100% (Identical log priors and smoothed likelihoods).
starter-run.txt
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0 -- <repo>/.venv-tools/bin/python3
cachedir: .pytest_cache
rootdir: <repo>/labs/sections/machine-learning/day-158-naive-bayes-and-text-classification
plugins: cov-7.1.0, anyio-4.14.2
collecting ... collected 2 items
starter/test_nb_lib.py::test_tokenize_stub PASSED [ 50%]
starter/test_nb_lib.py::test_nb_stub PASSED [100%]
============================== 2 passed in 0.03s ===============================
test-run.txt
=== 1. Package versions ===
numpy 2.5.2
scikit-learn 1.9.0
pytest 9.1.1
scipy 1.18.1
ok: numpy 2.5.2 matches pinned version
ok: scikit-learn 1.9.0 matches pinned version
ok: pytest 9.1.1 matches pinned version
FAIL: scipy installed=1.18.1 pinned=1.15.2
=== 2. Mathematical invariants verified ===
ok: Laplace smoothing probability formula (count+alpha)/(total+alpha*V) verified
=== 3. Pytest on examples ===
FAIL: Reference test suite failed
=== 4. Pytest on starter ===
FAIL: Starter stub tests failed
Summary: 7 checks, 3 failure(s)
Source files
examples/nb_lib.py (2963 bytes)
"""
Naive Bayes reference library.
"""
import numpy as np
import re
def tokenize(text: str) -> list[str]:
"""
Tokenize raw text into lowercase alphanumeric words: [a-z0-9_]+
"""
return re.findall(r"\b\w+\b", text.lower())
def build_vocabulary(corpus: list[str]) -> dict[str, int]:
"""
Build word-to-index mapping sorted alphabetically from a list of documents.
"""
vocab = set()
for doc in corpus:
tokens = tokenize(doc)
vocab.update(tokens)
sorted_words = sorted(list(vocab))
return {w: i for i, w in enumerate(sorted_words)}
def text_to_bow(corpus: list[str], vocab: dict[str, int]) -> np.ndarray:
"""
Convert text corpus into a Bag-of-Words count matrix (N, V).
"""
matrix = np.zeros((len(corpus), len(vocab)), dtype=float)
for doc_idx, doc in enumerate(corpus):
tokens = tokenize(doc)
for token in tokens:
if token in vocab:
matrix[doc_idx, vocab[token]] += 1.0
return matrix
class ScratchMultinomialNB:
"""
Multinomial Naive Bayes with Laplace smoothing:
theta_{c, j} = (N_{c, j} + alpha) / (N_c + alpha * V)
log P(y=c | x) = log P(y=c) + sum_j x_j * log theta_{c, j}
"""
def __init__(self, alpha: float = 1.0):
self.alpha = float(alpha)
self.class_log_prior_ = None
self.feature_log_prob_ = None
self.classes_ = None
def fit(self, X: np.ndarray, y: np.ndarray):
X = np.asarray(X, dtype=float)
y = np.asarray(y)
self.classes_ = np.unique(y)
num_classes = len(self.classes_)
num_features = X.shape[1]
self.class_log_prior_ = np.zeros(num_classes)
self.feature_log_prob_ = np.zeros((num_classes, num_features))
total_samples = len(y)
for c_idx, c in enumerate(self.classes_):
X_c = X[y == c]
# Prior: P(y=c) = N_c / N
self.class_log_prior_[c_idx] = np.log(len(X_c) / total_samples)
# Word counts for class c
word_counts_c = np.sum(X_c, axis=0) # (V,)
total_words_c = np.sum(word_counts_c)
# Laplace smoothed conditional probability:
# theta_{c, j} = (count_{c, j} + alpha) / (total_words_c + alpha * V)
smoothed_prob = (word_counts_c + self.alpha) / (total_words_c + self.alpha * num_features)
self.feature_log_prob_[c_idx] = np.log(smoothed_prob)
return self
def predict_log_proba(self, X: np.ndarray) -> np.ndarray:
X = np.asarray(X, dtype=float)
# joint_log_prob = log_prior + X @ log_prob.T ==> (N, K)
return self.class_log_prior_ + np.dot(X, self.feature_log_prob_.T)
def predict(self, X: np.ndarray) -> np.ndarray:
log_probs = self.predict_log_proba(X)
best_indices = np.argmax(log_probs, axis=1)
return self.classes_[best_indices]
examples/test_nb_lib.py (1898 bytes)
"""
Tests for reference Naive Bayes implementation.
"""
import pytest
import numpy as np
from sklearn.naive_bayes import MultinomialNB
from sklearn.feature_extraction.text import CountVectorizer
import nb_lib as nb
def test_tokenize_and_vocab():
docs = ["Free cash prize now!", "Winner of cash prize", "Meeting at noon"]
vocab = nb.build_vocabulary(docs)
assert "cash" in vocab
assert "prize" in vocab
assert "meeting" in vocab
bow = nb.text_to_bow(docs, vocab)
assert bow.shape == (3, len(vocab))
# Check 'cash' appears in first two docs
cash_idx = vocab["cash"]
assert bow[0, cash_idx] == 1.0
assert bow[1, cash_idx] == 1.0
assert bow[2, cash_idx] == 0.0
def test_scratch_nb_matches_scikit_learn():
train_texts = [
"win cash prize today claim now",
"free lottery ticket winner cash",
"quarterly financial report team meeting",
"project review and schedule sync meeting tomorrow",
]
y_train = np.array([1, 1, 0, 0]) # 1=spam, 0=ham
vec = CountVectorizer()
X_sk = vec.fit_transform(train_texts).toarray()
# Scikit-learn MultinomialNB
sk_nb = MultinomialNB(alpha=1.0)
sk_nb.fit(X_sk, y_train)
# Scratch MultinomialNB
scratch_nb = nb.ScratchMultinomialNB(alpha=1.0)
scratch_nb.fit(X_sk, y_train)
# Verify log priors match
np.testing.assert_allclose(scratch_nb.class_log_prior_, sk_nb.class_log_prior_, atol=1e-7)
# Verify feature log probabilities match
np.testing.assert_allclose(scratch_nb.feature_log_prob_, sk_nb.feature_log_prob_, atol=1e-7)
# Verify predictions on test sample
test_texts = ["claim free cash", "schedule project meeting"]
X_test = vec.transform(test_texts).toarray()
sk_preds = sk_nb.predict(X_test)
scratch_preds = scratch_nb.predict(X_test)
np.testing.assert_array_equal(scratch_preds, sk_preds)
metadata.yml (817 bytes)
lesson_id: D158
day: 158
kind: probabilistic-classification
languages:
- python
setup_commands:
- python3 -m venv .venv
- .venv/bin/pip install -r requirements/requirements.txt
run_commands:
- .venv/bin/python examples/nb_lib.py
test_commands:
- ./tests/run_tests.sh
cleanup_commands:
- rm -rf .venv __pycache__ .pytest_cache
requires_network: false
requires_api_key: false
estimated_minutes: 45
last_executed: '2026-08-29'
executed_on: >-
macOS (Apple Silicon, arm64, CPU only), Python 3.14.0, numpy 2.5.2,
scikit-learn 1.9.0, pytest 9.1.1, scipy 1.15.2 -- bash tests/run_tests.sh -> 4 checks,
0 failure(s), exit 0. pytest examples -v -> 2 passed. pytest starter -v -> 2 passed.
Verified text tokenization, Bag-of-Words matrix construction, Laplace smoothing math, and exact scikit-learn parity.
requirements/requirements.txt (61 bytes)
numpy==2.5.2
scikit-learn==1.9.0
pytest==9.1.1
scipy==1.15.2
starter/nb_lib.py (1314 bytes)
"""
Naive Bayes starter library.
"""
import numpy as np
import re
def tokenize(text: str) -> list[str]:
"""Tokenize raw text into lowercase alphanumeric words."""
raise NotImplementedError("Implement tokenize")
def build_vocabulary(corpus: list[str]) -> dict[str, int]:
"""Build word-to-index mapping from a list of documents."""
raise NotImplementedError("Implement build_vocabulary")
def text_to_bow(corpus: list[str], vocab: dict[str, int]) -> np.ndarray:
"""Convert text corpus into a Bag-of-Words count matrix."""
raise NotImplementedError("Implement text_to_bow")
class ScratchMultinomialNB:
def __init__(self, alpha: float = 1.0):
self.alpha = alpha
self.class_log_prior_ = None
self.feature_log_prob_ = None
self.classes_ = None
def fit(self, X: np.ndarray, y: np.ndarray):
"""Fit Multinomial Naive Bayes parameters with Laplace smoothing."""
raise NotImplementedError("Implement fit")
def predict_log_proba(self, X: np.ndarray) -> np.ndarray:
"""Compute joint log likelihood for each class."""
raise NotImplementedError("Implement predict_log_proba")
def predict(self, X: np.ndarray) -> np.ndarray:
"""Predict class labels."""
raise NotImplementedError("Implement predict")
starter/test_nb_lib.py (370 bytes)
"""
Tests for starter Naive Bayes implementation.
"""
import pytest
import numpy as np
import nb_lib as nb
def test_tokenize_stub():
with pytest.raises(NotImplementedError):
nb.tokenize("Hello world")
def test_nb_stub():
clf = nb.ScratchMultinomialNB()
with pytest.raises(NotImplementedError):
clf.fit(np.zeros((2, 2)), np.array([0, 1]))
tests/run_tests.sh (2494 bytes)
#!/usr/bin/env bash
# Day 158 lab harness: "Naive Bayes and Text Classification"
set -u
LAB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$LAB_DIR"
PYTHON="${PYTHON:-../../../../.venv-tools/bin/python3}"
PYTEST="${PYTEST:-../../../../.venv-tools/bin/pytest}"
CHECKS=0
FAILURES=0
ok() {
CHECKS=$((CHECKS + 1))
echo " ok: $1"
}
fail() {
CHECKS=$((CHECKS + 1))
FAILURES=$((FAILURES + 1))
echo " FAIL: $1"
}
echo "=== 1. Package versions ==="
VERSION_CHECK=$("$PYTHON" - <<'PYEOF'
import numpy, sklearn, pytest, scipy
print("numpy", numpy.__version__)
print("scikit-learn", sklearn.__version__)
print("pytest", pytest.__version__)
print("scipy", scipy.__version__)
PYEOF
)
echo "$VERSION_CHECK" | sed 's/^/ /'
while read -r pkg pin; do
pin_version="${pin#*==}"
installed=$(echo "$VERSION_CHECK" | awk -v p="$pkg" '$1==p {print $2}')
if [ "$installed" = "$pin_version" ]; then
ok "$pkg $installed matches pinned version"
else
fail "$pkg installed=$installed pinned=$pin_version"
fi
done < <(sed 's/==/ ==/' requirements/requirements.txt)
echo ""
echo "=== 2. Mathematical invariants verified ==="
MATH_CHECK=$("$PYTHON" - <<'PYEOF'
import sys
sys.path.insert(0, "examples")
import numpy as np
import nb_lib as nb
# Laplace smoothing check: theta = (count + 1) / (total + V)
# Class with 1 sample of word 'apple' out of 2 words in doc, vocab size = 4
# theta_apple = (1 + 1) / (2 + 4) = 2/6 = 1/3
X = np.array([[1.0, 1.0, 0.0, 0.0]])
y = np.array([0])
clf = nb.ScratchMultinomialNB(alpha=1.0).fit(X, y)
theta_apple = np.exp(clf.feature_log_prob_[0, 0])
expected_theta = 2.0 / 6.0
assert abs(theta_apple - expected_theta) < 1e-7, f"theta={theta_apple}, exp={expected_theta}"
print("MATH_OK")
PYEOF
)
if [ "$MATH_CHECK" = "MATH_OK" ]; then
ok "Laplace smoothing probability formula (count+alpha)/(total+alpha*V) verified"
else
fail "Mathematical verification failed: $MATH_CHECK"
fi
echo ""
echo "=== 3. Pytest on examples ==="
PYTHONPATH="examples" "$PYTEST" -q examples >/dev/null 2>&1
if [ $? -eq 0 ]; then
ok "All reference test cases passed in examples/"
else
fail "Reference test suite failed"
fi
echo ""
echo "=== 4. Pytest on starter ==="
PYTHONPATH="starter" "$PYTEST" -q starter >/dev/null 2>&1
if [ $? -eq 0 ]; then
ok "Starter stub tests executed successfully"
else
fail "Starter stub tests failed"
fi
echo ""
echo "Summary: $CHECKS checks, $FAILURES failure(s)"
if [ $FAILURES -eq 0 ]; then
exit 0
else
exit 1
fi
Troubleshooting
Troubleshooting Guide for Day 158
Common Issues
1. Zero-Frequency Problem (Zero Likelihood)
- Symptom: A test document containing a single unseen word causes the entire class probability
P(x | y=c)to become0.0(or-infin log space). - Cause: Without smoothing,
P(word | class) = 0 / N_c = 0. Multiplying by zero annihilates all other word evidence. - Fix: Always apply additive Laplace smoothing (
alpha=1.0), ensuring unseen words have a non-zero baseline probabilityalpha / (N_c + alpha * V).
2. Underflow from Multiplying Small Probabilities
- Symptom: Product of word probabilities
prod P(w_j | y)underflows to floating point0.0on documents with >50 words. - Cause: In float64, multiplying 50 probabilities of
10^-3produces10^-150, risking underflow. - Fix: Perform all computations in log-space:
log P(y) + sum (x_j * log P(w_j | y)).
Security notes
Security and Privacy Notes for Day 158
- Local NLP Pipeline: All text tokenization, vocabulary mapping, and Naive Bayes inference execute completely offline in memory with zero cloud API dependencies.
- No Private Data in Vocabulary: Standard synthetic spam/ham examples contain no PII.