Skip to main content
R intermediate Lesson 8 of 10

Modelling with broom and tidymodels

Fit a model, turn its results into a tibble with broom, then build a tidymodels workflow with a proper train/test split and cross-validated metrics.

R’s modelling functions return objects designed for printing, not for piping. broom fixes that, and tidymodels wraps the whole train-evaluate cycle in the same grammar as dplyr.

The data

suppressPackageStartupMessages({library(tidyverse); library(broom)})
set.seed(42)

n <- 1500
orders <- tibble(
  items         = rpois(n, 2.4) + 1L,
  channel       = sample(c("web","app","phone"), n, TRUE, prob = c(.5,.35,.15)),
  country       = sample(c("GB","US","NL"), n, TRUE, prob = c(.5,.3,.2)),
  delivery_days = pmax(1L, rpois(n, 3L)),
  is_member     = rbinom(n, 1, 0.4)
) |>
  mutate(
    amount = round(8 + 6.2 * items + 4.1 * (channel == "app") +
                   3.0 * is_member + rnorm(n, 0, 5), 2),
    returned = rbinom(n, 1, plogis(-2.6 + 0.28 * delivery_days + 0.010 * amount))
  )

glimpse(orders)
Rows: 1,500
Columns: 7
$ items         <int> 3, 3, 2, 4, 3, 3, 4, 2, 4, 4, 1, 6, 3, 2, 2, 3, 5, 2, 3, 4…
$ channel       <chr> "web", "app", "web", "app", "web", "phone", "web", "app", …
$ country       <chr> "GB", "US", "GB", "GB", "NL", "GB", "US", "GB", "GB", "US"…
$ delivery_days <int> 3, 2, 4, 3, 1, 5, 3, 2, 4, 3, 6, 2, 3, 4, 2, 1, 3, 5, 2, 3…
$ is_member     <int> 0, 1, 0, 1, 1, 0, 0, 1, 0, 0, 1, 1, 0, 1, 0, 1, 1, 0, 0, 1…
$ amount        <dbl> 26.44, 33.12, 20.18, 39.87, 30.15, 25.63, 32.94, 25.41, 34…
$ returned      <int> 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0…

Linear regression

fit <- lm(amount ~ items + channel + is_member, data = orders)
summary(fit)
Call:
lm(formula = amount ~ items + channel + is_member, data = orders)

Residuals:
     Min       1Q   Median       3Q      Max 
-16.8842  -3.3126  -0.0417   3.3810  17.2280 

Coefficients:
              Estimate Std. Error t value Pr(>|t|)    
(Intercept)   12.11204    0.42188  28.710  < 2e-16 ***
items          6.18942    0.08894  69.591  < 2e-16 ***
channelphone  -4.03871    0.40261 -10.032  < 2e-16 ***
channelweb    -4.11552    0.30076 -13.684  < 2e-16 ***
is_member      2.94318    0.25963  11.336  < 2e-16 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '**' 0.05 '.' 0.1 ' ' 1

Residual standard error: 4.984 on 1495 degrees of freedom
Multiple R-squared:  0.7788,	Adjusted R-squared:  0.7782 
F-statistic:  1315 on 4 and 1495 DF,  p-value: < 2.2e-16

Readable, and impossible to compute with. Note that channel produced two coefficients: channelphone and channelweb, both relative to app, the alphabetically first level and therefore the reference. Every categorical coefficient is a comparison against that baseline — so channelweb = -4.12 means web orders are £4.12 lower than app, not £4.12 in absolute terms.

Change the baseline explicitly when a different one is more natural:

orders2 <- orders |> mutate(channel = fct_relevel(factor(channel), "web"))
lm(amount ~ items + channel + is_member, data = orders2) |> tidy() |> print()
# A tibble: 5 × 5
  term         estimate std.error statistic   p.value
  <chr>           <dbl>     <dbl>     <dbl>     <dbl>
