R + GNU Make 可重复研究模式
这是一个 Claude 技能。 在 Claude 中使用 /skill r-gnumake-orchestrator 加载,即可获取构建可重复数据分析管道的模式。
一份关于如何使用 GNU Make 编排 R 分析管道的指南,采用文件目标工作流和标准化的脚本接口契约。
概述
此技能捕获了可重复数据分析的通用模式:
- 脚本作为纯函数: 接受参数、读取输入、写入一个输出、然后退出
- 具有文件目标的 Makefile: 仅在依赖项更改时重新构建
- 共享配置: 所有脚本共享一个真实来源
- Tidyverse 生态系统: 一致、现代化的 R 包
- roxygen2 文档: 每个可重用的函数都有文档
- 不打印输出: 结果仅存在于提交的数据文件中
这些模式可从小型探索性分析扩展到包含数十个阶段和数千个生成工件的大型研究项目。
R 脚本和 Makefile 约定
R 脚本模式
每个管道脚本都条件性定义 .args:如果在控制台中运行,则使用交互式默认值;如果从 Make 调用,则使用 commandArgs()。
# 定义参数:交互式时使用默认值,从 Make 调用时使用 commandArgs
.args <- if (interactive()) c(
"data/default1.rds",
"data/default2.rds",
"R/shared_inputs.R",
"output/default.csv"
) else commandArgs(trailingOnly = TRUE)
# 按位置从 .args 中提取
input1 <- .args[1]
input2 <- .args[2]
helper <- .args[3]
output <- .args[4]
source(helper) # 加载 COUNTRY、SEED、OUTPUT_DIR 等
# 现在:读取输入、计算、写入输出、退出
data <- read_rds(input1)
results <- process(data)
write_csv(results, output)
在 Make 中: Rscript $^ $@ 按顺序传递所有先决条件,然后是目标。
交互式: Rscript R/myscript.R 使用 .args 默认值(不需要参数)。
Makefile 模式
使用文件目标(而不是假目标规则)。仅在先决条件更改时重新构建。
# 目录创建(仅顺序依赖,不会触发重新构建)
output output/figures output/tables:
mkdir -p $@
# 单个输入 → 单个输出
output/processed.rds: data/raw.csv R/process.R R/shared_inputs.R | output
Rscript $^ $@
# 多个输入 → 单个输出
output/scores.csv: output/model.rds data/test.rds R/score.R R/shared_inputs.R | output
Rscript $^ $@
# 仅为方便而设的假目标(Make 不跟踪)
.PHONY: all
all: output/scores.csv
关键语法:
$^= 按顺序的所有先决条件$@= 目标文件名| output= 仅顺序依赖(目录已创建,但时间戳被忽略)
共享配置
用一个文件保存两个或更多脚本需要的每个值。不在脚本间重复。
# R/shared_inputs.R
COUNTRY <- "Afghanistan"
HORIZON <- as.integer(Sys.getenv("HORIZON", "1"))
SEED <- 42
OUTPUT_DIR <- file.path("output", paste0("horizon_", HORIZON))
VALIDATION_YEARS <- 2017:2023
# 每个脚本都 source 这个文件,从不重新定义它
source("R/shared_inputs.R")
文件组织
project/
├── Makefile # 编排规则(文件目标)
├── R/
│ ├── shared_inputs.R # 配置(所有脚本 source)
│ ├── process_data.R # 管道脚本
│ ├── fit_model.R # (每个脚本一个任务)
│ ├── score_model.R
│ ├── helpers.R # 可重用函数(无阶段前缀)
│ ├── models.R # roxygen2 文档
│ └── utils.R
├── data/
│ ├── raw/ # 源数据
│ ├── processed/ # 生成的数据(.gitignore 或 DVC 跟踪)
│ └── features/
├── output/
│ ├── figures/ # 提交(以便稿件在 CI 中渲染)
│ ├── tables/ # 包含所有数字的 .csv 文件
│ ├── models/ # 拟合的模型对象 (.rds)
│ └── predictions/ # 包含模型输出的 .csv
└── paper/
├── paper.qmd # 稿件(读取 output/*.csv)
└── bibliography.bib
阶段前缀按执行顺序排序:
process_*.R→fit_*.R→score_*.R→visualise_*.R
函数文件没有前缀,以便文档工具将它们与管道脚本区分开。
Make 模式规则
使用模式规则(%)处理重复的目标。定义一次,应用于多个。
# 模式:处理每个输入文件
INPUTS := file1 file2 file3
# 通用规则:将任何 %.csv 转换为 output/processed_%.rds
output/processed_%.rds: input/%.csv R/process.R R/shared_inputs.R | output
Rscript $^ $@
# 便利目标:所有已处理的文件
all_processed: $(patsubst input/%.csv, output/processed_%.rds, $(wildcard input/*.csv))
# 特殊情况覆盖:file1 需要不同的输入
output/processed_file1.rds: input/file1_v2.csv R/process.R R/shared_inputs.R | output
Rscript $^ $@
每条规则一个输出: 每个脚本只写入一个文件,因此每条规则只有一个目标。如果一个阶段需要两个文件,则编写两个脚本和两条规则(请参阅“每个脚本一个输出”,位于“常见模式”下)。
函数组织
管道脚本
每个脚本一个任务,每个脚本一个输出。每个阶段读取前一阶段写入的内容。
# R/fit_model.R
# 目的:对准备好的数据拟合模型
# 输入:data/prepared.rds, R/shared_inputs.R
# 输出:output/model.rds
.args <- if (interactive()) c(
"data/prepared.rds",
"R/shared_inputs.R",
"output/model.rds"
) else commandArgs(trailingOnly = TRUE)
data_file <- .args[1]
helper <- .args[2]
model_file <- .args[3]
source(helper)
data <- read_rds(data_file)
model <- fit_model(data, seed = SEED)
write_rds(model, model_file)
从相同输入派生的任何其他内容(例如训练数据的摘要)都有其自己的脚本和输出:
# R/summarise_training.R
# 目的:汇总用于拟合模型的数据
# 输入:data/prepared.rds, R/shared_inputs.R
# 输出:output/model_meta.csv
.args <- if (interactive()) c(
"data/prepared.rds",
"R/shared_inputs.R",
"output/model_meta.csv"
) else commandArgs(trailingOnly = TRUE)
data_file <- .args[1]
helper <- .args[2]
meta_file <- .args[3]
source(helper)
data <- read_rds(data_file)
write_csv(
tibble(
n_training = nrow(data),
n_features = ncol(data) - 1
),
meta_file
)
函数文件
可重用的函数放在单独的文件中(无阶段前缀)。使用 roxygen2 进行文档说明。
# R/models.R
#' 对数据拟合模型
#'
#' 使用指定参数训练随机森林。
#'
#' @details
#' 对输入的假设:
#' - 数据包含列 `outcome`(二进制:0/1),所有预测变量为数值型
#' - seed 是用于可重复性的整数
#'
#' @param data 包含结果和预测变量列的 tibble。
#' @param seed 用于随机数生成的整数。
#' @param mtry 整数;每次分割要采样的变量数量。
#'
#' @return 拟合的模型对象(ranger 类)。
#'
#' @export
fit_model <- function(data, seed = 42, mtry = floor(sqrt(ncol(data) - 1))) {
model <- ranger(outcome ~ ., data = data, seed = seed, mtry = mtry)
model
}
运行 make docs 以从 roxygen 块重新生成 man/ 和 NAMESPACE。
共享配置
研究范围参数的集中位置。每个脚本都 source 它。
# R/shared_inputs.R
# 研究设计
COUNTRY <- "Country_Name"
SEED <- 42
ANALYSIS_YEARS <- 2017:2023
# 路径(计算得出,非硬编码)
DATA_DIR <- "data"
OUTPUT_DIR <- "output"
HORIZON <- as.integer(Sys.getenv("HORIZON", "1"))
# 模型超参数
RF_MTRY <- NULL # sqrt(p)
RF_NTREES <- 500
# 不要在此处定义任何其他内容。
# 阶段特定的值保留在其阶段的脚本中。
样式指南
Tidyverse 栈
标准化使用以下包:
library(tidyverse) # 核心:dplyr, tidyr, ggplot2, readr, purrr
library(fs) # 文件系统操作(dir_create, file_exists)
library(cli) # 错误信号(cli_abort, cli_warn)
library(here) # 相对路径(here("R", "script.R"))
library(scales) # 绘图格式化
library(yardstick) # 模型度量
永远不要使用:
- 基础子集操作或
aggregate()(使用dplyr::filter()、dplyr::summarise()) map_dfr()(使用map()+list_rbind())group_by()+ungroup()(使用.by =参数)gather()、spread()(使用pivot_longer()、pivot_wider())%>%(使用原生管道|>)function(x)(使用 lambda\(x))- 已废弃的函数(查看帮助页面上的生命周期徽章)
代码格式化
使用 air 格式化工具(强制 80 字符行宽、2 空格缩进):
air format R/ # 格式化整个目录
air format R/myscript.R # 格式化单个文件
设置在 air.toml 文件中的仓库根目录(受版本控制,非个人设置)。
注释
注释应说明代码为什么这样做,而不是做什么(仅在从代码中理解理由不明显时)。避免仅重述代码的注释。
# 不好:重复了代码显而易见的功能
# 使用默认超参数拟合随机森林
model <- ranger(outcome ~ ., data = data)
# 好:解释了我们为什么做出这个选择
# 在此处使用随机森林而非 CART,因为我们需要分位数回归的置信区间
model <- ranger(outcome ~ ., data = data)
# 不好:从代码显而易见
# 筛选 year 在 ANALYSIS_YEARS 中的行
data <- data |> filter(year %in% ANALYSIS_YEARS)
# 好:解释了不明显的推理
# 由于 2015 年监测覆盖不完全,排除该年份(参见方法)
data <- data |> filter(year >= 2016)
# 无需注释:代码本身已说明一切
data <- data |>
select(district, year, outcome) |>
filter(!is.na(outcome))
在稿件的“方法”部分撰写解释方法论选择的注释;仅当决策出乎意料或非标准时才使用代码注释。
不打印输出
结果仅存在于已提交的 CSV、RDS 和 PNG 文件中。
不要使用 message()、print()、cat() 或 sprintf() 来输出信息。
例外情况:cli_abort() 和 cli_warn()(仅用于控制流程)。
# 不好
message(sprintf("处理了 %d 行", nrow(data)))
cat("模型已训练\n")
# 好(改为写入文件)
meta <- tibble(n_rows = nrow(data))
write_csv(meta, "output/metadata.csv")
稿件在渲染时读取 output/metadata.csv,因此每个数字都出现在已提交的文件中。
验证工作流
在编写新脚本时,先逐步测试,然后再添加 Make 规则。
第 1 步:交互式测试(使用默认值)
# 不带参数时可以工作吗?
Rscript R/myscript.R
# 检查输出文件是否已创建
ls -lh output/
第 2 步:使用参数测试(模拟 Make)
# 像 Make 一样传递参数
Rscript R/myscript.R \
data/raw.csv \
R/shared_inputs.R \
output/result.rds
# 与第 1 步的输出进行比较
第 3 步:添加 Make 规则并测试
# 在 Makefile 中:
output/result.rds: data/raw.csv R/myscript.R R/shared_inputs.R | output
Rscript $^ $@
# 测试它
make output/result.rds
# 验证:修改数据应触发重新构建
touch data/raw.csv
make output/result.rds # 应重新运行
# 验证:修改 output/result.rds(非依赖项)不应触发重新构建
touch output/result.rds
make output/result.rds # 应显示 "up to date"
第 4 步:试运行
make --dry-run output/result.rds # 显示将要运行的内容,而不实际运行
示例:简单的 3 阶段管道
Makefile
.PHONY: all
all: output/summary.csv
output:
mkdir -p $@
output/processed.rds: data/raw.csv R/process.R R/shared_inputs.R | output
Rscript $^ $@
output/model.rds: output/processed.rds R/fit.R R/shared_inputs.R | output
Rscript $^ $@
output/summary.csv: output/model.rds output/processed.rds R/score.R R/shared_inputs.R | output
Rscript $^ $@
R/process.R
.args <- if (interactive()) c(
"data/raw.csv",
"R/shared_inputs.R",
"output/processed.rds"
) else commandArgs(trailingOnly = TRUE)
raw_file <- .args[1]
helper <- .args[2]
output <- .args[3]
source(helper)
data <- read_csv(raw_file) |>
filter(year %in% ANALYSIS_YEARS) |>
mutate(across(everything(), as.numeric))
write_rds(data, output)
R/fit.R
.args <- if (interactive()) c(
"output/processed.rds",
"R/shared_inputs.R",
"output/model.rds"
) else commandArgs(trailingOnly = TRUE)
data_file <- .args[1]
helper <- .args[2]
output <- .args[3]
source(helper)
data <- read_rds(data_file)
model <- ranger(outcome ~ ., data = data, seed = SEED)
write_rds(model, output)
R/score.R
.args <- if (interactive()) c(
"output/model.rds",
"output/processed.rds",
"R/shared_inputs.R",
"output/summary.csv"
) else commandArgs(trailingOnly = TRUE)
model_file <- .args[1]
data_file <- .args[2]
helper <- .args[3]
output <- .args[4]
source(helper)
model <- read_rds(model_file)
data <- read_rds(data_file)
predictions <- predict(model, data)$predictions
scores <- tibble(
auc = AUC::auc(data$outcome, predictions),
n = nrow(data)
)
write_csv(scores, output)
运行它
make # 按顺序构建所有内容(process → fit → score)
make output/model.rds --always-make # 强制仅重建此步骤
make --dry-run # 显示将要运行的内容
新脚本检查清单
编写新的管道阶段时:
- 脚本按顺序接受参数:输入 → helper → 输出
- 脚本包含
.args默认值,用于交互式测试 - 脚本 source
R/shared_inputs.R(从不定义研究范围的值) - 脚本读取其输入,计算,并恰好写入一个输出文件
- 没有
print()、message()或cat()(仅对错误使用cli_abort()、cli_warn()) - 所有结果都写入已提交的 CSV/RDS/PNG 文件
- 依赖项在 Makefile 中正确列出(按顺序)
- 仅顺序目录先决条件使用
|语法 - 已进行交互式测试,然后使用参数测试,然后通过 Make 测试
- Makefile 规则使用
$^和$@语法 - 使用
air format R/scriptname.R格式化代码 - 可重用函数已被提取到单独的文件中,并使用 roxygen2 文档化
-
.Rbuildignore中适当使用了^man/$和^NAMESPACE$(如果适用) -
make --dry-run显示正确的命令顺序