简介这份资源是一套基于KNN算法与Hadoop平台的电影网站用户性别预测实现程序面向具备一定Java与大数据基础、希望实践分类算法与分布式计算的学习者。项目围绕MovieLens用户评分数据展开涵盖数据预处理、KNN建模及模型评价等环节可用于课程设计、毕业设计或算法练手场景。压缩包共78个文件约5.86MB包含28个java源码、30个class编译文件、5个jar包以及dat数据文件、properties配置与readme说明分别对应预处理、KNN计算与评价模块结构完整便于分阶段运行。目前已有3037人学习下载。读者可据此掌握Hadoop环境下数据预处理打包运行、KNN分类建模与模型评估的完整流程并参考作者两小时运行经验合理规划计算资源快速复现并理解大规模数据下的性别预测实现思路。1. 从电影网站日志到性别标签这条链路到底在算什么电影网站后台每天沉淀大量行为日志谁在几点看了哪部片、打了多少分、停留多久、搜索了什么关键词。把这些日志丢进 Hadoop 做离线批处理再训练一个分类模型去推断用户性别就是标题里说的「基于 Hadoop 的电影网站用户性别预测」。它解决的不是「猜得准不准」这一个问题而是当站点日活上到几十万、日志按 TB 涨的时候单机 pandas 已经跑不动必须把特征工程和模型训练拆到 HDFS 和 YARN 上。适合谁适合手里有站内行为日志、想用 Hadoop 生态做用户画像的工程师也适合正在做 Hadoop 课程设计、需要一条能跑通的完整链路的人。核心难点不在算法而在日志清洗、特征拼接和分布式任务调度的衔接。2. 为什么性别预测要挂在 Hadoop 上数据规模与特征选型2.1 单机跑不动的那条分界线在哪先算一笔账。一个中等规模电影站日活 20 万人均产生 30 条行为事件浏览、评分、搜索、收藏一天就是 600 万条。每条事件带上用户 ID、影片 ID、时间戳、事件类型、评分值、停留秒数序列化后约 200 字节一天 1.2 GB。看起来不大但性别预测不是拿一天数据就能训的通常要取 90 天做窗口那就是 100 GB 量级。再叠加用户侧的基础属性、影片侧的元数据类型、年代、导演做 join 之后中间结果会膨胀到原始数据的 3 到 5 倍。单机 pandas 处理 100 GB 的 join内存直接爆就算用分块读取特征拼接的 shuffle 也会把磁盘 IO 打满。这时候 Hadoop 的价值就出来了HDFS 负责把日志按块分散存储MapReduce 或 Spark on YARN 负责把 join 和聚合拆到多个节点并行做。我一般会跟人说数据量没到单机内存的 3 倍以上别急着上 Hadoop但一旦过了这条线Hadoop 就是刚需。2.2 性别预测的特征到底取哪些性别预测本质是一个二分类问题但特征设计决定了天花板。从电影网站日志里能抽出的有效特征分四类特征类别具体字段为什么对性别有区分度影片类型偏好用户观看影片的类型分布动作/爱情/科幻/文艺统计上男性更偏动作科幻女性更偏爱情文艺但这不是绝对只是概率倾向评分行为平均评分、评分方差、高分影片占比男性评分往往更极端打 1 分或 5 分女性评分更集中在中高段活跃时段按小时统计的活跃分布晚间和周末的活跃模式存在性别差异搜索词搜索关键词的 TF-IDF 向量搜索「复仇者联盟」和搜索「怦然心动」的人群分布不同注意这里说的都是统计倾向不是刻板印象。模型学的是数据里的相关性不是因果。实际做的时候我会把用户 ID 做哈希脱敏影片 ID 做 embedding 或者 one-hot时间戳拆成小时和星期两个离散特征。2.3 为什么选 Hadoop 而不是直接上 Spark有人会问既然要做机器学习为什么不直接上 Spark MLlib答案是可以而且常见做法就是 Spark on YARN。但标题里写的是 Hadoop说明底层存储和资源调度还是 HDFS YARN。实际链路通常是原始日志落 HDFS用 MapReduce 或 Spark 做 ETL 清洗特征宽表存 Hive 或 Parquet最后用 Spark MLlib 或单机 sklearn 在采样后的特征上训练。Hadoop 在这里的角色是数据底座和批处理引擎不是模型本身。提示如果数据量在 10 GB 以内单机 sklearn pandas 完全够用硬上 Hadoop 只会增加运维成本。选型要看数据规模不要为了用而用。3. 从原始日志到特征宽表Hadoop 上的 ETL 怎么写3.1 日志格式约定与清洗规则假设电影网站的行为日志是 JSON 行格式每行一条事件。典型字段包括 user_id、movie_id、event_typeview/rate/search/favorite、event_time、rating、duration、search_keyword。清洗要做四件事去掉 user_id 为空的脏数据、把 event_time 统一转成 yyyy-MM-dd HH:mm:ss、过滤掉 duration 小于 1 秒的误触记录、把 rating 不在 1 到 5 之间的异常值置空。下面是一个用 Hadoop Streaming 做清洗的 Python mapper 示例#!/usr/bin/env python # mapper.py - 清洗电影网站行为日志 import sys import json from datetime import datetime for line in sys.stdin: line line.strip() if not line: continue try: obj json.loads(line) except ValueError: continue # 跳过解析失败的脏行 user_id obj.get(user_id) if not user_id: continue # 用户ID为空无法归属直接丢弃 event_time obj.get(event_time, ) try: # 统一时间格式兼容时间戳和字符串两种输入 if event_time.isdigit(): dt datetime.fromtimestamp(int(event_time)) else: dt datetime.strptime(event_time, %Y-%m-%d %H:%M:%S) event_time dt.strftime(%Y-%m-%d %H:%M:%S) except Exception: continue duration obj.get(duration, 0) if duration is not None and int(duration) 1: continue # 停留小于1秒视为误触 rating obj.get(rating) if rating is not None: try: rating float(rating) if rating 1 or rating 5: rating None # 异常评分置空 except ValueError: rating None # 输出清洗后的记录用制表符分隔 print(\t.join([ str(user_id), str(obj.get(movie_id, )), str(obj.get(event_type, )), event_time, str(rating) if rating is not None else , str(duration), str(obj.get(search_keyword, )) ]))逻辑说明mapper 逐行读取标准输入用 json.loads 解析任何解析失败或字段缺失的行直接跳过保证下游不会因为脏数据翻车。时间字段做了双格式兼容因为实际日志里经常混着时间戳和格式化字符串。rating 异常值置空而不是丢弃整行是因为评分只是特征之一丢掉整条记录会损失其他字段的信息。参数说明duration 阈值设为 1 秒这个值可以根据业务调整短视频场景可能要设 3 秒。rating 范围锁定 1 到 5如果站点用的是 10 分制这里要改成 1 到 10。3.2 用 Hive 做用户-影片特征聚合清洗后的数据落到 HDFS 的 /user/hadoop/movie_clean 目录接下来用 Hive 建外部表做聚合。核心思路是按 user_id 分组把每个用户的行为聚成一行特征向量。-- 建外部表指向清洗后的数据 CREATE EXTERNAL TABLE IF NOT EXISTS movie_behavior ( user_id STRING, movie_id STRING, event_type STRING, event_time STRING, rating DOUBLE, duration INT, search_keyword STRING ) ROW FORMAT DELIMITED FIELDS TERMINATED BY \t STORED AS TEXTFILE LOCATION /user/hadoop/movie_clean; -- 用户侧聚合特征 CREATE TABLE user_features AS SELECT user_id, COUNT(*) AS total_events, SUM(CASE WHEN event_type view THEN 1 ELSE 0 END) AS view_cnt, SUM(CASE WHEN event_type rate THEN 1 ELSE 0 END) AS rate_cnt, SUM(CASE WHEN event_type search THEN 1 ELSE 0 END) AS search_cnt, AVG(rating) AS avg_rating, STDDEV(rating) AS std_rating, AVG(duration) AS avg_duration, -- 晚间活跃占比18点到23点 SUM(CASE WHEN HOUR(event_time) BETWEEN 18 AND 23 THEN 1 ELSE 0 END) / COUNT(*) AS night_ratio, -- 周末活跃占比 SUM(CASE WHEN DAYOFWEEK(event_time) IN (1,7) THEN 1 ELSE 0 END) / COUNT(*) AS weekend_ratio FROM movie_behavior GROUP BY user_id;逻辑说明这段 HiveQL 把原始事件按用户聚合生成每个用户的统计特征。total_events 反映活跃度view_cnt 和 rate_cnt 反映行为偏好avg_rating 和 std_rating 捕捉评分习惯night_ratio 和 weekend_ratio 刻画时间模式。这些特征后面会和影片类型偏好拼接成完整特征向量。参数说明晚间时段定义为 18 到 23 点这个窗口可以根据站点用户的实际活跃曲线调整。周末用 DAYOFWEEK 判断1 是周日7 是周六不同 Hive 版本可能有差异跑之前先用 SELECT DAYOFWEEK(2024-01-01) 验证一下。3.3 影片类型偏好特征的拼接用户看过的影片类型分布是强特征。做法是先把影片元数据movie_id 到 genre 的映射加载成一张维度表然后和用户行为表 join再按用户聚合出各类型的观看占比。-- 影片类型维度表 CREATE TABLE movie_genre ( movie_id STRING, genre STRING ) ROW FORMAT DELIMITED FIELDS TERMINATED BY \t; -- 用户类型偏好 CREATE TABLE user_genre_pref AS SELECT b.user_id, g.genre, COUNT(*) AS genre_cnt, COUNT(*) / SUM(COUNT(*)) OVER (PARTITION BY b.user_id) AS genre_ratio FROM movie_behavior b JOIN movie_genre g ON b.movie_id g.movie_id WHERE b.event_type IN (view, rate) GROUP BY b.user_id, g.genre;逻辑说明先按 user_id 和 genre 分组计数再用窗口函数算每个类型在该用户总观看量中的占比。这样每个用户会得到多行每个类型一行后面可以用 pivot 转成宽表或者直接在 Spark 里做 one-hot。参数说明WHERE 条件只取 view 和 rate 事件因为 search 事件没有 movie_id 关联favorite 事件量通常很小加不加对结果影响不大。如果站点类型体系很细超过 30 种建议先归并成 8 到 10 个大类否则特征维度太高模型容易过拟合。4. 性别预测模型训练从特征宽表到分类器4.1 特征宽表的最终形态与标签对齐走到这一步手上有两张表user_features用户统计特征和 user_genre_pref类型偏好。需要把它们拼成一张宽表并且和性别标签对齐。性别标签通常来自用户注册时填写的资料或者第三方登录返回的 gender 字段。实际场景里标签缺失率可能高达 40% 到 60%这是最大的坑之一。# 用 Spark 做特征拼接和标签对齐 from pyspark.sql import SparkSession from pyspark.sql.functions import col, when, lit from pyspark.ml.feature import VectorAssembler, StringIndexer from pyspark.ml.classification import RandomForestClassifier from pyspark.ml.evaluation import BinaryClassificationEvaluator spark SparkSession.builder \ .appName(GenderPrediction) \ .enableHiveSupport() \ .getOrCreate() # 读取 Hive 表 user_feat spark.sql(SELECT * FROM user_features) genre_pref spark.sql(SELECT * FROM user_genre_pref) labels spark.sql(SELECT user_id, gender FROM user_profile WHERE gender IS NOT NULL) # 把类型偏好 pivot 成宽表 genre_wide genre_pref.groupBy(user_id).pivot(genre).sum(genre_ratio).fillna(0) # 拼接所有特征 df user_feat.join(genre_wide, onuser_id, howleft) \ .join(labels, onuser_id, howinner) \ .fillna(0) # 标签编码male0, female1 df df.withColumn(label, when(col(gender) female, lit(1.0)).otherwise(lit(0.0))) # 特征向量组装 feature_cols [c for c in df.columns if c not in (user_id, gender, label)] assembler VectorAssembler(inputColsfeature_cols, outputColfeatures) df assembler.transform(df) # 划分训练集和测试集 train, test df.randomSplit([0.8, 0.2], seed42) # 随机森林训练 rf RandomForestClassifier(labelCollabel, featuresColfeatures, numTrees100, maxDepth8) model rf.fit(train) # 评估 predictions model.transform(test) evaluator BinaryClassificationEvaluator(labelCollabel, metricNameareaUnderROC) auc evaluator.evaluate(predictions) print(AUC %.4f % auc)逻辑说明先用 pivot 把类型偏好从长表转成宽表每个类型一列值是该类型的观看占比。然后和用户统计特征、性别标签做 joininner join 会丢掉没有标签的用户这是有意的因为无标签数据不能用于监督训练。标签编码成 0 和 1特征用 VectorAssembler 拼成一个向量列。随机森林的 numTrees 设 100maxDepth 设 8是为了在偏差和方差之间取平衡。参数说明randomSplit 的 0.8/0.2 是常见比例如果数据量超过 100 万行可以改成 0.9/0.1。numTrees 越大模型越稳但训练越慢100 到 200 是常用区间。maxDepth 超过 10 容易过拟合尤其是特征维度高的时候。4.2 类别不平衡的处理与阈值调整电影网站的用户性别分布通常不是 50/50男性用户可能占 60% 到 70%。如果直接训练模型会倾向于预测多数类导致少数类的召回率很低。常见做法有两种一是对多数类降采样二是用类别权重。# 方法一对多数类降采样 male_df df.filter(col(label) 0.0) female_df df.filter(col(label) 1.0) # 按少数类数量对齐多数类 ratio female_df.count() / male_df.count() male_sampled male_df.sample(withReplacementFalse, fractionratio, seed42) balanced_df male_sampled.union(female_df) # 方法二在随机森林里设置类别权重 from pyspark.ml.classification import RandomForestClassifier rf_balanced RandomForestClassifier( labelCollabel, featuresColfeatures, numTrees150, maxDepth8, weightColclass_weight # 需要提前构造权重列 )逻辑说明降采样简单直接但会损失多数类的信息量。类别权重不丢数据但需要额外构造权重列通常少数类权重设为多数类数量的倒数比。我一般先试降采样如果 AUC 掉得厉害再换权重法。参数说明sample 的 fraction 参数按少数类与多数类的比例算比如女性占 30%男性占 70%fraction 就是 30/70 约等于 0.43。weightCol 的权重值建议用总样本数除以类别样本数让两类总权重接近。4.3 模型评估AUC 之外还要看什么AUC 是排序指标反映模型把正样本排在负样本前面的能力。但业务上更关心的是在某个阈值下预测为女性的用户里有多少是真的女性精确率以及所有真实女性里有多少被找出来召回率。这两个指标此消彼长阈值调高精确率上升召回率下降反之亦然。from pyspark.ml.evaluation import BinaryClassificationEvaluator from pyspark.sql.functions import col # 按不同阈值统计精确率和召回率 thresholds [0.3, 0.4, 0.5, 0.6, 0.7] for t in thresholds: tp predictions.filter((col(prediction) 1.0) (col(label) 1.0)).count() fp predictions.filter((col(prediction) 1.0) (col(label) 0.0)).count() fn predictions.filter((col(prediction) 0.0) (col(label) 1.0)).count() precision tp / (tp fp) if (tp fp) 0 else 0 recall tp / (tp fn) if (tp fn) 0 else 0 print(threshold%.1f, precision%.4f, recall%.4f % (t, precision, recall))逻辑说明这段代码手动遍历阈值统计混淆矩阵的四个值算出精确率和召回率。实际项目里可以用 BinaryClassificationEvaluator 的 precisionRecall 曲线但手动算更直观方便和业务方沟通。参数说明阈值从 0.3 到 0.7 遍历步长 0.1。如果业务要求高精确率比如只对高置信用户做定向推荐阈值可以设到 0.7 以上如果要求高召回率比如做全量用户画像补全阈值可以降到 0.3。5. 避坑与排查这条链路上最容易翻车的五个地方5.1 标签缺失率过高导致模型学不到东西现象训练出来的 AUC 只有 0.55 左右接近随机猜。原因性别标签来自用户注册资料但大量用户没填或者填了「保密」实际有标签的样本可能只占总用户的 20% 到 30%而且有标签的用户本身就有偏愿意填性别的用户可能更活跃。解决先统计标签覆盖率如果低于 30%考虑用半监督学习或者用行为相似度做标签传播另外检查有标签用户和无标签用户在行为特征上是否有显著差异如果有模型只能适用于有标签的那部分人群。5.2 Hive 表分区没建全表扫描拖垮任务现象一个简单的聚合查询跑了 40 分钟还没出结果。原因movie_behavior 表按天分区但建表时没写 PARTITIONED BY所有数据堆在一个目录下每次查询都全量扫描。解决建表时加上 PARTITIONED BY (dt STRING)加载数据时用 INSERT OVERWRITE ... PARTITION(dt2024-01-01)查询时带上 WHERE dt BETWEEN ... 条件。这个坑我踩过不止一次血泪经验就是Hive 表不分区等于白用 Hadoop。5.3 特征穿越用了未来信息预测过去现象离线 AUC 很高0.85 以上但上线后效果很差。原因特征里混入了标签泄露的字段比如用「用户最近一次登录时间」做特征但登录时间是在性别填写之后产生的等于用未来信息预测过去。解决梳理每个特征的时间戳确保特征的计算时间早于标签的产生时间。具体做法是在 ETL 里加一个时间窗口限制只用标签产生之前的行为数据。5.4 数据倾斜某个用户的行为日志特别多现象Spark 任务卡在最后一个 reduce 阶段99% 的 task 都完成了剩下一个跑了半小时。原因某个爬虫账号或者测试账号产生了海量行为日志按 user_id 分组时全落到一个 reducer 上。解决先统计每个用户的事件数把超过阈值比如 10 万条的异常用户单独处理或者过滤掉或者在 groupBy 之前加随机前缀打散聚合两次。5.5 模型上线后性别分布漂移现象模型上线三个月后女性用户的召回率从 0.7 掉到 0.5。原因站点用户结构变了新注册用户里女性比例上升但模型还是按旧分布训练的。解决定期用新数据重新训练监控预测结果的性别分布和真实分布的 KL 散度超过阈值就触发重训。这个没有一劳永逸的办法只能靠监控和迭代。6. 把 AUC 再拉高五个点特征交叉与模型融合的实操技巧走到这一步基线模型已经能跑了但 AUC 可能卡在 0.72 到 0.75 上不去。我一般会做两件事特征交叉和模型融合。特征交叉方面最有效的是把「影片类型偏好」和「活跃时段」做交叉。比如一个用户晚上 10 点后大量观看动作片这个组合特征比单独的类型偏好或时段特征更有区分度。实现上可以在 Spark 里用 VectorAssembler 之后接一个 PolynomialExpansion但维度会爆炸更稳妥的做法是手动构造几个关键交叉项from pyspark.sql.functions import col, when # 构造交叉特征晚间动作片偏好 df df.withColumn( night_action, col(night_ratio) * col(Action_ratio) # Action_ratio 来自类型偏好宽表 ) # 构造交叉特征周末爱情片偏好 df df.withColumn( weekend_romance, col(weekend_ratio) * col(Romance_ratio) ) # 评分极端程度高分和低分占比之和 df df.withColumn( rating_extreme, col(high_rating_ratio) col(low_rating_ratio) )逻辑说明night_action 捕捉「夜间看动作片」这个组合行为weekend_romance 捕捉「周末看爱情片」的组合。rating_extreme 反映评分是否走极端男性用户往往更极端。这些交叉项不需要太多三到五个就够多了反而引入噪声。参数说明Action_ratio 和 Romance_ratio 是类型偏好宽表里的列名实际列名取决于 genre 的取值。high_rating_ratio 和 low_rating_ratio 需要在 user_features 里提前算好比如评分大于等于 4 算高小于等于 2 算低。模型融合方面我习惯用随机森林加逻辑回归做 stacking。随机森林捕捉非线性关系逻辑回归捕捉线性关系两者互补。具体做法是把随机森林的预测概率作为一个新特征和原始特征一起喂给逻辑回归from pyspark.ml.classification import LogisticRegression from pyspark.ml.feature import VectorAssembler # 随机森林预测概率作为新特征 rf_pred model.transform(df).select(user_id, probability, label) # 提取正类概率 from pyspark.ml.functions import vector_to_array rf_pred rf_pred.withColumn(rf_prob, vector_to_array(probability)[1]) # 和原始特征拼接 df_ensemble df.join(rf_pred.select(user_id, rf_prob), onuser_id) feature_cols_2 feature_cols [rf_prob] assembler_2 VectorAssembler(inputColsfeature_cols_2, outputColfeatures_2) df_ensemble assembler_2.transform(df_ensemble) # 逻辑回归融合 lr LogisticRegression(labelCollabel, featuresColfeatures_2, maxIter100) lr_model lr.fit(df_ensemble)逻辑说明先用随机森林跑一遍把预测概率取出来作为新特征再和原始特征拼在一起训练逻辑回归。这样逻辑回归能同时利用原始特征的线性信息和随机森林学到的非线性模式。注意要用交叉验证的方式生成 rf_prob否则会有标签泄露。参数说明vector_to_array 是 Spark 3.0 以上的函数低版本需要用 udf 手动解析。maxIter 设 100 通常够用如果没收敛可以加到 200。融合后的 AUC 一般能比单模型高 3 到 5 个点但训练时间也会翻倍。最后说一个我自己的习惯每次跑完模型我都会把特征重要性打出来看一遍。如果某个特征的重要性异常高比如占了 80% 以上我第一反应不是高兴而是怀疑标签泄露。这个习惯帮我抓过好几次穿越特征。希望帮到你。本文还有配套的精品资源点击获取