ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

KNN算法实战:约会网站配对分类器源码解析

KNN算法实战:约会网站配对分类器源码解析

简介:面向机器学习初学者的KNN算法实战资源,围绕“改进约会网站配对效果”这一经典场景,完整演示了从数据读取、特征归一化、模型训练到分类效果评估的流程。资源共4个文件,其中训练集与测试集分别为txt数据文件,便于直接对照源码理解数据加载与分类过程;另有Python源代码与Readme说明,压缩包仅29KB,轻量便于快速上手。目前已有959人学习下载。读者可掌握KNN分类器在真实数据集上的应用方法,理解不同特征量纲对距离计算的影响,并借助归一化处理提升配对准确率;训练集与测试集划分明确,可直接运行代码观察输出,适合边学边练。配套代码结构清晰、注释简明,可作为机器学习课程实验或课后练习的参考样例,帮助初学者快速建立KNN完整流程的直观印象。

1. KNN算法改进约会网站的配对效果:这份源码包能直接跑通什么

KNN算法改进约会网站的配对效果,这个项目名看起来很像个课堂作业,但它其实是初学机器学习绕不开的一个经典实战:用 KNN 分类器判断约会网站上一个人值不值得被喜欢。整份资源是一个 zip 压缩包,里面有两个数据集和一个 KNN.py 源码文件,代码风格非常直白,没有框架依赖,装上 numpy 和 matplotlib 就能跑。它把机器学习里最核心的几件事全走了一遍:读取文本数据、构造特征矩阵、归一化、训练集测试集划分、分类器实现、错误率评估。适合刚看完 KNN 理论但不知道代码怎么落地的初学者,也适合想快速复习分类流程的从业者。下面我按函数逐个拆,顺便把容易翻车的点都标出来。

2. 约会配对项目的底层逻辑:数据从哪来,KNN 怎么分类

2.1 数据集字段拆解:datingTestSet.txt 与 datingTestSet2.txt 的差别

先聊数据。这个项目里的两个 txt 文件都是约会网站用户的匿名记录,每行四列,前三列是特征,最后一列是分类标签。特征分别是:每年飞行里程数、玩游戏时间占总时间的百分比、每周消耗的冰淇淋公升数。标签用数字表示:1 代表“很不喜欢”,2 代表“有一点喜欢”,3 代表“非常喜欢”。datingTestSet.txt 和 datingTestSet2.txt 的格式完全一致,区别是第二个文件里的样本量更大,特征分布也更均匀,所以训练时我一般直接用 datingTestSet2.txt。

列位置字段含义数据类型示例
第1列每年飞行里程float40864.0
第2列玩游戏时间占比float0.1155
第3列冰淇淋公升数float1.2355
第4列配对喜好标签int1/2/3

注意第2列虽然是百分比,但转成小数存,不是字符串。这种数据格式在真实业务里也很常见:日志导出的文本、手工整理的 CSV,列之间用制表符隔开,没法直接喂给算法,必须先做解析。很多人一上来就把文件读成字符串丢给模型的接口,最后维度对不上,就是没理解这一层。读取时还要注意行尾符号,readlines 拿到每一行末尾都带着换行符,必须用 strip 去掉,否则最后一列标签转 int 时会直接抛异常。

2.2 KNN 分类器核心:欧氏距离与投票机制

KNN 全称 K Nearest Neighbors,核心思想就一句话:一个样本的类别,由它最近的 k 个邻居投票决定。这个项目没有训练过程,训练集本身就是模型,所以叫懒惰学习。你在源码里看到的 classify0 就是整个分类器,输入一个未知样本,输出一个类别。

源码里最核心的分类函数长这样:

def classify0(inX, dataSet, labels, k): # 复制输入向量为与数据集同形状,便于广播相减 dataSetSize = dataSet.shape[0] diffMat = np.tile(inX, (dataSetSize, 1)) - dataSet # 平方后按行求和,再开根号得到欧氏距离 sqDiffMat = diffMat ** 2 sqDistances = sqDiffMat.sum(axis=1) distances = sqDistances ** 0.5 # 按距离从小到大排序,取前k个邻居投票 sortedDistIndicies = distances.argsort() classCount = {} for i in range(k): voteIlabel = labels[sortedDistIndicies[i]] classCount[voteIlabel] = classCount.get(voteIlabel, 0) + 1 # 按票数降序返回得票最多的类别 sortedClassCount = sorted(classCount.items(), key=lambda x: x[1], reverse=True) return sortedClassCount[0][0]

这里用了 numpy 的广播机制,我习惯写成 np 前缀,原书代码直接用 from numpy import * 也能跑。逻辑拆开看:第一步,np.tile(inX, (dataSetSize, 1)) 把待预测的一维特征 inX 复制成 dataSetSize 行,再与 dataSet 逐元素相减,得到一个差值矩阵。第二步,对差值矩阵每个元素平方,然后按行求和,再开根号,得到未知样本到每个训练样本的欧氏距离。第三步,argsort 返回距离从小到大的索引,取前 k 个索引对应的标签,统计每个标签出现次数。最后按次数降序排序,返回次数最多的标签。

参数要看清:inX 是形状为 (3,) 的一维数组,顺序必须和 dataSet 的列顺序一致;dataSet 是归一化后的特征矩阵,每行一个样本;labels 是原始标签列表,顺序和 dataSet 的行一一对应;k 是邻居个数,取值直接决定分类结果。这个函数是纯 Python 写的,如果样本量到几十万,每次预测都要算一遍全量距离,会明显变慢,这也是 KNN 的硬伤。但在这个约会配对的小数据集上,速度完全够用。

为什么用欧氏距离而不用曼哈顿距离?因为三个特征都是连续数值,欧氏距离对几何位置更敏感,而且后续做归一化之后,距离度量才有意义。如果你把单位不统一的特征直接丢进这个函数,结果会被量级最大的特征主导,这就是下一章要解决的归一化问题。另外,KNN 不训练模型,但预测时需要把训练数据全部常驻内存,内存占用高;分类边界是非线性的,适合这种特征分布复杂的小样本场景。在选型时,如果特征维度很高,比如上千维,KNN 容易陷入维数灾难,需要先降维。这个项目只有三维,所以是入门的最佳场景。

3. 把文本变成特征矩阵:解析、归一化与测试集划分

3.1 file2matrix 解析:逐行读文件与类型转换

file2matrix 这个名字很直白,就是把文件转成矩阵。源码里第一个函数处理的就是两个 txt 文件:

def file2matrix(filename): # 打开文件并逐行读取 fr = open(filename) arrayOLines = fr.readlines() numberOfLines = len(arrayOLines) # 初始化特征矩阵,列数固定为3 returnMat = np.zeros((numberOfLines, 3)) classLabelVector = [] index = 0 for line in arrayOLines: # 去掉行尾换行符,按tab切分成四段 line = line.strip() listFromLine = line.split('\t') # 前三位是特征,最后一位是标签 returnMat[index, :] = listFromLine[0:3] classLabelVector.append(int(listFromLine[-1])) index += 1 return returnMat, classLabelVector

步骤说明:先 readlines 一次性读入所有行,得到字符串列表;然后按行处理,strip 去掉行尾的换行符;split('\t') 按制表符把一行切成四段;前三段给 returnMat 的某一列,最后一段转成 int 追加到 classLabelVector。注意这里没有做异常保护,如果文件里某一行少了一列或者多了一个空行,程序会在赋值或者 int 转换时直接抛异常。

参数说明:返回值 returnMat 是 (样本数, 3) 的浮点矩阵,classLabelVector 是长度为样本数的整数列表。这个函数的隐患是硬编码了列数 3,如果以后数据加了一列特征,就得手动改 zeros 的第二个参数,不如改成从 listFromLine 长度动态推断。我一般会在解析前先打印前几行,确认字段个数和类型,再决定列数。这个习惯帮我省掉了很多次定位解析 bug 的时间。

