Supervised Classification: An Exploration with R and tidymodels
The aim of this file is to visualize the predictions of many classical models on a simple dataset: the twoClass dataset from Applied Predictive Modeling, the book by M. Kuhn and K. Johnson. At the same time, it provides an example of how to use the tidymodels ecosystem to obtain those predictions as well as good estimates of the quality of the corresponding predictors.
We start by loading our main (meta)-libraries:
library("tidyverse")
library("tidymodels")
library("conflicted")
conflict_prefer("select", "dplyr")
conflict_prefer("filter", "dplyr")
conflict_prefer("slice", "dplyr")
They give access to most of the functions required to work with dataframes (or rather tibbles), to visualize them and to do machine learning on tabular data.
To accelerate our code, we will rely on parallelization, and, more precisely, on the mirai implementation:
library(mirai)
mirai_daemon_nb <- 4
daemons(mirai_daemon_nb)
The twoClass dataset
We are now ready to read the dataset and use ggplot2 to display it.
library(AppliedPredictiveModeling)
library(RColorBrewer)
data(twoClassData)
twoClass <- tibble(predictors) |> mutate(classes)
twoClassColor <- brewer.pal(3,'Set1')[1:2]
names(twoClassColor) <- c('Class1','Class2')
ggplot(data = twoClass,aes(x = PredictorA, y = PredictorB)) +
geom_point(aes(color = classes), size = 6, alpha = .5) +
scale_colour_manual(name = 'classes', values = twoClassColor) +
scale_x_continuous(expand = c(0,0)) +
scale_y_continuous(expand = c(0,0)) +
coord_equal() +
theme_light()

Let’s train a simple logistic regression on the data with tidymodels. We need to specify the preprocessing of the data as well as the model itself. The combination of those two steps is called a workflow and can be defined as follows:
preprocess <- workflow() |>
add_formula(classes ~ .)
model_ <- logistic_reg()
workflow_ <- preprocess |>
add_model(model_)
We can then train our model on the data and make some predictions:
fitted_ <- workflow_ |> fit(twoClass)
fitted_ |> predict(twoClass |> slice(1:10))
## # A tibble: 10 × 1
## .pred_class
## <fct>
## 1 Class2
## 2 Class1
## 3 Class1
## 4 Class2
## 5 Class1
## 6 Class1
## 7 Class2
## 8 Class1
## 9 Class2
## 10 Class2
To better visualize the predictor, we will plot the predictions on a grid as well as the decision boundary with the following function:
library("patchwork")
nbp <- 250;
PredA <- seq(min(twoClass$PredictorA), max(twoClass$PredictorA), length = nbp)
PredB <- seq(min(twoClass$PredictorB), max(twoClass$PredictorB), length = nbp)
Grid <- as_tibble(expand.grid(PredictorA = PredA, PredictorB = PredB))
plot_grid <- function(fitted, title) {
pred <- fitted |> predict(Grid)
surf <- (ggplot(data = twoClass, aes(x = PredictorA, y = PredictorB,
color = classes)) +
geom_tile(data = Grid |> bind_cols(pred), mapping = aes(fill = .pred_class, color = .pred_class)) +
scale_fill_manual(name = 'classes', values = twoClassColor) +
ggtitle("Decision region") + theme(legend.text = element_text(size = 10)) +
scale_colour_manual(name = 'classes', values = twoClassColor)) +
scale_x_continuous(expand = c(0,0)) +
scale_y_continuous(expand = c(0,0)) +
theme_light()
pts <- (ggplot(data = twoClass, aes(x = PredictorA, y = PredictorB,
color = classes)) +
geom_contour(data = Grid |> bind_cols(pred), aes(z = as.numeric(.pred_class), color = .pred_class),
color = "red", breaks = c(1.5)) +
geom_point(size = 4, alpha = .5) +
ggtitle("Decision boundary") +
theme(legend.text = element_text(size = 10)) +
scale_colour_manual(name = 'classes', values = twoClassColor)) +
scale_x_continuous(expand = c(0,0)) +
scale_y_continuous(expand = c(0,0)) +
coord_equal() + theme_light()
surf + pts +
plot_annotation(title = title)
}
plot_grid(fitted_, "Logistic")

