13 Grid Search

Published

November 4, 2025

Modified

November 6, 2025

在第12章中,我们演示了如何在预处理recipe对象和模型中,使用tune()函数标记待优化的参数。一旦确定了要优化的内容,接下来就要解决如何优化参数的问题。本章将介绍网格搜索(grid search)方法,这种方法可以先验地指定参数的可能值。第14章将继续讨论,介绍迭代搜索(iterative search)方法。

让我们先来看两种构建网格的主要方法。

Regular and Nonregular Grids

网格主要有两种类型。规则网格(regular grid)将每个参数(及其对应的一组可能值)以阶乘方式组合,也就是说,使用这些集合的所有组合。另外,非规则网格(nonregular grid)是指参数组合并非由一小组点形成的网格。

在我们更详细地研究每种类型之前,让我们考虑一个示例模型:多层感知器模型(又名单层人工神经网络)。标记为需要调整的参数如下:

  • hidden_units:隐藏层单元数

  • epochs:模型训练中的拟合轮次/迭代次数

  • penalty:权重衰减惩罚的量

从历史上看,时期数是通过早停法来确定的;一个单独的验证集根据错误率来决定训练的时长,因为对训练集进行重复预测会导致过拟合。在我们的案例中,使用权重衰减惩罚应该能防止过拟合,而且调整惩罚项和时期数几乎没有坏处。

使用parsnip包和nnet包拟合分类模型的示例如下:

library(tidymodels)
tidymodels_prefer()

mlp_spec <-
  mlp(hidden_units = tune(), penalty = tune(), epochs = tune()) %>%
  set_engine("nnet", trace = 0) %>%
  set_mode("classification")

参数trace = 0可防止对训练过程进行额外的日志记录。如第12.6节所示,extract_parameter_set_dials()函数能够提取具有未知值的参数集,并设置它们的dials对象:

mlp_param <- extract_parameter_set_dials(mlp_spec)
mlp_param %>% extract_parameter_dials("hidden_units")
mlp_param %>% extract_parameter_dials("penalty")
mlp_param %>% extract_parameter_dials("epochs")

此输出表明参数对象是完整的,并打印了它们的默认范围。这些值将用于演示如何创建不同类型的参数网格。

Regular grids

规则网格是不同参数值集合的组合。首先,用户为每个参数创建一组不同的值。每个参数的可能值数量不必相同。tidyr包中的函数crossing()是创建规则网格的一种方法:

crossing(
  hidden_units = 1:3,
  penalty = c(0.0, 0.1),
  epochs = c(100, 200)
)
#> # A tibble: 12 × 3
#>   hidden_units penalty epochs
#>          <int>   <dbl>  <dbl>
#> 1            1     0      100
#> 2            1     0      200
#> 3            1     0.1    100
#> 4            1     0.1    200
#> 5            2     0      100
#> 6            2     0      200
#> # ℹ 6 more rows

参数对象知道参数的范围。dials包包含一组grid_*()函数,这些函数将参数对象作为输入,以生成不同类型的网格。例如:

grid_regular(mlp_param, levels = 2)
#> # A tibble: 8 × 3
#>   hidden_units      penalty epochs
#>          <int>        <dbl>  <int>
#> 1            1 0.0000000001     10
#> 2           10 0.0000000001     10
#> 3            1 1                10
#> 4           10 1                10
#> 5            1 0.0000000001   1000
#> 6           10 0.0000000001   1000
#> # ℹ 2 more rows

levels参数用来设定每个参数创建时的水平数量。它也可以接受一个命名的值向量:

mlp_param %>%
  grid_regular(levels = c(hidden_units = 3, penalty = 2, epochs = 2))
#> # A tibble: 12 × 3
#>   hidden_units      penalty epochs
#>          <int>        <dbl>  <int>
#> 1            1 0.0000000001     10
#> 2            5 0.0000000001     10
#> 3           10 0.0000000001     10
#> 4            1 1                10
#> 5            5 1                10
#> 6           10 1                10
#> # ℹ 6 more rows

