Course contents
Part IIChapter 13Supervised learning: the complete workflow
Regression: Predict a Number
On this page
1. Regression versus classification
Regression predicts a number, such as a systolic blood pressure (SBP) or time to appointment. Classification predicts a category, such as a synthetic Yes/No label. Start with regression because you can visualize the idea of “fitting a line.”
We will use age and BMI to predict fabricated SBP. Because our dataset was simulated with related formulas, the model may detect an association; nothing learned here should be interpreted as a physiological relationship.
Example: the type of the outcome column tells you which kind of problem you have.
d <- readr::read_csv("data/synthetic_ehr.csv", show_col_types = FALSE)
c(sbp = class(d$sbp), diabetes = class(d$diabetes))
Output
sbp diabetes
"numeric" "character" head(d[c("age", "bmi", "sbp")], 3)
Output
# A tibble: 3 x 3
age bmi sbp
<dbl> <dbl> <dbl>
1 55 20.4 120
2 26 22.7 110
3 63 24.7 133Reading the output: sbp is numeric, so predicting it is regression. diabetes is character (a category), so predicting it is classification. The three rows show the inputs (age, bmi) and the number we want to predict (sbp) side by side.
2. Start with a complete-case teaching example
To demonstrate the mechanics of a linear model, we will temporarily restrict this exercise to rows with observed SBP and BMI. Complete-case analysis is not universally appropriate. It can create selection bias. Chapter 12’s recipe is a safer starting place for missing predictors in our classification workflow.
library(tidymodels)
ehr <- readr::read_csv("data/synthetic_ehr.csv", show_col_types = FALSE)
numeric_data <- ehr |>
filter(!is.na(sbp), !is.na(bmi)) |>
select(age, bmi, sbp)
set.seed(500)
split <- initial_split(numeric_data, prop = 0.8)
train <- training(split)
test <- testing(split)
c(complete_rows = nrow(numeric_data), train_rows = nrow(train), test_rows = nrow(test))
Output
complete_rows train_rows test_rows
546 436 110 nrow(ehr) - nrow(numeric_data)
Output
[1] 54Reading the output: 54 rows were dropped because sbp or bmi was missing, leaving 546 complete rows. They are split 80/20 into 436 training rows and 110 test rows.
3. Train the simplest useful baseline
What if we ignored all predictors and always guessed the average training SBP? The result is a legitimate baseline.
baseline <- mean(train$sbp)
baseline_predictions <- rep(baseline, nrow(test))
baseline_rmse <- sqrt(mean((test$sbp - baseline_predictions)^2))
baseline_rmse
Output
[1] 11.36997RMSE (root mean squared error) measures the typical scale of prediction errors, giving extra weight to large mistakes. Its units are the units of SBP: mmHg.
4. Fit a linear model
model <- lm(sbp ~ age + bmi, data = train)
summary(model)
Output
Call:
lm(formula = sbp ~ age + bmi, data = train)
Residuals:
Min 1Q Median 3Q Max
-10.6734 -6.9637 -0.2211 7.1324 10.6276
Coefficients:
Estimate Std. Error t value Pr(>|t|)
(Intercept) 74.07774 1.76482 41.98 <2e-16 ***
age 0.53164 0.01777 29.91 <2e-16 ***
bmi 0.69944 0.04834 14.47 <2e-16 ***
---
Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
Residual standard error: 7.108 on 433 degrees of freedom
Multiple R-squared: 0.7164, Adjusted R-squared: 0.7151
F-statistic: 547 on 2 and 433 DF, p-value: < 2.2e-16pred <- predict(model, newdata = test)
head(data.frame(actual = test$sbp, predicted = pred))
Output
actual predicted
1 120 117.5865
2 120 110.5487
3 145 136.8506
4 111 107.5543
5 123 133.1997
6 119 108.5194Read the formula as: predict SBP using age and BMI. lm() estimates coefficients from training rows. It does not inherently establish causal effects. Beware of overinterpreting p-values or coefficients from data where the process and selection may be unknown.
5. Evaluate the model
rmse <- sqrt(mean((test$sbp - pred)^2))
mae <- mean(abs(test$sbp - pred))
c(baseline_rmse = baseline_rmse, model_rmse = rmse, model_mae = mae)
Output
baseline_rmse model_rmse model_mae
11.369968 6.932251 6.185452 MAE (mean absolute error) measures average absolute deviation in mmHg. Smaller errors are better, but whether an error is acceptable depends on the task and setting. A model that achieves lower test RMSE than baseline is an improvement on this synthetic split, not proof of clinical usefulness.
6. Plot observed and predicted values
ggplot(data.frame(observed = test$sbp, predicted = pred),
aes(x = observed, y = predicted)) +
geom_point() +
geom_abline(slope = 1, intercept = 0, colour = "red") +
labs(x = "Observed synthetic SBP (mmHg)",
y = "Predicted SBP (mmHg)")