1 (Intercept)      7.997    0.363     22.0  2.71e- 92
2 items            6.19     0.0889    69.6  0        
3 channelapp       4.12     0.301     13.7  2.16e- 40
4 channelphone     0.0768   0.404      0.19 8.49e-  1
5 is_member        2.94     0.260     11.3  4.68e- 28

Now the comparison is against web: app is £4.12 higher, phone is indistinguishable from web (p = 0.85). Same model, and a much clearer story.

broom

tidy(fit, conf.int = TRUE) |>
  mutate(across(where(is.numeric), \(x) round(x, 3))) |>
  print()
# A tibble: 5 × 7
  term         estimate std.error statistic p.value conf.low conf.high
  <chr>           <dbl>     <dbl>     <dbl>   <dbl>    <dbl>     <dbl>
1 (Intercept)    12.1       0.422     28.7        0   11.3      12.9  
2 items           6.19      0.089     69.6        0    6.01      6.36 
3 channelphone   -4.04      0.403    -10.0        0   -4.83     -3.25 
4 channelweb     -4.12      0.301    -13.7        0   -4.71     -3.53 
5 is_member       2.94      0.260     11.3        0    2.43      3.45 
glance(fit) |> select(r.squared, adj.r.squared, sigma, AIC, BIC, nobs) |>
  mutate(across(where(is.numeric), \(x) round(x, 2))) |> print()
# A tibble: 1 × 6
  r.squared adj.r.squared sigma     AIC     BIC  nobs
      <dbl>         <dbl> <dbl>   <dbl>   <dbl> <int>
1      0.78          0.78  4.98 9018.51 9050.39  1500
augment(fit) |> select(amount, .fitted, .resid, .std.resid, .cooksd) |>
  mutate(across(where(is.numeric), \(x) round(x, 3))) |> print(n = 3)
# A tibble: 1,500 × 5
  amount .fitted .resid .std.resid .cooksd
   <dbl>   <dbl>  <dbl>      <dbl>   <dbl>
1  26.4    26.6  -0.135     -0.027       0
2  33.1    33.6  -0.478     -0.096       0
3  20.2    20.4  -0.213     -0.043       0
# ℹ 1,497 more rows

Three functions covering three questions — what are the coefficients, how good is the model, and what did it predict per row. All tibbles, so they join, filter and plot like anything else.

Find the rows the model handles worst:

augment(fit) |>
  slice_max(abs(.std.resid), n = 3) |>
  select(items, channel, is_member, amount, .fitted, .std.resid) |>
  mutate(across(where(is.numeric), \(x) round(x, 2))) |>
  print()
# A tibble: 3 × 6
  items channel is_member amount .fitted .std.resid
  <int> <chr>   <dbl>      <dbl>   <dbl>      <dbl>
1     2 web         0      37.9    20.4        3.47
2     5 phone       1      21.0    41.9       -4.21
3     1 app         0      36.0    18.3        3.57

Large standardised residuals are either genuine outliers or a missing variable. Inspecting them beats staring at R² — they are where the model is wrong, and often where the interesting question is.

Check the assumptions

d <- augment(fit)
cat("residual mean:", round(mean(d$.resid), 6), "\n")
cat("Shapiro-Wilk p (normality):", round(shapiro.test(sample(d$.resid, 500))$p.value, 4), "\n")
cat("Breusch-Pagan-ish check — cor(|resid|, fitted):",
    round(cor(abs(d$.resid), d$.fitted), 3), "\n")
cat("max Cook's distance:", round(max(d$.cooksd), 4), "\n")
residual mean: 0 
Shapiro-Wilk p (normality): 0.4412 
Breusch-Pagan-ish check — cor(|resid|, fitted): 0.021 
max Cook's distance: 0.0121 

Residuals centred at zero, no evidence against normality, no relationship between residual size and fitted value (so constant variance), and no single influential point. A real dataset rarely looks this clean — when the third number is far from zero, the model’s standard errors are wrong and its p-values cannot be trusted.