We will also use tidymodels to assess the quality of our predictors through a cross-validation scheme. We need to define a set of resamples of the original dataset, a collection of train/validate sets that will be used respectively to fit the workflow and to compute some metrics. We will rely on a classical repeated cross-validation strategy, but we will also compute an optimistic training error computed by testing a model on the very same data it has been trained on. As this is a bad idea, we need to hack the ecosystem a little…
set.seed(seed = 42)
folds <- vfold_cv(twoClass, v = 10, repeats = 4, strata = classes)
no_fold <- manual_rset(list(make_splits(x = twoClass, assessment = twoClass)), 'None')
all_folds <- folds|>
mutate(id = paste(id, id2)) |>
bind_rows(no_fold)
all_folds <- manual_rset(all_folds[["splits"]], all_folds[["id"]])
We are now ready to compute some metric values for all resamples (folds):
resamples_ <- workflow_ |>
fit_resamples(resamples = all_folds)
metrics_ <- resamples_ |>
collect_metrics(summarize = FALSE) |>
mutate(.model = "logistic", .before ="id")
metrics_
## # A tibble: 123 × 6
## .model id .metric .estimator .estimate .config
## <chr> <chr> <chr> <chr> <dbl> <chr>
## 1 logistic Repeat1 Fold01 accuracy binary 0.818 pre0_mod0_post0
## 2 logistic Repeat1 Fold01 roc_auc binary 0.758 pre0_mod0_post0
## 3 logistic Repeat1 Fold01 brier_class binary 0.182 pre0_mod0_post0
## 4 logistic Repeat1 Fold02 accuracy binary 0.762 pre0_mod0_post0
## 5 logistic Repeat1 Fold02 roc_auc binary 0.782 pre0_mod0_post0
## 6 logistic Repeat1 Fold02 brier_class binary 0.207 pre0_mod0_post0
## 7 logistic Repeat1 Fold03 accuracy binary 0.714 pre0_mod0_post0
## 8 logistic Repeat1 Fold03 roc_auc binary 0.8 pre0_mod0_post0
## 9 logistic Repeat1 Fold03 brier_class binary 0.180 pre0_mod0_post0
## 10 logistic Repeat1 Fold04 accuracy binary 0.762 pre0_mod0_post0
## # ℹ 113 more rows
As we will repeat this several times, we provide a function to learn a predictor, compute its metric values on the resamples and visualize the predictor and a function to add those values to those already collected.
learn_and_display <- function (name, workflow, seed=42) {
set.seed(seed)
resamples_ <- workflow |> fit_resamples(all_folds)
metrics_ <- resamples_ |>
collect_metrics(summarize = FALSE) |>
mutate(.model = name, .before ="id")
fitted_ <- workflow |>
fit(twoClass)
print(plot_grid(fitted_, name))
metrics_
}
learn_display_and_add <- function(metrics, name, workflow, seed=42) {
metrics_ <- learn_and_display(name, workflow, seed)
bind_rows(metrics, metrics_)
}
all_metrics <- tibble()
Any model available in tidymodels can be used! We will explore here the most classical ones. We will start with models that are explicitly based on the estimation of the conditional density (the probabilistic approach) and continue with models more related to the optimization approach, in which one tries to enforce a small training error by minimizing a surrogate criterion.
Statistical point of view
Parametric conditional estimation
The most classical model is probably the logistic model, that we have seen before. It is indeed the canonical example of a parametric conditional regression estimate.
workflow_ <- preprocess |>
add_model(logistic_reg())
all_metrics <- all_metrics |>
learn_display_and_add("Logistic", workflow_)

We are not restricted to the use of two features but may use any transformation of them. For instance, we may use all the possible monomials up to degree \(2\). We only need to change the preprocessing step:
quadratic_preprocess <- workflow() |>
add_formula(classes ~ PredictorA + PredictorB + I(PredictorA^2) + I(PredictorB^2) + I(PredictorA*PredictorB))
workflow_ <- quadratic_preprocess |>
add_model(logistic_reg())
all_metrics <- all_metrics |>
learn_display_and_add("Quadratic Logistic", workflow_)

