You will discover the Naive Bayes classifier yourself — step by step, from counting to a working ML model.
Before any algorithm, let's learn to count carefully. That's all probability is — careful counting.
Here are 14 days of weather observations. For each day we recorded the outlook, temperature, humidity, and wind — and whether we played tennis.
| Day | Outlook | Temp | Humidity | Wind | Play? |
|---|---|---|---|---|---|
| 1 | Sunny | Hot | High | Weak | No |
| 2 | Sunny | Hot | High | Strong | No |
| 3 | Overcast | Hot | High | Weak | Yes |
| 4 | Rainy | Mild | High | Weak | Yes |
| 5 | Rainy | Cool | Normal | Weak | Yes |
| 6 | Rainy | Cool | Normal | Strong | No |
| 7 | Overcast | Cool | Normal | Strong | Yes |
| 8 | Sunny | Mild | High | Weak | No |
| 9 | Sunny | Cool | Normal | Weak | Yes |
| 10 | Rainy | Mild | Normal | Weak | Yes |
| 11 | Sunny | Mild | Normal | Strong | Yes |
| 12 | Overcast | Mild | High | Strong | Yes |
| 13 | Overcast | Hot | Normal | Weak | Yes |
| 14 | Rainy | Mild | High | Strong | No |
Tasks (pen & paper):
data = [
{"Outlook": "Sunny", "Temperature": "Hot", "Humidity": "High", "Wind": "Weak", "Play": "No"},
{"Outlook": "Sunny", "Temperature": "Hot", "Humidity": "High", "Wind": "Strong", "Play": "No"},
{"Outlook": "Overcast", "Temperature": "Hot", "Humidity": "High", "Wind": "Weak", "Play": "Yes"},
{"Outlook": "Rainy", "Temperature": "Mild", "Humidity": "High", "Wind": "Weak", "Play": "Yes"},
{"Outlook": "Rainy", "Temperature": "Cool", "Humidity": "Normal", "Wind": "Weak", "Play": "Yes"},
{"Outlook": "Rainy", "Temperature": "Cool", "Humidity": "Normal", "Wind": "Strong", "Play": "No"},
{"Outlook": "Overcast", "Temperature": "Cool", "Humidity": "Normal", "Wind": "Strong", "Play": "Yes"},
{"Outlook": "Sunny", "Temperature": "Mild", "Humidity": "High", "Wind": "Weak", "Play": "No"},
{"Outlook": "Sunny", "Temperature": "Cool", "Humidity": "Normal", "Wind": "Weak", "Play": "Yes"},
{"Outlook": "Rainy", "Temperature": "Mild", "Humidity": "Normal", "Wind": "Weak", "Play": "Yes"},
{"Outlook": "Sunny", "Temperature": "Mild", "Humidity": "Normal", "Wind": "Strong", "Play": "Yes"},
{"Outlook": "Overcast", "Temperature": "Mild", "Humidity": "High", "Wind": "Strong", "Play": "Yes"},
{"Outlook": "Overcast", "Temperature": "Hot", "Humidity": "Normal", "Wind": "Weak", "Play": "Yes"},
{"Outlook": "Rainy", "Temperature": "Mild", "Humidity": "High", "Wind": "Strong", "Play": "No"},
]
Go through the "Play?" column and tally: Yes appears 9 times, No appears 5 times.
Yes: 9 days, No: 5 days. Fraction of Yes = 9/14 ≈ 0.643. Since Yes is more common, a reasonable default guess is "Yes".
Imagine you have no weather information at all. A friend calls and asks: "Should I come to play tennis?"
Your answers:
P(Yes) = (number of Yes days) / (total days) = 9/14. Since there are only two outcomes, P(No) = 1 − P(Yes) = 5/14.
P(Yes) = 9/14 ≈ 0.643, P(No) = 5/14 ≈ 0.357. We'd predict Yes since it's more likely. They sum to 1 because every day is either Yes or No — there are no other possibilities.
P(Play = Yes) = 9/14. In probability and machine learning, this is called a "prior" probability. Why is it called "prior"?
"Prior" means "before." Before what?
The prior probability is your belief prior to (before) seeing any evidence (features). It's the base rate: 9 out of 14 days were Yes, so without knowing the weather, your best guess is Yes. Once you look at features like Outlook or Wind, you'll update this prior — that's what the rest of this chapter is about.
The prior says "Yes" 64% of the time. But surely the weather matters. Let's see how one feature changes our prediction.
Look at the data table and answer by hand (just count the rows):
Your answers:
Filter the table to only Yes rows (days 3, 4, 5, 7, 9, 10, 11, 12, 13). Now count how many of those have Outlook = Sunny.
Yes & Sunny: days 9 and 11 → 2 out of 9. No & Sunny: days 1, 2, 8 → 3 out of 5.
Among Yes days: 2 out of 9 are Sunny → 2/9 ≈ 0.222. Among No days: 3 out of 5 are Sunny → 3/5 = 0.600. This is interesting: Sunny is much more common on No days!
It's a Sunny day. You want to decide: is it more likely a Yes day or a No day?
You have two pieces of information:
Tasks:
Your calculation:
Score(Yes) = (2/9) × (9/14). Notice that the 9s cancel: 2/14 ≈ 0.143.
Score(No) = (3/5) × (5/14) = 3/14 ≈ 0.214. Since 0.214 > 0.143, No wins.
Score(Yes) = 2/14 ≈ 0.143. Score(No) = 3/14 ≈ 0.214. No wins — on Sunny days, we predict "No".
Notice what happened: the prior said Yes (64%), but the evidence from the Sunny outlook flipped the prediction to No. The feature overrode the prior because Sunny is strongly associated with No days.
Repeat the same calculation from Ex 2.2 for:
You'll need to count how many Overcast and Rainy days appear among Yes days and No days first.
Overcast: Yes days with Overcast = days 3, 7, 12, 13 → 4 out of 9. No days with Overcast = 0 out of 5.
Overcast: Score(Yes) = (4/9)×(9/14) = 4/14. Score(No) = (0/5)×(5/14) = 0. Yes wins easily. Rainy: Among Yes days, Rainy = days 4, 5, 10 → 3/9. Among No days, Rainy = days 6, 14 → 2/5.
Overcast: Score(Yes) = 4/14 ≈ 0.286, Score(No) = 0. Predict Yes. Every Overcast day was a Yes day!
Rainy: Score(Yes) = (3/9)×(9/14) = 3/14 ≈ 0.214. Score(No) = (2/5)×(5/14) = 2/14 ≈ 0.143. Predict Yes.
Now let's automate the counting. Write a function that counts how many rows match a set of conditions.
def count_where(data, conditions):
"""
Count rows in data where ALL conditions are met.
conditions: dict like {"Outlook": "Sunny", "Play": "Yes"}
Returns: int
"""
# Your code here
pass
assert count_where(data, {"Play": "Yes"}) == 9
assert count_where(data, {"Play": "No"}) == 5
assert count_where(data, {"Outlook": "Sunny", "Play": "Yes"}) == 2
assert count_where(data, {"Outlook": "Sunny", "Play": "No"}) == 3
assert count_where(data, {"Outlook": "Overcast"}) == 4
assert count_where(data, {"Outlook": "Overcast", "Play": "No"}) == 0
print("All tests passed!")
Loop through each row in data. For each row, check if row[key] == value for every (key, value) pair in conditions. If all match, increment a counter.
Use Python's all(): all(row[k] == v for k, v in conditions.items()).
def count_where(data, conditions):
"""
Count rows in data where ALL conditions are met.
conditions: dict like {"Outlook": "Sunny", "Play": "Yes"}
Returns: int
"""
count = 0
for row in data:
if all(row[k] == v for k, v in conditions.items()):
count += 1
return count
Write a function that computes a conditional probability using count_where.
def probability(data, event, given):
"""
Compute P(event | given).
event: dict of conditions for the event, e.g. {"Outlook": "Sunny"}
given: dict of conditions for the given, e.g. {"Play": "Yes"}
Returns: float
"""
# Your code here
pass
# P(Outlook=Sunny | Play=Yes) = 2/9
assert abs(probability(data, {"Outlook": "Sunny"}, {"Play": "Yes"}) - 2/9) < 0.001
# P(Outlook=Sunny | Play=No) = 3/5
assert abs(probability(data, {"Outlook": "Sunny"}, {"Play": "No"}) - 3/5) < 0.001
# P(Wind=Strong | Play=Yes) = 3/9
assert abs(probability(data, {"Wind": "Strong"}, {"Play": "Yes"}) - 3/9) < 0.001
# P(Wind=Strong | Play=No) = 3/5
assert abs(probability(data, {"Wind": "Strong"}, {"Play": "No"}) - 3/5) < 0.001
print("All tests passed!")
P(event | given) = (count of rows matching BOTH event AND given) / (count of rows matching given). Combine the two dicts: {**given, **event}.
def probability(data, event, given):
"""
Compute P(event | given).
event: dict of conditions for the event, e.g. {"Outlook": "Sunny"}
given: dict of conditions for the given, e.g. {"Play": "Yes"}
Returns: float
"""
both = {**given, **event}
return count_where(data, both) / count_where(data, given)
Write a function that predicts the class using a single feature:
def predict_one_feature(data, feature_name, feature_value, label_col, labels):
"""
For each label, compute: score = P(feature_value | label) * P(label)
Return the label with the highest score.
"""
# Your code here
pass
assert predict_one_feature(data, "Outlook", "Sunny", "Play", ["Yes", "No"]) == "No"
assert predict_one_feature(data, "Outlook", "Overcast", "Play", ["Yes", "No"]) == "Yes"
assert predict_one_feature(data, "Outlook", "Rainy", "Play", ["Yes", "No"]) == "Yes"
assert predict_one_feature(data, "Wind", "Strong", "Play", ["Yes", "No"]) == "No"
assert predict_one_feature(data, "Wind", "Weak", "Play", ["Yes", "No"]) == "Yes"
print("All tests passed!")
For each label in labels: compute p_label = probability(data, {label_col: label}, {}) — wait, that won't work because given is empty. Use count_where(data, {label_col: label}) / len(data) for the prior. Then compute p_feature_given_label = probability(data, {feature_name: feature_value}, {label_col: label}). Multiply them.
Use max(labels, key=lambda label: score(label)) to pick the label with the highest score.
def predict_one_feature(data, feature_name, feature_value, label_col, labels):
"""
For each label, compute: score = P(feature_value | label) * P(label)
Return the label with the highest score.
"""
def score(label):
p_label = count_where(data, {label_col: label}) / len(data)
p_feature_given_label = probability(data, {feature_name: feature_value}, {label_col: label})
return p_feature_given_label * p_label
return max(labels, key=score)
One feature helped. Can two features help more? Let's combine evidence.
It's a Sunny and Hot day. Should we play?
We already know how to use one feature. For two features, try this: multiply the contribution of each feature separately, along with the prior.
Compute by hand:
Your calculation:
Among Yes days: Hot appears on days 3, 13 → 2 out of 9. Among No days: Hot appears on days 1, 2 → 2 out of 5.
Score(Yes) = (2/9) × (2/9) × (9/14) = 4/126 ≈ 0.032. Score(No) = (3/5) × (2/5) × (5/14) = 6/70 ≈ 0.086. No wins — Sunny + Hot strongly suggests no tennis.
Score(Yes) ≈ 0.032, Score(No) ≈ 0.086. Predict No. This matches our intuition: both Sunny and Hot are associated with No days.
Compute Score(Yes) and Score(No) for a Rainy and Cool day.
Among Yes: Rainy on days 4,5,10 → 3/9. Cool on days 5,7,9 → 3/9. Among No: Rainy on days 6,14 → 2/5. Cool on day 6 → 1/5.
Score(Yes) = (3/9) × (3/9) × (9/14) = 9/126 ≈ 0.071. Score(No) = (2/5) × (1/5) × (5/14) = 2/70 ≈ 0.029. Predict Yes.
In Exercise 3.1, you multiplied P(Sunny | Yes) × P(Hot | Yes). This multiplication is only mathematically valid under a specific assumption. What is it?
When can you multiply two probabilities to get their joint probability? Think back to basic probability rules.
Multiplying probabilities P(A) × P(B) gives P(A and B) only when A and B are independent. We're assuming that Outlook and Temperature are independent given the class label. This is often not true in reality (Sunny and Hot are correlated!), but the assumption simplifies the math enormously and works surprisingly well in practice. This is the "naive" in Naive Bayes — it naively assumes feature independence.
Write a function that predicts using two features:
def predict_two_features(data, f1_name, f1_val, f2_name, f2_val, label_col, labels):
"""
For each label, compute:
score = P(f1_val | label) * P(f2_val | label) * P(label)
Return the label with the highest score.
"""
# Your code here
pass
assert predict_two_features(data, "Outlook", "Sunny", "Temperature", "Hot",
"Play", ["Yes", "No"]) == "No"
assert predict_two_features(data, "Outlook", "Rainy", "Temperature", "Cool",
"Play", ["Yes", "No"]) == "Yes"
assert predict_two_features(data, "Outlook", "Overcast", "Temperature", "Mild",
"Play", ["Yes", "No"]) == "Yes"
assert predict_two_features(data, "Outlook", "Sunny", "Wind", "Strong",
"Play", ["Yes", "No"]) == "No"
print("All tests passed!")
This is just like predict_one_feature but with one more multiplication in the score: p_f1 * p_f2 * p_label.
def predict_two_features(data, f1_name, f1_val, f2_name, f2_val, label_col, labels):
"""
For each label, compute:
score = P(f1_val | label) * P(f2_val | label) * P(label)
Return the label with the highest score.
"""
def score(label):
p_label = count_where(data, {label_col: label}) / len(data)
p_f1 = probability(data, {f1_name: f1_val}, {label_col: label})
p_f2 = probability(data, {f2_name: f2_val}, {label_col: label})
return p_f1 * p_f2 * p_label
return max(labels, key=score)
You've done it for 1 feature and 2 features. Now let's generalize to any number of features.
It's Sunny, Hot, and High humidity. Compute Score(Yes) and Score(No) using all three features.
Among Yes days: Humidity=High on days 3, 4, 8 — wait, day 8 is a No day. Recount: days 3, 4, 12 → 3 out of 9. Among No days: days 1, 2, 8, 14 → 4 out of 5.
Score(Yes) = (2/9)(2/9)(3/9)(9/14) = 12/1134 ≈ 0.0106. Score(No) = (3/5)(2/5)(4/5)(5/14) = 24/350 ≈ 0.069. No wins convincingly.
Score(Yes) ≈ 0.011, Score(No) ≈ 0.069. Predict No. Adding the humidity feature reinforced the prediction — High humidity is strongly associated with No days.
You've seen the pattern: for each label, multiply the prior by the conditional probability of each feature. Now write the general version:
def predict(data, features, label_col, labels):
"""
features: dict mapping feature_name -> feature_value,
e.g. {"Outlook": "Sunny", "Temperature": "Hot", "Humidity": "High"}
label_col: name of the label column, e.g. "Play"
labels: list of possible labels, e.g. ["Yes", "No"]
For each label, compute:
score = P(label) * P(f1_val|label) * P(f2_val|label) * ... * P(fn_val|label)
Return the label with the highest score.
"""
# Your code here
pass
# Sunny, Hot, High → No
assert predict(data, {"Outlook": "Sunny", "Temperature": "Hot", "Humidity": "High"},
"Play", ["Yes", "No"]) == "No"
# Overcast, Mild, Normal → Yes
assert predict(data, {"Outlook": "Overcast", "Temperature": "Mild", "Humidity": "Normal"},
"Play", ["Yes", "No"]) == "Yes"
# All four features: Rainy, Cool, Normal, Weak → Yes
assert predict(data, {"Outlook": "Rainy", "Temperature": "Cool",
"Humidity": "Normal", "Wind": "Weak"},
"Play", ["Yes", "No"]) == "Yes"
# All four features: Sunny, Hot, High, Strong → No
assert predict(data, {"Outlook": "Sunny", "Temperature": "Hot",
"Humidity": "High", "Wind": "Strong"},
"Play", ["Yes", "No"]) == "No"
print("All tests passed!")
Loop through the features dict. For each (feature_name, feature_value) pair, multiply the running score by probability(data, {feature_name: feature_value}, {label_col: label}).
Start with score = count_where(data, {label_col: label}) / len(data) (the prior). Then for each feature: score *= probability(data, {fname: fval}, {label_col: label}).
def predict(data, features, label_col, labels):
"""
features: dict mapping feature_name -> feature_value
label_col: name of the label column
labels: list of possible labels
For each label, compute:
score = P(label) * P(f1_val|label) * P(f2_val|label) * ... * P(fn_val|label)
Return the label with the highest score.
"""
def score(label):
s = count_where(data, {label_col: label}) / len(data)
for fname, fval in features.items():
s *= probability(data, {fname: fval}, {label_col: label})
return s
return max(labels, key=score)
Run your predict function on every row in the dataset and compare to the actual label.
correct = 0
for row in data:
features = {k: v for k, v in row.items() if k != "Play"}
predicted = predict(data, features, "Play", ["Yes", "No"])
actual = row["Play"]
match = "✓" if predicted == actual else "✗"
print(f"{match} predicted={predicted}, actual={actual}")
if predicted == actual:
correct += 1
print(f"\nAccuracy: {correct}/{len(data)} = {correct/len(data):.1%}")
With the naive independence assumption, the classifier should get most rows right on this small dataset. Don't worry if a few are wrong — that's expected given the naive assumption.
Running the provided code produces:
✓ predicted=No, actual=No (day 1)
✓ predicted=No, actual=No (day 2)
✓ predicted=Yes, actual=Yes (day 3)
✓ predicted=Yes, actual=Yes (day 4)
✓ predicted=Yes, actual=Yes (day 5)
✓ predicted=No, actual=No (day 6)
✓ predicted=Yes, actual=Yes (day 7)
✓ predicted=No, actual=No (day 8)
✓ predicted=Yes, actual=Yes (day 9)
✓ predicted=Yes, actual=Yes (day 10)
✓ predicted=Yes, actual=Yes (day 11)
✓ predicted=Yes, actual=Yes (day 12)
✓ predicted=Yes, actual=Yes (day 13)
✓ predicted=No, actual=No (day 14)
Accuracy: 14/14 = 100.0%
The classifier achieves 100% training accuracy on this small dataset. This does not mean it will be perfect on new data — it is trained on the same data it is being tested on. With larger, noisier datasets, the naive independence assumption will cause some errors.
Your algorithm has a fatal flaw. Let's find it and fix it.
Look at the data: Outlook = Overcast appears on days 3, 7, 12, 13. What is the Play label for all of those days?
Now answer:
All Overcast days are Yes days. So P(Overcast | No) = 0/5 = 0.
Score(No) = P(Overcast|No) × ... × P(No) = 0 × ... = 0. No matter how much other evidence points to No, one zero kills the entire product. This is the zero-frequency problem.
P(Overcast | No) = 0. This makes Score(No) = 0 regardless of all other features. The algorithm becomes blind to all other evidence. Just because we haven't seen Overcast on a No day doesn't mean it's impossible — it just means our dataset is small.
The fix is beautifully simple: pretend you've seen every combination at least once.
The idea: Instead of computing P(Overcast | No) = 0/5, add 1 to the numerator (pretend you saw one extra Overcast-No day) and add k to the denominator (where k is the number of possible values for that feature).
For Outlook, k = 3 (Sunny, Overcast, Rainy). So:
Tasks:
probability_smooth(data, event, given, k):def probability_smooth(data, event, given, k):
"""
Smoothed conditional probability.
Adds 1 to the numerator and k to the denominator.
k = number of possible values for the event's feature.
"""
# Your code here
pass
# P_smooth(Overcast | No) = (0+1)/(5+3) = 1/8
assert abs(probability_smooth(data, {"Outlook": "Overcast"}, {"Play": "No"}, 3) - 1/8) < 0.001
# P_smooth(Sunny | No) = (3+1)/(5+3) = 4/8 = 0.5
assert abs(probability_smooth(data, {"Outlook": "Sunny"}, {"Play": "No"}, 3) - 0.5) < 0.001
# P_smooth(Sunny | Yes) = (2+1)/(9+3) = 3/12 = 0.25
assert abs(probability_smooth(data, {"Outlook": "Sunny"}, {"Play": "Yes"}, 3) - 0.25) < 0.001
print("All tests passed!")
The numerator is count_where(data, {**given, **event}) + 1 and the denominator is count_where(data, given) + k.
P_smooth(Sunny | No) = (3+1)/(5+3) = 4/8 = 0.5, compared to unsmoothed 3/5 = 0.6. Smoothing pulled the probability slightly toward the uniform value of 1/3.
def probability_smooth(data, event, given, k):
"""
Smoothed conditional probability.
Adds 1 to the numerator and k to the denominator.
k = number of possible values for the event's feature.
"""
both = {**given, **event}
return (count_where(data, both) + 1) / (count_where(data, given) + k)
Write predict_smooth — like your predict from Part 4, but using probability_smooth instead of probability.
You'll need to know k (number of possible values) for each feature. Compute this from the data.
def predict_smooth(data, features, label_col, labels):
"""
Like predict(), but uses Laplace smoothing.
For each feature, k = number of distinct values of that feature in the data.
"""
# Your code here
pass
# Same predictions as before on normal cases
assert predict_smooth(data, {"Outlook": "Sunny", "Temperature": "Hot",
"Humidity": "High", "Wind": "Strong"},
"Play", ["Yes", "No"]) == "No"
assert predict_smooth(data, {"Outlook": "Overcast", "Temperature": "Mild",
"Humidity": "Normal", "Wind": "Weak"},
"Play", ["Yes", "No"]) == "Yes"
# Now this works without the zero-frequency problem!
assert predict_smooth(data, {"Outlook": "Overcast", "Temperature": "Hot",
"Humidity": "High", "Wind": "Strong"},
"Play", ["Yes", "No"]) == "Yes"
print("All tests passed!")
To find k for a feature: k = len(set(row[feature_name] for row in data)).
def predict_smooth(data, features, label_col, labels):
"""
Like predict(), but uses Laplace smoothing.
For each feature, k = number of distinct values of that feature in the data.
"""
def score(label):
s = count_where(data, {label_col: label}) / len(data)
for fname, fval in features.items():
k = len(set(row[fname] for row in data))
s *= probability_smooth(data, {fname: fval}, {label_col: label}, k)
return s
return max(labels, key=score)
Laplace smoothing adds 1 to every count. What would happen if you added 1000 instead of 1?
With smoothing of 1000: P(Sunny|Yes) = (2 + 1000) / (9 + 3000) ≈ 1002/3009 ≈ 1/3. P(Overcast|Yes) = (4 + 1000) / (9 + 3000) ≈ 1004/3009 ≈ 1/3. They become nearly identical.
Large smoothing values drown out the real data. P(Sunny|Yes) = (2+1000)/(9+3000) ≈ 0.333 and P(Overcast|Yes) = (4+1000)/(9+3000) ≈ 0.334 — nearly identical. The classifier can no longer distinguish features because the fake counts dominate. A smoothing of 1 is the standard choice: just enough to avoid zeros without distorting the real signal.
You have all the pieces. Now wrap them into a reusable class with the standard ML interface: fit and predict.
Before writing code, think about what fit(X, y) needs to compute and store so that predict(X) can work later without re-scanning all the training data.
Think about:
Your design:
After fit(X, y), self should contain:
A good data structure: self.label_counts is a dict like {"Yes": 9, "No": 5}. self.feature_counts could be a nested dict: feature_counts[label][feature_name][feature_value] = count.
After fit(X, y), self should contain:
self.labels — list of unique labels, e.g. ["Yes", "No"]self.label_counts — dict mapping each label to its count, e.g. {"Yes": 9, "No": 5}self.feature_counts — nested dict: feature_counts[label][feature_name][feature_value] = count, e.g. {"Yes": {"Outlook": {"Sunny": 2, "Overcast": 4, "Rainy": 3}, ...}, ...}self.feature_values — dict mapping each feature name to the set of its distinct values, e.g. {"Outlook": {"Sunny", "Overcast", "Rainy"}, ...} (needed to compute k for smoothing)self.total — total number of training examples, e.g. 14With these stored, predict can compute scores using only simple lookups and arithmetic — no need to re-scan the raw data.
Implement the full class:
class NaiveBayes:
def __init__(self, smoothing=1):
"""smoothing: Laplace smoothing parameter (default 1)."""
pass
def fit(self, X, y):
"""
X : list of dicts (each dict maps feature_name -> feature_value)
y : list of labels
Precompute and store all the counts needed for prediction.
"""
pass
def predict(self, X):
"""
X : list of dicts
Returns: list of predicted labels, one per row.
"""
pass
X = [{"Outlook": row["Outlook"], "Temperature": row["Temperature"],
"Humidity": row["Humidity"], "Wind": row["Wind"]} for row in data]
y = [row["Play"] for row in data]
nb = NaiveBayes()
nb.fit(X, y)
predictions = nb.predict(X)
accuracy = sum(p == a for p, a in zip(predictions, y)) / len(y)
print(f"Training accuracy: {accuracy:.1%}")
# Test on new data
new_days = [
{"Outlook": "Sunny", "Temperature": "Cool", "Humidity": "High", "Wind": "Strong"},
{"Outlook": "Overcast", "Temperature": "Hot", "Humidity": "Normal", "Wind": "Weak"},
{"Outlook": "Rainy", "Temperature": "Mild", "Humidity": "Normal", "Wind": "Weak"},
]
preds = nb.predict(new_days)
print(f"Predictions: {preds}")
# Expected: ["No", "Yes", "Yes"]
assert preds == ["No", "Yes", "Yes"]
print("All tests passed!")
In fit: loop through zip(X, y). For each (row, label), increment self.label_counts[label] and self.feature_counts[label][feature_name][feature_value]. Also collect self.feature_values[feature_name] as a set of all observed values.
In predict: for each row, compute a score for each label. The prior is self.label_counts[label] / self.total. For each feature, the smoothed conditional probability is (count + smoothing) / (label_count + smoothing * k) where k = number of distinct values for that feature.
To get the count: self.feature_counts[label][feature_name].get(feature_value, 0). The .get(..., 0) handles unseen values (returns 0 instead of KeyError).
from collections import defaultdict
class NaiveBayes:
def __init__(self, smoothing=1):
"""smoothing: Laplace smoothing parameter (default 1)."""
self.smoothing = smoothing
def fit(self, X, y):
"""
X : list of dicts (each dict maps feature_name -> feature_value)
y : list of labels
Precompute and store all the counts needed for prediction.
"""
self.total = len(y)
self.labels = list(set(y))
self.label_counts = defaultdict(int)
self.feature_counts = defaultdict(lambda: defaultdict(lambda: defaultdict(int)))
self.feature_values = defaultdict(set)
for row, label in zip(X, y):
self.label_counts[label] += 1
for fname, fval in row.items():
self.feature_counts[label][fname][fval] += 1
self.feature_values[fname].add(fval)
def predict(self, X):
"""
X : list of dicts
Returns: list of predicted labels, one per row.
"""
results = []
for row in X:
best_label = None
best_score = -1
for label in self.labels:
score = self.label_counts[label] / self.total
for fname, fval in row.items():
k = len(self.feature_values[fname])
count = self.feature_counts[label][fname].get(fval, 0)
score *= (count + self.smoothing) / (self.label_counts[label] + self.smoothing * k)
if score > best_score:
best_score = score
best_label = label
results.append(best_label)
return results
Test your NaiveBayes class on a spam detection problem. Each email is represented by the presence/absence of certain words:
Tasks:
NaiveBayes on this dataset.new_emails = [
{"free": "yes", "money": "yes", "urgent": "no", "meeting": "no"}, # likely spam?
{"free": "no", "money": "no", "urgent": "no", "meeting": "yes"}, # likely ham?
{"free": "yes", "money": "no", "urgent": "yes", "meeting": "yes"}, # tricky!
]
spam_data = [
{"free": "yes", "money": "yes", "urgent": "yes", "meeting": "no", "label": "spam"},
{"free": "yes", "money": "no", "urgent": "yes", "meeting": "no", "label": "spam"},
{"free": "yes", "money": "yes", "urgent": "no", "meeting": "no", "label": "spam"},
{"free": "no", "money": "yes", "urgent": "yes", "meeting": "no", "label": "spam"},
{"free": "yes", "money": "yes", "urgent": "yes", "meeting": "yes", "label": "spam"},
{"free": "no", "money": "no", "urgent": "no", "meeting": "yes", "label": "ham"},
{"free": "no", "money": "no", "urgent": "no", "meeting": "no", "label": "ham"},
{"free": "yes", "money": "no", "urgent": "no", "meeting": "yes", "label": "ham"},
{"free": "no", "money": "no", "urgent": "yes", "meeting": "yes", "label": "ham"},
{"free": "no", "money": "no", "urgent": "no", "meeting": "yes", "label": "ham"},
{"free": "no", "money": "yes", "urgent": "no", "meeting": "yes", "label": "ham"},
{"free": "no", "money": "no", "urgent": "no", "meeting": "yes", "label": "ham"},
]
X_spam = [{k: v for k, v in row.items() if k != "label"} for row in spam_data]
y_spam = [row["label"] for row in spam_data]
Same code as before: nb = NaiveBayes(); nb.fit(X_spam, y_spam); nb.predict(new_emails). If your class is written correctly, it works on any dataset — that's the power of a general implementation.
The first email has "free" and "money" — strong spam signals. The second has only "meeting" — clearly ham. The third is tricky: "free" and "urgent" point to spam, while "meeting" points to ham. The classifier weighs all evidence and picks spam because two spam-indicative features outweigh one ham-indicative feature.
nb_spam = NaiveBayes()
nb_spam.fit(X_spam, y_spam)
# Training accuracy
preds_train = nb_spam.predict(X_spam)
accuracy = sum(p == a for p, a in zip(preds_train, y_spam)) / len(y_spam)
print(f"Training accuracy: {accuracy:.1%}") # 100.0%
# Predict on new emails
new_emails = [
{"free": "yes", "money": "yes", "urgent": "no", "meeting": "no"},
{"free": "no", "money": "no", "urgent": "no", "meeting": "yes"},
{"free": "yes", "money": "no", "urgent": "yes", "meeting": "yes"},
]
preds = nb_spam.predict(new_emails)
print(f"Predictions: {preds}")
# Expected: ["spam", "ham", "spam"]
Right now, predict returns just the winning label. Add a predict_proba method that returns the probability of each class.
To convert scores to probabilities, divide each score by the sum of all scores:
For example, if Score(Yes) = 0.03 and Score(No) = 0.07, then:
def predict_proba(self, X):
"""
X : list of dicts
Returns: list of dicts, each mapping label -> probability.
e.g. [{"Yes": 0.3, "No": 0.7}, ...]
"""
pass
Test: The probabilities for each row should sum to 1.0.
Compute all scores as before. Then: total = sum(scores.values()) and probs = {label: s / total for label, s in scores.items()}.
def predict_proba(self, X):
"""
X : list of dicts
Returns: list of dicts, each mapping label -> probability.
e.g. [{"Yes": 0.3, "No": 0.7}, ...]
"""
results = []
for row in X:
scores = {}
for label in self.labels:
score = self.label_counts[label] / self.total
for fname, fval in row.items():
k = len(self.feature_values[fname])
count = self.feature_counts[label][fname].get(fval, 0)
score *= (count + self.smoothing) / (self.label_counts[label] + self.smoothing * k)
scores[label] = score
total = sum(scores.values())
probs = {label: s / total for label, s in scores.items()}
results.append(probs)
return results
# Add to NaiveBayes class:
NaiveBayes.predict_proba = predict_proba
# Test:
probas = nb.predict_proba(X[:3])
for p in probas:
print(p, "sum =", round(sum(p.values()), 4))
assert abs(sum(p.values()) - 1.0) < 0.0001, "Probabilities must sum to 1!"
print("All probability sums verified!")
Scikit-learn has a built-in CategoricalNB. Compare your results:
from sklearn.preprocessing import OrdinalEncoder
from sklearn.naive_bayes import CategoricalNB
import numpy as np
# Encode string features as integers
enc = OrdinalEncoder()
X_encoded = enc.fit_transform([[row[f] for f in ["Outlook","Temperature","Humidity","Wind"]]
for row in data])
y_encoded = [1 if row["Play"]=="Yes" else 0 for row in data]
clf = CategoricalNB(alpha=1.0) # alpha=1.0 is Laplace smoothing
clf.fit(X_encoded, y_encoded)
# Compare predictions
sk_preds = clf.predict(X_encoded)
print("sklearn predictions:", ["Yes" if p==1 else "No" for p in sk_preds])
print("Your predictions: ", nb.predict(X))
Do the predictions match? If not, investigate why.
They should match exactly, since both use the same algorithm (categorical Naive Bayes with Laplace smoothing of 1). If they differ, check whether your smoothing formula matches scikit-learn's.
The predictions match exactly. Both implementations use the same algorithm: for each label, multiply the prior by the Laplace-smoothed conditional probability of each feature, then pick the label with the highest score. The only difference is that scikit-learn works with integer-encoded features internally, while our version works with string features directly.
from sklearn.preprocessing import OrdinalEncoder
from sklearn.naive_bayes import CategoricalNB
import numpy as np
# Encode string features as integers
feature_names = ["Outlook", "Temperature", "Humidity", "Wind"]
enc = OrdinalEncoder()
X_encoded = enc.fit_transform([[row[f] for f in feature_names] for row in data])
y_encoded = [1 if row["Play"] == "Yes" else 0 for row in data]
clf = CategoricalNB(alpha=1.0) # alpha=1.0 is Laplace smoothing
clf.fit(X_encoded, y_encoded)
# Compare predictions
sk_preds = clf.predict(X_encoded)
sk_labels = ["Yes" if p == 1 else "No" for p in sk_preds]
my_labels = nb.predict(X)
print("sklearn predictions:", sk_labels)
print("Your predictions: ", my_labels)
print("Match:", sk_labels == my_labels)
Your classifier works with categorical features (Sunny, Rainy, etc.). But what if a feature is a number, like actual temperature (72°F, 85°F, etc.)?
Try the binning approach on a dataset of your choice.
For binning: write a function bin_value(x, bins) that maps a number to a category string. Apply it to all numerical features before training.
You cannot count P(Temperature=72.3 | Yes) directly because with continuous values, almost no two rows share the exact same value. The count would be 0 or 1 for most values, making the probability estimate useless. The Gaussian approach (used by scikit-learn's GaussianNB) is more principled: it estimates the mean and standard deviation of each numeric feature per class, then uses the Gaussian probability density function instead of counting. This avoids the arbitrary choice of bin boundaries.
def bin_value(x, boundaries, labels):
"""
Map a numeric value to a category label.
boundaries: sorted list of thresholds, e.g. [60, 80]
labels: category names, one more than boundaries, e.g. ["Cold", "Mild", "Hot"]
"""
for i, b in enumerate(boundaries):
if x < b:
return labels[i]
return labels[-1]
# Example: a small dataset with numeric temperature
numeric_data = [
{"outlook": "Sunny", "temp": 85, "play": "No"},
{"outlook": "Sunny", "temp": 80, "play": "No"},
{"outlook": "Overcast", "temp": 83, "play": "Yes"},
{"outlook": "Rainy", "temp": 70, "play": "Yes"},
{"outlook": "Rainy", "temp": 68, "play": "Yes"},
{"outlook": "Rainy", "temp": 65, "play": "No"},
{"outlook": "Overcast", "temp": 64, "play": "Yes"},
{"outlook": "Sunny", "temp": 72, "play": "No"},
{"outlook": "Sunny", "temp": 69, "play": "Yes"},
{"outlook": "Rainy", "temp": 75, "play": "Yes"},
]
# Bin temperature into Cold/Mild/Hot
boundaries = [65, 75]
temp_labels = ["Cold", "Mild", "Hot"]
X_binned = [
{"outlook": row["outlook"],
"temp": bin_value(row["temp"], boundaries, temp_labels)}
for row in numeric_data
]
y_binned = [row["play"] for row in numeric_data]
nb_binned = NaiveBayes()
nb_binned.fit(X_binned, y_binned)
print(nb_binned.predict([{"outlook": "Sunny", "temp": bin_value(90, boundaries, temp_labels)}]))
# Hot + Sunny -> predicts "No"
What you just built:
You started by counting rows in a table. From there, you:
fit and predict.This is the Naive Bayes classifier — one of the oldest and most practical algorithms in machine learning. Despite its "naive" assumption, it excels at text classification (spam detection, sentiment analysis), medical diagnosis, and any problem with many categorical features.
The mathematical name for what you computed is Bayes' theorem: updating a prior belief using evidence. You invented it from scratch.