项目9 广告检测的流量作弊识别——Spark综合实战
本项目是 Spark 课程的收官实战,把前面学过的 Spark SQL、Hive、MLlib、集群提交全部串起来,完成一个完整的"数据分析 → 特征工程 → 建模评估 → 模型上线"业务闭环。
目录
- 一、项目背景:虚假流量是什么
- 二、需求分析:6步识别流程
- 三、数据说明:22个字段的数据字典
- 四、任务9.1 探索分析广告流量数据
- 五、任务9.2 预处理数据并构建特征
- 六、任务9.3 特征标准化与数据划分
- 七、任务9.4 构建与评估分类模型
- 八、任务9.5 模型加载与应用
- 九、排错指南
- 十、面试高频考点
- 十一、项目小结
一、项目背景:虚假流量是什么
1.1 业务问题
互联网虚假流量:通过特殊方式(如设置程序每分钟访问一次网站主页)模仿人类浏览行为生成的访问流量。
为什么有人要作弊? 浏览量的增加一般能促进销售量增加;同等条件下,流量大的网站收取的广告费用更高。部分网站受利益驱使,通过作弊方式产生虚假流量。
结果:网站凭虚假流量收了更高广告费,却没促进真实浏览量和销量,直接损害广告主利益。
💡 一句话理解:广告主按"曝光/点击"付钱,作弊者用机器冒充真人刷量骗广告费——本项目就是用 Spark 把机器刷出来的流量识别出来。
1.2 常见的流量作弊方式(4种)
| 作弊方式 | 原理 | 数据层面的特征表现 |
|---|---|---|
| 脚本刷量 | 设定程序,让电脑按规则访问目标网站 | 同一 ip + 同一 cookie 高频出现 |
| 控制肉鸡访问 | 利用受病毒感染的电脑(肉鸡)访问网站 | 大量不同 ip 短时间集中访问 |
| 页面代码修改 | 在媒体网站插入隐藏代码,加载指向目标网站的不可见小页面 | 特定渠道的异常流量 |
| DNS劫持 | 篡改 DNS 数据,强制把用户访问改到目标网站 | 大量用户被"劫持"到目标 |
本项目重点识别前两种衍生出的三类行为:脚本刷新、定期清除 cookie 刷新、ADSL 重新拨号刷新(见 4.4 节)。
二、需求分析:6步识别流程
本项目要完成 3 大需求:
- 探索分析广告流量数据
- 预处理数据并构建特征
- 构建与评估分类模型
对应到完整实现流程(6 步):
① 数据抽取与划分
(历史流量数据 → case_data_new.csv,7天数据)
↓
② 数据探索分析
(缺失值、冗余字段基础探索 + 作弊行为特征业务探索)
↓
③ 数据预处理 + 特征构建
(清洗规则 → 构建 N/N1/N2/N3 特征 → 建模样本数据)
↓
④ 建立识别模型并评估对比
(逻辑回归 vs 随机森林,对比准确率)
↓
⑤ 保存效果较好的模型
↓
⑥ 模拟新数据,加载模型应用
(LoadData 模拟新流量 → 模型预测 → 评估)💡 项目定位:这不是一个"纯算法"项目,而是一个数据工程 + 机器学习的综合项目。特征怎么构造(业务理解)比模型调参更重要——这也是生产环境大数据工程师的真实工作方式。
三、数据说明:22个字段的数据字典
数据文件:case_data_new.csv,共 7 天流量数据、1704154 条记录、22 个字段。
3.1 字段完整字典
| 字段 | 说明 | 类型 |
|---|---|---|
| rank | 记录序号 | 数值型 |
| dt | 相对日期(1~7,1 表示第一天) | 数值型 |
| cookie | cookie 值 | 字符型 |
| ip | IP 地址(已脱敏) | 字符型 |
| idfa | 用于识别 iOS 用户 | 字符型 |
| imei | 用于识别 Android 用户 | 字符型 |
| android | 用于识别 Android 用户 | 字符型 |
| openudid | 用于识别 iOS 用户 | 字符型 |
| mac | 识别不同硬件设备 | 字符型 |
| timestamps | 时间戳 | 数值型 |
| camp | 项目 ID | 数值型 |
| creativeid | 创意 ID | 数值型 |
| mobile_os | 设备 OS 版本(原始值) | 数值型 |
| mobile_type | 设备型号 | 数值型 |
| app_key_md5 | 应用 key(MD5) | 字符型 |
| app_name_md5 | 应用名(MD5) | 字符型 |
| placementid | 广告位 ID | 字符型 |
| useragent | 用户代理字符串 | 字符型 |
| mediaid | 媒体 ID | 数值型 |
| os_type | 操作系统类型 | 字符型 |
| born_time | 记录生成时间 | 数值型 |
| label | 标签(0=正常,1=作弊) | 数值型 |
3.2 数据类型分布
| 类型 | 字段 | 用途 |
|---|---|---|
| 字符型(12个) | cookie、ip、idfa、imei、android、openudid、mac、app_key_md5、app_name_md5、placementid、useragent、os_type | 大部分不能直接进数值模型,需要编码或构造特征 |
| 数值型(10个) | rank、dt、timestamps、camp、creativeid、mobile_os、mobile_type、mediaid、born_time、label | 部分可直接使用或作为特征来源 |
⚠️ 关键认知:大部分变量是字符型,不适用于直接运用在需要数值类型的分类模型中——这就是后面要做特征工程的根本原因。
四、任务9.1 探索分析广告流量数据
本任务采用本地模式(
master("local[*]"))对数据进行基础探索,根据探索结果确定清洗规则。
4.1 创建 SparkSession(代码9-1)
import org.apache.spark.sql.SparkSession
object Explore {
def main(args: Array[String]): Unit = {
val spark = SparkSession.builder()
.appName("Explore").master("local[*]")
.enableHiveSupport().getOrCreate()
spark.sparkContext.setLogLevel("WARN")
}
}| 代码 | 含义 |
|---|---|
.appName("Explore") | 应用名称,UI 和日志中显示 |
.master("local[*]") | 本地模式,* = 用满本机所有核 |
.enableHiveSupport() | 开启 Hive 支持(后续要读写 Hive 表) |
.setLogLevel("WARN") | 只输出 WARN 及以上日志,减少刷屏 |
⚠️ 探索阶段用本地模式,建模阶段要改成集群模式(
spark://master:7077)——代码9-18 会看到区别。
4.2 基础探索:记录数、日流量(代码9-2、9-3)
// 读取数据(option 设置首行为列名)
val rawData = spark.read.option("header", "true").csv("E:\\data\\case_data_new.csv")
println("原始数据集行数为:" + rawData.count())
// 统计每天数据量
rawData.groupBy("dt").count().selectExpr("dt","count as dayCount").sort("dt").show()结果:
- 总记录数:1704154 条
- 日流量:7 天差异不大 → 数据环境相对稳定,不存在数据倾斜问题 ✅
💡
selectExpr("dt","count as dayCount")= SQL 写法SELECT dt, count AS dayCount,把 count() 的默认列名改成易读的 dayCount。
4.3 缺失值探索(代码9-4、9-5)
自定义缺失率统计方法
def MissingCount(data: DataFrame, columnName: String): Unit = {
var missingRate = 0.0
if (columnName != "creativeid") {
missingRate = 1 - (data.select(columnName).na.drop().count().toDouble / data.count())
} else {
// creativeid 的"缺失"特殊处理:值为 0 视为缺失
missingRate = data.select(columnName).filter("creativeid == 0").count() / data.count().toDouble
}
if (missingRate > 0) {
println(columnName + " 缺少值比率:" + missingRate * 100 + "%")
}
}val columnName = rawData.columns.toList
for (i <- columnName) {
MissingCount(rawData, i)
}| 代码 | 含义 |
|---|---|
na.drop() | 删除该列含 null 的行 |
.count().toDouble / data.count() | 非缺失数 ÷ 总数 → 完整率;1 - 完整率 = 缺失率 |
creativeid == 0 | creativeid 的特殊逻辑:大量值为 0 不正常,0 视为缺失 |
缺失值结论
22 个字段中 12 个字段存在缺失;useragent 缺失率较少,creativeid 高达 98.39%,且是数值型无法插补 → 后续删除缺失率过高的字段。
4.4 业务探索:三类作弊行为的特征(重点 ⭐)
这是本项目的灵魂:先理解作弊行为在数据里长什么样,才能构造出识别它们的特征。三种作弊方式 → 三个特征探索方向。
作弊一:脚本刷新网页(代码9-6、9-7)
行为特征:cookie 与 ip 不变,且存在多条记录。
// 统计 ip+cookie 相同的记录数
val cookie_ip_distribute = rawData.groupBy("ip", "cookie")
.count().withColumn("ip_cookie_count_precent",
col("count") / rawData.count() * 100).orderBy(desc("count"))
cookie_ip_distribute.show(false)
// 过滤出同 ip、cookie 出现超过 100 次的记录数
val click_gt_100 = cookie_ip_distribute.filter("count > 100").count()
println("同ip、cookie出现超过100次以上的记录数:" + click_gt_100)结果:7 天中同 ip+cookie 浏览超过 100 次的记录有 104 个,叠加起来占据大量流量——若不识别,广告主损失很大。
作弊二:定期清除 cookie 刷新(代码9-8)
行为特征:ip 不变,多条不同 cookie 记录(作弊者清 cookie 制造"新用户")。
val ip_distribute = rawData.groupBy("ip")
.agg(countDistinct("cookie") as "ip_count")
.groupBy("ip_count").agg(count("ip_count") as "ip_count_count",
count("ip_count") / rawData.count() * 100 as "ip_count_count_precent")
.orderBy(desc("ip_count"))
ip_distribute.show(false)💡 两层 groupBy:第一层统计"每个 ip 对应多少个不同 cookie",第二层再统计"这些不同 cookie 数的分布"——看是否存在同一 ip 挂大量 cookie 的情况。
作弊三:ADSL 重新拨号刷新(代码9-9、9-10)
行为特征:某一时间段内,多条记录 ip 来自同一区域 → ip 前两段或前三段相同。
// ip 前两段
val ip_two = rawData.withColumn("ip_two", substring_index(col("ip"), ".", 2))
.groupBy("ip_two").agg(count("ip_two") as "ip_two_count").orderBy(desc("ip_two_count"))
ip_two.show(false)
// ip 前三段
val ip_three = rawData.withColumn("ip_three", substring_index(col("ip"), ".", 3))
.groupBy("ip_three").agg(count("ip_three") as "ip_three_count").orderBy(desc("ip_three_count"))
ip_three.show(false)结果:ip 前两段/前三段相同的记录上万条的情况较多 → 记录数过于庞大可大致判定为虚假流量。
三种作弊行为 → 特征对照表
| 作弊方式 | 数据特征表现 | 将构建的特征 |
|---|---|---|
| 脚本刷新 | 同 ip+cookie 高频出现 | N(ip+cookie 相同次数) |
| 定期清 cookie | 同 ip 多 cookie | N1(同 ip 不同 cookie 数) |
| ADSL 拨号 | ip 前两段相同 | N2(ip 前两段相同次数) |
| ADSL 拨号(更细) | ip 前三段相同 | N3(ip 前三段相同次数) |
💡 特征 = 业务规律的量化:探索阶段发现"作弊会造成什么数据现象",特征构建阶段就把这个现象"计数"成数值特征。先有业务洞察,才有特征工程。
4.5 探索结论汇总(清洗规则依据)
| 结论 | 数据 | 处理 |
|---|---|---|
| 记录数 170 万+ | 1704154 | 直接使用 |
| 日流量稳定 | 7 天差异小 | 无倾斜,不需要特殊处理 |
| 12 字段有缺失 | 高缺失集中在字符型 | 无法插补 → 删除高缺失字段 |
| creativeid 缺失极高 | 98.39% | 删除 |
| 冗余字段 | idfa/imei/android/openudid | 4 个字段均为"识别手机系统类型",可合并降缺失;暂不处理(后续特征构建不确定是否使用) |
| 三类作弊特征明显 | ip/cookie 分布异常 | 构建 N/N1/N2/N3 特征 |
五、任务9.2 预处理数据并构建特征
5.1 删除缺失率过高的字段(代码9-11)
val data_new = rawData.drop("mac").drop("creativeid").drop("mobile_os")
.drop("mobile_type").drop("app_key_md5").drop("app_name_md5").drop("os_type")删除 7 个字段的原因:缺失率高且多为字符型,无法插补,删除以减小对模型的影响。
| 删除字段 | 备注 |
|---|---|
| mac | 缺失率高 |
| creativeid | 缺失率 98.39%,0 视为缺失 |
| mobile_os / mobile_type | 缺失率高 |
| app_key_md5 / app_name_md5 | 缺失率高 |
| os_type | 缺失率高 |
⚠️ 保留 idfa/imei/android/openudid:4 个字段含义相似(手机系统类型),后续特征构建时不确定是否使用,先不处理。
5.2 保存到 Hive(代码9-12)
data_new.write.mode("overwrite").saveAsTable("ad_traffic.AdData")前置准备:
- 依次启动 Hadoop 集群和 Hive 元数据服务
- Hive CLI 创建数据库:
create database ad_traffic; - 将
hive-site.xml复制到项目工程的resources目录
| 知识点 | 说明 |
|---|---|
saveAsTable | 保存为 Hive 表 |
mode("overwrite") | 覆盖保存(表存在则覆盖) |
ad_traffic.AdData | 库名.表名 |
5.3 构建特征 N/N1/N2/N3(代码9-13、9-14)⭐ 本项目最难
特征定义
| 特征 | 构建方法 | 业务含义 |
|---|---|---|
| N | 5 小时内,ip 与 cookie 相同的记录出现次数 | ip+cookie 不变 → 脚本刷新嫌疑 |
| N1 | 5 小时内,同一个 ip 产生的不同 cookie 记录条数 | ip 不变多 cookie → 清 cookie 嫌疑 |
| N2 | 5 小时内,ip 前两段相同的记录出现次数 | ADSL 拨号嫌疑 |
| N3 | 5 小时内,ip 前三段相同的记录出现次数 | ADSL 拨号嫌疑(更细粒度) |
为什么选 5 小时(18000 秒)? 根据广告点击周期的频率进行划分得出。
整体思路框架
① 取 timestamps 最大值、最小值作为界限
② 以 18000 秒为间隔,用 List.range() 切分时间区间
③ for 循环遍历每个区间:
- filter 出区间内数据
- 构建 N/N1/N2/N3 四个特征
- join 回原数据(按 rank 关联)
- append 写入 Hive 表 TimeFeatures时间区间划分(代码9-13)
val max_min_timestamp = data.select(max(col(timestamps).cast(DataTypes.IntegerType)) as "max_ts",
min(col(timestamps).cast(DataTypes.IntegerType)) as "min_ts").rdd.collect()
val max_ts = max_min_timestamp(0).getInt(0)
val min_ts = max_min_timestamp(0).getInt(1)
val times = List.range(min_ts, max_ts, 18000)
println("时间分割点:" + times)| 代码 | 含义 |
|---|---|
.cast(DataTypes.IntegerType) | timestamps 转成整数(防止被当字符串比较) |
.rdd.collect() | 取回 Driver 端,便于取值 |
List.range(min, max, 18000) | 从 min 到 max 每 18000 秒切一个点 |
特征构建(代码9-14)
for (i <- 0 to 4) {
// 取出该时间区间内的数据
val data_sub = data.filter("timestamps>=" + times(i) + " and timestamps<" + times(i + 1))
// 特征 N:ip+cookie 相同次数
val data_N_sub = data_sub.groupBy(cookie, ip).agg(count(ip) as N)
.join(data_sub, Seq(cookie, ip), "inner").select(ranks, N)
// 特征 N1:同 ip 的不同 cookie 数
val data_N1_sub = data_sub.groupBy(ip).agg(countDistinct(cookie) as N1)
.join(data_sub, Seq(ip), "inner").select(ranks, N1)
// 特征 N2:ip 前两段相同次数
val data_ip_two = data_sub.withColumn("ip_two", substring_index(col(ip), ".", 2))
val data_N2_sub = data_ip_two.groupBy("ip_two").agg(count("ip_two") as "N2")
.join(data_ip_two, Seq("ip_two"), "inner").select(ranks, N2)
// 特征 N3:ip 前三段相同次数
val data_ip_three = data_sub.withColumn("ip_three", substring_index(col(ip), ".", 3))
val data_N3_sub = data_ip_three.groupBy("ip_three").agg(count("ip_three") as "N3")
.join(data_ip_three, Seq("ip_three"), "inner").select(ranks, N3)
// 4 个特征按 rank 合并
val data_model_N = data_N_sub.join(data_N1_sub, ranks).join(data_N2_sub, ranks).join(data_N3_sub, ranks)
data_model_N.write.mode("append").saveAsTable("ad_traffic.TimeFeatures")
}| 代码 | 含义 |
|---|---|
groupBy(cookie, ip).agg(count(ip) as N) | 按 ip+cookie 分组计数 → 每个组合出现几次 |
.join(data_sub, Seq(cookie, ip), "inner") | 把计数回填到每条原始记录(关联键是 cookie+ip) |
.select(ranks, N) | 只留 rank(用于最后合并)和特征值 |
countDistinct(cookie) | 同 ip 下不同 cookie 的个数 |
substring_index(col("ip"), ".", 2) | 取 ip 前两段(a.b.c.d → a.b) |
mode("append") | 追加写入,循环 5 次把 5 个区间数据都写进同一张表 |
⚠️ Scala for 循环的经典坑(PPT 明确提醒): Scala 中 for 循环内部构建的变量无法传出循环之外,所以无法在代码中完成各区间特征数据集的拼接 → 解决思路:在循环内直接 append 写入 Hive 表,循环结束时所有区间数据都已在同一张表 TimeFeatures 中。
⚠️ 为什么只取前 25 小时? 数据量非常大,7 天全量特征构建对硬件要求极高、耗费大量时间 → 只取前 25 小时(5 个 5 小时区间,
for (i <- 0 to 4))作为构建特征前的数据集。
5.4 合并标签构建 FeaturesData(代码9-15)
val FeaturesData = spark.read.table("ad_traffic.TimeFeatures")
.join(data, ranks).select(col(ranks), col("dt"), col("N"), col("N1"),
col("N2"), col("N3"), col("label").cast("double"))
FeaturesData.write.mode("overwrite").saveAsTable("ad_traffic.FeaturesData")💡 为什么要再合并一次? TimeFeatures 只有 rank+N/N1/N2/N3 四个特征,没有 label 标签;label 在完整数据集 AdData 中。用 rank 关联,取特征 + dt + label,组成建模需要的 FeaturesData。
⚠️
label.cast("double"):标签转成 Double,满足 MLlib 对标签列的类型要求。
六、任务9.3 特征标准化与数据划分
6.1 为什么要标准化
特征 N、N1、N2、N3 的数值量级差异很大(有的几百、有的几千),如果直接进模型,量级大的特征会对预测结果产生更大且不合理的影响 → 需要标准化。
本项目使用最小值-最大值归一化(MinMaxScaler):把每个特征压缩到 [0,1] 区间。
6.2 代码实现(代码9-16、9-17)
import org.apache.spark.ml.feature.{MinMaxScaler, VectorAssembler}
// 1. 特征向量化:把 4 列特征合并成一个向量列
val VectorData = new VectorAssembler()
.setInputCols(Array("N", "N1", "N2", "N3"))
.setOutputCol("VectorFeatures")
.transform(data)
// 2. 最小值——最大值归一化
val MaxMin = new MinMaxScaler()
.setInputCol("VectorFeatures")
.setOutputCol("features")
.fit(VectorData)
val dataScaler = MaxMin.transform(VectorData)
// 3. 按 7:3 划分数据集
val Array(modelData, loadData) = dataScaler.randomSplit(Array(0.7, 0.3))
modelData.write.mode("overwrite").saveAsTable("ad_traffic.ModelData")
loadData.write.mode("overwrite").saveAsTable("ad_traffic.LoadData")
println("数据集总数据量:" + dataScaler.count())
println("模型构建数据量:" + modelData.count())
println("模型加载数据量:" + loadData.count())| 代码 | 含义 |
|---|---|
VectorAssembler | 特征向量化:4 个数值列 → 1 个向量列 |
MinMaxScaler.fit() | 学习每个特征的最小值/最大值(训练阶段) |
MinMaxScaler.transform() | 用学到的参数做归一化(应用阶段) |
输出列名 features | MLlib 约定俗成的特征列名,算法默认找它 |
randomSplit(Array(0.7, 0.3)) | 随机按 7:3 划分 |
结果:
| 数据集 | 数据量 | 用途 |
|---|---|---|
| 总数据量 | 702135 | — |
| modelData(70%) | 491698 | 模型构建与评估 |
| loadData(30%) | 210437 | 模拟真实模型应用阶段 |
💡 划分的深意:loadData 特意命名为"模型加载数据"而非"测试数据"——它在任务9.5 中模拟"真实场景中新产生的流量",用于检验"训练好的模型部署后是否有效"。
七、任务9.4 构建与评估分类模型
广告流量作弊识别是二分类问题(label:0=正常,1=作弊)。本任务不再用本地模式,而是编译打包后用 **spark-submit 提交到集群(Standalone 模式)**运行。
7.1 逻辑回归模型(代码9-18~9-22)
SparkSession 集群模式设置(代码9-18)⭐ 与任务9.1 的区别
val spark = SparkSession.builder()
.appName("Logistic").master("spark://master:7077") // 集群模式
.enableHiveSupport().getOrCreate()
val inputTable = args(0) // 输入表名,从命令行参数传入
val output = args(1) // 模型保存路径,从命令行参数传入| 变化 | 本地模式(任务9.1) | 集群模式(任务9.4) |
|---|---|---|
| master | local[*] | spark://master:7077 |
| 数据源 | 写死本地 CSV 路径 | args(0) 命令行参数(Hive 表名) |
| 模型路径 | 无 | args(1) 命令行参数(HDFS 路径) |
💡 生产习惯:集群提交的代码不写死路径,用
args接收参数,同一个 jar 可以灵活切换输入输出。
模型构建(代码9-19)
val ModelData = spark.read.table(inputTable)
val Array(train, test) = ModelData.randomSplit(Array(0.7, 0.3))
val model = new LogisticRegression()
.setElasticNetParam(0.03) // 正则化系数
.setMaxIter(15) // 最大迭代次数
.fit(train)参数调优结论:经过调优,
setMaxIter(15)+setElasticNetParam(0.03)+ 其余默认值时模型效果较好。
💡 逻辑回归的输入是 LabeledPoint 格式,但 spark.ml 的 LogisticRegression 会自动搜寻
features特征列和label标签列——无需手动设置特征/标签参数(这就是 Scaler 里输出列名必须叫features的原因)。
模型评估(代码9-20)
val pre = model.transform(test)
pre.select("label", "prediction").show()
val evaluator = new MulticlassClassificationEvaluator()
.setLabelCol("label")
.setPredictionCol("prediction")
.setMetricName("accuracy")
println("Logistic Model Accuracy:" + evaluator.evaluate(pre))| 代码 | 含义 |
|---|---|
model.transform(test) | 用模型对测试集做预测,输出带 prediction 列 |
MulticlassClassificationEvaluator | 多分类评估器(二分类是其特例) |
.setMetricName("accuracy") | 用准确率评估(最直观) |
模型保存与提交(代码9-21、9-22)
model.write.overwrite().save(output)spark-submit --master spark://master:7077 \
--class Logistic /opt/data/model.jar ad_traffic.ModelData /tipdm/data/model/Logistic| spark-submit 参数 | 值 | 含义 |
|---|---|---|
| --master | spark://master:7077 | Standalone 集群模式 |
| --class | Logistic | 主类 |
| 第一个参数 | /opt/data/model.jar | 打包好的 jar 包 |
| 第二个参数 | ad_traffic.ModelData | 传给 args(0):输入表 |
| 第三个参数 | /tipdm/data/model/Logistic | 传给 args(1):模型保存 HDFS 路径 |
逻辑回归结果:准确率 85.8% —— 对大部分作弊访问记录都可以识别。
7.2 随机森林模型(代码9-23~9-25)
val rfModel = new RandomForestClassifier()
.setFeaturesCol("features")
.setLabelCol("label")
.setNumTrees(5) // 5 棵决策树
.fit(train)spark-submit --master spark://master:7077 \
--class RandomForest /opt/data/model.jar ad_traffic.ModelData /tipdm/data/model/RandomForest随机森林结果:准确率 91.1% —— 比逻辑回归提高近 5%。
7.3 两个模型深度对比
| 维度 | 逻辑回归 | 随机森林 |
|---|---|---|
| 类型 | 线性模型 + sigmoid | 集成学习(多棵决策树投票) |
| 实现复杂度 | 简单 | 较复杂 |
| 准确率 | 85.8% | 91.1%(高约 5%) |
| 特征列设置 | 自动识别 features/label | 需显式 setFeaturesCol/setLabelCol |
| 保存类 | LogisticRegression | RandomForestClassifier |
| 核心参数 | ElasticNetParam=0.03、MaxIter=15 | NumTrees=5 |
| 为什么差 5% | 本质是线性回归加 sigmoid,预测时易出现梯度消失;对非线性关系拟合能力有限 | 数据足够时,多棵树集成的分类准确率更高,能捕捉非线性模式 |
💡 随机森林为什么更好? 逻辑回归是线性决策边界,遇到"特征与标签呈非线性关系"时力不从心(且深层网络才容易梯度消失,这里更多是线性模型的表达力限制);随机森林通过多棵决策树投票,天然适合非线性、特征量纲不一致的场景。
八、任务9.5 模型加载与应用
8.1 场景理解
真实生产:模型训练好后部署,新产生的流量数据没有 label,需要加载模型实时判断是否作弊。本项目用 LoadData(30% 数据)模拟新产生的数据(为便于评估,保留了 label)。
8.2 代码实现(代码9-26)
import org.apache.spark.ml.classification.{LogisticRegressionModel, RandomForestClassificationModel}
object LoadModel {
def main(args: Array[String]): Unit = {
val spark = SparkSession.builder()
.appName("LoadModel").master("local[*]")
.enableHiveSupport().getOrCreate()
spark.sparkContext.setLogLevel("WARN")
// 读取模拟的新数据
val LoadData = spark.read.table("ad_traffic.LoadData")
// 加载逻辑回归模型
val LogisticModel = LogisticRegressionModel
.load("hdfs://master:8020/tipdm/data/model/Logistic")
val LogisticPre = LogisticModel.transform(LoadData)
val LogisticAcc = new MulticlassClassificationEvaluator()
.setLabelCol("label").setPredictionCol("prediction")
.setMetricName("accuracy").evaluate(LogisticPre)
println("逻辑回归模型后期数据准确率:" + LogisticAcc)
// 加载随机森林模型
val RandomForest = RandomForestClassificationModel
.load("hdfs://master:8020/tipdm/data/model/RandomForest")
val RandomForestPre = RandomForest.transform(LoadData)
val RandomForestAcc = new MulticlassClassificationEvaluator()
.setLabelCol("label").setPredictionCol("prediction")
.setMetricName("accuracy").evaluate(RandomForestPre)
println("随机森林模型后期数据准确率:" + RandomForestAcc)
}
}| 代码 | 含义 |
|---|---|
LogisticRegressionModel.load(路径) | 加载训练时保存的逻辑回归模型 |
RandomForestClassificationModel.load(路径) | 加载随机森林模型 |
.transform(LoadData) | 对新数据做预测,输出 prediction 列 |
| 评估器 | 由于模拟数据保留了 label,可计算准确率验证模型效果 |
⚠️ 训练用算法类,加载用模型类(经典易错点):
- 训练:
LogisticRegression/RandomForestClassifier- 加载:
LogisticRegressionModel/RandomForestClassificationModel- 加载路径必须与保存路径一致(HDFS 上的 /tipdm/data/model/...)
结论:模型保存后再加载,不会损失模型效果——加载后的准确率与训练时一致,说明模型部署可用。
九、排错指南
| 报错/问题 | 原因 | 解决 |
|---|---|---|
hive-site.xml not found / 无法读 Hive 表 | 缺少 Hive 配置文件 | 把 hive-site.xml 复制到项目 resources 目录 |
Table not found: ad_traffic.xxx | Hive 库没建或没连上 | create database ad_traffic;,确认 SparkSession 开启 enableHiveSupport |
本地模式能跑、集群模式报 Failed to connect to master | master 地址不对 / 集群没启动 | 确认 spark://master:7077、hosts 配置、Master 进程 |
IllegalArgumentException: Column 'features' does not exist | 特征列名不是 features | MinMaxScaler 输出列名必须设为 features |
label 类型不对报错 | label 是 String | col("label").cast("double") |
提交报 ClassNotFound: Logistic | --class 主类名不对 / jar 没打全 | 确认主类全名、重新打包 |
模型加载报 Path does not exist | HDFS 保存/加载路径不一致 | 核对 spark-submit 的 output 参数和 Load 路径 |
Python worker failed / 中文乱码 | 环境问题 | 集群环境用 Scala 代码为主(本项目全部是 Scala) |
| 特征全为 0 或异常 | 时间区间 filter 逻辑错 | 检查 times 区间是否正确、timestamps 是否转成整数 |
| for 循环外取不到变量 | Scala 作用域限制 | 循环内直接写 Hive(append),不要试图循环外拼接 |
| 数据量太大跑不动 | 全量特征构建硬件扛不住 | 只取前 25 小时数据(本项目做法) |
十、面试高频考点
概念类
Q1:什么是虚假流量?常见的作弊方式有哪些? A:虚假流量是通过程序等方式模仿人类浏览行为生成的访问流量。常见作弊方式:脚本刷量(同 ip+cookie 高频访问)、控制肉鸡访问、页面代码修改(隐藏 iframe)、DNS 劫持。本项目重点识别脚本刷新、定期清 cookie、ADSL 拨号三类。
Q2:数据标准化有哪几种?为什么本项目用 MinMaxScaler? A:常用有 MinMaxScaler(最小-最大归一化,缩放到 [0,1])和 StandardScaler(Z-score 标准化,均值为 0 方差为 1)。本项目特征间差值较大,MinMaxScaler 实现简单、不改变分布形态,适合本场景;StandardScaler 对存在离群点的数据更稳健。
Q3:逻辑回归和随机森林各自的特点? A:逻辑回归是线性分类模型,简单、可解释性强、训练快,但决策边界是线性的,对复杂非线性关系拟合有限;随机森林是集成学习(多棵决策树投票),能捕捉非线性关系、对量纲不敏感、准确率通常更高,但可解释性弱、训练更慢。本项目随机森林准确率(91.1%)比逻辑回归(85.8%)高约 5%。
原理类
Q4:为什么要做特征工程?本项目 4 个特征是怎么来的? A:原始字段多为字符型无法直接建模,且量纲差异大。特征工程把业务规律量化为数值特征。本项目根据三种作弊行为的数据特征,构建:N(5 小时内同 ip+cookie 出现次数,识别脚本刷新)、N1(同 ip 不同 cookie 数,识别清 cookie)、N2/N3(ip 前两段/前三段相同次数,识别 ADSL 拨号)。
Q5:randomSplit(Array(0.7, 0.3)) 划分的数据有什么特点? A:随机划分,不保证每类样本比例与总体完全一致(分层抽样需 StratifiedSplit);划分结果不可复现(除非设置随机种子)。本项目把 30% 作为 LoadData 模拟"新产生的数据",用于模型部署验证。
Q6:为什么要按 5 小时窗口构建特征,而不是按全天? A:5 小时根据广告点击周期频率划分。时间窗口太短,同 ip+cookie 的正常访问也会被计为高频(误报);窗口太长则作弊特征被稀释。按窗口切分还能保留流量随时间变化的信息。
实操类
Q7:Spark 程序如何在集群上运行?完整流程? A:① IDEA 中开发、测试(本地模式)→ ② 打包成 jar(本项目 model.jar)→ ③ 上传到 Linux(/opt/data/)→ ④ spark-submit --master spark://master:7077 --class 主类 jar 参数... 提交。注意:集群提交时 master 要写 spark://master:7077,输入输出用 args 接收。
Q8:模型保存和加载怎么写? A:保存:model.write.overwrite().save(path);加载:LogisticRegressionModel.load(path) / RandomForestClassificationModel.load(path)。训练用算法类(LogisticRegression),加载用模型类(LogisticRegressionModel)。
Q9:怎么评估分类模型? A:用 MulticlassClassificationEvaluator 设置 labelCol、predictionCol、metricName(accuracy 最直观,也可用 precision/recall/f1)。先 model.transform(test) 得到 prediction 列,再 evaluate。
十一、项目小结
11.1 本项目学到什么
| 环节 | 技术要点 |
|---|---|
| 业务理解 | 虚假流量 4 种作弊方式 → 数据特征表现 |
| 数据探索 | count/groupBy/agg/na.drop 统计缺失率、识别冗余 |
| 数据预处理 | drop 高缺失字段、保留可合并字段 |
| 特征工程 | 时间窗口切分(List.range + 18000s)+ N/N1/N2/N3 构建 + join 回填 |
| 特征标准化 | VectorAssembler + MinMaxScaler → features 列 |
| 模型构建 | 逻辑回归 vs 随机森林(同评估器对比) |
| 模型部署 | spark-submit 集群提交 + 模型保存/加载 + 新数据预测 |
11.2 项目思考题(来自教案,可用于课堂)
- 引导性:什么是流量?什么是虚假流量?常见的分类算法有哪些?分别适用于哪些场景?
- 探究性:对广告流量数据的基础探索可以从哪些方面进行?不同的作弊方式对应的数据特征是什么?用哪些分类算法可能取得较好效果,为什么?
- 拓展性:尝试优化随机森林参数(如调整 numTrees、maxDepth)使模型取得更好效果;思考 5 小时窗口改成其他值会怎样?
11.3 本项目的"生产环境对照"
⚠️ 教材说明:本项目是简化版案例,实际业务环境会更完善复杂。生产环境通常会:
- 用流处理(Spark Streaming/Flink)实时识别,而不是离线 T+1
- 特征更多、窗口更精细、加入更多业务规则
- 模型上线后有监控与定期重训机制
- 数据量是全量(本项目只用了前 25 小时构建特征)
附录A:Hive 表结构清单
| 表名 | 内容 | 字段 |
|---|---|---|
| ad_traffic.AdData | 删除 7 个高缺失字段后的原始数据 | 15 个字段 |
| ad_traffic.TimeFeatures | 各时间区间构建的 N/N1/N2/N3 特征 | rank、N、N1、N2、N3 |
| ad_traffic.FeaturesData | 特征 + 标签合并 | rank、dt、N、N1、N2、N3、label |
| ad_traffic.ModelData | 70% 建模数据 | 含 features 向量 + label |
| ad_traffic.LoadData | 30% 模拟新数据 | 含 features 向量 + label |
附录B:代码文件清单(配套 code 目录)
| 文件 | 对应任务 | 模式 |
|---|---|---|
| Explore.scala | 任务9.1 数据探索 | local[*] |
| Features.scala | 任务9.2 特征构建 | local[*] |
| Scaler.scala | 任务9.3 标准化+划分 | local[*] |
| Logistic.scala | 任务9.4 逻辑回归 | spark://master:7077 |
| RandomForest.scala | 任务9.4 随机森林 | spark://master:7077 |
| LoadModel.scala | 任务9.5 模型加载应用 | local[*] |
配套数据:
data/case_data_new.csv(7 天流量、1704154 条、22 字段)
本笔记基于教材配套 PPT、教案及代码整理,适用于教学与复习。