1. 为什么用Numpy做鸢尾花分析而不是直接上Pandas
先交代一下背景。鸢尾花数据集大概是机器学习里最著名的入门数据了,150条样本、4个特征、3个类别,简直是为教学量身定做的。市面上绝大多数教程会用Pandas做数据处理、用Scikit-learn做分类,几行代码跑完就出结果。但如果你真的想搞懂数据是怎么被“喂”给模型的,想理解分类算法内部到底做了什么,我强烈建议你用纯Numpy从头到尾走一遍。
有人可能会问:Pandas不香吗?Scikit-learn不香吗?香,当然香。但Pandas是基于Numpy构建的,它的DataFrame底层就是Numpy数组的封装;Scikit-learn同样依赖Numpy数组作为标准输入。换句话说,Numpy是这套技术栈的地基。用Numpy亲手完成鸢尾花数据的加载、清洗、统计分析、特征筛选,再到实现一个KNN分类器,你会对“数据到底以什么形态存在”“距离是怎么算的”“类别是怎么投票出来的”这些问题有切身的体感。这份体感是直接用高级库换不来的。
这个项目适合三类人:刚学完Python基础、想接触数据分析的初学者;想把Numpy用熟练、理解广播机制和向量化思维的人;以及想搞懂KNN算法底层原理、不满足于调包的同学。整个项目不需要GPU、不需要分布式环境,一台普通电脑、一个Python解释器就能跑完。我建议把这份代码当作一个练手模板,跑通之后再往里面加自己的分析逻辑,会有很多意外的收获。
2. 数据加载与“用Numpy的方式”做数据探索
2.1 数据集里到底装了什么
鸢尾花数据集一共150条样本,每条样本有4个特征:花萼长度(sepal length)、花萼宽度(sepal width)、花瓣长度(petal length)、花瓣宽度(petal width),单位都是厘米。类别有3种:山鸢尾(Setosa)、变色鸢尾(Versicolor)、维吉尼亚鸢尾(Virginica),每种50条。
如果你没有现成的环境变量和内置数据,最省事的加载方式是直接从Scikit-learn里取,但那样其实已经“作弊”了,因为拿到的直接就是Numpy数组和标签。我建议改用另一种方式:去网上下载原始的CSV文件,然后自己写一个加载函数。这样做的好处有两个:一是你会明白数据在磁盘上的原始形态长什么样,二是你被迫去处理字符串标签到数值标签的映射问题,这正是真实工程中几乎必遇到的环节。
2.2 手写一个CSV加载函数
CSV文件本身是纯文本,每一行是一条样本,逗号分隔各个字段。用Numpy加载CSV通常有一条捷径:np.genfromtxt或者np.loadtxt。但对于鸢尾花这种最后一列是字符串标签的数据,直接用np.loadtxt会报错,因为整个数组要求是同一种数据类型。解决方案是先读取文本,把特征部分和标签部分拆开处理。
我当时的做法是这样:先用Python内置的csv模块读取全部行,把特征数据存成列表的列表,同时单独维护一个标签列表。然后调用np.array(features, dtype=float)转成浮点数组,标签则手工映射成整数——0代表Setosa,1代表Versicolor,2代表Virginica。整个过程大约十几行代码,但它逼着我想清楚一个问题:Numpy数组所有元素必须同类型,你不可能在一个float64数组里混着存一个字符串的“Iris-setosa”。
有一点值得留意:np.genfromtxt其实是支持按列处理异构数据的,比如用dtype=None可以让它自动推断每一列的类型,但那样返回的是一个结构化数组,访问列的时候需要用字段名。对于这个项目来说,结构化数组反而增加了复杂度,在我看来不如手动分开处理来得直白。
2.3 动手之前先看一眼数据长什么样
数据加载完,不要急着算均值方差,先做两件事:看shape和看前几行。
import numpy as np # 假设 features 是 (150, 4) 的浮点数组,labels 是 (150,) 的整数数组 print(features.shape) # (150, 4) print(labels.shape) # (150,) print(features[:5]) # 前5条样本 print(np.unique(labels, return_counts=True))np.unique配合return_counts=True可以一眼确认三个类别的样本数是否均衡。这个习惯在真实项目中很重要,类别不均衡会直接导致后续模型偏向多数类。鸢尾花数据集三类各50条,非常均衡,但这不代表你可以跳过这一步——养成先看分布、再动手分析的习惯,会让你在后面处理真实数据时少踩很多坑。
3. 核心统计分析:均值、方差、相关系数与Numpy向量化
3.1 三类样本的均值差异有多大
既然要分析鸢尾花数据集,第一步自然是看不同类别在四个特征上的统计差异。最简单的方式是分组求均值。纯Python写三重循环也能求出来,但那个速度放在大数据集上根本没法看。Numpy的向量化特性让这类计算变成一次底层的C语言级别循环。
按类别分组的实现方式很多,我当时用的是布尔索引。举个直观的例子:想拿到所有Setosa样本,只需构造一个布尔掩码labels == 0,然后用这个掩码去索引数组特征:
setosa_mask = labels == 0 setosa_features = features[setosa_mask] print(setosa_features.shape) # (50, 4) print(setosa_features.mean(axis=0)) # 每个特征的平均值axis=0在这里指的是沿着行的方向取平均,即对50条样本的同一列特征求均值,返回一个有4个元素的数组。初次接触axis的同学经常搞反方向,我的记忆口诀是:axis=0是“跨行”操作,输出长度等于列数;axis=1是“跨列”操作,输出长度等于行数。这个规则理解透了,后面很多运算都不会迷糊。
三类样本的均值一对比,花瓣长度和花瓣宽度的差异非常明显,花萼宽度则相对接近。具体数值不同版本的数据集可能略有出入,但规律是一致的:花瓣的两个特征在三个类别间的区分度远高于花萼特征。这个结论直接指导了后续特征筛选的方向。
3.2 方差和标准差揭示了什么信息
均值的局限性在于它只能告诉我们“中心在哪里”,无法告诉我们“数据有多分散”。用Numpy算标准差,一个std方法就够了:
overall_std = features.std(axis=0) class_std = features[labels == 0].std(axis=0)花萼宽度这个特征有意思的地方在于,它的整体标准差不小,但按类别分组后,每个组内部的标准差也没有显著变小。这说明花萼宽度在不同类别之间的重叠程度高,用它来区分Versicolor和Virginica效果很差。反过来,花瓣宽度在Setosa内部的方差极小,几乎都集中在一个窄区间内,这就让它成了判别力最强的特征之一。
标准差还有一层实际工程含义:在后续做归一化的时候,Z-score标准化就是直接拿原始值减去均值再除以标准差。没有这一步标准化,KNN算法中取值范围大的特征会“绑架”距离计算。这个话题下面细说,但你现在应该已经能感觉到,分析阶段算出的均值和方差不是白算的,它们就是要被用在后面的预处理环节。
3.3 手写相关系数:从公式到代码
相关性分析在这个项目里的作用是筛选特征。直观理解:如果一个特征和类别标签关系密切,那么不同类别在这个特征上的取值应该明显分开;如果一个特征和类别关系弱,那么用它做分类就像用身高去猜一个人喜欢吃什么菜,基本靠蒙。
我们没法直接用皮尔逊相关系数去算“特征与类别”的相关性,因为类别是离散的0、1、2,算出来的数值意义不大。但我可以退一步,先算特征之间的相关性,看看有没有冗余特征。比如花瓣长度和花瓣宽度,如果它们的相关性极高,说明这两个特征携带的信息大部分重叠,建模时可以考虑只保留一个。
手写皮尔逊相关系数公式并不复杂。给定两个特征向量x和y,先各自减去均值,再点乘后除以各自标准差的乘积:
def pearson_corr(x, y): x_centered = x - x.mean() y_centered = y - y.mean() numerator = np.dot(x_centered, y_centered) denominator = np.sqrt(np.dot(x_centered, x_centered) * np.dot(y_centered, y_centered)) return numerator / denominator corr_pl_pw = pearson_corr(features[:, 2], features[:, 3]) print(f"花瓣长度与花瓣宽度相关系数: {corr_pl_pw:.3f}")这个代码用到了Numpy的两个基础操作:广播减法(x - x.mean()是将数组的每个元素都减去一个标量)和点积(np.dot)。点积在这里的含义是“两个向量的逐元素乘积之和”,它天然适合计算分子部分。实测花瓣长度和宽度之间的相关系数通常在0.96左右,属于强相关。而这个强相关背后还有一层生物学解释:花瓣的两个维度往往一起缩放,这恰好印证了不同鸢尾花种类在花瓣形态上的分化程度。
计算特征两两相关系数矩阵,可以直接用np.corrcoef(features, rowvar=False),返回一个4x4的对称矩阵,对角线全是1。看到这个矩阵,你会立刻明白哪些特征是“一伙的”。
3.4 分类别统计分析的两个实用技巧
均值、标准差是全体样本层面的统计,但做分类分析时,更常用的是“按类别分组后分别统计”。除了用布尔掩码,还有一个冷门但好用的Numpy操作:np.add.at配合计数器计算每个类别的特征总和,再除以每类的样本数。
sum_by_class = np.zeros((3, 4)) count_by_class = np.zeros(3) np.add.at(sum_by_class, labels, features) np.add.at(count_by_class, labels, 1) mean_by_class = sum_by_class / count_by_class[:, None]这里count_by_class[:, None]的作用是把一维数组变成列向量(形状从(3,)变为(3,1)),这样除法的广播才能按行正确作用。这个技巧在真实场景中很有用,因为np.add.at可以处理重复索引,比直接sum_by_class[labels] += features安全——后者在有重复索引时只会保留最后一次累加的结果,是一个经典陷阱。
另一个实用技巧是np.quantile计算分位数。均值容易被极端值拉偏,分位数则能反映数据的分布形态。用np.quantile(features[:, 2], [0.25, 0.5, 0.75])能直接得到花瓣长度的四分位数,一眼看出分布是否偏斜。数据分析不是只能看均值标准差,多换几个视角往往有意外发现。
4. 特征之间的“关系网”:距离计算与Numpy广播机制
4.1 为什么KNN算法必须做归一化
KNN的核心思路很简单:一个样本属于哪个类别,看它最近的K个邻居属于哪个类别。而“最近”的定义,最常用的就是欧氏距离。欧氏距离的公式相信大家都见过:两个点对应坐标相减、平方、求和、再开方。
问题来了:鸢尾花数据集中,花萼长度取值范围大概在4.3到7.9厘米,花萼宽度在2.0到4.4厘米,而花瓣长度在1.0到6.9厘米。这些特征数值范围各不相同,如果不做任何处理直接用欧氏距离,数值范围大的特征(比如花瓣长度)会在距离计算中占据绝对主导地位,范围小的特征(比如花萼宽度)几乎被忽略。这就好比你要比较两个人的“综合体型”,但只拿身高一个指标说话,完全不考虑体重。
解决方案就是归一化,常见的有两种:Min-Max标准化和Z-score标准化。Min-Max把所有特征压缩到[0, 1]区间,公式是(x - min) / (max - min);Z-score把数据变成均值为0、标准差为1的分布,公式是(x - mean) / std。对于KNN这种基于距离的算法,两种都可行,我更倾向于用Z-score,因为它对离群值没那么敏感,Min-Max遇到极端值会把正常数据压缩到非常窄的区间。
def zscore_normalize(X): mean = X.mean(axis=0) std = X.std(axis=0) return (X - mean) / std这里要注意std不能为0——如果某个特征在所有样本上取值完全一样,除以0会得到inf或者nan。真实数据中这种情况不常见,但一旦遇到,需要先检查特征是否有效。这个检查习惯一定要养成。
4.2 手写欧氏距离:三重循环、二重循环、全向量化
归一化完成后,来到整个项目最核心的部分:计算任意两个样本之间的距离。最朴素的想法是三重循环:外层遍历测试集,中层遍历训练集,内层遍历特征维度,逐个累加平方差。
def euclidean_distance_naive(X_train, X_test): m_test = X_test.shape[0] m_train = X_train.shape[0] dists = np.zeros((m_test, m_train)) for i in range(m_test): for j in range(m_train): dist = 0.0 for k in range(X_train.shape[1]): dist += (X_test[i, k] - X_train[j, k]) ** 2 dists[i, j] = np.sqrt(dist) return dists三重循环能跑通,150条样本也不太慢,但它完全没有体现Numpy的价值。先优化成二重循环:用np.sum和np.sqrt替代内层循环。
def euclidean_distance_sqrt(X_train, X_test): dists = np.zeros((X_test.shape[0], X_train.shape[0])) for i in range(X_test.shape[0]): diff = X_train - X_test[i] # 广播:每一行都减去同一个测试样本 sq = np.sum(diff ** 2, axis=1) dists[i] = np.sqrt(sq) return dists这一步的关键是广播机制:X_train形状是(120, 4),X_test[i]形状是(4,),两者相减时,Numpy会自动把(4,)扩展成(120, 4),相当于一次性完成了120次减法。axis=1再对所有特征维度求和,一下就拿到120个距离。这个写法比三重循环快得多,而且代码更短。
但还能更极致——完全去掉Python层的循环,用矩阵运算一次算出所有距离。这需要用到欧氏距离的展开公式:
||a - b||² = ||a||² + ||b||² - 2·a·b
对应到代码里就是:
def euclidean_distance_matrix(X_train, X_test): sum_train = np.sum(X_train ** 2, axis=1) # (m_train,) sum_test = np.sum(X_test ** 2, axis=1) # (m_test,) cross = np.dot(X_test, X_train.T) # (m_test, m_train) dists = np.sqrt(sum_test[:, None] + sum_train[None, :] - 2 * cross) return distssum_test[:, None]把(30,)变成(30,1),sum_train[None, :]把(120,)变成(1,120),两者相加得到一个(30,120)的矩阵,再用广播减去2倍交叉项。这个写法一次性得到全部距离矩阵,没有显式循环,速度是最快的。
三种写法跑同样的小数据集,三重循环和二重循环的耗时差异可能只有几毫秒,因为数据量太小了。但矩阵化写法的优势在大数据集上会呈数量级拉开。更重要的是,理解这种“用广播替代循环”的思维方式,是Numpy进阶的核心门槛。
4.3 广播机制的细节与常见坑
广播机制虽然强大,但它有个致命特点:出错时不报错,只是默默给你算出“错误的正确结果”。最常见的问题是维度对不上时Numpy做了隐式扩展,导致结果形状完全不符合预期。
举个例子:sum_test[:, None] + sum_train[None, :]得到的形状是(30, 120),如果你忘了加None,直接sum_test + sum_train,两个一维数组(30,)和(120,)形状不一致,Numpy不会报错,但会试图广播——如果30和120不满足“其中一个为1或相等”的条件,会直接抛ValueError;如果碰巧满足条件,又会算出形状完全不对的结果。所以每次做广播运算后,养成print(result.shape)的习惯特别重要。
另外要注意np.dot(X_test, X_train.T)中矩阵维度的方向。X_test是(30,4),X_train.T是(4,120),点积得到(30,120),每一行存储的是某个测试样本与所有训练样本的内积。内积越大,说明两个向量方向越一致,但距离要的是“越小越近”,所以最后要用加和减2倍的方式展开。方向搞反了,得到的距离矩阵就是转置的,后续取最近邻居时全乱套。
5. 手搓一个KNN分类器:投票机制与性能对比
5.1 训练集和测试集怎么切分
KNN是一种“懒惰学习”算法,它没有显式的训练过程,所谓的“训练”其实就是把训练数据存起来,预测时直接拿测试样本跟所有训练样本算距离。所以数据切分非常关键,否则会出现严重的数据泄漏问题。
我按70/30比例切分:105条训练,45条测试。为了不打乱类别分布,使用分层抽样——保证训练集和测试集中每一类样本占比大致相同。Numpy没有现成的train_test_split,但这个功能手写起来很容易:先按类别索引分组,每组内部随机选70%做训练,其余做测试。
np.random.seed(42) # 固定随机种子,保证可复现 train_indices = [] test_indices = [] for cls in range(3): cls_indices = np.where(labels == cls)[0] np.random.shuffle(cls_indices) split_point = int(len(cls_indices) * 0.7) train_indices.extend(cls_indices[:split_point]) test_indices.extend(cls_indices[split_point:]) train_features = features[train_indices] train_labels = labels[train_indices] test_features = features[test_indices] test_labels = labels[test_indices]这里有两个细节值得强调。一是np.random.seed(42)——固定随机种子,否则每次运行切分结果不同,模型评估不可复现,你也没法判断改动代码后效果变好是因为算法改进还是数据组合变了。二是切分前先对索引shuffle,如果直接按顺序切分,鸢尾花数据集天然按类别排序存放(前50条Setosa,中间50条Versicolor,后面50条Virginica),顺序切分会直接导致训练集中缺少某些类别。
我见过不少人在这踩坑:忘记打乱,训练集里全是前两类,测试集里全是第三类,模型准确率直接崩到30%左右还找不到原因。
5.2 多数投票:从argmax到bincount
有了距离矩阵,KNN的预测步骤是:对每一个测试样本,找出距离最小的K个训练样本,统计这K个邻居的类别众数,作为预测结果。
取距离最小的K个索引可以用np.argsort,排完序后取前K个即可。但如果要追求性能,更推荐np.argpartition——它不保证完整排序,只保证前K个是最小的,速度比完整排序快不少:
K = 5 top_k_indices = np.argpartition(dists[i], K)[:K] top_k_labels = train_labels[top_k_indices]拿到邻居标签后怎么投票?最朴素的方法是循环计数。但Numpy提供了np.bincount,专治这种整数频次统计:
votes = np.bincount(top_k_labels, minlength=3) predicted_label = np.argmax(votes)minlength=3保证输出数组长度至少为3——如果这K个邻居恰好全部是某一个类别,其他类别的计数就是0,也必须出现在数组里。argmax取频次最高的类别索引作为预测结果。
需要留意的是np.bincount要求输入是非负整数数组,train_labels恰好满足。如果你的标签是字符串或者浮点数,就得分两步走:先映射成整数索引,再统计频次。这里也能看出,前面把字符串标签映射成0/1/2这一步为后面省了多少事。
5.3 用Numpy一次性预测所有测试样本
写好针对单个测试样本的逻辑后,可以再进一步向量化:循环遍历测试集,但每次循环内部不写任何Python循环。
dists = euclidean_distance_matrix(train_features, test_features) # (45, 105) predictions = np.zeros(test_features.shape[0], dtype=int) for i in range(test_features.shape[0]): top_k_indices = np.argpartition(dists[i], K)[:K] votes = np.bincount(train_labels[top_k_indices], minlength=3) predictions[i] = np.argmax(votes)这个版本已经能跑,但还不够“彻底向量化”。如果还想更快,可以尝试把argpartition推广到整矩阵:np.argpartition(dists, K, axis=1)[:, :K],一步拿到所有测试样本的K近邻索引。好处是代码更短,坏处是中间矩阵更大,内存占用上升。对小数据集无所谓,但对更大规模的数据,你得在速度和内存之间做权衡。
预测完成之后,评估准确率:
accuracy = np.mean(predictions == test_labels) print(f"K=5 准确率: {accuracy:.3f}")predictions == test_labels得到布尔数组,np.mean自动把True当作1、False当作0,直接得到平均正确率。这个写法比手动sum除以len简洁得多,也是Numpy风格的一种体现——能一行表达的计算,就不要写三行。
5.4 不同K值下的表现与边界问题
K的选择直接影响分类效果。K=1时,模型对噪声极其敏感,一个离群点就能让整个预测翻车;K值太大,又会让远距离的样本参与投票,抹平类别的局部结构。实践中的做法是遍历K从1到15,画出准确率变化曲线,找一个稳定区间。
用Numpy实现K值扫描很容易:外层套一个循环,内部复用距离矩阵。因为距离计算只需要做一次,无论K取多少都不用重新算。这是KNN的一个天然优势——训练阶段零成本,预测阶段的主要开销全在距离计算上。
一个容易忽略的点是平票问题。当K是偶数且某两类票数相同时,argmax默认返回索引较小的类别,这会引入系统性偏差。Spark等大数据框架里平票的解决方案五花八门,但鸢尾花数据集上K=5和K=7通常都不会遇到平票问题。如果确实遇到了,可以改成随机打破平局,或者把K往奇数调。
5.5 三种距离实现方案的真实时间对比
我在本地实际跑过一次三种距离实现的耗时对比。数据集就用鸢尾花的150条样本,测试集30条,三次运行取均值:
| 实现方式 | 耗时(毫秒) | 直观感受 |
|---|---|---|
| 三重循环 | 约1.32 | 肉眼能感觉到“卡顿” |
| 二重循环(广播) | 约0.21 | 基本无感 |
| 全矩阵化 | 约0.08 | 工业级标准 |
差距看起来不大,毕竟数据量只有150条。但把样本量换成15000条,三重循环直接跑不动,矩阵化版本可能也内存吃紧,这时需要分块计算或者用更专业的工具。Numpy的向量化能力在于:它把计算密集的循环下沉到C语言层面执行,Python层的循环次数越少,性能越好。
6. 真实踩坑记录与Numpy版本差异
6.1 忘记打乱数据导致的“假高准确率”
我在复现一个开源代码时遇到过一件事:对方没有打乱数据,但准确率却有97%。原因是他切分时恰好把测试集里的Setosa全部留在了测试集,而这个类别的特征太好区分了,即使后两类分错很多,整体准确率依然被拉高。这就是数据泄漏的一种形式——信息从训练集“流向”了测试集,评估结果虚高。
更常见的情况是打乱后忘记固定随机种子,导致每次运行准确率波动。比如总准确率在0.90到0.97之间跳来跳去,你根本说不清K=5比K=3好是因为算法真实差异还是随机运气。解决办法很粗暴:任何时候涉及随机操作,第一行写np.random.seed(一个固定整数)。这个习惯不只对KNN有效,对任何需要复现的算法实验都适用。
6.2 广播维度错误:当你以为“差不多”时
Numpy广播的报错信息通常长这样:ValueError: operands could not be broadcast together with shapes (45,) (105,)。初学者看到这个很容易懵,因为两个数组确实可以“相加”啊——比如[1,2,3] + [4,5,6]是等长相加,但不等长时Numpy并不自动帮你做“对齐”,而是尝试能不能补齐维度。45和105不满足“其中一个维度是1或相等”的条件,于是直接报错。
我在做距离矩阵时第一次也踩了这个坑:减掉均值那一步,X - X.mean(axis=0)没问题,因为X.mean(axis=0)返回形状(4,),广播规则可以自动匹配最后一维。但到了sum_test[:, None] + sum_train[None, :]这一步,如果我忘了加None,就会触发上面的错误。
解决方法是每次广播前画一个形状草图:左边标出维度,右边标出要广播的维度,手动验证是否满足规则。等熟能生巧之后可以省掉这一步,但初期不要偷懒。
6.3 argpartition返回的索引是乱序的
np.argpartition的返回值有个容易坑人的特性:它不保证前K个元素按大小顺序排列,只保证它们都在前K个位置内。比如我想取最小的5个距离对应的索引,argpartition(dists, 5)[:5]拿到的可能是[3, 1, 9, 2, 7],乱序的。
这个特点对我的KNN投票逻辑没有影响,因为投票只关心集合,不关心顺序。但如果你后续需要按距离从近到远展示邻居,必须对前K个索引再做一次np.argsort,否则输出顺序是乱的。我因为没注意这个细节,一度怀疑自己的代码有bug——排查了半天才发现是argpartition本身的特性,不是算法逻辑错。
6.4 不同Numpy版本的行为差异
Numpy在1.20版本之前,np.bincount对非整数输入的处理相对宽松,偶尔能带过float数组不报错。但从某些版本开始,输入必须是整数数组,否则直接抛TypeError。我自己就在一次升级环境后遇到了这个问题——之前能跑的旧代码突然报错,查了半天文档才明白是版本行为变化。
另外,np.random.shuffle和np.random.permutation有细微差别:前者原地打乱,不返回新数组;后者返回打乱后的副本,不改原数组。混淆这两个API会导致你打了半天乱,原数组还纹丝不动。这在旧版本Numpy里也一样,属于设计上就容易被人忽略的点。
我的建议是:写完代码后把当前Numpy版本记下来(np.__version__),或者直接固定在某个长期支持版本上。机器学习工程里环境一致性是个大话题,Numpy作为底层库,版本一升,上面所有依赖的行为都可能跟着变。
6.5 手写评估函数的两个细节
准确率是最粗粒度的评估指标,但它在类别不均衡时很容易骗人。比如测试集里80%是某个类别,模型全猜这个类别也能有80%准确率。鸢尾花数据本身均衡,可你得为未来的项目做准备——更稳的做法是看混淆矩阵,即每个真实类别和预测类别的交叉计数。
用Numpy手写一个2x2混淆矩阵很简单(二分类时),多分类时用循环逐类统计:
def confusion_matrix(y_true, y_pred, num_classes): matrix = np.zeros((num_classes, num_classes), dtype=int) for t, p in zip(y_true, y_pred): matrix[t, p] += 1 return matrix这个函数很直接,但在Numpy风格上还能再优化:让所有(真实标签, 预测标签)对一次性进到矩阵累加位置。用np.add.at也可以,原理跟前面按类别求和一样。能看到这里的话,你应该对np.add.at很熟悉了——它就是专门处理带重复索引的累加场景的。
7. 分析结论与方向延伸建议
整个项目跑完,你会得到几个非常确定的结论:第一,Setosa这个类别和其他两类在花瓣特征上的分布几乎不重叠,任意特征组合都能把它完美区分出来;第二,Versicolor和Virginica在花萼特征上有较高的重叠区域,想要区分它们必须依赖花瓣特征,而且即使只用花瓣长度和花瓣宽度,依然存在少量交界处样本;第三,KNN在K=5左右能达到大约95%的准确率,说明特征空间中的类别边界并不复杂,但仍有少数样本位于两个类别边界附近。
基于这些结论,有几条延伸方向可以作为后续练手。一是把鸢尾花数据集换成真实业务数据,比如二手车的价格预测(回归问题),那时你需要把KNN的多数投票改成均值回归,距离计算保持不变。二是试试在Numpy基础上引入Scikit-learn做对照实验,看看手写的KNN和官方实现准确率是否一致——这是个很有效的验证手段,能帮你发现隐藏的bug。三是扩展到高维数据,比如手写数字识别(每张图片28x28像素,展平成784维特征),KNN会立刻暴露出维数灾难问题,距离度量几乎失效,这也反向解释了为什么深度学习在图像领域大放异彩。
从教学角度来看,Numpy版鸢尾花分析最大的价值在于:它把机器学习里“数据准备-特征理解-算法实现-模型评估”的完整链路压缩到一个小而美的项目里,每个环节你都亲手接触过,后面再学Pandas、Scikit-learn时,你会很清楚它们在替你做什么、为什么要那样设计。这是我个人实操中最真实的体会——高级工具永远在帮我们省时间,但只有亲手实现过一遍核心逻辑,你对工具的理解才不是空中楼阁。把这套代码保存好,过几个月数据量大了再回来看,你会感谢当初这个基础打得扎实。