The red line represents a perfect prediction. Dots far from it are larger errors. Check whether error sizes differ by groups; average performance may hide important weaknesses.
7. Is the dataset suitable?
The file has one row (one SBP value) per fabricated patient, so train and test share no patients. With repeated real visits, you would group the split by patient. A model predicting SBP from measurements taken after it would need a time-based data check.
Example: one check on the file, and one on what happens at prediction time.
n_distinct(ehr$patient_id) == nrow(ehr) # one row per patient?
Output
[1] TRUEnew_row <- data.frame(age = 60, bmi = NA_real_)
predict(model, newdata = new_row)
Output
1
NA Reading the output: TRUE means every row is a different patient, so the random split cannot put the same person in both parts. The second result is NA: predict() returns NA for a patient whose BMI is missing, and that is the gap the recipe in chapter 12 fills.
Try it
- Fit a model predicting SBP using age alone. Compare test RMSE with the age + BMI version.
Show solution to question 1
Answer: The age-only model has a test RMSE of 8.39 mmHg, worse than 6.93 mmHg for age + BMI. Both beat the constant baseline of 11.37 mmHg.
Step by step
- Fit
lm()with age as the only predictor, on the sametrainrows. - Predict for
testand compute RMSE the same way as in section 5.
age_only <- lm(sbp ~ age, data = train)
pred_age <- predict(age_only, newdata = test)
rmse_age <- sqrt(mean((test$sbp - pred_age)^2))
c(age_only = rmse_age, age_bmi = rmse, baseline = baseline_rmse)Output
age_only age_bmi baseline
8.393542 6.932251 11.369968 3. Read the three numbers in mmHg: smaller is better. Age alone cuts the error from 11.37 to 8.39, and adding BMI cuts it further to 6.93, so BMI helps on this split. 4. The comparison is on one test split, so a small difference between the two models may not repeat on another split.
- Calculate MAE for the constant baseline.
Show solution to question 2
Answer: The baseline MAE is 9.07 mmHg, larger than the age + BMI model’s 6.19 mmHg.
Step by step
- The baseline predicts the training mean for every test row, which is
baseline. - MAE is the mean of the absolute errors,
abs(actual - predicted).
baseline_mae <- mean(abs(test$sbp - baseline))
c(baseline_mae = baseline_mae, model_mae = mae)Output
baseline_mae model_mae
9.065263 6.185452 3. The first number (9.07) is the average miss of the “always guess the average” rule, and the second (6.19) is the average miss of the age + BMI model. The model is about 3 mmHg closer on average.
- Explain the units of the RMSE.
Show solution to question 3
Answer: The RMSE is in mmHg, the same unit as SBP, and it is a typical size of the prediction error.
Step by step
- Each error is
actual - predicted, in mmHg. - Squaring makes the unit mmHg squared, and the square root at the end brings it back to mmHg. That is why RMSE can be read directly.
errors <- test$sbp - pred
head(errors)Output
1 2 3 4 5 6
2.413544 9.451267 8.149416 3.445709 -10.199701 10.480611 c(rmse_mmHg = sqrt(mean(errors^2)))Output
rmse_mmHg
6.932251 3. The first lines show individual errors in mmHg (for example the first test row is 2.4 mmHg above its prediction). The last number says the model is typically off by about 6.9 mmHg. Because of the squaring, large errors count more than small ones.
- Explain why a low RMSE does not prove the predictors cause SBP.
Show solution to question 4
Answer: RMSE only says how close the predictions are. A predictor can help predict an outcome because of a shared cause, a selection effect, or the simulator’s formula, without causing it.
Step by step
- Prediction works in either direction. If age predicts SBP, SBP also predicts age:
cor_age_sbp <- cor(train$age, train$sbp)
rmse_age_from_sbp <- sqrt(mean((test$age - predict(lm(age ~ sbp, data = train), newdata = test))^2))
c(correlation = cor_age_sbp, rmse_age_from_sbp_in_years = rmse_age_from_sbp)Output
correlation rmse_age_from_sbp_in_years
0.7611245 13.3429037 2. The correlation is 0.76 whichever variable we call the outcome, and the reverse model predicts age with a typical error of about 13.3 years. Blood pressure obviously does not cause a person’s age, yet it predicts it, so predictive success cannot show direction of cause. 3. Causal claims need a design that separates causes (for example a trial or a careful causal analysis), and here the data are also simulated.
- What happens if a
testrow has missing BMI? Why do we need a recipe for a general workflow?
Show solution to question 5
Answer: predict() from lm() returns NA for that row, so no prediction is made. A recipe applies a stored rule (such as the training median BMI) to fill the gap, so every new row gets a prediction.
Step by step
- Make a copy of three test rows and blank the BMI of the first.
test_missing <- test[1:3, ]
test_missing$bmi[1] <- NA
predict(model, newdata = test_missing)Output
1 2 3
NA 110.5487 136.8506 2. The first prediction is NA, while the other two work. predict() returns NA for that row instead of guessing a value.
3. In chapter 12 a recipe’s step_impute_median() learns the median BMI from train and fills the gap when new rows arrive, in the same way every time. Putting the recipe and the model in one workflow means this happens automatically for each new row.
Checkpoint
You can define regression, fit lm(), predict on held-out observations, compare to a simple baseline, and distinguish predictive associations from causal claims.