详解贝叶斯(二):朴素贝叶斯分类器的实现
详解贝叶斯(二):朴素贝叶斯分类器的实现
上一篇《详解贝叶斯(一):贝叶斯概率》里,我们用贝叶斯公式反推了一枚骰子的面数。这一篇把公式真正落到代码上——从零实现一个朴素贝叶斯分类器,并用周志华老师《机器学习》里的西瓜数据集把它训练出来。
一、从公式到分类器
朴素贝叶斯分类器(Naive Bayes Classifier)是一种基于贝叶斯定理的概率统计分类算法,它被广泛用于文本分类、垃圾邮件过滤、情感分析等领域。朴素贝叶斯分类器的"朴素"来源于它对特征之间独立性的假设,即在给定类别的情况下,特征之间是相互独立的。尽管这个假设在实际情况中往往不成立,但朴素贝叶斯在实践中却往往表现得很好。
先回顾一下贝叶斯公式:
放到分类任务里,符号就有了具体含义:
- A:样本的类别,比如"好瓜"/"坏瓜";
- B:样本的特征向量,比如"青绿、蜷缩、浊响…";
- P(A):先验概率,不看特征时各类别出现的可能性;
- P(B|A):似然度,某个类别下出现这组特征的概率;
- P(A|B):后验概率,看到特征后判定类别的依据,也是我们要比较的对象。
一次完整的判别分成"训练"和"预测"两段:

留意图中最后一句:分母 P(B) 对所有类别都相同,在比较类别时可以直接省略,这能让实现简单不少。
二、数据集
我们以周志华老师的西瓜书中的西瓜数据集为例,使用 Python 实现一个朴素贝叶斯分类器。
| 色泽 | 根蒂 | 敲击 | 纹理 | 脐部 | 触感 | 好坏 |
|---|---|---|---|---|---|---|
| 青绿 | 蜷缩 | 浊响 | 清晰 | 凹陷 | 硬滑 | 好瓜 |
| 乌黑 | 蜷缩 | 沉闷 | 清晰 | 凹陷 | 硬滑 | 好瓜 |
| 乌黑 | 蜷缩 | 浊响 | 清晰 | 凹陷 | 硬滑 | 好瓜 |
| 青绿 | 蜷缩 | 沉闷 | 清晰 | 凹陷 | 硬滑 | 好瓜 |
| 浅白 | 蜷缩 | 浊响 | 清晰 | 凹陷 | 硬滑 | 好瓜 |
| 青绿 | 稍蜷 | 浊响 | 清晰 | 稍凹 | 软粘 | 好瓜 |
| 乌黑 | 稍蜷 | 浊响 | 稍糊 | 稍凹 | 软粘 | 好瓜 |
| 乌黑 | 稍蜷 | 浊响 | 清晰 | 稍凹 | 硬滑 | 好瓜 |
| 乌黑 | 稍蜷 | 沉闷 | 稍糊 | 稍凹 | 硬滑 | 坏瓜 |
| 青绿 | 硬挺 | 清脆 | 清晰 | 平坦 | 软粘 | 坏瓜 |
| 浅白 | 硬挺 | 清脆 | 模糊 | 平坦 | 硬滑 | 坏瓜 |
| 浅白 | 蜷缩 | 浊响 | 模糊 | 平坦 | 软粘 | 坏瓜 |
| 青绿 | 稍蜷 | 浊响 | 稍糊 | 凹陷 | 硬滑 | 坏瓜 |
| 浅白 | 稍蜷 | 沉闷 | 稍糊 | 凹陷 | 硬滑 | 坏瓜 |
| 乌黑 | 稍蜷 | 浊响 | 清晰 | 稍凹 | 软粘 | 坏瓜 |
| 浅白 | 蜷缩 | 浊响 | 模糊 | 平坦 | 硬滑 | 坏瓜 |
| 青绿 | 蜷缩 | 沉闷 | 稍糊 | 稍凹 | 硬滑 | 坏瓜 |
数据集里西瓜的特征有色泽、根蒂、敲击、纹理、脐部、触感等六个维度,每个维度都有三种或者两种取值,最后的标签有好瓜和坏瓜两种情况。各特征取值在两个类别下的样本数分布如下:

从图里能直接读出几个"信息量大"的特征:纹理为"清晰"的 9 条样本里有 7 条是好瓜;而根蒂为"硬挺"的 2 条样本则全部是坏瓜。
定义数据集:
dataset = np.array([
# 色泽,根蒂,敲击,纹理,脐部,触感, 好坏
['青绿', '蜷缩', '浊响', '清晰', '凹陷', '硬滑', '好瓜'],
['乌黑', '蜷缩', '沉闷', '清晰', '凹陷', '硬滑', '好瓜'],
['乌黑', '蜷缩', '浊响', '清晰', '凹陷', '硬滑', '好瓜'],
['青绿', '蜷缩', '沉闷', '清晰', '凹陷', '硬滑', '好瓜'],
['浅白', '蜷缩', '浊响', '清晰', '凹陷', '硬滑', '好瓜'],
['青绿', '稍蜷', '浊响', '清晰', '稍凹', '软粘', '好瓜'],
['乌黑', '稍蜷', '浊响', '稍糊', '稍凹', '软粘', '好瓜'],
['乌黑', '稍蜷', '浊响', '清晰', '稍凹', '硬滑', '好瓜'],
['乌黑', '稍蜷', '沉闷', '稍糊', '稍凹', '硬滑', '坏瓜'],
['青绿', '硬挺', '清脆', '清晰', '平坦', '软粘', '坏瓜'],
['浅白', '硬挺', '清脆', '模糊', '平坦', '硬滑', '坏瓜'],
['浅白', '蜷缩', '浊响', '模糊', '平坦', '软粘', '坏瓜'],
['青绿', '稍蜷', '浊响', '稍糊', '凹陷', '硬滑', '坏瓜'],
['浅白', '稍蜷', '沉闷', '稍糊', '凹陷', '硬滑', '坏瓜'],
['乌黑', '稍蜷', '浊响', '清晰', '稍凹', '软粘', '坏瓜'],
['浅白', '蜷缩', '浊响', '模糊', '平坦', '硬滑', '坏瓜'],
['青绿', '蜷缩', '沉闷', '稍糊', '稍凹', '硬滑', '坏瓜']
])
features = {
'色泽': ['青绿', '乌黑', '浅白'],
'根蒂': ['蜷缩', '稍蜷', '硬挺'],
'敲击': ['浊响', '沉闷', '清脆'],
'纹理': ['清晰', '稍糊', '模糊'],
'脐部': ['凹陷', '稍凹', '平坦'],
'触感': ['硬滑', '软粘']
}
labels = ["好瓜", "坏瓜"]
将特征与标签拆分开来:
X = dataset[:, :6] # 前6列为特征
Y = dataset[:, 6] # 最后一列为标签
三、模型设计
现在我们构造一个 Bayes 类,这个类的实例代表一个朴素贝叶斯分类器,其设计如下:
class Bayes:
def __init__(self, features: dict, labels: list) -> None:
pass # 初始化模型
def bayes_prob(self, arr):
pass # 计算贝叶斯概率
def train(self, X, Y):
pass # 训练模型
def predict(self, X):
pass # 模型推理
def show_params(self) -> str:
pass # 辅助函数,用来观察模型内部
四、初始化
首先实现构造函数,这个函数里要完成模型必要参数的初始化。模型需要一些什么参数呢?首先特征的维度、标签的种类肯定需要保存;贝叶斯公式里的核心——先验概率和似然度也需要保存。考虑到方方面面,最后构造函数设计如下:
def __init__(self, features: dict, labels: list) -> None:
self.features = features # 保存特征的维度、取值范围
self.labels = labels # 保存标签的类别
self.feature_classes = list(self.features.keys()) # 为了方便
self.condi_prob = {} # 保存条件概率/似然度的字典
self.prior_prob = {} # 保存先验概率的字典
# 保存计算概率用的训练样本统计信息,具体保存形式如下:
# {
# '好瓜': 8,
# '坏瓜': 9,
# '坏瓜.敲击.沉闷': 3,
# '好瓜.根蒂.稍蜷': 3,
# ...
# }
self._statistic = {}
self._samples = 0 # 计数
for i in self.labels:
# 先验概率的初始值认为各标签是等概率的
self.prior_prob[i] = 1 / len(self.labels)
# 每个标签初始化一个空的字典储存各标签下的条件概率
self.condi_prob[i] = {}
# 每个标签下的样本数清零
self._statistic[i] = 0
for j in self.feature_classes:
# i标签下j维度的条件概率初始化
self.condi_prob[i][j] = {}
# 遍历j维度的取值范围
for k in self.features[j]:
# 初始化时假设j维度的每个特征等概率出现
self.condi_prob[i][j][k] = 1 / len(self.features[j])
# 'i.j.k'储存在i标签下,j维度的k取值出现的次数,初始值为0
self._statistic['.'.join([i, j, k])] = 0
为了方便观察内部情况,设置一个辅助函数打印内部参数:
def show_params(self) -> str:
pprint(self.prior_prob)
pprint(self.condi_prob, sort_dicts=False)
# pprint(self._statistic)
初始化完成后打印一下,可以看到模型此刻处于"什么都不知道"的状态——先验各占一半,每个特征取值在各类别下等概率:
{'坏瓜': 0.5, '好瓜': 0.5}
{'坏瓜': {'敲击': {'沉闷': 0.3333333333333333,
'浊响': 0.3333333333333333,
'清脆': 0.3333333333333333},
'根蒂': {'硬挺': 0.3333333333333333,
'稍蜷': 0.3333333333333333,
'蜷缩': 0.3333333333333333},
'纹理': {'模糊': 0.3333333333333333,
'清晰': 0.3333333333333333,
'稍糊': 0.3333333333333333},
'脐部': {'凹陷': 0.3333333333333333,
'平坦': 0.3333333333333333,
'稍凹': 0.3333333333333333},
'色泽': {'乌黑': 0.3333333333333333,
'浅白': 0.3333333333333333,
'青绿': 0.3333333333333333},
'触感': {'硬滑': 0.5, '软粘': 0.5}},
'好瓜': {'敲击': {'沉闷': 0.3333333333333333,
'浊响': 0.3333333333333333,
'清脆': 0.3333333333333333},
'根蒂': {'硬挺': 0.3333333333333333,
'稍蜷': 0.3333333333333333,
'蜷缩': 0.3333333333333333},
'纹理': {'模糊': 0.3333333333333333,
'清晰': 0.3333333333333333,
'稍糊': 0.3333333333333333},
'脐部': {'凹陷': 0.3333333333333333,
'平坦': 0.3333333333333333,
'稍凹': 0.3333333333333333},
'色泽': {'乌黑': 0.3333333333333333,
'浅白': 0.3333333333333333,
'青绿': 0.3333333333333333},
'触感': {'硬滑': 0.5, '软粘': 0.5}}}
五、训练
接下来需要完成最核心的训练函数。在编写训练函数前,再回顾一下贝叶斯公式:
在模型中,self.prior_prob 其实就是先验概率 P(A),而似然度 P(B|A) 就是 self.condi_prob。推理的过程就是已知特征 B 的情况下,求标签 A 的概率。
取一条数据代入贝叶斯公式就是:
对于特征为 [青绿,蜷缩,浊响,清晰,凹陷,硬滑] 时,计算好瓜还是坏瓜的概率时分母是一样的,因此可以直接不计算分母,最后直接比较分子的大小输出结果。
公式展开后,P(青绿,蜷缩,浊响,清晰,凹陷,硬滑|好瓜)、P(好瓜) 都应该怎么计算呢?事实是无法计算!虽然无法计算,但是当有很多样本可以观察时,可以统计出这两者的近似值,这个过程就是机器学习的过程。
其中 P(好瓜) 和 P(坏瓜) 只要统计训练数据中对应标签出现的比例即可。而 P(青绿,蜷缩,浊响,清晰,凹陷,硬滑|好瓜) 的计算就要用到朴素贝叶斯的朴素性假设,即在给定类别的情况下,特征之间是相互独立的。既然每个特征可以看作是一个个独立事件,那整体的概率就等于一个个独立事件概率的乘积:
而 P(青绿|好瓜) 的概率只要简单统计所有好瓜样本中,色泽为青绿的样本数量即可。
拉普拉斯平滑
在实际操作中,可能会出现某个特征的某个取值一次都没出现的情况,比如训练数据里"好瓜"的根蒂从来没有取过"硬挺"。这会导致其中一项特征的条件概率变为 0,乘起来后全部为 0,导致计算出现异常。

