Pipelines, Selectors, Branches

Build a preprocessing pipeline with branching.

Authors

Milan Dragicevic

Giuseppe Casalicchio

Published

April 23, 2020

Intro

mlr3pipelines offers a very flexible way to create data preprocessing steps. This is achieved by a modular approach using PipeOps. For detailed overview check the mlr3book.

Recommended prior readings:

This post covers:

  1. How to apply different preprocessing steps on different features
  2. How to branch different preprocessing steps, which allows to select the best performing path
  3. How to tune the whole pipeline

Prerequisites

We load the mlr3verse package which pulls in the most important packages for this example.

library(mlr3verse)

We initialize the random number generator with a fixed seed for reproducibility, and decrease the verbosity of the logger to keep the output clearly represented.

set.seed(7832)
lgr::get_logger("mlr3")$set_threshold("warn")
lgr::get_logger("bbotk")$set_threshold("warn")

The diabetes classification task will be used.

task_diabetes = tsk("diabetes")
skimr::skim(task_diabetes$data())
Data summary
Name task_diabetes$data()
Number of rows 768
Number of columns 9
Key NULL
_______________________
Column type frequency:
factor 1
numeric 8
________________________
Group variables None

Variable type: factor

skim_variable n_missing complete_rate ordered n_unique top_counts
diabetes 0 1 FALSE 2 neg: 488, pos: 280

Variable type: numeric

skim_variable n_missing complete_rate mean sd p0 p25 p50 p75 p100 hist
age 0 1.00 33.05 11.94 21.00 24.00 28.00 41.00 81.00 ▇▂▁▁▁
glucose 5 0.99 125.30 31.59 57.00 101.00 119.00 144.50 199.00 ▁▇▆▃▂
insulin 405 0.47 159.05 123.34 14.00 76.00 125.00 194.00 846.00 ▇▂▁▁▁
mass 13 0.98 32.87 7.02 18.20 27.80 32.80 36.95 59.40 ▃▇▅▁▁
pedigree 0 1.00 0.48 0.36 0.08 0.24 0.37 0.64 2.42 ▇▃▁▁▁
pregnant 0 1.00 4.58 3.88 0.00 1.00 3.00 7.00 13.00 ▇▆▃▂▂
pressure 35 0.95 72.59 12.19 24.00 64.00 72.00 80.00 108.00 ▁▁▇▆▁
triceps 251 0.67 29.46 9.96 7.00 22.00 30.00 36.00 63.00 ▃▇▇▃▁

Selection of features for preprocessing steps

Several features of the diabetes task have missing values:

task_diabetes$missings()
diabetes      age  glucose  insulin     mass pedigree pregnant pressure  triceps 
       0        0        5      405       13        0        0       35      251 

A common approach in such situations is to impute the missing values and to add a missing indicator column as explained in the Impute missing variables post. Suppose we want to use

In the following subsections, we show two approaches to implement this.

1. Consider all features and apply the preprocessing step only to certain features

Using the affect_columns argument of a PipeOp to define the variables on which a PipeOp will operate with an appropriate Selector function:

# imputes values based on histogram
imputer_hist = po("imputehist",
  affect_columns = selector_name(c("glucose", "mass", "pressure")))
# imputes values using the median
imputer_median = po("imputemedian",
  affect_columns = selector_name(c("insulin", "triceps")))
# adds an indicator column for each feature with missing values
miss_ind = po("missind")

When PipeOps are constructed this way, they will perform the specified preprocessing step on the appropriate features and pass all the input features to the subsequent steps:

# no missings in "glucose", "mass" and "pressure"
imputer_hist$train(list(task_diabetes))[[1]]$missings()
diabetes      age  insulin pedigree pregnant  triceps  glucose     mass pressure 
       0        0      405        0        0      251        0        0        0 
# no missings in "insulin" and "triceps"
imputer_median$train(list(task_diabetes))[[1]]$missings()
diabetes      age  glucose     mass pedigree pregnant pressure  insulin  triceps 
       0        0        5       13        0        0       35        0        0 

We construct a pipeline that combines imputer_hist and imputer_median. Here, imputer_hist will impute the features “glucose”, “mass” and “pressure”, and imputer_median will impute “insulin” and “triceps”. In each preprocessing step, all the input features are passed to the next step. In the end, we obtain a data set without missing values:

# combine the two impuation methods
impute_graph = imputer_hist %>>% imputer_median
impute_graph$plot(html = FALSE)

