Course contents
Part IIChapter 12Supervised learning: the complete workflow
Prepare Inputs Without Leakage
On this page
1. Different models need different input forms
Some algorithms cannot handle missing predictors. Some cannot directly work with text categories. Preprocessing may include imputation (filling missing predictors with estimates), encoding categories, rescaling, and removing constant columns. This is a modeling decision, not a license to invent measured facts.
Rule: A preprocessing statistic (like the training median) must be learned only on training data. During CV it must be learned within each analysis fold, not on all training rows before folding.
Example: take five typed systolic readings where one is missing. A model that cannot handle NA needs a number there. The median of the four known values is the simple choice.
sbp_small <- c(118, 125, NA, 140, 132)
median(sbp_small, na.rm = TRUE)
Output
[1] 128.5ifelse(is.na(sbp_small), median(sbp_small, na.rm = TRUE), sbp_small)
Output
[1] 118.0 125.0 128.5 140.0 132.0Reading the output: the median of 118, 125, 132 and 140 is 128.5, and the NA is replaced by that value while the others stay as they were. In a real project that median would be calculated from the training rows only.
2. A recipe is a plan of transformations
The recipes package (part of tidymodels) describes preprocessing. A workflow attaches this plan to a model so each resample follows the same leak-safe procedure.
library(tidymodels)
ehr <- readr::read_csv("data/synthetic_ehr.csv", show_col_types = FALSE) |>
mutate(
diabetes = factor(diabetes, levels = c("Yes", "No")),
sex = factor(sex),
clinic_site = factor(clinic_site),
family_history = factor(family_history)
)
set.seed(500)
split <- initial_split(ehr, prop = 0.8, strata = diabetes)
train <- training(split)
test <- testing(split)
Example: check what the split gave us before building the recipe.
c(train_rows = nrow(train), test_rows = nrow(test))
Output
train_rows test_rows
480 120 colSums(is.na(train[c("age", "bmi", "sbp", "pulse")]))
Output
age bmi sbp pulse
0 27 12 23 Reading the output: train has 480 rows and test has 120. In train, age has no missing values, while bmi, sbp and pulse each have some. Those are the gaps the recipe in the next section will fill.
3. Define exactly which features are allowed
rec <- recipe(
diabetes ~ age + bmi + sbp + pulse + family_history + sex + clinic_site,
data = train
) |>
step_impute_median(all_numeric_predictors()) |>
step_impute_mode(all_nominal_predictors()) |>
step_dummy(all_nominal_predictors()) |>
step_zv(all_predictors())
summary(rec)
Output
# A tibble: 8 x 4
variable type role source
<chr> <list> <chr> <chr>
1 age <chr [2]> predictor original
2 bmi <chr [2]> predictor original
3 sbp <chr [2]> predictor original
4 pulse <chr [2]> predictor original
5 family_history <chr [3]> predictor original
6 sex <chr [3]> predictor original
7 clinic_site <chr [3]> predictor original
8 diabetes <chr [3]> outcome originalReading the output: summary() lists the variables the recipe knows about and the role of each: diabetes is the outcome and the seven allowed columns are predictors. The steps have only been written down; nothing is calculated until prep() in the next section.
Line by line:
- The formula uses outcome
~allowed predictors. “~” means “model outcome using these inputs.” step_impute_median()replaces missing numeric predictor values using a median learned in the relevant training subset.step_impute_mode()uses the most frequent value for missing categorical predictors.step_dummy()turns categorical levels into numeric indicator columns.step_zv()removes variables with no variation, which cannot help distinguish outcomes.
We did not include patient_id, visit_date, or the outcome itself among the inputs. Missing outcome labels are fundamentally different from missing predictors and require separate research decisions.
4. See how training transformations are learned
trained_recipe <- prep(rec, training = train)
processed_train <- bake(trained_recipe, new_data = train)
processed_test <- bake(trained_recipe, new_data = test)
names(processed_train)
Output
[1] "age" "bmi" "sbp"
[4] "pulse" "diabetes" "family_history_X1"
[7] "sex_M" "clinic_site_North" "clinic_site_South"colSums(is.na(processed_train))
Output
age bmi sbp pulse
0 0 0 0
diabetes family_history_X1 sex_M clinic_site_North
0 0 0 0
clinic_site_South
0 The trained_recipe object holds preprocessing rules learned from train; bake applies those same rules elsewhere. The test-set values never chose the imputation medians. Note that this explicit prep() example is explanatory; in later CV, workflows re-prep the recipe inside each fold.
5. Should we always impute?
No. Median imputation is a simple baseline with assumptions. Missingness may be informative or systematic; a single median can distort variation and subgroup behavior. Compare strategies on training folds, document missingness by site/group, and consider missingness indicator features when justified. Methods like MICE require careful outcome/time handling and resampling integration; do not run a global imputation on the entire dataset before splitting.
Example: look at where BMI is missing before deciding that a single median is fine.
train |>
group_by(clinic_site) |>
summarise(n = n(), missing_bmi = sum(is.na(bmi)),
share_missing = round(mean(is.na(bmi)), 3))
Output
# A tibble: 3 x 4
clinic_site n missing_bmi share_missing
<fct> <int> <int> <dbl>
1 Mobile 166 9 0.054
2 North 163 9 0.055
3 South 151 9 0.06 Reading the output: the table gives, for each site, how many training rows it has and how many have no BMI. Here the shares are 5.4%, 5.5% and 6.0%, so missingness is about the same at every site and one overall median is a reasonable simple choice. If one site had a much higher share missing than the others, the missingness would be tied to the site, and one overall median could distort that site. Roughly equal shares would be less worrying, though still worth documenting.
6. Scaling and outliers
Some algorithms depend strongly on variable scales (K-means, PCA, regularized regression); many tree models do not. We have not added step_normalize() because it is not necessary for our basic logistic regression or tree examples. If needed, add it inside the recipe, with its parameters learned on training folds.
Example: add scaling to a copy of the recipe and look at the result for age.
rec_scaled <- rec |> step_normalize(all_numeric_predictors())
scaled_train <- bake(prep(rec_scaled, training = train), new_data = train)
summary(train$age)
Output
Min. 1st Qu. Median Mean 3rd Qu. Max.
18.00 34.00 50.00 50.17 66.25 83.00 summary(scaled_train$age)
Output
Min. 1st Qu. Median Mean 3rd Qu. Max.
-1.697169 -0.853036 -0.008903 0.000000 0.848419 1.732121 Reading the output: the first summary is in years (roughly 18 to 83). The second is in standard deviations, centered at 0, which is the scale that K-means or regularized regression would see. The ordering of patients has not changed; only the units have.
Never remove clinically unusual measurements simply because they do not match an average. Check units, plausible ranges, and source records with authorized staff.
7. Skewed variables and transformations
A variable is right-skewed when most values sit at the low end and a long tail stretches to the high end. Lab values such as triglycerides, costs, and counts of visits often look like this. Methods that work with means and variances (linear regression, PCA, K-means) can be pulled around by a few very large values, so a transformation that pulls the tail in is sometimes worth trying.
The synthetic ehr table has no strongly skewed column, so here we make up a small lab table of triglyceride values (mg/dL) with a long right tail.
set.seed(12)
lab <- data.frame(triglycerides = round(rlnorm(400, meanlog = log(120), sdlog = 0.6)))
par(mfrow = c(1, 2))
hist(lab$triglycerides, main = "Raw", xlab = "Triglycerides")
hist(log(lab$triglycerides), main = "After log()", xlab = "log(triglycerides)")

