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("R/shared_inputs.R")
檔案組織
project/
├── Makefile # 協調規則(檔案目標)
├── R/
│ ├── shared_inputs.R # 設定(所有腳本來源)
│ ├── 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
#' 關於輸入的假設:
#' - data 具有名為 outcome 的欄位(二元值:0/1)且所有預測變數皆為數值
#' - seed 是用於隨機數生成的一個整數
#'
#' @param data 一個包含 outcome 和預測變數欄位的 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。
共用設定
研究範圍參數的集中位置。每個腳本都來源此檔案。
# 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)
# 不好:從程式碼即可明顯看出
# 篩選年份在 ANALYSIS_YEARS 中的行
filter(data, 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 # 應顯示「已是最新」
步驟 4:模擬執行
make --dry-run output/result.rds # 顯示將執行哪些命令而不實際執行
範例:簡單的三階段管線
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預設值以供互動式測試 - 腳本來源
R/shared_inputs.R(永不定義研究範圍值) - 腳本讀取其輸入、計算,並僅寫入一個輸出檔案
- 無
print(),message(), 或cat()(僅對錯誤使用cli_abort(),cli_warn()) - 所有結果寫入版控的 CSV/RDS/PNG 檔案
- 在 Makefile 中正確列出相依項目(依序)
- 僅次序相依的目錄先決條件使用
|語法 - 已互動式測試,然後使用引數測試,最後透過 Make 測試
- Makefile 規則使用
$^和$@語法 - 程式碼已使用
air format R/scriptname.R格式化 - 可重複使用的函式已提取到單獨的檔案中