Classifying by counting words.
On this page
To sort 15,076 training posts from 20 newsgroups (topic forums on Usenet, an early internet discussion network) without reading them, count: a post about "openwindows" and "motif" goes to the pile that uses those words most. That is naive Bayes, Bayes rule from Probability and Bayes Rule applied to a document as a bag of words. Fitting is one counting pass over the training posts; after that, scoring a new post is one pass over its words.
A post loses its word order and becomes one row of counts, a column per vocabulary word. The 20 Newsgroups corpus (Lang 1995) gives 15,076 training posts, one row each, over the 10,000 most common words.
Score each topic, or class, by how common it is times the chance of every word in the post, then pick the highest score.
My naive Bayes notebook (in my Machine-Learning-Concepts repo on GitHub) fits this model on the 15,076 training posts. There posts in comp.windows.x, the newsgroup for the X Window System (the graphics system of Unix workstations), are 0.0513 of the training set, so its prior is 0.0513.
Each word chance is a count share; unsmoothed, it is the maximum likelihood estimate (see Maximum Likelihood and EM). Laplace smoothing adds a little to every count.
Multiplying a thousand word chances of 1e-4 underflows: the product prints 0.0, while the sum of logs stays usable.
import numpy as np
word_probs = np.full(1000, 1e-4)
product = np.prod(word_probs)
log_sum = np.log(word_probs).sum()
print(product, log_sum) # 0.0 and about -9210.34
This snippet fits naive Bayes on four toy posts by counting words per class, then scores a new post with the log score card in Section 3.
import numpy as np
alpha = 0.1
# rows are posts; columns count "port", "motif", "puck"
counts = np.array([[2, 1, 0], [1, 2, 0], [0, 0, 3], [0, 1, 2]])
classes = np.array([0, 0, 1, 1])
ids = np.unique(classes)
log_prior = np.log([np.mean(classes == c) for c in ids])
count_wc = np.array([counts[classes == c].sum(0) for c in ids])
smooth = count_wc + alpha
log_pwc = np.log(smooth / smooth.sum(1, keepdims=True))
doc = np.array([1, 1, 0])
score = log_prior + doc @ log_pwc.T
print(ids[score.argmax()]) # 0, windows.x
Test post 20 in my notebook is held out from training. It asks how to port programs written for OS/2, an old IBM operating system, to the X Window System, using OpenWindows or Motif, two X toolkits. The notebook puts it in comp.windows.x, its real newsgroup.
Figure 4. "port" lifts comp.windows.x to 0.38, "motif" to 0.91. Scored with 61 toy posts written for this page, not the notebook’s training split.
A scikit-learn pipeline turns text into counts and fits MultinomialNB; fit runs once on the training posts, predict scores each new post.
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.naive_bayes import MultinomialNB
from sklearn.pipeline import make_pipeline
train_docs = [
"port the motif applications to x",
"openwindows emulator for sun workstations",
"the goalie stopped the puck",
"power play goal in the third period",
]
train_labels = ["comp.windows.x", "comp.windows.x", "rec.sport.hockey", "rec.sport.hockey"]
model = make_pipeline(CountVectorizer(), MultinomialNB(alpha=0.1))
model.fit(train_docs, train_labels)
print(model.predict(["need help to port motif code"]))
CountVectorizer makes the count rows of Figure 2, and MultinomialNB holds α.CountVectorizer learns the vocabulary from training posts only. Naive Bayes needs raw word counts, so the pipeline has no scaler.
| step | when, on what, and its cost |
|---|---|
fit | Once, one counting pass over the training posts, linear in their length. |
predict | Per new post, one score per class, one term per word of the post. |
| state | Log priors (class_log_prior_) and log word chances per class (feature_log_prob_). |
| setting | raise it, lower it | default |
|---|---|---|
alpha, α in the smoothing card | Up pulls every word toward share 1/V; huge α leaves only the prior deciding. Down follows raw counts; α = 0 lets one unseen word zero a class. | 1.0 (my notebook: 0.1) |
fit_prior, P(c) in the score card | True learns class shares; False makes all priors equal. | True |
Defaults from the MultinomialNB docs; cost from Manning, Raghavan and Schütze (2008). Gotchas are in Section 7.
With no labels at all, k-means finds the clumps, each point joining its nearest center.