用 ParBayesianOptimization 包寻参时使用并行计算的学习记录

· 3808字 · 8分钟

以前做 XGBoost 寻参时用的方法是网格寻参,执行起来比较慢,最近听说有个 R 包 ParBayesianOptimization 提供的方法寻参比较快,于是装了试试1。举个例子,有一台物理意义上的服务器,这台服务器被划分了7个节点,分别是IP.1、IP.2……IP.7,每个节点上的配置可能不大一样,以 IP.1 为例,这个节点上面有48个 CPU 核,一般情况下在IP.1上面跑数只用1个CPU核,显然这有点浪费。如果在IP.1这个节点上执行一个计算任务时,设定由此节点上的40个CPU核来同时帮忙计算,但所使用的的R包只需要指定线程数 = 48,具体任务是咋分配咋执行的都不用管,这就是多线程并行,属于隐式并行计算的范畴。如果觉得在IP.1上执行计算任务,CPU利用率飙到100%也还是太慢,可以考虑把IP.1、IP.2……IP.7这七个节点的全部CPU核都拿来一起计算,最后再把结果汇总到一起,这属于跨多节点并行,属于显示并行计算的范畴。

再捋捋对并行和串行、隐性并行和显性并行、多线程并行和多节点并行的理解。

  • 并行&串行。

    • 并行表示多个独立的任务可以同时执行。

    • 串行表示任务之间不独立、执行步骤之间有所依赖。

  • 隐式并行&显式并行。

    • 隐式并行不用写多的代码,有些并行计算库或者支持并行计算的包自带可以设置多线程并行的参数。比如在 xgboost 包的 xgb.cv 函数中有个 nthread参数,默认1,在允许的范围内,nthread 设得越大,相当于占用的 CPU 核数越多,执行越快,同时 CPU 利用率也会越高,这是在单个节点上设置多线程并行,这种情况下计算人物的分配是自动执行的。

    • 需要单独写代码分配计算资源和任务。在利用多个节点的资源进行显示并行时,同时也要求每个节点都安装了可用的 R 和 R 包,在设置好了主节点和从节点的基础上,要确保主节点能够连接上从节点。

  • 多线程并行&多节点并行(PS下面两段文字是新必应给的)。

    • 多线程并行:指在同一个进程中,多个线程同时执行不同的任务,利用单个 CPU 的多核或超线程技术来提高计算效率。多线程并行的优点是数据共享方便,开销较小;缺点是需要考虑线程安全和同步问题,受 CPU 核数和内存限制。

    • 多节点并行:指在不同的物理机器或虚拟机器上,多个进程同时执行相同或不同的任务,利用网络通信来实现数据交换和协调。多节点并行的优点是可以突破单机资源限制,提高可扩展性;缺点是数据交换成本较高,需要考虑网络延迟和负载均衡问题。

多线程并行 🔗

按照ParBayesianOptimization 包的介绍,使用并行计算要比不使用更快。此包子里面最重要的函数是bayesOpt(),需要在其中设置执行 寻参函数(比较寻找最优参数的函数,下同)的总次数(iters.n),每轮次(Epoch)执行寻参函数的次数(iters.k),那么运行轮次(Epoch)的次数为iters.n/iters.k

  • initPoints:表示初始化过程中使用拉丁超立方采样法在给定范围内选择的点的数量。这个我不大懂,个人理解就是设定一个比需要寻找的超参数的数量更大的值。

  • iters.n:表示初始化后运行寻参函数的总次数。

  • iters.k:表示每个轮次(Epoch)中运行寻参函数的次数。如果使用并行计算,建议将iters.k 设置为可用核心数的倍数,且iters.k必须小于或等于iters.n


2023年3月27日踩的一个坑,为一个极端不平衡数据寻参时,在xgb.cv()中设置early_stopping_rounds = 5报错,错误是Error in zeroOneScale(scoreSummary$Score) : Results from FUN have 0 variance, cannot build GP.。改成early_stopping_rounds = 10就不报错了,查了下原因,原来该数据每次运行xgb.cv()函数时,前8次的AUC都是0.5,导致寻参提前终止


查看寻找 XGBoost 超参数的代码(不并行)

library(xgboost)
library(ParBayesianOptimization)

train <- fread('train.csv')
trainlabel <- train[, 'label']
traindata <- train[, -c('label')]

#若要使用xgb.train,需要先将原来的数据转化为矩阵格式
traindata <- as.matrix(traindata)
trainlabel <- as.matrix(trainlabel)

Folds <- list(Fold1 = as.integer(seq(1, nrow(train), by = 3)),
              Fold2 = as.integer(seq(2, nrow(train), by = 3)),
              Fold3 = as.integer(seq(3, nrow(train), by = 3)))