为什么不用 pandas 的 read_csv?因为这个项目年代早,原书就是纯 numpy 实现,用 pandas 反而模糊了数据解析的关键步骤。初学者应该先理解逐行解析是怎么回事,再去用 read_csv 偷懒。实际工程里,如果文件是 CSV 格式,我用 pandas 一步到位没问题,但学习阶段还是建议手动拆一遍,不然遇到制表符、空格混合分隔的文件时会不知所措。

3.2 autoNorm 归一化:为什么距离要除以极差

归一化是这个项目里最影响准确率的一步:

def autoNorm(dataSet): # 计算每一列的极值 minVals = dataSet.min(0) maxVals = dataSet.max(0) ranges = maxVals - minVals # 先减去最小值,再除以极差,压缩到[0,1] normDataSet = np.zeros(np.shape(dataSet)) m = dataSet.shape[0] normDataSet = dataSet - np.tile(minVals, (m, 1)) normDataSet = normDataSet / np.tile(ranges, (m, 1)) return normDataSet, ranges, minVals

逻辑:minVals 是每一列的最小值,maxVals 是每一列的最大值,ranges 是极差。先把每个样本减去该列最小值,再除以极差,得到全列在 (0,1) 之间的新数据。返回的 normDataSet 是归一化矩阵,ranges 和 minVals 在后面预测新样本时还要继续用,所以不能丢。这里的 np.tile 是把 minVals 复制成和 dataSet 相同的行数,然后做逐元素减法,也可以直接用 dataSet - minVals 让 numpy 自动广播,但原书用 tile 是为了让维度逻辑更清晰。

不归一化的后果很明显:飞行里程的数值在几千到几万,玩游戏占比在 0 到 1 之间,冰淇淋在 0 到几百。计算欧氏距离时,(里程差值)^2 可能占掉整个距离的 99%,另外两个特征成了摆设。我测过一组对比,不归一化时错误率在 20% 上下波动,归一化之后能降到 5% 左右。这就是为什么规范流程里,归一化必须用训练集的参数,后面预测新样本也用同一套 minVals 和 ranges,不能拿新样本重新算一遍。ranges 出现 0 说明该列所有样本值相同,这时需要手动处理,否则会除零,虽然这个数据集里没遇到,但你自己换数据时很可能碰到。

3.3 datingClassTest 测试流程:错误率计算

约会配对能不能用,要看错误率:

def datingClassTest(): # 前10%样本做测试,后90%做训练 hoRatio = 0.10 datingDataMat, datingLabels = file2matrix('datingTestSet2.txt') normMat, ranges, minVals = autoNorm(datingDataMat) m = normMat.shape[0] numTestVecs = int(m * hoRatio) errorCount = 0.0 for i in range(numTestVecs): # 测试样本是normMat[0:numTestVecs],训练样本从numTestVecs开始 classifierResult = classify0(normMat[i, :], normMat[numTestVecs:m, :], datingLabels[numTestVecs:m], 3) if classifierResult != datingLabels[i]: errorCount += 1.0 # 错误率 = 错误样本 / 测试样本总数 print("the total error rate is: %f" % (errorCount / numTestVecs))

这里把前 10% 的样本当测试集,后 90% 当训练集。关键在于切分区间:训练集索引从 numTestVecs 到 m,测试集索引是 0 到 numTestVecs-1。如果写错了,把测试样本混进训练集,分类器会直接找到它自己,k=1 时距离为 0,错误率虚低到接近 0,这是典型的泄漏。参数说明:hoRatio 是测试比例,0.10 表示用 10% 的样本验证错误率,k 硬编码成 3。这个函数每次跑结果会有点浮动,因为数据集顺序本身可能随机,但样本量足够大时误差率稳定在 5% 左右。如果想更严谨,可以把 hoRatio 改成 0.2 或 0.3 对比,或者直接用 sklearn 的 train_test_split,但在这个教学项目里手写切分更直观。