一些创建规则网格的技术不会使用每个参数集的所有可能值,例如部分因子设计(fractional factorial designs,Box, Hunter, and Hunter,2005)。要了解更多信息,请查阅“CRAN Task View”中的实验设计部分。

规则网格的一个优点是,调优参数与模型指标之间的关系和模式易于理解。这些设计的因子特性使得可以单独检查每个参数,且参数之间的混淆很少。规则网格的一个缺点是,当存在(中)大量的调优参数时,可能在计算上成本较高。但并非所有模型都这样,如下文13.5节所讨论的,有许多模型的调优时间会随着规则网格而减少!

Irregular grids

创建非规则网格有几种选择。第一种是在参数范围内进行随机抽样。grid_random()函数会在参数范围内生成独立的均匀随机数。如果参数对象有相关的转换(比如我们对penalty所做的转换),则随机数会在转换后的尺度上生成。让我们为示例神经网络的参数创建一个随机网格:

set.seed(1301)
mlp_param %>%
  grid_random(size = 1000) %>% # 'size' is the number of combinations
  summary()
#>   hidden_units       penalty              epochs     
#>  Min.   : 1.000   Min.   :0.0000000   Min.   : 10.0  
#>  1st Qu.: 3.000   1st Qu.:0.0000000   1st Qu.:265.8  
#>  Median : 5.000   Median :0.0000061   Median :497.0  
#>  Mean   : 5.381   Mean   :0.0437435   Mean   :509.5  
#>  3rd Qu.: 8.000   3rd Qu.:0.0026854   3rd Qu.:761.0  
#>  Max.   :10.000   Max.   :0.9814405   Max.   :999.0

对于penalty,随机数在对数(以10为底)尺度上是均匀分布的,但网格中的值是以自然单位表示的。

随机网格的问题在于,对于中小规模的网格,随机值可能会导致参数组合重叠。此外,随机网格需要覆盖整个参数空间,但良好覆盖的可能性是会随着网格值数量的增加而提高。即使对于15个候选点的样本,Figure 1 也显示了我们示例中的多层感知器的点之间存在一些重叠。

library(ggforce)
set.seed(1302)
mlp_param %>%
  # The 'original = FALSE' option keeps penalty in log10 units
  grid_random(size = 20, original = FALSE) %>%
  ggplot(aes(x = .panel_x, y = .panel_y)) +
  geom_point() +
  geom_blank() +
  facet_matrix(vars(hidden_units, penalty, epochs), layer.diag = 2) +
  labs(title = "Random design with 20 candidates")
Figure 1: Three tuning parameters with 15 points generated at random

一种好得多的方法是使用一组称为空间填充设计(space-filling designs)的实验设计。虽然不同的设计方法目标略有不同,但它们通常会找到一种能够覆盖参数空间,且点重叠或值冗余的可能性最小的点配置。这类设计的例子包括:

  • “Latin hypercubes”(McKay, Beckman, and Conover 1979),
  • “maximum entropy designs”(Shewry and Wynn 1987),
  • “maximum projection designs”(Joseph, Gul, and Ba 2015),

更多见 Santner等人(2003)的综述。

dials包包含用于Latin hypercubes和maximum entropy designs的函数。与grid_random()一样,主要输入是参数组合的数量和一个参数对象。让我们在 Figure 2 中比较20个候选参数值的随机设计和Latin hypercubes设计。

set.seed(1303)
mlp_param %>%
  grid_latin_hypercube(size = 20, original = FALSE) %>%
  ggplot(aes(x = .panel_x, y = .panel_y)) +
  geom_point() +
  geom_blank() +
  facet_matrix(vars(hidden_units, penalty, epochs), layer.diag = 2) +
  labs(title = "Latin Hypercube design with 20 candidates")
Figure 2: Three tuning parameters with 20 points generated using a space-filling design

尽管并不完美,但这种Latin hypercubes设计能让各点之间的距离更远,从而可以更好地探索超参数空间。

空间填充设计在表示参数空间方面可能非常有效。tune包使用的默认设计是最大熵设计。这些设计往往能生成能很好地覆盖候选空间的网格,并极大地增加找到良好结果的几率。

