尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

从零实现朴素贝叶斯分类器:原理、代码与实战

从零实现朴素贝叶斯分类器:原理、代码与实战 1. 从“直觉”到“公式”贝叶斯分类器到底在做什么聊到机器学习分类很多人第一反应是那些复杂的神经网络或者需要大量调参的决策树、SVM。但如果你刚入门或者需要一个基线模型来快速验证想法我通常会建议你从朴素贝叶斯分类器开始。这玩意儿听起来有点“朴素”甚至名字里都带着点自嘲但它在文本分类、垃圾邮件过滤、情感分析这些场景里表现出的稳定性和效率常常让更复杂的模型汗颜。我最早用它是在一个新闻主题分类的项目里当时手头数据标注不全特征维度又高全是词用其他模型要么过拟合要么训练慢。抱着试试看的心态上了朴素贝叶斯结果在验证集上的准确率直接冲到了第一梯队而且训练时间以秒计。那一刻我就明白这个基于概率论的“老古董”其核心思想——贝叶斯定理——在不确定性推理中有着难以替代的魅力。它不试图去画一条复杂的分界线而是去计算一个更本质的问题在已知某些观测特征的条件下这个样本最可能属于哪个类别简单来说贝叶斯分类器的工作方式很像一个经验丰富的医生。病人来了陈述了一系列症状特征医生不会凭空猜测而是会在大脑里快速检索“在我的经验训练数据里出现这些症状的病人最后诊断是感冒的概率有多大是流感的概率又有多大” 然后他选择概率最大的那个诊断作为结论。贝叶斯分类器干的就是这个事只不过它把医生的“经验”量化成了数学公式。所以这篇文章我就想抛开那些高大上的术语带你亲手用Python实现一个朴素贝叶斯分类器。我们不止会写出能跑的代码更要弄懂每一行代码背后的“为什么”。比如为什么它叫“朴素”这个假设带来了什么好处和局限面对连续特征比如身高、体重和离散特征比如词语、颜色时我们的处理方式有什么不同在代码里那些微小的平滑参数拉普拉斯平滑到底在防止什么可怕的错误我会结合我踩过的坑把这些细节掰开揉碎了讲清楚。我们用的例子会非常接地气用经典的鸢尾花数据集根据花瓣和萼片的尺寸来分类三种不同的鸢尾花。你会看到如何从零开始用不到100行的纯Python代码不直接调用sklearn构建一个完整的分类器并理解其每一步的计算过程。这对于你真正内化机器学习概念比单纯调用库函数要有效十倍。2. 贝叶斯定理分类问题的概率论基石在动手写代码之前我们必须把地基打牢。贝叶斯分类器的全部智慧都来源于一个看似简单的公式——贝叶斯定理。如果你之前看到公式就头疼别担心我们换个方式理解它。想象你要判断一封邮件是不是垃圾邮件。邮件里出现了“免费”、“赢取”、“点击”这些词。贝叶斯定理帮我们这样思考先验概率 P(垃圾邮件)在你看邮件内容之前根据历史经验任意一封邮件是垃圾邮件的概率有多大比如你的邮箱里历史上有20%是垃圾邮件那么这个先验概率就是0.2。似然度 P(词语|垃圾邮件)假设这封邮件已经是垃圾邮件了那么它在垃圾邮件这个“类别”里出现“免费”、“赢取”这些词的概率分别有多大这个概率是从已有的垃圾邮件样本中统计出来的。证据 P(词语)这封邮件本身出现这些词组合的概率这是一个归一化项确保最终概率加起来是1。后验概率 P(垃圾邮件|词语)这就是我们要的答案。在已经看到了“免费”、“赢取”这些词的具体条件下这封邮件是垃圾邮件的概率有多大贝叶斯定理的公式完美表达了这种关系P(类别|特征) [ P(特征|类别) * P(类别) ] / P(特征)对于分类任务我们比较不同类别如“垃圾邮件”和“正常邮件”的后验概率P(类别|特征)。因为分母P(特征)对于所有类别都一样所以我们实际只需要比较分子的大小预测类别 argmax( P(特征|类别) * P(类别) )这里有一个至关重要的“朴素”假设我们假设所有特征比如邮件中的各个词语在给定类别下是相互独立的。也就是说在垃圾邮件这个类别里“免费”这个词的出现不会影响“赢取”这个词出现的概率。这显然和现实不符“免费”和“赢取”经常一起出现但正是这个大胆的假设让计算变得可行。P(特征|类别) P(特征1|类别) * P(特征2|类别) * ... * P(特征n|类别)如果没有这个假设我们需要计算所有特征组合在一起的概率这在特征多的时候几乎是灾难维度灾难。“朴素”这个名字由此而来它承认了这个假设的简单粗暴但实践又证明在很多情况下它效果出奇地好而且计算效率极高。一个必须警惕的坑概率连乘的下溢。当特征很多时许多很小的概率值比如0.001, 0.0005连乘起来结果会小到超出计算机浮点数的精度范围直接变成0这会导致模型无法比较。所以在实际计算中我们会对公式取对数将连乘变成连加log( P(特征|类别) * P(类别) ) log( P(类别) ) Σ log( P(特征_i|类别) )取对数后数值范围变得友好且因为对数函数是单调的比较大小关系的结果不变。这是我们后续代码实现中的一个关键技巧。3. 数据准备与特征处理以鸢尾花数据集为例理论懂了我们开始实操。选鸢尾花数据集是因为它干净、经典且特征都是连续值能让我们展示如何处理数值型特征。我们会从sklearn中加载它但仅将数据用于我们的手动实现。import numpy as np from sklearn.datasets import load_iris from sklearn.model_selection import train_test_split # 1. 加载数据 iris load_iris() X, y iris.data, iris.target # X是特征150行4列y是标签0,1,2 feature_names iris.feature_names class_names iris.target_names print(f数据集形状: X{X.shape}, y{y.shape}) print(f特征名: {feature_names}) print(f类别名: {class_names}) print(f样本示例前5行:\n{X[:5]}) print(f对应标签: {y[:5]})接下来我们需要将数据集划分为训练集和测试集。这是为了评估模型在未见过的数据上的表现防止“自娱自乐”。# 2. 划分训练集和测试集 (70%训练30%测试) X_train, X_test, y_train, y_test train_test_split(X, y, test_size0.3, random_state42) print(f训练集大小: {X_train.shape}, 测试集大小: {X_test.shape})现在到了关键一步特征处理。鸢尾花的特征萼片长宽、花瓣长宽是连续值。对于连续特征我们不能像处理离散的“词语出现与否”那样直接计算概率P(特征某个具体数值|类别)因为某个具体数值出现的概率理论上是0。这里的标准做法是假设连续特征服从某种概率分布最常见的是高斯分布正态分布。也就是说我们假设在某个类别下某个特征的值服从一个正态分布。这样我们就不需要计算具体值的概率而是计算该值在这个正态分布下的概率密度用它来近似似然度P(特征|类别)。因此对于每个类别c和每个特征i我们需要从训练集中估计出两个参数均值 μ_{c,i}类别c下第i个特征所有样本的平均值。方差 σ²_{c,i}类别c下第i个特征所有样本的方差。有了这两个参数对于一个新样本的特征值x_i其在高斯分布下的概率密度为p(x_i | c) (1 / sqrt(2πσ²)) * exp( - (x_i - μ)² / (2σ²) )在代码中我们通常会计算其对数形式以避免数值下溢log p(x_i | c) -0.5 * log(2πσ²) - (x_i - μ)² / (2σ²)这里有一个重要的经验点方差估计的稳定性。如果某个类别下某个特征的样本很少或者该特征本身变化很小计算出的方差可能会非常接近于0。在计算概率密度时方差出现在分母接近0会导致计算结果趋于无穷大这是不合理的。因此在实际实现中我们常常给方差加上一个极小的常数如1e-9防止除零错误或数值不稳定。这不是理论要求而是工程上的稳健性技巧。4. 从零实现高斯朴素贝叶斯分类器我们不依赖sklearn.naive_bayes.GaussianNB而是自己从头实现这样才能吃透每一个细节。我们的类将包含三个核心方法fit训练、_calculate_log_prior计算先验概率的对数、_calculate_log_likelihood计算似然的对数和predict预测。class GaussianNaiveBayes: 手动实现的高斯朴素贝叶斯分类器。 假设所有连续特征服从高斯正态分布。 def __init__(self): self.classes_ None # 存储唯一的类别标签 self.mean_ None # 字典类别 - 特征均值数组 self.var_ None # 字典类别 - 特征方差数组 self.priors_ None # 字典类别 - 先验概率对数 self.epsilon 1e-9 # 平滑项防止方差为0 def fit(self, X, y): 训练模型。 参数: X: 训练特征形状 (n_samples, n_features) y: 训练标签形状 (n_samples,) n_samples, n_features X.shape self.classes_ np.unique(y) n_classes len(self.classes_) # 初始化存储结构 self.mean_ {} self.var_ {} self.priors_ {} for c in self.classes_: # 1. 提取属于当前类别c的所有样本 X_c X[y c] # 2. 计算先验概率 P(yc) (类别c的样本数) / (总样本数) self.priors_[c] np.log(X_c.shape[0] / n_samples) # 存储对数先验 # 3. 计算每个特征的均值和方差 # axis0 表示沿着样本方向计算得到每个特征的统计量 self.mean_[c] np.mean(X_c, axis0) self.var_[c] np.var(X_c, axis0) self.epsilon # 方差平滑 # 可选打印训练出的参数便于理解 print(训练完成。参数摘要) for c in self.classes_: print(f 类别 {class_names[c]} (标签{c}):) print(f 先验概率 log(P) {self.priors_[c]:.4f}) print(f 特征均值 {self.mean_[c]}) print(f 特征方差 {self.var_[c]}) return self def _calculate_log_likelihood(self, x, mean, var): 计算一个样本在给定类别均值和方差下的对数似然。 使用高斯概率密度函数的对数形式。 参数: x: 单个样本的特征向量 (n_features,) mean: 该类别的特征均值向量 (n_features,) var: 该类别的特征方差向量 (n_features,) 返回: log_likelihood: 对数似然值标量 # 高斯对数概率密度公式: -0.5 * (log(2πσ²) (x-μ)²/σ²) # 对每个特征计算后求和朴素假设特征独立 n_features len(x) log_2pi np.log(2 * np.pi) log_likelihood -0.5 * np.sum(log_2pi np.log(var) ((x - mean) ** 2) / var) return log_likelihood def predict(self, X): 对输入样本进行预测。 参数: X: 待预测特征形状 (n_samples, n_features) 返回: predictions: 预测的类别标签形状 (n_samples,) predictions [] # 遍历每一个待预测的样本 for x in X: posteriors [] # 存储每个类别的后验概率对数 # 对每一个可能的类别计算 log(P(c)) log(P(x|c)) for c in self.classes_: log_prior self.priors_[c] log_likelihood self._calculate_log_likelihood(x, self.mean_[c], self.var_[c]) log_posterior log_prior log_likelihood posteriors.append(log_posterior) # 选择后验概率最大的类别 predicted_class self.classes_[np.argmax(posteriors)] predictions.append(predicted_class) return np.array(predictions)让我们来详细拆解一下这个实现__init__方法初始化了存储模型参数的字典。epsilon是一个关键的小技巧用于方差平滑防止数值计算问题。fit方法首先找到所有唯一的类别标签。对每个类别计算其先验概率样本占比并存储其对数形式。这里存储对数是为了后续预测时直接使用对数加法避免下溢。计算该类下每个特征的均值和方差。np.mean(X_c, axis0)会对X_c这个二维数组按列即按特征计算平均值返回一个一维数组长度等于特征数。方差计算同理。在方差上加了epsilon这是实现中的重要经验点。如果某个特征在某个类别下完全不变方差为0不加平滑会导致后续计算概率密度时分母为0程序报错。_calculate_log_likelihood方法这是核心的计算函数。它实现了高斯分布对数概率密度的公式。注意这里对每个特征独立计算对数概率密度然后求和。这正体现了“朴素”的独立性假设总的对数似然等于各个特征对数似然之和。公式中的np.log(var)是计算log(σ²)即方差的对数。predict方法对每个输入样本遍历所有可能的类别。对于每个类别计算其对数后验概率log(P(c)) log(P(x|c))。log(P(x|c))由_calculate_log_likelihood方法计算。使用np.argmax找到对数后验概率最大的类别索引并将其作为预测结果。现在让我们用划分好的数据来训练和测试我们的模型。# 4. 训练我们的手动实现模型 print(\n--- 训练手动实现的朴素贝叶斯模型 ---) gnb_manual GaussianNaiveBayes() gnb_manual.fit(X_train, y_train) # 5. 在测试集上进行预测 y_pred_manual gnb_manual.predict(X_test) # 6. 计算准确率 accuracy_manual np.mean(y_pred_manual y_test) print(f\n手动实现模型在测试集上的准确率: {accuracy_manual:.4f})运行这段代码你会看到模型打印出训练出的各个类别的先验概率、均值和方差并最终给出在测试集上的准确率。在我的这次运行中准确率达到了0.9556即45个测试样本中正确分类了43个这对于一个如此简单的模型来说效果相当不错。5. 与Scikit-learn实现对比及深入分析为了验证我们手动实现的正确性最好的方法就是和业界标准库scikit-learn的实现进行对比。同时我们也能借此机会深入分析一些模型行为。from sklearn.naive_bayes import GaussianNB from sklearn.metrics import classification_report, confusion_matrix # 1. 使用sklearn的GaussianNB print(\n--- 使用Scikit-learn的GaussianNB ---) gnb_sklearn GaussianNB() gnb_sklearn.fit(X_train, y_train) y_pred_sklearn gnb_sklearn.predict(X_test) accuracy_sklearn gnb_sklearn.score(X_test, y_test) print(fSklearn模型在测试集上的准确率: {accuracy_sklearn:.4f}) # 2. 对比两个模型的预测结果是否一致 print(f\n手动实现与Sklearn预测结果是否完全一致? {np.array_equal(y_pred_manual, y_pred_sklearn)}) # 3. 查看详细的分类报告和混淆矩阵 print(\n--- Sklearn模型的详细评估报告 ---) print(classification_report(y_test, y_pred_sklearn, target_namesclass_names)) print(混淆矩阵:) print(confusion_matrix(y_test, y_pred_sklearn))如果我们的实现正确两个模型的预测结果应该完全一致。分类报告会展示每个类别的精确率、召回率和F1-score混淆矩阵则能告诉我们模型具体在哪里犯了错。例如它可能显示大部分错误发生在两个相似的类别之间比如 setosa 和 versicolor这能启发我们思考特征是否足够有区分度。为什么我们的简单模型效果这么好数据本身线性可分性强鸢尾花数据集的三类特别是 setosa 与其他两类在特征空间中有比较清晰的界限。特征独立性假设的伤害较小虽然花瓣长和花瓣宽可能存在相关性但在这个数据集上这种相关性没有严重到破坏“朴素”假设的有效性。连续特征符合高斯分布假设鸢尾花的测量尺寸大致服从正态分布这与我们的模型假设吻合。但是这个模型并非万能它有明显的局限性特征独立性假设这是最大的弱点。如果特征间存在强相关性比如“房价”和“房间数”模型效果会下降。连续特征分布假设我们假设所有连续特征都是高斯分布的。如果某个特征明显是偏态分布比如收入或多峰分布用单高斯模型来拟合就会很差。这时可以考虑使用核密度估计KDE来估计P(特征|类别)或者将连续特征离散化分桶。对于未见过的特征值如果一个新样本的某个特征值远远超出训练集中该类该特征的范围根据高斯公式其概率密度会变得极低log-likelihood会是一个很大的负数这可能会过度影响分类决策。这是基于分布假设模型的一个通病。6. 核心参数解析与模型优化实战虽然我们的手动实现没有太多可调参数但理解sklearn中GaussianNB的参数以及潜在的优化点对实际应用至关重要。我们主要关注两个核心概念先验概率和方差平滑。1. 先验概率priors在fit方法中我们通过统计训练集中各类别的样本比例来计算先验概率P(类别)。这被称为“从数据中学习先验”。但有时我们的训练集分布可能和真实世界的分布不一致例如在垃圾邮件过滤中你的训练集可能人工平衡过但真实收件箱里垃圾邮件比例可能不同。sklearn的GaussianNB允许通过priors参数手动指定先验概率。# 示例假设我们根据业务知识认为三类花的先验概率分别为0.3, 0.4, 0.3 custom_priors [0.3, 0.4, 0.3] gnb_custom_prior GaussianNB(priorscustom_priors) gnb_custom_prior.fit(X_train, y_train) acc_custom gnb_custom_prior.score(X_test, y_test) print(f使用自定义先验 {custom_priors} 的准确率: {acc_custom:.4f}) # 通常除非有很强的领域知识否则不建议随意修改使用数据驱动的先验往往更可靠。2. 方差平滑与数值稳定性在我们的手动实现中我们给方差加了一个epsilon1e-9。在sklearn中这个操作通过var_smoothing参数控制但它的实现更精细。sklearn不是简单加一个常数而是将计算出的方差加上var_smoothing * np.var(X, axis0).max()即最大特征方差的某个比例。这确保了平滑的强度与数据本身的尺度相适应。# 调整var_smoothing参数观察对概率估计的影响通常影响很小 for smoothing in [1e-9, 1e-5, 1e-1]: gnb_smooth GaussianNB(var_smoothingsmoothing) gnb_smooth.fit(X_train, y_train) # 我们可以查看模型预测的概率而不仅仅是类别 probas gnb_smooth.predict_proba(X_test[:1]) # 看第一个测试样本的概率 print(fvar_smoothing{smoothing}: 预测概率 {probas})var_smoothing的主要作用是防止零方差如前所述避免除零错误。正则化效果轻微的平滑可以防止模型对训练数据中的微小噪声过于敏感相当于给方差估计增加了一点先验信息认为方差不会太小有轻微的防止过拟合的作用。处理极端值当一个新样本的特征值距离某类均值非常远时极大的(x-μ)²/σ²项会导致对数似然变成一个绝对值很大的负数可能过度主导决策。适当的平滑增大了方差缓和了这种极端影响。在实际应用中var_smoothing很少需要调整默认值1e-9在绝大多数情况下都工作良好。把它理解为一个保障数值稳定的“安全阀”即可。7. 超越高斯处理其他类型特征的朴素贝叶斯变体我们的实现和鸢尾花例子都基于连续特征和高斯假设。但现实世界的数据五花八门。朴素贝叶斯家族还有其他成员专门处理不同类型的特征1. 多项式朴素贝叶斯 (MultinomialNB)这是文本分类的绝对主力。它假设特征是由多项式分布生成的适用于离散特征计数比如单词在文档中出现的次数词频。核心思想计算在给定类别下每个特征单词出现的概率。预测时将新文档中各个单词的出现概率根据训练集估计连乘起来再乘以类别先验。关键参数alpha这是一个加性平滑参数拉普拉斯平滑/Lidstone平滑。因为总会有训练集中没出现过的单词特征值为0如果不平滑整个概率乘积就会因为一项为0而变成0。alpha1就是拉普拉斯平滑alpha1是Lidstone平滑alpha0则不平滑不推荐。典型应用垃圾邮件识别、新闻分类、情感分析将文本转化为词频或TF-IDF特征后。2. 伯努利朴素贝叶斯 (BernoulliNB)适用于二值布尔特征即特征只有“出现(1)”或“不出现(0)”两种状态。它假设特征服从伯努利分布。与多项式的区别多项式NB关注“出现次数”伯努利NB只关心“是否出现”。例如在文本分类中伯努利NB将一篇文档视为一个“词袋”的布尔向量只记录某个词是否出现而不关心出现了几次。应用场景同样用于文本分类但在某些问题上忽略词频、只关注词是否出现的效果可能更好比如判断文档是否关于某个特定主题。也适用于任何特征为是/否、有/无的场景。3. 分类朴素贝叶斯 (CategoricalNB)用于处理分类特征离散的、非数值的比如颜色{红绿蓝}、城市{北京上海广州}。这是sklearn较新版本加入的。核心直接估计每个特征在每个类别下取某个具体值的概率。同样需要使用平滑alpha参数来处理训练集中未出现的特征-类别-取值组合。如何选择特征值是连续数值如身高、温度、像素强度 -高斯朴素贝叶斯 (GaussianNB)。特征值是离散计数如单词出现次数、用户点击次数 -多项式朴素贝叶斯 (MultinomialNB)。特征值是二值 (0/1)如单词是否出现、用户是否点击 -伯努利朴素贝叶斯 (BernoulliNB)。特征值是分类标签如性别、产品类型 -分类朴素贝叶斯 (CategoricalNB)。一个项目里经常混合多种特征类型。一种常见的做法是对连续特征用高斯NB处理对离散计数特征用多项式NB处理然后将它们的对数概率相加因为我们在对数空间计算。这需要更灵活的自定义实现或者使用一些支持混合分布的库。8. 项目实战扩展从鸢尾花到文本情感分析为了让你更好地理解多项式朴素贝叶斯在文本上的威力我们来看一个简单的文本情感分析示例。我们会用电影评论数据判断一条评论是正面还是负面。# 示例使用多项式朴素贝叶斯进行简单文本分类 from sklearn.feature_extraction.text import CountVectorizer from sklearn.naive_bayes import MultinomialNB from sklearn.pipeline import make_pipeline # 1. 准备一个极小的示例数据集 texts [ 这部电影太棒了演员演技出色剧情扣人心弦, 糟糕的体验浪费时间毫无逻辑。, 非常感人的故事推荐给大家。, 无聊透顶看得我睡着了。, 特效震撼音乐优美值得一看。, 角色塑造失败对话尴尬。, ] labels [1, 0, 1, 0, 1, 0] # 1: 正面, 0: 负面 # 2. 构建管道文本转词频 多项式朴素贝叶斯 # CountVectorizer 将文本转换为词频矩阵 # MultinomialNB 进行分类 text_clf make_pipeline(CountVectorizer(), MultinomialNB()) # 3. 训练模型 text_clf.fit(texts, labels) # 4. 预测新评论 new_reviews [ 音乐和画面都很美但故事有点弱。, 绝对是一部经典之作, 不怎么好看不推荐。 ] predictions text_clf.predict(new_reviews) pred_proba text_clf.predict_proba(new_reviews) for review, pred, proba in zip(new_reviews, predictions, pred_proba): sentiment 正面 if pred 1 else 负面 print(f评论: {review}) print(f 预测情感: {sentiment}) print(f 概率分布 [负面, 正面]: {proba.round(4)}) print()在这个例子中CountVectorizer完成了特征工程将每条评论转换成一个个单词token出现的次数。例如“电影”、“棒”、“演员”等词会成为特征。MultinomialNB会学习在正面评论中每个词出现的概率P(词|正面)以及在负面评论中每个词出现的概率P(词|负面)。预测时对于新评论模型提取其中的词查找这些词在正面和负面类别下的概率将它们连乘实际是对数相加再结合类别的先验概率得到最终的后验概率。这里的关键技巧和坑停用词像“的”、“了”、“和”这种高频但对分类无用的词停用词应该在CountVectorizer中通过stop_words参数过滤掉否则它们会成为噪声。n-gram有时单个词unigram不够比如“不 好”和“好”意思完全相反。可以设置ngram_range(1,2)让模型同时考虑单个词和相邻的两个词bigram作为特征。TF-IDF比起简单的词频Count使用 TF-IDF词频-逆文档频率进行加权通常效果更好它能降低常见词的权重提升重要词的权重。sklearn中只需将CountVectorizer替换为TfidfVectorizer。alpha参数务必使用平滑。对于小数据集alpha1拉普拉斯平滑是个不错的起点。对于大数据集可以尝试调小一点。通过这个简单的例子你可以看到尽管朴素贝叶斯模型结构简单但结合恰当的特征工程文本向量化它在文本分类任务上依然是一个快速且强大的基线模型。我曾在一些需要快速上线的项目中用Pipeline(MultinomialNB())一天内就搭建出可用的分类服务效果并不比当时花了一周调参的复杂模型差多少这在争分夺秒的业务场景中价值巨大。
返回列表