learn-classification-regression-with-phoebe / Session 6 of 10
Learn Classification & Regression with Phoebe · Session 6 of 10

Naive Bayes - the millisecond baseline

Every animal so far had to search for its answer: logistic descended a loss surface, kNN measured distances, and next session's tree will audition thousands of splits. Naive Bayes just counts. It counts how features look inside each class, multiplies, flips the question with Bayes' theorem, and is done - no iterations, no descent, trained in milliseconds. The price of that speed is one gloriously wrong assumption (the "naive" part) and probabilities you must never take at face value. Today you learn when this is exactly the right first model - text, speed, tiny data - and land it on the Lumen leaderboard at ~0.66 AUC for essentially zero compute.

🟡 Core zoo Builders · DA / DE / DS Python · scikit-learn 45 min
0-3 · Setup 3-18 · Bayes and the naive bet 18-40 · Build: the millisecond baseline 40-45 · Wrap
Part 0

The zoo's probability engine

Naive Bayes is the only animal in the zoo that treats classification as pure probability bookkeeping. It never optimizes anything: no loss function, no gradient, no search. It reads the training data once, tallies a few per-class statistics, and every prediction after that is a multiply-and-compare. That makes it the fastest model you will ever fit - and a strangely honest one: everything it believes, you can print and inspect. The catch comes in two parts: an independence assumption that is almost always false, and output "probabilities" that rank well but calibrate terribly.

Live - built in session Self-study - run after ★ The one big idea The case: Lumen Skincare
★ What you walk out with today You will know when Naive Bayes is the right FIRST model - text data, speed constraints, tiny training sets - and why. You will fit GaussianNB and BernoulliNB on Lumen (~0.66 and ~0.65 AUC) in a fraction of logistic's training time, match each NB variant to its feature type, and internalize the one warning that saves careers: never trust this model's predict_proba. Rankings yes, probabilities no.
Part 1 · the flip

Bayes' theorem as a classifier 8 min live

The question we want is hard: P(convert | features) - given this session's channel, device, spend, will it convert? But the reverse question is easy: given a converter, what do their features look like? That is just counting rows in the training set. Bayes' theorem is the machine that turns the easy direction into the hard one: P(convert | features) ∝ P(features | convert) x P(convert). Score every class as prior times likelihood, predict the class with the biggest score. That argmax is the whole classifier.

The flip - count the easy direction, then turn it around The forward question (easy) Given a converter, how do their features look? Just count rows. Bayes flips it The question we want (hard) Given these features, will this session convert? The prediction. PRIOR P(convert) how common converting is before evidence (~3%) x LIKELIHOOD P(features | convert) how the features look among converters - counted from train ∝ POSTERIOR P(convert | features) the flipped answer - pick the bigger class (argmax) Count the easy direction, flip with Bayes - score = prior x likelihood, and the biggest score wins (MAP).
🔍 Click to zoom - the flip that turns counting into classification
LivePrior x likelihood, pick the winner5 min▶

For a new Lumen session, compute two scores. Score(convert) = P(convert) x P(these features | convert). Score(no) = P(no) x P(these features | no). Predict whichever is bigger. That is called the MAP decision - maximum a posteriori - and it is literally all NB does at prediction time.

Notice what we skipped: the denominator P(features) from the full theorem. It is the same for both classes, so it cannot change which score wins - we drop it, which is why the formula reads "∝" (proportional to) rather than "=". This shortcut is also the first clue about why NB's probabilities go wrong: it works with unnormalized scores and only rescales at the end.

The prior does real work here. Lumen converts at roughly 3%, so the "no" class starts with a ~30x head start; the likelihoods have to shout to overcome it. On imbalanced data, NB's prior is a built-in base-rate reminder - one thing it gets right by construction.

LiveBayes' theorem itself, in one breath3 min▶

Recap, not re-teach: Bayes' theorem says P(A | B) = P(B | A) x P(A) / P(B) - it converts "probability of evidence given hypothesis" into "probability of hypothesis given evidence". If you can state that sentence comfortably, keep going. If you want the derivation, the medical-test worked example, and the base-rate fallacy it protects you from, that lives in learn-statistics-with-phoebe ↗ - this course borrows the theorem and spends its minutes on the classifier built from it.