impute_graph$train(task_diabetes)[[1]]$missings()
diabetes      age pedigree pregnant  glucose     mass pressure  insulin  triceps 
       0        0        0        0        0        0        0        0        0 

The PipeOpMissInd operator replaces features with missing values with a missing value indicator:

miss_ind$train(list(task_diabetes))[[1]]$data()
     diabetes missing_glucose missing_insulin missing_mass missing_pressure missing_triceps
       <fctr>          <fctr>          <fctr>       <fctr>           <fctr>          <fctr>
  1:      neg         present         missing      present          present         present
  2:      pos         present         missing      present          present         missing
  3:      neg         present         missing      present          present         present
  4:      neg         present         missing      present          present         missing
  5:      neg         present         missing      present          present         present
 ---                                                                                       
764:      pos         present         missing      present          present         present
765:      pos         present         present      present          present         present
766:      neg         present         present      present          present         present
767:      neg         present         missing      present          present         missing
768:      neg         present         missing      present          present         missing

Obviously, this step can not be applied to the already imputed data as there are no missing values. If we want to combine the previous two imputation steps with a third step that adds missing value indicators, we would need to PipeOpCopy the data two times and supply the first copy to impute_graph and the second copy to miss_ind using gunion(). Finally, the two outputs can be combined with PipeOpFeatureUnion:

impute_missind = po("copy", 2) %>>%
  gunion(list(impute_graph, miss_ind)) %>>%
  po("featureunion")
impute_missind$plot(html = FALSE)

impute_missind$train(task_diabetes)[[1]]$data()
     diabetes   age pedigree pregnant glucose  mass pressure insulin triceps missing_glucose missing_insulin
       <fctr> <num>    <num>    <num>   <num> <num>    <num>   <num>   <num>          <fctr>          <fctr>
  1:      neg    28    0.240        0     194  28.5       50     125      45         present         missing
  2:      pos    44    0.828        8     129  27.6       84     125      30         present         missing
  3:      neg    26    0.696        2     132  38.2       72     125      42         present         missing
  4:      neg    39    0.192        6     184  24.7       62     125      30         present         missing
  5:      neg    22    0.178        4     108  31.6       58     125      32         present         missing
 ---                                                                                                        
764:      pos    43    0.346       10      92  33.1       50     125      28         present         missing
765:      pos    26    1.162        0     181  35.8       58     474      27         present         present
766:      neg    60    0.460        6     130  34.2      106     540      27         present         present
767:      neg    24    0.206        2     146  27.7       72     125      30         present         missing
768:      neg    41    0.187        6     115  27.6       72     125      30         present         missing
     missing_mass missing_pressure missing_triceps
           <fctr>           <fctr>          <fctr>
  1:      present          present         present
  2:      present          present         missing
  3:      present          present         present
  4:      present          present         missing
  5:      present          present         present
 ---                                              
764:      present          present         present
765:      present          present         present
766:      present          present         present
767:      present          present         missing
768:      present          present         missing

2. Select the features for each preprocessing step and apply the preprocessing steps to this subset

We can use the PipeOpSelect to select the appropriate features and then apply the desired impute PipeOp on them:

imputer_hist_2 = po("select",
  selector = selector_name(c("glucose", "mass", "pressure")),
  id = "slct1") %>>% # unique id so we can combine it in a pipeline with other select PipeOps
  po("imputehist")

imputer_hist_2$plot(html = FALSE)

imputer_hist_2$train(task_diabetes)[[1]]$data()
     diabetes glucose  mass pressure
       <fctr>   <num> <num>    <num>
  1:      neg     194  28.5       50
  2:      pos     129  27.6       84
  3:      neg     132  38.2       72
  4:      neg     184  24.7       62
  5:      neg     108  31.6       58
 ---                                
764:      pos      92  33.1       50
765:      pos     181  35.8       58
766:      neg     130  34.2      106
767:      neg     146  27.7       72
768:      neg     115  27.6       72
imputer_median_2 =
  po("select", selector = selector_name(c("insulin", "triceps")), id = "slct2") %>>%
  po("imputemedian")

imputer_median_2$train(task_diabetes)[[1]]$data()
     diabetes insulin triceps
       <fctr>   <num>   <num>
  1:      neg     125      45
  2:      pos     125      30
  3:      neg     125      42
  4:      neg     125      30
  5:      neg     125      32
 ---                         
