手工实现KNN与朴素贝叶斯:鸢尾花分类算法全解析

发布时间:2026/9/13 17:04:50
手工实现KNN与朴素贝叶斯:鸢尾花分类算法全解析
简介这一项目以鸢尾花数据集为对象手工实现KNN与朴素贝叶斯两种经典分类算法适合机器学习初学者对照理论动手实践。压缩包内共5个文件包含两个Python代码文件、鸢尾花数据csv、结果txt以及README说明整个资源仅4KB结构精简便于快速上手。已有935人学习使用代码可直接运行读者不仅能直接观察分类结果还可通过修改K值、启停拉普拉斯平滑等方式直观对比两种算法在准确率和运行时间上的差异。在实现层面KNN部分基于欧氏距离完成近邻投票朴素贝叶斯部分则依照贝叶斯定理计算先验与条件概率随附csv数据覆盖三类鸢尾花各50个样本有助于理解特征条件独立假设的实际影响。这份资料对课程设计、算法入门或期末复习均有帮助可作为对照理论公式动手编码的参考。1. 鸢尾花分类为什么值得手工写一遍KNN和朴素贝叶斯鸢尾花数据集几乎是每个机器学习入门者都会撞上的第一道坎。用sklearn三行代码就能跑完分类准确率还轻松超过95%于是多数人调完包就过去了对算法内部到底怎么决策一无所知。我见过不少简历写着熟悉KNN和朴素贝叶斯的候选人被问一句K值怎么调、先验概率怎么算就卡壳。这次分享的手工实现项目把knn.py、nbayes.py两份代码和iris.csv数据放在一起不依赖任何机器学习框架纯Python从头写距离计算、概率估计和类别判定跑一遍就能把这两个算法的全部细节看得清清楚楚。KNN作为惰性学习的代表朴素贝叶斯作为生成式模型的典型两者在鸢尾花这个150样本、4特征、3类别的小规模数据集上正好能暴露各自的特性和局限。2. KNN手工实现从距离度量到投票决策的完整链路2.1 KNN的惰性学习本质与鸢尾花场景的适配性KNN的思路非常直接新样本的类别由它最近的K个已知样本投票决定。这个最近在数学上落到具体计算就是距离度量最常见的欧氏距离公式是d(x, y) √(Σ(x_i - y_i)²)。手工实现时这个公式要自己动手写成循环而不是调用现成的pairwise_distances。理解这一层很关键因为距离度量的选择直接影响分类边界。Iris数据集的特征是花萼长度、花萼宽度、花瓣长度、花瓣宽度单位都是厘米4个特征的数值范围差别不大所以用欧氏距离不会出现某个特征主导整个距离计算的问题。如果换到量纲差异悬殊的数据集比如年龄和工资放在一起就必须先做标准化否则距离计算会完全被大数值特征绑架。KNN在这个场景下的优势是决策边界可以非常灵活能适应鸢尾花三个类别中Setosa线性可分、另外两个类别有重叠这种混合结构。代价是预测时需要遍历全部训练样本计算距离150个样本跑起来不觉得慢但样本量到万级时每次预测的耗时就会线性恶化这是懒惰学习最直接的代价。2.2 手工实现代码与运行逻辑import pandas as pd import numpy as np from collections import Counter def load_data(path): # 读取鸢尾花数据集最后一列是标签 data pd.read_csv(path) X data.iloc[:, :-1].values y data.iloc[:, -1].values return X, y def euclidean_distance(a, b): # 手工计算欧氏距离不使用任何现成距离函数 return np.sqrt(np.sum((a - b) ** 2)) def knn_predict(X_train, y_train, x_test, k): # 保存所有训练样本与新样本的距离 distances [] for i in range(len(X_train)): dist euclidean_distance(x_test, X_train[i]) distances.append((dist, y_train[i])) # 按距离升序排序取前K个 distances.sort(keylambda item: item[0]) neighbors distances[:k] # 统计K个邻居中出现最多的类别 votes Counter([label for _, label in neighbors]) return votes.most_common(1)[0][0]这段代码把KNN预测的全过程展开成了三个动作。euclidean_distance函数用numpy的数组逐元素相减、平方、求和、开方逻辑上等价于手写for循环但执行效率更高。knn_predict函数里用distances列表保存了每个样本的距离和标签排序后切片取前K个最后用Counter统计票数。整个流程没有任何一步是黑盒操作。2.3 K值选择、平票处理与性能边界K值是KNN里唯一需要人工确定的超参数也是第一个会踩的坑。K设成1的时候决策边界极度曲折训练集准确率接近100%但泛化能力差一个噪声点就能改变一片区域的分类结果这是过拟合的典型表现。K设得过大比如在150个样本里取K40以上少数类别的样本会被多数类别淹没决策边界被严重平滑欠拟合随之而来。平票问题是第二个坑。K4时四邻居可能出现2比2这时候代码里的most_common(1)只能返回先遇到的类别带有隐性的顺序偏差。普遍的处理策略是优先选择距离更近的邻居类别或者直接跳过偶数K值这也是K通常取奇数这句话的根本来源——奇数天然避免大部分平票情况。KNN在这里的性能边界也很明确预测时的时间复杂度是O(n)n是训练样本数。iris数据集只有150个样本单次预测在毫秒级完全无感。但这个复杂度意味着如果训练集变成10万条记录每次预测都要算十万次距离在高QPS的在线预测场景下基本不可用。所以KNN的实际应用场景集中在样本量可控的小规模分类、推荐系统里的相似项查找以及作为后续更复杂算法的baseline。3. 朴素贝叶斯手工实现先验概率、条件概率与拉普拉斯平滑3.1 从贝叶斯定理到条件独立假设的概率链朴素贝叶斯走的是完全不同的路线——它不存样本而是从训练数据里学习一组概率参数。核心是贝叶斯定理P(类别|特征) P(类别) × P(特征|类别) / P(特征)。分母P(特征)对所有类别是一样的所以在做类别比较时可以直接省略只需比较分子的大小。朴素二字落在条件独立假设上假设四个特征在给定类别时彼此独立这样联合概率P(特征|类别)可以拆成P(花萼长度|类别) × P(花萼宽度|类别) × P(花瓣长度|类别) × P(花瓣宽度|类别)的连乘形式。真实数据里花瓣长度和花瓣宽度强相关这个假设并不成立但拆开后参数估计变得异常简单——只需要为每个特征在每个类别下分别建模然后相乘。3.2 连续特征的概率估计方法对比鸢尾花的四个特征全是连续值处理连续特征有三种做法。第一种是把特征离散化分箱后统计频率缺点是分箱粒度难以把握。第二种是假设特征服从高斯分布在训练阶段计算每个类别下每个特征的均值μ和标准差σ预测时直接代入高斯分布公式算概率密度。第三种是用核密度估计更灵活但计算开销大。我在手工实现里选择了第二种高斯朴素贝叶斯因为鸢尾花的每个特征在各类别下的分布大致呈钟形高斯假设与数据基本吻合代码量也最小。有一个容易被忽略的操作预测时用的是概率密度值不是概率密度值可以大于1直接连乘会导致浮点下溢。标准做法是对连乘取对数把乘法变成加法数值稳定性会好很多。3.3 核心代码与拉普拉斯平滑的落地import numpy as np class GaussianNaiveBayes: def __init__(self, smoothing1e-9): self.smoothing smoothing # 防止方差为0导致除零错误 self.classes None self.mean {} # 每个类别下每个特征的均值 self.var {} # 每个类别下每个特征的方差 self.priors {} # 每个类别的先验概率 def fit(self, X, y): self.classes np.unique(y) for c in self.classes: X_c X[y c] self.mean[c] X_c.mean(axis0) # 加上平滑项避免特征完全相同时方差为0 self.var[c] X_c.var(axis0) self.smoothing self.priors[c] len(X_c) / len(X) return self def _gaussian_pdf(self, x, mean, var): 手工实现高斯概率密度函数 exponent -((x - mean) ** 2) / (2 * var) coefficient 1.0 / np.sqrt(2 * np.pi * var) return coefficient * np.exp(exponent) def predict(self, X): predictions [] for x in X: # 存储每个类别的对数后验得分 scores {} for c in self.classes: # 对数先验概率作为初始值 scores[c] np.log(self.priors[c] self.smoothing) # Verify和每个特征的条件分布 for i in range(len(x)): pdf self._gaussian_pdf(x[i], self.mean[c][i], self.var[c][i]) scores[c] np.log(pdf self.smoothing) predictions.append(max(scores, keyscores.get)) return np.array(predictions)fit方法是学习阶段的核心对每个类别分别计算该类别下所有样本的特征均值和方差再统计该类别占比作为先验概率。predict方法里关键在对数空间操作先取先验概率的对数再累加每个特征的条件概率密度对数值。self.smoothing这个参数需要特别说明它同时承担两个职责——对方差加一个极小值防止除零对概率加一个极小值防止log(0)报错。拉普拉斯平滑更标准的做法是分子加1、分母加类别数适用于离散特征计数。这里的高斯版本把拉普拉斯的思想迁移到连续场景用epsilon加性平滑替代了原始的1-加性平滑因为概率密度理论上可以是任意正数不存在未观测到的类别这个语义。跑代码时可以在iris上试着调大self.smoothing到1.0然后观察准确率的变化会发现它对最终结果的影响远小于KNN里K值的影响这说明朴素贝叶斯对概率参数的细微扰动不敏感这是它在小样本场景下稳定性的来源。3.4 边界场景与失效模式分析高斯朴素贝叶斯的场景失效模式是一个值得注意的点。如果数据特征存在极端偏差分布高斯假设就不成立。想象一个特征在某个类别下的分布是双峰或长尾的用均值±2σ范围内覆盖95%以上样本的假设去拟合中间密度最高的区域反而会欠拟合。另一个失效场景是特征数量远大于样本数量比如文本分类里词表几万维、训练文档只有几百篇高斯方差估计会严重失真这时候更适合用多项式朴素贝叶斯结合词频直方图。先验概率的影响容易被低估。在iris数据集上三个类别各占50条先验概率均为1/3均匀分布下先验对结果几乎没有干扰。但现实中如果训练集的类别比例和真实场景不一致比如训练数据里Setosa占80%、现场实际只有10%那么预测结果会偏向先验概率高的类别。手工实现里可以单独打印self.priors看看值然后手动改一份不均衡的样本模拟一下切身体会比理论说明更有说服力。4. 两算法对比评估交叉验证与边界样本分析4.1 评估脚本与准确率基准import numpy as np from knn import knn_predict from nbayes import GaussianNaiveBayes def cross_validate(X, y, k_folds5): indices np.arange(len(X)) np.random.shuffle(indices) fold_size len(X) // k_folds knn_scores [] nb_scores [] for fold in range(k_folds): val_idx indices[fold * fold_size:(fold 1) * fold_size] train_idx np.setdiff1d(indices, val_idx) X_train, y_train X[train_idx], y[train_idx] X_val, y_val X[val_idx], y[val_idx] # KNN评估K5 knn_correct 0 for x, true_label in zip(X_val, y_val): pred knn_predict(X_train, y_train, x, k5) if pred true_label: knn_correct 1 knn_scores.append(knn_correct / len(val_idx)) # 朴素贝叶斯评估 nb GaussianNaiveBayes(smoothing1e-9) nb.fit(X_train, y_train) nb_pred nb.predict(X_val) nb_scores.append(np.mean(nb_pred y_val)) return np.mean(knn_scores), np.mean(nb_scores), knn_scores, nb_scores这脚本把5折交叉验证做成了手工循环目的是同时考核两种算法在相同数据划分下的表现。fold_size是每一折的验证集大小np.setdiff1d用来从全集中剔除验证集获得训练索引保证训练集和验证集不重叠。运行后你会看到KNN和朴素贝叶斯在鸢尾花上的准确率都稳定在90%到96%区间具体数值取决于随机划分的方式。4.2 耗时对比和计算复杂度分析从运行时间看两算法呈现明显分野。KNN的训练阶段零耗时时间全部花在预测150个样本的交叉验证还可以接受但如果你把样本复制20倍单次预测就要多算3000个距离。朴素贝叶斯的fit阶段需要对每个特征遍历一次计算均值方差复杂度是O(n×d)预测阶段只做K次乘法累加与训练样本量无关。两算法在准确率上的差异可以用一个具体场景说明。取第51号样本它是Versicolour里最接近Virginica区域的一个数据点属于特征空间上的模糊地带。KNN只看它最近的5个邻居是怎么投的票只要邻居里多数标签指向Versicolour就判Versicolour。朴素贝叶斯计算的是该样本四个特征在Versicolour和Virginica两类下谁的概率乘积更大不关心邻域的局部结构。这两种决策逻辑在面对同一个模糊样本时结果可能是相反的代码跑出来的每一个预测值背后都有明确的概率或距离依据。4.3 混淆矩阵与类别偏置解读只盯准确率会掩盖算法的偏好这一点在手工实现项目里很容易验证。手动写一个混淆矩阵的统计代码记录每个真实类别被预测成了哪些类别def confusion_matrix(y_true, y_pred, classes): # 初始化全零矩阵 matrix np.zeros((len(classes), len(classes)), dtypeint) for true, pred in zip(y_true, y_pred): i list(classes).index(true) j list(classes).index(pred) matrix[i][j] 1 return matrix观察重点在Versicolour和Virginica这两行它们是混淆矩阵里非对角线区域最活跃的位置因为这两类样本在花瓣长度和花瓣宽度上的分布高度重叠。Setosa几乎永远不会被分错它的四个特征和其他两类完全分离线性分类器都能达到100%分离率。KNN在重叠区域的边界更依赖邻域密度朴素贝叶斯则用概率密度覆盖范围来判断清晰的还是模糊的采集到的样本多边界偏向就是两方对称的采集到的样本在某个区域缺了一块两个算法的表现差异立刻就会放大。5. 参数调优实战K值搜索与高斯平滑项的实验技巧KNN里K值的选取有规律可循。一个通用的实验模板是按奇数从1到21逐一遍历交叉验证后记录每个K值的平均准确率然后把曲线拉平出来看趋势精调K值就是把策略定在曲线平台期的最左侧也就是准确率表现稳定时的最小K值。k_candidates [1, 3, 5, 7, 9, 11, 13, 15, 17, 19, 21] for k in k_candidates: acc evaluate_knn_with_k(X_train, y_train, X_val, y_val, k) print(fK{k:2d} accuracy{acc:.4f})对朴素贝叶斯尝试把smoothing设成1e-3、0.01、0.1、1.0这几个量级观察准确率的变化幅度。在训练样本足够覆盖特征空间时它几乎不变。把训练集缩减到每类只留15条样本smoothing的影响就会被放大加性平滑的护城河作用就显现出来了此时记录下不加平滑时对应特征的方差值能看到部分特征方差已经接近零这是概率估计的数值危机信号。数据剂量也是一个被忽视的调参维度。分别用每类10条、25条、50条样本训练同一个朴素贝叶斯模型记录准确率随训练样本量的增长曲线能直接看到朴素贝叶斯的参数估计收敛速度比KNN快——KNN要达到同等准确率往往需要更多的样本因为它依赖样本密度覆盖特征空间而朴素贝叶斯是把数据压缩成几个统计量具备一定的数据效率优势。最后建议把两个模型的预测结果逐条对齐找出哪几个样本被KNN判对而朴素贝叶斯判错反向去找这些样本的特征值看它们到底处于类别边界的哪个位置对比两个算法的分歧样本对算法理解更有价值。本文还有配套的精品资源点击获取