简介:这份资源是基于KNN算法的Hadoop电影网站用户性别预测实现程序,面向具备一定Java与Hadoop基础、希望实践大数据分类建模的学习者与开发者,可用于课程设计、毕业设计或大数据实验场景。压缩包共78个文件,约5.86MB,包含28个java源码、30个class编译文件、5个jar包以及classpath、project、properties等工程配置,另有dat数据文件与readme说明,覆盖数据预处理、KNN数据计算与模型评价等完整环节。数据预处理阶段可打包成jar在Hadoop集群运行,数据目录需上传至运行目录并修改文件路径;KNN计算阶段改为本地目录直接运行即可,因数据量较大,作者实测运行约两小时。目前已有3037人学习下载,读者可借此掌握从数据join、评分与用户数据拆分到KNN分类预测的全流程实现思路,并参考现成工程结构与jar包快速复现实验、排查路径与运行配置问题。
1. 电影网站用户性别预测:从 Hadoop 日志到可落地的分类流水线
电影网站上用户注册时性别是选填项,运营侧却要靠性别做推荐冷启动和广告定向。真实场景里,一个日活几十万的站点,用户行为日志每天增量在 GB 级别,单机 pandas 读一遍就爆内存。这个标题要解决的就是:把散落在 HDFS 上的用户行为日志,用 Hadoop 做清洗和特征聚合,再喂给分类模型预测缺失性别。它适合有 Java 或 Python 基础、正在做课程设计或公司内数据平台建设的工程师。核心链路是 HDFS 存原始日志、MapReduce 或 Spark 做特征、模型做二分类。下面按「数据怎么进、特征怎么算、模型怎么训、坑怎么排」四段推进,每一步都给可复现的命令和参数。
2. 数据落 HDFS 与特征口径:先把性别预测的输入定义清楚
2.1 为什么性别预测不能直接拿原始日志开跑
电影网站的用户行为日志通常长这样:一行一条 JSON,字段包括 user_id、movie_id、action(click/collect/rate)、timestamp、rating、device。性别标签只存在于注册表里,且大量为空。直接拿原始日志做训练有两个致命问题:一是同一用户的行为条数差异极大,重度用户几千条、轻度用户几条,模型会被高频用户带偏;二是原始日志里没有聚合特征,单条点击记录对性别几乎没有区分度。
常见做法是先把行为按 user_id 聚合,生成每个用户的统计画像,再和注册表的性别标签 join。这一步在 Hadoop 上做,好处是数据量再涨也不用改架构。特征口径要在动手前定死,否则后面模型效果无法归因。我一般会定这几类:行为频次(点击、收藏、评分的次数)、行为偏好(各类型电影占比)、时间习惯(活跃时段分布)、评分倾向(平均分、方差)。性别差异往往藏在偏好和评分倾向里,频次类特征区分度反而有限。
提示:特征口径一旦确定,写进配置或常量类,不要散落在各个 Job 里,否则换一批数据就对不上。
2.2 用 MapReduce 做用户行为聚合的最小可跑示例
假设原始日志在/movie/raw/下,格式是每行一个 JSON。先写 Mapper 解析出 user_id 和关键行为字段,Reducer 按 user_id 聚合。下面是最小可跑的 Java 代码骨架,跑通后再加特征。
// UserBehaviorMapper.java public class UserBehaviorMapper extends Mapper<LongWritable, Text, Text, Text> { private ObjectMapper jsonMapper = new ObjectMapper(); // Jackson private Text outKey = new Text(); private Text outValue = new Text(); @Override protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException { String line = value.toString().trim(); if (line.isEmpty()) return; try { JsonNode node = jsonMapper.readTree(line); String userId = node.path("user_id").asText(); String action = node.path("action").asText(); String movieId = node.path("movie_id").asText(); // 输出格式:user_id -> action:movieId outKey.set(userId); outValue.set(action + ":" + movieId); context.write(outKey, outValue); } catch (Exception e) { // 脏数据计数,不要直接抛异常中断整个 Job context.getCounter("PARSE", "BAD_JSON").increment(1); } } }// UserBehaviorReducer.java public class UserBehaviorReducer extends Reducer<Text, Text, Text, Text> { @Override protected void reduce(Text key, Iterable<Text> values, Context context) throws IOException, InterruptedException { int click = 0, collect = 0, rate = 0; Set<String> movieSet = new HashSet<>(); for (Text v : values) { String[] parts = v.toString().split(":", 2); String action = parts[0]; String movieId = parts.length > 1 ? parts[1] : ""; movieSet.add(movieId); switch (action) { case "click": click++; break; case "collect": collect++; break; case "rate": rate++; break; default: break; } } // 输出:user_id \t click,collect,rate,distinct_movies String result = click + "," + collect + "," + rate + "," + movieSet.size(); context.write(key, new Text(result)); } }逻辑说明:Mapper 只做解析和字段抽取,不做任何聚合,保证可并行;Reducer 按 user_id 拿到该用户所有行为,统计各动作次数和去重电影数。参数说明:context.getCounter用来统计脏数据量,跑完在 Job 日志里能看到,方便判断数据质量;movieSet用 HashSet 去重,如果单用户行为条数极大(比如超过十万),要考虑用 HyperLogLog 近似去重,否则 Reducer 内存会吃紧。
打包提交命令:
# 编译打包 mvn clean package -DskipTests # 提交到 YARN hadoop jar user-profile.jar com.example.UserBehaviorJob \ -D mapreduce.job.reduces=8 \ /movie/raw/ /movie/feature/user_behavior/mapreduce.job.reduces按数据量调,一般 1GB 数据给 4 到 8 个 Reducer。输出目录不能预先存在,否则 Job 直接失败,这是新手最常踩的坑之一。
2.3 把注册表 join 进来生成带标签的训练集
聚合完行为特征后,需要和注册表 join 拿到性别标签。注册表通常不大,几十万到几百万行,可以走分布式缓存(DistributedCache)在 Map 端完成 join,避免 Reduce 端 shuffle。
// LabelJoinMapper.java public class LabelJoinMapper extends Mapper<LongWritable, Text, Text, Text> { private Map<String, String> userGender = new HashMap<>(); @Override protected void setup(Context context) throws IOException { // 从分布式缓存读取注册表 URI[] files = DistributedCache.getCacheFiles(context.getConfiguration()); for (URI file : files) { try (BufferedReader br = new BufferedReader( new InputStreamReader(new FileInputStream(file.getPath())))) { String line; while ((line = br.readLine()) != null) { String[] parts = line.split("\t"); if (parts.length >= 2) { userGender.put(parts[0], parts[1]); // user_id -> gender } } } } } @Override protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException { // 输入是上一步的输出:user_id \t click,collect,rate,distinct_movies String[] parts = value.toString().split("\t"); if (parts.length < 2) return; String userId = parts[0]; String gender = userGender.get(userId); if (gender == null || gender.isEmpty()) return; // 无标签样本丢弃 context.write(new Text(gender), new Text(parts[1])); } }逻辑说明:setup 阶段把注册表加载进内存,map 阶段对每条行为特征查表打标签。参数说明:注册表通过-files参数分发,命令里写-files hdfs:///movie/raw/register.tsv#register.tsv。注意性别标签只保留 M/F 两类,其他值(如空、未知)在 setup 里就过滤掉,否则模型会多出无意义类别。这一步输出就是训练集的原始格式,后续可以拉到本地或直接在 Spark 里读。
3. 特征工程与模型训练:从 HDFS 特征到性别分类器
3.1 在 Spark 里做特征向量化和类别平衡
Hadoop 聚合出的特征是数值型,但直接丢给逻辑回归效果一般,因为各特征量纲差异大,且性别样本往往不平衡(男性用户可能占七成)。常见做法是在 Spark 里做标准化和重采样。下面用 PySpark 写一段可跑的脚本。
from pyspark.sql import SparkSession from pyspark.ml.feature import VectorAssembler, StandardScaler from pyspark.ml.classification import LogisticRegression from pyspark.ml.evaluation import BinaryClassificationEvaluator from pyspark.sql.functions import col, when spark = SparkSession.builder \ .appName("GenderPredict") \ .config("spark.sql.shuffle.partitions", "200") \ .getOrCreate() # 读取 HDFS 上的特征,格式:gender \t click,collect,rate,distinct_movies df = spark.read.csv("hdfs:///movie/feature/labeled/", sep="\t") \ .toDF("gender", "features_raw") # 拆分特征列 from pyspark.sql.functions import split df = df.withColumn("feat_arr", split(col("features_raw"), ",")) for i, name in enumerate(["click", "collect", "rate", "distinct_movies"]): df = df.withColumn(name, col("feat_arr").getItem(i).cast("double")) # 标签编码:M -> 1.0, F -> 0.0 df = df.withColumn("label", when(col("gender") == "M", 1.0).otherwise(0.0)) # 向量化 assembler = VectorAssembler( inputCols=["click", "collect", "rate", "distinct_movies"], outputCol="features_raw_vec" ) df = assembler.transform(df) # 标准化 scaler = StandardScaler(inputCol="features_raw_vec", outputCol="features", withMean=True, withStd=True) scalerModel = scaler.fit(df) df = scalerModel.transform(df) # 类别平衡:对少数类过采样 major = df.filter(col("label") == 1.0) minor = df.filter(col("label") == 0.0) ratio = major.count() / max(minor.count(), 1) minor_over = minor.sample(withReplacement=True, fraction=ratio, seed=42) balanced = major.union(minor_over) # 训练 train, test = balanced.randomSplit([0.8, 0.2], seed=42) lr = LogisticRegression(maxIter=100, regParam=0.01, elasticNetParam=0.0) model = lr.fit(train) # 评估 predictions = model.transform(test) evaluator = BinaryClassificationEvaluator(metricName="areaUnderROC") auc = evaluator.evaluate(predictions) print("AUC =", auc)逻辑说明:先拆分 CSV 特征列并转 double,再做标签编码和向量化,标准化消除量纲影响,过采样缓解类别不平衡,最后训练逻辑回归并输出 AUC。参数说明:regParam控制正则强度,特征少时 0.01 到 0.1 都合理;elasticNetParam为 0 表示纯 L2 正则;spark.sql.shuffle.partitions默认 200,小数据可以降到 20 减少调度开销。AUC 低于 0.6 说明特征区分度不够,要回去补特征,而不是调模型参数。
3.2 特征重要性怎么看:别让模型当黑匣子
逻辑回归的系数可以直接解释。训练完打印model.coefficients,对应四个特征的权重。如果某个特征权重接近零,说明它对性别预测没贡献,可以考虑删掉。更直观的做法是用随机森林看特征重要性。
from pyspark.ml.classification import RandomForestClassifier rf = RandomForestClassifier(numTrees=100, maxDepth=5, seed=42) rfModel = rf.fit(train) print("Feature importances:", rfModel.featureImportances)featureImportances返回一个稀疏向量,索引对应输入特征顺序。我一般会把结果和业务直觉对照:如果「收藏次数」权重远高于「点击次数」,说明收藏行为更能区分性别,后续可以围绕收藏做更细的特征(比如收藏电影的类型分布)。这一步是血泪经验——不看特征重要性直接调参,等于蒙眼开车。
3.3 模型持久化与离线打分
训练好的模型要存下来,供后续批量打分。Spark 模型用save写到 HDFS,加载时用对应类的load。
# 保存 model.save("hdfs:///movie/model/gender_lr/") # 加载并打分 from pyspark.ml.classification import LogisticRegressionModel loaded = LogisticRegressionModel.load("hdfs:///movie/model/gender_lr/") scored = loaded.transform(df) scored.select("user_id", "prediction", "probability").write \ .mode("overwrite").parquet("hdfs:///movie/output/gender_pred/")参数说明:保存路径不要预先创建,Spark 会自己建目录;打分结果存 parquet 比 CSV 省空间且带 schema。离线打分适合每天跑一次批任务,把预测性别回写到用户画像表。如果要做实时,得换在线特征存储和模型服务,那是另一个话题。
4. 避坑与排查:Hadoop 性别预测流水线最常见的 5 个翻车点
4.1 现象:Job 卡在 Map 100% Reduce 0% 不动
原因:数据倾斜。某个 user_id 行为条数极多,所有记录落到同一个 Reducer,其他 Reducer 早早跑完,它还在慢慢磨。解决:在 Mapper 输出 key 上加随机后缀打散,比如user_id + "_" + rand(0,9),Reducer 里再去掉后缀聚合;或者改用 Spark 的repartition加盐。判断方法看 Job 计数器里各 Reduce 的输入记录数,差一个数量级就是倾斜。
4.2 现象:AUC 一直在 0.5 附近,模型等于瞎猜
原因:特征里混入了 user_id 或 movie_id 这类高基数 ID 特征,模型学到的是记忆而不是泛化;或者标签 join 时 user_id 格式不一致(一边是字符串一边是数字),导致大量样本标签为空被丢弃。解决:检查 join 后的样本量,如果比预期少很多,先排查格式;特征里坚决不放原始 ID,只放聚合统计量。
4.3 现象:提交 Job 报错 Could not find or load main class
原因:打包时没有把依赖打进去,或者hadoop jar指定的类名和实际包路径不一致。解决:用 Maven Shade 插件打 fat jar,命令里类名写全限定名,比如com.example.UserBehaviorJob。另外注意 Hadoop 集群的 Java 版本要和编译版本一致,本地用 JDK 17 编译、集群是 JDK 8,跑起来会报 UnsupportedClassVersionError。
4.4 现象:输出目录已存在导致 Job 直接失败
原因:Hadoop 默认不允许覆盖已存在的输出目录,这是保护机制。解决:提交前先hadoop fs -rm -r /movie/feature/user_behavior/,或者在代码里用FileOutputFormat.setOutputPath前先判断并删除。不要图省事改成允许覆盖,容易误删上游数据。
4.5 现象:Spark 读 HDFS 特征时报 NumberFormatException
原因:Hadoop 聚合输出里混入了脏数据,比如某行只有 user_id 没有特征,或者特征列里有空字符串。解决:在 Spark 读取后用filter过滤掉特征列数为 0 或转换失败的行,同时回到 MapReduce 阶段加计数器统计脏数据比例。如果脏数据超过 5%,要先修数据源,而不是在模型侧硬扛。
5. 进阶技巧:用性别预测概率做用户画像的软标签
硬标签(直接预测 M/F)在性别缺失率高的场景下信息损失大,更好的做法是输出概率,把「男性概率 0.73」作为软标签写进画像。推荐系统里可以用这个概率做加权,而不是二值切分。具体操作是在打分后不取prediction,而是从probability向量里取正类分量。
from pyspark.sql.functions import udf from pyspark.sql.types import DoubleType from pyspark.ml.linalg import VectorUDT # 提取正类概率 extract_prob = udf(lambda v: float(v[1]), DoubleType()) scored = scored.withColumn("male_prob", extract_prob("probability")) scored.select("user_id", "male_prob").write \ .mode("overwrite").parquet("hdfs:///movie/output/gender_soft/")验证软标签是否靠谱,可以按male_prob分桶,看每桶里真实性别为 M 的比例是否单调递增。如果 0.5 到 0.6 桶的真实男性比例反而低于 0.4 到 0.5 桶,说明模型排序能力有问题,要回去检查特征和采样。这个验证方法比单看 AUC 更能暴露问题。
另一个技巧是把性别预测和电影类型偏好做交叉特征。比如「男性概率 × 动作片点击占比」,这类交叉特征在树模型里往往比原始特征更有区分度。我一般会在 Spark 里用VectorAssembler把交叉项拼进去,再训一版随机森林对比 AUC。如果提升不到 0.02,就不值得增加线上复杂度。
最后说个习惯:每次跑完流水线,把 AUC、特征重要性、脏数据比例三个指标记到一张表里,按天对比。模型效果突然掉,先看脏数据比例是不是涨了,再看特征分布是不是漂了。这个习惯帮我省过好几次通宵排查。希望帮到你。
本文还有配套的精品资源,点击获取