简介:这份R语言机器学习练习代码包面向刚接触机器学习的初学者、数据分析师及需要在R中落地建模的研究人员,围绕数据预处理、模型训练、预测评估与结果可视化等核心环节提供可运行示例。压缩包共25个文件,以12个Rmd源码和12个HTML结果页为主,另有1个说明文档,整体约10.12MB;Rmd便于复现与修改,HTML可直接查看渲染后的分析过程。内容覆盖数据清洗、特征选择与标准化,线性回归、决策树、随机森林、支持向量机、集成学习、贝叶斯方法、逻辑回归及时间序列等主题,并展示交叉验证与性能指标评估思路,配套图表帮助理解数据和模型表现。已有132人学习,适合按主题查阅源码、对照输出结果并逐步完成从预处理到建模评估的练习。
1. 拿到一份 R 脚本先别急着 source:这套机器学习小程序的真实边界
见过太多人拿到一份 R 语言写的机器学习小程序代码,第一件事就是在 RStudio 里一路 source,控制台没报红就以为成了,结果换一份新数据去 predict,直接甩出一句factor has new levels或者object 'model' not found。这不是代码写错,是没搞清这套脚本的数据契约和对象生命周期。这份东西的真实形态,是一组按「读数据—清洗—训练—评估—预测」拆开的独立脚本,加上一个用 Shiny 包起来的轻量交互界面,分类和回归两条主线都覆盖,训练侧统一走 caret 的接口,模型侧落在随机森林和梯度提升树上。它适合两类人:想用 R 快速验证一个表格数据建模想法的,和手里已经有一堆跑不通的散装脚本、想照着一个有结构的骨架重排的。下面按目录、核心函数、界面、踩坑、改造的顺序往下拆。
2. 目录结构与技术栈:一份能直接跑的 R 脚本包长什么样
散装脚本最要命的地方不是算法写得差,而是所有东西堆在一个main.R里,读数据、画图、训练、输出混在一起,改一行就不知道哪块会塌。这套小程序代码的第一层价值,就是把职责切开了。切法不复杂,但每个目录都有明确的「只干一件事」的约束,这个约束比目录名本身重要。
2.1 五个目录各自干什么
拿到压缩包解压后,能看到的顶层结构大致是这样,不同版本可能多一两个文件,但主干不会变:
ml-r-mini/ ├── R/ # 可复用函数,只定义不执行 │ ├── preprocess.R # 缺失值、异常值、因子水平处理 │ ├── features.R # 特征构造与筛选 │ ├── train.R # 统一训练入口 │ ├── evaluate.R # 混淆矩阵、ROC、回归指标 │ └── predict.R # 单条/批量预测封装 ├── scripts/ # 可执行脚本,按序号串流程 │ ├── 01_load.R │ ├── 02_clean.R │ ├── 03_train.R │ └── 04_report.R ├── data/ │ ├── raw/ # 原始数据,只读不写 │ └── processed/ # 中间产物,可随时删掉重跑 ├── models/ # 训练好的 .rds 文件 ├── app/ # Shiny 界面 │ ├── ui.R │ └── server.R └── config.yml # 路径、目标列名、随机种子R/目录里只有函数定义,source 进去不产生任何副作用,这是它和scripts/最本质的区别。scripts/目录按数字前缀排序,意味着执行顺序是显式的,不依赖你去读代码猜哪段先跑。data/raw只读这个约定看着像洁癖,实际是后悔药——任何一次清洗逻辑改错,把 raw 覆盖了,你就只剩重下一遍数据这一条路。models/单独放而不是塞进output/,是为了让 Shiny 端能用一个固定的相对路径去读,部署时不至于找不到文件。
我一般会在这个结构上再加一个logs/目录,训练脚本里用sink()把 caret 的输出重定向进去。原因很实际:交叉验证跑二十分钟,控制台刷过去几百行,中途报个 warning 你根本翻不回去。日志落盘之后,调参翻车至少还有据可查。
2.2 依赖清单与版本锁定
R 项目最容易翻车的地方之一,是半年后重跑同一份代码,结果和当初对不上。原因八成不是算法,是某个包升级后默认参数变了。这套代码的依赖清单大致是这么几类:
| 包 | 用途 | 备注 |
|---|---|---|
| caret | 统一训练与交叉验证接口 | 核心,几乎所有训练都过它 |
| recipes | 预处理步骤的可复用管道 | 和 caret 配合,避免训练/预测处理不一致 |
| randomForest | 随机森林后端 | caret 的method = "rf"默认调它 |
| xgboost | 梯度提升树后端 | 需要method = "xgbTree" |
| dplyr / tidyr | 数据整理 | 清洗脚本重度依赖 |
| pROC | ROC 曲线与 AUC | 分类评估用 |
| shiny / DT | 交互界面与表格展示 | 只在 app 目录用 |
| yaml | 读 config.yml | 配置外置 |
版本这块,正规做法是上renv,在项目根目录执行一次初始化,它会生成renv.lock把每个包的精确版本记下来,换机器时renv::restore()一条命令还原:
# 首次在项目根目录执行,生成 renv/ 与 renv.lock renv::init() # 装完包之后拍一张快照,把当前版本写进 lock 文件 renv::snapshot() # 换机器或半年后重跑,先还原再执行脚本 renv::restore()renv::init()做的是扫描项目里所有library()调用,把识别到的包记进依赖图,同时创建项目级私有库,之后装的包只进这个库,不污染全局环境。snapshot()才是真正把版本号写死的那一步,很多人只 init 不 snapshot,等于没锁。restore()按 lock 文件逐包还原,遇到 Bioconductor 或 GitHub 来源的包也能处理,前提是 lock 文件里记了来源字段。
如果这份代码里暂时没有renv.lock,也不代表不能用。退一步的做法是在脚本开头加一段依赖检查,缺包就停,别让它跑到一半才报there is no package called 'xgboost':
# scripts/00_bootstrap.R —— 放在所有脚本最前面 source required <- c("caret", "recipes", "randomForest", "xgboost", "dplyr", "pROC", "yaml") missing <- required[!vapply(required, requireNamespace, logical(1), quietly = TRUE)] if (length(missing) > 0) { stop("缺少依赖包:", paste(missing, collapse = ", "), ";请先执行 install.packages() 或 renv::restore()", call. = FALSE) }requireNamespace(..., quietly = TRUE)和library()的区别在于它只检查包是否可加载,不往搜索路径上挂,适合做前置校验。vapply保证返回的是逻辑向量而不是列表,后面用!取反索引才不会出错。这段代码看着啰嗦,但它把「环境问题」和「代码问题」在时间上分开了——报错发生在第 0 秒,而不是第 800 秒。
2.3 配置外置与数据契约
config.yml是这套代码里最不起眼但最该先看的文件。它通常长这样:
paths: raw: "data/raw/train.csv" processed: "data/processed/clean.rds" model: "models/rf_model.rds" data: target: "label" # 目标列名 id_cols: ["id", "timestamp"] # 训练时必须剔除的列 positive_class: "yes" # 二分类里当作正例的水平 train: method: "rf" folds: 5 seed: 2024 tune_length: 5把目标列名、ID 列、正例水平写进配置,看起来是多此一举,实际解决的是「换个数据集就要改八处代码」的问题。ID 列这一项尤其关键:很多人训练时忘了剔 ID,模型 AUC 高得离谱,一上线就崩,因为树模型把 ID 的数值大小学成了信号。把id_cols显式列出来,在预处理阶段统一 drop,这个坑就堵住了。
positive_class的作用是给评估阶段定方向。二分类里yes/no和1/0的 AUC 是一样的,但 precision、recall 完全相反,混淆矩阵的四格也会翻个个儿。显式声明正例,比在评估函数里硬编码字符串要稳。
3. 核心代码拆解:从读数据到训练一个能用的模型
这一章是整套代码的重心。前面说了目录和依赖,那都是脚手架,真正决定这份东西能不能用的,是预处理管道、统一训练入口和评估封装这三个函数写得好不好。下面逐段拆,代码是我按这份项目的实际结构整理过的版本,参数名和主流程保持一致,个别辅助函数做了精简。
3.1 预处理管道:让训练和预测走同一条路
R 机器学习项目里最高频的线上事故,是训练时做了标准化、缺失值填补、独热编码,预测时忘了做,或者顺序错了。recipes包就是干这个的——把预处理步骤声明成一个对象,训练时prep()一次,之后训练集和任何新数据都走同一个bake()。
# R/preprocess.R library(recipes) build_recipe <- function(df, target, id_cols = character(), normalize = TRUE, impute = TRUE) { rec <- recipe(as.formula(paste(target, "~ .")), data = df) # 1. 剔除 ID 类列,防止树模型把编号学成特征 if (length(id_cols) > 0) { rec <- rec %>% step_rm(all_of(id_cols)) } # 2. 缺失值处理:数值列中位数,因子列众数 if (impute) { rec <- rec %>% step_impute_median(all_numeric_predictors()) %>% step_impute_mode(all_nominal_predictors()) } # 3. 名义变量独热编码,避免树模型误把类别当序数 rec <- rec %>% step_dummy(all_nominal_predictors()) # 4. 近零方差列清理:常数列会让部分模型直接报错 rec <- rec %>% step_nzv(all_predictors()) # 5. 标准化放在最后,只在需要时开启 if (normalize) { rec <- rec %>% step_normalize(all_numeric_predictors()) } rec } # 用法:训练集 prep 一次,得到可复用的配方 prep_recipe <- function(rec, train_df) { prep(rec, training = train_df, retain = TRUE) }recipe()的第一个参数是公式,target ~ .表示目标列之外全进。step_rm(all_of(id_cols))里用all_of()而不是直接写列名,是因为id_cols是变量,tidyselect需要显式告诉它「这是个外部向量」,否则会当成列名匹配。step_impute_median和step_impute_mode分别处理数值与因子,注意顺序——先填补再编码,反过来独热之后的列名带前缀,模式填补会找不到原列。step_nzv清理近零方差列这一步容易被忽略,但某些数据集里存在 99% 都是同一个值的列,不清理会让xgboost的某些配置直接抛错。标准化放在最后、且只在normalize = TRUE时开启,是因为树模型对量纲不敏感,做了反而白费计算。
prep()的retain = TRUE参数是必须的。默认retain = FALSE时,juice()取不出训练集处理后的数据,你后面想看特征重要性或者算残差就抓瞎了。代价是配方对象里多存了一份数据,内存换便利,值得。
3.2 统一训练入口:一个函数吃掉分类和回归
如果每个模型都写一遍trainControl+train,代码量会翻三倍,而且参数不一致。这套代码的做法是收成一个入口,用task参数区分分类与回归:
# R/train.R library(caret) train_model <- function(data, target, method = "rf", task = c("classification", "regression"), folds = 5, tune_length = 5, seed = 2024) { task <- match.arg(task) set.seed(seed) # 种子必须放在 train 之前,且每次调用都设 # 分类任务需要额外的控制参数 if (task == "classification") { ctrl <- trainControl( method = "cv", number = folds, classProbs = TRUE, # 要概率输出,AUC 才算得出来 summaryFunction = twoClassSummary, savePredictions = "final", allowParallel = TRUE ) metric <- "ROC" } else { ctrl <- trainControl( method = "cv", number = folds, savePredictions = "final", allowParallel = TRUE ) metric <- "RMSE" } formula <- as.formula(paste(target, "~ .")) model <- train( form = formula, data = data, method = method, trControl = ctrl, metric = metric, tuneLength = tune_length, preProcess = NULL # 预处理已由 recipes 完成,这里不重复 ) model }set.seed()的位置是这个函数里最容易被改错的一行。放在trainControl之后、train之前才算数,因为 caret 的随机性发生在折划分和模型内部抽样两个环节。放在函数第一行也可以,只要保证每次调用都执行且allowParallel的随机数流一致——这块细节后面避坑章节会展开说。
classProbs = TRUE和summaryFunction = twoClassSummary是绑定的,缺一个 ROC 就出不来。savePredictions = "final"只保留最优参数下的折外预测,如果你要画学习曲线或者做 stacking,得改成"all",内存占用会明显上去。metric在分类任务里用"ROC"而不是默认的"Accuracy",原因是类别不平衡时 Accuracy 是个玄学指标——正例只占 3% 的数据,全预测成负例也有 97% 的准确率,模型废了你还以为它很好。tuneLength = 5控制每个模型自动搜索的超参数组合数,随机森林会搜mtry的 5 个候选值,具体候选由 caret 内部按经验范围生成,不需要手动指定。
preProcess = NULL这行值得单独说。caret 的train()本身支持preProcess = c("center", "scale"),但那会和 recipes 的管道重复处理,导致标准化做了两遍——树模型无所谓,线性模型和神经网络就会出问题。两套机制选一套,既然前面已经用 recipes 建立了解释性更强的管道,这里就明确关掉。
3.3 交叉验证结果怎么读
模型训完,model$results是一张表,每行一组超参数,列是各个指标。很多人直接看bestTune就完事,实际上这张表里藏着不少信息:
# scripts/03_train.R 里的结果解读片段 res <- model$results # 按主指标排序,看最优和次优差多少 res_sorted <- res[order(-res[[metric]]), ] head(res_sorted[, c("mtry", "ROC", "ROCSD")], 5) # 折间标准差大的组合要警惕:均值高但方差大,说明不稳 res_sorted$cv_stability <- res_sorted$ROC / (res_sorted$ROCSD + 1e-6)ROCSD是各折 ROC 的标准差。最优组合的均值比次优高 0.005,但标准差大了三倍,这种情况我一般直接选次优——线上数据分布一漂移,高方差配置最先崩。cv_stability这个比值不是标准指标,是我自己加的观察量,用来快速扫一眼有没有「均值漂亮、方差吓人」的组合。
还有一个常被忽略的方法:model$pred里存着折外预测,可以自己按不同阈值算一遍 precision/recall,看模型在业务可接受的阈值下表现如何,而不是只盯着 0.5 那一刀:
library(pROC) roc_obj <- roc(response = model$pred$obs, predictor = model$pred[[positive_class]]) coords(roc_obj, x = c(0.2, 0.3, 0.5), input = "threshold", ret = c("precision", "recall"))roc()的response是真实标签,predictor是正例概率列,列名要跟positive_class对上。coords()按阈值取对应的 precision 和 recall,用来判断「把阈值压到 0.2 能不能多捞回一些正例、误报涨多少」。这一步在风控、故障预警这类场景里比 AUC 更能决定模型能不能落地。
3.4 模型落盘与单条预测
训练完的模型用saveRDS()存,不用save()。原因很简单:save()存的是工作空间变量,恢复时会把一堆无关对象一起拉回来,命名冲突防不胜防。saveRDS()一次只存一个对象,读的时候自己起名字。
# R/predict.R save_model <- function(model, path) { dir.create(dirname(path), recursive = TRUE, showWarnings = FALSE) saveRDS(model, path) invisible(path) } predict_one <- function(model, new_row, recipe_obj = NULL) { # new_row 是单行 data.frame,列名必须与训练时一致 if (!is.null(recipe_obj)) { new_row <- bake(recipe_obj, new_data = new_row) } predict(model, new_row, type = "prob") }dir.create(..., recursive = TRUE)保证models/不存在时自动建,省掉「本地能跑、换机器报错」这类环境问题。bake()用的是预处理阶段prep()出来的配方对象,new_data只做变换不做重估,这是训练/预测一致性的关键。type = "prob"返回概率,回归任务要换成type = "raw",分类任务如果只想拿硬标签用type = "raw"也行,但概率信息就丢了。
单条预测这里有个隐藏要求:new_row的列类型必须和训练数据一致。数值列传了字符,predict会在bake阶段或模型内部报类型错误,错误信息通常不直观。稳妥做法是在predict_one开头加一段类型校验,把字符型数值列转回来,但这属于补丁而非设计,真正的解法是在数据入口就做校验。
4. 小程序式交互:用 Shiny 把模型包成能点的界面
脚本跑通只是第一步。给不写代码的同事用,还得有个界面。这套代码在app/下用 Shiny 包了一层,逻辑很薄,就是把「输入一行特征 → 走一遍 bake + predict → 显示结果」这个过程可视化。薄是好事,说明模型逻辑没有被界面代码污染。
4.1 界面结构与输入校验
ui.R用sidebarLayout分两块,左侧放输入控件,右侧放结果:
# app/ui.R library(shiny) fluidPage( titlePanel("模型预测台"), sidebarLayout( sidebarPanel( numericInput("age", "数值特征 A", value = 35, min = 0, max = 120), selectInput("cat1", "类别特征 B", choices = c("A", "B", "C")), numericInput("score", "数值特征 C", value = 0.5, step = 0.01), actionButton("go", "开始预测") ), mainPanel( h4("预测结果"), verbatimTextOutput("result"), hr(), h4("特征贡献"), plotOutput("contrib", height = "320px") ) ) )numericInput的min/max只是前端约束,用户手动输入能被浏览器拦一部分,但拦不全,服务端还得再校验一次。selectInput的choices最好从训练数据的因子水平动态生成,硬编码在 UI 里,一旦训练集新增一个类别,界面和模型就对不上——又是factor has new levels的经典来源。
server.R的核心是把输入拼成一行data.frame,注意因子水平要和训练时一致:
# app/server.R library(shiny) model <- readRDS("models/rf_model.rds") recipe_obj <- readRDS("models/recipe.rds") train_levels <- readRDS("models/factor_levels.rds") # 训练时的水平快照 server <- function(input, output, session) { observeEvent(input$go, { new_row <- data.frame( age = as.numeric(input$age), cat1 = factor(input$cat1, levels = train_levels$cat1), score = as.numeric(input$score), stringsAsFactors = FALSE ) # 服务端兜底校验,别指望前端拦得住 if (any(is.na(new_row$age), is.na(new_row$score))) { output$result <- renderText("输入含非法数值,请检查") return() } prob <- predict_one(model, new_row, recipe_obj) output$result <- renderText( sprintf("正例概率:%.3f", prob[[positive_class]]) ) }) }factor(..., levels = train_levels$cat1)这行是整段代码里最关键的一句。它把界面上传进来的字符串强制对齐到训练时的因子水平集合上,即使新数据里只出现了"A",因子内部仍然保留A/B/C三个水平,predict就不会报新水平错误。train_levels是在训练脚本末尾单独存下来的快照,不要指望从模型对象里反推——部分模型的内部结构不保证能稳定取出原始水平。
observeEvent而不是reactive,是因为预测这个动作开销不小,不该在每次输入变化时都触发。用户点按钮才跑,交互上更可控,也不会因为连着改三个输入框就跑三遍模型。
4.2 输出窗口与可解释性
光给一个概率,用户不会信。旁边加一张特征贡献图,可信度立刻不一样。随机森林没有现成的 SHAP,但可以用randomForest::importance()或DALEX包的predict_parts()做局部解释:
# app/server.R 中补充的输出块 output$contrib <- renderPlot({ req(input$go) library(DALEX) explainer <- explain(model, data = train_sample, y = train_sample$label, label = "rf") parts <- predict_parts(explainer, new_observation = new_row, type = "break_down") plot(parts) })explain()只需要一小份训练样本(几百行足够),不需要全量,DALEX用它来估计基线。predict_parts()的type = "break_down"逐个特征累加贡献,最后落到预测值上,是局部解释里可读性最好的一种。new_observation必须和训练数据的列结构对齐,所以前面那套因子水平处理在这里同样适用。
需要提醒的是,explainer对象每次点击都重建,在数据量小的时候没问题,数据一大就会明显卡顿。正经做法是在 app 启动时建一次,server外部用local()或全局变量缓存住,这一点在二次开发章节会再提。
5. 避坑与排查:R 机器学习项目里最容易翻车的五处
下面这些坑我基本都在真实项目里踩过,有些是血泪经验,有些是别人踩完告诉我的。每条按「现象 → 原因 → 解决」写,照着对号入座比自己试错快得多。
5.1 predict 时报 factor has new levels
现象:训练集上一切正常,换一份新数据或者界面传一条新记录,predict直接抛factor has new levels,模型完全没法用。
原因:R 的因子类型不只存值,还存一套水平集合。训练时某列的水平是c("A","B","C"),新数据里只出现"A",data.frame默认会把它建成只有"A"一个水平的因子,或者更常见的——新数据里出现了一个训练时没见过的"D"。predict.randomForest这类函数会严格比对水平集合,对不上就报错,这是设计使然,不是 bug。
解决:在预测入口统一做水平对齐,用训练时保存的水平快照重建因子,见 4.1 那段代码。如果新数据里真的出现了训练时没见过的类别,业务上要么把它归到"其他"水平(前提是训练时预留了这个水平),要么直接拒绝预测并告警。千万别用droplevels()去「修」这个问题,那是把水平删掉,等于让模型在错误的编码上做预测,结果看着能出来,实际是错的。
5.2 同样的种子,两次训练结果不一样
现象:set.seed(2024)明明写了,重跑一遍train_model(),最优mtry变了,AUC 也差了一点。
原因有两种。一是种子位置不对,set.seed放在trainControl之后、train之前才管用,写在文件开头、中间还插了一堆绘图或抽样代码,随机数流早就被消耗掉了。二是开了并行,allowParallel = TRUE时 caret 会把折分配到多个 worker,每个 worker 的随机数流独立,主进程的种子管不到它们。
解决:种子紧贴train()调用设一次,并在并行场景下显式指定随机数生成器类型:
set.seed(2024, kind = "L'Ecuyer-CMRG") # 并行场景专用 RNG model <- train(...)L'Ecuyer-CMRG是 R 官方推荐的并行随机数生成器,能保证多流之间互不干扰。如果还不行,说明数据划分本身有随机性,检查一下有没有在train之前用sample()切过数据却没固定种子。这类问题属于玄学范畴,但只要种子位置和 RNG 类型两个点都对上,基本能消掉。
5.3 中文列名和编码问题
现象:read.csv()读进来的列名成了X.U.FEFF.年龄或者乱码,后面的公式as.formula("label ~ .")跑不通,或者step_dummy报列名非法。
原因:Windows 下 Excel 导出的 CSV 常带 BOM 头,read.csv()默认编码按系统区域设置走,中文环境里很容易读成 GBK 而不是 UTF-8。BOM 头会变成第一列列名的一部分,加个X.U.FEFF.前缀。
解决:统一用readr::read_csv(),它默认按 UTF-8 读并且自动处理 BOM:
df <- readr::read_csv("data/raw/train.csv", locale = readr::locale(encoding = "UTF-8"))列名这块,我一般会在读入后立刻做一次规范化,把所有非 ASCII 字符的列名转成英文或者V1/V2这种占位名,映射关系单独存一张表。看着麻烦,但能省掉后面所有公式解析、模型保存、界面绑定环节的编码问题。
5.4 类别不平衡还盯着 Accuracy 看
现象:模型报告 96% 准确率,一上线发现正例一个都没捞到,混淆矩阵里正例全被判成负例。
原因:类别不平衡时 Accuracy 基本失去意义。正例占 4%,全预测负例就有 96%。train()的默认metric是"Accuracy",很多人不显式改,调参过程实际上是在优化一个已经被多数类主导的指标。
解决:分类任务统一用metric = "ROC",同时把summaryFunction = twoClassSummary打开。数据极度不平衡时再加一步重采样:
ctrl <- trainControl( method = "cv", number = 5, classProbs = TRUE, summaryFunction = twoClassSummary, sampling = "smote", # 折内做 SMOTE,不能在折外做 savePredictions = "final" )sampling = "smote"是折内过采样,只在训练折上合成少数类样本,验证折保持原分布。这一点特别重要——如果先对全量数据做 SMOTE 再切折,验证集里混进了合成样本,评估结果会虚高,这是很多人翻车的地方。另外 SMOTE 需要themis包支持,装的时候一并装上。
5.5 saveRDS 之后换个环境读不出来
现象:本地训练好的model.rds,拷到服务器或者同事机器上,readRDS()报错,或者读出来了但predict行为异常。
原因:.rds存的是 R 对象序列化结果,里面可能带着训练时的公式环境(formula 的environment属性),如果公式是在某个函数内部动态生成的,这个环境会指向一个已经不存在的局部作用域。另一种情况是包版本差异,caret 版本不同,模型对象内部结构可能有细微变化。
解决:训练时用显式的公式对象,不用as.formula()在函数内部拼字符串:
# 不推荐:公式环境指向函数内部 formula <- as.formula(paste(target, "~ .")) # 推荐:在全局或明确的环境中构造 formula <- reformulate(".", response = target)reformulate()生成公式时环境是当前作用域,比字符串拼接可控。版本问题则靠renv.lock解决,模型和锁文件一起交付,别只交.rds。还有一个习惯:模型存盘时把预处理配方、因子水平快照、训练时的包版本一起打个 list 存进去,读的时候一次拿全,避免「模型在、配方丢了」的尴尬。
6. 二次开发:把它改成你自己的模型流水线
代码跑到这一步,基本能证明这套结构是通的。接下来是怎么把它变成你自己的东西。二次开发最容易犯的错,是一上来就改算法,把method = "rf"换成"xgbTree",然后发现评估指标、预处理管道、界面全要跟着动,改到一半就放弃了。正确的顺序是先动数据契约,再动模型,最后动界面。
6.1 换模型后端的最小改动路径
caret 的好处是换模型基本只改一个参数,但「基本」之外还有几处必须同步调整。以随机森林换梯度提升树为例:
# 原配置 model <- train_model(data = train_df, target = "label", method = "rf", task = "classification", folds = 5, tune_length = 5) # 换成 xgboost,注意三处差异 model <- train_model(data = train_df, target = "label", method = "xgbTree", task = "classification", folds = 5, tune_length = 8) # 搜索空间更大第一处差异是tune_length。随机森林主要调mtry,5 个候选值足够;xgboost 要同时调nrounds、max_depth、eta、subsample等,5 个组合根本覆盖不到,实际用 8 到 10 比较合理,代价是训练时间成倍上升。第二处是缺失值策略,xgboost 原生支持缺失值,step_impute_median那一步可以去掉,让模型自己学缺失方向,前提是你确认缺失不是有偏的。第三处是因子编码,xgboost 对独热后的稀疏矩阵更友好,step_dummy(..., one_hot = TRUE)打开,能明显减少内存占用。
改完之后必须验证的不是 AUC 涨没涨,而是「训练集上的折外预测分布」和「新数据上的预测分布」是否一致。做法是拿一份留出的验证集跑一遍,对比两个分布的均值和中位数,偏差超过 10% 就说明预处理管道有某一步没对齐。
6.2 加特征和加评估指标的验证清单
加特征比换模型更容易出错,因为特征工程的错误往往不报错,只是让指标悄悄变差或虚高。我一般按这个顺序验证:
| 步骤 | 检查什么 | 通过标准 |
|---|---|---|
| 特征计算 | 新特征在训练集和预测集上是否都算得出来 | 无 NA 突发、无 Inf |
| 时序正确性 | 新特征是否用到了未来信息 | 特征时间戳 ≤ 样本时间戳 |
| 单变量信号 | 新特征和目标的相关性/IV 值 | 有明显区分度但不极端 |
| 增量评估 | 加特征前后同一 CV 折上的指标 | 提升稳定,不是单折偶然 |
| 重要性排序 | 新特征在模型里的重要性位置 | 不排第一且远高于其他 |
时序正确性这一条最容易翻车。比如算「用户近 7 天平均消费」,如果窗口是左闭右闭且包含了当天,等于把标签信息漏进了特征,线下 AUC 会高得离谱。稳妥做法是所有窗口特征都做 shift(1),把当天排除在外,宁可少一点信号也别引入泄漏。
评估指标这块,除了 ROC 和 RMSE,建议至少再加一个和业务直接挂钩的指标。风控场景看召回率在固定误报率下的值,推荐场景看 top-K 的命中率。这类指标 caret 不直接给,得自己写summaryFunction:
# 自定义评估函数:固定 5% 误报率下的召回 recall_at_fpr <- function(data, lev = NULL, model = NULL) { pos <- lev[1] roc_obj <- pROC::roc(response = data$obs, predictor = data[[pos]], quiet = TRUE) coords <- pROC::coords(roc_obj, x = 0.05, input = "specificity", ret = "sensitivity") c(RecallAtFPR = coords$sensitivity) }函数签名必须是(data, lev, model)这三个参数,caret 调用时按位置传,少一个就报错。lev是水平向量,正例固定在第一位,这个顺序由trainControl里因子的水平顺序决定,别自己臆断。返回值必须是命名数值向量,名字就是指标名,会出现在model$results的列里。
界面层的改造建议放到最后。app/server.R里那个每次点击都重建explainer的问题,改法是把它挪到server函数外部,用local()包一层做缓存:
cached_explainer <- local({ exp <- NULL function() { if (is.null(exp)) { exp <<- DALEX::explain(model, data = train_sample, y = train_sample$label, label = "model") } exp } })local()创建一个封闭环境,exp和返回的函数共享这个环境,<<-往上一层写,第一次调用时构建,之后直接复用。这个模式在 Shiny 里很常见,比用全局变量干净,也不会被其他脚本误改。改成这样之后,界面响应时间能从两三秒降到几百毫秒,用户体感完全是两回事。
最后说一个习惯。我以前改这类流水线项目,喜欢直接在主分支上动,改完跑一遍训练脚本,指标没掉就提交。后来有一次换模型后端,线下 AUC 涨了 0.02,上线一周后被业务反馈效果反而差了,回头查才发现是新模型的概率分布整体偏移,原来的阈值全废了。从那以后我每次改模型或加特征,都强制走一遍「新旧模型在同一份留出集上的概率分布对比 + 阈值重扫」,把两张 ROC 曲线画在一张图上,阈值表并排打出来,确认没有分布漂移才敢往下走。多花二十分钟,省掉的是上线后一周的排查。希望帮到你。
本文还有配套的精品资源,点击获取