提示:如果你看到错误率飙到 20% 以上,先检查是不是没调用 autoNorm;如果错误率是 0%,先检查测试集是否被混进了训练集。这两个问题占了新手调试 KNN 的大半时间。

4. 调参观察:k 值选择与特征可视化验证

4.1 k 值对分类效果的影响与合理的取值范围

k 是 KNN 唯一的超参数。在这个项目里,k 太小,比如 1,分类器只认离自己最近的一个样本,对噪声极其敏感,可能因为一个异常点就误判;k 太大,比如 20,会把很远的不同类别样本也拉进投票,边界变得平滑但分类精度下降。常见做法是先试 3、5、7 这些奇数,再根据错误率微调。我一般会把分类函数改造成接受外部 k 参数,然后循环测试。

def dating_class_error(normMat, labels, k): # 固定10%测试集,按不同k值计算错误率 m = normMat.shape[0] num_test = int(m * 0.1) error_count = 0 for i in range(num_test): result = classify0(normMat[i, :], normMat[num_test:m, :], labels[num_test:m], k) if result != labels[i]: error_count += 1 return error_count / num_test for k in range(1, 16): err = dating_class_error(normMat, datingLabels, k) print("k =", k, "error rate =", round(err, 4))

运行结果通常是:k=1 和 k=2 错误率偏高,k=3 到 k=5 进入低谷,之后随着 k 增大错误率缓慢回升。这说明 KNN 对 k 的敏感度和样本分布有关,不是越大越好。这个项目的标签有三类,所以偶数 k 也可能出现平票,后面避坑章节会细说。还要留意:错误率曲线比较平缓时,取一个中间值而不是最小错误率的 k,因为最小错误率可能是噪声带来的,换一组测试集后就不成立了。

k 值错误率区间说明
16% 左右对噪声敏感,波动大
34%~5%常见默认值,平衡性好
54%~5%与 k=3 接近,更平滑
7 以上5%~7%边界过平滑,错误率回升

4.2 用散点图观察特征分布:游戏占比与飞行里程的关系

选特征前先画图:

import matplotlib.pyplot as plt plt.rcParams['font.sans-serif'] = ['SimHei'] fig = plt.figure() ax = plt.subplot(111) # 点的大小和颜色都按标签放大,便于观察三类分布 ax.scatter(datingDataMat[:, 1], datingDataMat[:, 2], 15.0 * np.array(datingLabels), 15.0 * np.array(datingLabels)) plt.xlabel('玩游戏时间占比') plt.ylabel('冰淇淋公升数') plt.show()

这段代码把第二列玩游戏占比作为横轴,第三列冰淇淋作为纵轴,点的大小和颜色都按标签放大,可以明显看到三类样本聚成几个团簇。如果不先做可视化,你很难知道哪些特征组合本身就有区分度。比如飞行里程和冰淇淋的组合可能边界模糊,而玩游戏占比和冰淇淋的组合分离度更高。参数说明:scatter 的前两个参数是横纵坐标数组,第三个参数是点的大小,这里用 15.0 * labels 放大差异,第四个参数是颜色,也复用 labels,相当于一个简洁的彩色图。换成其他列组合时,只需要改索引,分别是 0、1、2 对应飞行里程、游戏占比、冰淇淋。

画图前还要注意数据量级。如果直接用未归一化的数据,飞行里程范围太大,可能把整个图像压成一条直线,横纵坐标尺度差太多。我一般会先调用 autoNorm 再画图,这样三个特征都在同一量级,视觉上更容易判断分布。

4.3 不同特征组合下的分类边界判断

三维数据一共有三种二维投影:里程-游戏、里程-冰淇淋、游戏-冰淇淋。你可以在一个图里放三个子图对比。经验是:里程和游戏占比的组合,在散点图上三类有重叠,但核心区域能分开;游戏占比和冰淇淋的组合分离度最好,因为喜欢打游戏又爱吃冰淇淋的人群特征非常明显。判断标准很简单:相同标签的点是否聚在一起,不同标签之间是否有明显的空隙。空隙越大,KNN 越容易分;重叠多,调 k 也救不回来。