Nonparametric conditional density estimation
The most classical approaches are kernel methods, and among them the simplest one is the nearest neighbor method. We only need to supply a parameter: \(k\) the number of neighbors used to define the kernel. We will visually compare the solutions obtained with a few \(k\) values.
knn_metrics <- data.frame()
k_knn <- c(seq(1, 33, by = 4),seq(37,85, by = 8), seq(101, 200, by = 16))
for (k_ in k_knn) {
workflow_ <- preprocess |>
add_model(nearest_neighbor(mode = "classification", neighbors = {{ k_ }},
weight_func = "rectangular"))
knn_metrics <- knn_metrics |>
learn_display_and_add(paste("k-NN with k=", k_), workflow_)
}























all_metrics <- all_metrics |> bind_rows(knn_metrics)
Note that the default nearest neighbor algorithm in tidymodels is a weighted one, so that we had to specify weight_func = "rectangular" to use the classical algorithm.
As a teaser of what we are going to see, we could use those metrics to find the best parameters. This can be done in an efficient way:
workflow_ <- preprocess |>
add_model(nearest_neighbor(mode = "classification", neighbors = tune(), weight_func = "rectangular"))
# kknn refuses a k larger than the training set: keep the k values that fit in every fold
n_train_min <- min(map_int(folds$splits, \(split) nrow(analysis(split))))
param_grid <- expand_grid(neighbors = k_knn[k_knn <= n_train_min])
resamples_ <- workflow_ |>
tune_grid(resamples = folds, grid = param_grid)
best_params_ <- resamples_ |>
select_best(metric = "accuracy")
workflow_ <- workflow_ |>
finalize_workflow(best_params_)
all_metrics <- all_metrics |>
learn_display_and_add("k-NN with CV choice", workflow_)

We will look more closely at this choice at the end of this document.
Generative Modeling
In the generative modeling approach, one proposes to estimate the joint law of the covariates and the label and to derive the conditional law using the Bayes formula. The generative models differ by the choice of the density estimator (often specified by an intra-class model). We consider here the LDA and QDA methods, in which a Gaussian model is used, and two variants of the Naive Bayes method, in which all the features are assumed to be independent. For Naive Bayes, we will use a Gaussian model and a density-based one.
library(discrim)
workflow_ <- preprocess |>
add_model(discrim_linear())
all_metrics <- all_metrics |>
learn_display_and_add("Linear Discrimant Analysis", workflow_)

workflow_ <- preprocess |>
add_model(discrim_quad())
all_metrics <- all_metrics |>
learn_display_and_add("Quadratic Discriminant Analysis", workflow_)

workflow_ <- preprocess |>
add_model(naive_Bayes() |>
set_engine("klaR", usekernel = c(FALSE)))
all_metrics <- all_metrics |>
learn_display_and_add("Naive Bayes with Gaussian model", workflow_)

workflow_ <- preprocess |>
add_model(naive_Bayes() |>
set_engine("klaR"))
all_metrics <- all_metrics |>
learn_display_and_add("Naive Bayes with kernel density estimates", workflow_)

Optimization point of view
Neural Networks
Some simple neural networks can be used with tidymodels:
workflow_ <- preprocess |>
add_model(mlp(mode = "classification"))
all_metrics <- all_metrics |>
learn_display_and_add("Neural Network", workflow_)

but a dedicated deep learning package gives more control on the architecture. We use here brulee, which relies on torch, to build a network with two hidden layers and dropout. As neural networks are sensitive to the scale of their inputs, we normalize the features first:
mlp_spec <- mlp(mode = "classification", hidden_units = 4, dropout = 0.1, penalty = 0,
epochs = 100, learn_rate = 0.01) |>
set_engine("brulee_two_layer", hidden_units_2 = 3, optimizer = "ADAMw",
batch_size = 32, validation = 0)
normalized_preprocess <- workflow() |>
add_recipe(recipe(classes ~ ., data = twoClass) |> step_normalize(all_predictors()))
workflow_ <- normalized_preprocess |>
add_model(mlp_spec)
all_metrics <- all_metrics |>
learn_display_and_add("Neural Network with brulee", workflow_)

Penalization
Penalized models are available in tidymodels:
workflow_ <- quadratic_preprocess |>
add_model(logistic_reg(mode = "classification", penalty = 1))
all_metrics <- all_metrics |>
learn_display_and_add("Logistic with L2 penalization", workflow_)

In practice, we should probably optimize the choice of the penalty (or use another model as we only have two features).
Support Vector Machine
The SVM may be seen as the original optimization method, and it can be used with tidymodels:
- with a vanilla kernel
workflow_ <- preprocess |>
add_model(svm_linear(mode = "classification") |>
set_engine("kernlab"))
all_metrics <- all_metrics |>
learn_display_and_add("Support Vector Machine", workflow_)

