详解贝叶斯(二):朴素贝叶斯分类器的实现

详解贝叶斯(二):朴素贝叶斯分类器的实现

上一篇《详解贝叶斯(一):贝叶斯概率》里,我们用贝叶斯公式反推了一枚骰子的面数。这一篇把公式真正落到代码上——从零实现一个朴素贝叶斯分类器,并用周志华老师《机器学习》里的西瓜数据集把它训练出来。

一、从公式到分类器

朴素贝叶斯分类器(Naive Bayes Classifier)是一种基于贝叶斯定理的概率统计分类算法,它被广泛用于文本分类、垃圾邮件过滤、情感分析等领域。朴素贝叶斯分类器的"朴素"来源于它对特征之间独立性的假设,即在给定类别的情况下,特征之间是相互独立的。尽管这个假设在实际情况中往往不成立,但朴素贝叶斯在实践中却往往表现得很好。

先回顾一下贝叶斯公式:

P(A|B) = \frac{P(B|A)P(A)}{P(B)}

放到分类任务里,符号就有了具体含义:

  • 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}}}

五、训练

接下来需要完成最核心的训练函数。在编写训练函数前,再回顾一下贝叶斯公式:

P(A|B) = \frac{P(B|A)P(A)}{P(B)}

在模型中,self.prior_prob 其实就是先验概率 P(A),而似然度 P(B|A) 就是 self.condi_prob。推理的过程就是已知特征 B 的情况下,求标签 A 的概率。

取一条数据代入贝叶斯公式就是:

P(好瓜|青绿,蜷缩,浊响,清晰,凹陷,硬滑) = \frac{P(青绿,蜷缩,浊响,清晰,凹陷,硬滑|好瓜)P(好瓜)}{P(青绿,蜷缩,浊响,清晰,凹陷,硬滑)} \\ P(坏瓜|青绿,蜷缩,浊响,清晰,凹陷,硬滑) = \frac{P(青绿,蜷缩,浊响,清晰,凹陷,硬滑|坏瓜)P(坏瓜)}{P(青绿,蜷缩,浊响,清晰,凹陷,硬滑)}

对于特征为 [青绿,蜷缩,浊响,清晰,凹陷,硬滑] 时,计算好瓜还是坏瓜的概率时分母是一样的,因此可以直接不计算分母,最后直接比较分子的大小输出结果。

公式展开后,P(青绿,蜷缩,浊响,清晰,凹陷,硬滑|好瓜)P(好瓜) 都应该怎么计算呢?事实是无法计算!虽然无法计算,但是当有很多样本可以观察时,可以统计出这两者的近似值,这个过程就是机器学习的过程。

其中 P(好瓜)P(坏瓜) 只要统计训练数据中对应标签出现的比例即可。而 P(青绿,蜷缩,浊响,清晰,凹陷,硬滑|好瓜) 的计算就要用到朴素贝叶斯的朴素性假设,即在给定类别的情况下,特征之间是相互独立的。既然每个特征可以看作是一个个独立事件,那整体的概率就等于一个个独立事件概率的乘积:

P(青绿,蜷缩,浊响,清晰,凹陷,硬滑|好瓜) = P(青绿|好瓜) \times P(蜷缩|好瓜) \times P(浊响|好瓜) \times P(清晰|好瓜) \times P(凹陷|好瓜) \times 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 章 贝叶斯分类器)

详解贝叶斯(二):朴素贝叶斯分类器的实现
https://www.xiaos.tech/archives/xiang-jie-bei-ye-si-er-pu-su-bei-ye-si-fen-lei-qi
作者
xiaoski
发布于
2026年09月11日
许可协议