Evaluating the Grid

为了选择最佳的调优参数组合,每个候选参数集都使用未用于训练该模型的数据进行评估。重抽样方法或单个验证集很适合用于此目的。这一过程(以及语法)与10.3节中使用tune包的fit_resamples()函数的方法非常相似。

重抽样后,用户会选择最合适的候选参数集。选择经验上最佳的参数组合,或者偏向模型拟合的其他方面(如简洁性),可能都是合理的。

在本章和下一章中,我们将使用一个分类数据集来演示模型调优。这些数据来自Hill等人(2007),他们开发了一种用于癌症研究的自动化显微镜实验室工具。该数据集包含对2019个人类乳腺癌细胞的56项成像测量结果。这些预测变量代表了细胞不同部分(例如细胞核、细胞边界等)的形状和强度特征。预测变量之间存在高度的相关性。例如,有几个不同的预测变量用于测量细胞核和细胞边界的大小与形状。此外,许多预测变量各自具有偏斜分布。

每个细胞都属于两个类别中的一个。由于这是自动化实验室测试的一部分,重点在于预测能力而非推断。

这些数据包含在modeldata包中。让我们删除一个分析不需要的列(case):

data(cells)
cells <- cells %>% select(-case)

鉴于数据的维度,我们可以使用10折交叉验证来计算性能指标:

set.seed(1304)
cell_folds <- vfold_cv(cells)

由于预测变量之间具有高度的相关性,使用主成分分析(PCA)进行特征提取来消除预测变量的相关性是合理的。下面的流程包含以下步骤:对预测变量进行变换以提高对称性,将它们标准化到同一尺度,然后进行特征提取。需要保留的主成分分析成分数量会与模型参数一起进行调优。虽然得到的主成分分析(PCA)成分在技术上处于相同的尺度,但低阶成分的范围往往比高阶成分更宽。出于这个原因,我们再次进行标准化,以使预测变量具有相同的均值和方差

许多预测变量具有偏态分布。由于主成分分析(PCA)是基于方差的,极端值可能会对这些计算产生不利影响。为了解决这个问题,让我们添加一个recipe步骤,为每个预测变量估计“Yeo-Johnson”变换(Yeo和Johnson,2000年)。虽然它最初旨在对结果进行变换,但也可用于估计有助于获得更对称分布的变换。这个step_YeoJohnson()步骤在recipe对象中出现在通过step_normalize()进行初始标准化之前。然后,让我们将这个特征工程recipe对象与我们的神经网络模型mlp_spec结合起来。

mlp_rec <-
  recipe(class ~ ., data = cells) %>%
  step_YeoJohnson(all_numeric_predictors()) %>%
  step_normalize(all_numeric_predictors()) %>%
  step_pca(all_numeric_predictors(), num_comp = tune()) %>%
  step_normalize(all_numeric_predictors())

mlp_wflow <-
  workflow() %>%
  add_model(mlp_spec) %>%
  add_recipe(mlp_rec)

让我们创建一个参数对象mlp_param来调整一些默认范围。我们可以将轮次数量改为更小的范围(50到200轮)。此外,num_comp()的默认范围非常狭窄(1到4个成分);我们可以将该范围扩大到40个成分,并将最小值设置为0:

mlp_param <-
  mlp_wflow %>%
  extract_parameter_set_dials() %>%
  update(
    epochs = epochs(c(50, 200)),
    num_comp = num_comp(c(0, 40))
  )
Tip

step_pca()中,使用零个主成分分析(PCA)组件是跳过特征提取的快捷方式。通过这种方式,原始预测变量可以直接与包含主成分分析组件的结果进行比较。

tune_grid()函数是进行网格搜索的主要函数。其功能与第10.3节中的fit_resamples()非常相似,但它有一些与网格相关的额外参数:

  • grid:一个整数或数据框。当使用整数时,该函数会创建一个空间填充设计,其中包含grid数量的候选参数组合。如果存在特定的参数组合,则使用grid参数将它们传递给该函数。

  • param_info:一个用于定义参数范围的可选参数。当grid为整数时,该参数最为有用。

