
简介这套约会网站配对效果改进项目基于KNN分类算法对海量个人资料特征进行相似度匹配与类别判定适合正在学习机器学习入门、想通过完整案例掌握KNN原理的初学者。资源包含训练集与测试集两份样本数据、一份可直接运行的Python源码及配套说明文档共4个文件以txt数据文件、py脚本和readme文档为主压缩包仅29KB轻量易下载。已有959人学习使用适合边读代码边对照数据理解特征归一化、分类器训练与错误率评估等关键环节。学习者既能获得完整可复跑的代码逻辑也能借助原始数据自行尝试调整测试比例或特征权重直观感受KNN参数对配对效果的影响为进一步使用scikit-learn等工具打下基础。1. KNN 算法改进约会网站的配对效果从压缩包到可跑的配对分类器拿到一个以“源码及数据集”命名的机器学习实战项目第一件事不是看代码而是把一个看似抽象的问题还原成具体任务约会网站积累了一批用户的真实约会记录每条记录包含几个行为特征KNN 算法要做的就是根据这些历史样本预测一个新用户会被归入哪个吸引力等级。这个项目没有神经网络没有梯度下降却把特征工程、距离度量、数据归一化和模型评估完整地串了一遍尤其适合刚学完 KNN 原理、想在真实数据上跑通全流程的从业者。压缩包里一般就是两类东西一个或几个.txt格式的原始数据文件以及实现读取、归一化、分类器和测试流程的 Python 源码。数据规模通常只有几百到一千多条特征列数也不多但正因为小你才能逐行检查每一步的输入输出把“数据从文本变成预测结果”这条链路真正弄明白。对打算走数据分析或算法岗的新人来说这种小项目是性价比很高的练手素材对已经上过手的人它也适合用来快速验证自己对 KNN 和特征预处理的细节是否还记得扎实。2. 读懂数据集与 KNN 原理把 txt 原始记录变成特征矩阵2.1 KNN 分类器三要素样本集、距离度量、多数表决KNN 全称 K-Nearest Neighbors核心思想非常朴素一个新样本属于哪个类别看它在特征空间里距离最近的 K 个历史样本都来自哪些类别然后少数服从多数。这里有三件事直接决定效果。第一是样本集的质量特征是否完整、标签是否可靠、样本分布是否均匀。约会网站这个项目里特征都是数值型标签是离散的吸引力等级很适合 KNN 这种基于距离的算法。第二是距离度量方式。最常用的是欧氏距离即多维空间中的直线距离。假如每个样本有 n 个特征两个样本 x 和 y 的欧氏距离就是各维差值的平方和再开根号。距离度量决定了“相似”的定义换一种度量方式近邻名单可能完全不同。第三是 K 值的选取和表决策略。K 太小容易受噪声影响K 太大又可能把距离很远的样本也拉进投票。表决时一般是一票一票地计也可以按距离加权让近的样本话语权更大。KNN 是一个惰性学习算法它没有显式的“训练”过程所谓训练只是把数据存起来真正的计算发生在预测阶段。因此它的优点是实现简单、适合小数据、决策边界可以很复杂缺点是预测时要计算所有样本的距离样本量大时开销很高。约会网站这个项目的数据规模正好落在 KNN 的舒适区里。2.2 datingTestSet.txt 的四列字段与样本语义在常见的实战源码包里原始数据文件通常叫datingTestSet.txt或datingTestSet2.txt。用文本编辑器打开会看到一个典型的表格结构每一行是一个用户样本行内用 Tab 分隔出四列前三列是特征最后一列是类别标签。第一列是“每年获得的飞行常客里程数”反映一个人的差旅活跃度数值通常在几百到几万之间第二列是“玩视频游戏所耗时间百分比”是一个 0 到 1 之间的小数第三列是“每周消费的冰淇淋公升数”数值通常在 0 到几十之间。标签列则是三个整数1 表示“没有魅力”2 表示“魅力一般”3 表示“极具魅力”。要注意的是这些特征在现实中是否真的有预测力并不重要它们在这个教学项目里扮演的是“可计算的数值型特征”的角色。真正值得关注的是各列之间的量纲差异飞行里程动辄上万冰淇淋消费只有几十游戏时间占比还不到 1。如果直接拿原始值算欧氏距离飞行里程会彻底淹没其他两个特征。这个问题正是后面要重点解决的。2.3 file2matrix 读取函数把字符串行转成 numpy 浮点矩阵了解了数据长什么样下一步就是把文本读进内存并转成可以被数学公式直接处理的矩阵。最经典的手写读取函数如下import numpy as np def file2matrix(filename): 读取约会网站样本数据文件返回特征矩阵和标签向量。 with open(filename, r, encodingutf-8) as fr: lines fr.readlines() # 逐行读取原始文本 num_lines len(lines) # 样本总数 return_mat np.zeros((num_lines, 3)) # 初始化特征矩阵三列特征 class_label_vector [] # 标签列表先不指定长度 index 0 for line in lines: line line.strip() # 去掉行尾换行符 list_from_line line.split(\t) # 按 Tab 切分得到四个字段 # 前三个字段是特征转成浮点数填入矩阵 return_mat[index, :] list_from_line[0:3] # 最后一个字段是标签先存成字符串随后转 int class_label_vector.append(int(list_from_line[-1])) index 1 return return_mat, class_label_vector这个函数逻辑很直白先统计文件行数确定样本量然后初始化一个全零的特征矩阵接着逐行切分、逐列填入。关键点有两个。其一split(\t)指定了分隔符必须是 Tab如果实际数据用的空格或逗号这一行切出来的字段数就会不对。其二前三个字段可以直接整体赋值给return_mat[index, :]因为list_from_line[0:3]本身就是三个字符串的列表NumPy 会自动把它们转成浮点数但标签字段不能这样处理因为它包含的是 1、2、3 这样的字符串直接放进矩阵会破坏数值类型所以要单独 append 并转成 int。读完之后可以用一行代码快速查验结果是否正常打印return_mat.shape和class_label_vector[:10]确认特征矩阵的行数与标签长度一致。如果是用 pandas 一把梭等价写法也很短df pd.read_csv(datingTestSet.txt, sep\t, headerNone)然后取df.iloc[:, :3]为特征、df.iloc[:, 3]为标签。但从学习角度我建议至少手写一遍上面的函数因为只有手写时你才会真正确认分隔符、类型和行列顺序这些细节。3. 特征归一化与测试集划分三个必写函数让欧氏距离不再偏心3.1 为什么飞行里程会“碾压”游戏占比和冰淇淋公升数如果不对数据做任何处理直接把三列原始值丢进欧氏距离公式会出现一个很尴尬的局面计算两个样本的距离时飞行里程那一项的差值可能高达几千甚至上万平方之后更是天文数字而游戏时间占比的差值最多只有 1平方后也不超过 1。两者相加后两项对距离的贡献几乎可以忽略不计。这意味着 KNN 的“近邻”实际上只由飞行里程一个特征决定另外两个特征形同虚设。约会配对这种事如果只用差旅活跃度来判断两个人的相似程度显然丢失了大量信息。因此必须对每一列特征做缩放让所有特征落在相近的数值范围内使它们在距离计算中拥有大致相当的话语权。3.2 autoNorm 归一化函数min-max 公式与返回值设计最常用的缩放方式是 min-max 归一化也叫离差标准化。把每个特征值减去该列最小值再除以该列的取值极差这样每一列都会被压缩到 0 到 1 之间。公式是new_value (old_value - min) / (max - min)。实现如下def auto_norm(data_set): 对特征矩阵做 min-max 归一化。 返回归一化后的矩阵、每列取值范围、每列最小值。 min_vals data_set.min(0) # 按列求最小值得到 1 行 n 列数组 max_vals data_set.max(0) # 按列求最大值 ranges max_vals - min_vals # 每列极差 norm_data_set np.zeros(np.shape(data_set)) # 初始化输出矩阵 m data_set.shape[0] # 样本个数 # 用广播计算 (old - min) / range norm_data_set (data_set - np.tile(min_vals, (m, 1))) / np.tile(ranges, (m, 1)) return norm_data_set, ranges, min_vals这段代码里值得解释的是min(0)这个参数0 表示沿着行方向扫描每一列得到一个包含各列最小值的一维数组。np.tile(min_vals, (m, 1))的作用是把这行最小值复制成和原矩阵同样形状的矩阵这样减法操作就是逐元素对齐的。除法同理。之所以让函数同时返回ranges和min_vals是因为后面处理测试集时要用训练集算出的这两个参数去缩放测试数据而不是重新算测试集自己的 min 和 max。提示很多初学者会在整个数据集上先归一化再划分训练测试集课堂演示也常这么写。但在工程流程里应该先划分再用训练集的参数去转换测试集避免测试集信息提前泄漏进模型。3.3 划分训练测试集随机下标与 10% 验证样本数据归一化完成后下一步要划分训练集和测试集。常规做法是随机打乱样本下标取前 10% 作为测试集剩下 90% 作为训练集。这样做的原因是原始数据文件里的样本顺序往往有一定规律如果直接按顺序切测试集可能只覆盖某一类样本评估结果没有代表性。def split_train_test(feature_mat, label_vec, test_ratio0.1): 按比例随机划分训练集与测试集返回训练特征、测试特征、训练标签、测试标签。 m feature_mat.shape[0] shuffled_index np.random.permutation(m) # 随机打乱 0 到 m-1 的下标 test_count int(m * test_ratio) # 测试样本数量 test_index shuffled_index[:test_count] # 前 test_count 个作为测试集 train_index shuffled_index[test_count:] # 剩余作为训练集 train_mat feature_mat[train_index, :] test_mat feature_mat[test_index, :] train_labels np.array(label_vec)[train_index] test_labels np.array(label_vec)[test_index] return train_mat, test_mat, train_labels, test_labelsnp.random.permutation返回的是一个乱序后的下标数组它能保证每个样本只出现一次避免重复采样。训练集和测试集的比例通过test_ratio控制常见取值是 0.1 到 0.3。如果希望结果可复现可以在调用前加上np.random.seed(42)固定随机种子否则每次运行都会得到不同的划分错误率也会有小幅波动。这里有一个容易被忽略的细节label_vec原本是 Python 列表直接用列表做下标索引会得到单个元素因此要先转成 NumPy 数组再用数组做批量索引才能一次性取出一组标签。这一步虽然不起眼但忘记转换会直接导致维度相关的报错。4. 手写分类器与完整训练测试用 10% 数据验证配对准确率4.1 classify0 手写投票逻辑欧氏距离、排序、k 近邻计数训练集和测试集都准备好了接下来实现 KNN 的核心分类器。虽然 scikit-learn 里现成的KNeighborsClassifier一行就能调用但手写一遍能彻底看清预测时每一步发生了什么。核心函数如下from collections import Counter import numpy as np def classify0(in_x, data_set, labels, k): 对单个测试样本 in_x 做 KNN 分类。 data_set 是训练特征矩阵labels 是对应标签k 是近邻个数。 data_set_size data_set.shape[0] # 用 tile 把测试样本复制成与训练集同形状再做矩阵减法得到差值 diff_mat np.tile(in_x, (data_set_size, 1)) - data_set # 逐元素平方再按行求和最后开方得到欧氏距离数组 distances ((diff_mat ** 2).sum(axis1)) ** 0.5 # 按距离从小到大排序返回的是下标位置 sorted_dist_indices distances.argsort() # 取距离最近的 k 个样本的标签进行投票 vote_labels [] for i in range(k): vote_labels.append(labels[sorted_dist_indices[i]]) # Counter 统计每个标签出现次数取次数最多的标签作为预测结果 most_common Counter(vote_labels).most_common(1) return most_common[0][0]欧氏距离的计算用了 NumPy 的广播机制np.tile(in_x, (data_set_size, 1))把测试样本复制成和训练集同样的行数然后整体相减。sum(axis1)表示按行求和而不是把所有列加在一起这是很容易写错的地方。argsort()返回的是下标不是距离值本身因此后面取labels[sorted_dist_indices[i]]才能对应到原始样本的标签。Counter.most_common(1)返回的是形如[(标签, 次数)]的列表所以要取[0][0]拿到标签值。4.2 完整测试流程错误率计算与 k 值对比表分类器写好后就可以对测试集逐条预测并统计错误率。完整测试流程如下def dating_class_test(feature_mat, label_vec, k3, test_ratio0.1): 归一化、划分数据集、逐条预测并输出错误率。 norm_mat, ranges, min_vals auto_norm(feature_mat) # 先归一化 train_mat, test_mat, train_labels, test_labels split_train_test( norm_mat, label_vec, test_ratio ) test_count test_mat.shape[0] error_count 0.0 for i in range(test_count): predict_label classify0(test_mat[i, :], train_mat, train_labels, k) if predict_label ! test_labels[i]: error_count 1.0 error_rate error_count / test_count print(f总错误率: {error_rate * 100:.2f}%) return error_rate我一般会在固定随机种子后分别测试 k 值为 1、3、5、10、15 的错误率。常见结果大致如下表具体数值会因为随机划分和数据集版本不同而略有浮动k 值错误率范围观察结论13% ~ 7%容易受单点噪声影响表现不稳定34% ~ 8%经典默认值结果稳定55% ~ 9%平滑程度增加但可能欠拟合106% ~ 12%噪声被抑制但边界信息丢失158% ~ 15%过度平滑错误率明显上升需要注意这里使用的是先整体归一化再划分的顺序课堂演示可以接受但严谨的评估流程应该先划分再归一化。后文避坑部分会专门展开。4.3 换用 sklearn 的 KNeighborsClassifier 快速验证结果手写版本的意义在于理解原理而生产或实验场景中通常直接使用成熟的库实现。用 scikit-learn 验证同一份数据代码会简洁很多from sklearn.model_selection import train_test_split from sklearn.preprocessing import MinMaxScaler from sklearn.neighbors import KNeighborsClassifier from sklearn.metrics import accuracy_score import numpy as np # 沿用之前 file2matrix 读出的 feature_mat 和 label_vec feature_mat, label_vec file2matrix(datingTestSet.txt) label_vec np.array(label_vec) # 先划分再归一化避免数据泄漏 train_feat, test_feat, train_label, test_label train_test_split( feature_mat, label_vec, test_size0.1, random_state42 ) # 用训练集拟合 scaler再转换训练集和测试集 scaler MinMaxScaler() train_feat_scaled scaler.fit_transform(train_feat) test_feat_scaled scaler.transform(test_feat) # 创建 KNN 分类器并训练 model KNeighborsClassifier(n_neighbors3, weightsuniform, p2) model.fit(train_feat_scaled, train_label) pred model.predict(test_feat_scaled) print(f准确率: {accuracy_score(test_label, pred):.2%})这里三个参数值得说明n_neighbors对应手写版的 k取 3 是经典默认值具体可以按交叉验证结果调整weightsuniform表示每票权重相同改成distance则距离越近权重越大适合样本密度不均匀的数据p2指定使用欧氏距离改为p1就是曼哈顿距离。我建议把 sklearn 结果和手写版本对比一下两者在相同数据划分下应该高度一致如果不一致多半是归一化顺序或划分方式出了问题。5. 避坑KNN 在约会配对项目里最容易翻车的 5 个细节5.1 训练测试共用归一化边界错误率很好看上线就翻车现象在全量数据上做归一化后再划分训练测试集测试集错误率看起来非常低甚至低到让人觉得模型已经完美。但换一批新数据去跑准确率大幅下降。原因测试集样本参与了min和max的计算相当于模型在“考试前”偷看了试卷答案的一部分。测试集的极值信息已经悄悄进入了训练集的特征缩放尺度所以评估结果偏乐观这在机器学习里叫数据泄漏。约会配对项目数据量小泄漏的影响可能只有几个百分点但一旦把同样的流程搬到真实业务数据上问题会被放大。解决严格遵循“先划分、后归一化”。用训练集调用fit计算 min 和 max再用相同的 scaler 去transform测试集。sklearn 的MinMaxScaler天然支持这种用法手写代码时则要像 4.2 节那样先划分再把训练集的ranges和min_vals传给测试集。5.2 k1 的过拟合陷阱错误率低不代表泛化好现象把 k 设为 1很多人在约会数据集上测出的错误率很低甚至低于 k3。于是误以为“最近的那个样本最可靠k 越小越好”。原因k1 意味着预测结果完全由最近邻一个样本决定。当某个测试样本恰好处在同类样本聚集的区域时它很容易猜对但一旦遇到噪声点或边界样本单点就会带来极大的不确定性。在训练集上表现好是过拟合的典型特征泛化到新数据时错误率会显著上升。解决不要只看单次划分下的错误率。用 k 值扫描的方式从小到大逐个测试观察错误率曲线。同时可以固定多个随机种子分别运行看不同划分下哪个 k 值的均值和方差都更稳定。约会配对这个项目里常见的经验是 k 取 3 到 5 比较均衡但最优值仍应以你自己的数据为准。5.3 把 min-max 换成 z-score量纲统一了但参数敏感度变了现象有同学觉得归一化方式无所谓随手把 min-max 改成 z-score标准化发现错误率变化不大就放心用了。可换一组数据或换一个 k 值时结果波动变得异常明显。原因min-max 把数据映射到固定的 [0,1] 区间保留原始分布的形状z-score 则把数据中心化到 0、缩放单位到标准差输出会包含负值。当特征分布严重偏斜时z-score 会让远离均值的样本被压缩或拉伸到不同尺度对 KNN 这种依赖距离的算法影响不小。错误率变化不大可能只是当前数据和 k 值恰好不敏感并不代表两种方法等价。解决作为默认选项先使用 min-max 归一化。如果想尝试 z-score可以用StandardScaler替换MinMaxScaler但必须同步重新做 k 值扫描不能沿用之前的 k 值。另外无论用哪种缩放都要保证训练集和测试集使用同一组参数。5.4 不洗牌直接切前 10%同类样本扎堆造成准确率虚高现象有些人偷懒不调用permutation直接把前 90 行当训练集、后 10 行当测试集。结果错误率低得离谱换一种切法又高得离谱。原因原始文本文件里的样本往往按标签或时间顺序排列同类样本聚集在一起。直接切前 10% 做测试测试集中的样本很可能和训练集末尾的样本高度相似相当于“开卷考试”。测试集代表不了真实分布评估结果自然失真。解决划分前务必做随机洗牌。在 3.3 节里我用np.random.permutation生成乱序下标就是为了避免这个问题。也可以用 sklearn 的train_test_split它内部已经做了随机打乱。唯一要注意的是设置random_state固定种子否则每次运行结果不可复现。5.5 字符串标签混进特征矩阵float() 直接抛 ValueError现象运行file2matrix时程序报错could not convert string to float: largeDoses之类的错误或者特征矩阵的 dtype 变成了 object距离计算无法进行。原因有些版本的约会数据文件里标签列不是数字 1/2/3而是didntLike、smallDoses、largeDoses这样的字符串。如果读取代码直接把四个字段全部转 float前三个字段可以转换最后一列必然失败。即使勉强读进来特征矩阵混入字符串也会让后续的减法运算失效。解决读取时明确区分特征列和标签列。特征列必须转 float标签列可以先保留字符串或者用一个映射字典把字符串转成数字{didntLike: 1, smallDoses: 2, largeDoses: 3}。我自己的习惯是先用 pandas 快速读取并查看dtypes确认每一列的类型后再决定处理方式能省掉不少排查时间。6. 从约会配对到股票量化分析KNN 项目思路的多特征迁移6.1 把三特征扩展成多特征目标列从魅力等级变成涨跌方向约会配对项目虽然小但整套流程可以直接迁移到股票量化分析场景。差异只在于特征从“飞行里程、游戏时间、冰淇淋消费”换成“过去 n 日涨跌幅、成交量变化率、换手率”等技术指标目标从三分类的吸引力等级变成二分类的“次日上涨还是下跌”。KNN 做量化预测的思路并不复杂历史上出现过和当前最相似的几组行情形态看它们之后是涨是跌用多数表决得出方向判断。6.2 一个最小可运行的 KNN 量化方向预测代码import pandas as pd import numpy as np from sklearn.preprocessing import StandardScaler from sklearn.neighbors import KNeighborsClassifier from sklearn.model_selection import train_test_split # 假设 df 是日线数据包含 close、volume 两列 df pd.read_csv(daily_kline.csv) df[ret_5] df[close].pct_change(5) # 5 日涨跌幅 df[ret_20] df[close].pct_change(20) # 20 日涨跌幅 df[vol_change] df[volume].pct_change(5) # 5 日成交量变化率 df df.dropna().reset_index(dropTrue) # 目标变量次日收盘价是否高于当日 df[target] (df[close].shift(-1) df[close]).astype(int) feature_cols [ret_5, ret_20, vol_change] X df[feature_cols].values y df[target].values # 划分训练集和测试集再做标准化 X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, shuffleFalse ) scaler StandardScaler() X_train scaler.fit_transform(X_train) X_test scaler.transform(X_test) model KNeighborsClassifier(n_neighbors5) model.fit(X_train, y_train) print(方向预测准确率:, model.score(X_test, y_test))金融数据天然带有时间顺序所以这里划分时设置shuffleFalse避免未来数据泄漏进训练集。特征量纲差异很大标准化必不可少。需要提醒的是量化预测是典型的低信噪比场景准确率能稳定超过 55% 已属不易KNN 更多是作为形态相似度检索的基线工具。6.3 用交叉验证替代单次切分小样本的后悔药无论是约会配对还是量化预测单次切分都可能因为随机性得出误导性结论。小数据集上更可靠的做法是交叉验证把样本分成若干折轮流拿其中一折做验证其余做训练最后取平均。sklearn 里直接调用cross_val_score就能完成。我现在拿到这类小项目会先手写一版熟悉细节再用交叉验证选参数最后才看单次测试的错误率。这个过程能帮你省下不少“为什么换个数据就翻车”的血泪排查时间。希望帮到你。本文还有配套的精品资源点击获取