如果发现某两列特征几乎没有区分度,直接在 file2matrix 解析时去掉那一列,也可以把特征组合改成两个。KNN 在低维效果很好,但维度升高后样本会变得稀疏,这个项目只有三维,不需要降维,可视化的价值主要是帮你建立对数据的直觉。真正做业务时,特征工程花的精力往往比调参更多,先画图永远是最快的检验方式。我见过不少人跳过这一步直接上网格搜索调参,最后模型指标上不去,回头一查是选了两个高度相关的冗余特征,白白浪费时间。

5. 避坑指南:KNN 实战中常见的五个翻车现场

这一章的坑不是源码本身的问题,更多是使用时改坏了。最常出现在自己重新录入数据、修改测试集划分、以及把代码移植到新环境时。下面五个翻车现场,都是我实际跑项目时遇到过的,按概率从高到低排列。

5.1 不归一化就跑:错误率居高不下

现象:照着源码跑 datingClassTest,错误率在 20% 以上,甚至上不了 10%。原因:三个特征量级差异巨大,飞行里程的数值范围几千到几万,游戏占比是 0 到 1,冰淇淋是几十到几百,欧氏距离被飞行里程主导,相当于只用一个特征分类。解决:跑任何 KNN 之前先做归一化,这个项目里用 autoNorm 得到 (0,1) 区间的新矩阵。记住归一化参数必须来自训练集,测试集和预测样本都复用同一套 minVals 和 ranges。验证方式很简单:打印一下距离矩阵,如果某一列贡献的平方差之和占到总距离 90% 以上,基本就是没归一化。

5.2 训练测试样本混在一起:准确率虚高到像作弊

现象:错误率输出 0.000000 或接近 0,看起来模型完美,实际一上真实数据就翻车。原因:如果代码里错把训练集写成 normMat[0:m, :],测试集也是 normMat[0:numTestVecs, :],那测试样本已经出现在训练集中,最近邻就是样本自己,k=1 时距离 0,投票必然正确。解决:严格区分区间,训练集是 normMat[numTestVecs:m, :],测试集是 normMat[0:numTestVecs, :]。更稳妥的做法是先把索引随机打乱再切分,防止数据本身有排列顺序。数据泄漏在机器学习里是比参数不调更严重的问题,因为它的表现太有欺骗性,最容易让人误以为模型已经能上线了。

5.3 k 取偶数遭遇平票:结果时对时错找不到规律

现象:k=4 或 k=6 时,同样的输入有时分类正确有时错误,好像不稳定。原因:当最近的前几个邻居里两类票数持平,sorted 排序会按字典序返回第一个,相当于随机决定。三分类问题同样存在平票,比如票数 2:2:0。解决:优先取奇数 k,比如 3、5、7。如果业务上必须用偶数,可以在投票环节按距离加权,距离越近票权越高,而不是单纯计数。这个项目的三分类场景里,k=4 出现平票的概率不低,我最初跑的时候还以为是算法随机性,打印出每个邻居的标签和距离才意识到是平票问题。

5.4 维度对不上:classify0 运行时广播报错

现象:执行 classify0 抛异常,提示 operands could not be broadcast together with shapes。原因:inX 的列数与 dataSet 的列数不一致,常见于 file2matrix 里写死返回三列,但数据文件里有额外的空格或第四列被错误切割。也可能是标签列表不是 int,混入了字符串。解决:在调用前打印 dataSet.shape 和 len(inX),核对特征数。解析文件时先用 number_of_lines 拿到行数,确保 returnMat 行数匹配。这类错误在集成到其他项目时最容易出现,因为原数据可能被预处理成不同格式,字段位置变了,但 KNN.py 里的索引还硬编码着。