tune_grid()的其他参数接口与fit_resamples()相同。第一个参数要么是模型,要么是工作流。当提供模型时,第二个参数可以是recipe对象或公式。另一个必需的参数是rsample重抽样对象(例如cell_folds)。下面的调用还传递了一个指标集,以便在重抽样过程中测量ROC曲线下面积。

首先,让我们评估一个在重采样中包含三个级别的常规网格:

roc_res <- metric_set(roc_auc)
set.seed(1305)
mlp_reg_tune <-
  mlp_wflow %>%
  tune_grid(
    cell_folds,
    grid = mlp_param %>% grid_regular(levels = 3),
    metrics = roc_res
  )
mlp_reg_tune
#> # Tuning results
#> # 10-fold cross-validation 
#> # A tibble: 10 × 4
#>   splits             id     .metrics          .notes          
#>   <list>             <chr>  <list>            <list>          
#> 1 <split [1817/202]> Fold01 <tibble [81 × 8]> <tibble [0 × 4]>
#> 2 <split [1817/202]> Fold02 <tibble [81 × 8]> <tibble [0 × 4]>
#> 3 <split [1817/202]> Fold03 <tibble [81 × 8]> <tibble [0 × 4]>
#> 4 <split [1817/202]> Fold04 <tibble [81 × 8]> <tibble [0 × 4]>
#> 5 <split [1817/202]> Fold05 <tibble [81 × 8]> <tibble [0 × 4]>
#> 6 <split [1817/202]> Fold06 <tibble [81 × 8]> <tibble [0 × 4]>
#> # ℹ 4 more rows

我们可以使用一些高级便捷函数来理解结果。首先,用于规则网格的autoplot()方法在 Figure 3 中展示了不同调优参数下的性能概况。

autoplot(mlp_reg_tune) +
  scale_color_viridis_d(direction = -1) +
  theme(legend.position = "top")
Figure 3: The regular grid results

对于这些数据,惩罚量对ROC曲线下面积的影响最大。 epoch数似乎对性能没有显著影响。当正则化量较低时,隐藏单元数量的变化影响最大(并且会损害性能)。有几种参数配置的性能大致相当,这可以通过函数show_best()看出:

show_best(mlp_reg_tune) %>% select(-.estimator)
#> # A tibble: 5 × 9
#>   hidden_units penalty epochs num_comp .metric  mean     n std_err
#>          <int>   <dbl>  <int>    <int> <chr>   <dbl> <int>   <dbl>
#> 1            5       1    125        0 roc_auc 0.894    10 0.00851
#> 2            5       1    200        0 roc_auc 0.893    10 0.00808
#> 3            5       1    125       20 roc_auc 0.892    10 0.0104 
#> 4            5       1     50        0 roc_auc 0.891    10 0.00868
#> 5           10       1    125       20 roc_auc 0.891    10 0.00867
#> # ℹ 1 more variable: .config <chr>

根据这些结果,进行另一轮网格搜索是合理的,此次搜索应采用更大的权重衰减惩罚值。

要使用空间填充设计,既可以给grid参数赋一个整数,也可以使用某个grid_*()函数生成一个数据框。要使用具有20个候选值的最大熵设计来评估相同的范围:

set.seed(1306)
mlp_sfd_tune <-
  mlp_wflow %>%
  tune_grid(
    cell_folds,
    grid = 20,
    # Pass in the parameter object to use the appropriate range:
    param_info = mlp_param,
    metrics = roc_res
  )
mlp_sfd_tune
#> # Tuning results
#> # 10-fold cross-validation 
#> # A tibble: 10 × 4
#>   splits             id     .metrics          .notes          
#>   <list>             <chr>  <list>            <list>          
#> 1 <split [1817/202]> Fold01 <tibble [20 × 8]> <tibble [0 × 4]>
#> 2 <split [1817/202]> Fold02 <tibble [20 × 8]> <tibble [0 × 4]>
#> 3 <split [1817/202]> Fold03 <tibble [20 × 8]> <tibble [0 × 4]>
#> 4 <split [1817/202]> Fold04 <tibble [20 × 8]> <tibble [0 × 4]>
#> 5 <split [1817/202]> Fold05 <tibble [20 × 8]> <tibble [0 × 4]>
#> 6 <split [1817/202]> Fold06 <tibble [20 × 8]> <tibble [0 × 4]>
#> # ℹ 4 more rows

