李翔-大数据技术

Big data technology!

7.3 实验三_模型评估与结果入库【2026-5-5】

实验三:模型评估、版本管理与结果入库

一、实验背景

实验一我们读进了数据,实验二我们训练出了模型——但模型到底准不准?能不能上线给业务用?这就是本实验要解决的问题。

一个完整的机器学习项目不是"训完模型就完事了",还必须做三件事:

  1. 评估:用各种指标量化模型的好坏,看看预测得准不准;

  2. 版本管理:每次训练都保留一份带时间戳的模型,万一新模型不好可以回滚;

  3. 结果入库:把预测结果写入数据库,供大屏、报表、告警系统使用。

本实验完成后,整个"读数据 → 训模型 → 评估 → 落库"的闭环就跑通了。

二、实验目标

完成本实验后,学生应当能够:

  1. 加载实验二保存的模型,并验证模型可用;

  2. 重建与实验二相同的测试集(理解 seed 一致的重要性);

  3. 计算 4 个核心评估指标:Accuracy、Precision、Recall、F1,并解释每个指标的业务含义;

  4. 看懂混淆矩阵,区分 TP、TN、FP、FN 四种情况;

  5. 理解工业场景下"漏报(FN)比误报(FP)更致命"的原因;

  6. 实现"版本归档 + latest 别名"的双策略模型保存;

  7. 通过 JDBC 把预测结果写入 ClickHouse(或本地 CSV 替代)。

三、实验环境


项目配置
操作系统Windows / Linux / macOS
JDK1.8
Scala2.12.x
Spark3.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 行完全一致——这是验证"加载模型 = 重新训练后的模型"的关键证据;

  • 如果哪一行的 predictionprob1 不一样,说明加载过程有问题。


思考题 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)/总数整体猜对比例样本平衡时直观
PrecisionTP/(TP+FP)我说异常的,真异常的占比"不冤枉好设备"
RecallTP/(TP+FN)真异常的,被我抓出来的占比"不放过坏设备"
F12·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
实验三 latesthdfs:///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历史可追溯,线上不改代码



发表评论:

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。

Powered By Z-BlogPHP 1.7.3

版权:李翔
备案/许可证编号为:新ICP备2024006115号-1