- a polynomial one
workflow_ <- preprocess |>
add_model(svm_poly(mode = "classification", degree = 3) |>
set_engine("kernlab"))
all_metrics <- all_metrics |>
learn_display_and_add("Support Vector Machine with polynomial kernel", workflow_)

- or a Gaussian kernel for example
workflow_ <- preprocess |>
add_model(svm_rbf(mode = "classification") |>
set_engine("kernlab"))
all_metrics <- all_metrics |>
learn_display_and_add("Support Vector Machine with Gaussian kernel", workflow_)

Trees and Boosting
We conclude this model exploration by some tree-based methods: plain tree (CART), bagging, random forest, boosting with C5.0 and XGBoost…
workflow_ <- preprocess |>
add_model(decision_tree(mode = "classification",
min_n = 5, cost_complexity = 0.02))
all_metrics <- all_metrics |>
learn_display_and_add("CART", workflow_)

library(rpart.plot)
fit_ <- workflow_ |> fit(twoClass)
model_ <- fit_ |> extract_fit_parsnip()
rpart.plot(model_$fit, roundint = FALSE)

prp(model_$fit, type = 2, extra = 106, nn = TRUE, fallen.leaves = TRUE,
box.col = twoClassColor[model_$fit$frame$yval], roundint=FALSE)

library(baguette)
workflow_ <- preprocess |>
add_model(bag_tree(mode = "classification",
tree_depth = 5) |>
set_engine("rpart", times = 20))
all_metrics <- all_metrics |>
learn_display_and_add("Bagging", workflow_)

workflow_ <- preprocess |>
add_model(rand_forest(mode = "classification",
trees = 500, mtry = 2))
all_metrics <- all_metrics |>
learn_display_and_add("Random Forest", workflow_)

workflow_ <- preprocess |>
add_model(boost_tree(mode = "classification") |>
set_engine("C5.0"))
all_metrics <- all_metrics |>
learn_display_and_add("C5.0", workflow_)

workflow_ <- preprocess |>
add_model(boost_tree(mode = "classification"))
all_metrics <- all_metrics |>
learn_display_and_add("XGBoost Tree", workflow_)

Model comparison
We will now compare our models according to 4 criteria:
- Empirical: the naive empirical accuracy computed on the same dataset as the one used to learn the classifier,
- CV: the mean of the accuracies obtained by the resampling scheme,
- Pessimistic CV: a lower bound on the mean accuracy obtained by subtracting from the average accuracy twice the standard deviation divided by the square root of the number of resamples.
- PAC CV: a high-probability bound on the accuracy obtained by subtracting the standard deviation from the average accuracy.
Global comparison
We can display those values for all our methods:
compute_metrics_summary_long <- function(metrics) {
metrics <- metrics |> mutate(.model = forcats::fct_inorder(.model))
metrics_summary <- metrics |>
group_by(.model, .metric, .type = if_else(id == "None", "Empirical", "CV")) |>
summarize(.mean = mean(.estimate), .sd = sd(.estimate), .n = n(),
.groups = "drop") |>
mutate(.mean_conf = .mean - 2 * .sd / sqrt(.n),
.mean_pac = .mean - .sd)
metrics_summary_wide <- metrics_summary |>
select(- .n, - .sd) |>
pivot_wider(names_from = .type, values_from = c(.mean, .mean_conf, .mean_pac))
metrics_summary_long <- metrics_summary_wide |>
pivot_longer(cols = c(- .model, -.metric), names_to = c(".type"), values_to = ".value") |>
filter(!is.na(.value))
metrics_summary_long
}
all_metrics_summary_long <- compute_metrics_summary_long(all_metrics)
simplify_type_names <- function(type)
{
type_simpler_names = c(".mean_Empirical" = "Empirical",
".mean_CV" = "CV",
".mean_conf_CV" = "Pessimistic CV",
".mean_pac_CV" = "PAC CV")
type_simpler_names[type] |>
factor(level = type_simpler_names)
}
c(".mean_Empirical" = "Empirical",
".mean_CV" = "CV",
".mean_conf_CV" = "Pessimistic CV",
".mean_pac_CV" = "PAC CV")
## .mean_Empirical .mean_CV .mean_conf_CV .mean_pac_CV
## "Empirical" "CV" "Pessimistic CV" "PAC CV"
type_colors = c("Empirical" = "red", "CV" = "blue", "Pessimistic CV" = "green", "PAC CV" = "purple")
plot_accuracy <- function(all_metrics_summary_long, simple = FALSE)
{
df_ <- all_metrics_summary_long |>
filter(.metric == "accuracy") |>
mutate(.type = simplify_type_names(.type))
if (simple) {
df_ <- df_ |>
filter(.type %in% c("Empirical", "CV"))
}
df_ |>
ggplot(aes(x = .model, y = .value, color = .type)) +
geom_point(size = 5) +
scale_color_manual(values = type_colors, name = NULL) +
scale_y_continuous(name = "Accuracy", limits = c(0, 1), expand = expansion()) +
scale_x_discrete(name = "Method") +
theme_light() +
theme(axis.text.x = element_text(angle = 45, hjust = 1),
plot.margin = grid::unit(c(1, 1, .5, 1.5), "lines"))
}
plot_accuracy(all_metrics_summary_long)