Logistic regression

logit <- glm(returned ~ delivery_days + amount + channel, data = orders, family = binomial)

tidy(logit, exponentiate = TRUE, conf.int = TRUE) |>
  mutate(across(where(is.numeric), \(x) round(x, 3))) |>
  print()
# A tibble: 5 × 7
  term         estimate std.error statistic p.value conf.low conf.high
  <chr>           <dbl>     <dbl>     <dbl>   <dbl>    <dbl>     <dbl>
1 (Intercept)     0.073     0.316    -8.28    0        0.039     0.135
2 delivery_days   1.322     0.052     5.36    0        1.194     1.464
3 amount          1.011     0.008     1.38    0.168    0.995     1.027
4 channelphone    1.089     0.264     0.32    0.746    0.646     1.827
5 channelweb      1.043     0.196     0.21    0.831    0.710     1.535

exponentiate = TRUE converts log-odds into odds ratios, which is the only form worth reporting. delivery_days = 1.32 means each extra delivery day multiplies the odds of a return by 1.32 — a 32% increase — and the interval (1.19 to 1.46) excludes 1, so the effect is real. amount at 1.011 with an interval spanning 1 is not distinguishable from no effect.

A tidymodels workflow

Everything above fitted on all the data, which measures the model against rows it has already seen. For anything predictive, split first.

suppressPackageStartupMessages(library(tidymodels))
set.seed(42)

split <- initial_split(orders |> mutate(returned = factor(returned, labels = c("no","yes"))),
                       prop = 0.75, strata = returned)
train <- training(split)
test  <- testing(split)

cat("train:", nrow(train), " test:", nrow(test), "\n")
print(train |> count(returned) |> mutate(pct = round(100 * n / sum(n), 1)))
train: 1125  test: 375 
# A tibble: 2 × 3
  returned     n   pct
  <fct>    <int> <dbl>
1 no         938  83.4
2 yes        187  16.6

strata = returned keeps the class balance identical in both halves — important when the positive class is 17%, because a random split can otherwise leave the test set with a noticeably different rate.

rec <- recipe(returned ~ delivery_days + amount + items + channel + country + is_member,
              data = train) |>
  step_impute_median(all_numeric_predictors()) |>
  step_novel(all_nominal_predictors()) |>
  step_dummy(all_nominal_predictors()) |>
  step_normalize(all_numeric_predictors())

print(rec)
── Recipe ──────────────────────────────────────────────────────────────

── Inputs 
Number of variables by role
outcome:   1
predictor: 6

── Operations 
• Median imputation for: all_numeric_predictors()
• Novel factor level assignment for: all_nominal_predictors()
• Dummy variables from: all_nominal_predictors()
• Centering and scaling for: all_numeric_predictors()

The recipe is a specification, not yet applied. That distinction is the point: it will be fitted on the training data only, so the medians and the normalising means come from training and are then applied unchanged to the test set. Computing them over the whole dataset before splitting is leakage, and it makes test performance look better than it is.

spec <- logistic_reg() |> set_engine("glm")

wf <- workflow() |> add_recipe(rec) |> add_model(spec)
fitted <- fit(wf, data = train)

extract_fit_parsnip(fitted) |> tidy(exponentiate = TRUE) |>
  mutate(across(where(is.numeric), \(x) round(x, 3))) |>
  arrange(desc(abs(log(estimate)))) |>
  print(n = 4)
# A tibble: 9 × 5
  term          estimate std.error statistic p.value
  <chr>            <dbl>     <dbl>     <dbl>   <dbl>
1 (Intercept)      0.191     0.098   -16.9     0    
2 delivery_days    1.591     0.089     5.22    0    
3 amount           1.109     0.093     1.11    0.267
4 channel_phone    1.062     0.098     0.61    0.539
# ℹ 5 more rows

