一、实验背景
实验一我们读进了数据,实验二我们训练出了模型——但模型到底准不准?能不能上线给业务用?这就是本实验要解决的问题。
一个完整的机器学习项目不是"训完模型就完事了",还必须做三件事:
评估:用各种指标量化模型的好坏,看看预测得准不准;
版本管理:每次训练都保留一份带时间戳的模型,万一新模型不好可以回滚;
结果入库:把预测结果写入数据库,供大屏、报表、告警系统使用。
本实验完成后,整个"读数据 → 训模型 → 评估 → 落库"的闭环就跑通了。
二、实验目标
完成本实验后,学生应当能够:
加载实验二保存的模型,并验证模型可用;
重建与实验二相同的测试集(理解 seed 一致的重要性);
计算 4 个核心评估指标:Accuracy、Precision、Recall、F1,并解释每个指标的业务含义;
看懂混淆矩阵,区分 TP、TN、FP、FN 四种情况;
理解工业场景下"漏报(FN)比误报(FP)更致命"的原因;
实现"版本归档 + latest 别名"的双策略模型保存;
通过 JDBC 把预测结果写入 ClickHouse(或本地 CSV 替代)。
三、实验环境
| 项目 | 配置 |
|---|---|
| 操作系统 | Windows / Linux / macOS |
| JDK | 1.8 |
| Scala | 2.12.x |
| Spark | 3.3.0(含 spark-mllib) |
| 数据库 | ClickHouse 22.x(可选,没有则用 CSV 替代) |
| 开发工具 | IntelliJ IDEA + Maven(沿用实验一二的项目) |
| 输入数据 | /datas/lab1_clean_data(实验一产出) |
| 输入模型 | /datas/lab2_rf_model(实验二产出) |
四、启动环境
# 在master上启动HDFS start-dfs.sh # 在Slave1启动Yarn start-yarn.sh # 在HDFS上检查数据: ⚠️ **前置检查**:开始本实验前,请在HDFS上确认以下两个目录都存在: - `/datas/lab1_clean_data`(实验一产出,包含 `part-xxxxx.snappy.parquet`) - `/datas/lab2_rf_model`(实验二产出,包含 `data`、`metadata`、`treesMetadata` 三个子目录) 如果任何一个目录缺失,请回去重新跑对应的实验。
五、ClickHouse 表设计
启动ClickHouse客户端
# 启动多行模式(在master中操作) clickhouse-client -m
创建ClickHouse 表
-- 1.创建报表数据库(如果不存在) CREATE DATABASE IF NOT EXISTS iot_report; -- 2.创建预测结果表 CREATE TABLE IF NOT EXISTS iot_report.dws_device_pred_detail ( model_version String, -- 模型版本号 device_id String, -- 设备ID event_time DateTime, -- 数据时间 label Nullable(UInt8), -- 真实标签,0=正常,1=异常,允许为 NULL prediction UInt8, -- 预测结果 prob1 Float64, -- 异常概率 created_at DateTime -- 写入时间 ) ENGINE = MergeTree ORDER BY (device_id, event_time);
六、完整代码(一次性复制运行)
6.1 项目准备
第 1 步:在 pom.xml 中检查 ClickHouse 依赖(可选)
如果你的实验环境有 ClickHouse 数据库,在 pom.xml 的 <dependencies> 标签内追加:
<dependency>
<groupId>com.clickhouse</groupId>
<artifactId>clickhouse-jdbc</artifactId>
<version>0.4.6</version>
<classifier>all</classifier>
</dependency>
追加后点击右上角的 Maven 刷新图标。
💡 如果没有 ClickHouse,跳过这一步,本实验代码会自动用 CSV 文件替代写入数据库,效果一样。
第 2 步:创建 Scala Object 文件
在 com.demo.spark 包上右键 → New → Scala Class → 选 Object → 命名 Lab3_EvalAndExport。
注意:仍然在实验一二同一个项目里新建文件。
6.2 完整代码
把下面的代码完整复制到 Lab3_EvalAndExport.scala 文件中,并完成Todo的填空:
package com.demo.spark
import org.apache.spark.sql.{SaveMode, SparkSession}
import org.apache.spark.sql.functions._
import org.apache.spark.ml.feature.VectorAssembler
import org.apache.spark.ml.classification.RandomForestClassificationModel
import org.apache.spark.ml.evaluation.MulticlassClassificationEvaluator
import org.apache.spark.ml.functions.vector_to_array
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
object Lab3_EvalAndExport {
def main(args: Array[String]): Unit = {
// ========== 参数配置区 ==========
val inputPath = "hdfs:///datas/lab1_clean_data" // 实验一清洗后的数据
val modelFromLab2 = "hdfs:///datas/lab2_rf_model" // 实验二训练好的模型
val modelBasePath = "hdfs:///model/device_abnormal" // 与完整实验一致的模型归档根目录
// ClickHouse 连接参数
val ckUrl = "jdbc:clickhouse://master:8123/iot_report?jdbcCompliant=false"
val ckDriver = "com.clickhouse.jdbc.ClickHouseDriver"
val ckPredTable = "dws_device_pred_detail" // 与完整实验一致的表名
// ★ 必须与实验二的 seed 完全一致!seed 相同 → randomSplit 结果相同 → 测试集相同
val seed = 42
// ★ 设置 HDFS 操作权限,以 root 身份读写,避免权限报错
System.setProperty("HADOOP_USER_NAME", "root")
// ========== 启动 SparkSession ==========
val spark = SparkSession.builder()
.appName("Lab3-EvalAndExport")
.master("local[*]")
.getOrCreate()
spark.sparkContext.setLogLevel("ERROR")
// ★ 必须在 SparkSession 之后导入,启用 $"col" 语法 和 Dataset 隐式编码器
import spark.implicits._
println("\n========== 步骤 1:加载实验二的模型 ==========")
// ★ RandomForestClassificationModel.load():从 HDFS 还原训练好的模型
// 不需要重新训练,直接加载即可用于预测
val model = RandomForestClassificationModel.load(modelFromLab2)
println(s"【1.1 模型加载成功】")
println(s" 树的数量:${model.getNumTrees}")
println(s" 最大深度:${model.getMaxDepth}")
println("\n========== 步骤 2:重建实验二的测试集 ==========")
val df = spark.read.parquet(inputPath) // 读取实验一保存的 Parquet 文件
// 完全复刻实验二的特征装配步骤,保证特征列一致
val featureCols = Array("status", "temperature", "load", "voltage")
val assembler = new VectorAssembler()
.setInputCols(featureCols)
.setOutputCol("features")
val data = assembler.transform(df)
.select("device_id", "event_time", "features", "label")
// ★ seed=42 与实验二完全相同,randomSplit 才能切出完全一样的测试集
// ★ _ 表示丢弃训练集,这里只需要测试集
val Array(_, testDF) = data.randomSplit(Array(0.8, 0.2), seed)
println(s"【2.1 测试集行数】${testDF.count()}")
println("\n========== 步骤 3:用加载的模型做预测 ==========")
// ★ model.transform():批量推理,自动新增 prediction 和 probability 列
val pred = model.transform(testDF)
// ★ vector_to_array 把概率向量转为数组,getItem(1) 取下标1 = 异常概率
.withColumn("prob1", vector_to_array(col("probability")).getItem(1))
println("【3.1 预测结果(前 5 条)】")
pred.select("device_id", "label", "prediction", "prob1")
.show(5, truncate = false)
println("\n========== 步骤 4:计算 4 个核心评估指标 ==========")
// ★ 封装成函数,传入不同 metricName 复用,避免重复写 4 遍
def eval(metric: String): Double = {
new MulticlassClassificationEvaluator()
.setLabelCol("label")
.setPredictionCol("prediction")
.setMetricName(metric) // 指定计算哪个指标
.evaluate(pred) // 传入预测结果 DataFrame,返回 Double
}
val accuracy = eval("accuracy") // 准确率:预测对的比例
val precision = eval("weightedPrecision") // 精确率:预测为异常中真正异常的比例
val recall = eval("weightedRecall") // 召回率:真正异常中被找出来的比例
val f1 = eval("f1") // F1:精确率和召回率的综合评分
println(f"【4.1 Accuracy (准确率) 】 = $accuracy%.4f")
println(f"【4.2 Precision(精确率) 】 = $precision%.4f")
println(f"【4.3 Recall (召回率) 】 = $recall%.4f")
println(f"【4.4 F1 】 = $f1%.4f")
println("\n========== 步骤 5:混淆矩阵分析 ==========")
println("【5.1 混淆矩阵原始数据(label=真实,prediction=预测)】")
// ★ 混淆矩阵:label=真实值,prediction=预测值,count=数量
// 交叉看预测错误类型:FP=正常被误报为异常;FN=异常被漏掉(漏报)
pred.groupBy("label", "prediction")
.count()
.orderBy("label", "prediction")
.show()
println("\n========== 步骤 6:模型版本管理(双路径保存) ==========")
// ★ 用时间戳生成版本号,格式如:rf_20260430_115500,每次运行唯一
val ver = LocalDateTime.now()
.format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss"))
val modelVersion = s"rf_$ver"
// 路径1:带时间戳的归档版本(每次保留,方便历史回滚)
val versionPath = s"$modelBasePath/$modelVersion"
model.write.overwrite().save(versionPath)
println(s"✅ 6.1 版本归档保存:$versionPath")
// 路径2:latest 固定别名(永远指向最新模型,下游直接读这个路径)
val latestPath = s"$modelBasePath/latest"
model.write.overwrite().save(latestPath)
println(s"✅ 6.2 latest 已更新:$latestPath")
// 验证 latest 路径的模型可正常加载
val verifyModel = RandomForestClassificationModel.load(latestPath)
println(s"✅ 6.3 latest 加载验证成功,包含 ${verifyModel.getNumTrees} 棵树")
println("\n========== 步骤 7:预测结果入库 ==========")
val createdAt = LocalDateTime.now()
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
// ★ lit():生成常量列(所有行的值相同),用于附加版本号和入库时间
val predOut = pred.select(
lit(modelVersion).as("model_version"), // 常量列:本次模型版本号
col("device_id"),
col("event_time"),
col("label").cast("int").as("label"),
col("prediction").cast("int").as("prediction"),
col("prob1").cast("double").as("prob1"),
lit(createdAt).as("created_at") // 常量列:入库时间
)
println("【7.1 入库数据预览(前 5 条)】")
predOut.show(5, truncate = false)
// ★ format("jdbc"):通过 JDBC 协议写入数据库
// ★ SaveMode.Append:追加写入,不清空原有数据
predOut.write
.format("jdbc")
.option("url", ckUrl)
.option("driver", ckDriver)
.option("dbtable", ckPredTable)
.mode(SaveMode.Append)
.save()
println(s"✅ 7.2 已写入 ClickHouse 表:$ckPredTable")
println(s" model_version = $modelVersion")
println(s" created_at = $createdAt")
// ========== 关闭 SparkSession ==========
spark.stop() // 释放所有 Executor 和 Driver 资源
}
}6.3 运行程序
点击 main 方法旁边的绿色三角图标 → Run,等待程序执行完成(约 30 秒—1 分钟)。
如果一切正常,控制台会依次输出 7 个步骤的结果。下面我们逐步骤讲解每个输出代表什么。
七、分步骤讲解
步骤 1:加载实验二的模型
这段代码做什么:用 RandomForestClassificationModel.load() 把实验二保存的模型从磁盘读回来。
为什么不重新训练:实验二训练一次要 30 秒到几分钟。每次需要用模型时都重训既慢又浪费——保存 + 加载才是生产环境的正确做法。
关键代码解读:
RandomForestClassificationModel.load(path):注意类名要写全,因为通用的Model.load不存在;加载完成后,
model对象和实验二里训练完那一刻的model是完全一样的。
预期控制台输出:
========== 步骤 1:加载实验二的模型 ==========
【1.1 模型加载成功】
树的数量:80
最大深度:8
输出含义:
树的数量 80 应该和实验二配置的
numTrees一致;最大深度 8 应该和实验二配置的
maxDepth一致;这两个数字一致,说明模型加载没出问题。
思考题 1:为什么"加载模型再用"和"训练模型直接用"得到的预测结果应该完全一致?这种一致性对生产环境有什么意义?
答案:因为随机森林模型的本质是 80 棵决策树的结构和每个节点的判断阈值。
model.write.save()把这些参数完整序列化到磁盘,load()再完整还原回来,数学上和内存里的对象完全等价,所以预测结果一样。生产意义在于:训练和推理可以完全分离,训练一次,部署到任何机器随时调用,不需要重跑耗时的训练过程。
步骤 2:重建实验二的测试集
这段代码做什么:复刻实验二的"加载数据 → 装配向量 → 切分"过程,得到与实验二完全相同的测试集。
为什么必须复刻:评估必须在实验二训练没见过的数据上做,否则就是作弊。但程序结束后内存里的 testDF 也没了,所以要用相同的 seed 重新切一次——只要 seed 一样,得到的就是同一份数据。
关键代码解读:
seed = 42必须和实验二完全一致,否则切出来的测试集不一样,评估结果也不可信;val Array(_, testDF) = ...中的_表示"训练集我不要了,丢掉",只接收测试集。
预期控制台输出:
========== 步骤 2:重建实验二的测试集 ==========
【2.1 测试集行数】418751
输出含义:
测试集行数
418751应该和实验二步骤 3 输出的"测试集行数"完全一致;如果不一致,说明 seed 没对上或数据源不一样,后面的评估就没意义了。
思考题 2:如果把 seed 改成 100 重跑,测试集的"行数"会和实验二一样吗?测试集里的"具体哪些行"会和实验二一样吗?
答案:行数大致一样(都是约20%,约42万行),因为比例不变。但具体哪些行完全不一样,seed 不同意味着随机抽样的起点不同,切出来的是另一批数据。这就是为什么实验三必须和实验二用同一个 seed=42,否则测试集里混入了训练数据,评估结果就失去了可信度。
步骤 3:用加载的模型做预测
这段代码做什么:用刚加载的模型对测试集做预测——和实验二步骤 5 的代码完全一样。
为什么要再做一次预测:实验二的预测结果只在内存里,程序结束就没了。要做评估、要入库,都需要重新生成一份预测结果。
预期控制台输出:
========== 步骤 3:用加载的模型做预测 ==========
【3.1 预测结果(前 5 条)】
+----------+-----+----------+--------------------+
|device_id |label|prediction|prob1 |
+----------+-----+----------+--------------------+
|device-001|1 |1.0 |0.9297361576750062 |
|device-001|0 |0.0 |0.005195099952609918|
|device-001|0 |0.0 |0.004994138018897672|
|device-001|0 |0.0 |0.005689677771895455|
|device-001|0 |1.0 |0.9079884349771054 |
+----------+-----+----------+--------------------+
only showing top 5 rows
输出含义:
这 5 行应该和实验二步骤 5 的前 5 行完全一致——这是验证"加载模型 = 重新训练后的模型"的关键证据;
如果哪一行的
prediction或prob1不一样,说明加载过程有问题。
思考题 3:如果实验二之后又重新跑过一次(参数改了),模型会被覆盖。这时本实验加载的还是不是"那个我们用 80 棵树训练的模型"?怎么避免这种问题?(提示:步骤 6 会给出答案)
答案:不是。
hdfs:///datas/lab2_rf_model这个路径会被新模型覆盖,加载到的就是新参数的模型。步骤6的版本归档就是解决方案:每次训练同时保存一份带时间戳的归档路径(如rf_20260430_125623),即使lab2_rf_model被覆盖,归档版本永远保留,随时可以回滚到指定版本。
步骤 4:计算 4 个核心评估指标
这段代码做什么:用 MulticlassClassificationEvaluator 计算 4 个标准评估指标。
4 个指标的业务含义(重要!):
假设有 100 台设备,实际 30 台异常、70 台正常:
| 指标 | 公式 | 通俗理解 | 适用场景 |
|---|---|---|---|
| Accuracy | (TP+TN)/总数 | 整体猜对比例 | 样本平衡时直观 |
| Precision | TP/(TP+FP) | 我说异常的,真异常的占比 | "不冤枉好设备" |
| Recall | TP/(TP+FN) | 真异常的,被我抓出来的占比 | "不放过坏设备" |
| F1 | 2·P·R/(P+R) | 精确率和召回率的平衡 | 样本不平衡时常用 |
预期控制台输出:
========== 步骤 4:计算 4 个核心评估指标 ==========
【4.1 Accuracy (准确率) 】 = 0.9944
【4.2 Precision(精确率) 】 = 0.9944
【4.3 Recall (召回率) 】 = 0.9944
【4.4 F1 】 = 0.9944
输出含义:
4 个指标都在 0.98 以上,说明模型整体表现很不错;
Accuracy 0.9944 表示 99.44% 的设备模型都判断对了;
Precision 和 Recall 都很高,说明模型既不冤枉好设备,也不放过坏设备;
F1 是 Precision 和 Recall 的综合,0.9944 说明两者比较平衡。
思考题 4:如果工厂最怕"漏报故障导致设备炸了",应该优先看哪个指标?为什么?
答案:应该优先看 Recall(召回率)。Recall = TP/(TP+FN),分母里的 FN 就是"漏报数量"。漏报意味着异常设备没被发现,可能酿成事故。Recall 越高,说明真正异常的设备被找出来的比例越高,漏掉的越少。Accuracy 在这里不可靠,因为正常设备数量远多于异常设备,即使模型偏向预测"正常",Accuracy 也会很高,但 Recall 会暴露出漏报严重的问题。
步骤 5:混淆矩阵分析
这段代码做什么:用 groupBy("label", "prediction") 统计 4 种"真实 vs 预测"的组合数量。
混淆矩阵的 4 种情况:
| 预测=0(正常) | 预测=1(异常) | |
|---|---|---|
| 实际=0(正常) | TN:真负(对) | FP:假正/误报(错) |
| 实际=1(异常) | FN:假负/漏报(错) | TP:真正(对) |
为什么要看混淆矩阵:4 个评估指标只是数字汇总,混淆矩阵能让你看到模型具体在哪里错了——是误报多还是漏报多?
预期控制台输出:
========== 步骤 5:混淆矩阵分析 ==========
【5.1 混淆矩阵原始数据(label=真实,prediction=预测)】
+-----+----------+------+
|label|prediction| count|
+-----+----------+------+
| 0| 0.0|281763|
| 0| 1.0| 1164|
| 1| 0.0| 1164|
| 1| 1.0|134660|
+-----+----------+------+
输出含义(按 4 行的顺序解读):
第 1 行 TN = 281763:281763台正常设备被正确识别为正常 ✓
第 2 行 FP = 1164:1164 台正常设备被错判为异常(误报,浪费维修人力)✗
第 3 行 FN = 1164:1164 台异常设备被错判为正常(漏报,可能酿成事故)✗
第 4 行 TP = 134660:134660台异常设备被成功识别 ✓
思考题 5:根据自己跑出来的数据,计算漏报率和误报率。哪个数值更大?哪个数值更让你担忧?为什么?
答案:漏报率(0.86%)高于误报率(0.41%)。工业场景下漏报更让人担忧,因为漏报意味着有真实故障的设备没有被检测出来,可能导致设备损坏甚至安全事故;而误报只是让运维人员多跑一趟,浪费人力但不会造成安全风险。
步骤 6:模型版本管理(双路径保存)
这段代码做什么:把当前模型按"版本归档 + latest 别名"两个策略各保存一份。
实验三的步骤6本质是版本归档,不是重新训练。模型的80棵树、参数、结构和实验二训练出来的完全一样,只是被复制到了
hdfs:///model/device_abnormal/下统一管理。
为什么要做版本管理:
模型会不断更新(新数据→重训→新模型),需要管理多个版本;
出问题了能回滚到上一个好用的版本;
知道哪个版本是哪天的数据训练的;
线上代码总是指向 latest 这个固定路径,后台静悄悄换模型,不用改代码就能上线。
双路径策略:
| 路径 | 作用 | 用法 |
|---|---|---|
hdfs:///model/device_abnormal/rf_20260430_xxxxxx | 版本归档 | 历史可追溯,每次训练都新建 |
hdfs:///model/device_abnormal/latest | 固定别名 | 线上代码永远 load 这个 |
关键代码解读:
LocalDateTime.now().format(...)生成形如20260128_153045的时间戳;s"rf_$ver"是 Scala 的字符串模板,把变量嵌进字符串里;同一个
model对象保存两次,分别到两个不同路径。
预期控制台输出:
========== 步骤 6:模型版本管理(双路径保存) ==========
✅ 6.1 版本归档保存:hdfs:///model/device_abnormal/rf_20260430_125623
✅ 6.2 latest 已更新:hdfs:///model/device_abnormal/latest
✅ 6.3 latest 加载验证成功,包含 80 棵树
输出含义:
rf_20260430_125623中的时间戳是当前运行时间,每个学生的不一样;两个路径都保存成功,且 latest 能被重新加载——说明双路径策略生效;
6.3 的"加载验证"是上线前的"自检"步骤——保存了不一定能用,必须验证一下。
验证保存成功:在终端执行:
hdfs dfs -ls hdfs:///model/device_abnormal/
应该能看到:
rf_20260430_125623 (时间戳目录,每次运行不同)
latest/ (固定别名)
思考题 6:假设线上代码总是写死 load("/models/rf_20260128_153045"),每次重训模型都要改代码并重新发布。用 latest 别名为什么能解决这个问题?
答案:写死路径(如
load(".../rf_20260128_153045"))的问题是:每次重新训练模型,时间戳就变了,线上代码必须跟着改路径再重新发布,既繁琐又容易出错。用latest固定别名后,线上代码永远只写load(".../latest"),后台训练完新模型直接覆盖latest路径,代码一行不改就完成了模型升级,这是生产环境"热更新模型"的标准做法。
步骤 7:预测结果入库
这段代码做什么:把预测结果(带模型版本号和入库时间)写入 ClickHouse 数据库或本地 CSV。
为什么要入库:
预测结果只在内存里,程序结束就没了;
业务大屏、报表系统、告警系统都要读这些结果;
数据库能持久保存、能高速查询、能被多个系统共享。
关键代码解读:
lit(modelVersion):lit = literal(字面量),把固定值变成一列,每行都填一样的;useClickHouse开关:有数据库走 JDBC,没数据库走 CSV——保证不同环境都能跑通;SaveMode.Append:追加模式,不删原数据,只往后接(CSV 用 Overwrite,因为 CSV 不支持追加);
预期控制台输出:
========== 步骤 7:预测结果入库 ==========
【7.1 入库数据预览(前 5 条)】
+------------------+----------+-------------------+-----+----------+--------------------+-------------------+
|model_version |device_id |event_time |label|prediction|prob1 |created_at |
+------------------+----------+-------------------+-----+----------+--------------------+-------------------+
|rf_20260430_125623|device-001|2026-01-16 10:32:00|1 |1 |0.9297361576750062 |2026-04-30 12:56:28|
|rf_20260430_125623|device-001|2026-01-16 10:36:00|0 |0 |0.005195099952609918|2026-04-30 12:56:28|
|rf_20260430_125623|device-001|2026-01-16 10:38:00|0 |0 |0.004994138018897672|2026-04-30 12:56:28|
|rf_20260430_125623|device-001|2026-01-16 10:43:00|0 |0 |0.005689677771895455|2026-04-30 12:56:28|
|rf_20260430_125623|device-001|2026-01-16 10:49:00|0 |1 |0.9079884349771054 |2026-04-30 12:56:28|
+------------------+----------+-------------------+-----+----------+--------------------+-------------------+
only showing top 5 rows
✅ 7.2 已写入 ClickHouse 表:dws_device_pred_detail
model_version = rf_20260430_125623
created_at = 2026-04-30 12:56:28
输出含义:
比实验二多了 2 列:
model_version(哪个版本预测的)和created_at(什么时候入库的);这两列对追溯问题特别重要——以后发现某条预测错了,可以反查是哪个版本的模型出的问题;
每行的
model_version都一样(因为是同一次预测),但跨多次运行就不一样了。
思考题 7:为什么入库表里要存 model_version 这一列?如果不存,未来发生"线上误报激增"时排查会有什么困难?
答案:如果不存
model_version,数据库里只有预测结果,无法知道是哪次模型跑出来的。假设某天线上误报激增,排查时需要回答:是今天的新模型有问题,还是数据本身出了问题?有了model_version列,可以直接用WHERE model_version = 'rf_xxx'对比不同版本的预测结果,快速定位是哪次模型更新引入了问题,然后回滚到上一个归档版本即可恢复正常。
七、本实验产出(项目收尾)
本实验运行成功后,会在以下位置生成产出:
hdfs:///model/device_abnormal/
├── rf_20260430_xxxxxx/ ← 版本归档
└── latest/ ← 最新模型别名
在 ClickHouse 数据库的 dws_device_pred_detail 表中(ClickHouse 模式)。
至此,整个"读数据 → 训模型 → 评估 → 落库"的闭环已经走完。建议保留所有产出至少一周,方便复盘和实验报告整理。
路径与表名总览
三个分步实验的产出统一在以下路径,方便管理:
| 产出物 | 路径 / 表名 |
|---|---|
| 实验一清洗数据 | hdfs:///datas/lab1_clean_data |
| 实验二训练模型 | hdfs:///datas/lab2_rf_model |
| 实验三模型版本 | hdfs:///model/device_abnormal/rf_xxx |
| 实验三 latest | hdfs:///model/device_abnormal/latest |
| 实验三 CK 表 | dws_device_pred_detail |
八、项目总结:三个实验的串联回顾
通过三个实验,你已经完成了一个完整的工业级机器学习项目流程:
原始 CSV (150 万条)
│ 实验一:读取 + 清洗 + 过滤 + 保存
▼
干净的 DataFrame (Parquet, 142 万条)
│ 实验二:特征装配 + 训练随机森林 + 保存模型
▼
训练好的随机森林模型 (80 棵树)
│ 实验三:加载模型 + 评估 + 版本管理 + 入库
▼
ClickHouse 表 / CSV (供大屏查询)
核心概念回顾
| 概念 | 一句话理解 |
|---|---|
| SparkSession | 进入 Spark 世界的大门 |
| DataFrame | 分布式的 Excel 表格 |
| Parquet | 自带类型的列式存储格式,比 CSV 快又省空间 |
| VectorAssembler | 把多列特征打包成向量的"打包机" |
| 训练集 vs 测试集 | 一个用来学规律,一个藏起来考试 |
| seed | 固定随机性,让结果可复现 |
| fit | 训练:让"空白工具"通过数据学规律 |
| transform | 用学好的模型对数据做处理(如预测) |
| 随机森林 | 多棵决策树投票决定的集成模型 |
| Accuracy/Precision/Recall/F1 | 整体对/不冤枉/不放过/综合平衡 |
| 混淆矩阵 | 4 种"真实 vs 预测"组合的统计 |
| TP/TN/FP/FN | 真正/真负/假正(误报)/假负(漏报) |
| 版本归档 + latest |