A model-building pipeline is a
sequence of loops: fit one model per group, one per resample, one per
hyperparameter setting, one per bootstrap replicate. Each loop is
embarrassingly parallel, each benefits from a progress bar on a slow
run, and each should be reproducible and should not collapse because one
fit failed. That is exactly the surface functionals covers,
with the same handful of arguments throughout.
fmapg() splits a data frame by one or more columns and
hands each piece to .f.
by_species <- fmapg(iris, function(d) {
fit <- lm(Sepal.Length ~ Petal.Length, data = d)
c(slope = unname(coef(fit)[2]), r2 = summary(fit)$r.squared)
}, by = "Species")
do.call(rbind, by_species)
#> slope r2
#> setosa 0.5422926 0.07138289
#> versicolor 0.8282810 0.56858983
#> virginica 0.9957386 0.74688439Row-wise over a table of formulas with fmapr(), where
each row arrives as a named list:
specs <- data.frame(
response = "mpg",
predictor = c("wt", "hp", "wt + hp", "wt * hp"),
stringsAsFactors = FALSE
)
aic_tbl <- fmapr(specs, function(row) {
f <- reformulate(row$predictor, row$response)
fit <- lm(f, data = mtcars)
data.frame(model = deparse(f), aic = AIC(fit), adj_r2 = summary(fit)$adj.r.squared)
})
do.call(rbind, aic_tbl)
#> model aic adj_r2
#> 1 mpg ~ wt 166.0294 0.7445939
#> 2 mpg ~ hp 181.2386 0.5891853
#> 3 mpg ~ wt + hp 156.6523 0.8148396
#> 4 mpg ~ wt * hp 145.6109 0.8724170Or over a grid of tuning values with fmapn(), iterating
aligned lists in lockstep:
fcv() is fapply() named for the job: map an
evaluation function over a list of resample splits. Collect the per-fold
rows and rbind() once.
set.seed(42)
folds <- rsample::vfold_cv(mtcars, v = 5)
cv_rows <- fcv(folds$splits, function(split) {
tr <- rsample::analysis(split)
te <- rsample::assessment(split)
fit <- lm(mpg ~ wt + hp, data = tr)
pred <- predict(fit, newdata = te)
data.frame(rmse = sqrt(mean((te$mpg - pred)^2)))
})
cv <- do.call(rbind, cv_rows)
c(mean_rmse = mean(cv$rmse), sd_rmse = sd(cv$rmse))
#> mean_rmse sd_rmse
#> 2.7863370 0.3928442For a slow learner, add ncores and
pb = TRUE and nothing else changes:
If a fold can fail (a degenerate resample, a non-converging fit), use
.on_error = "pass" and drop the failures afterwards:
safe_rows <- fcv(folds$splits, function(split) {
tr <- rsample::analysis(split)
if (nrow(tr) < 3) stop("degenerate fold")
data.frame(rmse = sd(rsample::assessment(split)$mpg))
}, .on_error = "pass")
ok <- !vapply(safe_rows, inherits, logical(1), "functionals_error")
do.call(rbind, safe_rows[ok])
#> rmse
#> 1 7.019191
#> 2 6.837084
#> 3 4.124076
#> 4 6.542680
#> 5 5.123183frepeat() evaluates an expression times
times. With .seed, replicate i draws from its
own RNG stream, so the estimate is identical on a re-run and identical
whether you use one core or eight.
boot_coef <- frepeat(times = 200, .seed = 2024, simplify = TRUE, expr = function() {
idx <- sample(nrow(mtcars), replace = TRUE)
coef(lm(mpg ~ wt, data = mtcars[idx, ]))[["wt"]]
})
c(
estimate = coef(lm(mpg ~ wt, data = mtcars))[["wt"]],
boot_se = sd(boot_coef),
ci_lo = unname(quantile(boot_coef, 0.025)),
ci_hi = unname(quantile(boot_coef, 0.975))
)
#> estimate boot_se ci_lo ci_hi
#> -5.3444716 0.7310559 -6.6952511 -4.0490296# same result, more cores; .seed is invariant to ncores
b2 <- frepeat(times = 200, .seed = 2024, simplify = TRUE, ncores = 2, expr = function() {
idx <- sample(nrow(mtcars), replace = TRUE)
coef(lm(mpg ~ wt, data = mtcars[idx, ]))[["wt"]]
})
identical(round(sd(boot_coef), 10), round(sd(b2), 10))
#> [1] TRUEPut the pieces together: a list of fit/predict pairs, cross-validated
on the same folds, robust to a learner that errors, reproducible via
.seed.
learners <- list(
lm_wt = function(tr) lm(mpg ~ wt, data = tr),
lm_wt_hp = function(tr) lm(mpg ~ wt + hp, data = tr),
lm_full = function(tr) lm(mpg ~ ., data = tr)
)
cv_rmse <- function(fit_fun) {
rows <- fcv(folds$splits, function(split) {
tr <- rsample::analysis(split); te <- rsample::assessment(split)
p <- predict(fit_fun(tr), newdata = te)
sqrt(mean((te$mpg - p)^2))
})
mean(unlist(rows))
}
bench_tbl <- fmap(learners, cv_rmse, .on_error = "fill", .fill = NA_real_)
data.frame(learner = names(bench_tbl), cv_rmse = round(unlist(bench_tbl), 3))
#> learner cv_rmse
#> lm_wt lm_wt 3.148
#> lm_wt_hp lm_wt_hp 2.786
#> lm_full lm_full 3.497Swap fmap(learners, cv_rmse, ...) for
fmap(learners, cv_rmse, ncores = 3, pb = TRUE) to run the
learners in parallel with a progress bar; the rest of the code is
unchanged.
torch, xgboost, keras,
database handles and similar hold external-pointer state that does not
survive a fork. Build such objects inside .f (so
each worker makes its own) rather than capturing them from the caller.
When that is not possible, keep that loop sequential
(ncores = NULL) and parallelise a different level: the
folds, the learners, or the bootstrap.