为了解决这个问题可以引入拉普拉斯平滑。简单的讲,也就是分子直接加上 \lambda,分母加上 \lambda \times N,这里 N 是特征可取值的个数,\lambda 是拉普拉斯平滑系数,一般取 1。这种方法避免了零概率问题,在数据集较大时,也几乎不会对准确度产生影响。
经过上面的分析可以写出训练函数的代码如下:
def train(self, X, Y):
rows, cols = X.shape
for i in range(rows):
# 好瓜、坏瓜计数
self._statistic[Y[i]] += 1
for j in range(cols):
# 这里Y[i]为好瓜坏瓜
# self.feature_classes[j]为色泽、根蒂等特征维度
# X[i, j]就是上面的某个特征维度的具体取值
# 比如 self._statistic['好瓜.色泽.青绿'] += 1
self._statistic['.'.join(
[Y[i], self.feature_classes[j], X[i, j]])] += 1
# 总的样本数量
self._samples += 1
# 统计完了开始算概率
for i in self.labels:
# 好瓜坏瓜的数量 / 总的样本的数量
# 这里分子加了1,分母加了2(len(self.labels))是因为拉普拉斯平滑
self.prior_prob[i] = (self._statistic[i] + 1) / (self._samples + len(self.labels))
for j in self.feature_classes:
for k in self.features[j]:
# 分子:好瓜或坏瓜里面,色泽、根蒂等特征维度取值为k的样本数量
# 分母:好瓜或者坏瓜的数量
# 分子分母额外加的数是因为拉普拉斯平滑
self.condi_prob[i][j][k] = (self._statistic['.'.join([i, j, k])] + 1) / (self._statistic[i] + len(self.features[j]))
用西瓜书中的数据集训练一下,再调用打印内部参数的函数,可以看到参数已经从"均匀分布"被数据"掰"成了有倾向的概率:
{'坏瓜': 0.5263157894736842, '好瓜': 0.47368421052631576}
{'好瓜': {'色泽': {'青绿': 0.36363636363636365,
'乌黑': 0.45454545454545453,
'浅白': 0.18181818181818182},
'根蒂': {'蜷缩': 0.5454545454545454,
'稍蜷': 0.36363636363636365,
'硬挺': 0.09090909090909091},
'敲击': {'浊响': 0.6363636363636364,
'沉闷': 0.2727272727272727,
'清脆': 0.09090909090909091},
'纹理': {'清晰': 0.7272727272727273,
'稍糊': 0.18181818181818182,
'模糊': 0.09090909090909091},
'脐部': {'凹陷': 0.5454545454545454,
'稍凹': 0.36363636363636365,
'平坦': 0.09090909090909091},
'触感': {'硬滑': 0.7, '软粘': 0.3}},
'坏瓜': {'色泽': {'青绿': 0.3333333333333333, '乌黑': 0.25, '浅白': 0.4166666666666667},
'根蒂': {'蜷缩': 0.3333333333333333, '稍蜷': 0.4166666666666667, '硬挺': 0.25},
'敲击': {'浊响': 0.4166666666666667, '沉闷': 0.3333333333333333, '清脆': 0.25},
'纹理': {'清晰': 0.25, '稍糊': 0.4166666666666667, '模糊': 0.3333333333333333},
'脐部': {'凹陷': 0.25, '稍凹': 0.3333333333333333, '平坦': 0.4166666666666667},
'触感': {'硬滑': 0.6363636363636364, '软粘': 0.36363636363636365}}}
六、推理与验证
接下来是预测函数,前面已经展开了贝叶斯公式,可以看出计算时只要将各个维度的概率相乘即可,而贝叶斯公式分数线下方的内容完全可以忽略不计算。
# 输入一个特征,输出每种标签的概率
def bayes_prob(self, arr):
plist = []
for i in self.labels:
idx = 0
# 先验概率P(A)
p = self.prior_prob[i]
# 各维度的条件概率连乘
for j in self.feature_classes:
p *= self.condi_prob[i][j][arr[idx]]
idx += 1
# 算完一个标签的概率
plist.append(p)
return plist
# 返回预测标签
def predict(self, X):
rows, cols = X.shape
res = []
for i in range(rows):
# 对每个样本计算各标签概率
y = self.bayes_prob(X[i])
res.append(y)
# 找出概率最大的标签的索引值
res = np.argmax(res, axis=1)
# 转换为标签
res = [self.labels[x] for x in res]
return np.array(res)
直接拿训练的数据去跑预测,结果如下:
原始:['好瓜' '好瓜' '好瓜' '好瓜' '好瓜' '好瓜' '好瓜' '好瓜' '坏瓜' '坏瓜' '坏瓜' '坏瓜' '坏瓜' '坏瓜'
'坏瓜' '坏瓜' '坏瓜']
预测:['好瓜' '好瓜' '好瓜' '好瓜' '好瓜' '好瓜' '坏瓜' '好瓜' '坏瓜' '坏瓜' '坏瓜' '坏瓜' '好瓜' '坏瓜'
'好瓜' '坏瓜' '坏瓜']
准确率:0.8235294117647058
单独看那个"青绿·蜷缩·浊响·清晰·凹陷·硬滑"的样本,两个类别的得分归一化后是这样的:

整份训练集的回判情况则一目了然:

17 条样本对了 14 条,准确率 82.35%,错分的是第 7、13、15 条。需要说明的是,这里是用训练集自己回判,属于"自评",和真实的泛化性能不是一回事;样本量这么小,几个错分也很正常——第 7 条"乌黑·稍蜷·浊响·稍糊·稍凹·软粘"本身就是个和坏瓜长得很像的好瓜。
七、完整代码
from pprint import pprint
import numpy as np
dataset = np.array([
# 色泽,根蒂,敲击,纹理,脐部,触感, 好坏
['青绿', '蜷缩', '浊响', '清晰', '凹陷', '硬滑', '好瓜'],
['乌黑', '蜷缩', '沉闷', '清晰', '凹陷', '硬滑', '好瓜'],
['乌黑', '蜷缩', '浊响', '清晰', '凹陷', '硬滑', '好瓜'],
['青绿', '蜷缩', '沉闷', '清晰', '凹陷', '硬滑', '好瓜'],
['浅白', '蜷缩', '浊响', '清晰', '凹陷', '硬滑', '好瓜'],
['青绿', '稍蜷', '浊响', '清晰', '稍凹', '软粘', '好瓜'],
['乌黑', '稍蜷', '浊响', '稍糊', '稍凹', '软粘', '好瓜'],
['乌黑', '稍蜷', '浊响', '清晰', '稍凹', '硬滑', '好瓜'],
['乌黑', '稍蜷', '沉闷', '稍糊', '稍凹', '硬滑', '坏瓜'],
['青绿', '硬挺', '清脆', '清晰', '平坦', '软粘', '坏瓜'],
['浅白', '硬挺', '清脆', '模糊', '平坦', '硬滑', '坏瓜'],
['浅白', '蜷缩', '浊响', '模糊', '平坦', '软粘', '坏瓜'],
['青绿', '稍蜷', '浊响', '稍糊', '凹陷', '硬滑', '坏瓜'],
['浅白', '稍蜷', '沉闷', '稍糊', '凹陷', '硬滑', '坏瓜'],
['乌黑', '稍蜷', '浊响', '清晰', '稍凹', '软粘', '坏瓜'],
['浅白', '蜷缩', '浊响', '模糊', '平坦', '硬滑', '坏瓜'],
['青绿', '蜷缩', '沉闷', '稍糊', '稍凹', '硬滑', '坏瓜']
])
features = {
'色泽': ['青绿', '乌黑', '浅白'],
'根蒂': ['蜷缩', '稍蜷', '硬挺'],
'敲击': ['浊响', '沉闷', '清脆'],
'纹理': ['清晰', '稍糊', '模糊'],
'脐部': ['凹陷', '稍凹', '平坦'],
'触感': ['硬滑', '软粘']
}
labels = ["好瓜", "坏瓜"]
class Bayes:
def __init__(self, features: dict, labels: list) -> None:
self.features = features
self.labels = labels
self.feature_classes = list(self.features.keys())
self.condi_prob = {}
self.prior_prob = {}
self._statistic = {}
self._samples = 0
for i in self.labels:
self.prior_prob[i] = 1 / len(self.labels)
self.condi_prob[i] = {}
self._statistic[i] = 0
for j in self.feature_classes:
self.condi_prob[i][j] = {}
for k in self.features[j]:
self.condi_prob[i][j][k] = 1 / len(self.features[j])
self._statistic['.'.join([i, j, k])] = 0
def bayes_prob(self, arr):
plist = []
for i in self.labels:
idx = 0
p = self.prior_prob[i]
for j in self.feature_classes:
p *= self.condi_prob[i][j][arr[idx]]
idx += 1
plist.append(p)
return plist
def train(self, X, Y):
rows, cols = X.shape
for i in range(rows):
self._statistic[Y[i]] += 1
for j in range(cols):
self._statistic['.'.join(
[Y[i], self.feature_classes[j], X[i, j]])] += 1
self._samples += 1
for i in self.labels:
self.prior_prob[i] = (self._statistic[i] + 1) / (self._samples + len(self.labels))
for j in self.feature_classes:
for k in self.features[j]:
self.condi_prob[i][j][k] = (self._statistic['.'.join([i, j, k])] + 1) / (
self._statistic[i] + len(self.features[j]))
def predict(self, X):
rows, cols = X.shape
res = []
for i in range(rows):
y = self.bayes_prob(X[i])
res.append(y)
res = np.argmax(res, axis=1)
res = [self.labels[x] for x in res]
return np.array(res)
def show_params(self) -> str:
pprint(self.prior_prob)
pprint(self.condi_prob, sort_dicts=False)
# pprint(self._statistic)
if __name__ == "__main__":
X = dataset[:, :6]
Y = dataset[:, 6]
clf = Bayes(features, labels)
clf.show_params()
clf.train(X, Y)
clf.show_params()
Yp = clf.predict(X)
print(f"原始:{Y}\n预测:{Yp}\n准确率:{np.sum(Y == Yp) / len(Y)}")
八、小结
- 朴素贝叶斯的核心只有两步:训练时统计频次、算出先验与似然;预测时逐类连乘、取最大值。
- 分母 P(B) 在比较类别时可以省略,实现上少算一坨东西。
- "朴素"的独立性假设虽然粗糙,但在小样本、高维(比如文本)的场景下依然很能打,这也正是它在垃圾邮件过滤这类任务里长盛不衰的原因。
- 拉普拉斯平滑几乎是必加项,否则一个从未出现过的取值就能让整条样本"失忆"。
参考
- 周志华.《机器学习》[M]. 清华大学出版社, 2016.(第 7 章 贝叶斯分类器)