Preprocess with recipes
You have framed a problem, fit a regression, and trained a classifier. Real models need one more thing first: clean, model-ready data. Picture a lender sizing up twelve loan applicants: incomes run into the tens of thousands, ages sit near forty, one person's employment history is missing, and home is the words own, rent, or mortgage. No model can use that table as-is. A recipe is the tidymodels way to package the fix into one reusable object that learns from your training data and applies itself, identically, to everything else.
By the end of this lesson you will be able to:
- Say why raw columns (different scales, text categories, missing values) are not model-ready
- Explain preprocessing leakage, the quiet mistake that makes your scores look better than they are
- Build a recipe, learn its numbers from the training set with
prep(), and apply them to new data withbake()
Prerequisites: you can run R and use the |> pipe, and you have met the train/test split and why leakage matters.
Raw data is not model-ready
Meet our running example: a lender deciding who is likely to default. Twelve applicants, each with an income, an age, how many months they have been employed, and whether they own, rent, or have a mortgage.
Three things stop most models from using this as-is:
- Different scales. Income runs into the tens of thousands; age sits around forty. A model that measures distance (kNN, SVM) or penalizes coefficients (lasso, ridge) will be dominated by income purely because its numbers are bigger.
- Text categories.
homeis the words own, rent, mortgage. Most algorithms only do arithmetic, so a category has to become numbers. - A missing value. Omar's
employedisNA. Many models refuse to run with a gap in the data.
A recipe is how we fix all three, in one place, the right way.
The leakage trap
Here is the mistake almost everyone makes once. To put income and age on the same scale you standardize them: subtract the mean and divide by the standard deviation,
\[ z = \frac{x - \bar{x}}{s} \]
where \(x\) is one applicant's income, \(\bar{x}\) is the average income, and \(s\) is the standard deviation of income. The trap is hiding in \(\bar{x}\) and \(s\): they are numbers you have to estimate from data, and the only honest source for them is the training set. Compute them over the whole table and you have peeked at the rows you were supposed to hold back.
Those are different numbers. If you scale with 55250, that value was shaped partly by the test applicants, so a little information about them has leaked into how you prepare the training data.
This is exactly the bookkeeping a recipe does for you.
Spot the leak
You normalize income using the mean and standard deviation of the entire dataset, then split into train and test and fit a model. What is wrong?
A recipe is a blueprint
A recipe has two parts. First, recipe(formula, data = train) reads the column roles from a formula: what is the outcome, what are the predictors. Then you add step_* functions that declare the transformations, choosing columns with role-aware selectors like all_numeric_predictors() and all_nominal_predictors() instead of naming columns by hand.
The order matters: impute first (so there is no gap when we scale), then normalize, then dummy-code. But notice what has NOT happened yet. No median, no mean, no sd has been computed. The recipe is only a plan:
trained is FALSE for every step. A recipe is a list of instructions, like a cooking recipe written on a card. Nothing has been measured or mixed. That happens next.prep(): learn from the training set
prep() runs the recipe against the training data and records the actual numbers each step needs: the median to fill Omar's gap, the mean and sd to scale each column, the set of home levels. This is the single moment any learning happens, and it happens on train alone.
Look at the income mean: 56750. That is exactly mean(train$income) from before, not the leaky 55250 over the full table. The recipe used the training rows and nothing else.
prep() is where a recipe is fit, like fitting a model. The learned values (medians, means, sds, factor levels) are frozen into the prepped recipe so they can be reused, unchanged, on any future data.bake(): apply it to new data
bake() takes a prepped recipe and applies its frozen numbers to a dataset. Hand it the held-out applicants and every test row is scaled with the training mean and sd, any gap is filled with the training median, and home is expanded into indicator columns.
The text is gone: home became home_own and home_rent (the third level, mortgage, is the reference, so all-zero means mortgage). The numbers are centred near zero. No row was prepared using a statistic borrowed from itself.
prep), apply anywhere (bake). Because the numbers come only from training, the recipe is leak-free; because they are frozen into one object, it is reusable on test data, on tomorrow's applicants, and on every resample you will meet in later lessons. The one rule: prep() on data that is genuinely your training set, never on the full table, or the leak comes right back.prep versus bake
In a recipe workflow, what is the difference between prep() and bake()?
Finish the recipe
This recipe imputes and normalizes, but it never turns the home categories into numbers, so a model would choke on the text. Add the missing step that dummy-codes the nominal predictors, then check it.
Show answer
rec2 <- recipe(defaulted ~ income + age + employed + home, data = train) |>
step_impute_median(all_numeric_predictors()) |>
step_normalize(all_numeric_predictors()) |>
step_dummy(all_nominal_predictors())References
- recipes package documentation (tidymodels) - the official reference for
recipe(),prep(),bake(), and the role-aware selectors. - Tidy Modeling with R, ch. 8: Feature engineering with recipes - Kuhn and Silge's book chapter, the canonical walk-through of building recipes.
- Feature Engineering and Selection (Kuhn and Johnson) - the deeper why behind scaling, encoding, and imputation choices.
- The full catalog of step_ functions - every transformation you can compose, from
step_pca()tostep_other().
Lesson 1 complete
You can now spot preprocessing leakage and shut it down with a recipe: declare the steps once, prep() to learn the numbers from training data alone, bake() to apply them to anything. That single discipline, learn on train, apply anywhere, is the backbone of honest modeling.
Next, Lesson 2: Define models with parsnip. You will give that prepared data to a model through one consistent interface, so you can switch from a logistic regression to a random forest without rewriting your code, the perfect partner for the recipe you just built.