拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

MapReduce并行化KNN的电影用户性别预测实现

MapReduce并行化KNN的电影用户性别预测实现

简介:这是一份基于KNN算法与MapReduce框架实现的电影网站用户性别预测Java项目,适合Java学习者、毕业设计及期末大作业场景。项目包含完整源码、文档说明以及清晰的代码注释,从数据读取、特征处理到算法训练与预测均有模块化实现,新手也能快速理解核心流程。压缩包共25个文件,其中16个Java源文件负责业务逻辑,另含数据集、配置文件、Shell脚本及运行说明,整体体积约5.76MB,部署和使用都比较轻量。资源已有314人学习,属于经过严格调试、可运行的高分项目,尤其适合需要可直接落地的完整方案的读者。下载后可快速部署运行,既能作为课程设计或毕设主体,也能为理解KNN分类和MapReduce并行处理提供参考。

1. 用并行化的KNN把电影网站用户性别预测落地:这个项目到底在讲什么

做电影网站的人经常会遇到一个尴尬:注册流程里没收集性别,推荐策略和内容运营又特别需要性别画像。标题里这个 java实现基于knn算法和MapReduce实现电影网站用户性别预测项目,解决的就是这个需求——把用户在电影网站上的评分行为、类型偏好、活跃时段整理成特征向量,用KNN给每个未标注用户找出行为最相似的K个已知性别用户,用多数投票推断性别。整套实现不是单机玩具,而是把KNN的预测阶段拆成了真正的Hadoop MapReduce作业:训练集放进HDFS,Mapper负责算距离,Reducer负责投票,源码里还附带文档说明,把编译、打包、提交作业的完整链路交代清楚。适合两类人:一类是做HDFS和MapReduce综合实训、课程设计的学生,另一类是正在啃java面试题、手头缺一个有完整代码的mapreduce编程实例的工程师。

2. 为什么性别预测适合用KNN:算法选型、特征向量与归一化缺一不可

2.1 KNN被选中的两个硬理由:非参数建模与可解释性

性别和观影偏好之间的关系不是一条直线。看动作片多的人可能是男性,但也可能是喜欢爽片的女性;爱情片看得多的可能是女性,但文艺男青年也不罕见。用逻辑回归做这个预测,你得手动构造大量交叉特征,还得担心特征之间共线性;用决策树呢,树模型对特征值域的变化又特别敏感。KNN是标准的非参数方法,它不做任何全局假设,直接靠"相似的人有相似的行为"来投票,天生适合这种没有明确规则的画像问题。

另一个实际理由是解释成本低。预测出某个ID是男性之后,你可以直接反问:他是跟哪些已知性别的用户“长得像”胜出的?这五个最近邻分别贡献了多少票?线上业务要是因为预测结果误伤了用户体验,KNN至少能把理由摊开给人看,而不是甩一个黑匣子出来。

但KNN有一个绕不过去的代价:预测阶段时间复杂度是O(N),每预测一个用户都要把他和全部训练样本算一遍距离。单机内存顶不住百万级特征向量,四核八线程的CPU也算不过来。这正是标题里把KNN和MapReduce绑在一起的原因——MapReduce解决的是"距离计算能不能并行、训练集能不能放得下"的问题,算法本身还是经典KNN。

2.2 特征向量怎么构造:从原始评分记录到用户画像

KNN算的是样本之间的距离,距离必须先落到特征向量上。原始的rating表长这样:

字段示例说明
userId196用户ID
movieId242电影ID
rating4评分,1-5整数
timestamp881250949Unix时间戳

这个表跑不出距离。用户196和用户242之间的“相似度”得靠聚合来体现。我一般会聚合出下面这组特征,这也是这个项目文档说明里最常见的一组:

  • 平均评分:对爱情片、动作片、科幻片的平均打分分别算,而不是只算一个全局均值。原因是不同性别对不同类型电影的容忍度差异很大。
  • 类型观看占比:过去三个月内看过的电影里,动作片占比、爱情片占比、喜剧片占比。占比比绝对数量更稳,避免活跃用户天然占优。
  • 深夜活跃度:timestamp换算成小时,晚上22点到凌晨2点的观影记录占比。男性夜猫子用户在视频网站上占比明显偏高。
  • 评分数量:按周平均评分条数,反映对平台的黏性。

聚合逻辑可以直接用纯Java跑一遍,输出一行一个用户。这一步不需要Hadoop,因为是在原始日志上做规约,单机通常能扛住:

// UserFeatureExtractor.java 伪代码骨架 // 输入:rating.dat(user::movie::rating::timestamp) // 输出:userId, avgAction, avgRomance, actionRatio, nightRatio, weeklyCnt Map<Integer, UserFeatureAccumulator> accMap = new HashMap<>(); try (BufferedReader br = new BufferedReader(new FileReader(args[0]))) { String line; while ((line = br.readLine()) != null) { String[] p = line.split("::"); int userId = Integer.parseInt(p[0]); int movieId = Integer.parseInt(p[1]); double rating = Double.parseDouble(p[2]); long ts = Long.parseLong(p[3]); // 根据movieId去join电影类型表,累加到对应类型桶里 accMap.computeIfAbsent(userId, k -> new UserFeatureAccumulator()) .add(movieId, rating, ts); } } // 最后遍历accMap,把累积值转成归一化前的特征向量

这里要注意,movieId -> genre的join表单独加载进一个Map<Integer, String> genreMap,在上面的循环里查表就行。UserFeatureAccumulator内部维护的是累积量,不是每一条评分,否则1000万条评分记录能把堆内存吃穿。

2.3 归一化与样本均衡:不做这两步,准确率直接打折

特征向量构造出来之后,很多人直接把向量塞给KNN,然后发现准确率徘徊在50%上下,跟抛硬币一样。原因基本就是两个:没归一化、样本不均衡。

归一化不做的话,欧氏距离会被值域大的特征绑架。假设weeklyCnt取值范围是0到200,avgRating是1到5,那么距离公式里每周评分条数这一项会贡献绝大部分数值,类型占比全都变成背景噪音。KNN的“相似”就退化成了“活跃度相似”,而不是“观影口味相似”。

常见的做法是Min-Max归一化,在生成训练集时顺带完成:

// 对每个特征列做min-max for (int i = 0; i < featureDim; i++) { double min = minArray[i]; double max = maxArray[i]; if (max - min < 1e-6) { featureVec[i] = 0.0; // 该特征在训练集里没有区分度 } else { featureVec[i] = (featureVec[i] - min) / (max - min); } }

注意测试集归一化时必须复用训练集的min和max,不能各自独立算,否则测试样本分布会漂移。

样本不均衡的问题更隐蔽。电影网站的用户性别比例不会是1:1,如果男性是女性的3倍,KNN在边界区会不加思考地偏向多数类。这个项目里的缓解办法有两个:一是对多数类做下采样,让训练集男女比例接近1:1;二是对少数类做简单过采样,复制样本。第一种更靠谱,因为KNN本身就是基于邻域密度的算法,多数类密度一高,少数类的近邻很容易被挤掉。

3. 把KNN拆进MapReduce:Map分治算距离、Reduce合并投票与驱动配置

3.1 数据量级决定架构:为什么不能把训练集塞进内存

KNN的训练阶段是零开销,真正的开销在预测阶段,而且这个开销跟训练集大小严格线性相关。假设你有200万训练用户、50万待预测用户、特征维度是10,算一遍全量距离就是1000亿次浮点运算,单机Java程序跑完要到第二天早上。这不是代码优化能救的,问题出在单机算力上限。

MapReduce解决的正是这个问题。把50万待预测用户拆到200个Mapper上,每个Mapper只负责其中2500个用户,每个Mapper自己去完整体验一遍训练集、算距离、留下局部K近邻,最后Reducer合并局部候选做全局投票。训练集本身不用放进内存,放在HDFS上,通过DistributedCache分发到每个Mapper节点,节点内存里只保留一份。

这个设计还有一个隐性好处:训练集的副本数由HDFS和NodeManager调度决定,200个Mapper并发读同一份训练集,不会把NameNode打成热点。

3.2 Map阶段:用DistributedCache加载训练集,维护局部Top-K

Mapper的核心逻辑分两步:setup阶段读一次训练集,把特征向量和标签放进内存;map阶段每来一条测试样本,就遍历训练集算距离,顺手维护一个局部K近邻表。

这里有个常见的做法差异:有人让Mapper输出每一条测试样本和所有训练样本的距离,让Reducer统一排序取Top-K。这种做法逻辑最简单,但Shuffle量是"待预测用户数 × 训练集大小",一个500万条训练集的项目,Map输出能到几十GB,直接把磁盘写爆。

更稳妥的做法是在Mapper内部用TreeMap维护局部Top-K,map函数只输出K条候选给Reducer。K是5或者7时,200个Mapper最多输出1000条候选给单个Reducer,Shuffle量降了几个数量级。

