What it actually does
Use Bayes’ rule to turn "how likely is this evidence under each class" into "how likely is each class given this evidence" — then make one deliberately false assumption to render it computable: pretend every feature is independent of every other, given the class. Modelling the true joint distribution of a thousand features is hopeless; modelling a thousand one-dimensional distributions is arithmetic. The assumption is wrong on essentially every real dataset, and the classifier works anyway, because picking the largest score does not require the scores to be correct — only correctly ordered.
- Features are conditionally independent given the class — known to be false on essentially all real data, and the model is used anyway because argmax tolerates the error that calibration does not
- The chosen variant matches the feature distribution: multinomial for counts, Bernoulli for presence, Gaussian for continuous features that are actually roughly Gaussian
- Every feature value that will be seen at inference is either present in training or covered by smoothing
- The training class balance reflects the deployment base rate, since the prior enters the score directly and unmodified
Likelihood
Start from how common each class is, then add up the evidence each feature contributes for it, and pick the winner. The sum is where the naivety lives: adding log-likelihoods asserts that the features carry independent evidence, so two perfectly correlated features count twice. In text that is obviously false — "New" and "York" are not independent — and the consequence is precise. The ranking usually survives, because the double-counting tends to push every class in the same direction; the probabilities do not, because the accumulated overcounting saturates the winner at essentially 1.0.
- class prior — how common the class is before any evidence is seen
- class-conditional likelihood of one feature, estimated independently of all others
- the naive assumption itself: a sum of logs is a product of probabilities, which is only valid under independence
- not cosmetic — a product of thousands of probabilities underflows float64, and the log form is what makes the model implementable
How it is minimized
Closed-form maximum likelihood — counting, in a single pass over the data
- how often feature j took value v among class-c examples
- the count (or total token mass) of class c
- smoothing pseudo-count; alpha = 1 is Laplace, and alpha = 0 breaks on any unseen value
- number of values feature j can take — the denominator correction that keeps the smoothed estimates a distribution
There is no iterative optimization at all: under the independence assumption the likelihood factorizes completely, so the maximum-likelihood estimate for each parameter is a ratio of counts and can be written down directly. That is the entire fit. The only real decision is the smoothing constant, and it is not optional — with alpha = 0, a single feature value never seen with a given class assigns that class probability zero, and one zero annihilates the whole product no matter how much evidence the other thousand features supplied. Smoothing exists to stop one unseen word from vetoing every other word in the document.
alpha (smoothing)
Pseudo-count added to every cell. The only tuned parameter, and it must be non-zero (0.01 to 1.0; smaller for large vocabularies, cross-validated)
variant
Multinomial for counts, Bernoulli for presence/absence, Gaussian for continuous features, Complement for imbalanced text — a choice about the feature distribution, not a tunable
fit_prior
Whether to learn class priors from the data or assume them uniform; uniform is often better when the training class balance is an artefact of collection
binarization threshold
Bernoulli NB only — the cutoff that turns counts into presence, which matters more than it looks on short documents
Nothing iterates, so nothing converges or fails to. The failure modes are about what the estimates mean rather than whether they are found. The first is calibration: multiplying correlated evidence as if it were independent drives the posterior to 0 or 1 almost regardless of the input, so the reported confidence is not a probability and must never be thresholded as one. The second is correlated redundancy: duplicating a feature doubles its influence, which means feature selection is a genuine accuracy intervention here rather than an efficiency one. The third is numerical and is the reason for the log form — a product of ten thousand probabilities underflows to exactly zero in double precision, and every class then ties at zero.
Training: O(n·d) time in a single streaming pass, O(C·V) memory for the count table where C is the class count and V the vocabulary. Prediction: O(C·d) per example, or O(C·nnz) on sparse features, which for text means only the words actually present are ever touched. It is the cheapest model in this section by a wide margin, and the only one that trains without holding the dataset in memory.
How, where, and why it is used
Time-Series Forecasting
Not applicableThe model has no representation of order, lag, or a continuous horizon: applying it requires discretizing the target into classes and treating each observation as exchangeable, which discards exactly the temporal dependence that made it a forecasting problem.
Anomaly Detection
AdaptedBecause the model is generative, it gives you p(x) directly — fit it on normal data and score new points by their joint log-likelihood, flagging the improbable ones. On categorical or count data this is genuinely convenient, since the per-feature log-probabilities also decompose the score: the alert can name which feature made the point unusual, without a separate attribution method.
- Log and audit-event screening, where records are categorical and a rare combination of common values is the signal
- Intrusion-detection baselines profiling normal command, port, or protocol usage
- Data-quality screening on high-cardinality categorical records before they enter a warehouse
It is fast, streaming, and self-explaining, which makes it a reasonable first detector on categorical data where distance-based methods have no natural metric. But the independence assumption bites much harder here than in classification: the score is an actual density estimate rather than a ranking, and a density built by multiplying correlated marginals is badly wrong in a way that argmax was shielding you from. Use it when the alternative is no detector; do not use it when the anomalies are defined by an unusual *combination* of individually ordinary values, which is precisely what independence cannot represent.
- Fit on a confirmed-clean window; a contaminated fit raises the likelihood of the thing being hunted
- Smoothing is doing double duty here — it sets the floor on how improbable an unseen value can look, and therefore the maximum score any single feature can contribute
- Cap or bin high-cardinality features, or every rare-but-legitimate value scores as an anomaly
Precision@k against confirmed incidents with the threshold read from score quantiles on clean data. Check the per-feature contribution distribution too: if one feature dominates every alert, the detector has become a univariate rule with extra steps.
- Reading the joint likelihood as a probability — it is not calibrated, and its scale shifts with the number of features present
- Documents or records of varying length producing systematically different scores, since more features means more log terms; normalize by length
- Correlated features multiplying their own rarity, so a single unusual entity spread across three columns scores as three independent surprises
Optimization
Not applicableNothing is optimized — the parameters are closed-form count ratios — and the output is a class label rather than an allocation under constraints, so there is neither a solver to study nor a decision to feed.
Natural Language
Primary fitThe canonical application, and the one the multinomial variant was designed for: represent a document as its token counts, estimate each word’s probability under each class by counting, and classify by the summed log-likelihood. Sparsity makes it exceptionally cheap — only the words actually present contribute, so a 50,000-word vocabulary costs nothing on a 200-word document.
- Spam filtering, historically the deployment that made the method famous and still a live baseline
- Topic and intent classification when labelled data is scarce, where it beats discriminative models at small n
- Language identification and other high-class-count problems where per-class training cost matters
- The baseline any text classifier must beat before its cost can be justified
Text has exactly the structure that makes the method work despite its assumption: very high dimension, very sparse, and enough weakly-informative features that double-counting some correlations rarely flips the argmax. It also learns from very few examples, because estimating d univariate distributions needs far less data than estimating a joint one — which is a real asymptotic result, not a heuristic: the generative model reaches its (higher) error floor much faster than logistic regression reaches its lower one. It stops being the right choice as soon as data is plentiful, or when word order and negation carry the signal, which a bag of independent tokens cannot represent at all.
- Multinomial on raw counts or tf-idf; Bernoulli on presence for very short texts where repetition carries no extra information
- Complement Naive Bayes when classes are imbalanced — it estimates each class against the complement and corrects the systematic bias toward frequent classes
- Keep the matrix sparse end to end; densifying a document-term matrix is what makes people conclude the method is slow
- Bigrams reintroduce a little of the order the independence assumption destroys, at a large cost in vocabulary size
Macro-F1 on a temporal split, since vocabulary drifts. Report accuracy against a majority-class baseline, and never report the predicted probabilities as confidence — inspect the top log-probability ratios per class instead, which are directly readable and where label leakage usually surfaces.
- Fitting the vectorizer before splitting, leaking vocabulary and document frequencies
- Thresholding the posterior for a precision target: it sits at 0.9999 for almost everything, so the threshold has no usable dynamic range
- Very long documents dominating the count table for their class unless lengths are normalized
Risk & Fraud
ViableA first-pass triage model over categorical and count features — merchant category, device, channel, hour bucket — scoring transactions or claims in a single cheap pass so that an expensive model or a human reviewer only sees the survivors.
- High-volume transaction pre-screening where per-item latency budget is measured in microseconds
- Claims and application triage on mostly categorical intake forms
- A fast interpretable baseline used to sanity-check a boosted model’s lift
It is essentially free to train and retrain, updates incrementally as counts arrive, and every score decomposes into per-feature contributions a reviewer can read. Those are real operational advantages in a domain where models are audited. What it cannot do is exactly what fraud detection usually requires — model interactions, since a transaction is suspicious because of a *combination* of ordinary attributes, and independence is the assumption that makes combinations invisible. It is a screen, not the model.
- Bin continuous amounts into ordinal buckets rather than assuming a Gaussian, which transaction values never are
- Reduce cardinality of merchant and device identifiers, or every rare identifier becomes its own signal
- Handle class imbalance through the prior explicitly rather than resampling, since the prior is an estimated parameter here
PR-AUC and precision at a fixed alert budget rather than accuracy, on an out-of-time split. Compare against the interaction-aware model it is screening for, and measure what fraction of true positives it discards — that is the only number that matters for a screen.
- Treating the posterior as a fraud probability in a risk-weighted decision; it is not calibrated and never will be
- Correlated identifiers (device, IP, and geography usually move together) triple-counting the same evidence
- Concept drift in the count tables going unnoticed because the model never fails loudly, it just gets gradually less discriminating
What it costs to run
A single pass of counting — trivially parallel by shard, and the counts merge by addition. Millions of documents on one core in seconds, and there is no need to hold the corpus in memory.
A sparse dot product per class: microseconds, and small enough to run inside a database query or on an edge device. The model is a count table, so its size is set by vocabulary times class count, not by dataset size.
Effectively continuous. Because the parameters are counts, new data can be folded in incrementally without a refit, which makes it one of the few models here that genuinely supports online updating with no approximation.
- Track the fraction of unseen feature values per batch — a rising out-of-vocabulary rate is the earliest signal that the count table has gone stale
- Monitor the distribution of the score margin between the top two classes, not the posterior, which is pinned near 1 and carries no information
- Watch class priors against the observed label distribution; a shifted base rate degrades this model faster than a discriminative one because the prior enters the score directly
- Alert on documents or records whose length is far outside the training range, since the number of summed log terms scales with it
- Never expose the posterior as a confidence — it saturates at 0 or 1, and every downstream threshold built on it will behave as a coin flip near the boundary
- Smoothing must be applied at scoring time as well as training time, or an unseen value takes log(0) and produces negative infinity
- The vocabulary is part of the model; regenerating it separately at inference silently reindexes every probability
- Counts must be stored at sufficient precision, or a long-running incremental update loses small increments to floating-point absorption — integer counters, not float accumulators
- A class with no training examples has an undefined prior; the fallback must be explicit rather than an implicit division by zero
Pros and cons, in context
Trains in one counting pass with no iteration, no tuning, and no solver
It is the fastest reasonable baseline that exists, which makes it the right first thing to run on a new text problem. That advantage is worth nothing on a problem where a fit takes minutes anyway.
Works well with very little labelled data
Estimating d univariate distributions needs far less data than estimating a joint one, so it converges to its error floor much faster than a discriminative model converges to its lower one. The crossover is real: below a few hundred examples per class it frequently wins outright, and above a few thousand it usually loses.
Naturally incremental and shardable
Parameters are counts, so shards merge by addition and new data updates the model exactly rather than approximately. Genuinely rare — most models here require a full refit — and decisive for streaming pipelines.
Every prediction decomposes into per-feature contributions
The score is a sum of log terms, so the explanation is the arithmetic rather than a post-hoc approximation of it. Valuable in review settings; irrelevant when only the label is consumed.
The probability estimates are unusable
Correlated evidence multiplied as if independent saturates the posterior at 0 or 1. This is the single most common misuse of the model: anything that thresholds the probability, ranks by it across classes, or feeds it into an expected-cost calculation is wrong.
Cannot represent feature interactions at all
The failure is structural, not a matter of capacity: independence is the modelling assumption. Where the signal IS the combination — most fraud, most reasoning-adjacent text tasks — no amount of data fixes it.
Correlated and duplicated features are counted multiple times
Makes feature selection an accuracy intervention rather than an efficiency one, which is the opposite of most models here. Three near-identical columns silently triple that evidence’s weight.
Usually beaten once data is plentiful
Logistic regression on the same features has a lower error floor and reaches it given enough examples. Naive Bayes keeps the small-data and streaming regimes, and honestly not much beyond them.
Intuitive, idiomatic, optimized
The same algorithm in three languages, each progressing through Kent Beck's three stages. Every optimization names the lever it pulls and the trade-off it accepts — cost profiles are illustrative, not measured benchmarks.
The math, transcribed literally. No dependencies.
"""Multinomial Naive Bayes - counting, then Bayes' rule, transcribed.
Training is the two count tables in the update rule above: how much total token
mass each class holds, and how often each token appears within it. Prediction
adds one log term per token, which is the naive assumption made literal.
"""
import math
def fit(documents, labels, vocabulary_size, alpha=1.0):
"""documents[i] is a list of token ids; labels[i] is a class id."""
classes = sorted(set(labels))
class_counts = {c: 0 for c in classes}
token_totals = {c: 0 for c in classes}
token_counts = {c: [0] * vocabulary_size for c in classes}
for i in range(len(documents)):
label = labels[i]
class_counts[label] += 1
for token in documents[i]:
token_counts[label][token] += 1
token_totals[label] += 1
n = len(documents)
log_prior = {}
log_likelihood = {}
for c in classes:
log_prior[c] = math.log(class_counts[c] / n)
# Smoothing is not optional: with alpha = 0 an unseen token gives
# log(0), and one -inf term annihilates every other token's evidence.
denominator = token_totals[c] + alpha * vocabulary_size
row = [0.0] * vocabulary_size
for token in range(vocabulary_size):
row[token] = math.log((token_counts[c][token] + alpha) / denominator)
log_likelihood[c] = row
return log_prior, log_likelihood
def predict(log_prior, log_likelihood, document):
"""argmax_c log P(c) + sum_j log P(token_j | c)"""
best_class = None
best_score = None
for c in log_prior:
# Everything is in log space because a product of thousands of
# probabilities underflows float64 to exactly zero.
score = log_prior[c]
for token in document:
score += log_likelihood[c][token]
if best_score is None or score > best_score:
best_class = c
best_score = score
return best_classO(n * tokens) to fit and O(C * tokens) to score, in interpreter loops over dict-of-list count tables.
How this language actually wants it written.
"""Multinomial Naive Bayes - typed, validated, sparse-aware."""
from dataclasses import dataclass
import numpy as np
from numpy.typing import NDArray
Vector = NDArray[np.float64]
Matrix = NDArray[np.float64]
@dataclass(frozen=True)
class MultinomialNB:
"""A fitted model: one log-probability row per class, plus the log priors.
Everything is stored in log space. Converting back is never necessary and
is actively harmful - the exponentiated posterior saturates at 0 or 1 and
should not be exposed as a confidence.
"""
classes: NDArray
log_prior: Vector # (C,)
log_likelihood: Matrix # (C, V)
alpha: float
def joint_log_likelihood(self, X: Matrix) -> Matrix:
"""(n, C) matrix of scores. The whole prediction rule, as one product."""
if X.ndim != 2 or X.shape[1] != self.log_likelihood.shape[1]:
raise ValueError(
f"expected (n, {self.log_likelihood.shape[1]}) counts, got {X.shape}"
)
return X @ self.log_likelihood.T + self.log_prior
def predict(self, X: Matrix) -> NDArray:
return self.classes[self.joint_log_likelihood(X).argmax(axis=1)]
def log_proba(self, X: Matrix) -> Matrix:
"""Normalized log posteriors. Correctly computed, and still not calibrated."""
joint = self.joint_log_likelihood(X)
# log-sum-exp with the max factored out; the naive version overflows.
shift = joint.max(axis=1, keepdims=True)
return joint - (shift + np.log(np.exp(joint - shift).sum(axis=1, keepdims=True)))
def fit(X: Matrix, y: NDArray, alpha: float = 1.0) -> MultinomialNB:
"""Fit by counting. Raises ValueError on malformed input."""
if X.ndim != 2:
raise ValueError(f"X must be 2-D, got shape {X.shape}")
if X.shape[0] != y.shape[0]:
raise ValueError(f"X has {X.shape[0]} rows but y has {y.shape[0]}")
if alpha <= 0.0:
raise ValueError(f"alpha must be positive; alpha=0 makes unseen tokens fatal")
if (X < 0).any():
raise ValueError("multinomial NB expects non-negative counts")
classes, encoded = np.unique(y, return_inverse=True)
n_classes = classes.size
vocabulary = X.shape[1]
# One-hot the labels and the entire count table is a single matrix product:
# indicator.T @ X sums each class's rows in one pass.
indicator = np.zeros((X.shape[0], n_classes), dtype=np.float64)
indicator[np.arange(X.shape[0]), encoded] = 1.0
token_counts = indicator.T @ X # (C, V)
class_counts = indicator.sum(axis=0) # (C,)
smoothed = token_counts + alpha
log_likelihood = np.log(smoothed) - np.log(smoothed.sum(axis=1, keepdims=True))
log_prior = np.log(class_counts) - np.log(class_counts.sum())
return MultinomialNB(
classes=classes,
log_prior=log_prior,
log_likelihood=log_likelihood,
alpha=alpha,
)What changed: The dict-of-lists count table becomes two arrays, and the per-document accumulation loop becomes a single matrix product against a one-hot label indicator — the counting step is a GEMM, which is not an optimization so much as the honest way to write a sum over groups. Scoring becomes one product too, since the sum of per-token log-probabilities is exactly a dot product with the count vector. Around that, the idiomatic changes: a frozen dataclass instead of a pair of dicts, validated input including the alpha > 0 contract whose violation produces negative infinities much later, and a log-sum-exp normalization that does not overflow the way a direct exp would.
O(n*V) to fit and O(n*C*V) to score as dense products — correct, and wasteful on text, where X is 99.9% zeros.
Faster, and the justification for each change.
"""Multinomial Naive Bayes - sparse counts, one SpMM, batched scoring."""
import numpy as np
from numpy.typing import NDArray
from scipy import sparse
Vector = NDArray[np.float64]
Matrix = NDArray[np.float64]
class SparseMultinomialNB:
"""The same model, taking sparsity seriously.
On text, X is a document-term matrix that is over 99% zeros. The dense
formulation touches every vocabulary entry for every document; the sparse
one touches only the tokens actually present, which is the difference
between O(n * V) and O(nnz). Nothing about the model changes - only which
zeros are visited.
"""
def __init__(self, alpha: float = 1.0) -> None:
if alpha <= 0.0:
raise ValueError("alpha must be positive")
self._alpha = alpha
self._classes: NDArray | None = None
self._log_prior: Vector | None = None
self._log_likelihood: Matrix | None = None
def fit(self, X: sparse.csr_matrix, y: NDArray) -> "SparseMultinomialNB":
if X.shape[0] != y.shape[0]:
raise ValueError(f"X has {X.shape[0]} rows but y has {y.shape[0]}")
classes, encoded = np.unique(y, return_inverse=True)
n, vocabulary = X.shape
# The one-hot indicator is itself sparse, so the count table is one
# sparse-times-sparse product rather than a dense C x n intermediate.
indicator = sparse.csr_matrix(
(np.ones(n), (encoded, np.arange(n))), shape=(classes.size, n)
)
token_counts = np.asarray((indicator @ X).todense(), dtype=np.float64)
smoothed = token_counts + self._alpha
# In place: the (C, V) table is the largest array in the model, and on
# a large vocabulary each temporary copy is real memory pressure.
np.log(smoothed, out=smoothed)
totals = np.log(np.exp(smoothed).sum(axis=1, keepdims=True))
smoothed -= totals
class_counts = np.bincount(encoded, minlength=classes.size).astype(np.float64)
self._classes = classes
self._log_prior = np.log(class_counts) - np.log(n)
# C-contiguous so the SpMM below reads each class row sequentially.
self._log_likelihood = np.ascontiguousarray(smoothed)
return self
def predict(self, X: sparse.csr_matrix, batch: int = 8192) -> NDArray:
if self._log_likelihood is None or self._classes is None:
raise RuntimeError("predict called before fit")
out = np.empty(X.shape[0], dtype=self._classes.dtype) # allocated once
# Batching bounds the (batch, C) score block and keeps the class table
# resident in cache across many documents.
for start in range(0, X.shape[0], batch):
stop = min(start + batch, X.shape[0])
joint = X[start:stop] @ self._log_likelihood.T # SpMM
joint += self._log_prior
out[start:stop] = self._classes[joint.argmax(axis=1)]
return outWhat changed: The arithmetic is unchanged; what changes is which entries are visited. A document-term matrix is over 99% zeros, and the dense formulation spends nearly all of its work multiplying them — so both the count table and the scoring product move to sparse operations that touch only present tokens. The count table also stops materializing a dense one-hot indicator, which on a large corpus was the single biggest allocation in the fit. Scoring is batched so the class log-probability table stays cache-resident across many documents, and the log-probability normalization runs in place because on a 500,000-word vocabulary each temporary copy of that table is real memory.
Vectorize to NumPy/BLAS (@, np.dot, einsum)
Both counting and scoring are matrix products — the sparse ones dispatch to a sparse kernel that iterates nonzeros directly rather than scanning the vocabulary.
Trade-off: Sparse kernels are memory-bound and irregular, so on genuinely dense features they are slower than the dense GEMM they replaced; this is the right choice for text and the wrong one for a dense tabular matrix.
Pre-allocate output arrays and use in-place operations
The prediction output is allocated once for the whole corpus, and the log-probability table is normalized in place instead of through three full-size temporaries.
Trade-off: The in-place normalization destroys the intermediate counts, so anything that wanted to inspect raw counts afterwards has to keep its own copy — and the operation order can no longer be rearranged safely.
Ensure contiguous memory layout and a single dtype
The class log-probability table is stored C-contiguous in float64 so the sparse product reads each class row sequentially instead of striding.
Trade-off: Forces a copy when the table was produced in another layout, and float64 doubles the resident size of a table that on a large vocabulary is already the dominant memory cost.
O(nnz) to fit and O(nnz * C) to score, touching only present tokens. Illustrative, not a measured benchmark.