资讯动态

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

发布时间:2026/10/12 20:03:25 来源:尧图企业网站定制
简介面向机器学习初学者的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(axis1) 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(), keylambda x: x[1], reverseTrue) 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。如果写错了把测试样本混进训练集分类器会直接找到它自己k1 时距离为 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))运行结果通常是k1 和 k2 错误率偏高k3 到 k5 进入低谷之后随着 k 增大错误率缓慢回升。这说明 KNN 对 k 的敏感度和样本分布有关不是越大越好。这个项目的标签有三类所以偶数 k 也可能出现平票后面避坑章节会细说。还要留意错误率曲线比较平缓时取一个中间值而不是最小错误率的 k因为最小错误率可能是噪声带来的换一组测试集后就不成立了。k 值错误率区间说明16% 左右对噪声敏感波动大34%~5%常见默认值平衡性好54%~5%与 k3 接近更平滑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, :]那测试样本已经出现在训练集中最近邻就是样本自己k1 时距离 0投票必然正确。解决严格区分区间训练集是 normMat[numTestVecs:m, :]测试集是 normMat[0:numTestVecs, :]。更稳妥的做法是先把索引随机打乱再切分防止数据本身有排列顺序。数据泄漏在机器学习里是比参数不调更严重的问题因为它的表现太有欺骗性最容易让人误以为模型已经能上线了。5.3 k 取偶数遭遇平票结果时对时错找不到规律现象k4 或 k6 时同样的输入有时分类正确有时错误好像不稳定。原因当最近的前几个邻居里两类票数持平sorted 排序会按字典序返回第一个相当于随机决定。三分类问题同样存在平票比如票数 2:2:0。解决优先取奇数 k比如 3、5、7。如果业务上必须用偶数可以在投票环节按距离加权距离越近票权越高而不是单纯计数。这个项目的三分类场景里k4 出现平票的概率不低我最初跑的时候还以为是算法随机性打印出每个邻居的标签和距离才意识到是平票问题。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 时加 encodingutf-8 或 errorsignore。原版文件是纯 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、最后交叉验证。这套流程也帮你少踩点坑希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