5.5 文件路径和编码:运行直接 FileNotFoundError

现象:双击运行 KNN.py 报找不到 datingTestSet2.txt,或者 open 时出现 UnicodeDecodeError。原因:工作目录不对,脚本和 txt 不在同一个目录;或者数据集被编辑过保存成带 BOM 的 UTF-8。解决:把 txt 和 py 放在同一目录,或者用 os.path.join(os.path.dirname(file), 'datingTestSet2.txt') 显式指定路径。如果遇到编码问题,open 时加 encoding='utf-8' 或 errors='ignore'。原版文件是纯 ASCII,一般不需要,但二次修改后很容易踩。还有一个隐蔽问题:Windows 下文件路径里的反斜杠转义,推荐统一用 os.path.join,不要手写路径字符串。

遇到问题时不要急着改算法,先回到数据管道上排查,多数问题出在解析、归一化、划分这三步。KNN 分类器本身逻辑简单,排错重点就在喂给它的数据对不对。把训练集和测试集的形状打印出来,把归一化前后的距离值对比一下,基本能定位九成以上的问题。

6. 把 KNN 用起来:从训练到单条样本预测的完整验证

6.1 classifyPerson 实战:输入特征直接输出配对结果

这份源码最后还带了一个交互预测函数,可以直接手动输入三个特征,让分类器判断配对喜好:

def classifyPerson(): percentTats = float(input("玩游戏的时长占比: ")) ffMiles = float(input("每年飞行里程: ")) iceCream = float(input("冰淇淋公升数: ")) datingDataMat, datingLabels = file2matrix('datingTestSet2.txt') normMat, ranges, minVals = autoNorm(datingDataMat) # 注意输入顺序与训练数据列顺序对齐 inArr = np.array([ffMiles, percentTats, iceCream]) normInArr = (inArr - minVals) / ranges result = classify0(normInArr, normMat, datingLabels, 3) if result == 1: print("很不喜欢") elif result == 2: print("有一点喜欢") else: print("非常喜欢")

关键点有两个。第一,inArr 的构造顺序必须和训练数据列顺序对齐,这里按飞行里程、游戏占比、冰淇淋来排,如果你按原书代码里输入顺序写反了,预测结果完全不可信。第二,单条样本的归一化要用训练集算出来的 minVals 和 ranges,不能自己重新算,否则等于换了一个坐标系,距离计算就废了。这个函数跑通之后,可以随便输入几组数字体验完整链路,比如输入一个飞行里程少、游戏占比高、冰淇淋消耗高的样本,大概率输出“非常喜欢”,因为这类用户在训练数据里就是很受欢迎的一类。

6.2 用留一法重新评估 k 值

更严谨的做法是留一交叉验证,小数据集上跑很快,能看出每个样本对分类器的影响。给你一个参考实现:

from sklearn.model_selection import LeaveOneOut loo = LeaveOneOut() for k in [3, 5, 7]: correct = 0 for train_idx, test_idx in loo.split(normMat): train_X = normMat[train_idx] test_X = normMat[test_idx][0] train_y = [datingLabels[i] for i in train_idx] result = classify0(test_X, train_X, train_y, k) if result == datingLabels[test_idx[0]]: correct += 1 print("k =", k, "accuracy =", correct / len(normMat))

留一法把每个样本都当成一次测试,训练集是其余全部样本,结果最稳定,代价是计算量大。这个数据集只有千级样本,完全能承受。用这个方法选完 k,再回 datingClassTest 里确认一次,基本可以确定最终参数。

我自己做这个项目时,最大的教训是拿留一法跑出的最小错误率当唯一标准,结果换测试集后效果变差。后来我习惯在确定 k 之后,再用两到三组不同的测试集比例验证,如果错误率波动在 1% 以内,才敢说参数稳。从那以后我每次跑 KNN 项目都强制走一遍:先画特征图、再归一化、再选 k、最后交叉验证。这套流程也帮你少踩点坑,希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进