大规模-MLOps-工程(四)

227 阅读1小时+

大规模 MLOps 工程(四)

原文:zh.annas-archive.org/md5/5ca914896ff49b8bc0c3f25ca845e22b

译者:飞龙

协议:CC BY-NC-SA 4.0

第十一章:超参数优化

本章涵盖内容

  • 通过超参数优化理解机器学习

  • 引入超参数优化到 DC taxi 模型

  • 可视化超参数优化实验结果

在第十章,您将 PyTorch Lightning 框架与您的 DC taxi 模型集成,提取出模板化工程代码,并为其提供超参数优化支持的途径。在本章中,您将采用一种超参数优化框架 Optuna,以进一步超越试错法来选择您的机器学习超参数值。您将训练一系列基于 Optuna 的 Tree-Structured Parzen Estimator(TPE)选择的超参数值的 DC taxi 模型实例,该模型适配了您机器学习系统中的超参数的高斯混合模型(GMM)。使用各种 Optuna 可视化图比较这些模型实例的性能。

11.1 使用 Optuna 进行超参数优化

本小节介绍了 Optuna 用于超参数优化(HPO)以及如何为 DC taxi 车费估算模型添加 HPO 支持。

Optuna 是适用于 PyTorch 的众多开源 HPO 框架之一。与其他 HPO 框架一样,Optuna 包括了一系列无梯度的优化算法,从随机搜索、贝叶斯优化到 TPE 不等。Optuna 使用trial的概念来描述计算损失函数值的过程的实例,例如基于一组超参数值计算 DcTaxiModel 模型的测试损失的实验实例。

在 Optuna 中,一个试验必须生成一个你希望最小化(或最大化)的损失函数的值。计算损失值的过程的实现通常在一个目标函数中进行捕捉。请注意,为了仅解释与理解 Optuna API 相关的部分,这个实现是有意不完整的 ❸。

代码清单 11.1 DC taxi HPO 的起始点与目标函数

