1. 为什么KNN值得单独写一篇笔记
很多人第一次接触cs231n作业1的时候,注意力都被后面的两层神经网络和图像特征抢走了,觉得KNN这个部分太简单,无非就是算距离、投票、看准确率,跑完就翻篇。但我实际做下来发现,KNN恰恰是整个作业1里最能暴露工程思维差距的一环。它算法本身确实简单,可一旦把数据量放大到CIFAR-10的五万张训练图、一万张测试图,再让你用不同的k值、不同的距离度量反复跑,你就会发现向量化写得好不好、内存怎么管、超参怎么选,直接决定了你是等三分钟还是等三小时。
这篇笔记就是围绕KNN这个分类器,把我在实现过程中踩过的坑、想明白的原理、以及那些文档里不会写的调参经验完整梳理一遍。核心关键词包括KNN分类器、向量化距离计算、交叉验证选k值、L1与L2距离对比。不管你是刚打开作业1的新手,还是已经写完但想回头把细节抠清楚的人,应该都能从里面找到点有用的东西。
先说清楚KNN到底解决什么问题。它是最朴素的分类思路:一个样本的类别,由离它最近的k个训练样本投票决定。没有训练过程,没有参数要学,所有计算都堆在预测阶段。这个特点决定了它的优缺点——实现门槛极低,但预测极慢,而且对数据的尺度和维度非常敏感。在CIFAR-10这种32x32x3、展开成3072维的数据上,KNN的表现其实相当一般,但它作为一个教学工具,能把距离度量、超参搜索、向量化这些基础功练扎实。
2. 整体设计思路与方案选型
2.1 为什么作业要用CIFAR-10而不是MNIST
作业1选CIFAR-10是有讲究的。MNIST是28x28灰度图,展开才784维,而且数字的类间差异大、类内差异小,随便一个简单分类器都能刷到很高的准确率,练不出真功夫。CIFAR-10是32x32彩色图,3072维,十个类别里有猫和狗、鹿和马这种容易混的,还有飞机和船这种背景相似的。用KNN去跑,准确率大概在0.28到0.35之间晃悠,这个数字很尴尬,但恰恰能让你直观感受到:光靠像素级的距离,根本抓不住图像的语义。
我一开始也想过,是不是可以用降维把3072维压到几十维再算距离,这样速度快很多。实测下来,用PCA降到64维确实能把训练时间砍掉一大半,但准确率也会掉几个百分点。作业本身没要求降维,所以我的建议是先把原始维度的版本跑通,理解清楚计算瓶颈在哪,再考虑要不要加降维作为扩展实验。
2.2 距离度量的选择:L1还是L2
作业里要求同时实现L1(曼哈顿距离)和L2(欧氏距离)两种度量。这两个的区别不只是公式不同,背后对数据的假设也不一样。
L1距离是各维度差值的绝对值之和,它对异常值更鲁棒,因为不像L2那样把差值平方放大。L2距离是差值平方和开根号,它对大差异更敏感,倾向于把整体差异大的样本推得更远。在CIFAR-10上,我实测L2的准确率通常比L1高一点点,大概一两个百分点,但L1在某些k值下反而更稳。
这里有个容易忽略的点:L2距离开不开根号,对排序结果没有影响。因为开根号是单调函数,最近邻的顺序不变。所以实际实现的时候,为了省一次开方运算,很多人直接比较平方距离。但要注意,如果你后面要做距离的加权投票,那就必须开根号,否则权重会被平方扭曲。
2.3 向量化:从双重循环到完全无循环
作业要求实现三种距离计算方式:双重循环、单层循环、完全向量化。这不是为了炫技,而是让你亲身体会性能差距。
双重循环是最直观的:对每个测试样本,遍历每个训练样本,逐元素算差值。在5000个测试样本和50000个训练样本上,这就是2.5亿次内层循环,Python里跑起来慢到让人怀疑人生。单层循环是固定一个测试样本,用广播一次性算它和所有训练样本的距离,快了一个数量级。完全向量化则是利用矩阵运算,把整个距离矩阵一次性算出来。
完全向量化的核心技巧是利用平方展开:||x-y||² = ||x||² + ||y||² - 2x·y。这样距离矩阵就可以写成测试集范数向量、训练集范数向量和两者矩阵乘积的组合。这个变换把逐元素操作变成了矩阵乘法,能直接调用底层优化过的BLAS库,速度提升非常明显。
注意:完全向量化虽然快,但内存占用会大很多。5000x50000的距离矩阵是2.5亿个浮点数,按float64算就是2GB。如果你的机器内存吃紧,要么分批计算,要么老老实实用单层循环。
3. 核心细节解析与实操要点
3.1 数据预处理的几个关键动作
拿到CIFAR-10数据后,第一步不是急着写分类器,而是把数据形状理清楚。原始数据是50000x3072的训练集和10000x3072的测试集,标签是50000个0到9的整数。3072是32x32x3展开的结果,但展开顺序是通道优先还是像素优先,会影响你后面可视化图像时的正确性。
我习惯先把数据reshape成50000x3x32x32,这样可视化的时候直接转置一下就能用。另外,一定要把数据转成float类型,uint8做减法会溢出,算出来的距离全是错的。这个坑我踩过,当时距离矩阵里一堆负数,排查了半天才发现是数据类型的问题。
数据归一化方面,KNN对尺度敏感,但CIFAR-10的像素值本身就在0到255之间,各维度尺度一致,所以不做归一化问题也不大。不过如果你要做PCA降维,那就必须先做零均值化,否则主成分会被均值主导。
3.2 距离矩阵的内存布局与分块计算
完全向量化的距离计算,最容易出问题的地方是广播的维度对齐。假设测试集是X_test(形状N_test x D),训练集是X_train(形状N_train x D),那么:
- X_test的平方范数是形状N_test的向量
- X_train的平方范数是形状N_train的向量
- 交叉项是X_test @ X_train.T,形状N_test x N_train
把这三个组合起来的时候,前两个向量需要reshape成列向量和行向量才能正确广播。我见过不少人在这里写错,结果距离矩阵形状不对,或者算出来的值完全离谱。
分块计算是解决内存问题的实用技巧。把测试集切成每批500或1000个样本,逐批算距离、逐批预测,最后拼起来。这样内存峰值能控制在几百MB以内,速度损失也不大。分块大小怎么选?我的经验是让每块的距离矩阵控制在100MB左右,也就是块大小乘以训练集数量再乘以8字节不超过这个数。
3.3 k值的选择与交叉验证的实操
k值是KNN唯一的超参数,选得好不好直接影响准确率。k太小,模型对噪声敏感,一个异常点就能带偏预测;k太大,决策边界过于平滑,容易把不同类的样本混在一起。
作业里要求用交叉验证来选k。我的做法是把训练集分成5份,每次用4份训练、1份验证,轮流一遍,取平均准确率最高的k。这里有个细节:交叉验证的每一折都要重新计算距离矩阵,因为训练集和验证集的划分变了。如果你偷懒只算一次距离矩阵然后复用,那验证结果就是错的。
实测下来,CIFAR-10上k在5到20之间准确率比较稳,k=1的时候明显偏低,k超过50之后开始缓慢下降。我一般会跑k=[1, 3, 5, 8, 10, 15, 20, 30, 50]这一组,画个曲线看趋势。选k的时候不要只看最高点,还要看曲线的平坦程度,平坦区域中间的k值泛化能力通常更好。
3.4 预测阶段的投票策略
最简单的投票就是多数表决,k个邻居里哪个类多就选哪个。但这样有个问题:如果k=4,两个类各得2票,怎么选?常见的做法是选距离更近的那个类,或者干脆把k限制成奇数。
更精细的做法是距离加权投票,每个邻居的票数按距离的倒数加权。距离越近权重越大,这样能缓解k值选择带来的边界问题。我在实验里对比过,加权投票在k较大的时候提升明显,k小的时候差别不大。
实操心得:投票的时候用np.bincount或者np.argmax配合one-hot编码,比手写循环快很多。尤其是测试集大的时候,这个优化能省不少时间。
4. 完整实操流程与关键环节实现
4.1 环境准备与数据加载
先把依赖装好,numpy是必须的,matplotlib用来可视化,后面如果要做PCA还需要scipy。数据加载部分,作业提供了pickle格式的文件,读进来之后按批次拼接成完整的训练集和测试集。
import numpy as np import pickle import os def load_cifar10(data_dir): train_data = [] train_labels = [] for i in range(1, 6): file_path = os.path.join(data_dir, f'data_batch_{i}') with open(file_path, 'rb') as f: batch = pickle.load(f, encoding='bytes') train_data.append(batch[b'data']) train_labels.append(batch[b'labels']) X_train = np.concatenate(train_data) y_train = np.concatenate(train_labels) test_path = os.path.join(data_dir, 'test_batch') with open(test_path, 'rb') as f: batch = pickle.load(f, encoding='bytes') X_test = batch[b'data'] y_test = np.array(batch[b'labels']) return X_train, y_train, X_test, y_test加载完之后记得检查形状,X_train应该是50000x3072,y_train是50000。如果形状不对,多半是拼接的时候搞错了。
4.2 距离计算的三种实现
先写双重循环版本,作为正确性基准。这个版本慢,但逻辑最清晰,后面向量化版本的结果要跟它对齐。
def compute_distances_two_loops(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): diff = X_test[i] - X_train[j] dists[i, j] = np.sqrt(np.sum(diff ** 2)) return dists单层循环版本,固定测试样本,用广播算它和所有训练样本的距离:
def compute_distances_one_loop(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): diff = X_train - X_test[i] dists[i, :] = np.sqrt(np.sum(diff ** 2, axis=1)) return dists完全向量化版本,用平方展开:
def compute_distances_no_loops(X_test, X_train): test_sq = np.sum(X_test ** 2, axis=1, keepdims=True) train_sq = np.sum(X_train ** 2, axis=1, keepdims=True) cross = X_test @ X_train.T dists_sq = test_sq + train_sq.T - 2 * cross dists_sq = np.maximum(dists_sq, 0) return np.sqrt(dists_sq)那个np.maximum是为了防止浮点误差导致负数开根号出nan。实测下来,完全向量化比双重循环快几百倍,在5000x50000的规模上,双重循环要跑好几分钟,向量化几秒钟就完事。
4.3 交叉验证选k值的完整流程
交叉验证的核心是把训练集打乱后分成folds份,轮流做验证集。这里要注意,打乱的时候要同时打乱数据和标签,保持对应关系。
def cross_validate_k(X_train, y_train, k_choices, num_folds=5): num_train = X_train.shape[0] fold_size = num_train // num_folds indices = np.random.permutation(num_train) X_shuffled = X_train[indices] y_shuffled = y_train[indices] k_to_accuracies = {k: [] for k in k_choices} for fold in range(num_folds): start = fold * fold_size end = start + fold_size X_val = X_shuffled[start:end] y_val = y_shuffled[start:end] X_tr = np.concatenate([X_shuffled[:start], X_shuffled[end:]]) y_tr = np.concatenate([y_shuffled[:start], y_shuffled[end:]]) dists = compute_distances_no_loops(X_val, X_tr) for k in k_choices: y_pred = predict_labels(dists, y_tr, k=k) acc = np.mean(y_pred == y_val) k_to_accuracies[k].append(acc) return k_to_accuracies跑完之后把每个k的平均准确率画出来,选最高的那个。我一般还会把标准差也看一下,标准差大的k值说明对数据划分敏感,泛化能力可能不稳。
4.4 预测函数的实现细节
预测函数要做两件事:找到每个测试样本的k个最近邻,然后投票。用argsort拿到距离排序后的索引,取前k个,再查对应的标签做投票。
def predict_labels(dists, y_train, k=1): num_test = dists.shape[0] y_pred = np.zeros(num_test) for i in range(num_test): closest_y = y_train[np.argsort(dists[i])[:k]] y_pred[i] = np.argmax(np.bincount(closest_y)) return y_prednp.bincount要求标签是非负整数,CIFAR-10的标签正好满足。如果标签是其他类型,得先做映射。另外,当k个邻居里出现票数相同时,np.argmax会返回最小的那个索引,这算是一个隐式的平局处理策略。
5. 常见问题与排查技巧实录
5.1 准确率异常低怎么办
如果你跑出来的准确率只有0.1左右,基本等于随机猜,那大概率是下面几个问题之一。第一,距离矩阵算错了,比如数据类型没转float导致溢出,或者广播维度不对。第二,标签和数据没对齐,打乱的时候只打乱了数据没打乱标签。第三,预测的时候k值设得太大,把整个训练集都当成邻居了。
排查方法很简单:先用k=1跑一遍,如果准确率还是0.1,那就是距离计算或者数据加载的问题。再用双重循环版本算一个小规模的距离矩阵,跟向量化版本对比,看是否一致。我一般会取前100个测试样本和前1000个训练样本做对比,误差在1e-6以内就算对。
5.2 内存爆了怎么处理
完全向量化在5000x50000的规模上,距离矩阵占2GB左右,加上中间变量可能到4GB。如果你的机器内存不够,有几个办法。一是分块计算,把测试集切成小块,每块算完就预测,不保留完整距离矩阵。二是用float32代替float64,内存直接减半,精度损失可以忽略。三是先做PCA降维,把3072维压到128维,距离矩阵大小不变但计算量小很多。
分块计算的代码大概长这样:
def predict_in_batches(X_test, X_train, y_train, k=1, batch_size=500): num_test = X_test.shape[0] y_pred = np.zeros(num_test) for start in range(0, num_test, batch_size): end = min(start + batch_size, num_test) dists = compute_distances_no_loops(X_test[start:end], X_train) y_pred[start:end] = predict_labels(dists, y_train, k=k) return y_pred5.3 交叉验证太慢怎么加速
交叉验证要跑5折,每折都要算一次距离矩阵,如果每折都跑完整的50000x10000,那时间确实吃不消。我的做法是每折只取一部分训练数据,比如10000个样本,这样距离矩阵小很多,选出来的k值跟全量数据选出来的差别不大。另外,k值的候选列表不用太长,跑个七八个值就够了,画曲线看趋势比精确找最优点更重要。
还有一个技巧是复用距离矩阵。虽然每折的训练集和验证集划分不同,但如果你固定验证集,只改变训练集的子集,那验证集和训练集的距离可以增量计算。不过这个实现起来复杂,作业阶段没必要,知道有这么个思路就行。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 准确率约0.1 | 距离计算错误或标签错位 | 用双重循环对比向量化结果 | 检查数据类型和广播维度 |
| 内存溢出 | 距离矩阵过大 | 查看矩阵形状和数据类型 | 分块计算或转float32 |
| 运行极慢 | 用了双重循环 | 计时对比三种实现 | 改用完全向量化 |
| 交叉验证结果波动大 | 数据划分不均匀 | 看各折准确率标准差 | 增加折数或分层采样 |
| 距离矩阵有负数 | 浮点误差 | 检查平方展开结果 | 加np.maximum截断 |
| k=1准确率正常但k大时崩 | 投票逻辑错误 | 检查bincount输入 | 确认标签是非负整数 |
5.5 几个容易被忽略的实操细节
第一个细节是随机种子的设置。交叉验证打乱数据的时候,如果不固定种子,每次跑出来的结果都不一样,调参的时候会干扰判断。我习惯在打乱之前设np.random.seed(42),这样结果可复现。
第二个细节是距离矩阵的对称性。测试集和训练集的距离矩阵不是对称的,因为两边样本数量不同。但如果你算的是训练集内部的距离矩阵,那它是对称的,可以利用这个性质省一半计算量。不过作业里没这个需求,知道就行。
第三个细节是k值的选择不要只看准确率。有时候两个k值准确率差不多,但一个的决策边界更平滑,泛化能力更好。我的经验是选曲线平坦区域中间的k值,而不是尖峰上的那个。
第四个细节是预测阶段的批处理。如果测试集很大,逐个样本预测会很慢,可以一次性算完距离矩阵再统一预测。但要注意内存,实在不行就分批。
6. 从KNN延伸出去的一些思考
KNN跑完之后,我最大的感受是:它把机器学习的很多核心问题用最直白的方式摆在了你面前。距离度量对应特征工程,k值选择对应超参搜索,向量化对应工程实现,交叉验证对应模型评估。这些东西在后面学神经网络的时候还会反复出现,只是换了个形式。
比如说,神经网络里的卷积核,本质上也是在学一种距离度量,只不过它是从数据里学出来的,而不是像KNN那样固定成L1或L2。再比如说,神经网络的全连接层,可以看成是在做加权投票,权重就是学出来的距离敏感度。理解了KNN,后面看这些概念会顺很多。
另外,KNN的预测复杂度是O(N*D),N是训练集大小,D是特征维度。这意味着训练集越大,预测越慢。这个特性在实际系统里是致命的,因为线上服务要求低延迟。所以工业界基本不用KNN做图像分类,但它作为推荐系统的召回层、作为异常检测的基线,还是有它的位置。理解一个算法的适用边界,比会调它的参数更重要。
我在实现过程中还试过用KNN做图像检索,就是给定一张查询图,返回训练集里最相似的几张。这个应用里k=1就够了,而且距离度量可以换成余弦相似度。实测下来,用L2距离检索出来的结果,相似图片的视觉一致性还不错,但语义一致性就差很多。比如查询一只猫,返回的可能是另一只姿势不同的猫,也可能是颜色相近的狗。这再次说明像素级距离抓不住语义。
最后分享一个调参的小技巧:如果你不确定k值选多少,可以先跑k=1看baseline,然后每次把k翻倍跑,观察准确率的变化趋势。通常准确率会先升后降,峰值附近的k值就是候选。这个方法比网格搜索快,适合快速摸清数据的脾气。