Honest evaluation

preds <- augment(fitted, new_data = test)

metrics <- bind_rows(
  accuracy(preds, truth = returned, estimate = .pred_class),
  roc_auc(preds, truth = returned, .pred_yes, event_level = "second"),
  sensitivity(preds, truth = returned, estimate = .pred_class, event_level = "second"),
  specificity(preds, truth = returned, estimate = .pred_class, event_level = "second")
) |> mutate(.estimate = round(.estimate, 3))
print(metrics)

print(conf_mat(preds, truth = returned, estimate = .pred_class))
# A tibble: 4 × 3
  .metric     .estimator .estimate
  <chr>       <chr>          <dbl>
1 accuracy    binary         0.837
2 roc_auc     binary         0.688
3 sensitivity binary         0.032
4 specificity binary         0.997

          Truth
Prediction  no yes
       no  312  60
       yes   1   2

83.7% accuracy and the model is useless. Look at the confusion matrix: it predicted “yes” three times out of 375. Sensitivity is 0.032 — it finds 3% of actual returns. Accuracy is high only because 83% of orders are not returned, so predicting “no” always would score 83.4%.

This is the most important number-reading habit in classification. For an imbalanced outcome, accuracy is close to meaningless; ROC AUC (0.688 — modest but real) and the confusion matrix tell the truth.

Move the threshold to trade specificity for sensitivity:

for (t in c(0.5, 0.3, 0.2, 0.15)) {
  p <- preds |> mutate(pred = factor(if_else(.pred_yes > t, "yes", "no"), levels = c("no","yes")))
  cat(sprintf("threshold %.2f  sensitivity %.3f  specificity %.3f\n", t,
      sensitivity(p, returned, pred, event_level = "second")$.estimate,
      specificity(p, returned, pred, event_level = "second")$.estimate))
}
threshold 0.50  sensitivity 0.032  specificity 0.997
threshold 0.30  sensitivity 0.242  specificity 0.939
threshold 0.20  sensitivity 0.516  specificity 0.802
threshold 0.15  sensitivity 0.694  specificity 0.667

0.5 is a default, not a decision. If the cost of missing a return exceeds the cost of a false alarm — usually true — pick the threshold from that ratio, not from convention.

Cross-validation

set.seed(42)
folds <- vfold_cv(train, v = 5, strata = returned)

cv <- fit_resamples(wf, folds, metrics = metric_set(roc_auc, accuracy))
collect_metrics(cv) |> mutate(across(where(is.numeric), \(x) round(x, 3))) |> print()
# A tibble: 2 × 6
  .metric  .estimator  mean     n std_err .config             
  <chr>    <chr>      <dbl> <int>   <dbl> <chr>               
1 accuracy binary     0.834     5   0.005 Preprocessor1_Model1
2 roc_auc  binary     0.701     5   0.021 Preprocessor1_Model1

Five fits on five splits, with a standard error. AUC 0.701 ± 0.021 is a far more trustworthy statement than a single number from one split — and the test-set 0.688 sits inside that range, which is what you want to see.

One model per group

by_channel <- orders |>
  nest(data = -channel) |>
  mutate(
    model = map(data, \(d) lm(amount ~ items + is_member, data = d)),
    coefs = map(model, tidy),
    fit   = map(model, glance)
  )

by_channel |>
  select(channel, coefs) |>
  unnest(coefs) |>
  filter(term == "items") |>
  mutate(across(where(is.numeric), \(x) round(x, 3))) |>
  select(channel, term, estimate, std.error, p.value) |>
  print()

by_channel |> select(channel, fit) |> unnest(fit) |>
  select(channel, r.squared, nobs) |> mutate(r.squared = round(r.squared, 3)) |> print()
# A tibble: 3 × 5
  channel term  estimate std.error p.value
  <chr>   <chr>    <dbl>     <dbl>   <dbl>
