R + GNU Make Reproduzierbare Forschungspatterns
Dies ist ein Claude-Skill. Lade es mit /skill r-gnumake-orchestrator in Claude, um Zugriff auf Patterns zum Erstellen reproduzierbarer Datenanalyse-Pipelines zu erhalten.
Ein Leitfaden zur Orchestrierung von Analyse-Pipelines in R mit GNU Make, dateibasierten Workflows und einem standardisierten Skriptschnittstellenvertrag.
Übersicht
Dieses Skill erfasst generische Patterns für reproduzierbare Datenanalyse:
- Skripte als reine Funktionen: Akzeptieren Argumente, lesen Eingaben, schreiben eine Ausgabe, beenden sich
- Makefiles mit Dateizielen: Nur neu bauen, wenn sich Abhängigkeiten ändern
- Geteilte Konfiguration: Eine einzige Quelle der Wahrheit für alle Skripte
- Tidyverse-Ökosystem: Konsistente, moderne R-Pakete
- roxygen2-Dokumentation: Jede wiederverwendbare Funktion wird dokumentiert
- Keine Ausgabeausgabe: Ergebnisse leben nur in kommittierten Datendateien
Diese Patterns skalieren von kleinen explorativen Analysen bis zu großen Forschungskompendien mit Dutzenden von Stufen und Tausenden generierten Artefakten.
R-Skripte und Makefile-Konventionen
R-Skript-Pattern
Jedes Pipeline-Skript definiert .args bedingt: interaktive Standardwerte, wenn es in der Konsole läuft, commandArgs() wenn es von Make aufgerufen wird.
# Definiere Argumente: Standardwerte für interaktiven Modus, commandArgs wenn von Make aufgerufen
.args <- if (interactive()) c(
"data/default1.rds",
"data/default2.rds",
"R/shared_inputs.R",
"output/default.csv"
) else commandArgs(trailingOnly = TRUE)
# Extrahiere aus .args nach Position
input1 <- .args[1]
input2 <- .args[2]
helper <- .args[3]
output <- .args[4]
source(helper) # lädt COUNTRY, SEED, OUTPUT_DIR, etc.
# Jetzt: Eingaben lesen, berechnen, Ausgabe schreiben, beenden
data <- read_rds(input1)
results <- process(data)
write_csv(results, output)
In Make: Rscript $^ $@ übergibt alle Voraussetzungen der Reihe nach, dann das Ziel.
Interaktiv: Rscript R/myscript.R verwendet .args-Standardwerte (keine Argumente nötig).
Makefile-Pattern
Verwende Dateiziele (keine Phony-Regeln). Nur neu bauen, wenn sich Voraussetzungen ändern.
# Verzeichniserstellung (order-only, löst keine Neubauten aus)
output output/figures output/tables:
mkdir -p $@
# Eine Eingabe → eine Ausgabe
output/processed.rds: data/raw.csv R/process.R R/shared_inputs.R | output
Rscript $^ $@
# Mehrere Eingaben → eine Ausgabe
output/scores.csv: output/model.rds data/test.rds R/score.R R/shared_inputs.R | output
Rscript $^ $@
# Phony-Ziele nur zur Bequemlichkeit (nicht von Make verfolgt)
.PHONY: all
all: output/scores.csv
Wichtige Syntax:
$^= alle Voraussetzungen in Reihenfolge$@= Zieldateiname| output= order-only-Voraussetzung (Verzeichnis erstellt, aber Zeitstempel ignoriert)
Geteilte Konfiguration
Eine Datei enthält jeden Wert, den zwei oder mehr Skripte benötigen. Keine Duplikation über Skripte hinweg.
# 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
# Jedes Skript sourced dies, definiert es nie neu
source("R/shared_inputs.R")
Dateiorganisation
project/
├── Makefile # Orchestrierungsregeln (Dateiziele)
├── R/
│ ├── shared_inputs.R # Konfiguration (von allen Skripten gesourced)
│ ├── process_data.R # Pipeline-Skripte
│ ├── fit_model.R # (ein Job pro Skript)
│ ├── score_model.R
│ ├── helpers.R # Wiederverwendbare Funktionen (kein Stage-Präfix)
│ ├── models.R # roxygen2-dokumentiert
│ └── utils.R
├── data/
│ ├── raw/ # Quelldaten
│ ├── processed/ # Generiert (gitignoriert oder DVC-verfolgt)
│ └── features/
├── output/
│ ├── figures/ # Kommittiert (damit Manuskript in CI rendert)
│ ├── tables/ # .csv-Dateien mit allen Zahlen
│ ├── models/ # Angepasste Modellobjekte (.rds)
│ └── predictions/ # .csv mit Modellausgaben
└── paper/
├── paper.qmd # Manuskript (liest output/*.csv)
└── bibliography.bib
Stage-Präfixe sortieren in Ausführungsreihenfolge:
process_*.R→fit_*.R→score_*.R→visualise_*.R
Funktionsdateien haben kein Präfix, damit Dokumentationstools sie von Pipeline-Skripten unterscheiden können.
Make-Pattern-Regeln
Verwende Pattern-Regeln (%) für wiederholte Ziele. Einmal definieren, auf viele anwenden.
# Pattern: Verarbeite jede Eingabedatei
INPUTS := file1 file2 file3
# Generische Regel: Transformiere jede %.csv in output/processed_%.rds
output/processed_%.rds: input/%.csv R/process.R R/shared_inputs.R | output
Rscript $^ $@
# Bequemlichkeitsziel: Alle verarbeiteten Dateien
all_processed: $(patsubst input/%.csv, output/processed_%.rds, $(wildcard input/*.csv))
# Überschreibung für Spezialfall: file1 benötigt andere Eingabe
output/processed_file1.rds: input/file1_v2.csv R/process.R R/shared_inputs.R | output
Rscript $^ $@
Eine Ausgabe pro Regel: Jedes Skript schreibt genau eine Datei, also hat jede Regel genau ein Ziel. Wenn eine Stufe zwei Dateien benötigt, schreibe zwei Skripte und zwei Regeln (siehe "Eine Ausgabe pro Skript" unter Allgemeine Patterns).
Funktionsorganisation
Pipeline-Skripte
Ein Job pro Skript und eine Ausgabe pro Skript. Jede Stufe liest, was die vorherige Stufe geschrieben hat.
# R/fit_model.R
# Zweck: Ein Modell an vorbereitete Daten anpassen
# Eingaben: data/prepared.rds, R/shared_inputs.R
# Ausgabe: 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)
Alles andere, das aus denselben Eingaben abgeleitet wird, wie eine Zusammenfassung der Trainingsdaten, bekommt ein eigenes Skript und seine eigene Ausgabe:
# R/summarise_training.R
# Zweck: Die Daten zusammenfassen, die zum Anpassen des Modells verwendet wurden
# Eingaben: data/prepared.rds, R/shared_inputs.R
# Ausgabe: 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
)
Funktionsdateien
Wiederverwendbare Funktionen kommen in separate Dateien (kein Stage-Präfix). Dokumentiere mit roxygen2.
# R/models.R
#' Passen Sie ein Modell an Daten an
#'
#' Trainiert einen Random Forest mit angegebenen Parametern.
#'
#' @details
#' Annahmen über Eingaben:
#' - Daten haben Spalte `outcome` (binär: 0/1) und alle Prädiktoren sind numerisch
#' - seed ist eine ganze Zahl für Reproduzierbarkeit
#'
#' @param data Ein Tibble mit outcome- und Prädiktorspalten.
#' @param seed Ganze Zahl für die Zufallszahlengenerierung.
#' @param mtry Ganze Zahl; Anzahl der Variablen, die pro Split abgetastet werden.
#'
#' @return Ein angepasstes Modellobjekt (ranger-Klasse).
#'
#' @export
fit_model <- function(data, seed = 42, mtry = floor(sqrt(ncol(data) - 1))) {
model <- ranger(outcome ~ ., data = data, seed = seed, mtry = mtry)
model
}
Führe make docs aus, um man/ und NAMESPACE aus roxygen-Blöcken neu zu generieren.
Geteilte Konfiguration
Zentraler Ort für studienweite Parameter. Jedes Skript sourced dies.
# R/shared_inputs.R
# Studiendesign
COUNTRY <- "Country_Name"
SEED <- 42
ANALYSIS_YEARS <- 2017:2023
# Pfade (berechnet, nicht hartcodiert)
DATA_DIR <- "data"
OUTPUT_DIR <- "output"
HORIZON <- as.integer(Sys.getenv("HORIZON", "1"))
# Modell-Hyperparameter
RF_MTRY <- NULL # sqrt(p)
RF_NTREES <- 500
# Definieren Sie hier nichts anderes.
# Stufenspezifische Werte bleiben im Skript ihrer Stufe.
Stilrichtlinien
Tidyverse-Stack
Standardisiere auf diese Pakete:
library(tidyverse) # Kern: dplyr, tidyr, ggplot2, readr, purrr
library(fs) # Dateisystemoperationen (dir_create, file_exists)
library(cli) # Fehlermeldungen (cli_abort, cli_warn)
library(here) # Relative Pfade (here("R", "script.R"))
library(scales) # Plot-Formatierung
library(yardstick) # Modellmetriken
Nie verwenden:
- Base-Subsetting oder
aggregate()(verwendedplyr::filter(),dplyr::summarise()) map_dfr()(verwendemap()+list_rbind())group_by()+ungroup()(verwende.by =-Argument)gather(),spread()(verwendepivot_longer(),pivot_wider())%>%(verwende nativen Pipe-Operator|>)function(x)(verwende Lambda\(x))- Überholte Funktionen (überprüfe Lifecycle-Badge auf Hilfeseite)
Code-Formatierung
Verwende air-Formatter (erzwingt 80-Zeichen-Zeilen, 2-Leerzeichen-Einrückung):
air format R/ # formatiere gesamtes Verzeichnis
air format R/myscript.R # formatiere eine Datei
Einstellungen in air.toml im Repo-Root (verfolgt, nicht persönlich).
Kommentare
Kommentiere warum der Code etwas tut, nicht was (nur wo die Begründung nicht offensichtlich aus dem Code hervorgeht). Vermeide Kommentare, die nur den Code wiederholen.
# Schlecht: wiederholt, was der Code offensichtlich tut
# Passe einen Random Forest mit Standard-Hyperparametern an
model <- ranger(outcome ~ ., data = data)
# Gut: erklärt, warum wir diese Wahl getroffen haben
# Random Forest statt CART hier, weil wir Konfidenzintervalle aus Quantilsregression brauchen
model <- ranger(outcome ~ ., data = data)
# Schlecht: offensichtlich aus dem Code
# Filtere Zeilen, bei denen Jahr in ANALYSIS_YEARS ist
data <- data |> filter(year %in% ANALYSIS_YEARS)
# Gut: erklärt die nicht offensichtliche Begründung
# Schließe Jahr 2015 aus wegen unvollständiger Überwachungsabdeckung (siehe Methoden)
data <- data |> filter(year >= 2016)
# Kein Kommentar nötig: Der Code erklärt sich selbst
data <- data |>
select(district, year, outcome) |>
filter(!is.na(outcome))
Schreibe Kommentare in den Methodenabschnitt des Manuskripts, wenn eine methodische Wahl erklärt wird; verwende Code-Kommentare nur, wenn die Entscheidung unerwartet oder nicht standardmäßig ist.
Keine Ausgabeausgabe
Ergebnisse leben nur in kommittierten CSV-, RDS- und PNG-Dateien.
Kein message(), print(), cat() oder sprintf() für informative Ausgaben.
Ausnahmen: cli_abort() und cli_warn() (nur zur Steuerung des Ablaufs).
# Schlecht
message(sprintf("%d Zeilen verarbeitet", nrow(data)))
cat("Modell trainiert\n")
# Gut (stattdessen in Datei schreiben)
meta <- tibble(n_rows = nrow(data))
write_csv(meta, "output/metadata.csv")
Das Manuskript liest output/metadata.csv zur Rendering-Zeit, also erscheint jede Zahl in kommittierten Dateien.
Validierungsworkflow
Wenn du ein neues Skript schreibst, teste progressiv, bevor du Make-Regeln hinzufügst.
Schritt 1: Interaktiv testen (mit Standardwerten)
# Funktioniert es ohne Argumente?
Rscript R/myscript.R
# Überprüfe, ob Ausgabedateien erstellt wurden
ls -lh output/
Schritt 2: Mit Argumenten testen (imitiert Make)
# Übergib Argumente, wie Make es tun würde
Rscript R/myscript.R \
data/raw.csv \
R/shared_inputs.R \
output/result.rds
# Vergleiche mit Ausgabe von Schritt 1
Schritt 3: Make-Regel hinzufügen und testen
# In Makefile:
output/result.rds: data/raw.csv R/myscript.R R/shared_inputs.R | output
Rscript $^ $@
# Teste es
make output/result.rds
# Überprüfe: Ändern von Daten sollte Neubau auslösen
touch data/raw.csv
make output/result.rds # Sollte neu laufen
# Überprüfe: Ändern von output/result.rds (keine Abhängigkeit) löst keinen Neubau aus
touch output/result.rds
make output/result.rds # Sollte "up to date" sagen
Schritt 4: Trockenlauf
make --dry-run output/result.rds # Zeige, was laufen würde, ohne es auszuführen
Beispiel: Einfache 3-Stufen-Pipeline
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)
Ausführen
make # Baut alles der Reihe nach (process → fit → score)
make output/model.rds --always-make # Erzwinge Neubau nur dieses Schritts
make --dry-run # Zeige, was laufen würde
Checkliste für neue Skripte
Beim Schreiben einer neuen Pipeline-Stufe:
- Skript akzeptiert Argumente in Reihenfolge: Eingaben → helper → Ausgabe
- Skript enthält
.args-Standardwerte für interaktives Testen - Skript sourced
R/shared_inputs.R(definiert nie studienweite Werte) - Skript liest seine Eingaben, berechnet und schreibt genau eine Ausgabedatei
- Kein
print(),message()odercat()(verwendecli_abort(),cli_warn()nur für Fehler) - Alle Ergebnisse in kommittierte CSV-/RDS-/PNG-Dateien geschrieben
- Abhängigkeiten im Makefile korrekt aufgelistet (in Reihenfolge)
- Order-only-Verzeichnisvoraussetzungen verwenden
|-Syntax - Interaktiv getestet, dann mit Argumenten, dann via Make
- Makefile-Regel verwendet
$^- und$@-Syntax - Code mit
air format R/scriptname.Rformatiert - Wiederverwendbare Funktionen in separate Dateien ausgelagert und mit roxygen2 dokumentiert