plot_accuracy(all_metrics_summary_long, simple = TRUE)

We can also display the best models for all those criteria:
find_best <- function(all_metrics_summary_long) {
all_metrics_summary_long |>
group_by(.metric, .type) |>
slice_max(.value, n = 1) |>
mutate(.type = simplify_type_names(.type)) |>
arrange(.metric, .type)
}
find_best(all_metrics_summary_long)
## # A tibble: 13 × 4
## # Groups: .metric, .type [12]
## .model .metric .type .value
## <fct> <chr> <fct> <dbl>
## 1 k-NN with k= 1 accuracy Empirical 1
## 2 k-NN with k= 85 accuracy CV 0.773
## 3 k-NN with CV choice accuracy CV 0.773
## 4 k-NN with k= 53 accuracy Pessimistic CV 0.742
## 5 k-NN with k= 25 accuracy PAC CV 0.679
## 6 k-NN with k= 197 brier_class Empirical 0.246
## 7 k-NN with k= 1 brier_class CV 0.312
## 8 k-NN with k= 1 brier_class Pessimistic CV 0.273
## 9 k-NN with k= 197 brier_class PAC CV 0.247
## 10 k-NN with k= 1 roc_auc Empirical 1
## 11 k-NN with k= 29 roc_auc CV 0.819
## 12 k-NN with k= 29 roc_auc Pessimistic CV 0.788
## 13 k-NN with k= 29 roc_auc PAC CV 0.720
find_best(all_metrics_summary_long) |> filter(.metric == "accuracy")
## # A tibble: 5 × 4
## # Groups: .metric, .type [4]
## .model .metric .type .value
## <fct> <chr> <fct> <dbl>
## 1 k-NN with k= 1 accuracy Empirical 1
## 2 k-NN with k= 85 accuracy CV 0.773
## 3 k-NN with CV choice accuracy CV 0.773
## 4 k-NN with k= 53 accuracy Pessimistic CV 0.742
## 5 k-NN with k= 25 accuracy PAC CV 0.679
An interesting plot to assess the variability of the cross-validation results is the following parallel plot in which an error is computed for each resample in addition to the mean.
df_ <- all_metrics |>
mutate(.model = forcats::fct_inorder(.model)) |>
filter(.metric == "accuracy") |>
filter(id != "None" )
df_ |>
ggplot(aes(x = .model, y = .estimate)) +
geom_line(aes(color = id, group = id)) +
scale_color_grey(guide = FALSE) +
scale_x_discrete(name = "Method") +
scale_y_continuous(name = "Accuracy", limits = c(0, 1), expand = expansion()) +
geom_line(data = df_ |>
summarize(.estimate = mean(.estimate), .by = .model),
group = 1,
linewidth = 1.5) +
theme_light() +
theme(axis.text.x = element_text(angle = 45, hjust = 1),
plot.margin = grid::unit(c(1, 1, .5, 1.5), "lines"))

K-NN Comparison
We focus here on the choice of the neighborhood in the \(k\) Nearest Neighbor method.
knn_metrics_summary_long <- all_metrics_summary_long |>
filter(str_starts(.model, "k-NN")) |>
filter(!str_detect(.model, "CV"))
knn_metrics_summary_long |>
plot_accuracy()

