简介:图像分类是计算机视觉的基础任务,本资源以Kaggle的Dogs vs Cats数据集为例,提供基于K最近邻(KNN)算法的完整Python实现,适合想通过动手实践理解KNN原理与图像分类流程的初学者。压缩包共4个文件,均为.py脚本,大小仅3KB,涵盖图像缩放归一化等预处理、数据集加载、KNN核心的距离计算与邻居投票算法,以及整合所有流程的主程序模块。目前已有466人学习/下载。资源虽小,但代码结构清晰,覆盖从数据准备、特征提取到模型评估的完整链路;用户可在此基础上替换预训练CNN特征或调节K值,进一步探索分类性能提升。
1. 项目初衷与整体设计思路
1.1 为什么拿KNN做图像分类的入门首选
图像分类这个方向,现在大多数人一上来就扎进深度学习的堆里,PyTorch、ResNet、迁移学习一套组合拳打下来,模型倒是跑通了,但问一句"为什么这个loss曲线长这样""卷积层到底提取了什么特征",多半是支支吾吾的。我始终觉得,跳过经典算法直接上深度学习,就像没学过力学分析就直接去设计桥梁——能搭出来,但心里没底。
K最近邻(K-Nearest Neighbors,简称KNN)恰恰是那个能让你把图像分类的底层逻辑彻底想清楚的项目。它不需要训练过程,没有反向传播,也没有成千上万的参数要调。它的全部思想就一句话:判断一张图片属于哪个类别,就看它和已有的哪一类图片最"像"——给它找出K个最像的"邻居",让这些邻居投票决定它的身份。
这个项目虽然朴素,但作为入门图像分类的起点,它有几个不可替代的价值。第一,它让你直观地理解"图像特征"到底是什么意思——一张图片在计算机眼里就是一堆数字,怎么把这些数字变成"可比较的形态"是整个视觉任务的根基。第二,它能让你感受到维度灾难的威力——为什么图片一放大、像素一增多,距离计算就变得不那么靠谱了。第三,它实现起来极其简单,几十行Python代码就能跑通全流程,你可以在没有任何深度学习框架依赖的情况下,亲手完成一个从加载数据到评估精度的完整闭环。
以我个人的经验来看,这个项目最适合三类人:刚学完Python语法、想做点"真东西"练手的初学者;转行做算法工程、想快速建立图像处理整体认知的开发者;以及在实际工作中需要建立一个"朴素基线"(baseline)来对比后续模型效果的研究人员。做完这个项目,你再去接触卷积神经网络,会清晰很多——你会明白深度学习到底在哪些环节上战胜了传统方法。
1.2 核心需求拆解与技术路线选型
这个项目的核心需求,拆开来看其实就四件事:加载图像数据、提取图像特征、计算相似度、输出分类结果。听起来简单,但每一步都有值得琢磨的设计决策。
先说数据的选取。CIFAR-10数据集是图像分类领域最经典的"练习题"之一,包含10个类别(飞机、汽车、鸟、猫、鹿、狗、青蛙、马、船、卡车),共60000张32x32的彩色图片。这个尺寸非常关键——32x32的像素意味着单张图片只有3072个数值(3个颜色通道乘以1024个像素),计算量完全可以接受,同时又保留了足够的视觉信息,不会像MNIST手写数字那样简单到失去代表性。
再说技术路线。KNN本身没有"训练"阶段,它所谓的"训练"就是把你准备好的特征向量全部存下来。到了预测阶段,计算新图片与所有已存图片之间的距离,排序选出最小的K个,然后统计这K个邻居中哪个类别出现次数最多,就把这个类别作为预测结果。
这个流程里有三个决策点直接决定了最终效果的上限:一是如何衡量两张图片的相似度,欧氏距离(L2)和曼哈顿距离(L1)是两种最基础的选择;二是K值取多大,K值太小容易受噪声干扰,太大又会把其他类别的样本卷进来;三是用什么样的特征来表示一张图片,这是整个系统中变数最大的环节,直接使用原始像素是最简单的办法,但效果往往差强人意,后面我会详细展开。
我自己在做这个项目时采用了"先保底、再优化"的策略:第一版直接用原始像素加L2距离跑通整个流程,拿到一个基准精度;然后再逐步尝试数据归一化、主成分分析(PCA)降维、不同K值对比,每一步改进都能用数字看到效果。这个过程比最终结果本身更有价值——你对每一个环节的影响力会产生非常具体的感觉。
2. 环境准备与数据集处理
2.1 开发环境搭建与依赖安装
这个项目对运行环境的要求非常宽松,我平时写代码用的是一台配置普通的笔记本电脑,没有GPU也能流畅跑完整个流程。Python版本建议3.8以上,我用的是3.10。如果你之前只用过Python做简单的数据处理,那最适合你的组合是Anaconda加Jupyter Notebook——Anaconda帮你管理Python环境和包,Notebook适合边写边看结果的交互式探索,这也是kaggle上很多图像分类项目常见的开发方式。
具体需要安装的库有四个,每个都有明确的用途:
| 库名 | 版本建议 | 用途说明 |
|---|---|---|
| numpy | 1.24+ | 所有数值计算的基础,图像本质上就是numpy数组 |
| matplotlib | 3.7+ | 可视化图像样本和分类结果 |
| scikit-learn | 1.2+ | 提供KNN分类器、PCA降维、train_test_split等工具 |
| tqdm | 4.65+ | 显示循环进度条,跑大批量数据时心里有数 |
安装命令很简单,如果你用的是pip:
pip install numpy matplotlib scikit-learn tqdm如果你用的是conda:
conda install numpy matplotlib scikit-learn tqdm这里我想给你一个建议:不要在全局环境里装包。用conda为这个项目单独创建一个虚拟环境,比如conda create -n knn_image python=3.10,然后激活它再装依赖。我见过太多人在全局环境里装了一堆互相冲突的包,最后排查半天发现是版本问题。
2.2 CIFAR-10数据集下载与预处理
CIFAR-10的数据获取有两条路。一条是用keras.datasets直接下载,如果你装过TensorFlow,这个接口就存在;另一条是去官网下载二进制文件,自己写代码解析。我用的是前一种,因为它直接把数据分割好了训练集和测试集,省去不少麻烦。
from keras.datasets import cifar10 (x_train, y_train), (x_test, y_test) = cifar10.load_data() print(f"训练集形状: {x_train.shape}") # (50000, 32, 32, 3) print(f"测试集形状: {x_test.shape}") # (10000, 32, 32, 3) print(f"类别标签范围: {y_train.min()} - {y_train.max()}") # 0 - 9数据加载之后,有一个细节要特别注意:y_train的维度是(50000, 1),需要flatten()成一维的(50000,),否则后续传递给scikit-learn的分类器时会报维度不匹配的警告。我在第一次跑的时候就被这个坑绊了一下,后来干脆在预处理步骤里统一处理。
预处理这一步,我做了三件事。第一,归一化:把像素值从0-255的范围缩放到0-1之间。为什么这么做?因为KNN依赖距离计算,如果一个特征的数值范围比其他特征大很多,它会在距离计算中占据主导地位,相当于变相给这个特征加了更高的权重,这通常不是我们想要的。具体实现是一个除法:
x_train = x_train.astype('float32') / 255.0 x_test = x_test.astype('float32') / 255.0第二,展平:把每张32x32x3的图片变成一个3072维的向量。KNN分类器要求输入是二维数组,形状为(样本数, 特征数),所以这一步是把三维数组(50000, 32, 32, 3)改成二维(50000, 3072):
x_train_flat = x_train.reshape(x_train.shape[0], -1) x_test_flat = x_test.reshape(x_test.shape[0], -1)第三,抽样:完整的5万张训练集在KNN里计算量很惊人,因为预测一张图片要算5万次距离。为了第一版跑起来快一点,我随机选了一万个样本作为训练子集,两千个样本作为测试子集。这样精度会打点折扣,但调试迭代的速度会快好几倍,等所有逻辑都验证没问题了再上全量数据。
预处理这块,我特别想强调一个原则:所有只依赖训练集的统计计算(比如归一化的最大值、均值),都要先从训练集上算出来,再应用到测试集上。虽然CIFAR-10的像素范围恰好是固定的0-255,但换成其他数据集,如果你在测试集上也单独算了一次归一化参数,数据分布就被人为扰动了,评估结果就不干净。这个习惯要从入门阶段就养成。
3. KNN核心原理与代码实现详解
3.1 从"人以群分"到数学表达
KNN的思想,放到生活里就是"物以类聚,人以群分"。你想判断一个陌生人大概是什么性格,就看他的朋友们都是什么性格;你想判断一个水果是苹果还是梨,就看它在外观特征上和哪一堆已知水果更接近。图像分类的KNN,把这种直觉变成了一个可以在数学上严格执行的过程。
算法的完整流程只有四步。第一步,把训练集中所有图片的特征向量存储起来。第二步,对一张待预测的图片,计算它与训练集中每一张图片的距离。第三步,按照距离从小到大排序,取前K个样本。第四步,对这K个样本的类别标签进行投票,得票最多的类别就是预测结果。
这里最核心的数学概念是距离度量。在图像分类这个场景里,最常用的两种是:
- 欧氏距离(L2):
d = sqrt(sum((x - y)^2)),刻画的是多维空间里两点之间的直线距离 - 曼哈顿距离(L1):
d = sum(|x - y|),刻画的是只能沿着坐标轴方向走的情况下需要走多远
我还是拿一个具体的例子帮你理解。假设有一张图片的特征向量是[0.2, 0.5, 0.8],训练集里有一张猫的图片特征是[0.3, 0.4, 0.7],有一张狗的图片特征是[0.7, 0.8, 0.2]。用L2距离计算,猫的图片距离是sqrt((0.2-0.3)^2 + (0.5-0.4)^2 + (0.8-0.7)^2) = sqrt(0.03) ≈ 0.173,狗的图片距离是sqrt((0.2-0.7)^2 + (0.5-0.8)^2 + (0.8-0.2)^2) = sqrt(0.7) ≈ 0.837。猫的距离更小,所以这张图片更可能属于猫。
L1和L2的区别体现在鲁棒性上。L1距离对异常值不那么敏感,因为它是绝对值的累加,单个维度的巨大差异不会像L2那样被平方放大。但L2在特征连续性较好时通常表现更优,因为它对"各个维度小幅偏离"的累积更加敏感。实际项目中两个都可以试,把选择权交给验证集的精度。
3.2 手写版与Scikit-Learn版双实现
理论学习再充分,不如盯着代码看一遍。我强烈建议你先自己动手实现一个"笨"版本,再引入scikit-learn优化版本,对比着看,理解会非常深刻。
手写版的核心是距离计算和投票逻辑。我用numpy的广播机制一次算出测试样本和所有训练样本的距离:
import numpy as np from collections import Counter class KNNClassifier: def __init__(self, k=5): self.k = k self.X_train = None self.y_train = None def fit(self, X, y): """KNN没有训练过程,这里只是存储数据""" self.X_train = X self.y_train = y def predict(self, X): """预测一批样本的类别""" predictions = [] for i in range(len(X)): # 计算当前样本与所有训练样本的欧氏距离 # 利用广播机制:X[i]形状是(3072,),X_train形状是(10000, 3072) # 相减后得到(10000, 3072),axis=1求平方和再开方 distances = np.sqrt(np.sum((self.X_train - X[i]) ** 2, axis=1)) # 取距离最近的k个样本的索引 k_indices = np.argsort(distances)[:self.k] # 获取这k个样本的标签 k_labels = self.y_train[k_indices] # 投票得出预测结果 label_counts = Counter(k_labels) predictions.append(label_counts.most_common(1)[0][0]) return np.array(predictions)这个版本的逻辑完全暴露了KNN的机制:fit就是在保存数据,predict里的核心步骤就是算距离、排序、取前K、投票。但它的效率极低——每预测一张图片都要循环遍历所有训练样本,而且for循环的Python开销很大。我测试过,预测2000张图片大概要花几分钟,这在项目早期可以接受,但想快速做实验就不太够用。
所以实际项目中我强烈推荐直接用scikit-learn的封装版本,它在底层用了KD树或球树做索引,不需要每次都线性扫描所有样本,效率高得多:
from sklearn.neighbors import KNeighborsClassifier knn = KNeighborsClassifier( n_neighbors=5, # K值 metric='euclidean', # 距离度量方式:欧氏距离 weights='uniform', # 投票权重:所有邻居一视同仁 n_jobs=-1 # 使用所有CPU核心并行计算 ) knn.fit(x_train_flat, y_train_flat) y_pred = knn.predict(x_test_flat)用scikit-learn版本后,预测速度提升了十几倍,而且它还内置了score方法直接计算精度:
accuracy = np.mean(y_pred == y_test_flat) print(f"基础KNN分类精度: {accuracy:.4f}")以我跑下来的结果,使用原始像素、K=5、L2距离,在10000个训练样本、2000个测试样本的条件下,CIFAR-10的分类精度大约在30%-35%之间。坦白说这个数字很低——随机猜的概率是10%,我们只是比随机猜好了一点。但这完全正常,原因有两个:原始像素特征包含大量冗余信息和噪声;KNN在高维空间中会遭遇维度灾难。这恰恰是下一步优化要解决的问题。
3.3 权重模式与距离度量的选择经验
scikit-learn的KNeighborsClassifier里有一个容易被忽略但非常重要的参数:weights。默认值是'uniform',意思是所有K个邻居在投票时权重相同。还有一种模式叫'distance',意思是距离越近的邻居权重越大,比如权重取1/distance。
我做过对比实验:在同样的数据集上,weights='distance'通常比'uniform'精度高1-2个百分点。原因是显而易见的——离目标样本越近的训练样本,其"意见"越有参考价值;那些恰好被选进K个但距离已经很远的样本,可能会引入噪声。如果你的K值选得比较大(比如K=15以上),'distance'模式的优点会被放大,因为它能有效抑制远处样本的干扰。
距离度量方面,metric参数最常用的三个选项是'euclidean'(L2)、'manhattan'(L1)和'cosine'(余弦相似度)。我建议你至少对比L1和L2,因为它们在高维空间下变化趋势不同。CIFAR-10这种自然图像数据集上,L2通常略优于L1,但在某些特定特征(如直方图特征)下,L1或余弦距离反而更好。做实验的时候把这些都写在循环里,用带交叉验证的网格搜索一次跑完,结果一目了然。
4. 从30%到45%:特征工程与优化实践
4.1 为什么原始像素不够用
在深度学习普及之前,图像分类的研究者们一直在和"特征表示"作斗争。你需要明白一个关键问题:一张32x32的彩色图片,在计算机眼里不过是一个3072维的向量,这个向量的每个维度对应一个像素位置上一个颜色通道的亮度值。这种表示方式非常底层,它包含的信息虽然完整,但结构很差。
打个比方,原始像素就像一堆积木的所有零件混在一起,你很难直接看出它们能拼成什么;而好的特征提取,就像是提前按零件形状分好了类,一看就知道哪个是轮子哪个是车身。具体到图像上,原始像素的维度过高,而KNN在距离计算时,所有维度都被平等对待——这就带来了两个问题:其一,大量无关紧要的像素(比如背景的空白区域)干扰了真正重要的板块;其二,高维度下任何两个随机向量之间的距离都会趋于相似,这就是"维度灾难"。
为了在传统方法框架内把精度提升上去,我尝试了三种思路,每种都有它的意义。
4.2 特征提取与降维的实战组合
第一个思路是数据归一化与标准化。我之前已经做了像素值除以255的操作,但这只是把数值范围压缩到了0-1。更严格的做法是用z-score标准化:对每个维度单独减去均值、除以标准差。这样处理的好处是所有特征在距离计算中"权重相当",不会因为某些维度的数值天然偏大而主导距离。实现很简单:
from sklearn.preprocessing import StandardScaler scaler = StandardScaler().fit(x_train_flat) x_train_scaled = scaler.transform(x_train_flat) x_test_scaled = scaler.transform(x_test_flat)注意这里又是同一个原则:scaler只能训练集上fit,然后同时应用到训练集和测试集。
第二个思路是主成分分析(PCA)降维。PCA的思想是找到数据变化最大的几个方向,把高维数据投影到这些方向上。对CIFAR-10来说,3072维降到100-200维左右,既能保留大部分信息,又能显著减轻维度灾难的影响。PCA在scikit-learn里用起来非常方便:
from sklearn.decomposition import PCA pca = PCA(n_components=150) x_train_pca = pca.fit_transform(x_train_scaled) x_test_pca = pca.transform(x_test_scaled)我在实验中把10000张训练图片降到150维后,KNN分类精度从约33%提升到了约38%。这个提升非常可观,而且PCA降维之后计算速度也更快了,属于"一个操作带来双重收益"的典型。
第三个思路是提取更加结构化的特征。原始像素丢失了"空间结构"信息,而图像的语义恰恰由空间结构承载。传统方法里最经典的做法是提取颜色直方图和HOG(方向梯度直方图)。颜色直方图统计每种颜色出现的频率,它对物体位置不敏感,但对颜色分布敏感;HOG统计图像局部的梯度方向分布,对纹理和形状有很好的刻画能力。用代码实现这两者也不算复杂,但篇幅较长,这里我就简单描述思路,有兴趣的话可以自行查阅OpenCV的calcHist和hog模块。
这三种思路最终可以叠加使用:提取颜色直方图特征 -> 标准化 -> PCA降维 -> KNN分类,整体精度能稳定突破40%。虽然这个数字放在今天的深度学习面前不值一提,但你已经能够清晰地感觉到"特征处理"这件事本身的力量。
4.3 用网格搜索确定最优参数
KNN模型的超参数不多,但选不好影响很大。K值是最关键的,它会直接影响决策边界的平滑程度。在CIFAR-10上,K值从1到30之间往往存在一个最优区间。K太小,模型对单个样本的噪声敏感,容易过拟合;K太大,模型的决策边界过于平滑,不同类别的边界变得模糊,也容易欠拟合。
我的做法是固定其他条件不变,循环测试K从1到20的精度。而且我会把这个实验放在交叉验证的框架里做,而不是在测试集上反复尝试——否则调参过程本身就变成了对测试集的"过拟合"。更规范的做法是再从训练集中分出一部分作为验证集(validation set),在验证集上调参,确认最优参数后再在测试集上进行一次性的最终评估。
scikit-learn的GridSearchCV把整个过程封装好了:
from sklearn.model_selection import GridSearchCV param_grid = { 'n_neighbors': [3, 5, 7, 9, 11], 'metric': ['euclidean', 'manhattan'], 'weights': ['uniform', 'distance'] } knn = KNeighborsClassifier(n_jobs=-1) grid_search = GridSearchCV( knn, param_grid, cv=5, scoring='accuracy', verbose=1, n_jobs=-1 ) grid_search.fit(x_train_pca, y_train_flat) print(f"最优参数: {grid_search.best_params_}") print(f"最优交叉验证精度: {grid_search.best_score_:.4f}")网格搜索会遍历参数的所有组合,用5折交叉验证评估每个组合的效果。这相当于把5种K值、2种距离、2种权重的20种组合分别做5次验证,总共跑100次训练和评估。数据量小的时候还好,数据量上来之后相当耗时——我的体验是10000张训练图片跑完这组实验大约需要10-15分钟,可以接受。如果数据量再大十倍,建议先用随机搜索(RandomizedSearchCV)缩小范围,再用网格搜索精调。
5. 常见问题与排查技巧实录
5.1 精度低得离谱:先检查数据预处理链路
很多人在跑通第一版代码后,发现精度只有20%出头,甚至接近随机猜测,第一反应是换算法。但我负责任地告诉你,90%的低精度问题出在数据预处理链路上。请你按以下顺序排查:
- 归一化是否做了:原始像素值范围0-255和0-1,距离计算的量纲完全不同。未归一化的高维数据会导致距离被少数像素主导。
- 数据形状是否混乱:
y_train到底是(10000,)还是(10000,1)?x_train到底是(10000, 32, 32, 3)还是(10000, 3072)?用print(数组.shape)逐步确认。 - 训练集和测试集是否被污染:如果在预处理时让测试集信息提前进入训练集(比如全量数据做标准化再划分),验证结果就虚高了。正确做法是先划分,再fit预处理参数。
- 类别标签是否对齐:手写版本中
argsort后取的索引要确保能和y_train对应,否则可能出现"张冠李戴"的分类错误。
我记得有一次排查了一个下午,最后发现是reshape的时候顺序不对,把通道维度和空间维度搞混了,图片变得面目全非。这种事情看起来低级,但实操中特别容易发生。
5.2 训练慢、预测慢:性能瓶颈在哪里
KNN真正的性能瓶颈在预测阶段,因为每预测一个样本,都要计算它和所有训练样本的距离。当训练集有5万张图片时,预测一张新图片就要跑5万次距离计算,这是一个O(N·D)的复杂度,N是样本数,D是特征维度。
有几个实用的提速办法。第一,用n_jobs=-1参数让scikit-learn利用多核并行。第二,用PCA降维降低D,距离计算本身会变快。第三,在scikit-learn的algorithm参数中选择'kd_tree'或'ball_tree',它们会建立索引结构,避免线性扫描所有样本。在低维数据上KD树效果很好,但维度太高(几百维以上)时,树结构的优势会被削弱,反而退回暴力计算。所以PCA降维后再配合KD树,是比较合理的组合。
还有一个常见的工程做法是批量预测,不要一条一条predict。把整个测试集一次性传入,scikit-learn内部会做矩阵运算优化,效率高很多。这也是为什么前面的代码都是直接传x_test_flat而不是循环里单个样本。
5.3 数据不平衡:投票机制的"从众效应"
如果数据集里某个类别的样本特别多,KNN在边界区域很容易把新样本预测到这个大类里,这被称为投票机制的"从众效应"。当你在实际项目中遇到这类问题时,最简单的办法是调整weights参数为'distance',让近邻样本的权重压过远处的大类样本。再一个办法是改用带权重投票的等效实现,或者对数据进行过采样/欠采样,这些在scikit-learn里有对应的工具可以组合使用。
在CIFAR-10这个数据集上,每个类别是均匀分配的(每个类6000张图),所以这个问题不明显,但你自己收集数据做项目时几乎一定会碰到,这个知识点要提前记住。
6. 项目总结与个人实操体会
这个KNN图像分类项目做下来,我最强烈的感受是:它用一个极其简单的算法,把一个很核心的命题摊开在你面前——在数据不被充分理解的情况下,再聪明的分类器也救不回来。你看着30%的精度觉得沮丧,但当你一步步做归一化、降维、特征提取,眼睁睁看着精度一点点爬升的时候,你会非常具体地体会到什么叫"数据决定了模型的上限,算法只是在逼近这个上限"。
最后分享两个小技巧,是我在实际操作中摸索出来的。一是做实验一定要有记录习惯,每改一个参数就把精度和耗时记录下来,否则十几个组合跑完,你根本记不清哪个参数配哪个结果,会浪费大量时间重复实验。二是要善于用可视化理解数据——把分类错误的图片打印出来看看,你会直观地发现KNN错在哪里:比如它把"猫"分成了"狗",看图之后你可能会感慨"这图像确实狗里狗气的",尤其是低分辨率下的CIFAR-10图片,人眼都认不出来的比比皆是。这种感受能帮助你建立对模型能力边界的合理认知。
这个项目往后扩展的方向也很多。你可以尝试自己提取HOG特征或颜色直方图特征来替代原始像素;也可以尝试用降维后的特征做可视化,理解数据分布;甚至可以对比KNN和线性分类器在同一特征上的表现差异。无论接下来你往哪个方向发展,KNN作为一个简单而稳固的baseline,都会是你衡量其他模型"到底有没有变好"的参照物。这条从30%到40%的探索之路,值得你亲自走一遍。
本文还有配套的精品资源,点击获取