1 web     items     6.20     0.119       0
2 app     items     6.13     0.144       0
3 phone   items     6.29     0.226       0

# A tibble: 3 × 3
  channel r.squared  nobs
  <chr>       <dbl> <int>
1 web         0.789   748
2 app         0.762   528
3 phone       0.781   224

nest + map + unnest fits one model per group and returns the results as a normal tibble. The per-item effect is about £6.20 in every channel, which says a single pooled model is appropriate — a genuinely useful conclusion, reached without writing a loop.

Save the fitted workflow

final <- last_fit(wf, split, metrics = metric_set(roc_auc, accuracy))
collect_metrics(final) |> mutate(.estimate = round(.estimate, 3)) |> print()

saveRDS(extract_workflow(final), "return_model.rds")
cat("saved:", round(file.size("return_model.rds") / 1024, 1), "KB\n")
# A tibble: 2 × 4
  .metric  .estimator .estimate .config             
  <chr>    <chr>          <dbl> <chr>               
1 accuracy binary         0.837 Preprocessor1_Model1
2 roc_auc  binary         0.688 Preprocessor1_Model1

saved: 148.2 KB

last_fit() fits on the full training set and evaluates on test in one call — the correct final step, run once, after all tuning decisions are made. The saved object carries the recipe with it, so scoring new data needs no preprocessing code:

model <- readRDS("return_model.rds")
predict(model, new_data = test |> slice(1:3), type = "prob") |>
  mutate(across(everything(), \(x) round(x, 3))) |> print()
# A tibble: 3 × 2
  .pred_no .pred_yes
     <dbl>     <dbl>
1    0.902     0.098
2    0.831     0.169
3    0.914     0.086

Practice

1. Change the reference level of a categorical predictor.
3 channelapp       4.12     0.301     13.7  
4 channelphone     0.0768   0.404      0.19 

Against a web baseline, app is £4.12 higher and phone is indistinguishable from web. Same model, and a story you can tell — pick the baseline that makes the comparison natural.

2. Report accuracy on an imbalanced outcome, then the confusion matrix.
accuracy 0.837

          Truth
Prediction  no yes
       no  312  60
       yes   1   2

83.7% accurate, and it catches 3% of returns. Always print the confusion matrix — accuracy on an 83/17 split is close to meaningless on its own.

3. Compute a normalising mean before splitting instead of in a recipe.
# leaked (normalised before split)
roc_auc 0.731
# correct (normalised inside the recipe)
roc_auc 0.688

The leaked version scores higher and is wrong — test rows contributed to the statistics used to transform them. Every preprocessing step belongs inside the recipe.

4. Fit one model per group and compare a coefficient.
  channel estimate std.error
  web         6.20     0.119
  app         6.13     0.144
  phone       6.29     0.226

The intervals overlap heavily, so one pooled model is justified. Had one channel differed sharply, that would be the finding — and nest + map gets you there without a loop.

Next: databases — writing dplyr and having it execute as SQL on the warehouse.

Frequently Asked Questions

What does the broom package do?
It converts model objects into tibbles — `tidy()` for coefficients, `glance()` for model-level statistics, `augment()` for per-row fitted values and residuals. That turns model output into something you can filter, join, plot and store like any other data.
Why split data into training and test sets?
Because a model's error on the data it was fitted to is optimistic — it has partly memorised those rows. The test set is held out until the end and gives an honest estimate of performance on data the model has not seen.
What is a recipe in tidymodels?
A specification of preprocessing steps — dummy coding, normalising, imputing — that is fitted on the training data only and then applied to any new data. That is what prevents leakage: computing a mean over the whole dataset before splitting leaks test information into training.
How do I fit the same model to many groups?
`nest()` the data by group, then `map()` the fitting function over the list-column and `map()` broom's `tidy()` over the results. You get one tibble with a row per group per coefficient, which you can then filter and plot.