The contract, again: statistics course = the theorem. This session = the theorem weaponized as a classifier, plus the engineering choices (the naive bet, the variants, smoothing) that make it practical.
Part 2 · the bet

The naive bet 8 min live

There is a hole in Part 1: the likelihood P(features | convert) means the probability of this exact combination - this channel AND this device AND this spend AND these pages, jointly. With 7 features, the combinations explode; almost no exact combination appears in the training data even once. You cannot count what never occurs. So NB makes its famous bet: assume every feature is independent of every other, given the class. Then the joint likelihood factorizes into a product of per-feature likelihoods - P(email | convert) x P(mobile | convert) x P(spend | convert) x ... - and each factor is trivially countable on its own.

LiveThe assumption is false on Lumen - and NB scores ~0.66 anyway3 min▶

Is the naive bet true for Lumen? Not even close. Channel correlates with new_vs_returning (email lands disproportionately on returning customers), so "email" and "returning" are not independent given the class - NB double-counts their shared evidence. The assumption is simply wrong.

And yet: GaussianNB lands ~0.66 ROC-AUC on the full feature set - level with the depth-5 decision tree, a whisker under logistic's ~0.69. The lesson generalizes far beyond NB: a model with wrong assumptions can still rank well. Double-counting inflates the scores, but it often inflates them in a way that preserves their ordering - and AUC only measures ordering.

LiveThe hard rule: a decent classifier, a bad estimator3 min▶

The same double-counting that leaves rankings intact wrecks the probability values. sklearn's own user guide (1.9) puts it bluntly: naive Bayes is a decent classifier but a bad estimator - the outputs of predict_proba are "not to be taken too seriously". Piling up correlated evidence pushes scores toward extremes, so NB says "99.7% will convert" about sessions where the honest answer is "maybe 8%".

Operationally: ranking yes, calibrated probability no. Sorting sessions by NB score to pick the top 500 for an email push - fine. Feeding NB's 99.7% into an expected-revenue calculation or a bid price - a silent disaster. If you need real probabilities from NB, it must be recalibrated (a model-evaluation-course story; today, just never trust the raw values).

Interview-grade phrasing: "Naive Bayes' independence violation preserves the argmax more often than it preserves the probabilities - so it classifies and ranks acceptably while being poorly calibrated."
Self-studyWhy the naive bet relieves the curse of dimensionality3 min▶

Session 5's villain was the curse of dimensionality: in high dimensions, kNN needs enormous data because it must populate the whole joint space with neighbors. The naive bet is an escape hatch: NB never models the joint space at all. Each feature gets its own little 1-D distribution, estimated independently - a mean and a variance here, a handful of category counts there.

Estimating seven 1-D distributions takes dramatically less data than estimating one 7-D distribution. That is why NB shines exactly where kNN suffers: many features, few rows. Both StatQuest's NB episodes and the sklearn guide hit this beat - the decoupling of per-feature estimates is the reason NB works at all on wide data like text, where "features" are thousands of word counts.

Part 3 · the variants

Match the distribution to the feature 9 min live

"Naive Bayes" is a family, not a model. The engine - prior x per-feature likelihoods, pick the argmax - never changes. What changes is the shape you assume for each per-feature likelihood, and sklearn ships one variant per shape. Choosing the variant IS the modeling decision:

VariantFeature typeWhat it learns per classLumen / classic use
GaussianNBContinuous numbersA mean and a variance per feature - one bell per classOur spend / pages / hour columns
MultinomialNBCountsCategory frequencies, smoothed by alpha (Laplace)THE text classic - word counts
BernoulliNBBinary presence / absenceP(feature on | class) - and it penalizes non-occurrence tooOur one-hot categoricals; binarize thresholds raw inputs
ComplementNBCounts, imbalancedStatistics of the complement of each classImbalanced text - often beats MultinomialNB there
CategoricalNBOrdinal-encoded categoriesA frequency table per category valueCategoricals without one-hotting
GaussianNB - continuous one bell per class per feature (a mean + a variance, that's it) MultinomialNB - counts count frequencies per class, alpha-smoothed - text's home BernoulliNB - on / off presence AND absence both count as evidence (0s matter) Same Bayes engine, three likelihood shapes - match the variant to what your features look like.
🔍 Click to zoom - three likelihood shapes, one engine
LiveSmoothing - why alpha exists3 min▶

NB multiplies. Multiplication has a fatal failure mode: one zero kills everything. If the word "refund" never appeared in a training spam email, then P(refund | spam) = 0 by counting - and any future email containing "refund" gets Score(spam) = 0, no matter how much other evidence screams spam. One unseen feature vetoes the entire product.

alpha is the fix: add a pseudocount to every count before computing frequencies (StatQuest's word for it - pseudocounts). alpha=1 is classic Laplace / add-one smoothing, sklearn's default. Nothing is ever exactly zero, so nothing can veto. Smaller alpha trusts the counts more; bigger alpha flattens the differences. It is MultinomialNB's and BernoulliNB's one knob worth tuning.

Self-studyThe spam story - NB's greatest hit3 min▶
Real worldThe classic spam filter is naive Bayes wearing a trench coat. Training: count every word's frequency in known spam and known ham - that is the entire "fit". Scoring a new email: multiply P(word | spam) across its words, times the spam prior; same for ham; flip with Bayes; compare. Early web-mail providers filtered billions of messages a day this way, retraining in seconds as users hit "report spam". Decades later, an NB filter is still a strong production baseline for text - anything fancier has to beat it to justify its GPU bill.

Lumen has no text columns, so we teach the spam pipeline conceptually and keep our hands on GaussianNB and BernoulliNB. When you meet text for real (a vectorizer feeding MultinomialNB), the "covered" section below names this gap honestly.

Self-studyToo much data for RAM? partial_fit1 min▶

Because NB's "model" is just running counts and running means, it can learn incrementally: model.partial_fit(X_chunk, y_chunk, classes=[0, 1]) in a loop over chunks streams through data that never fits in memory. One line to remember: NB is one of the few zoo animals with out-of-core training built in.

Build-along

Build: the millisecond baseline ★ 18 min · everyone builds

Three experiments: GaussianNB on the full feature set, BernoulliNB on the one-hot categoricals alone, and a timing race against logistic regression. Then one plot - the predict_proba histogram - to see the bad-estimator warning with your own eyes.

Fit GaussianNB on everything. Scaled numerics plus one-hots through the usual pipeline (note sparse_output=False - GaussianNB wants a dense matrix). Expect ~0.66 ROC-AUC.

Fit BernoulliNB on the one-hot categoricals only. Binary features are its native food. Expect ~0.65 - four categorical columns carry nearly the whole GaussianNB signal.

Race it against logistic. Time both fits with time.perf_counter(). NB tallies counts in one pass; logistic iterates. On this data expect NB to be roughly an order of magnitude faster to fit.

Look at the probabilities. Histogram predict_proba. The overconfident clumps near 0 and 1 are the bad-estimator warning made visible.

nb_baseline.py - ~0.66 AUC for essentially zero computeimport time import pandas as pd import matplotlib.pyplot as plt from sklearn.model_selection import train_test_split from sklearn.compose import ColumnTransformer from sklearn.preprocessing import OneHotEncoder, StandardScaler from sklearn.pipeline import Pipeline from sklearn.naive_bayes import GaussianNB, BernoulliNB from sklearn.linear_model import LogisticRegression from sklearn.metrics import roc_auc_score df = pd.read_parquet("lumen_sessions.parquet") cat = ["channel", "device", "new_vs_returning", "product_category"] num = ["pages_viewed", "session_hour", "prior_30d_spend"] X, y = df[cat + num], df["converted"] X_tr, X_te, y_tr, y_te = train_test_split( X, y, test_size=0.25, random_state=42, stratify=y) prep = ColumnTransformer([ ("cat", OneHotEncoder(handle_unknown="ignore", sparse_output=False), cat), ("num", StandardScaler(), num)]) def fit_timed(name, pipe): t0 = time.perf_counter() pipe.fit(X_tr, y_tr) secs = time.perf_counter() - t0 auc = roc_auc_score(y_te, pipe.predict_proba(X_te)[:, 1]) print(f"{name:12s} AUC {auc:.2f} fit {secs:.4f}s") return pipe # 1 - GaussianNB on everything: ~0.66, fit in milliseconds gnb = fit_timed("GaussianNB", Pipeline([("prep", prep), ("model", GaussianNB())])) # 2 - BernoulliNB on the one-hot cats only: ~0.65 cats_only = ColumnTransformer([ ("cat", OneHotEncoder(handle_unknown="ignore", sparse_output=False), cat)]) bnb = fit_timed("BernoulliNB", Pipeline([("prep", cats_only), ("model", BernoulliNB())])) # 3 - the race: logistic iterates, NB just counts logit = fit_timed("logistic", Pipeline([("prep", prep), ("model", LogisticRegression(max_iter=2000))])) # expect NB roughly an order of magnitude faster to fit - it makes ONE pass # 4 - the bad estimator, visible: probabilities pile up near 0 and 1 plt.hist(gnb.predict_proba(X_te)[:, 1], bins=50) plt.title("GaussianNB predict_proba - overconfident clumps at the extremes") plt.show()
Reading the histogram: a well-calibrated model on a ~3% base rate would concentrate its probabilities down near 0.03 with a thin honest tail. NB instead slams scores toward 0 and 1 - correlated features double-counted as independent evidence. Same ranking quality as the tree (~0.66), wildly worse probability quality. Exactly what the sklearn warning predicts.
Real worldA growth team once shipped NB conversion scores straight into a bidding system as expected probabilities. Rankings were fine, so tests looked fine - but the bids priced 90%+ "certainties" that converted 10% of the time, and the budget evaporated in a weekend. One calibration plot would have caught it. Decent classifier, bad estimator: it is not a footnote, it is a budget line.
Homework

Before session 7 ◐ 25 min

Check yourself

Three questions before you go 🎯 ◐ 90 seconds

1 · What exactly is "naive" about naive Bayes?

The bet is conditional independence given the class. It is almost always false (on Lumen, channel correlates with returning) - yet it turns an impossible joint estimation into countable 1-D pieces, and the rankings often survive the lie.

2 · Why should you never trust naive Bayes' predict_proba values?

The independence violation preserves the argmax far more often than it preserves the values. Use NB scores to sort and select; never feed them into expected-value math without recalibration.

3 · You have word-count features. Which variant, and why does its alpha parameter exist?

Counts belong to MultinomialNB. Because NB multiplies, one unseen feature would veto everything with a hard zero; Laplace smoothing (alpha=1 by default) guarantees no count is ever exactly zero.

Source material

What this session covers

Session 6 covers the naive Bayes material of the anchor sources - StatQuest's two NB episodes and sklearn's module 1.9 - with the theorem itself recapped from the statistics course. Videos, labs, and certificates stay with the official providers.

✓StatQuest - Naive Bayes (multinomial, the spam walk-through)Parts 1-3 + the spam story
✓StatQuest - Gaussian Naive BayesPart 3 + demo
◐Bayes' theorem foundationrecap card only - learn-statistics-with-phoebe owns it
✓sklearn 1.9 - GaussianNB, MultinomialNB, BernoulliNBPart 3 table + demo
◐sklearn 1.9 - ComplementNB, CategoricalNBtable row + homework
✓sklearn 1.9 - the "bad estimator" predict_proba warningPart 2 hard rule + histogram
✓Laplace / alpha smoothing (pseudocounts)Part 3
✗Full text-classification pipeline (vectorizer to MultinomialNB)not covered by design - Lumen has no text; spam demo stays conceptual

Session 6 cheat sheet · pin this

The flipP(convert | features) ∝ P(features | convert) x P(convert). Count the easy direction per class, flip with Bayes, predict the argmax (MAP).
The naive betAssume features independent given the class, so the joint likelihood factorizes into per-feature products. False on Lumen - and it still ranks at ~0.66 AUC.
The variantsGaussian = continuous (bell per class). Multinomial = counts (text's home). Bernoulli = on/off bits, absence counts too. Complement = imbalanced text. Categorical = ordinal codes.
alpha smoothingPseudocounts so no unseen feature has probability zero - one hard zero would veto the whole product. alpha=1 (Laplace) is the default.
Never trust its probaDecent classifier, bad estimator. Ranking and top-k selection: yes. Calibrated probabilities into expected-value math: never, without recalibration.
The scoreboardGaussianNB ~0.66, BernoulliNB (cats only) ~0.65 - level with the depth-5 tree, under logistic's ~0.69, fit in milliseconds with one pass and no iterations.