scoringFunction <-
  function(eta,
           max_depth,
           min_child_weight,
           subsample,
           colsample_bytree,
           gamma,
           lambda,
           alpha) {
    dtrain <-
      xgb.DMatrix(data = traindata, label = trainlabel)
    
    Pars <- list(
      booster = "gbtree",
      objective = "binary:logistic",
      eval_metric = "auc",
      eta = eta,
      max_depth = max_depth,
      min_child_weight = min_child_weight,
      subsample = subsample,
      colsample_bytree = colsample_bytree,
      gamma = gamma,
      lambda = lambda,
      alpha = alpha)
    
    xgbcv <- xgb.cv(
      params = Pars,
      data = dtrain,
      nround = 200,
      folds = Folds,
      prediction = TRUE,
      showsd = TRUE,
      early_stopping_rounds = 8,
      maximize = TRUE,
      nthread = 4, # 设置多线程
      verbose = 0)
    
    return(list(
      Score = max(xgbcv$evaluation_log$test_auc_mean),
      nrounds = xgbcv$best_iteration
    ))
  }

bounds <- list(
  eta = c(0.001, 0.5),
  max_depth = c(2L, 10L),
  min_child_weight = c(2L, 50L),
  subsample = c(0.25, 1),
  colsample_bytree = c(0.25, 1),
  gamma = c(0, 10),
  lambda = c(0, 10),
  alpha = c(0, 10)
)

set.seed(1234)
optObj <- bayesOpt(
  FUN = scoringFunction,
  bounds = bounds,
  initPoints = 10,
  iters.n = 32
)

# 查看寻参过程
optObj$scoreSummary
# 查看寻找到的超参数
getBestPars(optObj)

若要在 XGBoost 寻参时设置多线程并行,有些细节需要注意:

  • 寻参所需的数据要加载到注册的并行后端。在包作者给的例子中,使用的案例数据为 xgboost 包自带的数据集 agaricus.train,在设置寻参函数时所写的代码是dtrain <- xgb.DMatrix(agaricus.train$data,label = agaricus.train$label),所以载入数据时写的是clusterExport(cl,c('Folds','agaricus.train'))。而键者平时用的数据是数据框格式,且训练集和标签数据是两个单独的矩阵,在设置寻参函数时所写的代码dtrain <- xgb.DMatrix(data = traindata, label = trainlabel),那么载入数据时也应该改为clusterExport(cl, c('Folds', 'traindata', 'trainlabel'))。在执行多线程并行计算时,如果没有报错,但是 CPU 利用率为0,那么可能是没有正常载入数据。

  • xgboost 包中有个nthread 参数可以设置多个线程数,ParBayesianOptimization 包也可以设置多个线程数,同时设置这两个参数时应注意两者相乘不大于总的 CPU 核数,避免因资源竞争而拖慢速度。

  • 不一定是设置的线程数越多越好,因为数据和任务被分配到不同的线程上计算也是需要花时间的,相对容易的任务单线程运行可能更快。

在设定好了寻参函数的基础上,执行多线程并行计算的代码如下。

# 多线程并行
library(foreach)
library(iterators)
library(parallel)
library(doParallel)

# 检查一下服务器上面的 CPU 核数
detectCores(logical = F)

# 设定核数,创建一个用于并行计算的虚拟集群
cl <- makeCluster(8)

# 注册并行后端
registerDoParallel(cl)

# 检查注册并行后端是否生效,假如设定8核会得到数字8
getDoParWorkers()

# 把要用到的包和全部对象(包括数据)加载到后端
clusterExport(cl, c('Folds', 'traindata', 'trainlabel'))
clusterEvalQ(cl,expr= { library(xgboost) })

optObj <- bayesOpt(
  FUN = scoringFunction,
  bounds = bounds,
  initPoints = 10,
  iters.n = 16,
  iters.k = 8,
  parallel = TRUE)

# 关闭隐式集群对象,即用 registerDoParallel() 函数注册的并行后端
stopImplicitCluster()

# 关闭显式集群对象,即用 makeCluster() 函数创建并赋值给某个变量的集群对象
stopCluster(cl)

运行效率比较 🔗

查看比较并行与不并行的时长的代码

library(xgboost)

data(agaricus.train, package = "xgboost")

Folds <- list(Fold1 = as.integer(seq(1, nrow(agaricus.train$data), by = 3)),
Fold2 = as.integer(seq(2, nrow(agaricus.train$data), by = 3)),
Fold3 = as.integer(seq(3, nrow(agaricus.train$data), by = 3)))

scoringFunction <-
  function(max_depth, min_child_weight, subsample) {
    dtrain <-
      xgb.DMatrix(agaricus.train$data, label = agaricus.train$label)
    
    Pars <- list(
      booster = "gbtree",
      eta = 0.001,
      max_depth = max_depth,
      min_child_weight = min_child_weight,
      subsample = subsample,
      objective = "binary:logistic",
      eval_metric = "auc")
    
    xgbcv <- xgb.cv(
      params = Pars,
      data = dtrain,
      nround = 100,
      folds = Folds,
      early_stopping_rounds = 5,
      maximize = TRUE,
      verbose = 0)
    
    return(list(
      Score = max(xgbcv$evaluation_log$test_auc_mean),
      nrounds = xgbcv$best_iteration
    ))
  }

