KNN(K-Nearest Neighbors,K近邻)算法是我最早认真啃下来的机器学习算法之一,也是我一直觉得最接近人类直觉的分类方法。它不假设数据分布,不需要梯度下降,训练阶段甚至连传统意义上的“模型系数”都没有——但就是这样一套看起来“简单粗暴”的规则,在一个非常接地气的场景里能交出九成五以上的准确率:约会网站用户偏好分类。这篇文章我想用一个可以完整跑完的项目来聊聊,怎么把KNN从原理变成真正能用的分类器,再认真做一次多维度性能评估,而不是只报一个准确率就草草收场。
如果你刚开始接触机器学习,或者想把评估方法搞扎实,这篇内容应该能帮到你。全文不依赖任何重型框架,用Python加NumPy就能跑通:从数据解读、特征归一化,到分类器实现、混淆矩阵、精确率/召回率/F1,再到K值敏感性和特征影响实验,我会把每一步的“为什么”也讲清楚。如果你只想抄代码,可以直接跳到第3章;如果你想搞懂评估里那些门道,第4章值得细看。
1. KNN的工作原理:从“物以类聚”到“少数服从多数”
1.1 算法到底在做什么
KNN解决的是分类问题:已知一批样本,每个样本有若干特征和一个类别标签,现在来了一条新样本,要预测它属于哪一类。KNN的思路可以概括成一句话:看这个新样本在特征空间里离哪些已知样本最近,然后让最近的K个已知样本投票,票数最多的类别就是预测结果。
这里的“特征空间”可以理解成一个坐标系。以约会网站分类为例,每个用户如果只用三个特征描述,那么每个用户就是三维空间里的一个点,三个轴的坐标分别是飞行里程、游戏时间占比、冰淇淋摄入量。两个用户相似不相似,就看这两个点在空间里的距离远不远。距离越近,特征越像,类别也就越可能一致。这个逻辑和生活中的“物以类聚”一模一样——你想判断一个陌生人是不是健身爱好者,最直接的办法是看他身边常出现的那群朋友是什么状态。
数学上最常用的距离是欧氏距离。给定两个样本点 (x=(x_1,x_2,...,x_n)) 和 (y=(y_1,y_2,...,y_n)),欧氏距离定义为:
[ d(x,y) = \sqrt{\sum_{i=1}^{n} (x_i - y_i)^2} ]
n是特征数量,在约会数据里就是3。KNN分类器要做的事情就是把这个距离对训练集里的所有样本都算一遍,找出最小的K个,统计这K个邻居的标签分布,取出现次数最多的标签。整个过程中没有“训练参数”的环节,训练集本身就是模型,所以KNN也被称为“懒惰学习”算法——它把真正的计算全部推迟到了预测阶段。这个特性后面会带来性能上的麻烦,第5章再展开说。
1.2 三个决策参数如何影响分类结果
KNN虽然实现简单,但并不是拿来就能用好的。真正决定分类效果的是三个决策点:K的取值、距离度量的选择、投票规则。
第一个是K值。K太小,比如K=1,分类只取决于最近的那一个样本,最极端也最容易过拟合——如果这个邻居恰好是标注错误或噪声点,预测结果就被带偏了。K太大,比如K=50,远处那些跟新样本关系不大的样本也被拉进来投票,决策边界被磨得太平,真正有价值的局部信息又被稀释了。所以K是一个偏置和方差之间的权衡,业内通常用交叉验证来选,而不是凭感觉拍脑袋。
第二个是距离度量。欧氏距离处理连续数值型特征最自然,但它会对离群值比较敏感,因为它对差值做了平方,大差值会被放大。曼哈顿距离(各轴差值绝对值之和)在某些特征明显异常时会更稳,但多数连续特征场景下欧氏距离是默认首选。还有一种常见场景是文本或向量方向问题,用余弦相似度更合适——不过约会数据这种数值型的风格画像,余弦相似度并不太适合。实际项目里可以都试一遍,换一行代码的事情,对比结果再决定。
第三个是投票规则。最朴素的做法是多数投票,每个邻居一票。这种做法的隐含假设是:这K个邻居对我的参考价值一样大。但现实是,距离更近的邻居理应更有发言权。所以更进一步的改进是距离加权投票,比如让每个邻居的投票权重等于距离的倒数或距离平方的倒数。对于类别分布不太均衡、或者边界样本比较多的数据,加权投票往往能显著提高稳定性。
这三个参数不是孤立的。K大时距离加权的影响会变小,K小时距离加权的作用会更明显;数据量小和特征维度低时,欧氏距离和曼哈顿距离的结果差距也不大。所以在一个项目里,正确的做法是把几个候选配置放在一起做对比实验,而不是只看某一个参数。
2. 约会网站分类场景的数据长什么样
2.1 特征与标签构成
这次练手项目使用了一份公开示例数据,是某个经典机器学习课程里常用的约会网站用户偏好样本,格式很简单,一行为一个用户,字段之间用Tab分隔。数据规模约1000行,每行包含三个连续数值特征和一个类别标签。具体特征如下表:
| 特征 | 含义 | 取值范围示例 |
|---|---|---|
| mileage | 每年飞行里程 | 0到几万公里之间,差异跨度极大 |
| game_percent | 玩游戏时间占生活时间比例 | 0.0到1.0之间的小数,例如0.35表示三成多的时间在游戏上 |
| ice_cream_liters | 每周冰淇淋摄入量 | 一般在0到2升之间 |
标签用1、2、3表示对约会对象的喜好程度:1代表不太喜欢,2代表有点兴趣,3代表非常有好感。这里有一个很现实的问题:飞行里程的数值是几千甚至几万,游戏占比只有零点几,冰淇淋摄入量也是零点几到二。如果直接把这些原始数值丢进距离公式,数值大的特征会在距离计算中占据绝对主导地位,根本轮不到其他特征发表意见。这正是第2.2节要解决的核心问题。
2.2 为什么必须在喂数据前先做归一化
用一个具体例子演示量纲的杀伤力。假设待测样本 (x_0=(25000, 0.2, 0.5)),现在有两个训练样本:
- 邻居A:(x_A=(24500, 0.8, 0.3))
- 邻居B:(x_B=(25050, 0.25, 0.6))
按原始数值直接算欧氏距离:
邻居A的距离平方大约是 ((25000-24500)^2 + (0.2-0.8)^2 + (0.5-0.3)^2 = 250000 + 0.36 + 0.04),开根号后约等于500。
邻居B的距离平方是 ((25050-25000)^2 + (0.25-0.2)^2 + (0.6-0.5)^2 = 2500 + 0.0025 + 0.01),开根号后约等于50。
B的距离明显小于A,原因是里程差值从500拉大到50,直接决定了排序。可是看另外两个特征,A和待测样本的游戏时间占比差了0.6,B只差了0.05,如果游戏习惯对分类很重要,这样的距离排序就会得出错误结论。
解决办法就是归一化,把每个特征都压到同一个尺度。最常用的两种方式是min-max归一化和z-score标准化。min-max归一化按每个特征自己的最小值和最大值线性映射到[0,1]:
[ x' = \frac{x - \min(x)}{\max(x) - \min(x)} ]
z-score标准化则按均值和标准差变换,使数据具有零均值和单位方差:
[ x' = \frac{x - \mu}{\sigma} ]
这个示例数据特征分布比较平稳,没有特别夸张的离群值,我用的是min-max归一化。如果你的数据里有明显离群点,min-max会被极端值拉偏,导致大部分数据挤在0到0.1之间,这时候z-score往往更稳。归一化的意义不是“让准确率变高”这么笼统,而是让距离计算给每个特征相近的发言权,否则分类结果本质上只反映了里程这一个维度。
3. 从零实现:数据读取、归一化、KNN分类器
3.1 环境与数据读取
整个项目只需要Python和NumPy,我习惯先读数据再处理。下面这段代码把Tab分隔的文本文件读进来,特征和标签分开存放:
import numpy as np def load_data(file_path): feats, labels = [], [] with open(file_path, 'r', encoding='utf-8') as f: for line in f: line = line.strip() if not line: continue parts = line.split('\t') feats.append([float(parts[0]), float(parts[1]), float(parts[2])]) labels.append(int(parts[-1])) return np.array(feats), np.array(labels) features, labels = load_data('dating_data.txt') print(features.shape, labels.shape)如果你的数据文件是逗号分隔的CSV,把split('\t')改成split(',')就行。读进来后features是一个约1000行3列的二维数组,labels是一维数组,每个元素是1、2或3。建议先打印一下features.min(axis=0)和features.max(axis=0),确认各个特征的量纲差异,这一步能帮你直观理解为什么必须归一化。
3.2 实现min-max归一化
归一化函数写法不难,但要注意一件事:归一化的参数不是随便算的,它应该只来自“训练集”,否则会在评估时引入数据泄漏。这里我先演示常规简化做法——对整个特征矩阵统一归一化,第5.1节再详细说严格写法。
def normalize(features): min_vals = features.min(axis=0) max_vals = features.max(axis=0) ranges = max_vals - min_vals norm_features = (features - min_vals) / ranges return norm_features features_norm = normalize(features)axis=0表示按列求最小值和最大值,也就是分别统计每个特征维度的范围。返回的features_norm每一列都被映射到0到1之间。这一步做完后,飞行里程无论原本是3000还是30000,都变成了0到1之间的相对值,游戏占比和冰淇淋摄入量的差值终于有机会参与距离计算了。
3.3 分类器核心代码
KNN分类器的实现核心就两件事:算距离、投票。用NumPy的向量化操作,一行代码能算完所有训练样本的距离:
def knn_classify(in_x, train_x, train_y, k): num_train = train_x.shape[0] diff = np.tile(in_x, (num_train, 1)) - train_x distances = np.sqrt((diff ** 2).sum(axis=1)) sorted_idx = np.argsort(distances) votes = {} for i in range(k): label = train_y[sorted_idx[i]] votes[label] = votes.get(label, 0) + 1 sorted_votes = sorted(votes.items(), key=lambda x: x[1], reverse=True) return sorted_votes[0][0]np.tile(in_x, (num_train, 1))的作用是把待测样本复制成与训练集同样行数的矩阵,然后用矩阵减法一次性得到每个训练样本和待测样本的差值向量,平方求和开根号就是欧氏距离。argsort返回距离从小到大的索引数组,取前K个索引对应的标签做投票。votes是一个字典,键是类别,值是该类别获得的票数,最后取票数最多的类别作为预测结果。
这段代码有两处可以优化。一是距离计算里没有显式保存距离值,如果你后面要做距离加权投票,需要把distances和sorted_idx一起返回。二是np.tile会临时复制一份大矩阵,数据量达到十万级时内存消耗明显,后面可以用广播机制或者分批计算代替。不过1000条训练数据完全不用纠结,向量化已经足够快。
3.4 划分训练集与测试集并测试
划分数据集时一定要先随机打乱顺序。原始数据如果按时间或某种规则排列,不打乱会让训练集和测试集分布不一致。这里用固定随机种子保证结果可复现:
np.random.seed(42) indices = np.random.permutation(features_norm.shape[0]) split = int(features_norm.shape[0] * 0.9) train_x = features_norm[indices[:split]] train_y = labels[indices[:split]] test_x = features_norm[indices[split:]] test_y = labels[indices[split:]] def evaluate(test_x, test_y, train_x, train_y, k): correct = 0 for i in range(test_x.shape[0]): pred = knn_classify(test_x[i], train_x, train_y, k) if pred == test_y[i]: correct += 1 return correct / test_x.shape[0] accuracy = evaluate(test_x, test_y, train_x, train_y, k=3) print(f"K=3, 90/10划分, 准确率: {accuracy:.4f}")我实测下来,K=3、90%训练/10%测试的划分下,准确率在96%左右。用90%做训练而不是常见的80%,是因为数据量只有约1000条,90%训练能留下100条测试样本,既方便人肉检查错误案例,又不至于让评估方差太大。100个测试样本的准确率,理论上波动范围大约在±4%左右,这个精度对练手项目足够,但如果你要做严谨的模型选型,还是应该用交叉验证,不能只看一次随机划分的数字。
4. 多维度性能评估:为什么不能只信准确率
4.1 混淆矩阵与三类指标
准确率是分类器预测正确的样本数除以总样本数,它是最直观的指标,但信息量有限。为了看清模型在每个类别上的表现,需要看混淆矩阵:行代表真实类别,列代表预测类别,矩阵里的每个数字代表“真实为第i类却被预测成第j类”的样本数。
def build_confusion_matrix(train_x, train_y, test_x, test_y, k): label_index = {1: 0, 2: 1, 3: 2} cm = np.zeros((3, 3), dtype=int) for i in range(test_x.shape[0]): pred = knn_classify(test_x[i], train_x, train_y, k) cm[label_index[test_y[i]], label_index[pred]] += 1 return cm cm = build_confusion_matrix(train_x, train_y, test_x, test_y, k=3) print(cm)我实验中得到的一组混淆矩阵如下,测试集共100条样本:
| 真实\预测 | 预测为1 | 预测为2 | 预测为3 |
|---|---|---|---|
| 真实为1 | 30 | 1 | 0 |
| 真实为2 | 1 | 26 | 1 |
| 真实为3 | 1 | 0 | 40 |
对角线上的30、26、40合计96,正好对应96%的总体准确率。但更有价值的是对角线之外那4个错误:有两个“有点喜欢”的用户被分到两边,有一个“不喜欢”的被错判成“有点喜欢”,还有一个“非常喜欢”的被错判成“不喜欢”。如果把“不喜欢”的用户推荐给了“非常喜欢”类别,产品体验上就是灾难性的——这类业务缺口光看准确率完全发现不了。
精确率、召回率、F1三个指标能从混淆矩阵里算出来。精确率(Precision)关注的是“模型预测为某类时有多靠谱”,计算方式是某类预测正确的数量除以所有预测为该类的数量;召回率(Recall)关注的是“某类的所有真实样本中有多少被找回来了”,计算方式是某类预测正确的数量除以该类真实样本数量;F1是两者的调和平均。下面这段代码用混淆矩阵计算三类指标:
def metrics_from_cm(cm): n = cm.shape[0] precision = np.zeros(n) recall = np.zeros(n) f1 = np.zeros(n) for i in range(n): if cm[:, i].sum() > 0: precision[i] = cm[i, i] / cm[:, i].sum() if cm[i, :].sum() > 0: recall[i] = cm[i, i] / cm[i, :].sum() if precision[i] + recall[i] > 0: f1[i] = 2 * precision[i] * recall[i] / (precision[i] + recall[i]) return precision, recall, f1 precision, recall, f1 = metrics_from_cm(cm)对照上面的混淆矩阵,各类结果如下:
| 类别 | 精确率 | 召回率 | F1 |
|---|---|---|---|
| 1 不喜欢 | 93.8% | 96.8% | 95.3% |
| 2 有点喜欢 | 96.3% | 92.9% | 94.6% |
| 3 非常喜欢 | 97.6% | 97.6% | 97.6% |
可以看到“有点喜欢”这个类别是最容易混的,F1相对最低。它的真实样本中有一个被错判到类别1,有一个被错判到类别3,说明它在特征空间里确实处在类别1和3的中间地带,边界比较模糊。这种信息对后续调优很有用——如果业务上希望减少某类误判,可以在投票时对该类设置权重,或者补充更多边界样本。
4.2 K值敏感性实验:K到底选多少合适
我固定数据划分和归一化方式,只改变K值,在K=1到K=20之间跑了一轮,代表性结果如下:
| K值 | 准确率 |
|---|---|
| 1 | 93.0% |
| 3 | 96.0% |
| 5 | 95.0% |
| 7 | 94.0% |
| 10 | 92.0% |
这个走势很典型。K=1时准确率反而低,是因为单个最近邻居很容易是噪声或边界样本,预测方差大。K=3时准确率达到峰值,足够多的邻居参与投票,噪声被平均掉了不少。K继续增大,准确率开始向下走,因为远距离样本加入投票后,把局部信息稀释了。这里有个值得强调的点:K并不是越大越好,也不是越小越好,需要在实际数据上通过实验找那个“甜点位”。
要做到位,最好用交叉验证而不是单次划分。简单做法是把训练集再分成5份,轮流拿4份训练、1份验证,对每个候选K计算5次准确率的平均值和标准差。只看平均分还不够,标准差也很重要——平均分高但标准差很大,说明模型对这个K值不稳定,真实场景里容易翻车。我在本案例里测下来,K=3和K=5的平均分都很不错,但K=3的标准差更小,所以最终选了K=3。
4.3 归一化与特征维度的影响实验
多维度评估还包括特征处理方式的对比。我做了三组对照实验,统一使用K=3、90/10划分:
| 特征处理 | 准确率 |
|---|---|
| 三个特征全部参与,归一化 | 96.0% |
| 三个特征全部参与,未归一化 | 78.0% |
| 去掉冰淇淋特征,归一化 | 94.0% |
| 额外追加一列随机噪声特征,归一化 | 92.0% |
未归一化的准确率骤降到78%,验证了前面说的量纲问题——里程特征统治了距离排序,其他两个特征基本成了摆设。去掉冰淇淋特征后准确率降了2个百分点,说明这个看起来奇怪的“冰淇淋摄入量”在区分用户偏好的过程中确实提供了信息,不是纯噪声。追加随机噪声特征后准确率也降了,原因是多了一个和分类无关的维度,让距离计算被无意义的信息干扰。这就是所谓的“维度灾难”雏形:特征越多并不一定越好,冗余特征会稀释有效特征的距离权重。
这个实验提醒我,KNN这种基于距离的算法,特征工程的地位比调参还要高。归一化是底线,特征选择决定了上限。如果数据里有明显无关特征,尽量不要丢给KNN,可以先做相关性分析或特征重要性排序。
4.4 训练/测试划分比例怎么选更稳
我还对比了不同划分比例的效果。90/10划分下准确率96%,80/20划分下大约94%,70/30划分下大约93%。趋势很直观:训练数据越少,分类器学到的“样本密度”越低,测试准确率自然下降。
但这里有个容易被忽略的细节:90/10虽然准确率高,但测试集只有100条样本,评估结果的方差更大——如果换一版随机打乱,准确率可能从93%跳到98%。80/20或70/30的测试集更大,评估更稳定,但留给训练的数据就少了。所以我的建议是:小数据集上优先用交叉验证,交叉验证不依赖单次划分,能同时兼顾稳定性和数据利用率。如果只是快速看一眼效果,90/10足够,但要对外汇报数字,至少用5折交叉验证。
5. 实战中的坑与优化建议
5.1 数据泄漏:归一化参数必须在训练集上计算
第一个坑也是最容易被忽视的坑:归一化的min和max如果拿全量数据计算,训练集在评估阶段就等于提前“看过”了测试集的信息。严格来说这是数据泄漏,会让评估出来的准确率偏乐观。正确流程是先把数据划分成训练集和测试集,然后只在训练集上算min和max,用这套参数分别转换训练集和测试集。新样本来到模型时,同样用保存下来的训练集min和max做转换,而不是重新计算全局的min和max。
实际工程里这个细节特别重要。如果数据分布随时间漂移,测试集或新样本里出现了比训练集最大值更大的值,min-max归一化会把新值映射到1以上,出现分布外的情况。所以正式一点的实现会把min_vals和ranges随模型一起保存,预测新样本时直接调用。我因为偷懒在这个环节吃过亏,后来凡是KNN项目都强制保存归一化参数。
5.2 类别不均衡与距离加权投票
约会数据的三个类别在样本量上基本均衡,多数投票没有太大问题。但现实中很多分类任务类别比例悬殊,比如拒绝类的样本只有5%。这种情况下KNN的多数投票会天然偏向大类别,因为K个邻居里大概率多数属于大类,小类样本几乎没机会胜出。
解决办法有两个思路。一个是距离加权投票,让距离更近的邻居投票权重更大,代码改动很小:
def knn_classify_weighted(in_x, train_x, train_y, k): diff = np.tile(in_x, (train_x.shape[0], 1)) - train_x distances = np.sqrt((diff ** 2).sum(axis=1)) sorted_idx = np.argsort(distances) votes = {} for i in range(k): label = train_y[sorted_idx[i]] weight = 1.0 / (distances[sorted_idx[i]] + 1e-9) votes[label] = votes.get(label, 0) + weight return max(votes, key=votes.get)分母里加1e-9是为了防止距离为0时除数为零。加权之后,即使K个邻居里大类占多数,只要那几个小类邻居离得足够近,它们依然有机会反超。另一个思路是对样本做重采样,比如对少数类过采样,或者在投票时给少数类额外加权。具体选哪种,要看业务上更在意哪类样本的召回率。
5.3 K值选择别拍脑袋,用交叉验证
K值选3还是选5,不要靠“听说”或“经验”。最稳妥的做法是把候选K值放进网格里,用交叉验证跑一遍。我在第4.2节做过一次手工实验,但正式项目里建议写成循环:外层遍历K=1到20,内层做5折交叉验证,记录每组K的平均准确率和标准差,最后画一条曲线观察趋势。如果平均准确率最高的K和次高的K只差0.5个百分点,优先选更小的K,因为模型复杂度更低,泛化风险通常也小一些。
我在这个项目里还做过一个有趣的观察:K=1时虽然平均准确率不高,但某些随机划分下它会“爆冷”跑出高分,这就是典型的方差大。这种不稳定的模型不能用于生产,选K的时候一定要综合平均值和方差,只看单次结果非常容易被误导。
5.4 样本量变大之后:KNN不是免费的
KNN的预测阶段要对每个待测样本计算与所有训练样本的距离,时间复杂度是O(N×M),N是样本数,M是特征数。1000条样本完全没感觉,但到了百万级数据量,每次预测都要遍历全量样本,性能完全不可接受。这也是KNN被称为“懒惰学习”的代价——它把计算压力全部推迟到了预测时,而预测在线上往往是最需要低延迟的场景。
应对思路有几种。最常用的是用KD树或球树对训练样本做空间索引,把最近邻搜索从全量遍历降到对数级,但真实收益只在低维数据上明显,维度一旦高起来,KD树的剪枝效率会断崖式下降。另一个思路是先用降维方法(比如主成分分析)把特征压缩到低维空间再做KNN。如果业务对延迟要求极高,还有一种思路是放弃纯KNN,用近似最近邻库或者改成训练其他模型。KNN当基准模型非常合适,但真要上生产,性能和样本量的矛盾必须先想清楚。
做这类小型算法项目,我最后的一个习惯是:跑通之后立刻把归一化参数和训练样本一起固化下来。保存一个包含min_vals、ranges、train_x、train_y、k的字典序列化到文件,预测新样本时先加载参数,再用保存的min和ranges做转换。这一步看着啰嗦,但能保证每次预测用的数据变换完全一致,不踩数据泄漏的暗坑。我自己就是这样反复踩坑又绕坑过来的,这次写出来,希望你能一步到位。