autoplot()方法也适用于这些设计,不过结果的格式会有所不同。Figure 4 是使用autoplot(mlp_sfd_tune)生成的。

autoplot(mlp_sfd_tune)
Figure 4: The autoplot() method results when used with a space-filling design

这个边际效应图( Figure 4 )展示了每个参数与性能指标之间的关系。查看此图表时要注意:由于未使用规则网格,其他调优参数的值可能会影响每个面板。

惩罚参数在权重衰减量较小时似乎能带来更好的性能。这与常规网格的结果相反。由于每个面板中的每个点都与其他三个调优参数相关联,因此一个面板中的趋势可能会受到其他面板的影响。使用常规网格时,每个面板中的每个点都会在其他参数上进行均等平均。因此,使用常规网格能更好地分离每个参数的影响。

与常规网格一样,show_best()可以报告数值上最佳的结果:

show_best(mlp_sfd_tune) %>% select(-.estimator)
#> # A tibble: 5 × 9
#>   hidden_units     penalty epochs num_comp .metric  mean     n std_err
#>          <int>       <dbl>  <int>    <int> <chr>   <dbl> <int>   <dbl>
#> 1            4 0.0264         184        8 roc_auc 0.887    10 0.00928
#> 2            6 0.298           73       12 roc_auc 0.885    10 0.0107 
#> 3            7 1              152       29 roc_auc 0.884    10 0.00764
#> 4            8 0.000000483     57        6 roc_auc 0.875    10 0.00774
#> 5            2 0.00000546      50       18 roc_auc 0.871    10 0.00931
#> # ℹ 1 more variable: .config <chr>

一般来说,通过多个指标对模型进行评估是个不错的主意,这样可以考虑到模型拟合的不同方面。此外,选择一个与更简单模型相关联的略次优的参数组合往往是合理的。对于这个模型而言,简单性对应的是更大的惩罚值和/或更少的隐藏单元。

fit_resamples()的结果一样,通常没有必要保留不同重抽样和调优参数下的中间模型拟合结果。不过,和之前一样,control_grid()extract选项允许保留拟合好的模型和/或recipe对象。此外,将save_pred选项设置为TRUE会保留评估集的预测结果,这些结果可以通过collect_predictions()进行访问。

Finalizing the Model

如果通过show_best()找到的某组可能的模型参数对于这些数据来说是一个有吸引力的最终选项,我们可能希望评估它在测试集上的表现。然而,tune_grid()的结果仅为选择合适的调优参数提供了基础。该函数不会拟合最终模型。

要拟合最终模型,必须确定一组最终的参数值。有两种方法可以做到这一点:

  • 手动选择看起来合适的值,或者
  • 使用select_*()函数。

例如,select_best()会选择在数值上结果最佳的参数。让我们回到常规网格的结果,看看哪一个是最佳的:

select_best(mlp_reg_tune, metric = "roc_auc")
#> # A tibble: 1 × 5
#>   hidden_units penalty epochs num_comp .config         
#>          <int>   <dbl>  <int>    <int> <chr>           
#> 1            5       1    125        0 pre1_mod17_post0

回顾 Figure 3 ,我们可以看到,一个具有单个隐藏单元、在原始预测变量上训练125个epochs且带有大量惩罚项的模型,其性能与该选项相当,而且更简单。这本质上就是有惩罚项的逻辑回归!要手动指定这些参数,我们可以创建一个包含这些值的tibble,然后使用最终确定函数将这些值拼接回工作流中:

logistic_param <-
  tibble(
    num_comp = 0,
    epochs = 125,
    hidden_units = 1,
    penalty = 1
  )

final_mlp_wflow <-
  mlp_wflow %>%
  finalize_workflow(logistic_param)