def objective(trial):                          ❶
  hparams = {
    "seed": trial.suggest_int('seed',
                0,
                pt.iinfo(pt.int32).max - 1),   ❷
    "num_features": "8",
    ...                                        ❸
  }
  model, trainer = build(DcTaxiModel(**hparams),
    train_glob = "https://raw.githubusercontent.com/osipov/smlbook/
➥     master/train.csv",
    val_glob = "https://raw.githubusercontent.com/osipov/smlbook/
➥     master/valid.csv")

  return (trainer
          .callback_metrics['train_val_rmse']
          .item())                             ❹

❶ 目标函数是 Optuna 试验的标准接口。

❷ suggest_int 返回 Optuna 选择的整数,以优化目标函数。

❸ hparams 的实现在本章后面完成。

❹ Optuna 根据每次实验的结果建议调整超参数,以减小 train_val_rmse 的值。

注意,在 hparams 字典中,有一个从 Optuna 请求的整数超参数值 seed。suggest_int 方法是 Optuna trial API 中的几种方法之一,用于获取超参数的值。(试验接口中的其他可用方法在此处描述:mng.bz/v4B7。)在本例中,suggest_int('seed', 0, pt.iinfo(pt.int32).max - 1)方法调用指定 Optuna 应该为伪随机数生成器推荐从 0 到 32 位整数最大正值之前的值。

请记住,DcTaxiModel 的实现取决于其他超参数值,包括 optimizer、bins、lr(学习率)、max_batches 等。为了在 DcTaxiModel 的实现中支持这些超参数,需要扩展 hparams 字典以符合其他超参数值的 Optuna 规范。由于这些超参数的取样策略比 suggest_int 更复杂,下一节将解释一些基本概念。

11.1.1 理解 loguniform 超参数

本节提出了在训练过程中使用对数均匀超参数的合理性,并提出了应将哪些超参数设置为 DC 出租车模型的对数均匀形式。

对于许多连续的超参数,比如学习率,使用 Optuna 试验 API 的 suggest_loguniform 方法非常方便,该方法通过超参数的上限和下限值来调用。由于在 Optuna 试验 API 中,有几个连续超参数值的选项,因此有必要澄清为什么学习率应该使用 Optuna suggest_loguniform 而不是 suggest_uniform。一般来说,loguniform 更适合于搜索上限比下限大一个数量级以上的范围。这种合理性与十进制数系统的工作原理有关,下面的例子进行了解释:

x = pt.linspace(1, 1_000, 300)
#prints the 1s, 10s, 100s
print(pt.count_nonzero(x[(x > 0) & (x < 10) ]).item(),
    pt.count_nonzero(x[(x > 10) & (x < 100) ]).item(),
    pt.count_nonzero(x[(x > 100) & (x < 1_000) ]).item())

输出结果为

3 27 269

由于 x 包含了从 0 到 1,000(三个数量级)的 300 个浮点值,因此 print 语句输出了在每个数量级范围内出现的值的计数(即,从 0 到 10,从 10 到 100,从 100 到 1,000)。在这个例子中,在 0 到 10 的范围内的 x 值的数量与在 100 到 1,000 范围内的值的数量之间大约相差 100 倍,或者更确切地说是 3 与 269。

一般来说,从一个线性范围中以均匀概率取样,可以预期从较大范围中获得的样本平均要多 10N倍。log 函数的应用消除了由于十进制数系统导致的这种无意的偏差,下面的例子证明了这一点:

y = pt.logspace(pt.log10(pt.tensor(1)),
                pt.log10(pt.tensor(1_000)),  300)

#prints the 1s, 10s, 100s
print(pt.count_nonzero(y[(y > 0) & (y < 10) ]).item(),
    pt.count_nonzero(y[(y > 10) & (y < 100) ]).item(),
    pt.count_nonzero(y[(y > 100) & (y < 1_000) ]).item())

输出结果为

100 100 99

由于使用了以对数 10 为底的比例尺,示例中展示了约 300 个样本在整个从 1 到 1,000 的范围内大致相等地分布。

在对离散整数值进行对数尺度范围上的超参数值取样的概念,适用于连续值一样。例如,max_batches 超参数可以使用 Optuna Trial API 函数调用 suggest_int('max_batches', 40, 4000, log = True)进行初始化,因为调用中的下限和上限值跨越了一个数量级以上的范围。

11.1.2 使用分类和对数均匀超参数

本节基于你学到的关于对数均匀超参数的知识,解释了如何通过 Optuna 从对数均匀尺度中采样的超参数来初始化优化器学习率。

优化器和相应的学习率是你可能想要包含在 HPO 试验中的其他超参数之一。由于优化器学习率最好用连续值表示,跨越几个数量级的范围,因此应该在试验中使用 suggest_loguniform 方法进行优化。这可以如下实现,对于候选学习率在范围 [0.001, 0.1) 中的值:

hparams = {
...
"lr": trial.suggest_loguniform('lr', 0.001, 0.1),    ❶
...
}

❶ 使用来自对数均匀范围 [0.001, 0.1) 的优化器学习率。

由于 DcTaxiModel 的 configure_optimizers 方法已经包含了对随机梯度下降和 Adam 的支持,因此你可以让 Optuna 在 objective 方法的实现中建议这些值的选择(SGD 或 Adam)。这需要在试验对象的 suggest_categorical 方法中使用,如下所示:

hparams = {
...
"optimizer": \
  trial.suggest_categorical('optimizer',
                            ['Adam', 'SGD']),    ❶
...
}

❶ 在每个 HPO 试验中,使用 Adam 和 SGD 作为优化器选项。

试验 API 的参数是在程序运行时计算的,这意味着你可以使用标准的 Python 特性来更加表达超参数值的规范。例如,batch_size 超参数可以使用一个整数列表来指定,其中整数是动态生成的,解析为从 2¹⁶ 到 2²¹ 的二次幂,换句话说,值为 [65536, 131072, 262144, 524288, 1048576, 2097152]

hparams = {
  ...
  "batch_size": \
    trial.suggest_categorical('batch_size',
                  [2 ** i for i in range(16, 22)]),    ❶
  ...
}

❶ 在使用 suggest_categorical 方法之前,预先计算一个 Python 列表的超参数值。

展示了一个更有趣的 suggest_categorical 应用,实现了 num_hidden_neurons 超参数的规范化。

列表 11.2 Optuna 试验以发现神经网络架构

hparams = {
  ...
  "num_hidden_neurons": \                                            ❶
    [trial.suggest_categorical(f"num_hidden_layer_{layer}_neurons",
                              [7, 11, 13, 19, 23]) for layer in \
                              range(trial.suggest_categorical('num_layers',
                                                       [11, 13, 17, 19]))],
  ...
}

❶ 将网络架构指定为一个超参数(例如,[5, 11, 7])。

DcTaxiModel 可以使用隐藏层中神经元数量的字符串表示来构建其模型层。例如,一个字符串表示 [3, 5, 7, 8] 可以表示四个隐藏层,其中第一层有三个神经元,第二层有五个,依此类推。

这种类型的规范可以通过一系列的 suggest_categorical 调用来实现为 Optuna 超参数。首先,Optuna 为隐藏层的总数(num_layers)分配一个值,该值基于列表中的可能隐藏层数量 [11, 13, 17, 19],然后,根据 Optuna 为隐藏层数量(num_layers)选择的值,通过 for 运算符多次调用下一个 suggest_categorical 调用,每个隐藏层调用一次。每个调用都会更改对 layer 变量的赋值,并实例化一个新的超参数,例如架构中的第一层的 num_hidden_layer_0_neurons,第二层的 num_hidden_layer_1_neurons,依此类推,具体取决于超参数层数(num_layers)的值。为每个超参数(描述每个隐藏层的神经元数量)分配的值来自不同的 suggest_categorical 列表,指定为 [7, 11, 13, 19, 23]。最终,num_hidden_neurons 解析为一个 Python 列表,其中包含 Optuna 提出的隐藏层和神经元的配置。

将这些超参数与目标函数的整个实现结合起来,结果如下:

def objective(trial):
  hparams = {
    "seed": trial.suggest_int('seed', 0, pt.iinfo(pt.int32).max - 1),
    "num_features": "8",
    "optimizer": trial.suggest_categorical('optimizer', ['Adam', 'SGD']),
    "lr": trial.suggest_loguniform('lr', 0.009, 0.07),
    "num_hidden_neurons": \
      str([trial
➥           .suggest_categorical(f"num_hidden_layer_{layer}_neurons",
            [7, 11]) for layer in \
              range(trial.suggest_categorical('num_layers', [2, 3]))]),
    "batch_size": trial.suggest_int('batch_size', 30, 50, log = True),
    "max_batches": trial.suggest_int('max_batches', 30, 50, log = True)
    "batch_norm_linear_layers": \
      str(trial.suggest_int('batch_norm_linear_layers', 0, 1)),
  }
  model, trainer = build(DcTaxiModel(**hparams),
    train_glob = 'https://raw.githubusercontent.com/osipov/smlbook/
➥     master/train.csv',
    val_glob = 'https://raw.githubusercontent.com/osipov/smlbook/
➥     master/valid.csv')

  return trainer.callback_metrics['train_val_rmse'].item()

11.2 神经网络层配置作为超参数

本节介绍如何扩展 DC 出租车模型以支持深度学习模型,每层隐藏层和每层神经元数都是任意的。该部分还描述了如何使用超参数值来指定此配置。

不是随意选择神经网络参数的配置(例如,层数或每层的神经元数),而是将配置视为要优化的超参数是有价值的。尽管 PyTorch 和 PyTorch Lightning 都没有直接的技术来优化神经网络配置,但是您可以轻松实现一个实用方法来将网络的隐藏层的字符串表示转换为与字符串镜像的 nn.Module 实例集合。例如,字符串 [3, 5, 8] 可以表示具有三个隐藏层的神经网络,第一层有三个神经元,第二层有五个神经元,第三层有八个神经元。以下代码片段中显示的 build_hidden_layers 实用方法实现了从字符串到 torch.nn.Linear 实例的转换,以及任意激活函数:

def build_hidden_layers(self, num_hidden_neurons, activation):
  linear_layers = \                                            ❶
    [ pt.nn.Linear(num_hidden_neurons[i],
      num_hidden_neurons[i+1]) for i in range(len(num_hidden_neurons) - 1) ]

  classes = \                                                  ❷
    [activation.__class__] * len(num_hidden_neurons)

  activation_instances = \                                     ❸
    list(map(lambda x: x(), classes))

  hidden_layer_activation_tuples = \                           ❹
    list(zip(linear_layers, activation_instances))

  hidden_layers = \                                            ❺
    [i for sublist in hidden_layer_activation_tuples for i in sublist]

  return hidden_layers

❶ 创建一个线性(前馈)层的 Python 列表...

❷ . . . 并创建一个匹配长度的激活类列表。

❸ 将激活类转换为激活实例。

❹ 将线性层与激活函数实例进行压缩。

❺ 将结果作为平面 Python 列表返回。

在 DcTaxiModel 的实现中,添加了 build_hidden_layers 实用程序方法,现在您可以修改 init 方法以使用 build_hidden_layers 如下:

import json
import torch as pt
import pytorch_lightning as pl
class DcTaxiModel(pl.LightningModule):
  def __init__(self, hparams = None):
    super().__init__()
    self.hparams = hparams

    pt.manual_seed(self.hparams['seed'])

    num_hidden_neurons = \                           ❶
      json.loads(self.hparams.num_hidden_neurons)

    self.layers = \                                  ❷
      pt.nn.Sequential(
        pt.nn.Linear(int(self.hparams.num_features), num_hidden_neurons[0]),
        pt.nn.ReLU(),
        *self.build_hidden_layers(num_hidden_neurons, pt.nn.ReLU()),
        pt.nn.Linear(num_hidden_neurons[-1], 1)
    )

model = build(DcTaxiModel(**{
        "seed": "1686523060",
        "num_features": "8",
        "num_hidden_neurons": "[3, 5, 8]",           ❸
        "optimizer": "Adam",
        "lr": "0.03",
        "max_batches": "100",
        "batch_size": "100",}),
  train_glob = 'https://raw.githubusercontent.com/osipov/smlbook/
➥                 master/train.csv',
  val_glob = 'https://raw.githubusercontent.com/osipov/smlbook/
➥               master/valid.csv',
  test_glob = 'https://raw.githubusercontent.com/osipov/smlbook/
➥               master/train.csv')

❶ 创建一个隐藏层神经元的列表(例如[3, 5, 8])。

❷ 对模型使用前馈隐藏层的序列。

❸ 使用字符串格式指定隐藏层。

在示例中,使用 json.loads 方法将隐藏层字符串表示(例如[3, 5, 8])转换为 Python 整数值列表。此外,虽然 self.layers 参考神经网络模型仍然具有四个输入特征和一个输出值,但隐藏值是通过将 build_hidden_layers 方法中的 torch.nn.Linear 实例的列表展开为作为 torch.nn.Sequential 初始化程序传递的单个对象来指定的。

11.3 使用批量归一化超参数进行实验

批量归一化是一种广泛使用的技术,可以增加梯度下降收敛率。²尽管批量归一化被广泛使用,但收集证明它可以帮助创建更有效的 DC 出租车模型的数据仍然很有用。

由于批量归一化特性应根据布尔型 HPO 标志启用或禁用,因此引入一种自动重连 DC 出租车模型的图层以利用此特性的方法是很有用的。以下 batch_norm_linear 方法在模型中的每个 torch.nn.Linear 层之前自动插入 PyTorch torch.nn.BatchNorm1d 类实例。以下实现还正确配置每个 BatchNorm1d 实例,使其具有正确数量的输入,与相应的 Linear 层的输入数相匹配,应遵循 BatchNorm1d:

def batch_norm_linear(self, layers):
  idx_linear = \                                                   ❶
    list(filter(lambda x: type(x) is int,
      [idx if issubclass(layer.__class__, pt.nn.Linear) else None \
        for idx, layer in enumerate(layers)]))

  idx_linear.append(sys.maxsize)                                   ❷

  layer_lists = \                                                  ❸
    [list(iter(layers[s:e])) \
      for s, e in zip(idx_linear[:-1], idx_linear[1:])]

  batch_norm_layers = \                                            ❹
    [pt.nn.BatchNorm1d(layer[0].in_features) for layer in layer_lists]

  batch_normed_layer_lists = \                                     ❺
    [ [bn, *layers] for bn, layers in \
      list(zip(batch_norm_layers, layer_lists)) ]

  result = \                                                       ❻
    pt.nn.Sequential(*[layer for nested_layer in \
      batch_normed_layer_lists for layer in nested_layer ])

  return result

❶ 创建一个列表,其中包含模型中线性类的位置索引。

❷ 使用最大整数值作为列表的最后一个元素,表示无限值。

❸ 创建带有 s 作为每个 Linear 的索引和 e 作为每个 Linear 之前的索引的子列表。

❹ 使用与对应的 Linear 输入匹配的输入实例化 BatchNorm1d。

❺ 在对应的 Linear 之前插入 BatchNorm1d 实例。

❻ 将所有的 BatchNorm1d 和 Linear 层序列打包成 Sequential。

一旦将 batch_norm_linear 方法添加到 DcTaxiModel 类中,就应该修改该类的 init 方法(列表 11.3 ❶),以根据 batch_norm_linear_layers 超参数的值应用批量归一化。

列表 11.3 使用可选批量归一化

from distutils.util import strtobool

def __init__(self, **kwargs):
  super().__init__()
  self.save_hyperparameters()

  self.step = 0
  self.start_ts = time.perf_counter()
  self.train_val_rmse = pt.tensor(0.)

  pt.manual_seed(int(self.hparams.seed))
  #create a list of hidden layer neurons, e.g. [3, 5, 8]
  num_hidden_neurons = json.loads(self.hparams.num_hidden_neurons)

  self.layers = pt.nn.Sequential(
      pt.nn.Linear(int(self.hparams.num_features), num_hidden_neurons[0]),
      pt.nn.ReLU(),
      *self.build_hidden_layers(num_hidden_neurons, pt.nn.ReLU()),
      pt.nn.Linear(num_hidden_neurons[-1], 1)
  )

  if 'batch_norm_linear_layers' in self.hparams \          ❶
    and strtobool(self.hparams.batch_norm_linear_layers):
    self.layers = self.batch_norm_linear(self.layers)

❶ 如果 batch_norm_linear_layers 为 True,则对 Linear 层进行批量归一化。

使用批量归一化后,DcTaxiModel 和匹配的构建方法已准备好用于 HPO。

列表 11.4 DcTaxiModel 实现支持 HPO

import sys
import json
import time
import torch as pt
import pytorch_lightning as pl
from distutils.util import strtobool
from torch.utils.data import DataLoader
from kaen.torch import ObjectStorageDataset as osds
pt.set_default_dtype(pt.float64)

class DcTaxiModel(pl.LightningModule):
    def __init__(self, **kwargs):
      super().__init__()
      self.save_hyperparameters()

      self.step = 0
      self.start_ts = time.perf_counter()
      self.train_val_rmse = pt.tensor(0.)

      pt.manual_seed(int(self.hparams.seed))
      #create a list of hidden layer neurons, e.g. [3, 5, 8]
      num_hidden_neurons = json.loads(self.hparams.num_hidden_neurons)

      self.layers = \
        pt.nn.Sequential(
          pt.nn.Linear(int(self.hparams.num_features),
                              num_hidden_neurons[0]),
          pt.nn.ReLU(),
          *self.build_hidden_layers(num_hidden_neurons, pt.nn.ReLU()),
          pt.nn.Linear(num_hidden_neurons[-1], 1)
        )

      if 'batch_norm_linear_layers' in self.hparams \
        and strtobool(self.hparams.batch_norm_linear_layers):
        self.layers = self.batch_norm_linear(self.layers)

    def build_hidden_layers(self, num_hidden_neurons, activation):
      linear_layers = [ pt.nn.Linear(num_hidden_neurons[i],
          num_hidden_neurons[i+1]) for i in \
            range(len(num_hidden_neurons) - 1) ]

      classes = [activation.__class__] * len(num_hidden_neurons)

      activation_instances = list(map(lambda x: x(), classes))

      hidden_layer_activation_tuples = \
        list(zip(linear_layers, activation_instances))

      hidden_layers = \
        [i for sublist in hidden_layer_activation_tuples for i in sublist]

      return hidden_layers

    def batch_norm_linear(self, layers):
      idx_linear = \
        list(filter(lambda x: type(x) is int,
        [idx if issubclass(layer.__class__, pt.nn.Linear) else None \
          for idx, layer in enumerate(layers)]))

      idx_linear.append(sys.maxsize)
      layer_lists = \
        [list(iter(layers[s:e])) \
          for s, e in zip(idx_linear[:-1], idx_linear[1:])]
      batch_norm_layers = \
        [pt.nn.BatchNorm1d(layer[0].in_features) for layer in layer_lists]
      batch_normed_layer_lists = \
        [ [bn, *layers] for bn, layers in \
          list(zip(batch_norm_layers, layer_lists)) ]

      return \
        pt.nn.Sequential(*[layer \
          for nested_layer in batch_normed_layer_lists \
          for layer in nested_layer ])

    def batchToXy(self, batch):
      batch = batch.squeeze_()
      X, y = batch[:, 1:], batch[:, 0]
      return X, y

    def forward(self, X):
      y_est = self.layers(X)
      return y_est.squeeze_()

    def training_step(self, batch, batch_idx):
        self.step += 1

        X, y = self.batchToXy(batch) #unpack batch into features and label

        y_est = self.forward(X)

        loss = pt.nn.functional.mse_loss(y_est, y)

        for k,v in {

          "train_mse": loss.item(),
          "train_rmse": loss.sqrt().item(),
          "train_steps_per_sec": \
            self.step / (time.perf_counter() - self.start_ts),

        }.items():
          self.log(k, v, on_step=True, on_epoch=True,
                          prog_bar=True, logger=True)

        self.train_val_rmse = loss.sqrt().item()

        return loss

    def validation_step(self, batch, batch_idx):
      X, y = self.batchToXy(batch)

      with pt.no_grad():
          loss = pt.nn.functional.mse_loss(self.forward(X), y)

      for k,v in {
        "val_mse": loss.item(),
        "val_rmse": loss.sqrt().item(),
        "train_val_rmse": self.train_val_rmse + loss.sqrt().item(),
      }.items():
        self.log(k, v, on_step=True, on_epoch=True,
                        prog_bar=True, logger=True)
      return loss

    def test_step(self, batch, batch_idx):
      X, y = self.batchToXy(batch)

      with pt.no_grad():
          loss = pt.nn.functional.mse_loss(self.forward(X), y)

      for k,v in {
          "test_mse": loss.item(),
          "test_rmse": loss.sqrt().item(),
      }.items():
          self.log(k, v, on_step=True, on_epoch=True,
                          prog_bar=True, logger=True)

    def configure_optimizers(self):
        optimizers = {'Adam': pt.optim.AdamW,
                      'SGD': pt.optim.SGD}
        optimizer = optimizers[self.hparams.optimizer]

        return optimizer(self.layers.parameters(),
                            lr = float(self.hparams.lr))

def build(model, train_glob, val_glob, test_glob = None):
  csvLog = CSVLogger(save_dir = "logs",
                    name = "dctaxi",
                    version = f"seed_{model.hparams.seed}")

  trainer = pl.Trainer(gpus = pt.cuda.device_count() \
                                if pt.cuda.is_available() else 0,
    max_epochs = 1,
    limit_train_batches = int( model.hparams.max_batches ) \
                                if 'max_batches' in model.hparams else 1,
    limit_val_batches = 1,
    num_sanity_val_steps = 1,
    val_check_interval = min(20, int( model.hparams.max_batches ) ),
    limit_test_batches = 1,
    log_every_n_steps = 1,
    logger = csvLog,
    gradient_clip_val=0.5,
    progress_bar_refresh_rate = 0,
    weights_summary = None,)

  train_dl = \
    DataLoader(osds(train_glob,
                    batch_size = int(model.hparams.batch_size) ),
               pin_memory = True)

  val_dl = \
    DataLoader(osds(val_glob,
                    batch_size = int(model.hparams.batch_size) ),
               pin_memory = True)
  trainer.fit(model,
              train_dataloaders = train_dl,
              val_dataloaders = val_dl)

  if test_glob is not None:
    test_dl = \
      DataLoader(osds(test_glob,
                      batch_size = int(model.hparams.batch_size) ),
                pin_memory = True)

    trainer.test(model,
                dataloaders=test_dl)

  return model, trainer

model, trainer = build(DcTaxiModel(**{
        "seed": "1686523060",
        "num_features": "8",
        "num_hidden_neurons": "[3, 5, 8]",
        "batch_norm_linear_layers": "1",
        "optimizer": "Adam",
        "lr": "0.03",
        "max_batches": "100",
        "batch_size": "100",}),

  train_glob = 'https://raw.githubusercontent.com/osipov/smlbook/
➥                 master/train.csv',
  val_glob = 'https://raw.githubusercontent.com/osipov/smlbook/
➥                 master/valid.csv',
  test_glob = 'https://raw.githubusercontent.com/osipov/smlbook/
➥                 master/train.csv')

11.3.1 使用 Optuna 研究进行超参数优化

本节介绍了 Optuna 研究的概念,描述了它与 Optuna 试验的关系,并帮助您使用研究实例来运行和分析 HPO 实现中的一组试验。

在 Optuna 中,目标函数负责执行单个试验,包括从 Optuna 中检索超参数值、训练模型,然后根据验证损失评估训练模型的步骤。因此,每个试验仅向 Optuna HPO 算法返回单个评估指标,以便决定下一组建议的超参数值用于下一个试验。典型的 HPO 过程涉及几十甚至几百次试验;因此,有能力组织试验、比较其结果并分析试验中涉及的超参数是很重要的。在 Optuna 中,研究扮演着相关试验的容器角色,并提供有关试验结果和相关超参数的表格数据以及可视化数据。

如图 11.5 所示,通过一个目标函数,一个研究由优化方向(例如,最小化或最大化函数)和采样器来定义,采样器基于 Optuna 支持的许多 HPO 算法和框架之一实例化。用于初始化研究的种子(图 11.5 ❷)与用于初始化 PyTorch 和 NumPy 的种子值不同。在使用 HPO 时,此种子可用于创建下游随机数生成器的种子值,包括 Python 自己的随机数生成器。尽管 HPO 种子值从优化 DcTaxiModel 机器学习性能的角度来看毫无用处,但它确实具有确保 HPO 试验的可重现性的重要目的。

列表 11.4 中显示了 DcTaxiModel 的整个 HPO 实现。

列表 11.5 用于执行 HPO 的 Optuna 研究

def objective(trial):
  hparams = {
    "seed": trial.suggest_int('seed', 0, pt.iinfo(pt.int32).max - 1),

    "num_features": "8",

    "batch_norm_linear_layers": \
      str(trial.suggest_int('batch_norm_linear_layers', 0, 1)),

    "optimizer": trial.suggest_categorical('optimizer', ['Adam', 'SGD']),

    "lr": trial.suggest_loguniform('lr', 0.009, 0.07),

    "num_hidden_neurons": \
      str([trial.suggest_categorical(f"num_hidden_layer_{layer}_neurons",
            [7, 11]) for layer in \
              range(trial.suggest_categorical('num_layers', [2, 3]))]),

    "batch_size": trial.suggest_int('batch_size', 30, 50, log = True),

    "max_batches": trial.suggest_int('max_batches', 30, 50, log = True)
  }
  model, trainer = build(DcTaxiModel(**hparams),
    train_glob = 'https://raw.githubusercontent.com/osipov/smlbook/
➥                   master/train.csv',
    val_glob = 'https://raw.githubusercontent.com/osipov/smlbook/
➥                   master/valid.csv')

  return trainer.callback_metrics['train_val_rmse'].item()

import optuna
from optuna.samplers import TPESampler                       ❶
study = \
  optuna.create_study(direction = 'minimize',                ❷
                      sampler = TPESampler( seed = 42 ),)

study.optimize(objective, n_trials = 100)                    ❸

❶ 配置研究以最小化 DcTaxiModel 的 MSE 损失。

❷ 使用 TPE 算法进行 HPO。

❸ 使用 100 次试验开始 HPO。

在执行列表 11.5 中的代码后,study.optimize 方法完成了 100 次 HPO 试验。使用以下方式可获得各个试验的详细信息

study_df = study.trials_dataframe().sort_values(by='value',
                                                ascending = True)
study_df[:5][['number', 'value', 'params_seed']],

应返回一个类似以下值的 pandas 数据帧:

numbervalueparams_seed
962.3905411372300804
567.4033451017301131
719.006614939699871
749.139935973536326
949.8177461075268021

其中,数字列指定由 Optuna 建议的试验的索引,值是由目标方法中的 trainer.callback_ metrics['train_val_rmse'].item() 返回的损失函数的相应值,params_seed 是用于初始化 DcTaxiModel 的模型参数(权重)的种子值。

11.3.2 在 Optuna 中可视化 HPO 研究

本节介绍了本章中使用三种不同 Optuna 可视化执行的 HPO 研究,并比较了这些可视化图在 HPO 方面的相关性。

完成的研究实例也可以使用 Optuna 可视化包进行可视化。虽然全面概述 Optuna 中各种可视化的范围超出了本书的范围,但我发现自己在一系列机器学习模型中一直重复使用三个可视化图。这些可视化图将在本节的其余部分按重要性下降的顺序进行解释。

超参数重要性图揭示了关于超参数对目标函数相对影响的惊人信息。在列表中包含种子超参数特别有用,以评估某些超参数是否比仅用于模型初始化的随机变量具有更多或更少的重要性。比随机种子更重要的超参数值得进一步研究,而比随机变量不重要的超参数应该降低优先级。

要创建重要性图,您可以使用

optuna.visualization.plot_param_importances(study)

这应该会生成类似于图 11.1 的条形图。

11-01

图 11.1 重要性图有助于指导后续 HPO 迭代。

一旦您确定了要更详细探讨的一组超参数,下一步就是在平行坐标图上绘制它们。您可以使用以下命令实例化此图。

optuna.visualization.plot_parallel_coordinate(study,
    params=["lr", "batch_size", "num_hidden_layer_0_neurons"])

这会绘制学习率(lr)、批量大小和 num_hidden_layer_0_neurons 超参数之间的关系。请注意,在图 11.2 中,线条代表各个试验配置,颜色较深的线条对应于目标函数值较低的试验。因此,通过某个超参数的一系列较深线条穿过一定区间,表明该超参数区间值得更仔细检查,可能需要进行另一次 HPO 迭代。

11-02

图 11.2 平行坐标图有助于确定超参数值的影响区间。

到目前为止描述的图中,轮廓图在生成有关研究结果的见解方面排在最后。由于轮廓图仅限于可视化超参数值对的图形,您会发现自己生成多个轮廓图,通常基于从重要性图或平行坐标图中选择的超参数。例如,要绘制批量大小、学习率和目标函数之间的关系,您可以运行

optuna.visualization.plot_contour(study, params=["batch_size", "lr"])

这应该生成类似于图 11.3 的图形。

11-03

图 11.3 轮廓图有助于分析与损失函数相关的超参数对。

摘要

  • Optuna 是一个超参数优化框架,具有与本机 Python 集成,可支持机器学习模型实验中的复杂超参数配置。

  • 当使用 HPO 用于跨量级的超参数范围时,采用对数均匀采样是有用的,以确保样本在范围内均匀分布而不是倾斜分布。

  • 在执行 HPO 试验之后,Optuna 的可视化功能有助于分析试验结果和相关的超参数。

^(1.)无梯度算法不需要计算损失(或任何目标)函数的梯度来优化函数参数。换句话说,无梯度的超参数优化可以在目标函数没有可计算梯度的情况下进行优化。

^(2.)最初广泛引用的论文《Batch Normalization: Accelerating Deep Network Training by Reducing Internal Covariate Shift》介绍了批归一化,并可从arxiv.org/abs/1502.03167获取。

第十二章:机器学习管道

本章包括

  • 了解具有实验管理和超参数优化的机器学习管道

  • 为了减少样板代码,为 DC 出租车模型实现 Docker 容器

  • 部署机器学习管道以训练模型

到目前为止,你已经学习了机器学习的各个独立阶段或步骤。一次只专注于机器学习的一个步骤有助于集中精力处理更可管理的工作范围。然而,要部署一个生产机器学习系统,有必要将这些步骤集成到一个单一的管道中:一个步骤的输出流入到管道后续步骤的输入中。此外,管道应该足够灵活,以便启用超参数优化(HPO)过程来管理并对管道各阶段执行的具体任务进行实验。

在本章中,您将了解到用于集成机器学习管道、部署到 AWS 并使用实验管理和超参数优化训练 DC 出租车车费估算机器学习模型的概念和工具。

12.1 描述机器学习管道

本节介绍了解释本章描述的机器学习管道实现所需的核心概念。

为了澄清本章描述的机器学习管道的范围,有助于从整个管道的输入和输出描述开始。在输入方面,管道期望的是从探索性数据分析(EDA)和数据质量(数据清理)过程产生的数据集。机器学习管道的输出是一个或多个训练好的机器学习模型,这意味着管道的范围不包括将模型部署到生产环境的步骤。由于管道的输入和输出要求人机交互(EDA 和数据质量)或可重复自动化(模型部署),它们都不在 HPO 的范围内。

要了解机器学习管道所需的期望特性,请参考图 12.1。

12-01

图 12.1 一个统一的机器学习管道可以在每个阶段进行超参数优化。

在图中,数据准备、特征工程和机器学习模型训练阶段由 HPO 管理。使用由 HPO 管理的阶段可能会导致关于是否

  • 在数据准备阶段,具有缺失数值特征的训练示例将从训练数据集中删除或更新为将缺失值替换为特征的预期(均值)值

  • 在特征工程阶段,数值位置特征(如纬度或经度坐标)将通过分箱转换为具有 64 或 128 个不同值的分类特征

  • 在机器学习训练阶段,模型使用随机梯度下降(SGD)或 Adam 优化器进行训练

尽管实施图 12.1 中的管道可能看起来很复杂,但通过使用一系列 PyTorch 和配套框架,您将能够在本节结束时部署它。本节中管道的实施依赖于以下技术:

  • MLFlow — 用于开源实验管理

  • Optuna — 用于超参数优化

  • Docker — 用于管道组件打包和可重复执行

  • PyTorch Lightning — 用于 PyTorch 机器学习模型训练和验证

  • Kaen — 用于跨 AWS 和其他公共云提供商的管道供应和管理

在继续之前,总结一下将更详细地描述管道的 HPO 方面的关键概念是有帮助的。图 12.2 中的图表澄清了管道、HPO 和相关概念之间的关系。像 MLFlow 这样的实验管理平台(其他示例包括 Weights & Biases、Comet.ML 和 Neptune.AI)存储和管理实验实例,以便每个实例对应于不同的机器学习管道。例如,实验管理平台可以为实现训练 DC 出租车票价估算模型的机器学习管道存储一个实验实例,并为用于在线聊天机器人的自然语言处理模型训练的机器学习管道存储一个不同的实验实例。实验实例彼此隔离,但由单个实验管理平台管理。

12-02

图 12.2 实验管理器根据 HPO 设置控制管道执行(作业)实例。

每个实验实例使用 父运行 作为一个或多个机器学习管道执行(子运行)的集合。父运行配置为应用于多个管道执行的设置,例如 HPO 引擎(如 Optuna)使用的伪随机数种子的值。父运行还指定应执行以完成父运行的子运行(机器学习管道执行)的总数。由于每个机器学习管道执行还对应于一组唯一的超参数键/值对的组合,父运行指定的子运行数还指定了 HPO 引擎应生成的 HPO 试验(值集)的总数,以完成父运行。

机器学习管道代码与实验管理、超参数优化和机器学习模型训练的服务一起部署为 Docker 容器,在云提供商的虚拟专用云(VPC)网络中相互连接。该部署如图 12.3 所示。

12-03

图 12.3 具有 HPO 的机器学习管道部署为包含至少一个管理节点和一个工作节点以及可选管理节点和工作节点的一组 Docker 容器。

如图所示,为了部署具有 HPO 的机器学习管道,至少需要两个 Docker 容器在虚拟专用云网络上连接,部署中至少有一个管理器和至少一个工作节点。管理节点托管具有

  • 实验管理服务(例如 MLFlow)

  • HPO 引擎(例如 Optuna)作为与实验管理集成的服务运行(例如 Kaen 的 BaseOptunaService)

  • 实验管理用户界面

  • 工作节点管理服务,用于在工作节点上安排和编排机器学习管道子运行

工作节点托管具有机器学习模型(例如 PyTorch 代码)的 Docker 容器,以及描述如何根据超参数(例如 PyTorch Lightning 代码)训练、验证和测试机器学习模型的代码。

请注意,管理节点和工作节点的生命周期与节点上 Docker 容器执行的生命周期不同。这意味着相同的节点可以托管多个容器实例的执行和多个机器学习管道运行,而无需进行配置或取消配置。此外,管理节点上的容器是长时间运行的,例如为了在多个机器学习管道执行期间提供实验服务用户界面和超参数优化引擎服务,而工作节点上的容器仅在机器学习管道执行期间保持运行。

尽管本节描述的部署配置可能看起来复杂,但节点的提供、机器学习中间件(实验管理、超参数优化等)以及机器学习管道在工作节点之间的执行的编排完全由 Kaen 框架和相关的 Docker 容器处理。您将在本章后面学习有关该框架以及如何在现有的 Kaen 容器上构建您的机器学习管道的更多信息。

12.2 使用 Kaen 启用 PyTorch 分布式训练支持

本节说明了如何使用 PyTorch DistributedDataParallel 类添加 PyTorch 分布式训练支持。通过本节的结束,DC 出租车费用模型的 train 方法将被扩展以与云环境中的分布式训练框架 Kaen 集成。

与本书前几章的代码和 Jupyter 笔记本说明不同,本章剩余部分的代码要求您的环境已安装 Docker 和 Kaen。有关安装 Docker 和开始使用的更多信息,请参阅附录 B。要将 Kaen 安装到已存在 Docker 安装的环境中,请执行

pip install kaen[cli,docker]

这将下载并安装 kaen 命令行界面(CLI)到您的 shell 环境中。例如,如果 Kaen 安装正确,您可以使用以下命令获取有关 Kaen 命令的帮助:

kaen --help

这应该会产生类似以下的输出:

Usage: kaen [OPTIONS] COMMAND [ARGS]...

Options:
  --help  Show this message and exit.

Commands:
  dojo     Manage a dojo training environment.
  hpo      Manage hyperparameter optimization.
  init     Initialize a training dojo in a specified infrastructure...
  job      Manage jobs in a specific dojo training environment.
  jupyter  Work with a Jupyter notebook environment.

要执行本书其余部分的说明,请启动 Kaen Jupyter 环境,使用以下命令:

kaen jupyter

从您的 shell 环境中执行以下命令,这应该会在您本地的 Docker 主机上启动一个专门的 Jupyter 笔记本环境作为一个新的 Docker 容器。kaen jupyter 命令还应该将您的默认浏览器导航到 Jupyter 主页,并在 shell 中输出类似以下的文本:

Started Jupyter. Attempting to navigate to Jupyter in your browser using
➥ http://127.0.0.1:8888/?token=...

它指定了您可以在浏览器中使用的 URL,以打开新启动的 Jupyter 实例。

在 Jupyter 环境中,创建并打开一个新的笔记本。例如,您可以将笔记本命名为 ch12.ipynb。作为笔记本中的第一步,您应该执行 shell 命令

!mkdir -p src

在此环境中为您的代码创建一个 src 目录。请记住,在 Jupyter 中的 Python 代码单元格中使用感叹号!时,其后的命令将在底层的 bash shell 中执行。因此,运行该代码的结果是在文件系统中创建一个 src 目录。

接下来,使用 %%writefile 魔术将 DC 出租车模型的最新版本(如第十一章所述)保存到 src 目录中的 model_v1.py 文件中。

列表 12.1 将实现保存到 model_v1.py

%%writefile src/model_v1.py
import sys
import json
import time
import torch as pt
import pytorch_lightning as pl
from distutils.util import strtobool

pt.set_default_dtype(pt.float64)
class DcTaxiModel(pl.LightningModule):
    def __init__(self, **kwargs):
      super().__init__()
      self.save_hyperparameters()
      pt.manual_seed(int(self.hparams.seed))

      self.step = 0
      self.start_ts = time.perf_counter()
      self.train_val_rmse = pt.tensor(0.)

      #create a list of hidden layer neurons, e.g. [3, 5, 8]
      num_hidden_neurons = json.loads(self.hparams.num_hidden_neurons)

      self.layers = \
        pt.nn.Sequential(
          pt.nn.Linear(int(self.hparams.num_features),
                        num_hidden_neurons[0]),
          pt.nn.ReLU(),
          *self.build_hidden_layers(num_hidden_neurons, pt.nn.ReLU()),
          pt.nn.Linear(num_hidden_neurons[-1], 1)
      )

      if 'batch_norm_linear_layers' in self.hparams \
        and strtobool(self.hparams.batch_norm_linear_layers):
        self.layers = self.batch_norm_linear(self.layers)

    def build_hidden_layers(self, num_hidden_neurons, activation):
      linear_layers = [ pt.nn.Linear(num_hidden_neurons[i],
          num_hidden_neurons[i+1]) \
            for i in range(len(num_hidden_neurons) - 1) ]

      classes = [activation.__class__] * len(num_hidden_neurons)

      activation_instances = list(map(lambda x: x(), classes))

      hidden_layer_activation_tuples = \
        list(zip(linear_layers, activation_instances))

      hidden_layers = [i for sublist in \
        hidden_layer_activation_tuples for i in sublist]

      return hidden_layers

    def batch_norm_linear(self, layers):
      idx_linear = \
        list(filter(lambda x: type(x) is int,
            [idx if issubclass(layer.__class__, pt.nn.Linear) else None \
              for idx, layer in enumerate(layers)]))
      idx_linear.append(sys.maxsize)
      layer_lists = [list(iter(layers[s:e])) \
        for s, e in zip(idx_linear[:-1], idx_linear[1:])]
      batch_norm_layers = [pt.nn.BatchNorm1d(layer[0].in_features) \
        for layer in layer_lists]
      batch_normed_layer_lists = [ [bn, *layers] \
        for bn, layers in list(zip(batch_norm_layers, layer_lists)) ]
      return pt.nn.Sequential(*[layer \
        for nested_layer in batch_normed_layer_lists \
        for layer in nested_layer ])

    def batchToXy(self, batch):
      batch = batch.squeeze_()
      X, y = batch[:, 1:], batch[:, 0]
      return X, y

    def forward(self, X):
      y_est = self.layers(X)
      return y_est.squeeze_()

    def log(self, k, v, **kwargs):
        super().log(k, v,
                on_step = kwargs['on_step'],
                on_epoch = kwargs['on_epoch'],
                prog_bar = kwargs['prog_bar'],
                logger = kwargs['logger'],)

    def training_step(self, batch, batch_idx):
        self.step += 1

        X, y = self.batchToXy(batch) #unpack batch into features and label

        y_est = self.forward(X)

        loss = pt.nn.functional.mse_loss(y_est, y)

        for k,v in {
          "train_step": self.step,
          "train_mse": loss.item(),
          "train_rmse": loss.sqrt().item(),
          "train_steps_per_sec": \
            self.step / (time.perf_counter() - self.start_ts),

        }.items():
          self.log(k, v, step = self.step, on_step=True, on_epoch=True,
                                            prog_bar=True, logger=True)

        self.train_val_rmse = loss.sqrt()

        return loss

    def validation_step(self, batch, batch_idx):
      X, y = self.batchToXy(batch)

      with pt.no_grad():
          loss = pt.nn.functional.mse_loss(self.forward(X), y)

      for k,v in {
        "val_mse": loss.item(),
        "val_rmse": loss.sqrt().item(),
        "train_val_rmse": (self.train_val_rmse + loss.sqrt()).item(),
      }.items():
        self.log(k, v, step = self.step, on_step=True, on_epoch=True,
                                          prog_bar=True, logger=True)

      return loss

    def test_step(self, batch, batch_idx):
      X, y = self.batchToXy(batch)

      with pt.no_grad():
          loss = pt.nn.functional.mse_loss(self.forward(X), y)

      for k,v in {
          "test_mse": loss.item(),
          "test_rmse": loss.sqrt().item(),
      }.items():
        self.log(k, v, step = self.step, on_step=True, on_epoch=True,
                                          prog_bar=True, logger=True)

    def configure_optimizers(self):
        optimizers = {'Adam': pt.optim.AdamW,
                      'SGD': pt.optim.SGD}
        optimizer = optimizers[self.hparams.optimizer]

        return optimizer(self.layers.parameters(),
                            lr = float(self.hparams.lr))

由于列表 12.1 中的代码将 DC 出租车模型的版本 1 保存到名为 model_v1.py 的文件中,因此构建和测试该模型版本的过程的入口点(在 src 目录的 trainer.py 文件中)从加载 model_v1 包中的 DC 出租车模型实例开始:

%%writefile src/trainer.py
from model_v1 import DcTaxiModel

import os
import time
import kaen
import torch as pt
import numpy as np
import pytorch_lightning as pl
import torch.distributed as dist
from torch.utils.data import DataLoader
from torch.nn.parallel import DistributedDataParallel

from kaen.torch import ObjectStorageDataset as osds

def train(model, train_glob, val_glob, test_glob = None):
    #set the pseudorandom number generator seed
    seed = int(model.hparams['seed']) \                          ❶
                if 'seed' in model.hparams \
                else int( datetime.now().microsecond )

    np.random.seed(seed)
    pt.manual_seed(seed)

    kaen.torch.init_process_group(model.layers)                  ❷

    trainer = pl.Trainer(gpus = pt.cuda.device_count() \
                            if pt.cuda.is_available() else 0,
        max_epochs = 1,
        limit_train_batches = int( model.hparams.max_batches ) \
                                 if 'max_batches' in model.hparams else 1,
        limit_val_batches = 1,
        num_sanity_val_steps = 1,
        val_check_interval = min(20, int( model.hparams.max_batches ) ),
        limit_test_batches = 1,
        log_every_n_steps = 1,
        gradient_clip_val=0.5,
        progress_bar_refresh_rate = 0,
        weights_summary = None,)

    train_dl = \
    DataLoader(osds(train_glob,
                    worker = kaen.torch.get_worker_rank(),
                    replicas = kaen.torch.get_num_replicas(),
                    shard_size = \                              ❸
                      int(model.hparams.batch_size),
                    batch_size = \                              ❹
                      int(model.hparams.batch_size),
                    storage_options = {'anon': False},
                   ),
               pin_memory = True)

    val_dl = \
    DataLoader(osds(val_glob,
                    batch_size = int(model.hparams.batch_size),
                    storage_options = {'anon': False},
                   ),
               pin_memory = True)

    trainer.fit(model,
              train_dataloaders = train_dl,
              val_dataloaders = val_dl)
    if test_glob is not None:
        test_dl = \
          DataLoader(osds(test_glob,
                          batch_size = int(model.hparams.batch_size),
                          storage_options = {'anon': False},
                         ),
                    pin_memory = True)

        trainer.test(model,
                    dataloaders=test_dl)

    return model, trainer

if __name__ == "__main__":
    model, trainer = train(DcTaxiModel(**{
            "seed": "1686523060",
            "num_features": "8",
            "num_hidden_neurons": "[3, 5, 8]",
            "batch_norm_linear_layers": "1",
            "optimizer": "Adam",
            "lr": "0.03",
            "max_batches": "1",
            "batch_size": str(2 ** 18),}),

      train_glob = \
        os.environ['KAEN_OSDS_TRAIN_GLOB'] \
          if 'KAEN_OSDS_TRAIN_GLOB' in os.environ \
          else 'https://raw.githubusercontent.com/osipov/smlbook/
➥                 master/train.csv',

      val_glob = \
        os.environ['KAEN_OSDS_VAL_GLOB'] \
          if 'KAEN_OSDS_VAL_GLOB' in os.environ \
          else 'https://raw.githubusercontent.com/osipov/smlbook/
➥                 master/valid.csv',

      test_glob = \
        os.environ['KAEN_OSDS_TEST_GLOB'] \
          if 'KAEN_OSDS_TEST_GLOB' in os.environ \
          else 'https://raw.githubusercontent.com/osipov/smlbook/
➥                 master/valid.csv')

    print(trainer.callback_metrics)

❶ 使用超参数或当前时间戳初始化伪随机数种子。

❷ 自动更新 DC 出租车模型,以利用多个训练节点(如果有的话)。

❸ 正如第八章中所述,在分布式集群中,shard_size 往往不同于 . . .

❹ . . . 用于计算梯度的 batch_size。

在此时,您可以通过从您的 shell 环境中运行以下命令对 trainer.py 进行单元测试。

列表 12.2 运行一个简单的测试来确认实现是否按预期工作。

%%bash
python3 src/trainer.py

这应该会使用 DC 出租车数据的小样本训练、测试并报告您模型的度量标准。

12.2.1 理解 PyTorch 分布式训练设置

本节说明了执行分布式训练时 PyTorch 模型所期望的环境变量和相关设置的配置。

PyTorch 模型的分布式训练方法出奇的简单。尽管原生的 PyTorch 不提供与 AWS、Azure 或 GCP 等云提供商的集成,但列表 12.3 中的代码说明了如何使用 Kaen 框架(kaen.ai)在云提供商中桥接 PyTorch 和 PyTorch Lightning,实现分布式训练。

kaen.torch.init_process_ group 方法使用的 PyTorch 特定实现,使得 DC 出租车模型的分布式训练成为可能,如模型 PyTorch Lightning 模块所指定的,其中 PyTorch torch.nn.Sequential 层存储在 model.layers 属性中。

清单 12.3 Kaen 框架配置 PyTorch 模型

#pytorch distributed training requires MASTER_ADDR and MASTER_PORT to be set
os.environ['MASTER_ADDR'] = \
  os.environ['KAEN_JOB_MANAGER_IP'] \                            ❶
  if 'KAEN_JOB_MANAGER_IP' in os.environ else "127.0.0.1"

MASTER_ADDR = os.environ['MASTER_ADDR']
os.environ['MASTER_PORT'] = \                                    ❷
  os.environ['MASTER_PORT'] if 'MASTER_PORT' in os.environ else "12355"
MASTER_PORT = os.environ['MASTER_PORT']

BACKEND = os.environ['KAEN_BACKEND'] \                           ❸
                    if 'KAEN_BACKEND' in os.environ else "gloo"
RANK = int(os.environ['KAEN_RANK'])                              ❹
WORLD_SIZE = int(os.environ['KAEN_WORLD_SIZE'])                  ❺

if not dist.is_initialized():
    dist.init_process_group(init_method = "env://",              ❻
                            backend = BACKEND,
                            rank = RANK,
                            world_size = WORLD_SIZE)
    model.layers = \                                              ❼
      DistributedDataParallel(model.layers, device_ids=[])

❶ 将 PyTorch 的 MASTER_ADDR 设置为本地主机地址,除非 Kaen 另有规定。

❷ 将 PyTorch 的 MASTER_PORT 设置为 12355,除非 MASTER_PORT 变量中另有规定。

❸ 除非 KAEN_BACKEND 另有规定,否则使用基于 CPU 的 gloo 后端。

❹ 初始化分布式数据并行训练的等级 . . .

❺ . . . 并根据 KAEN_RANK 和 KAEN_WORLD_SIZE 变量设置训练节点的计数。

❻ 确保分布式训练进程组已准备好进行训练。

❼ 使用 DistributedDataParallel 为模型启用分布式训练。

当使用 DistributedDataParallel 实现训练 PyTorch 模型时,在训练开始之前必须满足几个先决条件。首先,必须为网络上的模型训练管理节点配置分布式训练库的 MASTER_ADDR 和 MASTER_PORT 环境变量。即使在单节点场景中使用 DistributedDataParallel,也必须指定这些值。在单节点场景中,MASTER_ADDR 和 MASTER_ PORT 的值分别初始化为 127.0.0.1 和 12355。当分布式训练集群由多个节点组成时,MASTER_ADDR 必须对应于集群中的管理节点的 IP 地址(根据第十一章中的描述,即节点等级为 0 的节点)。

Kaen 框架可以使用 PyTorch 训练的管理节点的运行时 IP 地址初始化您的模型训练环境。因此,在示例中,如果 Kaen 框架设置了后者的环境变量,则将 MASTER_ADDR 初始化为 KAEN_ JOB_MANAGER_IP 的值,否则将其初始化为 127.0.0.1(用于单节点训练)。在示例中,默认情况下将 MASTER_PORT 初始化为 12355,除非在启动训练运行时之前预先设置了不同的值。

注意,init_process_group 方法的 init_method 参数被硬编码为 env://,以确保分布式训练初始化根据前述的 MASTER_ADDR 和 MASTER_PORT 环境变量的值发生。虽然可以使用文件或键/值存储进行初始化,但在本示例中演示了基于环境的方法,因为它是 Kaen 框架本地支持的。

除了初始化方法之外,请注意 init_process_group 是使用 BACKEND、WORKER 和 REPLICAS 设置的值调用的。BACKEND 设置对应于 PyTorch 支持的几个分布式通信后端库之一的名称。 (这些库支持的特性的详细信息在此处可用:pytorch.org/docs/stable/distributed.html。)gloo 用于启用基于 CPU 的分布式训练,而 nccl 则用于基于 GPU 的分布式训练。由于基于 CPU 的分布式训练更容易、更便宜,并且通常更快地在云提供商(如 AWS)中预配,因此本章首先关注基于 CPU 的训练,然后再介绍如何引入支持基于 GPU 的训练所需的更改。

初始化分布式训练所需的 RANK 和 WORLD_SIZE 值也由 Kaen 框架提供。WORLD_SIZE 值对应于用于分布式训练的节点的整数计数的自然计数(即,从一开始),而 RANK 值对应于在 PyTorch 模型中执行 Python 运行时训练的节点的从零开始的整数索引。请注意,RANK 和 WORLD_SIZE 都是根据 Kaen 框架的环境变量设置进行初始化的。例如,如果您实例化一个仅包含单个训练节点的 Kaen 训练环境,则 KAEN_WORLD_SIZE 设置为 1,而单个训练节点的 RANK 值设置为 0。相比之下,对于由 16 个节点组成的分布式 Kaen 训练环境,KAEN_WORLD_SIZE 初始化为 16,并且每个训练节点分配了范围为 [0, 15] 的 RANK 值,换句话说,包括起始(0)索引和结束(15)索引。

最后,请注意,仅在检查 is_initialized 状态之后才初始化 DistributedDataParallel 训练。初始化涉及使用此部分前述的 backend、rank 和 world_size 设置执行 init_process_group。一旦初始化完成(换句话说,init_process_group 返回),就会将 DistributedDataParallel 实例包装在基于 PyTorch nn.Module 的模型实例周围,并将其分配给示例中的 model.nn。此时,模型已准备好通过分布式集群进行训练。

12.3 在本地 Kaen 容器中对模型训练进行单元测试

这一部分描述了如何在本地的 Kaen 容器中对模型实现进行单元测试,然后再将代码部署到像 AWS 这样的云环境中。

尽管代码实现支持分布式训练,但无需在云提供商中预留(和支付)分布式训练环境即可进行测试。您将通过下载为面向 AWS 的 PyTorch 模型提供的 Kaen 提供的基础容器镜像来开始单元测试。

确保您可以使用 DockerHub 进行身份验证,您可以下载基础容器镜像。一旦您在 Kaen Jupyter 环境中执行以下代码片段,您将被提示输入您的 DockerHub 用户名,然后将其存储在 DOCKER_HUB_USER Python 变量中:

DOCKER_HUB_USER = input()
DOCKER_HUB_USER

接下来,在提示时输入您的用户名的 DockerHub 密码。请注意,验证完成后,密码将从 DOCKER_HUB_PASSWORD 变量中清除出来:

import getpass
DOCKER_HUB_PASSWORD = getpass.getpass()

!echo "{DOCKER_HUB_PASSWORD}" | \
docker login --username {DOCKER_HUB_USER} --password-stdin

DOCKER_HUB_PASSWORD = None

如果您指定了有效的 DockerHub 凭据,您应该看到一个显示“登录成功”的输出消息。

基本的 PyTorch Docker 镜像相当庞大,大约为 1.9 GB。基于 Kaen 的 PyTorch 镜像 (kaenai/pytorch-mlflow-aws-base:latest),添加了支持 AWS 和 MLFlow 的二进制文件,大约大小为 2 GB,所以请做好准备,以下下载将根据您的互联网连接速度需要几分钟。

要执行下载,请运行

!docker pull kaenai/pytorch-mlflow-aws-base:latest

下载完成后,您可以使用以下 Dockerfile 将您的源代码打包到一个从 kaenai/pytorch-mlflow-aws-base:latest 派生的镜像中。请注意,该文件只是将 Python 源代码复制到镜像文件系统的 /workspace 目录中:

%%writefile Dockerfile
FROM kaenai/pytorch-mlflow-aws-base:latest
COPY *.py /workspace/

由于本章早些时候描述的源代码文件 model_v1.py 和 trainer.py 被保存到了一个 src 目录中,请注意以下命令构建您的 Docker 镜像时将 src/ 目录作为 Docker 镜像构建过程的根目录。为了确保您构建的镜像可以上传到 DockerHub,镜像使用 {DOCKER_HUB_USER} 作为前缀进行标记:

!docker build -t {DOCKER_HUB_USER}/dctaxi:latest -f Dockerfile src/

在 docker build 命令完成后,您可以使用以下命令运行您新创建的 Docker 容器

!docker run -it {DOCKER_HUB_USER}/dctaxi:latest \
"python /workspace/trainer.py"

这应该产生一个与列表 12.2 输出相同的输出。为什么要费心创建 Docker 镜像呢?回想一下,拥有 Docker 镜像将简化在诸如 AWS 等云服务提供商环境中部署和训练模型的过程。如何将镜像从您的本地环境共享到云服务提供商环境?通常,Docker 镜像是使用 DockerHub 等 Docker Registry 实例进行共享的。

要推送(上传)您新构建的镜像到 DockerHub,执行

!docker push {DOCKER_HUB_USER}/dctaxi:latest

由于 docker push 操作只需要推送源代码(Python 文件)的内容到 DockerHub,所以应该在几秒钟内完成。您的 dctaxi 镜像的其余部分是从基础 kaenai/pytorch-mlflow-aws-base:latest 镜像挂载的。

12.4 使用 Optuna 进行超参数优化

本节介绍了用于 HPO 的 Optuna 和如何使用 Kaen 框架为 DC 出租车车费估算模型添加 HPO 支持。

到目前为止,您已经使用静态的超参数值对模型训练进行了单元测试。回想一下第十一章,您可以使用 Optuna 对您的代码执行超参数优化(HPO)。

Optuna 是 Kaen 支持的几种 HPO 框架之一。要在分布式训练中纳入对 HPO 的支持,您需要使用一个将 Optuna 作为服务将其公开给您的代码的基于 Kaen 的 Docker 映像,并实现一个可子类化的 Python 类命名为 BaseOptunaService。回想一下,Optuna 中的超参数是使用试验 API 指定的。Kaen 中的 BaseOptunaService 提供了对 BaseOptunaService 子类中的 Optuna 试验实例的访问。例如:

import optuna
import numpy as np
from kaen.hpo.optuna import BaseOptunaService
class DcTaxiHpoService(BaseOptunaService):
  def hparams(self):
    trial = self._trial         ❶

    #define hyperparameter
    return {
        "seed": \               ❷
          trial.suggest_int('seed', 0, np.iinfo(np.int32).max)
    }

❶ _trial 属性引用 Optuna 试验实例。

❷ 试验实例支持 Optuna 试验 API 方法,例如 suggest_int。

注意,在 hparams 方法返回的字典实例中,有一个超参数向 Optuna 请求。suggest_int 方法是 Optuna 试验 API 中可用的几种方法之一,用于获取超参数的值。 (试验接口中可用的其他方法在这里描述:optuna.readthedocs.io/en/stable/reference/generated/optuna.trial.Trial.html#。) 在本例中,suggest_int('seed', 0, np.iinfo(np.int32).max) 方法指定 Optuna 应推荐从 0 到包括正 32 位整数的最大值的伪随机数种子生成器的值。

回想一下,DcTaxiModel 的训练还依赖于额外的超参数值,包括 optimizer、bins、lr(学习率)、num_hidden_neurons、batch_size 和 max_batches。本书第十一章介绍了使用 Optuna 试验 API 实现这些超参数。要在 DcTaxiHpoService 类的实现中启用对这些超参数的支持,您需要扩展 hparams 方法返回的字典,使用 Optuna 对应超参数值的规范来尝试:

def hparams(self):
  trial = self._trial

  return {
    "seed": \
        trial.suggest_int('seed', 0, np.iinfo(np.int32).max - 1),

    "optimizer": \
        trial.suggest_categorical('optimizer', ['Adam']),

    "lr": \
        trial.suggest_loguniform('lr', 0.001, 0.1),

    "num_hidden_neurons": \
        [trial.suggest_categorical(f"num_hidden_layer_{layer}_neurons", \
            [7, 11, 13, 19, 23]) for layer in \
            range(trial.suggest_categorical('num_layers',
                                            [11, 13, 17, 19]))],

    "batch_size": \
        trial.suggest_categorical('batch_size',
                                  [2 ** i for i in range(16, 22)]),

    "max_batches": \
        trial.suggest_int('max_batches', 40, 400, log = True)
  }

除了试验,Optuna 还使用了一个“研究”(相当于 MLFlow 父运行),它是试验的集合。在 Kaen 框架中,Optuna 研究用于生成关于试验摘要统计信息的报告,以及生成形式为已完成试验的自定义可视化报告。

要持久化试验摘要统计信息,您可以使用 Optuna 研究 API 的 trials_dataframe 方法,该方法返回一个 pandas DataFrame,描述了已完成试验以及关联超参数值的摘要统计信息。请注意,在下面的示例中,数据帧基于实验名称持久化为 html 文件:

def on_experiment_end(self, experiment, parent_run):
    study = self._study
    try:
      for key, fig in {
        "plot_param_importances": \
            optuna.visualization.plot_param_importances(study),

        "plot_parallel_coordinate_all": \
            optuna.visualization.plot_parallel_coordinate(study, \
                params=["max_batches",
                        "lr",
                        "num_hidden_layer_0_neurons",
                        "num_hidden_layer_1_neurons",
                        "num_hidden_layer_2_neurons"]),

        "plot_parallel_coordinate_l0_l1_l2": \
            optuna.visualization.plot_parallel_coordinate(study, \
                params=["num_hidden_layer_0_neurons",
                        "num_hidden_layer_1_neurons",
                        "num_hidden_layer_2_neurons"]),

        "plot_contour_max_batches_lr": \
            optuna.visualization.plot_contour(study, \
                params=["max_batches", "lr"]),
      }.items():
        fig.write_image(key + ".png")
        self.mlflow_client.log_artifact(run_id = parent_run.info.run_id,
                            local_path = key + ".png")

    except:
      print(f"Failed to correctly persist experiment 
➥             visualization artifacts")
      import traceback
      traceback.print_exc()

    #log the dataframe with the study summary
    study.trials_dataframe().describe().to_html(experiment.name + ".html")
    self.mlflow_client.log_artifact(run_id = parent_run.info.run_id,
                        local_path = experiment.name + ".html")

    #log the best hyperparameters in the parent run
    self.mlflow_client.log_metric(parent_run.info.run_id,
                                  "loss", study.best_value)
    for k, v in study.best_params.items():
      self.mlflow_client.log_param(parent_run.info.run_id, k, v)

在示例中,对 Optuna API 的调用是在 on_experiment_end 方法的上下文中执行的,该方法在实验结束后由 BaseOptunaService 基类调用。在将包含实验摘要统计信息的 html 文件持久化后,方法的剩余部分生成并持久化使用 Optuna 可视化包 (mng.bz/4Kxw) 的研究的可视化效果。请注意,对于每个可视化效果,相应的图像都会持久化到一个 png 文件中。

代码中的 mlflow_client 充当对 MLFlow 客户端 API (mng.bz/QqjG) 的通用引用,使得可以从 MLFlow 中读取和写入数据,并监视实验的进展。parent_run 变量是对“父”运行的引用,或者说是具有 Optuna HPO 服务建议的特定超参数值配置的一系列试验或执行。

本章描述的整个 HPO 实现如下代码片段所示。请注意,该片段将实现源代码保存为 src 文件夹中的 hpo.py 文件:

%%writefile src/hpo.py
import optuna
import numpy as np
from kaen.hpo.optuna import BaseOptunaService

class DcTaxiHpoService(BaseOptunaService):
  def hparams(self):
    trial = self._trial

    #define hyperparameters
    return {
      "seed": trial.suggest_int('seed', 0, np.iinfo(np.int32).max - 1),
      "optimizer": trial.suggest_categorical('optimizer', ['Adam']),
      "lr": trial.suggest_loguniform('lr', 0.001, 0.1),
      "num_hidden_neurons": \
        [trial.suggest_categorical(f"num_hidden_layer_{layer}_neurons",
          [7, 11, 13, 19, 23]) for layer in \
            range(trial.suggest_categorical('num_layers',
                                            [11, 13, 17, 19]))],

      "batch_size": \
        trial.suggest_categorical('batch_size', \
                                  [2 ** i for i in range(16, 22)]),

      "max_batches": trial.suggest_int('max_batches', 40, 400, log = True)
    }

  def on_experiment_end(self, experiment, parent_run):
    study = self._study
    try:
      for key, fig in {
        "plot_param_importances": \
          optuna.visualization.plot_param_importances(study),
        "plot_parallel_coordinate_all": \
          optuna.visualization.plot_parallel_coordinate(study,
            params=["max_batches",
                    "lr",
                    "num_hidden_layer_0_neurons",
                    "num_hidden_layer_1_neurons",
                    "num_hidden_layer_2_neurons"]),
        "plot_parallel_coordinate_l0_l1_l2": \
          optuna.visualization.plot_parallel_coordinate(study,
            params=["num_hidden_layer_0_neurons",
            "num_hidden_layer_1_neurons",
            "num_hidden_layer_2_neurons"]),

        "plot_contour_max_batches_lr": \
          optuna.visualization.plot_contour(study,
            params=["max_batches", "lr"]),
      }.items():
        fig.write_image(key + ".png")
        self.mlflow_client.log_artifact(run_id = parent_run.info.run_id,
                            local_path = key + ".png")

    except:
      print(f"Failed to correctly persist experiment 
➥             visualization artifacts")
      import traceback
      traceback.print_exc()

    #log the dataframe with the study summary
    study.trials_dataframe().describe().to_html(experiment.name + ".html")
    self.mlflow_client.log_artifact(run_id = parent_run.info.run_id,
                        local_path = experiment.name + ".html")

    #log the best hyperparameters in the parent run
    self.mlflow_client.log_metric(parent_run.info.run_id,
                                    "loss", study.best_value)
    for k, v in study.best_params.items():
      self.mlflow_client.log_param(parent_run.info.run_id, k, v)

有了源代码,你就可以将其打包成一个 Docker 容器。首先拉取一个用于 Optuna 和 MLFlow 的基本 Kaen 容器:

!docker pull kaenai/optuna-mlflow-hpo-base:latest

一旦完成,使用以下命令为派生图像创建一个 Dockerfile:

%%writefile Dockerfile
FROM kaenai/optuna-mlflow-hpo-base:latest
ENV KAEN_HPO_SERVICE_PREFIX=hpo \
    KAEN_HPO_SERVICE_NAME=DcTaxiHpoService

COPY hpo.py /workspace/.

请注意,你的 DcTaxiHpoService 实现的软件包前缀对应于文件名 hpo.py,分别由 KAEN_HPO_SERVICE_NAME 和 KAEN_HPO_SERVICE_PREFIX 环境变量指定。保存 Dockerfile 后,通过以下命令构建图像:

!docker build -t {DOCKER_HUB_USER}/dctaxi-hpo:latest -f Dockerfile src/

并将其推送到 DockerHub:

!docker push {DOCKER_HUB_USER}/dctaxi-hpo:latest.

12.4.1 启用 MLFlow 支持

本节描述如何在你的 DcTaxiModel 和 MLFlow 框架之间添加集成,以管理和跟踪 HPO 实验。

尽管基本 kaenai/pytorch-mlflow-aws-base:latest 图像包含对 MLFlow 的支持,但 trainer.py 中的训练实现没有利用 MLFlow 实验管理和跟踪功能。由于 MLFlow 使用实验的概念来组织一系列 HPO 试验和运行,Kaen 提供了一个 BaseMLFlowClient 类,可以用来为 DcTaxiModel 实现一个由 MLFlow 管理的实验。BaseMLFlowClient 的子类负责使用 BaseMLFlowClient 从 MLFlow 和 Optuna 获取的超参数值实例化未训练的 PyTorch 模型实例。

首先,在你的 Kaen Jupyter 环境中运行以下命令保存名为 DcTaxiExperiment 的 BaseMLFlowClient 子类的一个实例:

%%writefile src/experiment.py
import os
from model_v1 import DcTaxiModel
from trainer import train
from kaen.hpo.client import BaseMLFlowClient

class DcTaxiExperiment(BaseMLFlowClient):

    def on_run_start(self, run_idx, run):
        print(f"{run}({run.info.status}): starting...")

        #create a set of default hyperparameters
        default_hparams = {"seed": "1686523060",
                        "num_features": "8",
                        "num_hidden_neurons": "[3, 5, 8]",
                        "batch_norm_linear_layers": "1",
                        "optimizer": "Adam",
                        "lr": "0.03",
                        "max_batches": "1",
                        "batch_size": str(2 ** 18),}

        #fetch the MLFlow hyperparameters if available
        hparams = run.data.params if run is not None \
                    and run.data is not None else \
                    default_hparams

        #override the defaults with the MLFlow hyperparameters
        hparams = {**default_hparams, **hparams}

        untrained_model = DcTaxiModel(**hparams)
        def log(self, k, v, **kwargs):
            if self.mlflow_client and 0 == int(os.environ['KAEN_RANK']):
                if 'step' in kwargs and kwargs['step'] is not None:
                    self.mlflow_client.log_metric(run.info.run_id,
                      k, v, step = kwargs['step'])
                else:
                    self.mlflow_client.log_metric(run.info.run_id,
                       k, v)

        import types
        untrained_model.log = types.MethodType(log, self)

        model, trainer = \
          train(untrained_model,
                train_glob = os.environ['KAEN_OSDS_TRAIN_GLOB'],
                val_glob = os.environ['KAEN_OSDS_VAL_GLOB'],
                test_glob = os.environ['KAEN_OSDS_TEST_GLOB'])

        print(trainer.callback_metrics)

这将代码保存到 src/experiment.py 文件中。

有了实验支持,你就可以使用以下命令构建更新的 dctaxi 图像:

%%writefile Dockerfile
FROM kaenai/pytorch-mlflow-aws-base:latest
COPY * /workspace/
ENV KAEN_HPO_CLIENT_PREFIX=experiment \
    KAEN_HPO_CLIENT_NAME=DcTaxiExperiment

它指定了一个新的入口点进入镜像,使用 experiment.py 中的 experiment.DcTaxiExperiment 来更改 KAEN_HPO_CLIENT_PREFIX 和 KAEN_HPO_CLIENT_NAME 环境变量的默认值。

与以前一样,使用以下命令构建你的 dctaxi 镜像。

!docker build -t {DOCKER_HUB_USER}/dctaxi:latest -f Dockerfile src/

并使用以下命令将其推送到 DockerHub。

!docker push {DOCKER_HUB_USER}/dctaxi:latest.

12.4.2 在本地 Kaen 提供程序中为 DcTaxiModel 使用 HPO

此时,你已经准备好构建一个能够建议超参数优化试验并管理试验运行的 Docker 容器。在容器中,超参数值由 Optuna 建议,并且基于这些值的试验由 MLFlow 管理。

在配置更昂贵的云提供程序之前,最好先通过配置本地 Kaen 提供程序来开始,以便你可以对 HPO 和模型训练代码进行单元测试。你可以通过执行以下命令创建一个 Kaen 训练 道场

!kaen dojo init --provider local

它应该返回新创建的 Kaen 道场的字母数字标识符。

你可以使用以下命令列出工作空间中可用的 Kaen 道场。

!kaen dojo ls

它应该打印出你刚刚创建的道场的 ID。

你将希望将道场的标识符保存为 Python 变量以供将来使用,你可以使用以下 Jupyter 语法将 bash 脚本赋值给 Python 变量。

[MOST_RECENT_DOJO] = !kaen dojo ls | head -n 1
MOST_RECENT_DOJO

在 Kaen 道场用于训练之前,它应该被激活。通过运行以下命令激活由 MOST_RECENT_DOJO 变量中的标识符指定的道场。

!kaen dojo activate {MOST_RECENT_DOJO}

由于 Jupyter 的 ! shell 快捷方式提供了对 Python 变量的访问,因此在前面的代码片段中,{MOST_RECENT_DOJO} 语法将替换为相应 Python 变量的值。你可以通过检查来确认道场是否激活。

!kaen dojo inspect {MOST_RECENT_DOJO}

它应该包含一个输出行,其中包含 KAEN_DOJO_STATUS=active。

在你能够在道场中启动训练作业之前,你需要创建一个指定了道场和用于训练的 Kaen 镜像的作业。

要创建一个训练 DcTaxiModel 的作业,请执行以下命令。

!kaen job create --dojo {MOST_RECENT_DOJO} \
--image {DOCKER_HUB_USER}/dctaxi:latest

它将尝试从 DockerHub 拉取指定的镜像,如果成功,则会返回作业的字母数字标识符。

与道场一样,你可以使用以下命令将作业标识保存到 Python 变量中。

[MOST_RECENT_JOB] = !kaen job ls | head -n 1
MOST_RECENT_JOB

它应该打印出你创建的作业的标识符。

Kaen 中的每个作业都配置了专用的网络设置,你可以通过运行以下命令来检查。

!kaen job inspect {MOST_RECENT_JOB}

由于你还没有为这个作业启用 HPO,所以检查的作业设置中不包含用于提供 MLFlow 实验管理和 Optuna 超参数值的 HPO 镜像的信息。你可以通过执行以下命令来配置作业进行一次 HPO 运行。

!kaen hpo enable \
--image {DOCKER_HUB_USER}/dctaxi-hpo:latest \
--num-runs 1 \
--service-prefix hpo \
--service-name DcTaxiHpoService \
--port 5001 5001 \
{MOST_RECENT_JOB}

它会覆盖你的 dctaxi-hpo 镜像的默认设置,以指定使用 hpo.DcTaxiHpoService 类来启动 HPO 服务。执行的语句还使用 --port 设置配置了 MLFlow UI 端口 5001。

假设 hpo enable 命令成功完成,你可以再次检查作业以观察与 HPO 相关的设置:

!kaen job inspect {MOST_RECENT_JOB}

请注意,此时输出中包括 KAEN_HPO_MANAGER_IP,用于内部 Docker 网络的 IP 地址(由 KAEN_JOB_SUBNET 指定),该网络处理容器实例之间的通信。

此时,HPO 服务应该已经启动并运行,因此您应该能够通过将浏览器导航到 http://127.0.0.1:5001 访问 MLFlow 用户界面,该界面应该显示类似于图 12.4 的屏幕。请注意,在您探索 HPO 实验的详细信息之前,您需要在 MLFlow 界面的左侧边栏中打开以 job 前缀开头的 MLFlow 实验。

12-04

图 12.4 屏幕截图显示了 MLFlow 基于浏览器的界面,展示了实验实例的父运行和唯一的子运行

由于此时您刚刚启动了 HPO 服务,因此您的实验只包括一个父运行和一个子运行。主运行与 MLFlow 实验具有一对一的关系,并包含定义应由机器学习管道执行实例使用的特定超参数配置的各个子运行。如果您在 MLFlow 用户界面中导航到子运行,则应看到类似于图 12.5 截图的屏幕。

12-05

图 12.5 MLFlow 屏幕截图显示了 Optuna HPO 建议的设置,用于子运行

要使用您 AWS 存储桶中的可用数据在本地提供程序中开始训练模型,您需要配置环境变量与您的 AWS 凭据。在以下代码片段中,将 Python None 替换为您的匹配 AWS 凭据的值,用于 AWS_ACCESS_KEY_ID、AWS_SECRET_ACCESS_KEY 和 AWS_DEFAULT_REGION。同时,对于您的 BUCKET_ID 值,进行相同的替换,并执行代码以在您的 Kaen Jupyter 环境中配置相应的环境变量:

import os
os.environ['MOST_RECENT_JOB'] = MOST_RECENT_JOB

os.environ['BUCKET_ID'] = None
os.environ['AWS_ACCESS_KEY_ID'] = None
os.environ['AWS_SECRET_ACCESS_KEY'] = None
os.environ['AWS_DEFAULT_REGION'] = None

我建议您从您的 bash shell 中执行以下一系列 echo 命令,以确保所有环境变量都配置如预期:

%%bash
echo $BUCKET_ID
echo $AWS_ACCESS_KEY_ID
echo $AWS_SECRET_ACCESS_KEY
echo $AWS_DEFAULT_REGION
echo $MOST_RECENT_JOB

现在,您可以通过运行 kaen job start 来开始训练您的模型。为了简单起见,首先使用单个训练工作者进行训练(由 --replicas 1 指定)。请注意,命令中的 KAEN_OSDS 环境变量指向您在 AWS 存储桶中的数据 CSV 文件:

!kaen job start \
--replicas 1 \
-e KAEN_HPO_JOB_RUNS 1 \
-e AWS_DEFAULT_REGION $AWS_DEFAULT_REGION \
-e AWS_ACCESS_KEY_ID $AWS_ACCESS_KEY_ID \
-e AWS_SECRET_ACCESS_KEY $AWS_SECRET_ACCESS_KEY \
-e KAEN_OSDS_TRAIN_GLOB "s3://dc-taxi-$BUCKET_ID-
➥ $AWS_DEFAULT_REGION/csv/dev/part*.csv" \
-e KAEN_OSDS_VAL_GLOB "s3://dc-taxi-$BUCKET_ID-
➥ $AWS_DEFAULT_REGION/csv/test/part*.csv" \
-e KAEN_OSDS_TEST_GLOB "s3://dc-taxi-$BUCKET_ID-
➥ $AWS_DEFAULT_REGION/csv/test/part*.csv" \
$MOST_RECENT_JOB

当训练作业正在运行时,您应该能够在 MLFlow 用户界面中导航到子运行的详细信息,假设您的训练过程至少运行了 25 个训练步骤,则 train_rmse 指标的结果图表应该类似于图 12.6 中的图表。

12-06

图 12.6 MLFlow 屏幕截图显示了 train_rmse 指标的图表

12.4.3 使用 Kaen AWS 提供程序进行训练

本节说明了如何使用 Kaen 框架在 AWS 虚拟私有云环境中训练容器,而不是在本地提供者中,这样你就可以利用 AWS 中提供的弹性、水平扩展。

要在 AWS 中创建一个 Kaen 道场,你需要在运行 kaen init 时使用 --provider aws 设置。默认情况下,在使用 AWS 提供者时,Kaen 会在 AWS 中将 t3.micro 实例作为 worker 和 manager 节点。尽管 t3.micro 实例是适用于简单演示的低成本默认值,但对于 DcTaxiModel,我建议按照以下方式提供 t3.large 实例:

!kaen dojo init --provider aws \
--worker-instance-type t3.xlarge --manager-instance-type t3.xlarge

在成功创建后,应该报告道场 ID。

要配置 MOST_RECENT_DOJO Python 变量,你应该执行以下操作:

[MOST_RECENT_DOJO] = !kaen dojo ls | head -n 1
MOST_RECENT_DOJO

然后使用以下命令激活道场:

!kaen dojo activate {MOST_RECENT_DOJO}

注意,如果你提供了性能不足的 AWS 节点实例(例如 t3.micro),激活过程可能需要一些时间。一旦激活正确完成,你应该能够使用以下命令检查道场:

!kaen dojo inspect {MOST_RECENT_DOJO}

输出应包含以 KAEN_DOJO_STATUS=active 开头的一行以及激活完成的时间戳。

与本地提供者一样,要在 AWS 中执行训练,你应该首先创建一个作业:

!kaen job create --dojo {MOST_RECENT_DOJO} \
--image {DOCKER_HUB_USER}/dctaxi:latest

与本地提供者不同,在 AWS 提供者中运行 kaen job create 可能需要一些时间。这是因为你推送到 DockerHub 的 dctaxi 镜像需要下载到 AWS 道场中的 AWS 节点。作业创建完成后,你应该使用以下命令将作业 ID 保存到 MOST_RECENT_JOB Python 变量中:

[MOST_RECENT_JOB] = !kaen job ls | head -n 1
os.environ['MOST_RECENT_JOB'] = MOST_RECENT_JOB
MOST_RECENT_JOB

这也将 MOST_RECENT_JOB 环境变量设置为与相应 Python 变量匹配的值。

接下来,使用以下命令为作业启用 HPO:

!kaen hpo enable \
--num-runs 1 \
--image {DOCKER_HUB_USER}/dctaxi-hpo:latest \
--service-prefix hpo \
--service-name DcTaxiHpoService \
--port 5001 5001 \
{MOST_RECENT_JOB}

一旦 kaen hpo enable 操作完成,你可以通过构造笔记本中的 URL 打开 MLFlow 用户界面:

!echo "http://$(kaen dojo inspect {MOST_RECENT_DOJO} \
| grep KAEN_DOJO_MANAGER_IP | cut -d '=' -f 2):5001"

并在浏览器中导航到 URL。由于 MLFlow UI 可能需要几秒钟才能变为可用(取决于 AWS 管理节点实例的性能),你可能需要刷新浏览器才能访问该界面。

要开始训练,kaen job start 命令与之前使用的相同:

!kaen job start \
--replicas 1 \
-e AWS_DEFAULT_REGION $AWS_DEFAULT_REGION \
-e AWS_ACCESS_KEY_ID $AWS_ACCESS_KEY_ID \
-e AWS_SECRET_ACCESS_KEY $AWS_SECRET_ACCESS_KEY \
-e KAEN_OSDS_TRAIN_GLOB "s3://dc-taxi-$BUCKET_ID-
➥ $AWS_DEFAULT_REGION/csv/dev/part*.csv" \
-e KAEN_OSDS_VAL_GLOB "s3://dc-taxi-$BUCKET_ID-
➥ $AWS_DEFAULT_REGION/csv/test/part*.csv" \
-e KAEN_OSDS_TEST_GLOB "s3://dc-taxi-$BUCKET_ID-
➥ $AWS_DEFAULT_REGION/csv/test/part*.csv" \
$MOST_RECENT_JOB

与本地提供者的情况类似,你可以在浏览器中导航到 MLFlow UI 并在模型训练时监视指标。

完成后,不要忘记使用以下命令移除 AWS 训练道场:

!kaen dojo rm {MOST_RECENT_DOJO}.

概要

  • 实验管理和超参数优化是机器学习流水线的组成部分。

  • Docker 容器便于将机器学习代码打包、部署和集成到机器学习流水线服务中。

  • 训练机器学习模型意味着执行大量作为机器学习流水线运行实例的实验。

附录 A:机器学习简介

在和机器学习领域的新手讨论时,我发现很多人已经掌握了基础知识,但是他们觉得机器学习领域的信息量和数学深度让人望而生畏。我还记得当我刚开始学习机器学习的时候,我有过类似的经历:感觉需要学的东西太多了。这个附录是为那些可能已经通过一些教程或几个在线课程试图理解机器学习的人准备的。在这个附录中,我将基础的机器学习概念整理成一个完整的框架,并解释它们如何组合在一起,以便你对基础有足够的回顾,可以尝试本书中的项目。在可能的情况下,我将直观地介绍机器学习概念,并尽量少使用数学符号。我的目标不是要替代机器学习的全面课程或深度博客文章;相反,我想向你展示机器学习中最重要、最显著的部分,以供实际应用。

机器学习初学者往往会从深入研究机器学习算法开始他们的学习之旅,这是错误的。机器学习算法可以用于解决问题,但首先应理解适合机器学习的问题。作为机器学习从业者(例如机器学习工程师或数据科学家),你需要了解客户的业务问题,并决定它是否可以重组为一个机器学习问题。因此,A.1 节到 A.3 节介绍机器学习的基础知识,并涵盖结构化数据集的最常见机器学习用例。从第 A.4 节开始并通过本附录的结论,我将向你介绍可以用于解决机器学习问题的机器学习算法以及有关如何应用算法的细节。

A.1 为什么要学机器学习?

如果你正在阅读这本书,那么你至少愿意考虑把机器学习作为研究课题甚至解决问题的方法。但是,机器学习是你学习或使用的正确技术吗?什么情况下应用机器学习才有意义?即使你对机器学习很感兴趣,你可能会发现,进入门槛(是相当高的)让人望而生畏,决定不付出真正理解机器学习所需的深度努力来应用这项技术。过去,很多技术曾经在市场上声称要“改变一切”,但最终没有兑现承诺。机器学习是否注定成为头条新闻几年后就消失在人们的记忆里?还是说有些东西与众不同?

表面上看,机器学习对于当代计算机软件和硬件的用户可能看起来非常普通。机器学习依赖于编写代码的人类,并且代码又依赖于信息技术资源,如计算、存储、网络以及输入和输出接口。然而,要了解机器学习为计算机领域带来的变革之巨大,回顾计算机经历了类似规模的转型的时刻是很有用的。

阅读关于 1940 年代进行数学计算的“计算机”的机器学习书籍可能会让你感到惊讶。这是因为如果你不知道在 1950 年代电子和数字计算机的发明和广泛采用之前,术语计算机被用来描述执行数学计算的人类,通常是与其他“计算机”组合工作。图 A.1 显示了 1949 年的一个计算机团队的照片。

A-01

图 A.1 1949 年夏季,人类计算机在德雷登飞行研究中心设施的办公室里工作。(此照片属于公共领域;更多信息请访问 mng.bz/XrBv。)

在其核心,计算是关于使用数据回答问题的程序(算法)。在 1950—60 年代数字计算机广泛部署之前,计算机(人类)在回答计算问题中扮演了关键角色。在这项工作中,人类计算机通常依靠从纸和笔到早期计算器或基于打孔卡的制表机等外部计算工具。在这种计算范式中,计算指令,描述如何计算的程序,仍然存储在人类计算机的脑海中(图 A.2 的左侧)。

A-02

图 A.2 人类计算机依靠设备来存储计算中使用的数据。这些设备范围从纸和笔到电机计算器,甚至是基于打孔卡的制表机。然而,这些设备都没有内部存储器,用于存储和执行计算指令,换句话说,程序代码(图的左侧)。相比之下,冯·诺伊曼计算机架构,它将计算机设备内存用于数据和计算机指令,创造了一种革命性的计算机编程实践:将用于计算的指令(程序)传输到计算设备的存储器中以供存储和执行(图的右侧)。

现代计算机通过改变人类与计算设备的关系角色而转变了这种计算范式。人类程序员不再将计算指令与计算机程序存储在人类思维中,而是将程序以代码或可机读的指令形式输入到计算设备的内存中(图 A.2 右侧)。

冯·诺依曼结构以与工业革命带来的变革相媲美的规模改变了全球经济。几乎地球上每一台当代计算设备,从袖珍式的移动电话到驱动云计算数据中心的大型服务器,都使用冯·诺依曼结构。人工智能领域的出现是一旦计算转移到了从前的范式,即计算指令存储在人类计算机的生物思维中,才变得可能的。

冯·诺依曼计算范式还为人工智能领域带来了显著突破;例如,IBM 基于该范式构建的 DeepBlue 是第一个击败人类国际象棋冠军加里·卡斯帕罗夫的国际象棋程序。尽管如此,人类程序员编写的硬编码程序对人工智能的许多子领域(包括计算机视觉、语音识别、自然语言理解等)来说过于简单了。人类编程人员编写的用于执行诸如数字图像中对象分类或语音识别等任务的代码最终过于不准确且脆弱,无法广泛采用。

当代机器学习正在以与上世纪五十年代计算机革命相当的基本程度改变程序员与现代计算设备的关系。机器学习从业者不再编写计算机程序,而是使用定制的数据集训练机器学习系统(使用机器学习算法),以生成机器学习模型(见图 A.3)。由于机器学习模型只是计算机代码,机器学习算法可以赋予机器学习从业者产生能够计算超出人类编写程序能力的问题答案的代码的能力。例如,在 2010 年代,机器学习模型被用于以超人类的表现分类图像,如此有效地识别人类语音,以至于许多家庭安装了语音识别数字助理(如亚马逊的 Alexa 和谷歌的 Home),并击败了李世石,古老棋类游戏围棋的人类冠军。

A-03

图 A.3 机器学习依赖于机器学习从业者,后者使用机器学习算法根据定制数据集来“训练”机器学习模型。虽然训练好的机器学习模型只是由机器学习算法创建的代码,但该模型可以回答人类无法手动编程的复杂问题。例如,机器学习模型可以比由人类程序员开发的手工编码更好地对数字图像中的对象进行分类,或者识别数字音频中的人类语音。

本附录将机器学习介绍为计算机科学的一个子领域,专注于使用计算机从数据中学习。尽管这个定义是准确的,但它并没有完全传达机器学习对计算机领域转型的重要性和持久影响。在另一端,关于机器学习的营销口号,如“新的电力”或人工通用智能的实现者,夸大了和模糊了这个领域。显然,机器学习正在改变从 1950 年代到 2010 年代基本保持不变的计算架构的部分。机器学习将如何改变计算尚不清楚。我希望您能像我一样对这种变革的不确定性和您可以在其中扮演的潜在角色感到兴奋!

A.2 乍一看的机器学习

本节介绍了机器学习算法对传统计算机科学带来的变革,用易于理解的例子说明了机器学习,并描述了如何使用 Python 编程语言和 pandas、Numpy 和 scikit-learn 库来实现机器学习示例。通过本节的介绍,您应该能够解释基本的机器学习概念,并将这些概念与简单的机器学习示例结合使用。

在机器学习出现之前,传统计算机科学算法¹主要关注于根据已知数据计算答案。机器学习通过使用数据计算答案来回答基于可能但未知的内容的问题,从而扩展了计算机科学领域。

为了说明机器学习对计算机科学带来的变革的实质,假设您正在处理以下易于理解的数据集,描述了从 1971 年到 1982 年制造的福特野马及其燃油效率²,以每加仑英里数(mpg)的燃油消耗。使用 Python 编程语言和用于结构化数据的 pandas 库,您可以通过在计算机内存中将其实例化为 pandas 数据结构来准备该数据集以进行分析,称之为 DataFrame。

清单 A.1 在内存中创建一个准备好进行分析的 pandas DataFrame 数据集

import pandas as pd                                   ❶
import numpy as np                                    ❷
df = \                                                ❸
       pd.DataFrame([{"mpg": 18, "model year": 1971, "weight": 3139},
                    {"mpg": 13, "model year": 1975, "weight": 3169},
                    {"mpg": 25.5, "model year": 1977, "weight": 2755},
                    {"mpg": 23.6, "model year": 1980, "weight": 2905},
                    {"mpg": 27, "model year": 1982, "weight": 2790}])
print(df.to_string(index=False))                      ❹

❶ 导入 pandas 库并将其别名为 pd。

❷ 导入 NumPy 库并将其别名设为 np。

❸ 用于存储和管理结构化数据的 pandas DataFrame 可以通过使用包含每行数据的 Python 字典列表构建,其中每行使用字典实例指定,数据框列名为键,行内容为字典值。

❹ 为了避免列印每行的默认零基索引,使用 df.to_string(index=False) 代替 print(df)。

这样会产生一个输出,显示在图 A.4 的左侧作为表格。

A-04

图 A.4 中福特野马燃油效率数据集(左)和对应的散点图(右),基于同一数据集。(这个公开的数据集是从加州大学尔湾分校机器学习库获取的:archive.ics.uci.edu/ml/datasets/Auto+MPG.)

毫不奇怪,众所周知,计算机科学中的算法和数据结构(例如哈希表)可以用于回答关于数据集中已知内容的问题,比如一辆重 2,905 磅的福特野马的每加仑油耗效率。使用 pandas 可以回答这个问题

df[ df['weight'] == 2905 ]['mpg'].values

这会输出一个 NumPy⁴ 数组,其中包含了 mpg 列的单个元素,以及列表 A.1 中数据集的第四行的值:

array([23.6])

pandas DataFrame

本附录中使用 pandas 来演示对机器学习数据集的常见操作。虽然 pandas DataFrame 是一个易学易用的数据结构,但它无法处理不适合单个节点内存(网络上的计算机)的数据集。此外,pandas 并不是为那些可以在主要云计算提供商的云计算环境中使用分布式计算集群进行数据分析设计的。本附录将继续使用 pandas DataFrames 来介绍机器学习;然而,本书的其余部分重点关注可以处理比 pandas 更大数据集的 SQL 表和 PySpark DataFrames 。许多关于 pandas DataFrames 的概念直接适用于 PySpark 和 SQL。例如,第 A.3 节关于结构化数据集和监督机器学习的描述适用于不管是使用 pandas、PySpark 还是 SQL 管理的数据集。

那么一辆重 3,000 磅的福特野马的油耗如何?在机器学习出现之前,传统计算机科学算法无法回答这个问题。

然而,作为一个人类,你可以观察数据集(图 A.4 的右侧)并注意到数据集描述的相关福特野马之间的一个模式(一个重复的规律):随着车辆重量的增加,它们的燃油效率下降。如果要估计一辆重 3,000 磅的福特野马的燃油效率(数据集并未给出),你可以将这个模式的心理模型应用到估计答案,大约为每加仑 20 英里。

给定正确的机器学习算法,计算机可以学习数据集的软件模型(称为机器学习模型,将在第 A.3 节中更准确地定义),使得学习到的(也称为训练的)模型可以输出估计值,就像你推断出来的估计 3,000 磅福特野马燃油效率的心理模型一样。

scikit-learn,一种流行的机器学习库,⁵ 包括各种可供使用的机器学习算法,包括几种可以构建与数据集中观察到的模式相符的机器学习模型的算法。根据仅从重量列中的值构建模型的步骤显示如下。⁶

A.2 列出了福特野马数据集的简单模型

from sklearn.linear_model import LinearRegression       ❶
model = LinearRegression()                              ❷
model = \                                               ❸
       model.fit(df['weight'].values.reshape(len(df), 1), df['mpg'].values)

❶ 从 scikit-learn 库导入线性回归实现。

❷ 创建线性回归机器学习模型的实例。

❸ 使用福特野马数据集中的重量列作为模型输入和 mpg 值作为模型输出来训练(拟合)线性回归模型。reshape 函数将 df['weight'].values 返回的 NumPy 数组重新塑形为由单列组成的矩阵。由于 scikit-learn 要求模型输入为结构化矩阵,因此在这里需要重新塑形。

线性回归算法被广泛认为是机器学习中的基本算法之一,⁷ 训练(即“拟合”)机器学习模型实例基于传递给 LinearRegression 类的数据集。一旦通过 fit 方法训练,模型实例就可以回答诸如“一辆 3,000 磅的福特野马的预计燃油效率是多少?”这样的问题。

A.3 使用训练过的线性回归模型来估计 mpg

model.predict( np.array(3_000).reshape(1, 1) )[0]     ❶

❶ 将包含单个值 3,000(代表一辆 3,000 磅的福特野马)的数组重新塑形为一个具有一行一列的矩阵,并使用预测方法要求模型估计输出值。由于预测方法的输出以 NumPy 数组的形式返回,因此使用 [0] 索引从估计值的数组中检索第一个元素。

这输出

20.03370792

这代表着大约 20.03 英里每加仑(MPG)的估计值。机器学习模型还可以为车辆重量的其他值产生估计值。请注意,随后的代码对于已经熟悉 Python 但对机器学习新手来说更加简单直观,因此更容易理解。⁸

A.4 列出了对重量为 2,500、3,000 和 3,500 磅的车辆进行 MPG 估计的情况

ds = \                                            ❶
  np.array([[ model.predict(np.array(weight).reshape(1, 1))[0], weight] \
       for weight in [2_500, 3_000, 3_500] ])

df = \                                            ❷
       pd.DataFrame(data=ds, columns=['mpg_est', 'weight'])

print(df.to_string(index=False))                  ❸

❶ Python for 表达式遍历来自列表 [2_500, 3_000, 3_500] 的重量值。对于列表中的每个重量,表达式返回一个由两列组成的矩阵的行:左列是模型预测的 mpg 值,右列是重量本身的值。生成的矩阵存储在变量 ds 中。

❷ 使用 ds 矩阵实例化 pandas DataFrame,并以 mpg_est 和 weight 作为左右列的列名进行注释。

❸ 为了避免打印每一行的默认从零开始的索引,使用 df.to_string(index=False) 替代 print(df)

这将输出如图 A.5 左侧显示的作为 pandas DataFrame 的结果。图 A.5 右侧显示了由 LinearRegression 从原始数据集学习的模型具有虚线,可用于估计任意重量值的 mpg 值。

A-05

图 A.5 一个估计的 Ford Mustang 虚构燃油效率 mpg 的表格,其中给定的重量值由重量列(左侧)给出,并由连接表中数据点的线性模型(右侧)绘制

在本节中,你通过一个示例学到了机器学习问题、数据集和机器学习算法是如何事先准备好的。这意味着你不必完全理解问题的细微之处,如何为问题准备数据集,或者如何选择合适的机器学习算法来解决问题。在附录的其余部分,你将更深入地探索与机器学习工作的这些方面,并准备将你的理解应用到本书中的机器学习项目中去。

A.3 结构化数据集的机器学习

在前一节中,你已经了解了使用描述 Ford Mustang 燃油效率的示例数据集应用机器学习的实例。本节将教授应用机器学习到任意结构化数据集所需的概念。

对于本附录的目的,结构化数据集存储了关于

  • 相关对象,例如不同品牌和型号的汽车、标准普尔 500 指数中的上市公司、不同亚种的鸢尾花,或

  • 重复事件,例如赢得或失去的销售机会、按时或延迟的餐食送达或网站上的按钮点击

作为表格中的记录(通常是行),其中每个记录由至少两列但通常是三列或更多列的数字值组成。请注意,图 A.6 展示了一个具有 N 个观测值(以行表示的记录)和 M 列的结构化数据集,以及本节后面解释的相关术语。

A-06

图 A.6 机器学习用的结构化数据集的概念表示。数据集被组织成 N 行的观察,每一行都有一个观察的标签列,以及其他 M-1 列(剩下的)特征列。按照惯例,第一列通常是观察标签。在有关事件的观察中(例如标记销售机会是否成功),标签有时被称为事件的“结果”。在使用数据集进行机器学习时,标签通常被描述为“实际值”、“目标”或“真值”。在机器学习中,变量 y 和 X 通常用来表示标签和特征。

用于结构化数据集的监督式机器学习算法(例如第 A.2 节中使用的线性回归算法)训练(输出)一个机器学习模型,该模型可以使用记录中的其他列的值(特征)来估计记录中某列(标签)的值。

在监督式机器学习中,“标签”是机器学习模型在训练过程中用作估计结果的数值。按照惯例,机器学习从业者通常使用结构化机器学习数据集的第一列来存储标签的值(通常使用变量 y 进行指示),并使用其余列(使用变量 X 进行指示)存储特征。特征是监督式机器学习模型用来估计标签值的数值。当使用由标签和特征组成的记录集来训练机器学习模型时,这些记录被描述为“训练数据集”。

“标签”这个术语在机器学习中具有歧义。

不幸的是,在机器学习社区中,对于“标签”一词的使用并不一致,这给机器学习的初学者带来了很多困惑。尽管在结构化数据集中,当观察值描述相关对象(例如福特野马)时,该词频繁使用,但当数据集中的观察值描述事件(例如销售机会)时,与“标签”同义的词为“结果”。具有统计学背景的机器学习从业者通常将标签描述为“因变量”,将特征描述为“自变量”。其他人将“目标值”或“实际值”与“标签”同义使用。本书旨在简化术语,并尽可能使用“标签”这个词。

机器学习领域远比监督式机器学习更广泛,包括无监督机器学习(标签不被使用或不可用)、强化学习(算法寻求最大化奖励)、生成对抗网络(神经网络竞争生成和分类数据)等等。然而,即使在谷歌这样一个在机器学习应用上领先的公司,超过 80%的投入生产的机器学习模型都是基于使用结构化数据的监督式机器学习算法。因此,本书完全专注于这个重要的机器学习领域。¹¹

监督式机器学习的数学定义

虽然本书不要求这样做,但是这里有一个更正式的监督式机器学习定义:如果y[i]是要从索引 i 的记录中估计的值,则监督式机器学习模型可以被描述为一个函数 F,它基于记录中除y[i]之外的列(即其他列)的值X[i]输出估计值F(X[i])。监督式机器学习模型的训练过程描述了基于训练数据集y, X构建函数 F 的过程。训练算法通常使用y, X, F(X)进行迭代,其中基础F(X)是从 F 的某个随机初始化生成的。

为了说明监督式机器学习,回想一下在 A.2 节中您学到的描述福特野马燃油效率的结构化数据集。训练数据集仅包含两列:一个标签列,其中包含 mpg 值,和一个带有车辆重量值的单个特征。该数据集也作为示例在图 A.7 中显示。基于该数据集的相应监督式机器学习模型可以根据车重列的值估计 1971 年到 1982 年款福特野马的平均燃油效率(mpg 列)。

A-07

图 A.7 是一份关于福特野马燃油效率的样本数据集,以每加仑英里数为单位。

大多数机器学习算法都是一个迭代过程的训练过程(如图 A.8 所示),从使用机器学习算法生成第一个迭代的机器学习模型开始。一些算法使用训练数据集创建模型的第一个迭代,但这不是必需的:大多数基于神经网络的深度学习模型是根据简单的随机方案进行初始化的。

一旦模型的第一次迭代完成,它就会根据训练数据集中的特征输出第一次迭代的估计值(预测)。 接下来,机器学习算法通过比较估计值与训练数据集中的标签的接近程度来评估估计的质量。 用于评估机器学习模型质量(即性能)的可量化措施称为损失(也称为成本目标函数),在第 A.4 节中有更详细的介绍。 对于流程的下一次迭代,算法使用损失以及训练数据集来生成下一个模型的迭代。 在图 A.8 中显示的过程的单次迭代后,非迭代机器学习算法可以输出一个机器学习模型。

A-08

图 A.8 机器学习算法产生的初始机器学习模型用于输出估计的标签值(y_est),这是基于记录的特征值的记录的估计。 然后通过更改模型(特别是模型参数)来迭代改进机器学习模型以改善模型性能得分(损失),该性能得分基于估计值和标签值的比较。

迭代机器学习算法在如何决定停止训练过程上有所不同; 一些具有内置标准用于停止迭代,而其他一些则要求机器学习从业者提供显式的停止标准,或者提供用于决定何时停止的时间表或函数。 在涵盖不同机器学习算法的停止标准时,如何处理这些停止标准的其他细节始于第 A.4 节。

到目前为止,本附录直观地使用了短语数字值,但没有提供适用于机器学习的数字值的清晰定义。 如第 A.1 节所述,机器学习算法需要准备定制的数据集,并且在使用任意数据值时可能会失败。 因此,机器学习从业者必须清楚地了解结构化数据集中存在的数字值。 在图 A.9 中详细说明了基于值对数字变量进行分类的详细分类法(源自统计学中的类似分类法)。

A-09

图 A.9 数字值的类别适用于受监督机器学习,这是根据斯坦利·史密斯·史蒂文斯(mng.bz/0w4z)对统计变量进行分类的著名框架改编而来。 数字值可以被分类为互斥的连续值和分类值子集。 连续值可以进一步分类为区间或比率,而分类值可以是名义或有序的。

  • 本附录和本书的其余部分侧重于具有连续和分类变量的机器学习,特别是使用间隔、比率和名义数据。本书在可能的情况下,为处理有序值提供提示和技巧;然而,本书中的项目不涵盖这两种值的任何具体用例。作为机器学习从业者,你需要准备并将混乱的现实世界数据集转换为机器学习能够正确使用的数值。第一部分的大部分内容都致力于磨练你在这个领域的技能。

  • 在这一节中,你了解了训练,即机器学习算法执行的一系列步骤,以生成机器学习模型。在训练过程中,算法使用结构化数据集的一个子集(称为训练数据集)来生成模型,然后可以使用该模型输出估计值(也称为预测),给定记录的特征值。

- A.4 结构化数据集的回归

  • 在本节中,你将了解两种常用的监督式机器学习问题类别:回归和分类。本节介绍了损失的定义(也称为成本或目标函数),这是对机器学习模型在标签和特征数据集上的性能的定量和技术性度量。通过本节的结论,你将熟悉与这些问题相关的术语,并回顾回归应用。

  • 结构化数据集的回归是一个监督式机器学习问题,其中标签是一个连续的(如图 A.9 中定义的)变量。例如,在第 A.2 节中估计福特野马的燃油效率时,你处理了一个回归问题的实例,因为 mpg 是一个连续(更确切地说是一个间隔)值。在第 A.5 节中,你将了解更多关于结构化数据集的分类,这是一个监督式机器学习问题,其中标签是一个分类变量。对这些机器学习问题的全面理解对于机器学习从业者至关重要,因为正如在第 A.3 节中解释的那样,回归和分类占据了像谷歌这样的顶级信息技术公司生产机器学习模型的 80%以上。

根据福特野马数据集的回归问题示例和相关损失计算,图 A.10 中显示了模型损失的平方误差。回想一下,在第 A.2 节中,您扮演了机器学习算法的角色,并推断了一个用于估计每加仑英里数(mpg)值的心理模型。假设您在本节中再次扮演相同的角色,但在这里,您通过取重量值并将其乘以 0.005 来估计 mpg 值。由于训练过程是迭代的,0.005 的值只是一个初始(也许是幸运的)但合理的猜测。更好的猜测方法将很快介绍。与此同时,基于此计算的估计值显示在图 A.10 的 Estimate 列中。

A-10

图 A.10 在回归问题中,许多机器学习实践者首先应用均方误差损失函数来建立基线,然后再转向更复杂的机器学习方法并尝试更复杂的损失函数。

从图 A.8 中解释的过程中回想一下,下一步是评估损失,这是机器学习模型产生的估计质量的可量化度量。损失函数的选择取决于机器学习问题,更精确地说是问题中标签和估计值的数字类型。

在回归问题中,最常用的损失函数之一是 均方误差(MSE),定义为个别平方误差(也称为 残差差值)值的算术平均值,如图 A.10 的 Squared Error 列所示。

提供生成图 A.10 中各列值的 Python 代码。

清单 A.5 计算模型损失的平方误差

df = pd.DataFrame([{"y": 18,  "X": 3139},           ❶
                   {"y": 13, "X": 3169},
                   {"y": 25.5, "X": 2755},
                   {"y": 23.6, "X": 2905},
                   {"y": 27, "X": 2790}])

W = np.array([0.007])[:, None]                      ❷

df['y_est'] = df[['X']] @ W                         ❸

df['error'] = df['y'] - df['y_est']                 ❹
df['squared_error'] = df['error'] ** 2              ❺

df[['squared_error', 'error', 'y_est', 'y', 'X']]   ❻

❶ 将福特野马数据集实例化为 pandas DataFrame。为简洁起见,并遵循已接受的做法,本示例使用 y 表示标签,X 表示特征。有关更详细的说明,请参阅清单 A.1。

❷ 变量名 W 通常用于表示机器学习模型参数的值。注意,NumPy 的切片表示法 [:, None] 等效于使用 reshape(1,1) 将 W 重塑为下一步中所需的矩阵。

❸ 表达式 df[['X']] 使用的双方括号符号返回特征值矩阵,并使用 @ 操作进行矩阵乘积,其中包含生成结果的单列的模型参数值的矩阵,该列包含权重乘以 0.005。在这里使用矩阵乘法,因为它可以轻松扩展到许多特征和模型参数值,而无需更改实现。

❹ 误差只是标签与估计值之间的差异。

❺ 使用 Python 中的 ** 指数表示法来计算平方误差。

❻ 列名列表被指定,以确保输出的列顺序与图 A.10 中显示的顺序相对应。

假设均方误差的值存储在名为 squared_error 的 pandas DataFrame 列中,则只需使用简单地计算即可得到 MSE 的相应值

df['squared_error'].mean()

在图 A.10 中的值的情况下,会输出一个近似等于的数字

80.71

正如您所期望的,由于 W 的值是随机选择的,均方误差的值远非零。在图 A.11 中,您可以探索对 W 的各种随机选择的结果(子图(a)对应于使用 0.005 作为 W),以及随机值 W 和相应均方误差之间的关系。

A-11

图 A.11 子图 a—d 说明了选择替代的、随机选择的 W 值对均方误差的影响。请注意,在所有情况下,W 的值对应于通过特征和标签值对的点确定的直线的斜率。与图 A.5 中使用的线性回归模型不同,该图中的线经过原点,因此无法捕捉燃油效率中更低重量对应的模式。

由于基于 W 的基于线性的模型非常简单,所以不需要随机猜测 W 的值,可以依靠解决估计最小化数据集均方误差的问题的分析解。这个分析解被称为普通最小二乘法(OLS)公式(X^TX)^(-1)X^Ty,可以使用 Python 代码来实现。

列表 A.6 线性回归的普通最小二乘法解

X = df.X.values                           ❶
y = df.y.values                           ❷

W = \
  np.linalg.inv( np.array(X.T @ X,        ❸
                     dtype = np.float,
                     ndmin = 2) )
                @ np.array( X.T @ y,      ❹
                     dtype = np.float,
                     ndmin = 2)
W

❶ 将 X 分配为特征值的 NumPy 数组。

❷ 将 y 分配为标签值的 NumPy 数组。

❸ 计算 OLS 公式中的 X.T @ X 表达式,将其转换为 NumPy 矩阵(使用 ndmin=2),并使用 np.linalg.inv 倒置所得矩阵。

❹ 用 OLS 公式中的 X.T @ y 乘以倒置矩阵。

这返回一个 1 × 1 的矩阵:

array([[0.00713444]])

您可以通过使用列表 A.5 中的示例代码确认,当使用基于 OLS 公式的最优 W 值时,会产生 40.88 的 MSE。这意味着什么?请注意,与图 A.5 中显示的 LinearRegression 模型不同,仅基于 W 的模型不够复杂,无法捕捉数据中的基本模式:较大的权值会导致较低的燃油效率。当然,仅通过对图 A.11 中的子图进行目测检查,原因是显而易见的:基于单个 W 参数的线必须经过原点(即 y 截距为零);因此,不可能使用它来模拟 mpg 和 weight 数据列中大于零值之间的倒数关系。

然而,当处理更复杂的数据集时,数据维度过多以至于难以进行可视化,可视化对于判断模型是否足够灵活以捕捉到数据集中期望的模式没有帮助。除了依赖可视化,你可以进行额外的测试来评估模型的灵活性。在回归问题中,你可以将你的模型与平均标签值估计的均方误差进行比较,使用来自训练数据集的平均标签值。例如,使用训练数据集的 DataFrame,可以通过如下方式进行评估:

np.mean((df['y'] - df['y'].mean()) ** 2)

应该得到一个大约如下的输出

27.03

最优模型的均方误差为 40.88,而使用平均值的简单均方误差为 27.03,这表明该模型不够复杂(参数太少)以捕捉数据中期望的模式。

A.5 结构化数据集的分类

本节将向您介绍并演示许多机器学习算法用于训练分类模型的交叉熵损失函数。在理解了交叉熵的基础上,本节将引导您完成实现标签的独热编码及如何使用编码后的标签计算交叉熵损失值的步骤。最后,本节将教您使用 NumPy、pandas 和 scikit-learn 的最佳实践,以便您可以训练和评估一个基准的 LogisticRegression 分类模型。

从第 A.4 节可以回顾到,对于结构化数据集的分类是一个机器学习问题,其目标是从特征中估计出一个分类标签的值。例如,图 A.12 中展示的福特野马数据集可以用来训练一个分类模型(也被称为分类器),来估计车型年份的十年代,可以选择 1970 年代和 1980 年代,利用 mpg(燃油效率)和重量两个特征。

A-12

图 A.12 使用名为 1970s 和 1980s 的列对车型年份标签进行独热编码,用于表示福特野马车的十年代(左)。编码的独热性指的是在用于编码的列中,每一行都只有一个 1 值;其余值为 0。数据集的散点图(右)以“x”和“•”标记分别表示 1970 年代和 1980 年代的车辆。对于网格上的任何位置(不限于标记所示的位置),一个经过训练的分类模型必须能够判断车辆是在 1970 年代还是 1980 年代生产的。

虽然均方误差损失函数可用于一些分类问题,但许多基线分类器使用交叉熵损失。用于优化交叉熵损失的机器学习算法包括 logistic 回归(这是一种用于分类的机器学习算法,不应与回归机器学习问题混淆)和神经网络等。流行的决策树和随机森林算法使用的一个密切相关的损失函数称为基尼不纯度。本节首先解释使用交叉熵损失进行分类,以便为您理解更高级的分类机器学习算法所使用的交叉熵的变体做准备。

与均方损失不同,均方误差损失期望回归模型的输出为单个数值,而交叉熵损失期望分类模型为分类标签的每个可能值输出一个概率。继续使用用于估计福特野马车型年代的工作数据集,图 A.13 展示了基于数据集的假设分类模型的四个信息示例的输出。

在图 A.13 左上方显示的示例 1 中,分类模型将 1970 年代的估计概率为 0.6。由于概率必须加起来等于 1,因此 1980 年代的估计概率为 0.4。在这个例子中,相应的损失值(显示在示例标题中)约为 0.51,这个值显著大于零,因为分类模型虽然估计了正确的值(1970 年代),但对估计缺乏信心。从图 A.13 右上方的示例 2 中可以看出,当模型完全不确定正确的值,缺乏对 1970 年代或 1980 年代的估计信心或偏好(由于两者的概率均为 0.5)时,损失进一步增加,达到约 0.6931。简而言之,当分类模型输出正确标签估计的高概率(实际上是高置信度)时,交叉熵损失函数向零减少,否则增加。

如果分类模型在估计标签值时不正确,损失函数会进一步增加,即使在完全不确定的情况下也是如此,就像图 A.13 左下方的示例 3 中所报告的损失数字一样。在这个例子中,正确的标签值是 1980 年代,而模型对 1970 年代的估计比对 1980 年代的稍微有点信心,分别为 0.6 和 0.4。请注意,损失值进一步增加,从示例(3)中的 0.9163 增加到图 A.13 右下方的示例 4 中的 4.6052,其中分类模型对错误估计非常有信心。

A-13

图 A.13(1)模型对正确值的信心略高。 (2)模型完全不确定选择哪个值。 (3)模型对错误值的信心略高。 (4)模型对错误值的信心很高。

由于分类模型的输出由标签值的概率(概率分布)组成,所以在训练或测试机器学习模型之前,工作数据集中的原始标签必须被编码(转换)成相同的格式。编码的结果显示在图 A.12 左侧的 1970 年代和 1980 年代列中。这种转换过程被称为one-hot 编码,指的是在编码标签的列中,整行中只有一个值被设置为一(1)。

交叉熵损失函数可以用 NumPy 操作来定义。 xe_loss 函数定义实现了对给定分类模型输出 y_est 和相应的 one-hot 编码的标签值 y 数组的交叉熵损失的计算。请注意,使用此实现时,需要注意不要混淆标签和模型输出数组参数,因为 np.log 函数分别对值 0 和 1 输出 -Inf 和 0.0。

列表 A.7 xe_loss 计算并返回交叉熵损失

def xe_loss(y, y_est):
  return -np.sum( y * np.log( y_est ) )

print( xe_loss ( np.array([1., 0.]),
                     np.array([.6, .4]) ) )       ❶
print( xe_loss ( np.array([1., 0.]),
                     np.array([.5, .5]) ) )       ❷
print( xe_loss ( np.array([0., 1.]),
                     np.array([.6, .4]) ) )       ❸
print( xe_loss ( np.array([0., 1.]),
                     np.array([.99, .01]) ))      ❹

❶ 根据图 A.13 的例 3,计算损失值为 0.9163。

❷ 根据图 A.13 的例 2,计算损失值为 0.6931。

❸ 根据图 A.13 的例 3,计算损失值为 0.9163。

❹ 根据图 A.13 的例 4,计算损失值为 4.6052。

运行列表 A.7 中的代码,输出对应于图 A.13 中示例 1—4 的以下交叉熵损失值:

0.5108256237659907
0.6931471805599453
0.916290731874155
4.605170185988091

交叉熵损失的数学定义。

以下是交叉熵损失的数学描述:给定单个训练示例 yX 的标签和特征,函数定义为 A-13_EQ01,其中 K 是分类标签变量的值的数量,y[k] 是 one-hot 编码标签 y 中特定标签值的概率, A-13_EQ02 是分类模型产生的估计 A-13_EQ03 的特定标签值的概率估计。

此部分中到目前为止使用的示例依赖于已经为您进行独热编码的标签值。在实践中,在训练分类模型之前,您必须实现标签编码。虽然可以使用一套全面的 scikit-learn 类来对模型年份列标签进行独热编码,¹⁵ ,但由于在附录中数据集是作为一个 pandas DataFrame 实例化的,所以更容易使用通用的 pandas get_dummies 方法进行标签编码。这个方法的奇怪的 get_dummies 命名来自于虚拟变量,这是一个用于描述二进制指示变量的术语,它们要么是 1 表示存在,要么是 0 表示不存在一个值。

给定数据集的标签和特征作为 pandas DataFrame,将 get_dummies 方法直接应用于模型年份标签。

清单 A.8 使用 get_dummies 对分类标签进行编码

import pandas as pd

df = \                                                               ❶
  pd.DataFrame([{"model year": 1971, "mpg": 18,  "weight": 3139},
                    {"model year": 1975, "mpg": 13, "weight": 3169},
                    {"model year": 1977, "mpg": 25.5,  "weight": 2755},
                    { "model year": 1980, "mpg": 23.6, "weight": 2905},
                    {"model year": 1982, "mpg": 27,  "weight": 2790}])

enc_df = \                                                           ❷
       pd.get_dummies(df['model year'], prefix='le', sparse=False)
print(enc_df.to_string(index=False))                                 ❸

❶ 将数据集实例化为 pandas DataFrame。由于此实例化使用模型年份作为标签,因此它放置在前导列中。

❷ 使用带有 pandas Series 的 get_dummies 可以识别系列中的唯一值集,并为集合中的每个值创建一个新列。prefix 参数确保每个新列都使用指定的前缀命名。将 sparse 设置为 True 可以导致结果 DataFrame 的内存利用率降低,但不保证。具有较大数量的不同值和对应更多列的标签在独热编码格式下受益于由 sparse set 为 True 启用的稀疏数组表示。

❸ 不打印以零为基础的索引的 enc_df DataFrame。

这产生了

le_1971  le_1975  le_1977  le_1980  le_1982
       1        0        0        0        0
       0        1        0        0        0
       0        0        1        0        0
       0        0        0        1        0
       0        0        0        0        1

这不是所需的编码。尽管您可以轻松地实现将车辆的确切年份从列名转换为模型十年的代码,分箱是一种替代方法和更灵活的方法,用于执行此用例的标签编码。使用 pandas cut 方法,您可以将标签值“分箱”为一个范围:

pd.cut(df['model year'], bins=[1969, 1979, 1989])

它输出一个区间范围的 pandas.Series:

0    (1969, 1979]
1    (1969, 1979]
2    (1969, 1979]
3    (1979, 1989]
4    (1979, 1989]
Name: model year, dtype: category
Categories (2, interval[int64]): [(1969, 1979] < (1979, 1989]]

注意到前三辆车是在 1970 年代正确放置的(1969 年被排除在外,如括号所示),而其余的车辆则放置在 1980 年代。

结合标签分箱和 get_dummies 进行独热编码,

enc_df = pd.get_dummies(pd.cut(df['model year'], bins=[1969, 1979, 1989]),
               prefix='le', sparse=False)
print(enc_df.to_string(index = False))

输出了图 A.12 所示的所需编码:

le_(1969, 1979]  le_(1979, 1989]
               1                0
               1                0
               1                0
               0                1
               0                1

在使用编码值评估交叉熵损失函数之前,将标签编码的列与原始数据集合并,用编码值替换原始标签值很方便:

enc_df = pd.get_dummies(pd.cut(df['model year'], bins=[1969, 1979, 1989]),
               prefix='le', sparse=False)
          .join(df[df.columns[1:]])

print(enc_df.to_string(index = False))

它的结果是

le_(1969, 1979]  le_(1979, 1989]   mpg  weight
               1                0  18.0    3139
               1                0  13.0    3169
               1                0  25.5    2755
               0                1  23.6    2905
               0                1  27.0    2790

此时,数据集已准备好分割为用于训练的标签和特征,并转换为 NumPy 数组。从标签值开始,

y_train = df[ df.columns [df.columns.str.startswith('le_') == True] ].values
print(y_train)

输出

array([[1, 0],
       [1, 0],
       [1, 0],
       [0, 1],
       [0, 1]], dtype=uint8)

要将特征值放置到 NumPy X_train 数组中,使用

X_train = df [['mpg', 'weight']].values
print(X_train)

打印出

array([[  18\. , 3139\. ],
       [  13\. , 3169\. ],
       [  25.5, 2755\. ],
       [  23.6, 2905\. ],
       [  27\. , 2790\. ]])

此时,您已经准备好训练一个 LogisticRegression 分类器模型,

from sklearn.linear_model import LogisticRegression
model = LogisticRegression(solver='liblinear')
model.fit(X_train, y_train.argmax(axis = 1))

并计算交叉熵损失,

def cross_entropy_loss(y, y_est):
  xe = -np.sum(y * np.log (y_est))
  return xe

cross_entropy_loss(y_train, model.predict_proba(X_train))

输出为

2.314862688295351

A.6 训练监督机器学习模型

当训练机器学习模型时,几乎永远不会将整个结构化数据集用作训练数据集。相反,大多数机器学习从业者遵循的模式是将初始数据集划分为两个相互排斥的子集:开发(dev)数据集和测试(也称为保留)数据集。

A-14

图 A.14 一旦机器学习项目数据集被分割以提取保留的测试数据集,通常会直接开始探索性数据分析和机器学习模型训练,换句话说,使用开发数据集作为训练数据集(左侧)。一个更成熟的机器学习训练工作流程可以帮助及早检测过拟合并优化超参数,包括将开发数据集进一步分割为训练和验证数据集,以及使用交叉验证与开发数据集。

许多机器学习领域的新手不熟悉开发数据集的概念。虽然可以直接将其用作训练数据集(许多在线课程和教程使用这种简化方法),但为了生产训练机器学习需要更健壮的方法。这两种方法之间的区别在图 A.14 中有所说明。如图 A.14 右侧所示,开发数据集进一步分为训练和验证数据集。与以前一样,训练数据集的目的是训练机器学习模型;但验证(或评估)数据集的目的是估计训练好的机器学习模型在保留(测试)数据集上的预期性能。例如,在福特野马燃油效率数据集中,可以随机选择一个记录放入测试数据集,四个记录放入开发数据集。接下来,再次从开发数据集中随机选择一个记录放入验证数据集,其余三个记录放入训练数据集以训练机器学习模型。

由于验证数据集的目的是估计训练机器学习模型在测试数据集上的表现,只在验证数据集中拥有一条记录是一个问题。然而,尽可能多地利用开发数据集进行训练也很有价值。解决这个困境的一个方法是使用一种称为K-fold 交叉验证的技术,如图 A.15 所示。使用 K 折交叉验证的关键思想是通过 K 次重复使用开发数据集训练 K 个不同的机器学习模型,每次将开发数据集划分为 K 个折叠,其中 K-1 折叠被用作训练数据集,剩下的第 K 个折叠被用作验证数据集。图 A.15 中的示例使用三个分区,即三折交叉验证。当数据集中的观测值不能被 K 折折叠时,具有最小观测值数量的分区被指定为验证数据集。否则,所有分区的观测值数量都相同。

接下来,使用 K-1 训练数据集分区分别训练 K 个不同的机器学习模型,并使用剩余的第 K 个验证分区进行验证。因此,在图 A.15 的示例中,使用每个三个不同分区中的两个训练折叠来训练三个单独的机器学习模型。

A-15

图 A.15 K 折交叉验证技术包括训练 K 个不同的机器学习模型并报告基于每个 K 个独立模型获得的训练和验证损失(和度量)的平均值。请注意,K 个模型中的每一个都是使用开发数据集的不同验证分区进行验证的,使用开发数据集的剩余部分进行训练。

开发数据集可以按原样用作训练数据集,换句话说,作为训练数据集。然而,对于生产机器学习模型来说,这很少发生。

相反,开发数据集被进一步划分为训练数据集和验证数据集。第四章详细解释和阐述了这个过程,但是对于本附录的目的,您可以预计验证数据集用于估计机器学习模型在未使用的(测试)数据集上的性能。

^(1.)Donald E. Knuth 在他的经典著作 计算机编程艺术(Addison-Wesley Professional,2011 年)中提供了传统计算机科学算法的全面回顾。

^(2.)这些值基于公开可用的加利福尼亚大学尔湾机器学习库archive.ics.uci.edu/ml/datasets/Auto+MPG 中的数据集

^(3.)也称为面板或表格数据,结构化数据集是基于行和列组织的值

NumPy 是一个用于高性能数值计算的 Python 库。pandas 封装了 NumPy 库并将其用于高性能数据分析。

scikit-learn (scikit-learn.org)被设计用于机器学习与内存数据集,就像这里用于说明机器学习的一个简单示例一样。使用云计算的大内存数据集进行机器学习需要其他框架。

尽管它通常与现代统计学联系在一起,但线性回归源自高斯和拉格朗日在 19 世纪早期关于行星运动预测的工作。

以及许多其他科学领域,包括统计学,计量经济学,天文学等等。

可以以更简洁的方式实现这段代码,但需要解释更多的 NumPy 和 pandas 概念,包括多维数组。如果你对这个主题和张量的更一般的主题感兴趣,请参阅第五章。

为了数学上的精确性,观察结果被期望是统计独立和同分布的,尽管大多数真实世界的数据集存在这一定义的灰色地带。

因此,当你听到标签被描述为“目标值”时,不应感到惊讶。

如果你有兴趣扩展你的机器学习知识超出监督学习,并愿意阅读更多数学密集型的书籍,请查看 Stuart Russell 和 Peter Norvig(Pearson,2020)的《人工智能:一种现代方法》;Christopher Bishop(Springer,2006)的《模式识别与机器学习》;Ian Goodfellow,Yoshua Bengio 和 Aaron Courville(麻省理工学院出版社,2016)的《深度学习》;以及 Trevor Hastie,Robert Tibshirani 和 Jerome Friedman(Springer,2016)的《统计学习的要素》。

如果标签是二进制的,并且编码为—1 或 1,那么可以使用均方误差来进行分类问题的评估。

均匀概率分布的这种状态也被称为最大熵

你可能已经注意到,这几乎是 e 常数的值,因为这里的交叉熵计算使用的是自然对数。

scikit-learn 提供了一套全面的类,包括 LabelEncoder 和 LabelBinarizer,旨在帮助进行标签编码,以及 OneHotEncoder 和 OrdinalEncoder 用于特征编码;这些类最适合于不使用 pandas 存储和管理数据集的开发场景。例如,如果你的整个数据集都存储为 NumPy 数组,那么这些 scikit-learn 类是一个不错的选择。然而,如果你的数据集是一个 pandas DataFrame 或一个 pandas Series,对于标签和特征编码,直接应用 pandas 自己的 get_dummies 方法会更简单。

^(16.)这里的“相互排斥”意味着在分区之前会移除重复记录,并在去重之后,任何给定的记录都存在于其中一个子集中。