par(mfrow = c(1, 1))
The left histogram leans to the left with a long right tail; the right one, after log(), is close to symmetric.
Skewness is a single number for this lean. Standardize the variable (subtract the mean, divide by the standard deviation), cube each value, and take the mean. A value near 0 means symmetric, a clearly positive value means a right tail, and a negative value means a left tail.
skew <- function(x) mean(((x - mean(x)) / sd(x))^3)
c(raw = skew(lab$triglycerides),
log = skew(log(lab$triglycerides)))
Output
raw log
1.6334226 -0.0134994 The raw values give a clearly positive number (about 1.6 on this seed) and the logged values give a number near 0. The e1071 package has e1071::skewness(). Its default (type = 3) gives the same number as this formula; type = 1 divides by n in the standard deviation and gives a slightly different number.
log() works only for values above 0. If zeros are possible (counts, for example), log1p(x), which is log(1 + x), is the usual fix. If the variable can be zero or negative, or you do not want to choose the transformation yourself, step_YeoJohnson() in a recipe picks the best power from the training data. Like every step in this chapter, it learns its setting (here a power called lambda) from the training rows only and then applies the same setting to new rows.
idx <- sample(nrow(lab), 300)
lab_train <- lab[idx, , drop = FALSE]
lab_test <- lab[-idx, , drop = FALSE]
rec_yj <- recipe(~ triglycerides, data = lab_train) |>
step_YeoJohnson(triglycerides)
prepped_yj <- prep(rec_yj, training = lab_train)
tidy(prepped_yj, number = 1) # the lambda learned from training rows
Output
# A tibble: 1 x 3
terms value id
<chr> <dbl> <chr>
1 triglycerides 0.0373 YeoJohnson_GTxr1test_yj <- bake(prepped_yj, new_data = lab_test)
c(before = skew(lab_test$triglycerides),
after = skew(test_yj$triglycerides))
Output
before after
1.7880443 0.2977128 The value column is the lambda learned from lab_train. Applied to lab_test, the same lambda reduces the skewness from about 1.8 to about 0.3, although the test rows did not choose it.
When not to transform. Leave a variable alone when it is already roughly symmetric (like age, bmi, sbp and pulse here), when the model does not care (trees split on order, so any monotone transform gives the same tree), or when you need results in the original units, such as a coefficient per mg/dL. A transformed variable is also harder to explain to clinicians: say clearly when a result is on the log scale. And do not log a variable to hide a few extreme values that are really data-entry errors; check those first.
Try it
- Count missing BMI and SBP in
trainand intestseparately.
Show solution to question 1
Answer: train has 27 missing BMI and 12 missing SBP values; test has 8 missing BMI and 8 missing SBP values.
Step by step
is.na()gives TRUE for a missing value.sum()counts the TRUE values.- Apply it to each column of each part.
c(train_missing_bmi = sum(is.na(train$bmi)),
test_missing_bmi = sum(is.na(test$bmi)),
train_missing_sbp = sum(is.na(train$sbp)),
test_missing_sbp = sum(is.na(test$sbp)))Output
train_missing_bmi test_missing_bmi train_missing_sbp test_missing_sbp
27 8 12 8 3. Read each name as part and column. The BMI pair adds up to 27 + 8 = 35 and the SBP pair to 12 + 8 = 20, the totals for the full file, so no missing value was lost in the split. The recipe will fill the test values using a median from train.
- Add a
step_normalize(all_numeric_predictors())to a copy of the recipe. Explain why it must be trained only ontrain.
Show solution to question 2
Answer: step_normalize() subtracts a mean and divides by a standard deviation. Those two numbers must come from train only, because numbers computed from the test rows would let the test set shape the preprocessing.
Step by step
- Copy the recipe and add the step at the end. The original
recstays unchanged.
rec_norm <- rec |>
step_normalize(all_numeric_predictors())
prepped_norm <- prep(rec_norm, training = train)
norm_train <- bake(prepped_norm, new_data = train)
norm_test <- bake(prepped_norm, new_data = test)- Check the result for
age: the training mean and standard deviation, then the test values scaled with the training numbers.
c(train_mean = mean(norm_train$age), train_sd = sd(norm_train$age),
test_mean = mean(norm_test$age), test_sd = sd(norm_test$age))Output
train_mean train_sd test_mean test_sd
-1.502302e-16 1.000000e+00 8.210510e-02 1.023569e+00 3. In train the mean is 0 (the printed -1.5e-16 is rounding error) and the standard deviation is 1, by construction. In test they are 0.08 and 1.02: close to 0 and 1 but not exact, because the test rows were scaled with the training mean and standard deviation. That is the correct behavior: new patients are scaled the way the model has learned. If we had computed the numbers on the test rows, the test set would have influenced the pipeline.
- Explain the difference between a missing predictor and a missing outcome label.
Show solution to question 3
Answer: A missing predictor is a gap in an input, and we can fill it with a rule learned from the training rows. A missing outcome label means we do not know the truth for that row, so it cannot be scored or used to train a normal supervised model, and filling it with an estimate would make up the answer.
Step by step
- Count missing values in a predictor and in the outcome.
c(missing_bmi = sum(is.na(ehr$bmi)),
missing_outcome = sum(is.na(ehr$diabetes)))Output
missing_bmi missing_outcome
35 0 2. bmi has missing values, and the recipe handles them with step_impute_median(). The outcome has none in this fabricated file.
3. If the outcome had NA values, imputing them would be inventing labels. Those rows would be set aside (or the problem treated as unlabeled-data learning, see chapter 9), and the decision recorded.
- Print the processed column names and identify dummy columns.
Show solution to question 4
Answer: The dummy columns are the ones whose names end in a level of a categorical variable, sex_M, clinic_site_North, clinic_site_South and family_history_X1. (family_history is stored as 0/1, and factor() turned it into levels that step_dummy() names X0 and X1.)
Step by step
- Print the names from the baked training data.
names(processed_train)Output
[1] "age" "bmi" "sbp"
[4] "pulse" "diabetes" "family_history_X1"
[7] "sex_M" "clinic_site_North" "clinic_site_South"2. Numeric columns (age, bmi, sbp, pulse) keep their names, and diabetes is the outcome.
3. Pick out the dummy columns by their naming pattern: variable name, underscore, level.
grep("^(sex|clinic_site|family_history)_", names(processed_train), value = TRUE)Output
[1] "family_history_X1" "sex_M" "clinic_site_North"
[4] "clinic_site_South"4. One level of each category is left out as the reference: F for sex, Mobile for site and X0 for family history. A row with sex_M equal to 0 is female, and a row with both site columns equal to 0 is from the Mobile site.
- State why using the full dataset median before splitting would contaminate the evaluation.
Show solution to question 5
Answer: The full-data median is partly computed from the test rows, so test information flows into the training preprocessing. The test score is then slightly too optimistic, and the test set is no longer fully unseen.
Step by step
- Compute the median BMI both ways.
c(full_data_median = median(ehr$bmi, na.rm = TRUE),
train_only_median = median(train$bmi, na.rm = TRUE))Output
full_data_median train_only_median
30.2 30.5 2. The full-data median is 30.2 and the training-only median is 30.5. The gap is small here, but the point is the rule: the first used test values, the second did not.
3. With other statistics (a mean, a scaling factor, a selected feature list) the leak can change the results by more. Learn every statistic from train and apply it to test.
- Compute the skewness of
ageandbmiintrainwith the one-line formula (drop missing values first) and say whether either needs a transformation.
Show solution to question 6
Answer: The skewness of age is about 0.02 and of bmi about 0.003, both close to 0, so neither needs a transformation.
Step by step
- Reuse the
skew()function from section 7. Use onlytrain, since a transformation decision is a preprocessing decision and must not use the test rows. Drop the missing values withna.omit()so the mean and standard deviation can be computed.
skew <- function(x) mean(((x - mean(x)) / sd(x))^3)
c(age = skew(na.omit(train$age)), bmi = skew(na.omit(train$bmi)))Output
age bmi
0.021465863 0.002659681 2. Read the numbers: values near 0 mean a roughly symmetric distribution; a clearly positive number would mean a long right tail. 3. Here 0.02 and 0.003 are both tiny, so we leave both variables alone.
- Make a right-skewed vector with
rexp(300), compare its skewness afterlog1p()and afterstep_YeoJohnson()fitted on 200 training rows, and say which rows chose lambda.
Show solution to question 7
Answer: On the 100 held-out rows the skewness falls from about 1.29 (raw) to about 0.50 after log1p() and to about -0.09 after Yeo-Johnson. Lambda was chosen from the 200 training rows only; the 100 test rows were only transformed.
Step by step
- Make the vector and split it into 200 training and 100 test rows.
set.seed(7)
v <- data.frame(v = rexp(300))
v_train <- v[1:200, , drop = FALSE]
v_test <- v[201:300, , drop = FALSE]- Fit Yeo-Johnson on the training rows, then apply it to the test rows. Compare skewness on the test rows for the raw,
log1p()and Yeo-Johnson versions.
yj <- prep(recipe(~ v, data = v_train) |> step_YeoJohnson(v), training = v_train)
tidy(yj, number = 1)Output
# A tibble: 1 x 3
terms value id
<chr> <dbl> <chr>
1 v -0.820 YeoJohnson_06BjGc(raw = skew(v_test$v),
log1p = skew(log1p(v_test$v)),
yeo_johnson = skew(bake(yj, new_data = v_test)$v))Output
raw log1p yeo_johnson
1.29300663 0.50322467 -0.09373028 3. The tidy() table shows the lambda that prep() learned from v_train. The lambda is about -0.82. In the second output, raw is clearly positive (1.29), log1p is closer to 0 (0.50), and Yeo-Johnson is closest (-0.09).
4. Only v_train was used to learn lambda, so the test result is a fair check.
Checkpoint
You can describe imputation, dummy variables, and preprocessing pipelines, and know why every learned transformation belongs within training resamples.