final_mlp_wflow
#> ══ Workflow ═════════════════════════════════════════════════════════════════
#> Preprocessor: Recipe
#> Model: mlp()
#> 
#> ── Preprocessor ─────────────────────────────────────────────────────────────
#> 4 Recipe Steps
#> 
#> • step_YeoJohnson()
#> • step_normalize()
#> • step_pca()
#> • step_normalize()
#> 
#> ── Model ────────────────────────────────────────────────────────────────────
#> Single Layer Neural Network Model Specification (classification)
#> 
#> Main Arguments:
#>   hidden_units = 1
#>   penalty = 1
#>   epochs = 125
#> 
#> Engine-Specific Arguments:
#>   trace = 0
#> 
#> Computational engine: nnet

在此最终确定的工作流程中,不再包含tune()的更多值。现在可以将模型拟合到整个训练集:

final_mlp_fit <-
  final_mlp_wflow %>%
  fit(cells)

现在可以使用这个对象对新数据进行未来预测。

如果您没有使用工作流,则模型和/或recipe对象的最终确定是通过finalize_model()finalize_recipe()来完成的。

Tools for Creating Tuning Specifications

usemodels包可以接收一个数据框和模型公式,然后生成用于模型调优的R代码。该代码还会创建一个合适的recipe对象,其步骤取决于所请求的模型以及预测变量数据。

例如,对于Ames房价数据,可以使用以下代码创建xgboost建模代码:

library(usemodels)

use_xgboost(
  Sale_Price ~ Neighborhood + Gr_Liv_Area + Year_Built + Bldg_Type +
    Latitude + Longitude,
  data = ames_train,
  # Add comments explaining some of the code:
  verbose = TRUE
)

生成的代码如下:

xgboost_recipe <-
  recipe(formula = Sale_Price ~ Neighborhood + Gr_Liv_Area + Year_Built + Bldg_Type +
    Latitude + Longitude, data = ames_train) %>%
  step_novel(all_nominal_predictors()) %>%
  ## This model requires the predictors to be numeric. The most common
  ## method to convert qualitative predictors to numeric is to create
  ## binary indicator variables (aka dummy variables) from these
  ## predictors. However, for this model, binary indicator variables can be
  ## made for each of the levels of the factors (known as 'one-hot
  ## encoding').
  step_dummy(all_nominal_predictors(), one_hot = TRUE) %>%
  step_zv(all_predictors())

xgboost_spec <-
  boost_tree(trees = tune(), min_n = tune(), tree_depth = tune(), learn_rate = tune(),
    loss_reduction = tune(), sample_size = tune()) %>%
  set_mode("regression") %>%
  set_engine("xgboost")

xgboost_workflow <-
  workflow() %>%
  add_recipe(xgboost_recipe) %>%
  add_model(xgboost_spec)

set.seed(69305)
xgboost_tune <-
  tune_grid(xgboost_workflow,
            resamples = stop("add your rsample object"),
            grid = stop("add number of candidate points"))

根据usemodels对数据的理解,这段代码是所需的最低限度预处理。对于其他模型,会添加像step_normalize()这样的操作来满足模型的基本需求。需要注意的是,作为建模从业者,选择用于调优的resamples以及grid类型是我们的责任。

通过将参数tune = FALSE进行设置,usemodels包还可用于创建无需调优的模型拟合代码。

Chapter Summary

本章讨论了可用于模型调优的网格搜索的两个主要类别(常规和非常规),并演示了如何构建这些网格,既可以手动构建,也可以使用grid_*()函数族。tune_grid()函数可以通过重抽样来评估这些候选模型参数集。本章还展示了如何确定模型、recipe对象或工作流的最终版本,以更新最终拟合的参数值。网格搜索可能计算成本很高,但在这类搜索的实验设计中做出深思熟虑的选择可以使其变得易于处理。

下一章将重用的数据分析代码如下:

library(tidymodels)

data(cells)
cells <- cells %>% select(-case)

set.seed(1304)
cell_folds <- vfold_cv(cells)

roc_res <- metric_set(roc_auc)
Back to top