第 48 课 · 进阶 · 19 分钟

Python 朴素贝叶斯文本分类

用计数、先验和平滑给短文本分类。

朴素贝叶斯先验拉普拉斯平滑对数概率

投诉和夸奖用的词不一样,数一数就够做第一个分类器。朴素贝叶斯假设了一个并不存在的独立。它为什么还是有用?

朴素贝叶斯是一个由计数构成的快速文本分类基线。它假设在给定类别后各个词相互独立,这个假设通常不成立,但模型仍然有用。本课把计数、先验、平滑和对数计分全部写清楚。 你还会看到平滑为什么对未登录词重要,以及长文档为什么得到更负的分数。

按类别统计词元

训练从带标签的文档开始。对每个类别,统计每个词元出现了多少次。这些计数就是分类器使用的原始证据,标签本身不能作为特征混进词表。

docs = [
    ("good movie", "pos"),
    ("great film", "pos"),
    ("bad movie", "neg"),
    ("boring film", "neg"),
]
counts = {}
for text, label in docs:
    counts.setdefault(label, {})
    for word in text.split():
        counts[label][word] = counts[label].get(word, 0) + 1
print(counts["pos"]["movie"])
print(counts["neg"]["bad"])

每个类别各有一张计数表

先验与平滑似然

先验是某个类别在训练文档中的占比。某个词在该类别下的似然等于“该词计数加一”,除以“类别总词数加词表大小”。拉普拉斯平滑可以避免没见过的词把概率变成零。

docs = [
    ("good movie", "pos"),
    ("great film", "pos"),
    ("bad movie", "neg"),
    ("boring film", "neg"),
]
vocab = sorted({word for text, _ in docs for word in text.split()})
counts = {}
for text, label in docs:
    counts.setdefault(label, {})
    for word in text.split():
        counts[label][word] = counts[label].get(word, 0) + 1
for label in sorted(counts):
    total = sum(counts[label].values())
    probability = (counts[label].get("great", 0) + 1) / (total + len(vocab))
    print(label, round(probability, 3))

先加一,再归一化

在对数空间计分

很多小概率连乘可能下溢成零。改为把对数相加,数值更安全,排序也不变。对数概率是负数,数值越大表示该类别越可能。

import math

print(round(math.log(0.5 * 0.2), 3))
print(round(math.log(0.5) + math.log(0.2), 3))

排序相同,算术更安全

选出最可能的类别

对新文档,把对数先验与每个词的平滑对数似然相加,选择分数更大的类别。要规定平局规则,并且只在未参与训练的新文档上评估分类器。

import math

docs = [
    ("good movie", "pos"),
    ("great film", "pos"),
    ("bad movie", "neg"),
    ("boring film", "neg"),
]
vocab = sorted({word for text, _ in docs for word in text.split()})
counts = {}
class_docs = {}
for text, label in docs:
    class_docs[label] = class_docs.get(label, 0) + 1
    counts.setdefault(label, {})
    for word in text.split():
        counts[label][word] = counts[label].get(word, 0) + 1

def predict(text):
    scores = {}
    for label in counts:
        total = sum(counts[label].values())
        score = math.log(class_docs[label] / len(docs))
        for word in text.split():
            score += math.log((counts[label].get(word, 0) + 1) / (total + len(vocab)))
        scores[label] = score
    return max(scores, key=scores.get)

print(predict("great film"))

对数分数更大的类别

平滑让未登录词的分数保持有限

不做平滑时,未登录词的似然为零,它的对数没有定义。加 alpha 平滑给词表中的每个词一个小的非零计数。alpha 是一种选择:太小仍然脆弱,太大则冲淡证据。

import math

count = 0
total = 2
vocab_size = 3
for alpha in [0, 1]:
    probability = (count + alpha) / (total + alpha * vocab_size)
    print(alpha, probability, round(math.log(probability), 3) if probability else "undefined")

零计数需要一条策略

越长的文档,总分越负

朴素贝叶斯分数会为每个词元累加一个对数似然。因此较长文档即使平均证据相似,总分也往往更负。在把分数当成概率之前,应比较每词元平均对数似然,或在验证集上校准。

import math

log_prior = math.log(0.5)
log_word = math.log(0.8)

def score(tokens):
    return log_prior + len(tokens) * log_word

for tokens in [["good"], ["good", "good"], ["good", "good", "good"]]:
    print(len(tokens), round(score(tokens), 2), round((score(tokens) - log_prior) / len(tokens), 2))

总分与每词元分数

本课要点

  • 朴素贝叶斯按类别汇总词元计数来学习。
  • 先验反映每个类别在训练数据中的常见程度。
  • 拉普拉斯平滑防止没见过的词把分数归零。
  • 概率连乘改为对数相加,可以避免下溢。
  • 独立假设是简化选择,不是事实。
  • 平滑处理未登录词;文档长度会改变对数分数的尺度。

常见错误

  • 把标签本身当成词元特征。
  • 只对当前文档出现的词做平滑。
  • 在训练文档本身上报告准确率。
  • 直接比较长度差异很大的文档的朴素贝叶斯原始分数。
轮到你

使用四篇训练文档,计算在 neg 类别下出现 'bad' 的平滑概率,保留三位小数。

动手试试

改改变量、先预测输出,再运行代码。第一次点击“运行代码”时会下载 Python 运行时,可能需要几秒钟。

浏览器内 Python本地运行 · 无需账号
首次运行会从 CDN 下载 Pyodide。修改后点击“运行代码”。

练习本课

12 道题,作答后立即看到解释。

查看 12 道题答案开始练习
← 梯度下降k-means 聚类 →