import org.apache.hadoop.io.LongWritable; import org.apache.hadoop.io.Text; import org.apache.hadoop.mapreduce.Mapper; import java.io.BufferedReader; import java.io.FileReader; import java.io.IOException; import java.net.URI; import java.util.ArrayList; import java.util.List; import java.util.TreeMap; public class KnnMapper extends Mapper<LongWritable, Text, Text, Text> { private final List<double[]> trainVectors = new ArrayList<>(); private final List<Integer> trainLabels = new ArrayList<>(); private TreeMap<Double, List<Integer>> localNearest; private int k = 5; @Override protected void setup(Context context) throws IOException, InterruptedException { // 从DistributedCache读训练集,每个Mapper只加载一次 URI[] cacheFiles = context.getCacheFiles(); if (cacheFiles != null && cacheFiles.length > 0) { // 兼容从本地文件系统读缓存文件;生产环境走HDFS String trainPath = cacheFiles[0].getPath(); try (BufferedReader br = new BufferedReader(new FileReader(trainPath))) { String line; while ((line = br.readLine()) != null) { String[] parts = line.split(","); double[] vec = new double[parts.length - 1]; for (int i = 0; i < vec.length; i++) { vec[i] = Double.parseDouble(parts[i]); } trainVectors.add(vec); trainLabels.add(Integer.parseInt(parts[parts.length - 1])); } } } k = context.getConfiguration().getInt("knn.k", 5); } @Override protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException { // 输入一行:一个待预测用户的特征向量,逗号分隔 String[] parts = value.toString().split(","); double[] testVec = new double[parts.length - 1]; for (int i = 0; i < testVec.length; i++) { testVec[i] = Double.parseDouble(parts[i]); } // 局部候选表:距离 -> 标签列表,避免相同距离互相覆盖 localNearest = new TreeMap<>(); for (int i = 0; i < trainVectors.size(); i++) { double dist = euclidean(testVec, trainVectors.get(i)); localNearest.computeIfAbsent(dist, d -> new ArrayList<>()).add(trainLabels.get(i)); // 超过K组距离时,踢掉最远的一组 if (localNearest.size() > k) { localNearest.pollLastEntry(); } } // 把局部候选输出给Reducer:用户ID -> 距离:标签 String userId = parts[parts.length - 1]; StringBuilder sb = new StringBuilder(); for (var entry : localNearest.entrySet()) { for (int label : entry.getValue()) { sb.append(entry.getKey()).append(":").append(label).append(";"); } } context.write(new Text(userId), new Text(sb.toString())); } private double euclidean(double[] a, double[] b) { double sum = 0.0; for (int i = 0; i < a.length; i++) { double diff = a[i] - b[i]; sum += diff * diff; } return Math.sqrt(sum); } }

上面代码有三个值得抠的细节:一是TreeMap的key是Double,两个不同样本距离完全相等时,TreeMap会认为是同一个key,直接把后到的标签覆盖掉。所以代码里computeIfAbsent把相同距离的标签放进一个List,等投票的时候一起算,这就避免了K近邻被浮点相等坑掉。二是pollLastEntry()只在TreeMap的size大于K时触发,保证局部候选不会超过K组。三是knn.k这个参数通过Configuration传进来,Mapper在setup里读取,参数化而不是硬编码。

3.3 Reduce阶段:把Map算好的topK候选统一投票

Reducer拿到的是同一个用户ID在所有Mapper上输出的局部候选,也就是"距离:标签;距离:标签;"这种拼接串。Reducer要做两件事:解析字符串、解析出的所有距离对按距离排序、取前K个标签做多数投票。这里其实是MapReduce排序——自定义排序的典型应用,但reduce端数据量不大,直接用TreeMap就够了:

import org.apache.hadoop.io.IntWritable; import org.apache.hadoop.io.Text; import org.apache.hadoop.mapreduce.Reducer; import java.io.IOException; import java.util.Map; import java.util.TreeMap; public class KnnReducer extends Reducer<Text, Text, Text, IntWritable> { private int k = 5; @Override protected void setup(Context context) { k = context.getConfiguration().getInt("knn.k", 5); } @Override protected void reduce(Text key, Iterable<Text> values, Context context) throws IOException, InterruptedException { // 合并所有Mapper贡献的局部候选,按距离升序取前K TreeMap<Double, Integer> nearest = new TreeMap<>(); for (Text val : values) { String[] pairs = val.toString().split(";"); for (String pair : pairs) { if (pair.isEmpty()) continue; String[] dp = pair.split(":"); double dist = Double.parseDouble(dp[0]); int label = Integer.parseInt(dp[1]); // 相同距离只保留一份,投票权重不变 nearest.putIfAbsent(dist, label); if (nearest.size() > k) { nearest.pollLastEntry(); } } } // 统计K个最近邻里男女标签的票数 int male = 0; int female = 0; for (Map.Entry<Double, Integer> entry : nearest.entrySet()) { if (entry.getValue() == 1) male++; else female++; } // 注意按key排序输出,便于后续跟真实标签做准确率对比 context.write(new Text(key.toString() + "_" + male + "_" + female), new IntWritable(male >= female ? 1 : 0)); } }

Reducer这里仍然用了TreeMap来排序。之前热搜里提到的"mapreduce排序——分组排序"确实存在,但那是通用的Hadoop排序姿势,这个场景数据量小,用TreeMap把距离排序保持在内存里就够了。真要写WritableComparator自定义排序也能做,属于杀鸡用牛刀。

有个容易踩坑的地方:context.write输出的key里拼接了male和female票数,这不是为了好看,是方便后面用脚本直接比对真实性别和预测票型,排查误判样本到底是一票之差还是全面误判。

3.4 Driver配置与三个必调参数

Driver负责把所有零件组装起来。它的作用不只是设置类名,关键是控制好Shuffle阶段的类型声明、缓存路径和reducer数量:

import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.fs.Path; import org.apache.hadoop.io.IntWritable; import org.apache.hadoop.io.Text; import org.apache.hadoop.mapreduce.Job; import org.apache.hadoop.mapreduce.lib.input.FileInputFormat; import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat; public class SexPredictorDriver { public static void main(String[] args) throws Exception { Configuration conf = new Configuration(); // 通过命令行参数把K值传进去,方便做3/5/7/9的对比实验 conf.setInt("knn.k", Integer.parseInt(args[3])); Job job = Job.getInstance(conf, "knn sex predictor"); job.setJarByClass(SexPredictorDriver.class); job.setMapperClass(KnnMapper.class); job.setReducerClass(KnnReducer.class); job.setOutputKeyClass(Text.class); job.setOutputValueClass(IntWritable.class); // map输出和reduce输出类型一致,不用额外设置map output类型 FileInputFormat.addInputPath(job, new Path(args[1])); // 待预测特征文件 FileOutputFormat.setOutputPath(job, new Path(args[2])); // 训练集通过DistributedCache下发到所有Mapper job.addCacheFile(new java.net.URI(args[0])); // 训练特征文件 System.exit(job.waitForCompletion(true) ? 0 : 1); } }

三个必调参数分别是:

参数位置作用建议值
knn.kConfiguration最近邻个数从5起步,奇数
mapreduce.job.reducesyarn命令Reducer数量默认1就够,数据倾斜再调大
DistributedCache路径addCacheFile训练集分发必须是HDFS上的路径

mapreduce.job.reduces这个参数我要多说一句:这个项目里Reducer的输入是按用户ID分组的,Reducer数量调大反而会把同一个用户的候选切到多个Reducer里,投票结果直接碎掉。所以reducer保持默认1,靠Mapper并行度分摊计算压力就对了。

4. 准备训练集与测试集:输入格式、数据清洗和防泄漏划分

4.1 三张表的输入格式:评分记录、电影信息和用户标签

这个项目的数据来源通常就是MovieLens风格的三张表。虽然不同课程设计里字段名略有差别,但核心结构一致:

数据表常见字段样例行
users.datuser::gender::age196::M::23
movies.datmovie::title::genres242::Kolya::Comedy
ratings.datuser::movie::rating::timestamp196::242::4::881250949

特征提取的第一步就是把ratings.dat和movies.dat join起来,获得"这个用户看的每一部电影是什么类型"。第4章开头那个纯Java的UserFeatureExtractor干的就是这件事。join完之后,按用户分组聚合出特征向量,再和users.dat的gender标签拼成训练样本。

文档说明里一般会要求输入文件用::做分隔符,不要用逗号。原因是::在真实评分数据里几乎不可能出现在电影标题中,而逗号在电影标题里特别常见(比如"Love, Actually"),用逗号做分隔符会在数据清洗阶段多出一堆解析异常。

4.2 数据清洗的三个必做动作:空值、异常值、冷启动

数据清洗看起来没有技术含量,但在这个项目里直接决定能不能跑出能看的准确率。我踩过的坑按出现频率排序,基本是这三个:

第一,评分值域过滤。rating字段理论上只有1到5,但真实导出的数据里经常混入0分、6分甚至-1,多半是爬虫或者老系统迁移时留下的脏数据。KNN对异常值本身就敏感,一个6分能把距离拉偏一大截。清洗时直接if (rating < 1 || rating > 5) continue;,不要做任何插值补救。

第二,空值处理。userId、movieId出现空字符串,直接跳过整行。但要注意:如果某条评分因为类型缺失被跳过,用户的类型占比特征就会失真。正确的处理是先检查movie是否有genre,genre缺失的评分整条丢弃,而不是丢掉genre字段但保留评分。

第三,冷启动用户剔除。评分记录少于5条的用户特征向量几乎全是0,他们跟任何训练样本都算不出有意义的距离。这类用户在特征提取阶段就会被滤掉,不进入训练集也不进入测试集。对电影网站运营来说这其实是合理的——评分少于5条的用户本来就不是KNN推荐策略的目标人群。

这轮数据清洗的逻辑和很多教材里的“招聘数据清洗”实验本质相同:先过滤格式异常,再处理值域异常,最后按业务规则剔除无意义样本。顺序错了容易把有效数据误伤。比如先做冷启动剔除,再去重,统计基线就歪了。

4.3 训练集和测试集怎么划分才能不泄漏

训练集和测试集的划分是准确率虚高的重灾区。最常见的错误是按时间切分,比如用上半年的数据做训练集、下半年做测试集。这在时间序列预测里是对的,但在用户性别预测里不成立——用户的性别不会随时间改变,按时间切分等于让模型从来没有见过下半年新注册用户的特征分布,准确率波动会很剧烈。

正确的做法是按用户ID做随机划分。比如随机抽70%的已知性别用户进训练集,剩下30%进测试集。划分时还要满足一个硬约束:同一个用户不能同时出现在训练集和测试集里。这个约束看着废话,但在特征提取阶段容易翻车——有些人直接把每个用户的所有评分行打散,前70%行进训练集、后30%行进测试集,这不叫划分,这叫特征泄漏。同一个用户的特征碎片被拆到两个集合里,KNN在预测时其实是拿着半张用户画像去match另外半张,准确率能虚高到90%以上,上线之后立刻被打回原形。

划分代码其实就几行:

# 对已知性别的用户ID做随机分桶,0-6进训练集,7-9进测试集 awk -F'::' 'BEGIN{srand(42)} {print int(rand()*10)"\t"$0}' users.dat > user_fold.txt # 按第一列排序后拆成两个文件 grep -P '^[0-6]\t' user_fold.txt | cut -f2- > train_users.dat grep -P '^[7-9]\t' user_fold.txt | cut -f2- > test_users.dat

srand(42)固定随机种子,是为了让实验可复现。你没有固定种子的话,每次跑代码训练测试集划分都不一样,调参时根本无法判断准确率变化是参数引起的还是数据划分引起的。

5. 性别预测项目的5个典型避坑记录

5.1 DistributedCache撑爆:训练集太大导致JVM直接OOM

现象:作业跑起来之后,Mapper的setup阶段卡死,然后报java.lang.OutOfMemoryError: Java heap space,整个task重试几次后失败。

原因:训练集特征文件被DistributedCache原样分发到每个Mapper节点,JVM默认堆内存只有1GB左右。如果训练集有50万用户×10维特征,文本文件可能膨胀到500MB以上,加上Java对象头开销,setup阶段把特征读进List后堆内存直接爆掉。

解决:先区分文件大小和内存占用。把mapreduce.map.java.opts调大是最后的办法,但治标不治本。更好的做法是控制单节点并发Mapper数,在yarn-site里把yarn.nodemanager.resource.memory-mb分给少量Mapper,让每个Mapper有充足堆内存。另一个思路是把训练集特征用二进制SequenceFile存,读入速度和内存占用都比纯文本好一个量级。

5.2 数据倾斜:热门电影把某个Reducer打成热点

现象:整个作业大部分Reducer几秒跑完,有一个Reducer跑了几十分钟不停,拖慢整个作业。

原因:这个项目里Reducer是归并同一个用户的候选,理论上按用户ID分组后倾斜不严重。但在特征提取阶段,如果某个电影是全网爆款,比如那种几百万人都看过的豆瓣Top250,它对应的评分行会集中在少数几个HDFS分块里,导致某些Map任务的数据量是别人的几十倍。

解决:特征提取阶段不要把原始评分文件直接塞给Hadoop作业,先做一次按用户ID的哈希分区。userId.hashCode() % numPartitions可以把同一个用户的评分行散列到不同分区,Map端的负载自然摊平。如果只是MapTask倾斜,调大mapreduce.input.fileinputformat.split.maxsize也能缓解,但哈希分区是更根治的做法。

5.3 k值是玄学:太小过拟合、太大被多数类带偏

现象:k=1时准确率在训练集上逼近100%,测试集上只有53%;k=50时准确率反而跌到49%,还不如随机猜。

原因:k=1意味着只认最相似的那一个样本,训练样本自身的噪声被完整继承下来,这是典型的过拟合。k太大时,邻域范围扩到了不同类型口味的用户群体里,投票被大族群稀释。

解决:k值不是一个可以理论推导的参数,只能网格搜索。在这个项目里,从k=3开始,按奇数递增跑到k=15,记录每个k对应的测试集准确率。一般规律是k=5到k=9之间出现峰值,但训练集样本量不同峰值位置也不同。另外配合交叉验证来选k,而不是单次划分,不然选出来的k可能是数据划分的偶然产物。

5.4 特征不归一化:欧氏距离被评分尺度绑架

现象:特征提取和MapReduce代码都对,但准确率稳定在55%以下,比随机猜高不了几个点。查看预测结果发现,投票几乎全部偏向样本量大的性别。

原因:训练集特征里,weeklyCnt这个特征的范围是0到200,而avgRating的范围是1到5。算欧氏距离时,weeklyCnt的差值在距离公式里占据了绝对主导,评分偏好、类型占比全是摆设。KNN实际退化成了“只看活跃度是否接近”。

解决:回到第2.3节的Min-Max归一化。注意归一化的min和max必须从训练集统计出来,测试集直接套用。而且要检查归一化后的特征是否真的落到了0到1区间,部分特征因为outlier的存在,归一化后仍然是0.8到1.2的分布,这时应该用截断式归一化:超过95分位数的值一律截断到95分位数。

5.5 TreeMap的Double key丢数据:距离相等时近邻被悄悄覆盖

现象:Reducer输出的候选数量总是少于预期的K,而且准确率忽高忽低,关键时刻总是差一票。

原因:Mapper里用TreeMap<Double, Integer>保存局部近邻,key是距离值。Java里两个不同的样本算出来的欧氏距离完全相同是常事,尤其是归一化后特征精度有限时。TreeMap认为是同一个key,后进去的标签直接把前面的覆盖掉,实际参与投票的近邻数比K少。这个坑在代码表面看不出来,你打印日志才能发现treemap的size只有3个,而k设的是5。

解决:如第3.2节代码所示,用TreeMap<Double, List<Integer>>作为距离到标签列表的映射,相同距离的样本存进同一个列表。投票时遍历整个List,避免有效近邻丢失。这里有个进阶细节:如果某个距离值下堆积了特别多的样本,说明特征区分度不够,应该回头优化特征向量,而不是在代码里打补丁。

6. 用交叉验证和混淆矩阵调准k值:验证方法与一条实用提交命令

项目跑通只是第一步,准确率能拿出来讲才算完成。调参阶段我用5折交叉验证代替单次切分:把已知性别的用户随机分成5份,每次留1份当测试集、其余4份当训练集,跑5轮,取平均准确率。每轮提交作业的命令长这样:

yarn jar knn-sex-predictor.jar com.example.SexPredictorDriver \ /data/train_fold_$i.dat /data/test_fold_$i.dat /output_fold_$i $K

$i从1循环到5,$K从3循环到15。跑完一轮后用脚本把HDFS输出拉回本地:

hdfs dfs -cat /output_fold_$i/part-r-00000 > pred_fold_$i.txt

接下来看混淆矩阵,而不是只看整体准确率。整体准确率容易骗人,比如女性用户占30%,模型全预测男性就能拿到70%准确率。混淆矩阵能把误差拆开:

实际\预测男性女性
男性TPFP
女性FNTN

重点看召回率:男性召回率 = TP / (TP + FN),如果男性召回率高而女性召回率低,说明模型被多数类绑架了。此时不是调k,而是回到第2.3节做样本均衡。

我自己跑这个项目时最大的教训是:KNN调参的优先顺序一定是特征 > 距离度量 > k值 > 样本均衡,顺序反了,后面全是在浪费时间。先把特征图纸画干净,再谈并行优化。希望这份文档说明能帮你少走一次这个弯路。

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

返回列表