764:      pos     125      28
765:      pos     474      27
766:      neg     540      27
767:      neg     125      30
768:      neg     125      30

To reproduce the result of the fist example (1.), we need to copy the data four times and apply imputer_hist_2, imputer_median_2 and miss_ind on each of the three copies. The fourth copy is required to select the features without missing values and to append it to the final result. We can do this as follows:

other_features = task_diabetes$feature_names[task_diabetes$missings()[-1] == 0]

imputer_missind_2 = po("copy", 4) %>>%
  gunion(list(imputer_hist_2,
    imputer_median_2,
    miss_ind,
    po("select", selector = selector_name(other_features), id = "slct3"))) %>>%
  po("featureunion")

imputer_missind_2$plot(html = FALSE)

imputer_missind_2$train(task_diabetes)[[1]]$data()
     diabetes glucose  mass pressure insulin triceps missing_glucose missing_insulin missing_mass missing_pressure
       <fctr>   <num> <num>    <num>   <num>   <num>          <fctr>          <fctr>       <fctr>           <fctr>
  1:      neg     194  28.5       50     125      45         present         missing      present          present
  2:      pos     129  27.6       84     125      30         present         missing      present          present
  3:      neg     132  38.2       72     125      42         present         missing      present          present
  4:      neg     184  24.7       62     125      30         present         missing      present          present
  5:      neg     108  31.6       58     125      32         present         missing      present          present
 ---                                                                                                              
764:      pos      92  33.1       50     125      28         present         missing      present          present
765:      pos     181  35.8       58     474      27         present         present      present          present
766:      neg     130  34.2      106     540      27         present         present      present          present
767:      neg     146  27.7       72     125      30         present         missing      present          present
768:      neg     115  27.6       72     125      30         present         missing      present          present
     missing_triceps   age pedigree pregnant
              <fctr> <num>    <num>    <num>
  1:         present    28    0.240        0
  2:         missing    44    0.828        8
  3:         present    26    0.696        2
  4:         missing    39    0.192        6
  5:         present    22    0.178        4
 ---                                        
764:         present    43    0.346       10
765:         present    26    1.162        0
766:         present    60    0.460        6
767:         missing    24    0.206        2
768:         missing    41    0.187        6

Note that when there is one input channel, it is automatically copied as many times as needed for the downstream PipeOps. In other words, the code above works also without po("copy", 4):

imputer_missind_3 = gunion(list(imputer_hist_2,
  imputer_median_2,
  miss_ind,
  po("select", selector = selector_name(other_features), id = "slct3"))) %>>%
  po("featureunion")

imputer_missind_3$train(task_diabetes)[[1]]$data()
     diabetes glucose  mass pressure insulin triceps missing_glucose missing_insulin missing_mass missing_pressure
       <fctr>   <num> <num>    <num>   <num>   <num>          <fctr>          <fctr>       <fctr>           <fctr>
  1:      neg     194  28.5       50     125      45         present         missing      present          present
  2:      pos     129  27.6       84     125      30         present         missing      present          present
  3:      neg     132  38.2       72     125      42         present         missing      present          present
  4:      neg     184  24.7       62     125      30         present         missing      present          present
  5:      neg     108  31.6       58     125      32         present         missing      present          present
 ---                                                                                                              
764:      pos      92  33.1       50     125      28         present         missing      present          present
765:      pos     181  35.8       58     474      27         present         present      present          present
766:      neg     130  34.2      106     540      27         present         present      present          present
767:      neg     146  27.7       72     125      30         present         missing      present          present
768:      neg     115  27.6       72     125      30         present         missing      present          present
     missing_triceps   age pedigree pregnant
              <fctr> <num>    <num>    <num>
  1:         present    28    0.240        0
  2:         missing    44    0.828        8
  3:         present    26    0.696        2
  4:         missing    39    0.192        6
  5:         present    22    0.178        4
 ---                                        
764:         present    43    0.346       10
765:         present    26    1.162        0
766:         present    60    0.460        6
767:         missing    24    0.206        2
768:         missing    41    0.187        6

Usually, po("copy") is required when there are more than one input channels and multiple output channels, and their numbers do not match.

Branching

We can not know if the combination of a learner with this preprocessing graph will benefit from the imputation steps and the added missing value indicators. Maybe it would have been better to just use imputemedian on all the variables. We could investigate this assumption by adding an alternative path to the graph with the mentioned imputemedian. This is possible using the “branch” PipeOp:

imputer_median_3 = po("imputemedian", id = "simple_median") # add the id so it does not clash with `imputer_median`

branches = c("impute_missind", "simple_median") # names of the branches

graph_branch = po("branch", branches) %>>%
  gunion(list(impute_missind, imputer_median_3)) %>>%
  po("unbranch")

graph_branch$plot(html = FALSE)

Tuning the pipeline

To finalize the graph, we combine it with a rpart learner:

graph = graph_branch %>>%
  lrn("classif.rpart")

graph$plot(html = FALSE)

To define the parameters to be tuned, we first check the available ones in the graph:

as.data.table(graph$param_set)[, .(id, class, lower, upper, nlevels)]
                              id    class lower upper nlevels
                          <char>   <char> <num> <num>   <num>
 1:             branch.selection ParamFct    NA    NA       2
 2:    imputehist.affect_columns ParamUty    NA    NA     Inf
 3:  imputemedian.affect_columns ParamUty    NA    NA     Inf
 4:                missind.which ParamFct    NA    NA       2
 5:                 missind.type ParamFct    NA    NA       4
 6:       missind.affect_columns ParamUty    NA    NA     Inf
 7: simple_median.affect_columns ParamUty    NA    NA     Inf
 8:             classif.rpart.cp ParamDbl     0     1     Inf
 9:     classif.rpart.keep_model ParamLgl    NA    NA       2
10:     classif.rpart.maxcompete ParamInt     0   Inf     Inf
11:       classif.rpart.maxdepth ParamInt     1    30      30
12:   classif.rpart.maxsurrogate ParamInt     0   Inf     Inf
13:      classif.rpart.minbucket ParamInt     1   Inf     Inf
14:       classif.rpart.minsplit ParamInt     1   Inf     Inf
15: classif.rpart.surrogatestyle ParamInt     0     1       2
16:   classif.rpart.usesurrogate ParamInt     0     2       3
17:           classif.rpart.xval ParamInt     0   Inf     Inf

We decide to jointly tune the "branch.selection", "classif.rpart.cp" and "classif.rpart.minbucket" hyperparameters:

search_space = ps(
  branch.selection = p_fct(c("impute_missind", "simple_median")),
  classif.rpart.cp = p_dbl(0.001, 0.1),
  classif.rpart.minbucket = p_int(1, 10))

In order to tune the graph, it needs to be converted to a learner:

graph_learner = as_learner(graph)

cv3 = rsmp("cv", folds = 3)

cv3$instantiate(task_diabetes) # to generate folds for cross validation

instance = tune(
  tuner = tnr("random_search"),
  task = task_diabetes,
  learner = graph_learner,
  resampling = rsmp("cv", folds = 3),
  measure = msr("classif.ce"),
  search_space = search_space,
  term_evals = 5)

as.data.table(instance$archive, unnest = NULL, exclude_columns = c("x_domain", "uhash", "resample_result"))
   branch.selection classif.rpart.cp classif.rpart.minbucket classif.ce runtime_learners           timestamp warnings
             <char>            <num>                   <int>      <num>            <num>              <POSc>    <int>
1:    simple_median       0.06039370                       4  0.2799479            0.458 2026-11-04 19:42:27        0
2:   impute_missind       0.08838643                       3  0.2812500            0.980 2026-11-04 19:42:28        0
3:    simple_median       0.06849620                       4  0.2799479            0.446 2026-11-04 19:42:28        0
4:   impute_missind       0.01160007                       3  0.2981771            0.808 2026-11-04 19:42:28        0
5:    simple_median       0.08184096                       4  0.2799479            0.445 2026-11-04 19:42:29        0
   errors batch_nr
    <int>    <int>
1:      0        1
2:      0        2
3:      0        3
4:      0        4
5:      0        5

The best performance in this short tuned experiment was achieved with:

instance$result
   branch.selection classif.rpart.cp classif.rpart.minbucket learner_param_vals  x_domain classif.ce
             <char>            <num>                   <int>             <list>    <list>      <num>
1:    simple_median        0.0603937                       4          <list[9]> <list[3]>  0.2799479

Conclusion

This post shows ways on how to specify features on which preprocessing steps are to be performed. In addition it shows how to create alternative paths in the learner graph. The preprocessing steps that can be used are not limited to imputation. Check the list of available PipeOp.