knn_metrics_summary_long |>
find_best() |> filter(.metric == "accuracy")
## # A tibble: 4 × 4
## # Groups: .metric, .type [4]
## .model .metric .type .value
## <fct> <chr> <fct> <dbl>
## 1 k-NN with k= 1 accuracy Empirical 1
## 2 k-NN with k= 85 accuracy CV 0.773
## 3 k-NN with k= 53 accuracy Pessimistic CV 0.742
## 4 k-NN with k= 25 accuracy PAC CV 0.679
knn_metrics_summary_long |>
plot_accuracy(simple = TRUE)

knn_metrics_summary_long |>
find_best() |> filter(.type %in% c("Empirical", "CV"))
## # A tibble: 6 × 4
## # Groups: .metric, .type [6]
## .model .metric .type .value
## <fct> <chr> <fct> <dbl>
## 1 k-NN with k= 1 accuracy Empirical 1
## 2 k-NN with k= 85 accuracy CV 0.773
## 3 k-NN with k= 197 brier_class Empirical 0.246
## 4 k-NN with k= 1 brier_class CV 0.312
## 5 k-NN with k= 1 roc_auc Empirical 1
## 6 k-NN with k= 29 roc_auc CV 0.819
Bonus: CART variability
One of the issues of the CART method is that the predictor is quite unstable: it can change a lot when the data is slightly changed. Here we train the CART with bootstrapped versions of the original dataset:
workflow_ <- preprocess |>
add_model(decision_tree(mode = "classification",
min_n = 5, cost_complexity = 0.02))
for (i in 1:9){
set.seed(i*42)
twoClass_boot <- twoClass |> slice_sample(prop = 1, replace = TRUE)
print(plot_grid(workflow_ |> fit(twoClass_boot),
paste("CART with seed", i)))
}









Bonus: CV variability
We illustrate here the variability of the accuracy estimated by V-fold cross-validation. Each boxplot corresponds to a repetition of a 10-fold cross-validation for the same method (SVM with a polynomial kernel).
set.seed(42)
more_folds <- vfold_cv(twoClass, v = 10, repeats = 10, strata = classes)
workflow_ <- preprocess |>
add_model(svm_poly(mode = "classification", degree = 3) |>
set_engine("kernlab"))
resamples_ <- workflow_ |> fit_resamples(more_folds)
metrics_ <- resamples_ |>
collect_metrics(summarize = FALSE)
metrics_ |>
filter(.metric == "accuracy") |>
ggplot(aes(x = id, y = .estimate)) +
geom_boxplot() +
geom_point() +
scale_y_continuous(name = "accuracy", limits = c(0,1), expand = expansion()) +
scale_x_discrete(name = NULL) +
theme_light()

Bonus: Naive Bayes with kernel density estimates
The most surprising predictor is probably this one. In particular, there is a strange blue zone in the top left… that can be explained by the density of each feature:
p <- ggplot(data = twoClass,aes(x = PredictorA, y = PredictorB)) +
geom_point(aes(color = classes), size = 6, alpha = .5) +
scale_colour_manual(name = 'classes', values = twoClassColor) +
scale_x_continuous(expand = c(0,0)) +
scale_y_continuous(expand = c(0,0)) +
coord_equal() +
theme_light() +
guides(color = "none")
ptop <- ggplot(data = twoClass,aes(x = PredictorA)) +
geom_density(aes(color = classes, fill = classes), alpha = .5) +
guides(color = "none", fill = "none") +
scale_colour_manual(name = 'classes', values = twoClassColor) +
scale_fill_manual(name = 'classes', values = twoClassColor) +
theme_void() +
scale_x_continuous(expand = c(0,0))
pright <- ggplot(data = twoClass,aes(y = PredictorB)) +
geom_density(aes(color = classes, fill = classes), alpha = .5) +
guides(color = "none", fill = "none") +
scale_color_manual(name = 'classes', values = twoClassColor) +
scale_fill_manual(name = 'classes', values = twoClassColor) +
theme_void() +
scale_y_continuous(expand = c(0,0))
(ptop +
patchwork::plot_spacer()+ p + pright) +
plot_layout(guides = "collect",
ncol = 2,
heights = unit(c(.2, .6), "snpc"),
widths = unit(c(.6, .2), "snpc"))