bounds <- list(
  max_depth = c(1L, 5L),
  min_child_weight = c(0, 25),
  subsample = c(0.25, 1)
)

# 设置不使用并行计算
n1 <- 16
k1 <- 1
A <- system.time(
  optObj <- bayesOpt(
    FUN = scoringFunction,
    bounds = bounds,
    initPoints = 4,
    iters.n = n1,
    iters.k = k1,
    parallel = FALSE))

# 设置多线程并行
cl <- makeCluster(4)
registerDoParallel(cl)
clusterExport(cl, c('Folds', 'agaricus.train'))
clusterEvalQ(cl, expr = {
  library(xgboost)
})

n2 <- 16
k2 <- 4
B <- system.time(
  optObj <- bayesOpt(
    FUN = scoringFunction,
    bounds = bounds,
    initPoints = 4,
    iters.n = n2,
    iters.k = k2,
    parallel = TRUE))

stopCluster(cl)
registerDoSEQ()

A
B

下面 A 表示不使用并行计算,B 表示使用并行计算,每次比较 A 与 B 运行寻参函数的总次数保持一致。

设定训练 xgboost 模型时,nthread = 1

  • 总次数为16。A的线程数为1,每轮次运行1次函数,需运行16轮次。B 的线程数设为4,每轮次运行4次函数,需运行4轮次。按花费的实际时长(elapsed)看:A < B。
> A
    user   system  elapsed 
4520.928   23.765  221.648 
> B
   user  system elapsed 
  3.686   0.245 289.303 
  • 总次数为16。B线程数仍为4,B 每轮次运行8次函数,需运行2轮次。按花费的实际时长(elapsed)看,A < B。
> A
    user   system  elapsed 
4520.928   23.765  221.648 
> B
   user  system elapsed 
  2.026   0.335 237.757 
  • 总次数为16。B线程数仍为4,B 每轮次运行16次函数,需运行1轮次。按花费的实际时长(elapsed)看,A < B。
> A
    user   system  elapsed 
4520.928   23.765  221.648 
> B
   user  system elapsed 
  1.422   0.257 321.035 
  • 总次数增加到32,B 的线程数设为4,每轮次运行8次函数,一共需运行4轮次。按花费的实际时长(elapsed)看,A < B。
> A
    user   system  elapsed 
8194.374   38.245  396.726 
> B
   user  system elapsed 
  4.153   0.860 457.497 
  • 总次数仍为32,B的线程数从4提升至8,每轮次运行8次函数,一共需运行4轮次。按花费的实际时长(elapsed)看,A > B。
> A
    user   system  elapsed 
8100.211   41.690  515.498 
> B
   user  system elapsed 
  4.313   0.521 398.943 
  • 设定训练 xgboost 模型时,nthread = 4。不管寻参时是否设定并行,整体上都变快了。
    • 总次数仍为32,B的线程数设为至8,每轮次运行8次函数,一共需运行4轮次。按花费的实际时长(elapsed)看,A » B。
> A
   user  system elapsed 
399.027   2.474 378.816 
> B
   user  system elapsed 
  3.654   0.087  12.319 
  • 将 xgboost 模型中nthread = 4改为nthread = 8,其他不变,A » B,但是 B 的时长增加了。
> A
   user  system elapsed 
298.579   3.955 256.512 
> B
   user  system elapsed 
  3.905   0.213  28.357 

xgb.cv()函数和bayesOpt()函数中同时设置多线程是可能会产生冲突,前者是在给定的数据集和参数下训练模型并输出评估指标,后者是在前者的基础上选择一组新的超参数,然后新的超参数会再输入前者进行训练。两者设定的线程数相乘超过总的CPU核数时,反而导致整体运行效率减慢。


  1. 由于服务器(Linux CentOS7)上面有防火墙,只能离线下载安装 R 包,这个包有个依赖包 nloptr 装起来很麻烦,一直报错:configure: error: C++ compiler cannot create executables。由于安装 xgboost 包需要环境支持 C++14标准,对应需要 gcc6.1以上版本,但不知为何安装 nloptr 必须切换更低版本的 gcc。由于 nloptr 包本身是一个开源的非线性优化库NLopt的接口,按照nloptr 包的介绍,需要现在Linux环境中先安装2.7以上版本的NLopt。接下来我搞不定,领导帮忙装好了包,他的原话是:安装 nloptr 要用 gcc4.8.5编译一下 R,然后安装,安装成功后再用 gcc6.1.0 编译一下R,然后再编译 ParBayesianOptimization 剩下需要依赖的包就可以成功了。 ↩︎

R