Learning Naive Bayes by Inventing It

You will discover the Naive Bayes classifier yourself — step by step, from counting to a working ML model.

Part 1: Counting and Probability

Before any algorithm, let's learn to count carefully. That's all probability is — careful counting.

Exercise 1.1 — Meet the Data

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):

  1. Count the number of Yes days and No days.
  2. What fraction of all days resulted in "Yes"?
  3. Without looking at any weather features, if someone asked "Will we play tomorrow?", what would you guess?
Provided — Provided — load the data in Python
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"},
]
Hint 1

Go through the "Play?" column and tally: Yes appears 9 times, No appears 5 times.

Solution

Yes: 9 days, No: 5 days. Fraction of Yes = 9/14 ≈ 0.643. Since Yes is more common, a reasonable default guess is "Yes".

Exercise 1.2 — Your First Prediction Rule

Imagine you have no weather information at all. A friend calls and asks: "Should I come to play tennis?"

  1. What would you predict? Why?
  2. What is the probability of Play = Yes? Express it as a fraction.
  3. What is the probability of Play = No?
  4. Do these two probabilities add up to 1? Why must they?

Your answers:

  1. Prediction: ...
  2. P(Yes) = ...
  3. P(No) = ...
  4. Sum = ... because ...
Hint 1

P(Yes) = (number of Yes days) / (total days) = 9/14. Since there are only two outcomes, P(No) = 1 − P(Yes) = 5/14.

Solution

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.

Quick Check 1.3 — Prior Probability

P(Play = Yes) = 9/14. In probability and machine learning, this is called a "prior" probability. Why is it called "prior"?

Hint

"Prior" means "before." Before what?

Reasoning

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.

Part 2: One Feature — Does Weather Help?

The prior says "Yes" 64% of the time. But surely the weather matters. Let's see how one feature changes our prediction.

Exercise 2.1 — Counting with a Condition

Look at the data table and answer by hand (just count the rows):

  1. Of the 9 Yes days, how many have Outlook = Sunny?
  2. Of the 5 No days, how many have Outlook = Sunny?
  3. Compute the fractions: "Among Yes days, what fraction is Sunny?" and "Among No days, what fraction is Sunny?"

Your answers:

  1. Sunny & Yes days: ... out of 9
  2. Sunny & No days: ... out of 5
  3. Fraction among Yes: ... / 9 = ...    Fraction among No: ... / 5 = ...
Hint 1

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.

Hint 2

Yes & Sunny: days 9 and 11 → 2 out of 9. No & Sunny: days 1, 2, 8 → 3 out of 5.

Solution

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!

Exercise 2.2 — The Key Question

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:

  1. Compute: (fraction of Sunny among Yes) × P(Yes) = ?
  2. Compute: (fraction of Sunny among No) × P(No) = ?
  3. Which product is larger? What would you predict for a Sunny day?

Your calculation:

  1. Score(Yes) = (2/9) × (9/14) = ...
  2. Score(No) = (3/5) × (5/14) = ...
  3. Winner: ...
Hint 1

Score(Yes) = (2/9) × (9/14). Notice that the 9s cancel: 2/14 ≈ 0.143.

Hint 2

Score(No) = (3/5) × (5/14) = 3/14 ≈ 0.214. Since 0.214 > 0.143, No wins.

Solution

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.

Exercise 2.3 — Try Other Outlooks

Repeat the same calculation from Ex 2.2 for:

  1. Overcast days: compute Score(Yes) and Score(No). What do you predict?
  2. Rainy days: compute Score(Yes) and Score(No). What do you predict?

You'll need to count how many Overcast and Rainy days appear among Yes days and No days first.

Hint 1

Overcast: Yes days with Overcast = days 3, 7, 12, 13 → 4 out of 9. No days with Overcast = 0 out of 5.

Hint 2

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.

Solution

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.

Exercise 2.4 — Code It: `count_where`

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
Provided — test cell
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!")
Hint 1

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.

Hint 2

Use Python's all(): all(row[k] == v for k, v in conditions.items()).

Solution
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

Exercise 2.5 — Code It: `probability`

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
Provided — test cell
# 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!")
Hint 1

P(event | given) = (count of rows matching BOTH event AND given) / (count of rows matching given). Combine the two dicts: {**given, **event}.

Solution
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)

Exercise 2.6 — Predict with One Feature

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
Provided — test cell
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!")
Hint 1

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.

Hint 2

Use max(labels, key=lambda label: score(label)) to pick the label with the highest score.

Solution
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)

Part 3: Two Features — Combining Evidence

One feature helped. Can two features help more? Let's combine evidence.

Exercise 3.1 — Two Features by Hand

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:

  1. From the data, find:
    • P(Sunny | Yes) = ?
    • P(Hot | Yes) = ?
    • P(Yes) = ?
  2. Multiply them: Score(Yes) = P(Sunny | Yes) × P(Hot | Yes) × P(Yes)
  3. Do the same for No: Score(No) = P(Sunny | No) × P(Hot | No) × P(No)
  4. Which score is higher? What do you predict?

Your calculation:

  1. P(Sunny|Yes) = ..., P(Hot|Yes) = ..., P(Yes) = ...
  2. Score(Yes) = ... × ... × ... = ...
  3. P(Sunny|No) = ..., P(Hot|No) = ..., P(No) = ...
  4. Score(No) = ... × ... × ... = ...
  5. Prediction: ...
Hint 1

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.

Hint 2

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.

Solution

Score(Yes) ≈ 0.032, Score(No) ≈ 0.086. Predict No. This matches our intuition: both Sunny and Hot are associated with No days.

