
简介面向机器学习初学者的 KNN 算法实战项目包围绕“改进约会网站的配对效果”这一经典场景展开适合希望把算法原理落到真实数据上的读者。压缩包共 4 个文件两个 txt 数据文件分别作为训练样本与测试样本配合 KNN.py 源码与 Readme 说明整体仅 29KB轻量但覆盖了从数据准备到结果验证的完整流程。通过这套小项目读者可以从读取数据、特征归一化、构建分类器再到测试评估完整体验 KNN 基于距离度量进行类别判断的机制同时理解数据预处理、样本划分对分类效果的影响并验证算法对约会配对效果的改进。附带的 Readme 能帮助快速理清文件用途源码结构简洁、依赖少便于在本地运行或按需修改。已有 959 人学习适合作为课程实验、毕业设计或自学者入门机器学习的动手练习能在较短时间内建立对 KNN 算法的直观认识。1. KNN算法改进约会网站配对效果这份源码和数据集到底在解决什么问题很多人第一次接触KNN算法跑的都是鸢尾花数据集训练完就结束了对KNN能在真实业务里长什么样完全没有概念。这份资源把KNN算法放进了一个特别具体的场景——约会网站配对。任务很简单根据一个人的三个数值特征判断 TA 是值得约会还是不值得约会。反直觉的地方在于KNN 连训练过程都没有纯粹靠样本之间的距离投票却能在这个数据集上稳定拿到 95% 左右的准确率。对初学者来说这是理解 KNN 原理、特征归一化必要性、分类效果评估方式最完整的一套入门实例。资源本身只有两个 txt 数据文件、一个 KNN.py 源码和一个 Readme适合刚学完 Python 基础、准备上手第一个机器学习项目的读者。2. 数据集与特征选型datingTestSet.txt 的三个特征为什么够用2.1 文件清单两个 txt 加一个 KNN.py 构成的最小闭环解压这份资源后核心就四个文件结构非常干净文件作用datingTestSet.txt带英文文本标签的原始数据集约 1000 条样本datingTestSet2.txt数值型标签版本适合直接做矩阵运算KNN.py数据解析、归一化、KNN 分类器、测试代码全部在这里Readme简单说明数据格式和运行方式两个数据文件格式上几乎一样唯一的区别在最后一列。datingTestSet.txt 最后一列是字符串liked或dislikeddatingTestSet2.txt 最后一列是数字3对应 liked2对应 disliked。为什么要准备两个版本因为字符串标签在 NumPy 矩阵运算里需要先做映射而数字标签可以直接当作标签向量传入分类器省掉一次类型转换。初学阶段建议先把两个文件都打开看一遍确认分隔符是 Tab 而不是空格这一点直接关系到后面解析会不会翻车。2.2 特征设计逻辑三个数值从三个维度刻画约会偏好先别急着跑代码先把特征的语义理清楚。每条样本的前三列分别是每年飞行常客里程数、玩视频游戏消耗时间百分比、每周消费冰淇淋公升数。这三个特征看起来随意实际上对应着三个维度工作节奏、兴趣爱好、生活习惯。飞行常客里程数高的人大概率经常出差、生活节奏快游戏时间占比高的人偏好室内活动冰淇淋消耗量则反映了一个人对甜食和生活品质的态度。三个特征彼此独立没有明显的重复信息。这对 KNN 很关键因为 KNN 不做特征筛选特征之间的相关性会直接扭曲距离计算。如果三个特征高度相关相当于某个维度被重复加权如果某个特征方差过大又会压制其他特征。这个数据集的选型刚好避开了这两个问题。2.3 归一化特征范围差一个数量级距离计算就失控拿到数据后第一件事应该是看每个特征的数值范围。飞行常客里程数动辄几千上万游戏时间百分比在 0 到 100 之间冰淇淋公升数一般是个位数。如果直接用原始数值算欧氏距离那么每飞一公里对距离贡献的权重约等于游戏和冰淇淋特征全部失效。换句话说KNN 实际上退化成只看飞行里程数的单特征分类器。最常见的处理方式是做极差归一化把每个特征都压缩到 0 到 1 的区间import numpy as np def auto_norm(data_set): # 按列计算每个特征的最小值、最大值和范围 min_vals data_set.min(0) max_vals data_set.max(0) ranges max_vals - min_vals m data_set.shape[0] # 复制 min_vals 到和 data_set 同样形状再逐元素做减法和除法 norm_data data_set - np.tile(min_vals, (m, 1)) norm_data norm_data / np.tile(ranges, (m, 1)) return norm_data, ranges, min_vals逻辑说明data_set.min(0)是按列取最小值返回一个长度为特征数的数组。np.tile(min_vals, (m, 1))把这个数组在行方向复制 m 次得到一个和原数据形状一致、每一行都是最小值的矩阵这样就可以整体做矩阵减法不需要写循环。除法同理最后每个特征都落到 0 到 1 区间。这里有一个关键点ranges和min_vals必须保存下来。后面分类新样本时需要用训练阶段算出来的同一组参数做变换不能对测试数据重新求 min 和 max。很多初学者在这里丢掉参数导致训练结果和测试结果对不上后面避坑章节会细讲。3. KNN.py 源码剖析从数据读取到分类输出的完整流程3.1 file2matrix把文本解析成特征矩阵和标签向量KNN.py 里承担数据解析的是file2matrix。常见做法是按行读取用 Tab 切分前三个元素转成浮点数放进特征矩阵最后一列根据文件类型决定怎么处理标签def file2matrix(filename, label_modetext): fr open(filename, encodingutf-8) array_lines fr.readlines() number_of_lines len(array_lines) return_mat np.zeros((number_of_lines, 3)) class_label_vector [] index 0 for line in array_lines: line line.strip() list_from_line line.split(\t) # 前三个字段是特征值 return_mat[index, :] list_from_line[0:3] if label_mode text: # 处理 datingTestSet.txt 的字符串标签 label list_from_line[-1] if label liked: class_label_vector.append(3) else: class_label_vector.append(2) else: # 处理 datingTestSet2.txt 的数字标签 class_label_vector.append(int(list_from_line[-1])) index 1 return return_mat, class_label_vector逻辑说明readlines()一次性把整个文件读进内存对 1000 条这种规模的数据没问题如果换到几十万条不建议这么写。strip()去掉行尾的换行符split(\t)按 Tab 切分后list_from_line[0:3]是三个特征字符串直接赋值给矩阵行时会自动转成浮点。标签部分用label_mode区分两种文件的解析方式。参数说明encodingutf-8在 Python3 里需要显式指定。如果文件是用 Windows 记事本编辑过的编码可能是 GBK 或带 BOM这里会直接抛UnicodeDecodeError处理方式放到避坑章节。label_mode是自定义的开关参数默认走字符串标签逻辑换成numeric就读取数字标签版本。3.2 classify0距离计算、排序、取前 K 个邻居、投票KNN.py 的核心分类函数逻辑拆开就是四个步骤。第一步算输入样本到全部训练样本的欧氏距离第二步按距离排序第三步取前 K 个邻居对应的标签第四步统计票数票数最多的类别就是预测结果def classify0(in_x, data_set, labels, k): # 第一步计算欧氏距离 data_set_size data_set.shape[0] diff_mat np.tile(in_x, (data_set_size, 1)) - data_set sq_diff_mat diff_mat ** 2 sq_distances sq_diff_mat.sum(axis1) distances sq_distances ** 0.5 # 第二步距离升序排序返回索引 sorted_dist_indicies distances.argsort() # 第三步统计前 k 个邻居的标签票数 class_count {} for i in range(k): vote_label labels[sorted_dist_indicies[i]] class_count[vote_label] class_count.get(vote_label, 0) 1 # 第四步按票数降序返回得票最多的类别 sorted_class_count sorted(class_count.items(), keylambda item: item[1], reverseTrue) return sorted_class_count[0][0]逻辑说明tile先把待测样本复制成和训练集同样大小的矩阵再逐行相减平方、求和、开方。sum(axis1)是按行求和得到每个样本到in_x的距离数组。argsort()升序排序后返回的是索引数组所以后面取标签时用labels[sorted_dist_indicies[i]]而不是直接遍历距离。参数说明k控制邻居个数k越小结果越容易受噪声影响k越大结果越平滑但可能把小类别淹没。这份资源里约会数据集的常规起点是k3下一节解释为什么。3.3 K 值选择K3 为什么是约会场景的常用起点先看两个极端。k1等价于最近邻算法一个异常点就能改变判断k取到 50那么个别样本的局部特征基本被抹平算法退化成了按先验概率投票。约会数据集里两类样本比例接近 1:1k3和k5都能跑出不错的结果但在交叉验证里k3的错误率通常最低。另外一个实际原因是k取奇数可以避免投票打平。两类样本的情况下k4会出现 2:2 的平票代码不会报错但返回的结果取决于字典的迭代顺序这就是典型的玄学行为。所以实际工程里我一般直接避开偶数k不给自己埋雷。4. 测试集验证datingTestSet2.txt 与误分类率的完整计算4.1 训练/测试划分10% 做测试、90% 做训练的经典配比这份资源和《机器学习实战》里的划分方式一致总共约 1000 条样本取前 10% 做测试后 90% 做训练。测试集和训练集按顺序切分因为原始数据已经是随机打乱过的前 100 条和后 900 条的类别比例和整体基本一致所以直接顺序切分不会造成明显的分布偏差。这种划分方式的好处是代码极简逻辑一目了然适合初学阶段先跑通主流程。它的缺点是单次划分带有随机性误差评估只有一次结果不能反映模型的稳定性。等 KNN 流程跑通之后可以换成 K 折交叉验证后面进阶章节会补这部分。4.2 手把手跑通误分类率计算测试代码在 KNN.py 里核心逻辑是循环遍历测试样本逐一调用classify0分类再把预测结果和真实标签做比较def dating_class_test(): # 读取数值标签版本的数据 dating_data_mat, dating_labels file2matrix(datingTestSet2.txt, label_modenumeric) # 归一化同时保留 ranges 和 min_vals norm_mat, ranges, min_vals auto_norm(dating_data_mat) m norm_mat.shape[0] num_test_vecs int(m * 0.10) error_count 0.0 for i in range(num_test_vecs): classifier_result classify0(norm_mat[i, :], norm_mat[num_test_vecs:m, :], dating_labels[num_test_vecs:m], 3) if classifier_result ! dating_labels[i]: error_count 1.0 print(the total error rate is: %f % (error_count / num_test_vecs))逻辑说明先把整个数据集读进来做归一化然后按索引切分。norm_mat[i, :]是当前测试样本norm_mat[num_test_vecs:m, :]是从第 100 条到末尾的训练数据dating_labels[num_test_vecs:m]是对应的训练标签。num_test_vecs是 100错误率等于错误次数除以 100。参数说明这里k传的是 3。如果你想看不同k的影响把这个值改成 1、5、7 分别跑一遍错误率会有明显差异。m * 0.10的写法意味着测试占比是 10%改成0.20就是 20% 测试集可以自己调整。4.3 结果解读百分之几的错误率代表什么在未参与训练的 100 条样本上评估跑出来的错误率通常在百分之四到六之间。这个数字比在训练集上自评可信得多因为训练集自评时每个样本都能找到自己的双胞胎邻居错误率会虚低。需要留意的是类别比例问题。如果某一类占数据集的 80%那么一个把所有样本都预测为该类别的模型也有 80% 准确率。所以看结果时不能只盯总体错误率最好打印一下测试集里两类样本各占多少条再对照错误样本的分布。如果错误集中在某一个类别说明特征对该类别的区分度不够下一步要考虑加特征而不是调 K。5. 避坑与常见问题排查KNN 跑出玄学结果的五个原因5.1 不归一化直接跑准确率暴跌现象跳过auto_norm直接拿原始数据调用classify0误分类率超过 20%甚至接近 30%。原因飞行常客里程数值域太大欧氏距离被这个维度主导游戏时间和冰淇淋消耗量两个特征在距离计算中的贡献几乎归零。KNN 退化成单特征分类器准确率自然难看。解决归一化是硬性步骤不能省。跑完归一化后打印一下ranges确认三个特征的 range 都在同一数量级否则继续检查特征维度是否出了问题。5.2 归一化参数丢失训练测试各归一一次现象训练时调用了auto_norm测试时又对测试数据重新调用了一次auto_norm结果错误率忽高忽低完全没有规律。原因auto_norm每次都会重新计算 min 和 max。测试阶段必须沿用训练阶段算出来的那组参数重新计算等于测试集单独做了变换测试样本和训练样本不在同一个尺度空间里。解决把auto_norm返回的ranges和min_vals当作模型的一部分保存下来。测试时用同一组参数做变换写一个独立函数接收ranges和min_vals而不是重复调用auto_norm。5.3 Python3 读取文件解码失败现象file2matrix跑第一行就抛UnicodeDecodeError或者读出来的数据里带着\r转换 float 时直接报错。原因数据文件可能是用 Windows 记事本编辑保存的编码格式是 GBK或者行尾是 CRLF。Python3 默认用 UTF-8 解码遇到 GBK 字节流就崩溃了。解决open时显式指定encodingutf-8如果仍然报错改成encodinggbk重试。每一行读进来之后先strip()再去split(\t)这样能把\r一并去掉。别用二进制模式读取后面全是手撕编码的麻烦。5.4 测试集和训练集重叠评估结果失真现象误分类率低得惊人甚至接近 0但换一批真实新数据跑就明显变差。原因拿全量数据既做训练又做测试或者先在整个数据集上归一化再切分训练测试集。归一化时用了测试集的 min 和 max测试集信息已经泄漏进了训练阶段KNN 作为惰性学习算法对这类泄漏尤其敏感。解决先切分训练测试集再分别做归一化。准确的做法是从全量数据上划分出训练索引和测试索引只在训练部分计算 min 和 max用这套参数变换测试部分。前面的dating_class_test里先归一化再切分虽然沿用自原书但它确实属于简化写法工程落地时要改掉。5.5 K 取偶数导致投票打平现象k4时分类结果不稳定同一组数据多次运行可能得到不同结果。原因两类样本投票出现 2:2 平票代码里的sorted对票数相同的字典项不做稳定排序返回结果取决于字典迭代顺序。解决直接用奇数 K3 或 5。如果坚持要用偶数 K需要在票数相同时增加最近距离优先的规则我在实际项目里的做法是保存distances数组平票时比较待测样本到两类最近邻居的距离谁近归谁。6. 进阶把约会配对分类器改造成可复用的 KNN 模板6.1 三处必要的通用化改造把 KNN.py 改造成能复用到其他场景之前有三个地方值得动手。第一特征维度不要写死 3而是从数据矩阵的shape[1]自动推断第二file2matrix里的标签映射逻辑从固定映射改成可配置映射第三把训练和测试步骤拆成独立函数方便换数据集直接调用。核心的classify0可以简化掉tile利用 NumPy 的广播机制直接算距离def knn_predict(in_x, data_set, labels, k3): # 利用广播机制直接相减替代 tile 复制 diff_mat data_set - in_x distances (diff_mat ** 2).sum(axis1) ** 0.5 sorted_idx distances.argsort() # 取前 k 个邻居用 Counter 统计票数 from collections import Counter top_labels [labels[int(i)] for i in sorted_idx[:k]] return Counter(top_labels).most_common(1)[0][0]逻辑说明data_set - in_x在 NumPy 里会自动把in_x广播到和data_set一致的形状行为等价于tile但内存更省。Counter(top_labels).most_common(1)返回票数最高的标签和票数取[0][0]就是分类结果。6.2 用交叉验证替代固定 10% 划分固定前 100 条做测试只有一次结果受数据顺序影响很大。把数据切成 5 份轮流让每一份都当一次测试集取平均错误率这样选出来的 K 值才可信。交叉验证的代码可以在dating_class_test基础上改循环外圈多加一层 cross-validation 逻辑。我之前拿这份资源改造过其他分类任务有一次没检查特征范围归一化后某个特征直接全是 0KNN 无声无息地退化成了单特征分类器整整调试了一晚上。从那以后我每次跑 KNN 第一步一定是打印ranges确认每个特征的 min 和 max 都正常再调 K 看准确率。拿这份源码练手的时候建议你也把这一步养成习惯。希望帮到你。本文还有配套的精品资源点击获取