ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

从零实现KNN图像分类:CIFAR-10实战与优化指南

从零实现KNN图像分类:CIFAR-10实战与优化指南 简介这套K近邻KNN图像分类Python代码面向机器学习初学者与计算机视觉入门者帮助掌握非参数监督学习在图像分类中的实际用法。压缩包共4个.py文件体积仅3KB分别承担数据预处理、数据集加载、KNN分类器核心逻辑与主控制流程代码分层清晰可直接运行于Kaggle狗猫数据集。目前已有466人浏览学习通过代码可完整走通图像缩放、灰度化与归一化、样本距离计算、K近邻投票等关键流程并灵活调整K值与距离度量观察准确率变化从而直观理解参数对分类边界的影响。项目还保留了扩展接口可替换为预训练CNN提取特征再送入KNN便于对比不同特征表示下的分类表现即便在无预训练环境时也可用原始像素快速验证算法效果整体轻量易调试是课程设计或算法入门的上手资料。 图像分类这个题目我见过太多人一上来就直奔深度学习模型反而把最基础的K最近邻KNN扔到一边。说实话用Python把KNN图像分类从零实现一遍远比想象中有意思也远比想象中坑多。这个算法简单到一句话就能讲完——找到距离最近的K个训练样本让它们投票决定测试样本的类别——但真要做一次完整的代码实现你会撞上各种教科书没写清楚的问题图像数据到底怎么表示、距离计算怎么才能不慢、K值怎么定、为什么预测阶段卡到怀疑人生。这篇文章就把我自己从手写裸代码到在CIFAR-10上跑通KNN的完整过程拆开讲。适合刚入门机器学习、想把算法原理落到实处的读者也适合那些已经会用sklearn调包、但始终不太理解背后在发生什么的人。我不打算只给一段能跑的程序而是把每一步为什么这么写、不这么做会踩什么坑都讲清楚。1. 冷启动为什么用KNN做图像分类以及它真正能解决什么问题1.1 算法本身只有一句话但图像分类四个字不简单KNN的核心逻辑不超过一句话给定一个待预测样本在训练数据里找到距离最近的K个样本让它们投票决定这个样本属于哪一类。这里真正需要把握的是三个东西距离怎么定义、K取多少、票怎么投。三者共同决定了这个分类器的行为。KNN属于非参数模型而且是惰性学习模型。所谓惰性是指它在训练阶段基本什么都不学只是把你给的数据原封不动存下来真正的工作全部堆到预测阶段才开始算。这个特性在后面会带来非常明显的性能问题你现在先记住这个词。但是到了图像分类情况就不一样了。一张32x32x3的彩色图片展平后是一个3072维的向量。问题是这个向量背后的语义结构非常复杂同一只猫只要平移几个像素产生的像素级差异可能比猫和狗之间的差异还要大。KNN天然依赖距离度量来定义相似性而原始像素空间里的欧氏距离和人对图像类别的感知并不一致。这是第一个要从认知上打破的误区。1.2 在CIFAR-10上的真实预期你可能连一半准确率都跑不到CIFAR-10是我在本文里用来做实验的数据集10个类别飞机、汽车、鸟、猫、鹿、狗、青蛙、马、船、卡车一共6万张32x32的彩色小图其中5万张训练、1万张测试。用KNN加上原始像素的L2距离去跑CIFAR-10准确率大概在三成到四成这个区间而一个简单的卷积神经网络轻松可以到90%以上。很多第一次动手的读者看到这个数字会失望觉得KNN是不是不行。这里我想把话说清楚不是KNN这个算法不行而是原始像素欧氏距离这个组合本身不具备语义可比性。三成多的准确率恰好说明了这个事实也解释了为什么深度学习要学习特征表示而不是直接拿原始像素做算术。那为什么还要学它因为KNN是理解分类器原理的最小可运行样本。它把距离度量、交叉验证、维度灾难这些核心概念全部浓缩在一个几十行的程序里。通过它建立起baseline意识后面你再去看任何复杂模型都不会觉得那是黑盒。2. 数据准备图像不是直接扔进算法的这几步不做准翻车2.1 CIFAR-10的数据格式与加载细节CIFAR-10从官网下载解压后你会看到一串文件data_batch_1到data_batch_5、test_batch、batches.meta。每个batch文件都是Python的pickle格式里面有两个关键字段data和labels。data的shape是(10000, 3072)dtype是uint8。也就是说每个batch里有一万张图每张图已经按通道展平成了3072维的向量。初学者最容易在这里被坑这个展平顺序不是随便排的。CIFAR-10的通道排列是前1024个数值属于R通道接着1024个属于G通道最后1024个属于B通道。如果你要把它还原成能看的图像得先用np.reshape把它变成(32, 32, 3)而且不能把通道顺序搞反。下面这段代码负责加载单个batch文件import pickle import numpy as np def load_cifar10_batch(file_path): with open(file_path, rb) as f: batch pickle.load(f, encodingbytes) X batch[bdata] # (10000, 3072), dtypeuint8 y np.array(batch[blabels]) # (10000,) return X, y加载完之后我强烈建议先随机抽几张图用matplotlib画出来看一眼确认数据没有读错。这个动作只要十秒钟却能在后面省下好几个小时的定位时间。别问我为什么强调这种鬼事数据加载错了后面所有结果都是错的而这类错误往往最隐蔽。2.2 展平、归一化、减均值每个操作背后的理由原始数据的dtype是uint8值域0到255。如果直接拿去算距离会出现一个尴尬的问题距离计算时数值大的通道天然会主导结果但图像的语义并不应该因为某个通道像素值整体偏大就被带偏。所以第一步要做归一化把所有像素除以255把值域压到[0, 1]。减均值这一步很多人会忽略但它对KNN这种依赖距离的算法挺关键。减去均值相当于把数据整体做一个平移消除图像亮度的直流分量。比如某个类别整体偏亮如果不去均值亮度本身的差异就会成为距离的主导项而这不是我们想关注的类别差异。这里有一个最容易踩的坑均值必须在训练集上计算然后再把同一个均值减到验证集和测试集上。你要是图省事把全部数据放在一起算均值就造成了数据泄漏后面验证出来的准确率是虚高的换到真实场景立刻打回原形。def preprocess(X_train, X_val, X_test): X_train X_train.astype(np.float32) / 255.0 X_val X_val.astype(np.float32) / 255.0 X_test X_test.astype(np.float32) / 255.0 mean X_train.mean(axis0, keepdimsTrue) X_train - mean X_val - mean X_test - mean return X_train, X_val, X_test注意我在这里用了float32而不是float64。对于3072维的数据float64的矩阵内存占用是float32的两倍而精度对KNN结果几乎没有影响。这种内存抠门的习惯在数据量大时会救你命。2.3 宁可先跑子集规模估算与采样策略如果你一上来就把全量CIFAR-10塞进KNN很快就会撞上内存墙。试算一笔账测试集1万张、训练集5万张完整距离矩阵的形状是(10000, 50000)也就是5亿个浮点数即使按float32算也要占据2GB内存。加上排序、索引、副本内存可能直接翻倍。所以我的建议永远是先抽子集把整个流程跑通确认逻辑没问题再考虑要不要上全量。CIFAR-10每次抽子集时最好按类别均匀采样不要随手乱抽否则某些类别可能缺样本实验结果完全不可信。def sample_per_class(X, y, per_class500): idx [] for c in range(10): idx.append(np.where(y c)[0][:per_class]) idx np.concatenate(idx) return X[idx], y[idx]先用每类500张做训练、每类100张做验证整个流程几秒钟就能跑完。这个规模下你能快速验证代码正确性再逐步加大数据量而不是一开始就坐在那不切实际地等结果。3. 核心代码实现距离计算、排序取K、投票判别的完整链路3.1 最直观的嵌套循环版本能看不能用的反面教材我先给出一个最容易理解的实现方式两重循环去算每个测试样本和每个训练样本之间的距离def compute_distances_naive(X_test, X_train): num_test X_test.shape[0] num_train X_train.shape[0] dists np.zeros((num_test, num_train)) for i in range(num_test): for j in range(num_train): dists[i, j] np.sqrt(np.sum((X_test[i] - X_train[j]) ** 2)) return dists这个版本逻辑完全正确但对于5000个训练样本、1000个测试样本、3072维特征来说它要做500万次内层循环每次循环里还要做3072维的向量相减和求和。Python解释器在这个量级下会慢到让你怀疑人生整个预测可能要跑到几分钟起步。这个版本最大的价值是拿来和后面向量化版本对拍验证。你先用它在极小数据上跑一遍结果确定逻辑正确再用向量化版本跑同样数据两个结果应该一致。这样你就有信心优化没有改变算法行为。3.2 向量化一个矩阵展开式解决所有距离问题KNN全部性能瓶颈几乎都在距离计算上。要提速核心思路是把逐个算距离变成一次矩阵运算算完所有距离。这里用到一个非常经典的展开式。L2距离的平方可以写成[ |x - y|^2 |x|^2 |y|^2 - 2 x \cdot y ]也就是说任意两个样本的距离平方都可以拆成两项独立的模长平方再减去两倍的向量内积。放到整个矩阵上就是三个矩阵操作的事X_test每个样本的模长平方按行求和X_train每个样本的模长平方按行求和X_test和X_train每一对样本的内积就是X_test X_train.T这一个矩阵乘法我之前写过一个分块版本因为当数据量大了以后一次性把整个距离矩阵塞进内存可能撑不住。分块处理可以在内存和速度之间做平衡def compute_distances_vectorized(X_test, X_train, chunk_size500): num_test X_test.shape[0] num_train X_train.shape[0] dists np.zeros((num_test, num_train), dtypenp.float32) for start in range(0, num_test, chunk_size): end min(start chunk_size, num_test) chunk X_test[start:end] chunk_sq np.sum(chunk * chunk, axis1, keepdimsTrue) # (chunk_size, 1) train_sq np.sum(X_train * X_train, axis1, keepdimsTrue).T # (1, num_train) cross np.dot(chunk, X_train.T) # (chunk_size, num_train) sq_dists chunk_sq train_sq - 2.0 * cross sq_dists np.maximum(sq_dists, 0) # 清理浮点误差产生的负数 dists[start:end] np.sqrt(sq_dists) return dists这里面有两个值得注意的细节。第一np.maximum(sq_dists, 0)不是可有可无矩阵运算里浮点舍入误差可能让理论为0的值变成-1e-8这种极小负数后面开方会报错或者产生nan。第二如果只是为了找最近邻理论上可以不开平方因为开方是单调函数不影响排序结果。但保留平方根更符合距离的语义后面如果要对比其他距离度量也方便。3.3 邻居投票与最终的准确率评估距离矩阵拿到之后剩下的逻辑就变得很直白对每一行做升序排序取前K个下标再把这些下标映射到训练标签上然后投票。一个实用技巧是优先用np.argsort而不是np.sort因为你要的是索引而不是距离值本身。投票时np.bincount再适合不过它会统计每个类别出现的次数argmax直接返回票数最多的类别def predict_knn(dists, y_train, k5): num_test dists.shape[0] y_pred np.zeros(num_test, dtypeint) for i in range(num_test): nearest_idx np.argsort(dists[i])[:k] nearest_labels y_train[nearest_idx] y_pred[i] np.bincount(nearest_labels).argmax() return y_pred整个主流程组装起来就是这样if __name__ __main__: X_train, y_train load_cifar10_batch(cifar-10-batches-py/data_batch_1) X_test, y_test load_cifar10_batch(cifar-10-batches-py/test_batch) X_train, y_train sample_per_class(X_train, y_train, 500) X_test, y_test sample_per_class(X_test, y_test, 100) X_train, X_val, X_test preprocess(X_train, X_test, X_test[:0]) # 演示用实际请按验证集切分 dists compute_distances_vectorized(X_val, X_train) y_pred predict_knn(dists, y_train, k5) acc np.mean(y_pred y_val) print(fvalidation accuracy: {acc:.4f})这里我故意先留了一个粗糙的切分写法实际你应当按照训练集、验证集的三分方式来切分而不是只拿0张样本做验证。后面调K的那一节会给出完整的交叉验证写法。如果你只是为了做工程而不管原理sklearn里一行KNeighborsClassifier就能完成同样的事还内置了KD-Tree等加速策略。但我建议至少手写一次这个实现只有自己写过你才能体会distance / argsort / bincount这些东西在内存和时间上到底在发生什么。4. 调参和优化K值、距离度量、PCA这些变量能带来多大变化4.1 用交叉验证定K别拍脑袋K值可能是KNN里最关键的参数。K1时分类结果完全由最近的一个邻居决定对噪声极度敏感训练集上准确率永远是100%但验证集上往往表现很差这就是典型的过拟合。K如果太大比如几百那分类边界会被过度平滑很多细节被抹掉训练集准确率反而下滑。正确做法是从训练集里再切出一块验证集然后对一组K值逐个测试选出验证集准确率最高的那个K。这个过程叫交叉验证核心思路是用验证集模拟测试集调参时绝不碰测试集。def train_val_split(X, y, val_ratio0.1, seed0): rng np.random.RandomState(seed) n len(X) idx rng.permutation(n) n_val int(n * val_ratio) return X[idx[n_val:]], y[idx[n_val:]], X[idx[:n_val]], y[idx[:n_val]]我在自己的子集上跑过一次K值扫描结果大致如下表所示数值示意不同子集会浮动但趋势一定类似K值训练集准确率验证集准确率1100%28%3约73%31%5约65%33%7约58%34%10约53%33%15约48%31%20约44%30%从这张表能清楚看到K7左右是验证集表现的峰值再往上增长准确率开始回落。这就是选K的实操思路永远让数据帮你决定而不是拍脑袋说我觉得K5比较好。这里还要强调一个原则调参全程只用验证集测试集只能最后评估一次。如果你拿测试集反复试K那测试集就变成了验证集最终评估结果会虚高失去可靠性。4.2 L1、L2与原始像素的语义天花板除了K值距离度量的选择也会影响结果。L1距离是各维度绝对差之和L2距离是平方和再开根号。两者在行为上有一个直观差异L2会把大的偏差平方放大所以对个别维度上的离群噪声更敏感L1在多数情况下更稳健一些。但在CIFAR-10上用原始像素做KNN不管换L1还是L2准确率基本都在三成到四成这个区间内小幅浮动。这个结果其实在告诉我们一个更本质的问题像素级距离和语义距离之间有巨大的鸿沟。一张图里狗和猫的差异并不体现在欧氏空间里某几个像素跳跃的大小上而是体现在纹理、轮廓、结构这些高层特征上。KNN在原始像素上做图像分类碰到的就是这层天花板。这个认知对你后续理解深度学习特别重要。CNN之所以能碾压KNN不是因为它用了更复杂的数学而是它自己学会了从数据中提取语义特征把图像投影到了一个距离更有意义的特征空间里。4.3 PCA降维又提速又提精度的免费午餐KNN对高维距离极其敏感这可以用维度灾难来解释当维度升高时任意两个样本之间的距离都会趋向于拉平最近邻和次近邻之间的差距变得微不足道分类器的判别力自然下降。而图像原始像素是3072维里面大量维度是相邻像素的冗余还有一些维度纯粹是噪声。PCA通过SVD把数据投影到方差最大的主成分方向上可以在降维的同时干掉一部分噪声维度。在KNN这个场景里降维不仅让计算量大幅减少验证集准确率有时还会变高这在很多实验里都存在。用numpy实现PCA其实不需要装额外库def fit_pca(X_train, n_components200): mean X_train.mean(axis0, keepdimsTrue) X_centered X_train - mean _, _, Vt np.linalg.svd(X_centered, full_matricesFalse) W Vt[:n_components].T # (3072, n_components) return mean, W def pca_transform(X, mean, W): return (X - mean) W用的时候注意fit_pca只用训练集得到的mean和W要保存下来之后用同样的mean和W变换验证集和测试集。这是和前面减均值一样的道理绝不能让主成分的信息在拟合阶段接触到验证或测试数据。我自己实测下来在5000张训练样本上把3072维压到200维预测速度提升非常明显验证集准确率基本持平甚至略高。所以说PCA算是一个在KNN场景里免费的午餐。5. 惰性学习的性能之痛训练瞬间完成预测却等到怀疑人生5.1 惰性学习的本质训练零成本反而更麻烦前面提到KNN是惰性学习模型训练阶段只是把数据存下来真正的工作全部堆到预测阶段。这就带来一个反直觉的后果训练阶段几乎不花时间但每预测一个新样本都要和全部训练数据算一遍距离。复杂度可以很直观地算出来预测一个样本要计算它在D维空间里与N_train个训练样本的距离也就是O(N_train * D)。如果训练集有5万张图、特征维度3072维那每预测一个样本就要做约1.5亿次浮点运算。1万张测试图全部预测完总计算量是天文数字。这也是为什么全量CIFAR-10在KNN上跑起来让人崩溃的原因。对比一下参数化模型比如逻辑回归或神经网络。它们训练阶段很慢需要迭代更新参数但训练完之后预测一个样本只需要一次前向传播耗时极小。KNN恰恰相反这种差异是模型结构决定的不是代码优化能彻底解决的。5.2 实战救急方案降维先行、分块计算、必要时上近似搜索既然KNN的预测复杂度集中在全量距离计算上实际使用时可以从几个方向救急。第一先降维。用PCA把维度从几千压到几百距离计算的时间几乎是线性缩减。这一步本文还有配套的精品资源点击获取
返回列表