Exercise 3.2 — Try Another Combination

Compute Score(Yes) and Score(No) for a Rainy and Cool day.

  1. Count P(Rainy | Yes), P(Cool | Yes), P(Rainy | No), P(Cool | No).
  2. Compute Score(Yes) and Score(No).
  3. What do you predict?
Hint 1

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.

Solution

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.

Quick Check 3.3 — The Big Assumption

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?

Hint

When can you multiply two probabilities to get their joint probability? Think back to basic probability rules.

Reasoning

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.

Exercise 3.4 — Code It: `predict_two_features`

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
Provided — test cell
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!")
Hint 1

This is just like predict_one_feature but with one more multiplication in the score: p_f1 * p_f2 * p_label.

Solution
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)

Part 4: N Features — The General Algorithm

You've done it for 1 feature and 2 features. Now let's generalize to any number of features.

Exercise 4.1 — Three Features by Hand

It's Sunny, Hot, and High humidity. Compute Score(Yes) and Score(No) using all three features.

  1. You already have P(Sunny|Yes) and P(Hot|Yes). Now find P(High|Yes) and P(High|No) from the data.
  2. Score(Yes) = P(Sunny|Yes) × P(Hot|Yes) × P(High|Yes) × P(Yes)
  3. Score(No) = P(Sunny|No) × P(Hot|No) × P(High|No) × P(No)
  4. Which class wins?
Hint 1

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.

Hint 2

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.

Solution

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.

Exercise 4.2 — Generalize to N Features

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
Provided — test cell
# 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!")
Hint 1

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}).

Hint 2

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}).

Solution
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)

Exercise 4.3 — Test on All Training Rows

Run your predict function on every row in the dataset and compare to the actual label.

  1. How many does it get right?
  2. Which rows (if any) does it get wrong? Look at those rows — can you see why?
Provided — test cell
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%}")
Hint 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.

Solution

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.

Part 5: The Zero-Frequency Problem

Your algorithm has a fatal flaw. Let's find it and fix it.

Exercise 5.1 — Break Your Code

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:

  1. What is P(Overcast | No)?
  2. If you try to predict for a new day with Outlook = Overcast and any other features, what happens to Score(No)?
  3. Is this a reasonable prediction? What if you had Overcast + Strong Wind + High Humidity — conditions that historically lean toward No?
Hint 1

All Overcast days are Yes days. So P(Overcast | No) = 0/5 = 0.

Hint 2

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.

Solution

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.

Exercise 5.2 — Fix It: Laplace Smoothing

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:

  1. Compute P_smooth(Sunny | No) with k=3. Compare to the unsmoothed version.
  2. Write 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
Provided — test cell
# 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!")
Hint 1

The numerator is count_where(data, {**given, **event}) + 1 and the denominator is count_where(data, given) + k.

Solution

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)

Exercise 5.3 — Update Your Predict Function

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
Provided — test cell
# 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!")
Hint 1

To find k for a feature: k = len(set(row[feature_name] for row in data)).

Solution
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)

Quick Check 5.4 — Why Smoothing Works

Laplace smoothing adds 1 to every count. What would happen if you added 1000 instead of 1?

Hint

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.

Reasoning

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.

Part 6: The NaiveBayes Class

You have all the pieces. Now wrap them into a reusable class with the standard ML interface: fit and predict.

Exercise 6.1 — Design the Interface

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:

  1. What are the possible labels? (e.g., Yes and No)
  2. For each label, how many training examples had that label? (the priors)
  3. For each label AND each feature AND each feature value, how many times did that combination appear? (the conditional counts)
  4. For each feature, how many distinct values does it have? (needed for smoothing)

Your design:

After fit(X, y), self should contain:

Hint 1

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.

Solution

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. 14

With these stored, predict can compute scores using only simple lookups and arithmetic — no need to re-scan the raw data.

Exercise 6.2 — Implement `NaiveBayes`

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
Provided — test cell
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!")
Hint 1

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.

Hint 2

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.

Hint 3

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).

Solution
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

Exercise 6.3 — A Bigger Dataset

Test your NaiveBayes class on a spam detection problem. Each email is represented by the presence/absence of certain words:

Tasks:

  1. Train your NaiveBayes on this dataset.
  2. Compute training accuracy.
  3. Predict on these new emails:
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!
]
  1. Do the predictions match your intuition?
Provided — Provided — spam dataset
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]
Hint 1

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.

Solution

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"]

Part 7: Bonus Challenges

Predict Probabilities

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.

Hint 1

Compute all scores as before. Then: total = sum(scores.values()) and probs = {label: s / total for label, s in scores.items()}.

Solution
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!")

Compare with scikit-learn

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.

Hint 1

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.

Solution

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)

What About Continuous Features?

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.)?

  1. Why can't you directly count P(Temperature=72.3 | Yes)? (How many training rows have that exact value?)
  2. One approach: assume numerical features follow a bell curve (Gaussian distribution) within each class. Instead of counting, you'd compute the mean and standard deviation per class, then use the bell curve to get the probability.
  3. Another approach: bin the numbers into categories (e.g., Cold: <60, Mild: 60-80, Hot: >80) and use your existing categorical classifier.

Try the binning approach on a dataset of your choice.

Hint 1

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.

Solution

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:

  1. Discovered that prior probability (just counting labels) gives a baseline prediction.
  2. Found that conditional probabilities (counting features within each class) improve predictions.
  3. Learned to combine multiple features by multiplying their conditional probabilities — under the naive independence assumption.
  4. Discovered and fixed the zero-frequency problem with Laplace smoothing.
  5. Wrapped it all in a reusable class with 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.