精通-PyTorch-第二版-全-

精通 PyTorch 第二版(全)

原文:zh.annas-archive.org/md5/ed4780a817b954d8a29cd07c34f589a6

译者:飞龙

协议:CC BY-NC-SA 4.0

第二章:结合 CNN 和 LSTM

加入我们的书籍社区 Discord

packt.link/EarlyAccessCommunity

img

卷积神经网络CNNs)是一种深度学习模型,已知可以解决与图像和视频相关的机器学习问题,如图像分类、目标检测、分割等。这是因为 CNN 使用一种称为卷积层的特殊层,它具有共享的可学习参数。权重或参数共享之所以有效,是因为图像中要学习的模式(如边缘或轮廓)被假定独立于图像中像素的位置。正如 CNN 适用于图像一样,长短期记忆网络LSTM)——它们是循环神经网络RNN)的一种类型——在解决顺序数据相关的机器学习问题时非常有效。顺序数据的一个例子可以是文本。例如,在一个句子中,每个单词依赖于前面的单词。LSTM 模型旨在建模这种顺序依赖关系。

这两种不同类型的网络——CNN 和 LSTM——可以级联以形成一个混合模型,接受图像或视频并输出文本。这种混合模型的一个众所周知的应用是图像字幕生成,模型接收图像并输出图像的一个合理的文本描述。自 2010 年以来,机器学习已被用于执行图像字幕生成任务[2.1]。

然而,神经网络最初成功地用于这项任务大约是在 2014/2015 年[2.2]。自那时以来,图像字幕生成一直在积极研究中。每年都有显著改进,这种深度学习应用可以成为现实世界应用的一部分,例如在网站上自动生成视觉障碍者友好的替代文本。

本章首先讨论这种混合模型的架构,以及在 PyTorch 中相关的实现细节,最后我们将使用 PyTorch 从头开始构建一个图像字幕系统。本章涵盖以下主题:

  • 使用 CNN 和 LSTM 构建神经网络

  • 使用 PyTorch 构建图像标题生成器

使用 CNN 和 LSTM 构建神经网络

CNN-LSTM 网络架构包括一个或多个卷积层,用于从输入数据(图像)中提取特征,然后是一个或多个 LSTM 层,用于执行顺序预测。这种模型在空间和时间上都很深。模型的卷积部分通常被用作一个编码器,它接受输入图像并输出高维特征或嵌入。

在实践中,用于这些混合网络的 CNN 通常是在图像分类任务上预训练的。预训练 CNN 模型的最后一个隐藏层然后作为 LSTM 组件的输入,LSTM 作为一个解码器用于生成文本。

当我们处理文本数据时,我们需要将单词和其他符号(标点符号、标识符等)一起转换为数字,统称为标记。我们通过用唯一对应的数字表示文本中的每个标记来实现这一点。在下一小节中,我们将演示文本编码的示例。

文本编码演示

让我们假设我们正在构建一个使用文本数据的机器学习模型;例如,我们的文本如下:

<start> PyTorch is a deep learning library. <end>

然后,我们将每个单词/标记映射为数字,如下所示:

<start> : 0
PyTorch : 1
is : 2
a : 3
deep : 4
learning : 5
library : 6
. : 7
<end> : 8

一旦我们有了映射,我们可以将这个句子表示为一个数字列表:

<start> PyTorch is a deep learning library. <end> -> [0, 1, 2, 3, 4, 5, 6, 7, 8]

另外,例如 <start> PyTorch is deep. <end> 将被编码为 -> [0, 1, 2, 4, 7, 8] 等等。这种映射通常被称为词汇表,构建词汇表是大多数文本相关的机器学习问题的关键部分。

作为解码器的 LSTM 模型在 t=0 时以 CNN 嵌入作为输入。然后,每个 LSTM 单元在每个时间步进行标记预测,这些预测作为下一个 LSTM 单元的输入。因此生成的整体架构可以如下图所示:

图 2.1 – 示例 CNN-LSTM 架构

图 2.1 – 示例 CNN-LSTM 架构

所演示的架构适用于图像字幕任务。如果我们不仅仅有单个图像,而是有一个图像序列(比如视频)作为 CNN 层的输入,那么我们将在每个时间步将 CNN 嵌入作为 LSTM 单元的输入,而不仅仅是在 t=0。这种架构对于诸如活动识别或视频描述等应用非常有用。

在接下来的章节中,我们将在 PyTorch 中实现一个图像字幕系统,包括构建混合模型架构、数据加载、预处理、模型训练和模型评估流程。

使用 PyTorch 构建图像字幕生成器

在这个练习中,我们将使用通用物体上下文COCO)数据集 [2.3],这是一个大规模的对象检测、分割和字幕数据集。

这个数据集包含超过 20 万张带有每张图像五个标题的标注图像。COCO 数据集于 2014 年出现,并显著促进了与对象识别相关的计算机视觉任务的进展。它是最常用于基准测试任务的数据集之一,例如对象检测、对象分割、实例分割和图像字幕。

在这个练习中,我们将使用 PyTorch 在这个数据集上训练一个 CNN-LSTM 模型,并使用训练好的模型为未见样本生成标题。在此之前,我们需要处理一些先决条件。

注意

我们将仅参考一些重要的代码片段来进行说明。完整的练习代码可以在我们的 GitHub 仓库 [2.4] 中找到。

下载图像字幕数据集

在我们开始构建图像字幕系统之前,我们需要下载所需的数据集。如果您尚未下载数据集,请在 Jupyter Notebook 的帮助下运行以下脚本。这应该可以帮助您在本地下载数据集。

注意

我们使用稍旧版本的数据集,因为它的大小稍小,这样可以更快地得到结果。

训练和验证数据集分别为 13 GB 和 6 GB。下载和提取数据集文件以及清理和处理它们可能需要一些时间。一个好主意是按照以下步骤执行,并让它们在夜间完成:

# download images and annotations to the data directory
!wget http://msvocds.blob.core.windows.net/annotations-1-0-3/captions_train-val2014.zip -P ./data_dir/
!wget http://images.cocodataset.org/zips/train2014.zip -P ./data_dir/
!wget http://images.cocodataset.org/zips/val2014.zip -P ./data_dir/
# extract zipped images and annotations and remove the zip files
!unzip ./data_dir/captions_train-val2014.zip -d ./data_dir/
!rm ./data_dir/captions_train-val2014.zip
!unzip ./data_dir/train2014.zip -d ./data_dir/
!rm ./data_dir/train2014.zip
!unzip ./data_dir/val2014.zip -d ./data_dir/
!rm ./data_dir/val2014.zip

您应该看到以下输出:

图 2.2 – 数据下载和提取

图 2.2 – 数据下载和提取

此步骤基本上创建了一个数据文件夹(./data_dir),下载了压缩的图像和注释文件,并将它们提取到数据文件夹中。

预处理字幕(文本)数据

下载的图像字幕数据集包含文本(字幕)和图像。在本节中,我们将预处理文本数据,使其可用于我们的 CNN-LSTM 模型。这项练习按步骤进行。前三个步骤专注于处理文本数据:

  1. 对于这个练习,我们需要导入一些依赖项。本章的一些关键模块如下:
import nltk
from pycocotools.coco import COCO
import torch.utils.data as data
import torchvision.models as models
import torchvision.transforms as transforms
from torch.nn.utils.rnn import pack_padded_sequence

nltk 是自然语言工具包,将有助于构建我们的词汇表,而 pycocotools 是与 COCO 数据集一起工作的辅助工具。我们在这里导入的各种 Torch 模块已在前一章中讨论过,除了最后一个 - pack_padded_sequence。此函数将有助于通过应用填充,将具有不同长度(单词数)的句子转换为固定长度的句子。

除了导入nltk库之外,我们还需要下载其punkt分词模型,如下所示:

nltk.download('punkt')

这将使我们能够将给定文本标记为组成单词。

  1. 接下来,我们构建词汇表 - 即可以将实际文本标记(如单词)转换为数值标记的字典。这一步在大多数与文本相关的任务中都是必不可少的:
def build_vocabulary(json, threshold):
    """Build a vocab wrapper."""
    coco = COCO(json)
    counter = Counter()
    ids = coco.anns.keys()
    for i, id in enumerate(ids):
        caption = str(coco.anns[id]['caption'])
        tokens = nltk.tokenize.word_tokenize(caption.lower())
        counter.update(tokens)
        if (i+1) % 1000 == 0:
            print("[{}/{}] Tokenized the captions.".format(i+1, len(ids)))

首先,在词汇构建器函数内部,加载了 JSON 文本注释,并将注释/字幕中的个别单词进行了标记化或转换为数字并存储在计数器中。

然后,丢弃少于某个数量出现次数的标记,并将剩余的标记添加到词汇对象中,同时添加一些通配符标记 - start(句子的开头)、endunknown_word和填充标记,如下所示:

 # If word freq < 'thres', then word is discarded.
    tokens = [token for token, cnt in counter.items() if cnt >= threshold]
    # Create vocab wrapper + add special tokens.
    vocab = Vocab()
    vocab.add_token('<pad>')
    vocab.add_token('<start>')
    vocab.add_token('<end>')
    vocab.add_token('<unk>')
    # Add words to vocab.
    for i, token in enumerate(tokens):
        vocab.add_token(token)
    return vocab

最后,使用词汇构建器函数创建并保存了一个词汇对象 vocab,以便进一步重用,如下所示:

vocab = build_vocabulary(json='data_dir/annotations/captions_train2014.json', threshold=4)
vocab_path = './data_dir/vocabulary.pkl'
with open(vocab_path, 'wb') as f:
    pickle.dump(vocab, f)
print("Total vocabulary size: {}".format(len(vocab)))
print("Saved the vocabulary wrapper to '{}'".format(vocab_path))

此操作的输出如下:

图 2.3 – 词汇表创建

图 2.3 – 词汇表创建

一旦构建了词汇表,我们可以在运行时将文本数据转换为数字。

图像数据预处理

下载数据并为文本标题构建词汇表后,我们需要对图像数据进行一些预处理。

因为数据集中的图像可能有不同的尺寸或形状,我们需要将所有图像重塑为固定的形状,以便它们可以输入到我们 CNN 模型的第一层,如下所示:

def reshape_images(image_path, output_path, shape):
    images = os.listdir(image_path)
    num_im = len(images)
    for i, im in enumerate(images):
        with open(os.path.join(image_path, im), 'r+b') as f:
            with Image.open(f) as image:
                image = reshape_image(image, shape)
                image.save(os.path.join(output_path, im), image.format)
        if (i+1) % 100 == 0:
            print ("[{}/{}] Resized the images and saved into '{}'.".format(i+1, num_im, output_path))
reshape_images(image_path, output_path, image_shape)

结果如下:

图 2.4 – 图像预处理(重塑)

图 2.4 – 图像预处理(重塑)

我们已将所有图像重塑为 256 x 256 像素,使其与我们的 CNN 模型架构兼容。

定义图像字幕数据加载器

我们已经下载并预处理了图像字幕数据。现在是将此数据转换为 PyTorch 数据集对象的时候了。这个数据集对象随后可以用来定义一个 PyTorch 数据加载器对象,在训练循环中使用以获取数据批次,如下所示:

  1. 现在,我们将实现自己的定制 Dataset 模块和一个自定义的数据加载器:
class CustomCocoDataset(data.Dataset):
    """COCO Dataset compatible with torch.utils.data.DataLoader."""
    def __init__(self, data_path, coco_json_path, vocabulary, transform=None):
        """Set path for images, texts and vocab wrapper.

        Args:
            data_path: image directory.
            coco_json_path: coco annotation file path.
            vocabulary: vocabulary wrapper.
            transform: image transformer.
        """
        ...
    def __getitem__(self, idx):
        """Returns one data sample (X, y)."""
        ...
        return image, ground_truth
    def __len__(self):
        return len(self.indices)

首先,为了定义我们自定义的 PyTorch Dataset 对象,我们已经为实例化、获取项目和返回数据集大小定义了自己的 __init____get_item____len__ 方法。

  1. 接下来,我们定义 collate_function,它以 Xy 的形式返回数据的小批量,如下所示:
def collate_function(data_batch):
    """Creates mini-batches of data
    We build custom collate function rather than using standard collate function,
    because padding is not supported in the standard version.
    Args:
        data: list of (image, caption)tuples.
            - image: tensor of shape (3, 256, 256).
            - caption: tensor of shape (:); variable length.
    Returns:
        images: tensor of size (batch_size, 3, 256, 256).
        targets: tensor of size (batch_size, padded_length).
        lengths: list.
    """
    ...       
    return imgs, tgts, cap_lens

通常情况下,我们不需要编写自己的 collate 函数,但我们需要处理变长句子,以便当句子的长度(例如 k)小于固定长度 n 时,使用 pack_padded_sequence 函数填充 n-k 个标记。

  1. 最后,我们将实现 get_loader 函数,该函数返回一个用于 COCO 数据集的自定义数据加载器,代码如下:
def get_loader(data_path, coco_json_path, vocabulary, transform, batch_size, shuffle):
    # COCO dataset
    coco_dataset = CustomCocoDataset(data_path=data_path,
                       coco_json_path=coco_json_path,
                       vocabulary=vocabulary,
                       transform=transform)
    custom_data_loader = torch.utils.data.DataLoader(dataset=coco_dataset, batch_size=batch_size, shuffle=shuffle,  collate_fn=collate_function)
    return custom_data_loader

在训练循环中,此函数将非常有用且高效,用于获取数据的小批量。

这完成了为模型训练设置数据流水线所需的工作。现在我们将朝着实际模型本身迈进。

定义 CNN-LSTM 模型

现在我们已经设置好了数据流水线,我们将按照 图 2.1 中的描述定义模型架构,如下所示:

class CNNModel(nn.Module):
    def __init__(self, embedding_size):
        """Load pretrained ResNet-152 & replace last fully connected layer."""
        super(CNNModel, self).__init__()
        resnet = models.resnet152(pretrained=True)
        module_list = list(resnet.children())[:-1]
      # delete last fully connected layer.
        self.resnet_module = nn.Sequential(*module_list)
        self.linear_layer = nn.Linear(resnet.fc.in_features, embedding_size)
        self.batch_norm = nn.BatchNorm1d(embedding_size, momentum=0.01)
            def forward(self, input_images):
        """Extract feats from images."""
        with torch.no_grad():
            resnet_features = self.resnet_module(input_images)
        resnet_features = resnet_features.reshape(resnet_features.size(0), -1)
        final_features = self.batch_norm(self.linear_layer(resnet_features))
        return final_features

我们定义了两个子模型,即 CNN 模型和 RNN 模型。对于 CNN 部分,我们使用了 PyTorch 模型库中可用的预训练 CNN 模型:ResNet 152 架构。在下一章节中,我们将详细学习 ResNet,这个具有 152 层的深度 CNN 模型已在 ImageNet 数据集上进行了预训练 [2.5] 。ImageNet 数据集包含超过 140 万张 RGB 图像,标注了超过 1000 个类别。这些 1000 个类别包括植物、动物、食物、运动等多个类别。

我们移除了预训练的 ResNet 模型的最后一层,并替换为一个全连接层,接着是一个批归一化层。

FAQ - 为什么我们能够替换全连接层?

神经网络可以被看作是一系列权重矩阵,从输入层到第一个隐藏层之间的权重矩阵开始,直到倒数第二层和输出层之间的权重矩阵。预训练模型可以被看作是一系列精调的权重矩阵。

通过替换最终层,实质上是替换最终的权重矩阵(K x 1000 维度,假设 K 为倒数第二层的神经元数)为一个新的随机初始化的权重矩阵(K x 256 维度,其中 256 是新的输出大小)。

批归一化层将全连接层输出归一化,使得整个批次中的均值为0,标准偏差为1。这类似于我们使用 torch.transforms 进行的标准输入数据归一化。执行批归一化有助于限制隐藏层输出值波动的程度。它还通常有助于更快的学习。由于优化超平面更加均匀(均值为0,标准偏差为1),我们可以使用更高的学习率。

由于这是 CNN 子模型的最终层,批归一化有助于隔离 LSTM 子模型免受 CNN 可能引入的任何数据变化的影响。如果我们不使用批归一化,那么在最坏的情况下,CNN 最终层可能会在训练期间输出具有均值 > 0.5 和标准偏差 = 1 的值。但是在推断期间,如果对于某个图像,CNN 输出具有均值 < 0.5 和标准偏差 = 1 的值,那么 LSTM 子模型将难以处理这种未预见的数据分布。

回到全连接层,我们引入自己的层是因为我们不需要 ResNet 模型的 1,000 类概率。相反,我们想要使用这个模型为每个图像生成一个嵌入向量。这个嵌入可以被看作是一个给定输入图像的一维数字编码版本。然后将这个嵌入送入 LSTM 模型。

我们将在第四章详细探讨 LSTM,深度递归模型架构。但正如我们在图 2.1中看到的,LSTM 层将嵌入向量作为输入,并输出一系列理想情况下应描述生成该嵌入的图像的单词:

class LSTMModel(nn.Module):
    def __init__(self, embedding_size, hidden_layer_size, vocabulary_size, num_layers, max_seq_len=20):
        ...
        self.lstm_layer = nn.LSTM(embedding_size, hidden_layer_size, num_layers, batch_first=True)
        self.linear_layer = nn.Linear(hidden_layer_size, vocabulary_size)
        ...

    def forward(self, input_features, capts, lens):
        ...
        hidden_variables, _ = self.lstm_layer(lstm_input)
        model_outputs = self.linear_layer(hidden_variables[0])
        return model_outputs

LSTM 模型由 LSTM 层后跟一个全连接线性层组成。LSTM 层是一个递归层,可以想象为沿时间维度展开的 LSTM 单元,形成 LSTM 单元的时间序列。对于我们的用例,这些单元将在每个时间步输出单词预测概率,而具有最高概率的单词将被附加到输出句子中。

每个时间步的 LSTM 单元还会生成一个内部单元状态,这将作为下一个时间步 LSTM 单元的输入。这个过程持续进行,直到一个 LSTM 单元输出一个<end>标记/词。<end>标记将附加到输出句子中。完成的句子就是我们对图像的预测标题。

请注意,我们还在max_seq_len变量下指定了允许的最大序列长度为20。这基本上意味着任何少于 20 个词的句子将在末尾填充空词标记,而超过 20 个词的句子将被截断为前 20 个词。

为什么我们这样做,以及为什么选择 20?如果我们真的希望我们的 LSTM 处理任意长度的句子,我们可能会将这个变量设置为一个非常大的值,比如 9999 个词。然而,(a)不多的图像标题包含那么多词,而且(b)更重要的是,如果有这样的超长异常句子,LSTM 将在学习跨如此多时间步的时间模式时遇到困难。

我们知道 LSTM 在处理较长序列时比 RNN 更好;然而,跨这种序列长度保持记忆是困难的。考虑到通常的图像标题长度和我们希望模型生成的最大标题长度,我们选择20作为一个合理的数字。

前面代码中 LSTM 层和线性层对象都是从nn.module派生的,我们定义了__init__forward方法来构建模型并通过模型运行前向传播。对于 LSTM 模型,我们还额外实现了一个sample方法,如下所示,用于为给定图像生成标题:

 def sample(self, input_features, lstm_states=None):
        """Generate caps for feats with greedy search."""
        sampled_indices = []
        ...
        for i in range(self.max_seq_len):
...
            sampled_indices.append(predicted_outputs)
            ...
        sampled_indices = torch.stack(sampled_indices, 1)
        return sampled_indices

sample方法利用贪婪搜索生成句子;也就是说,它选择具有最高总体概率的序列。

这将我们带到图像标注模型定义步骤的最后。现在我们已经准备好训练这个模型了。

训练 CNN-LSTM 模型

由于我们已经在前一节中定义了模型架构,现在我们将训练 CNN-LSTM 模型。让我们一步一步地检查这一步骤的详细信息:

  1. 首先,我们定义设备。如果有 GPU 可用,则用于训练;否则,使用 CPU:
# Device configuration device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

虽然我们已经将所有图像重塑为固定形状(256, 256),但这还不够。我们仍然需要对数据进行归一化。

FAQ - 为什么我们需要对数据进行归一化?

数据归一化很重要,因为不同的数据维度可能具有不同的分布,这可能会偏移整体优化空间并导致梯度下降效率低下(想象椭圆与圆的区别)。

  1. 我们将使用 PyTorch 的transform模块来归一化输入图像像素值:
# Image pre-processing, normalization for pretrained resnet
transform = transforms.Compose([
    transforms.RandomCrop(224),
    transforms.RandomHorizontalFlip(),
    transforms.ToTensor(),
    transforms.Normalize((0.485, 0.456, 0.406),
                         (0.229, 0.224, 0.225))])

此外,我们增加了可用数据集。

FAQ - 为什么我们需要数据增强?

增强不仅有助于生成更大量的训练数据,还有助于使模型在输入数据的潜在变化中变得更加健壮。

在这里,我们使用 PyTorch 的transform模块实现了两种数据增强技术:

i) 随机裁剪,将图像大小从(256, 256)减小为(224, 224)

ii) 图像的水平翻转。

  1. 接下来,我们加载在预处理字幕(文本)数据部分中构建的词汇表。我们还使用在定义图像字幕数据加载器部分中定义的get_loader函数初始化数据加载器:
# Load vocab wrapper
with open('data_dir/vocabulary.pkl', 'rb') as f:
    vocabulary = pickle.load(f)

# Instantiate data loader
custom_data_loader = get_loader('data_dir/resized_images', 'data_dir/annotations/captions_train2014.json', vocabulary,
                         transform, 128,
                         shuffle=True)
  1. 接下来,我们进入本步骤的主要部分,在这里,我们以编码器和解码器模型的形式实例化 CNN 和 LSTM 模型。此外,我们还定义损失函数 – 交叉熵损失 – 和优化调度 – Adam 优化器 – 如下所示:
# Build models
encoder_model = CNNModel(256).to(device)
decoder_model = LSTMModel(256, 512, len(vocabulary), 1).to(device)

# Loss & optimizer
loss_criterion = nn.CrossEntropyLoss()
parameters = list(decoder_model.parameters()) + list(encoder_model.linear_layer.parameters()) + list(encoder_model.batch_norm.parameters())
optimizer = torch.optim.Adam(parameters, lr=0.001)

第一章中讨论的,使用 PyTorch 进行深度学习的概述中,Adam 可能是处理稀疏数据时最佳选择的优化调度。在这里,我们处理图像和文本 – 这两者都是稀疏数据的完美例子,因为并非所有像素包含有用信息,而数值化/向量化的文本本身就是一个稀疏矩阵。

  1. 最后,我们运行训练循环(五个 epoch),使用数据加载器获取一个 CO​​CO 数据集的小批量数据,通过编码器和解码器网络对小批量进行前向传递,最后使用反向传播调整 CNN-LSTM 模型的参数(LSTM 网络的时间反向传播):
for epoch in range(5):
    for i, (imgs, caps, lens) in enumerate(custom_data_loader):
        tgts = pack_padded_sequence(caps, lens, batch_first=True)[0]
        # Forward pass, backward propagation
        feats = encoder_model(imgs)
        outputs = decoder_model(feats, caps, lens)
        loss = loss_criterion(outputs, tgts)
        decoder_model.zero_grad()
        encoder_model.zero_grad()
        loss.backward()
        optimizer.step()

每 1,000 次迭代进入训练循环时,我们保存一个模型检查点。为了演示目的,我们只运行了两个 epoch 的训练,如下所示:

 # Log training steps
        if i % 10 == 0:
            print('Epoch [{}/{}], Step [{}/{}], Loss: {:.4f}, Perplexity: {:5.4f}'
                  .format(epoch, 5, i, total_num_steps, loss.item(), np.exp(loss.item())))
        # Save model checkpoints
        if (i+1) % 1000 == 0:
            torch.save(decoder_model.state_dict(), os.path.join(
                'models_dir/', 'decoder-{}-{}.ckpt'.format(epoch+1, i+1)))
            torch.save(encoder_model.state_dict(), os.path.join(
                'models_dir/', 'encoder-{}-{}.ckpt'.format(epoch+1, i+1)))

输出将如下所示:

图 2.5 – 模型训练循环

图 2.5 – 模型训练循环

使用训练模型生成图像标题

在前一节中,我们训练了一个图像字幕模型。在本节中,我们将使用训练好的模型为模型以前未见的图像生成字幕:

  1. 我们已经存储了一个样本图像,sample.jpg,用于运行推理。我们定义一个函数来加载图像并将其重塑为(224, 224)像素。然后,我们定义转换模块来规范化图像像素,如下所示:
image_file_path = 'sample.jpg'
# Device config
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
def load_image(image_file_path, transform=None):
    img = Image.open(image_file_path).convert('RGB')
    img = img.resize([224, 224], Image.LANCZOS)
    if transform is not None:
        img = transform(img).unsqueeze(0)
    return img
# Image pre-processing
transform = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize((0.485, 0.456, 0.406),
                         (0.229, 0.224, 0.225))])
  1. 接下来,我们加载词汇表并实例化编码器和解码器模型:
# Load vocab wrapper
with open('data_dir/vocabulary.pkl', 'rb') as f:
    vocabulary = pickle.load(f)
# Build models
encoder_model = CNNModel(256).eval()  # eval mode (batchnorm uses moving mean/variance)
decoder_model = LSTMModel(256, 512, len(vocabulary), 1)
encoder_model = encoder_model.to(device)
decoder_model = decoder_model.to(device)
  1. 一旦我们有了模型框架,我们将使用训练的两个 epoch 中最新保存的检查点来设置模型参数:
# Load trained model params
encoder_model.load_state_dict(torch.load('models_dir/encoder-2-3000.ckpt'))
decoder_model.load_state_dict(torch.load('models_dir/decoder-2-3000.ckpt'))

在此之后,模型已准备好用于推理。

  1. 接下来,我们加载图像并进行模型推理 – 首先使用编码器模型从图像生成嵌入,然后将嵌入传递给解码器网络以生成序列,如下所示:
# Prepare image
img = load_image(image_file_path, transform)
img_tensor = img.to(device)
# Generate caption text from image
feat = encoder_model(img_tensor)
sampled_indices = decoder_model.sample(feat)
sampled_indices = sampled_indices[0].cpu().numpy()
          # (1, max_seq_length) -> (max_seq_length)
  1. 此时,字幕预测仍以数字标记的形式存在。我们需要使用反向词汇表将数字标记转换为实际文本:
# Convert numeric tokens to text tokens
predicted_caption = []
for token_index in sampled_indices:
    word = vocabulary.i2w[token_index]
    predicted_caption.append(word)
    if word == '<end>':
        break
predicted_sentence = ' '.join(predicted_caption)
  1. 一旦我们将输出转换为文本,我们可以同时可视化图像及生成的标题:
# Print image & generated caption text
print (predicted_sentence)
img = Image.open(image_file_path)
plt.imshow(np.asarray(img))

输出如下:

图 2.6 – 在样本图像上的模型推理

图 2.6 – 在样本图像上的模型推理

看起来虽然模型并非完美无缺,但在两个 epochs 内,已经训练得足够好以生成合理的标题。

总结

本章讨论了在编码器-解码器框架中结合 CNN 模型和 LSTM 模型的概念,联合训练它们,并使用组合模型为图像生成标题。

我们在本章和上一章的练习中都使用了 CNN。

在下一章中,我们将深入探讨多年来开发的不同 CNN 架构的全貌,它们各自的独特用途,以及如何使用 PyTorch 轻松实现它们。

第三章:深度 CNN 架构

加入我们的书籍社区 Discord

packt.link/EarlyAccessCommunity

img

在本章中,我们将首先简要回顾 CNN 的演变(在架构方面),然后详细研究不同的 CNN 架构。我们将使用 PyTorch 实现这些 CNN 架构,并在此过程中,我们旨在全面探索 PyTorch 在构建深度 CNN时提供的工具(模块和内置函数)。在 PyTorch 中建立强大的 CNN 专业知识将使我们能够解决涉及 CNN 的多个深度学习问题。这也将帮助我们构建更复杂的深度学习模型或 CNN 是其一部分的应用程序。

本章将涵盖以下主题:

  • CNN 如此强大的原因是什么?

  • CNN 架构的演变

  • 从头开始开发 LeNet

  • 微调 AlexNet 模型

  • 运行预训练的 VGG 模型

  • 探索 GoogLeNet 和 Inception v3

  • 讨论 ResNet 和 DenseNet 架构

  • 理解 EfficientNets 和 CNN 架构的未来

CNN 如此强大的原因是什么?

CNN 是解决诸如图像分类、物体检测、物体分割、视频处理、自然语言处理和语音识别等挑战性问题中最强大的机器学习模型之一。它们的成功归因于各种因素,例如以下几点:

  • 权重共享:这使得 CNN 在参数效率上更为高效,即使用相同的权重或参数集合来提取不同的特征。特征是模型使用其参数生成的输入数据的高级表示。

  • 自动特征提取:多个特征提取阶段帮助 CNN 自动学习数据集中的特征表示。

  • 分层学习:多层 CNN 结构帮助 CNN 学习低、中和高级特征。

  • 能够探索数据中的空间和时间相关性,例如在视频处理任务中。

除了这些现有的基本特征之外,多年来,CNN 在以下领域的改进帮助下不断进步:

  • 使用更好的激活损失函数,例如使用ReLU来克服梯度消失问题

  • 参数优化,例如使用基于自适应动量(Adam)而非简单随机梯度下降的优化器。

  • 正则化:除了 L2 正则化外,应用了丢弃法和批量归一化。

FAQ - 什么是梯度消失问题?

在神经网络中的反向传播基于微分的链式法则。根据链式法则,损失函数对输入层参数的梯度可以写成每层梯度的乘积。如果这些梯度都小于 1,甚至趋近于 0,那么这些梯度的乘积将会是一个接近于零的值。梯度消失问题可能会在优化过程中造成严重问题,因为它会阻止网络参数改变其值,这相当于限制了学习能力。

然而,多年来推动 CNN 发展的一些最重要的因素之一是各种架构创新

  • 基于空间探索的 CNNs空间探索的理念是使用不同的核尺寸来探索输入数据中不同级别的视觉特征。以下图表展示了一个基于空间探索的 CNN 模型的示例架构:

    图 3.1 – 基于空间探索的 CNN

    图 3.1 – 基于空间探索的 CNN

  • 基于深度的 CNNs:这里的深度指的是神经网络的深度,也就是层数。因此,这里的理念是创建一个带有多个卷积层的 CNN 模型,以提取高度复杂的视觉特征。以下图表展示了这样一个模型架构的示例:

    图 3.2 – 基于深度的 CNN

    图 3.2 – 基于深度的 CNN

  • 基于宽度的 CNNs宽度指的是数据中的通道数或特征图数量。因此,基于宽度的 CNNs 旨在从输入到输出层增加特征图的数量,如以下图表所示:

    图 3.3 – 基于宽度的 CNN

    图 3.3 – 基于宽度的 CNN

  • 基于多路径的 CNNs:到目前为止,前面提到的三种架构在层之间的连接上是单调的,即仅存在于连续层之间的直接连接。多路径 CNNs引入了在非连续层之间建立快捷连接或跳跃连接的理念。以下图表展示了一个多路径 CNN 模型架构的示例:

图 3.4 – 多路径 CNN

图 3.4 – 多路径 CNN

多路径架构的一个关键优势是信息在多个层之间的更好流动,这要归功于跳跃连接。这反过来也使得梯度能够回流到输入层而不会有太多损耗。

现在我们已经看过 CNN 模型中不同的架构设置,接下来我们将看看自从它们首次使用以来,CNN 如何在这些年来发展。

CNN 架构的演变

1989 年以来,CNN 一直存在,当时第一个多层次 CNN,称为ConvNet,是由 Yann LeCun 开发的。这个模型可以执行视觉认知任务,如识别手写数字。1998 年,LeCun 开发了一个改进的 ConvNet 模型称为LeNet。由于其在光学识别任务中的高准确性,LeNet 很快就被工业界采用。从那时起,CNN 一直是最成功的机器学习模型之一,无论在工业界还是学术界。以下图表显示了从 1989 年到 2020 年 CNN 架构发展的简要时间轴:

图 3.5 – CNN 架构演进 – 大局览

图 3.5 – CNN 架构演进 – 大局览

我们可以看到,1998 年和 2012 年之间存在显著差距。这主要是因为当时没有足够大且合适的数据集来展示 CNN,特别是深度 CNN 的能力。在当时现有的小数据集(如 MNIST)上,传统的机器学习模型如 SVM 开始击败 CNN 的性能。在这些年里,进行了一些 CNN 的发展。

ReLU 激活函数的设计旨在处理反向传播过程中的梯度爆炸和衰减问题。网络参数值的非随机初始化被证明是至关重要的。Max-pooling被发明作为一种有效的子采样方法。GPU 在训练神经网络,尤其是大规模 CNN 时变得流行。最后但也是最重要的是,由斯坦福研究团队创建的大规模带注释图像数据集ImageNet [3.1],至今仍然是 CNN 模型的主要基准数据集之一。

随着这些发展多年来的叠加,2012 年,一种不同的架构设计在ImageNet数据集上显著改善了 CNN 性能。这个网络被称为AlexNet(以创建者 Alex Krizhevsky 命名)。AlexNet 除了具有随机裁剪和预训练等各种新颖特点外,还确立了统一和模块化卷积层设计的趋势。这种统一和模块化的层结构通过反复堆叠这些模块(卷积层)被推广,导致了非常深的 CNN,也被称为VGGs

另一种分支卷积层块/模块并将这些分支块堆叠在一起的方法对定制视觉任务非常有效。这种网络被称为GoogLeNet(因为它是在 Google 开发的)或Inception v1(inception 是指那些分支块的术语)。随后出现了几个VGGInception网络的变体,如VGG16VGG19Inception v2Inception v3等。

开发的下一阶段始于跳跃连接。为了解决训练 CNN 时梯度衰减的问题,非连续层通过跳跃连接连接,以免信息因小梯度而在它们之间消失。利用这一技巧出现了一种流行的网络类型,其中包括批量归一化等其他新特性,即ResNet

ResNet 的一个逻辑扩展是DenseNet,其中各层之间密集连接,即每一层都从前面所有层的输出特征图中获取输入。此外,混合架构随后发展,结合了过去成功的架构,如Inception-ResNetResNeXt,其中块内的并行分支数量增加。

近年来,通道增强技术在提高 CNN 性能方面证明了其实用性。其思想是通过迁移学习学习新特征并利用预先学习的特征。最近,自动设计新块并找到最优 CNN 架构已成为 CNN 研究的一个趋势。这些 CNN 的例子包括MnasNetsEfficientNets。这些模型背后的方法是执行神经架构搜索,以推断具有统一模型缩放方法的最优 CNN 架构。

在接下来的部分,我们将回顾最早的一些 CNN 模型,并深入研究自那时以来发展的各种 CNN 架构。我们将使用 PyTorch 构建这些架构,训练其中一些模型并使用真实数据集。我们还将探索 PyTorch 的预训练 CNN 模型库,通常称为模型动物园。我们将学习如何微调这些预训练模型以及在它们上运行预测。

从头开始开发 LeNet

LeNet,最初称为LeNet-5,是最早的 CNN 模型之一,于 1998 年开发。LeNet-5 中的数字5代表了该模型中的总层数,即两个卷积层和三个全连接层。这个模型总共大约有 60,000 个参数,在 1998 年的手写数字图像识别任务中表现出色。与当时的经典机器学习模型(如 SVM)不同,后者将图像的每个像素分别处理,LeNet 则利用了相邻像素之间的相关性,展示了旋转、位置和尺度不变性以及对图像扭曲的鲁棒性。

请注意,尽管 LeNet 最初是为手写数字识别而开发的,但它当然可以扩展到其他图像分类任务,正如我们将在下一个练习中看到的那样。以下图显示了 LeNet 模型的架构:

图 3.6 – LeNet 架构

图 3.6 – LeNet 架构

正如前面提到的,图中有两个卷积层,接着是三个全连接层(包括输出层)。这种先堆叠卷积层,然后后续使用全连接层的方法后来成为 CNN 研究的趋势,并且仍然应用于最新的 CNN 模型。除了这些层外,中间还有池化层。这些基本上是减少图像表示的空间大小的子采样层,从而减少参数和计算量。LeNet 中使用的池化层是一个具有可训练权重的平均池化层。不久之后,最大池化成为 CNN 中最常用的池化函数。

图中每个层中括号中的数字显示了维度(对于输入、输出和全连接层)或窗口大小(对于卷积和池化层)。灰度图像的预期输入大小为 32x32 像素。然后该图像经过 5x5 的卷积核操作,接着是 2x2 的池化操作,依此类推。输出层大小为 10,代表 10 个类别。

在本节中,我们将使用 PyTorch 从头开始构建 LeNet,并在图像分类任务的图像数据集上对其进行训练和评估。我们将看到使用 PyTorch 根据图 3.6中的概述构建网络架构是多么简单和直观。

此外,我们将演示 LeNet 的有效性,即使在与其最初开发的数据集(即 MNIST)不同的数据集上,并且 PyTorch 如何在几行代码中轻松训练和测试模型。

使用 PyTorch 构建 LeNet

遵循以下步骤构建模型:

  1. 对于此练习,我们需要导入几个依赖项。执行以下import语句:
import numpy as np
import matplotlib.pyplot as plt
import torch
import torchvision
import torch.nn as nn
import torch.nn.functional as F
import torchvision.transforms as transforms
torch.use_deterministic_algorithms(True) 

除了通常的导入之外,我们还调用了use_deterministic_algorithms函数,以确保此练习的可重现性。

  1. 接下来,我们将根据图 3.6中的概述定义模型架构:
class LeNet(nn.Module):
    def __init__(self):
        super(LeNet, self).__init__()
        # 3 input image channel, 6 output feature maps and 5x5 conv kernel
        self.cn1 = nn.Conv2d(3, 6, 5)
        # 6 input image channel, 16 output feature maps and 5x5 conv kernel
        self.cn2 = nn.Conv2d(6, 16, 5)
        # fully connected layers of size 120, 84 and 10
        self.fc1 = nn.Linear(16 * 5 * 5, 120)  # 5*5 is the spatial dimension at this layer
        self.fc2 = nn.Linear(120, 84)
        self.fc3 = nn.Linear(84, 10)
    def forward(self, x):
        # Convolution with 5x5 kernel
        x = F.relu(self.cn1(x))
        # Max pooling over a (2, 2) window
        x = F.max_pool2d(x, (2, 2))
        # Convolution with 5x5 kernel
        x = F.relu(self.cn2(x))
        # Max pooling over a (2, 2) window
        x = F.max_pool2d(x, (2, 2))
        # Flatten spatial and depth dimensions into a single vector
        x = x.view(-1, self.flattened_features(x))
        # Fully connected operations
        x = F.relu(self.fc1(x))
        x = F.relu(self.fc2(x))
        x = self.fc3(x)
        return x
    def flattened_features(self, x):
        # all except the first (batch) dimension
        size = x.size()[1:]  
        num_feats = 1
        for s in size:
            num_feats *= s
        return num_feats
lenet = LeNet()
print(lenet)

在最后两行,我们实例化模型并打印网络架构。输出将如下所示:

图 3.7 – LeNet PyTorch 模型对象

图 3.7 – LeNet PyTorch 模型对象

架构定义和运行前向传播的通常__init__forward方法。额外的flattened_features方法旨在计算图像表示层(通常是卷积层或池化层的输出)中的总特征数。此方法有助于将特征的空间表示展平为单个数字向量,然后作为全连接层的输入使用。

除了前面提到的架构细节,ReLU 被用作整个网络的激活函数。与原始的 LeNet 网络相反,该模型被修改为接受 RGB 图像(即三个通道)作为输入。这样做是为了适应用于此练习的数据集。

  1. 接下来我们定义训练例程,即实际的反向传播步骤:
def train(net, trainloader, optim, epoch):
    # initialize loss
    loss_total = 0.0
     for i, data in enumerate(trainloader, 0):
        # get the inputs; data is a list of [inputs, labels]
        # ip refers to the input images, and ground_truth refers to the output classes the images belong to
        ip, ground_truth = data
        # zero the parameter gradients
        optim.zero_grad()
        # forward-pass + backward-pass + optimization -step
        op = net(ip)
        loss = nn.CrossEntropyLoss()(op, ground_truth)
        loss.backward()
        optim.step()
        # update loss
        loss_total += loss.item()
         # print loss statistics
        if (i+1) % 1000 == 0:    # print at the interval of 1000 mini-batches
            print('[Epoch number : %d, Mini-batches: %5d] loss: %.3f' % (epoch + 1, i + 1, loss_total / 200))
            loss_total = 0.0

每个 epoch,此函数会遍历整个训练数据集,通过网络进行前向传播,并使用反向传播根据指定的优化器更新模型参数。在遍历训练数据集的每 1,000 个小批次后,该方法还会记录计算得到的损失。

  1. 类似于训练例程,我们将定义用于评估模型性能的测试例程:
def test(net, testloader):
    success = 0
    counter = 0
    with torch.no_grad():
        for data in testloader:
            im, ground_truth = data
            op = net(im)
            _, pred = torch.max(op.data, 1)
            counter += ground_truth.size(0)
            success += (pred == ground_truth).sum().item()
    print('LeNet accuracy on 10000 images from test dataset: %d %%' % (100 * success / counter))

此函数为每个测试集图像执行模型的前向传播,计算正确预测的数量,并打印出测试集上的正确预测百分比。

  1. 在我们开始训练模型之前,我们需要加载数据集。对于此练习,我们将使用CIFAR-10数据集。

数据集引用

从小图像中学习多层特征,Alex Krizhevsky,2009

该数据集包含 60,000 个 32x32 的 RGB 图像,分为 10 个类别,每个类别 6000 张图像。这 60,000 张图像分为 50,000 张训练图像和 10,000 张测试图像。更多详细信息可以在数据集网站 [3.2] 找到。Torch 在torchvision.datasets模块下提供了CIFAR数据集。我们将使用该模块直接加载数据,并按照以下示例代码实例化训练和测试的数据加载器:

# The mean and std are kept as 0.5 for normalizing pixel values as the pixel values are originally in the range 0 to 1
train_transform = transforms.Compose([transforms.RandomHorizontalFlip(),
transforms.RandomCrop(32, 4),
transforms.ToTensor(),
transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))])
trainset = torchvision.datasets.CIFAR10(root='./data', train=True, download=True, transform=train_transform)
trainloader = torch.utils.data.DataLoader(trainset, batch_size=8, shuffle=True)
test_transform = transforms.Compose([transforms.ToTensor(), transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))])
testset = torchvision.datasets.CIFAR10(root='./data', train=False, download=True, transform=test_transform)
testloader = torch.utils.data.DataLoader(testset, batch_size=10000, shuffle=False)
# ordering is important
classes = ('plane', 'car', 'bird', 'cat', 'deer', 'dog', 'frog', 'horse', 'ship', 'truck')

注意

在上一章中,我们手动下载了数据集并编写了自定义数据集类和dataloader函数。在这里,由于torchvision.datasets模块的存在,我们无需再次编写这些内容。

因为我们将download标志设置为True,数据集将被下载到本地。然后,我们将看到以下输出:

图 3.8 – CIFAR-10 数据集下载

图 3.8 – CIFAR-10 数据集下载

用于训练和测试数据集的转换不同,因为我们对训练数据集应用了一些数据增强,例如翻转和裁剪,这些对测试数据集不适用。此外,在定义trainloadertestloader之后,我们使用预定义的顺序声明了该数据集中的 10 个类别。

  1. 加载数据集后,让我们来看看数据的情况:
# define a function that displays an image
def imageshow(image):
    # un-normalize the image
    image = image/2 + 0.5     
    npimage = image.numpy()
    plt.imshow(np.transpose(npimage, (1, 2, 0)))
    plt.show()
# sample images from training set
dataiter = iter(trainloader)
images, labels = dataiter.next()
# display images in a grid
num_images = 4
imageshow(torchvision.utils.make_grid(images[:num_images]))
# print labels
print('    '+'  ||  '.join(classes[labels[j]] for j in range(num_images)))

上述代码展示了来自训练数据集的四个样本图像及其相应的标签。输出将如下所示:

图 3.9 – CIFAR-10 数据集样本

图 3.9 – CIFAR-10 数据集样本

上述输出展示了四张颜色图像,每张图像大小为 32x32 像素。这四张图片属于四个不同的标签,如下文所示。

现在我们将训练 LeNet 模型。

训练 LeNet

让我们通过以下步骤训练模型:

  1. 我们将定义 optimizer 并开始如下的训练循环:
# define optimizer
optim = torch.optim.Adam(lenet.parameters(), lr=0.001)
# training loop over the dataset multiple times
for epoch in range(50):  
    train(lenet, trainloader, optim, epoch)
    print()
    test(lenet, testloader)
    print()
print('Finished Training')

输出如下所示:

图 3.10 – 训练 LeNet

图 3.10 – 训练 LeNet

  1. 训练完成后,我们可以将模型文件保存到本地:
model_path = './cifar_model.pth'
torch.save(lenet.state_dict(), model_path)

在训练完 LeNet 模型后,我们将在下一节中测试其在测试数据集上的表现。

测试 LeNet

测试 LeNet 模型需要遵循以下步骤:

  1. 通过加载保存的模型并在测试数据集上运行,让我们进行预测:
# load test dataset images
d_iter = iter(testloader)
im, ground_truth = d_iter.next()
# print images and ground truth
imageshow(torchvision.utils.make_grid(im[:4]))
print('Label:      ', ' '.join('%5s' % classes[ground_truth[j]] for j in range(4)))
# load model
lenet_cached = LeNet()
lenet_cached.load_state_dict(torch.load(model_path))
# model inference
op = lenet_cached(im)
# print predictions
_, pred = torch.max(op, 1)
print('Prediction: ', ' '.join('%5s' % classes[pred[j]] for j in range(4)))

输出如下所示:

图 3.11 – LeNet 预测

图 3.11 – LeNet 预测

显然,四次预测中有三次是正确的。

  1. 最后,我们将检查该模型在测试数据集上的总体准确度以及每类准确度:
success = 0
counter = 0
with torch.no_grad():
    for data in testloader:
        im, ground_truth = data
        op = lenet_cached(im)
        _, pred = torch.max(op.data, 1)
        counter += ground_truth.size(0)
        success += (pred == ground_truth).sum().item()
print('Model accuracy on 10000 images from test dataset: %d %%' % (
    100 * success / counter))

输出如下所示:

图 3.12 – LeNet 总体准确度

图 3.12 – LeNet 总体准确度

  1. 对于每类准确度,代码如下:
class_sucess = list(0\. for i in range(10))
class_counter = list(0\. for i in range(10))
with torch.no_grad():
    for data in testloader:
        im, ground_truth = data
        op = lenet_cached(im)
        _, pred = torch.max(op, 1)
        c = (pred == ground_truth).squeeze()
        for i in range(10000):
            ground_truth_curr = ground_truth[i]
            class_sucess[ground_truth_curr] += c[i].item()
            class_counter[ground_truth_curr] += 1
for i in range(10):
    print('Model accuracy for class %5s : %2d %%' % (
        classes[i], 100 * class_sucess[i] / class_counter[i]))

输出如下所示:

图 3.13 – LeNet 每类准确度

图 3.13 – LeNet 每类准确度

有些类别的表现比其他类别好。总体而言,该模型远非完美(即 100% 准确率),但比随机预测的模型要好得多,后者的准确率为 10%(由于有 10 个类别)。

在从头开始构建 LeNet 模型并评估其在 PyTorch 中的表现后,我们现在将转向 LeNet 的后继者 – AlexNet。对于 LeNet,我们从头开始构建了模型,进行了训练和测试。对于 AlexNet,我们将使用预训练模型,对其在较小数据集上进行微调,并进行测试。

对 AlexNet 模型进行微调

在本节中,我们首先快速浏览 AlexNet 的架构以及如何使用 PyTorch 构建一个。然后我们将探索 PyTorch 的预训练 CNN 模型库,最后,使用预训练的 AlexNet 模型进行微调,用于图像分类任务以及进行预测。

AlexNet 是 LeNet 的后继者,其架构有所增强,例如由 5 层(5 个卷积层和 3 个全连接层)增加到 8 层,并且模型参数从 6 万增加到 6000 万,同时使用 MaxPool 而不是 AvgPool。此外,AlexNet 是在更大的数据集 ImageNet 上训练和测试的,ImageNet 数据集超过 100 GB,而 LeNet 是在 MNIST 数据集上训练的,后者只有几 MB 大小。AlexNet 在图像相关任务中显著领先于其他传统机器学习模型,如 SVM。图 3.14 展示了 AlexNet 的架构:

图 3.14 – AlexNet 架构

图 3.14 – AlexNet 架构

如我们所见,该架构遵循了 LeNet 的常见主题,即由卷积层顺序堆叠,然后是一系列全连接层朝向输出端。PyTorch 使得将这样的模型架构转化为实际代码变得容易。可以在以下 PyTorch 代码中看到这一点- 该架构的等效代码:

class AlexNet(nn.Module):
    def __init__(self, number_of_classes):
        super(AlexNet, self).__init__()
        self.feats = nn.Sequential(
            nn.Conv2d(in_channels=3, out_channels=64, kernel_size=11, stride=4, padding=5),
            nn.ReLU(),
            nn.MaxPool2d(kernel_size=2, stride=2),
            nn.Conv2d(in_channels=64, out_channels=192, kernel_size=5, padding=2),
            nn.ReLU(),
            nn.MaxPool2d(kernel_size=2, stride=2),
            nn.Conv2d(in_channels=192, out_channels=384, kernel_size=3, padding=1),
            nn.ReLU(),
            nn.Conv2d(in_channels=384, out_channels=256, kernel_size=3, padding=1),
            nn.ReLU(),
            nn.Conv2d(in_channels=256, out_channels=256, kernel_size=3, padding=1),
            nn.ReLU(),
            nn.MaxPool2d(kernel_size=2, stride=2),
        )
        self.clf = nn.Linear(in_features=256, out_features=number_of_classes)
    def forward(self, inp):
        op = self.feats(inp)
        op = op.view(op.size(0), -1)
        op = self.clf(op)
        return op

代码相当自解释,__init__函数包含了整个分层结构的初始化,包括卷积、池化和全连接层,以及 ReLU 激活函数。forward函数简单地通过初始化的网络运行数据点x。请注意,forward方法的第二行已经执行了扁平化操作,因此我们无需像为 LeNet 那样单独定义该函数。

除了初始化模型架构并自行训练的选项外,PyTorch 还提供了一个torchvision包,其中包含用于解决不同任务(如图像分类、语义分割、目标检测等)的 CNN 模型的定义。以下是用于图像分类任务的可用模型的非详尽列表 [3.3]:

  • AlexNet

  • VGG

  • ResNet

  • SqueezeNet

  • DenseNet

  • Inception v3

  • GoogLeNet

  • ShuffleNet v2

  • MobileNet v2

  • ResNeXt

  • Wide ResNet

  • MNASNet

  • EfficientNet

在接下来的部分,我们将使用一个预训练的 AlexNet 模型作为示例,并展示如何使用 PyTorch 对其进行微调,形式上是一个练习。

使用 PyTorch 对 AlexNet 进行微调

在接下来的练习中,我们将加载一个预训练的 AlexNet 模型,并在一个与 ImageNet 不同的图像分类数据集上进行微调。最后,我们将测试微调后模型的性能,看它是否能够从新数据集中进行迁移学习。练习中的部分代码为了可读性而进行了修剪,但你可以在我们的 github 库[3.4]中找到完整的代码。

对于这个练习,我们需要导入几个依赖项。执行以下import语句:

import os
import time
import copy
import numpy as np
import matplotlib.pyplot as plt
import torch
import torchvision
import torch.nn as nn
import torch.optim as optim
from torch.optim import lr_scheduler
from torchvision import datasets, models, transforms
torch.use_deterministic_algorithms(True) 

接下来,我们将下载并转换数据集。对于这次的微调练习,我们将使用一个小型的昆虫图像数据集,包括蜜蜂和蚂蚁。共有 240 张训练图像和 150 张验证图像,均等分为两类(蜜蜂和蚂蚁)。

我们从 kaggel [3.5]下载数据集,并存储在当前工作目录中。有关数据集的更多信息可以在数据集的网站[3.6]找到。

数据集引用

Elsik CG, Tayal A, Diesh CM, Unni DR, Emery ML, Nguyen HN, Hagen DE。Hymenoptera Genome Database:在 HymenopteraMine 中整合基因组注释。Nucleic Acids Research 2016 年 1 月 4 日;44(D1):D793-800。doi: 10.1093/nar/gkv1208。在线发表于 2015 年 11 月 17 日。PubMed PMID: 26578564。

为了下载数据集,您需要登录 Kaggle。如果您还没有 Kaggle 账户,您需要注册:

ddir = 'hymenoptera_data'
# Data normalization and augmentation transformations for train dataset
# Only normalization transformation for validation dataset
# The mean and std for normalization are calculated as the mean of all pixel values for all images in the training set per each image channel - R, G and B
data_transformers = {
    'train': transforms.Compose([transforms.RandomResizedCrop(224), transforms.RandomHorizontalFlip(),
                                    transforms.ToTensor(),
                                    transforms.Normalize([0.490, 0.449, 0.411], [0.231, 0.221, 0.230])]),
    'val': transforms.Compose([transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize([0.490, 0.449, 0.411], [0.231, 0.221, 0.230])])}
img_data = {k: datasets.ImageFolder(os.path.join(ddir, k), data_transformers[k]) for k in ['train', 'val']}
dloaders = {k: torch.utils.data.DataLoader(img_data[k], batch_size=8, shuffle=True)
            for k in ['train', 'val']}
dset_sizes = {x: len(img_data[x]) for x in ['train', 'val']}
classes = img_data['train'].classes
dvc = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")

现在我们已经完成了先决条件,让我们开始吧:

  1. 让我们可视化一些样本训练数据集图像:
def imageshow(img, text=None):
    img = img.numpy().transpose((1, 2, 0))
    avg = np.array([0.490, 0.449, 0.411])
    stddev = np.array([0.231, 0.221, 0.230])
    img = stddev * img + avg
    img = np.clip(img, 0, 1)
    plt.imshow(img)
    if text is not None:
        plt.title(text)
# Generate one train dataset batch
imgs, cls = next(iter(dloaders['train']))
# Generate a grid from batch
grid = torchvision.utils.make_grid(imgs)
imageshow(grid, text=[classes[c] for c in cls])

输出如下所示:

图 3.15 – 蜜蜂与蚂蚁数据集

图 3.15 – 蜜蜂与蚂蚁数据集

  1. 现在我们定义微调例程,这本质上是在预训练模型上执行的训练例程:
def finetune_model(pretrained_model, loss_func, optim, epochs=10):
    ...
    for e in range(epochs):
        for dset in ['train', 'val']:
            if dset == 'train':
                pretrained_model.train()  # set model to train mode (i.e. trainbale weights)
            else:
                pretrained_model.eval()   # set model to validation mode
            # iterate over the (training/validation) data.
            for imgs, tgts in dloaders[dset]:
                ...
                optim.zero_grad()
                with torch.set_grad_enabled(dset == 'train'):
                    ops = pretrained_model(imgs)
                    _, preds = torch.max(ops, 1)
                    loss_curr = loss_func(ops, tgts)
                    # backward pass only if in training mode
                    if dset == 'train':
                        loss_curr.backward()
                        optim.step()
                loss += loss_curr.item() * imgs.size(0)
                successes += torch.sum(preds == tgts.data)
            loss_epoch = loss / dset_sizes[dset]
            accuracy_epoch = successes.double() / dset_sizes[dset]
            if dset == 'val' and accuracy_epoch > accuracy:
                accuracy = accuracy_epoch
                model_weights = copy.deepcopy(pretrained_model.state_dict())
    # load the best model version (weights)
    pretrained_model.load_state_dict(model_weights)
    return pretrained_model

在这个函数中,我们需要预训练模型(即架构和权重)作为输入,还需要损失函数、优化器和 epoch 数。基本上,我们不是从随机初始化权重开始,而是从 AlexNet 的预训练权重开始。这个函数的其他部分与我们之前的练习非常相似。

  1. 在开始微调(训练)模型之前,我们将定义一个函数来可视化模型预测:
def visualize_predictions(pretrained_model, max_num_imgs=4):
    was_model_training = pretrained_model.training
    pretrained_model.eval()
    imgs_counter = 0
    fig = plt.figure()
    with torch.no_grad():
        for i, (imgs, tgts) in enumerate(dloaders['val']):
            imgs = imgs.to(dvc)
            tgts = tgts.to(dvc)
            ops = pretrained_model(imgs)
            _, preds = torch.max(ops, 1)
             for j in range(imgs.size()[0]):
                imgs_counter += 1
                ax = plt.subplot(max_num_imgs//2, 2, imgs_counter)
                ax.axis('off')
                ax.set_title(f'Prediction: {class_names[preds[j]]}, Ground Truth: {class_names[tgts[j]]}')
                imshow(inputs.cpu().data[j])
                if imgs_counter == max_num_imgs:
pretrained_model.train(mode=was_training)
                    return
        model.train(mode=was_training)
  1. 最后,我们来到了有趣的部分。让我们使用 PyTorch 的 torchvision.models 子包加载预训练的 AlexNet 模型:
model_finetune = models.alexnet(pretrained=True)

该模型对象有以下两个主要组件:

i) features: 特征提取组件,包含所有卷积和池化层

ii) classifier: 分类器块,包含所有通向输出层的全连接层

  1. 我们可以像这样可视化这些组件:
print(model_finetune.features)

应该输出如下内容:

图 3.16 – AlexNet 特征提取器

图 3.16 – AlexNet 特征提取器

  1. 接下来,我们检查 classifier 块如下所示:
print(model_finetune.classifier)

应该输出如下内容:

图 3.17 – AlexNet 分类器

图 3.17 – AlexNet 分类器

  1. 正如您可能注意到的那样,预训练模型的输出层大小为 1000,但我们在微调数据集中只有 2 类。因此,我们将进行修改,如下所示:
# change the last layer from 1000 classes to 2 classes
model_finetune.classifier[6] = nn.Linear(4096, len(classes))
  1. 现在,我们准备定义优化器和损失函数,并随后运行训练例程,如下所示:
loss_func = nn.CrossEntropyLoss()
optim_finetune = optim.SGD(model_finetune.parameters(), lr=0.0001)
# train (fine-tune) and validate the model
model_finetune = finetune_model(model_finetune, loss_func, optim_finetune, epochs=10)

输出如下所示:

图 3.18 – AlexNet 微调循环

图 3.18 – AlexNet 微调循环

  1. 让我们可视化一些模型预测结果,看看模型是否确实学习了来自这个小数据集的相关特征:
visualize_predictions(model_finetune)

应该输出如下内容:

图 3.19 – AlexNet 预测

图 3.19 – AlexNet 预测

显然,预训练的 AlexNet 模型已经能够在这个相当小的图像分类数据集上进行迁移学习。这既展示了迁移学习的强大之处,也展示了使用 PyTorch 很快和轻松地微调已知模型的速度。

在下一节中,我们将讨论 AlexNet 的更深入和更复杂的后继者 – VGG 网络。我们已经详细展示了 LeNet 和 AlexNet 的模型定义、数据集加载、模型训练(或微调)和评估步骤。在随后的章节中,我们将主要关注模型架构的定义,因为 PyTorch 代码在其他方面(如数据加载和评估)将是类似的。

运行预训练的 VGG 模型

我们已经讨论了 LeNet 和 AlexNet,这两个基础的 CNN 架构。随着章节的进展,我们将探索越来越复杂的 CNN 模型。虽然在构建这些模型架构时的关键原则是相同的。我们将看到在组合卷积层、池化层和全连接层到块/模块中以及顺序或分支堆叠这些块/模块时的模块化模型构建方法。在本节中,我们将看到 AlexNet 的继任者 – VGGNet。

名称 VGG 源自于牛津大学视觉几何组,这个模型在那里被发明。相比于 AlexNet 的 8 层和 6000 万参数,VGG 由 13 层(10 个卷积层和 3 个全连接层)和 1.38 亿参数组成。VGG 基本上在 AlexNet 架构上堆叠更多层,使用较小尺寸的卷积核(2x2 或 3x3)。因此,VGG 的新颖之处在于它带来的前所未有的深度。图 3.20 展示了 VGG 的架构:

图 3.20 – VGG16 架构

图 3.20 – VGG16 架构

前述的 VGG 架构被称为VGG13,因为它有 13 层。其他变体包括 VGG16 和 VGG19,分别包含 16 层和 19 层。还有另一组变体 – VGG13_bnVGG16_bnVGG19_bn,其中 bn 表示这些模型也包含批处理归一化层

PyTorch 的torchvision.model子包提供了在 ImageNet 数据集上训练的预训练VGG模型(包括前述的六个变体)。在下面的练习中,我们将使用预训练的VGG13模型对一小组蚂蚁和蜜蜂(用于前面的练习)进行预测。我们将专注于这里的关键代码部分,因为我们的大部分代码将与之前的练习重叠。我们可以随时查阅我们的笔记本来探索完整的代码 [3.7]:

  1. 首先,我们需要导入依赖项,包括torchvision.models

  2. 下载数据并设置蚂蚁和蜜蜂数据集以及数据加载器,同时进行转换。

  3. 为了对这些图像进行预测,我们需要下载 ImageNet 数据集的 1,000 个标签 [3.8] 。

  4. 下载后,我们需要创建类索引 0 到 999 和相应类标签之间的映射,如下所示:

import ast
with open('./imagenet1000_clsidx_to_labels.txt') as f:
    classes_data = f.read()
classes_dict = ast.literal_eval(classes_data)
print({k: classes_dict[k] for k in list(classes_dict)[:5]})

这应该输出前五个类映射,如下截图所示:

图 3.21 – ImageNet 类映射

图 3.21 – ImageNet 类映射

  1. 定义模型预测可视化函数,该函数接受预训练模型对象和要运行预测的图像数量。该函数应输出带有预测的图像。

  2. 加载预训练的VGG13模型:

model_finetune = models.vgg13(pretrained=True)

这应该输出以下内容:

图 3.22 – 加载 VGG13 模型

图 3.22 – 加载 VGG13 模型

VGG13模型在这一步下载完成。

常见问题 - VGG13 模型的磁盘大小是多少?

VGG13 模型在您的硬盘上大约占用 508 MB。

  1. 最后,我们使用这个预训练模型对我们的蚂蚁和蜜蜂数据集进行预测:
visualize_predictions(model_finetune)

这应该输出以下内容:

图 3.23 – VGG13 预测

图 3.23 – VGG13 预测

在完全不同的数据集上训练的VGG13模型似乎能够在蚂蚁和蜜蜂数据集上正确预测所有测试样本。基本上,该模型从数据集中提取了两个最相似的动物,并在图像中找到它们。通过这个练习,我们看到模型仍能从图像中提取相关的视觉特征,并且这个练习展示了 PyTorch 的开箱即用推断功能的实用性。

在下一节中,我们将研究一种不同类型的 CNN 架构 - 这种架构涉及具有多个并行卷积层的模块。这些模块被称为Inception 模块,生成的网络被称为Inception 网络。我们将探索该网络的各个部分以及其成功背后的原因。我们还将使用 PyTorch 构建 Inception 模块和 Inception 网络架构。

探索 GoogLeNet 和 Inception v3

我们迄今为止已经发现了从 LeNet 到 VGG 的 CNN 模型的发展过程,观察到了更多卷积层和全连接层的顺序堆叠。这导致了参数众多的深度网络需要训练。GoogLeNet以一种完全不同的 CNN 架构出现,由称为 inception 模块的并行卷积层模块组成。正因为如此,GoogLeNet 也被称为Inception v1(v1 标志着后续出现了更多版本)。GoogLeNet 引入的一些显著新元素包括以下内容:

  • inception 模块 – 由几个并行卷积层组成的模块

  • 使用1x1 卷积来减少模型参数数量

  • 全局平均池化而不是完全连接层 – 减少过拟合

  • 使用辅助分类器进行训练 - 用于正则化和梯度稳定性

GoogLeNet 有 22 层,比任何 VGG 模型变体的层数都多。然而,由于使用了一些优化技巧,GoogLeNet 中的参数数量为 500 万,远少于 VGG 的 1.38 亿参数。让我们更详细地介绍一些这个模型的关键特性。

Inception 模块

或许这个模型最重要的贡献之一是开发了一个卷积模块,其中包含多个并行运行的卷积层,最终将它们串联以产生单个输出向量。这些并行卷积层使用不同的核大小,从 1x1 到 3x3 到 5x5。其想法是从图像中提取所有级别的视觉信息。除了这些卷积层外,一个 3x3 的最大池化层还增加了另一级特征提取。Figure 3.24展示了 Inception 模块的块图以及整体的 GoogLeNet 架构:

Figure 3.24 – GoogLeNet 架构

Figure 3.24 – GoogLeNet 架构

利用这个架构图,我们可以在 PyTorch 中构建 Inception 模块,如下所示:

class InceptionModule(nn.Module):
    def __init__(self, input_planes, n_channels1x1, n_channels3x3red, n_channels3x3, n_channels5x5red, n_channels5x5, pooling_planes):
        super(InceptionModule, self).__init__()
        # 1x1 convolution branch
        self.block1 = nn.Sequential(
            nn.Conv2d(input_planes, n_channels1x1, kernel_size=1),nn.BatchNorm2d(n_channels1x1),nn.ReLU(True),)
        # 1x1 convolution -> 3x3 convolution branch
        self.block2 = nn.Sequential(
            nn.Conv2d(input_planes, n_channels3x3red, kernel_size=1),nn.BatchNorm2d(n_channels3x3red),
            nn.ReLU(True),nn.Conv2d(n_channels3x3red, n_channels3x3, kernel_size=3, padding=1),nn.BatchNorm2d(n_channels3x3),nn.ReLU(True),)
        # 1x1 conv -> 5x5 conv branch
        self.block3 = nn.Sequential(
            nn.Conv2d(input_planes, n_channels5x5red, kernel_size=1),nn.BatchNorm2d(n_channels5x5red),nn.ReLU(True),
            nn.Conv2d(n_channels5x5red, n_channels5x5, kernel_size=3, padding=1),nn.BatchNorm2d(n_channels5x5),nn.ReLU(True),
            nn.Conv2d(n_channels5x5, n_channels5x5, kernel_size=3, padding=1),nn.BatchNorm2d(n_channels5x5),
            nn.ReLU(True),)
        # 3x3 pool -> 1x1 conv branch
        self.block4 = nn.Sequential(
            nn.MaxPool2d(3, stride=1, padding=1),
            nn.Conv2d(input_planes, pooling_planes, kernel_size=1),
            nn.BatchNorm2d(pooling_planes),
            nn.ReLU(True),)
    def forward(self, ip):
        op1 = self.block1(ip)
        op2 = self.block2(ip)
        op3 = self.block3(ip)
        op4 = self.block4(ip)
        return torch.cat([op1,op2,op3,op4], 1)

接下来,我们将看另一个 GoogLeNet 的重要特性 – 1x1 卷积。

1x1 卷积

除了 Inception 模块中的并行卷积层外,每个并行层还有一个前置的1x1 卷积层。使用这些 1x1 卷积层的原因是降维。1x1 卷积不改变图像表示的宽度和高度,但可以改变图像表示的深度。这个技巧用于在并行进行 1x1、3x3 和 5x5 卷积之前减少输入视觉特征的深度。减少参数数量不仅有助于构建更轻量的模型,还有助于对抗过拟合。

全局平均池化

如果我们看一下Figure 3.24中的整体 GoogLeNet 架构,模型的倒数第二输出层之前是一个 7x7 平均池化层。这一层再次帮助减少模型的参数数量,从而减少过拟合。如果没有这一层,由于完全连接层的密集连接,模型将具有数百万额外的参数。

辅助分类器

Figure 3.24 还展示了模型中的两个额外或辅助输出分支。这些辅助分类器旨在通过在反向传播过程中增加梯度的幅度来解决梯度消失问题,尤其是对于靠近输入端的层次。由于这些模型具有大量层次,梯度消失可能成为一个瓶颈。因此,使用辅助分类器已被证明对这个 22 层深的模型非常有用。此外,辅助分支还有助于正则化。请注意,在进行预测时这些辅助分支是关闭/丢弃的。

一旦我们用 PyTorch 定义了 Inception 模块,我们可以如下轻松地实例化整个 Inception v1 模型:

class GoogLeNet(nn.Module):
    def __init__(self):
        super(GoogLeNet, self).__init__()
        self.stem = nn.Sequential(
            nn.Conv2d(3, 192, kernel_size=3, padding=1),
            nn.BatchNorm2d(192),
            nn.ReLU(True),)
        self.im1 = InceptionModule(192,  64,  96, 128, 16, 32, 32)
        self.im2 = InceptionModule(256, 128, 128, 192, 32, 96, 64)
        self.max_pool = nn.MaxPool2d(3, stride=2, padding=1)
        self.im3 = InceptionModule(480, 192,  96, 208, 16,  48,  64)
        self.im4 = InceptionModule(512, 160, 112, 224, 24,  64,  64)
        self.im5 = InceptionModule(512, 128, 128, 256, 24,  64,  64)
        self.im6 = InceptionModule(512, 112, 144, 288, 32,  64,  64)
        self.im7 = InceptionModule(528, 256, 160, 320, 32, 128, 128)
        self.im8 = InceptionModule(832, 256, 160, 320, 32, 128, 128)
        self.im9 = InceptionModule(832, 384, 192, 384, 48, 128, 128)
        self.average_pool = nn.AvgPool2d(7, stride=1)
        self.fc = nn.Linear(4096, 1000)
    def forward(self, ip):
        op = self.stem(ip)
        out = self.im1(op)
        out = self.im2(op)
        op = self.maxpool(op)
        op = self.a4(op)
        op = self.b4(op)
        op = self.c4(op)
        op = self.d4(op)
        op = self.e4(op)
        op = self.max_pool(op)
        op = self.a5(op)
        op = self.b5(op)
        op = self.avgerage_pool(op)
        op = op.view(op.size(0), -1)
        op = self.fc(op)
        return op

除了实例化我们自己的模型外,我们还可以只用两行代码加载预训练的 GoogLeNet:

import torchvision.models as models
model = models.googlenet(pretrained=True)

最后,如前所述,后来开发了多个版本的 Inception 模型。其中一个显赫的是 Inception v3,我们接下来将简要讨论它。

Inception v3

这是 Inception v1 的后继者,总共有 2400 万个参数,而 v1 中仅有 500 万个参数。除了增加了几个更多的层外,该模型引入了不同种类的 Inception 模块,这些模块按顺序堆叠。图 3.25 展示了不同的 Inception 模块和完整的模型架构:

图 3.25 – Inception v3 架构

图 3.25 – Inception v3 架构

从架构中可以看出,该模型是 Inception v1 模型的架构扩展。除了手动构建模型外,我们还可以按如下方式使用 PyTorch 的预训练模型:

import torchvision.models as models
model = models.inception_v3(pretrained=True)

在下一节中,我们将详细讨论在非常深的 CNNs 中有效对抗消失梯度问题的 CNN 模型的类别 – ResNetDenseNet。我们将学习跳跃连接和密集连接的新技术,并使用 PyTorch 编写这些先进架构背后的基础模块。

讨论 ResNet 和 DenseNet 架构

在前一节中,我们探讨了 Inception 模型,随着层数的增加,由于 1x1 卷积和全局平均池化的使用,模型参数数量减少。此外,还使用了辅助分类器来对抗消失梯度问题。

ResNet 引入了 跳跃连接 的概念。这个简单而有效的技巧克服了参数溢出和消失梯度问题。如下图所示,其思想非常简单。首先,输入经过非线性变换(卷积后跟非线性激活),然后将这个变换的输出(称为残差)加到原始输入上。每个这样的计算块称为 残差块,因此模型被称为 残差网络ResNet

图 3.26 – 跳跃连接

图 3.26 – 跳跃连接

使用这些跳跃(或快捷)连接,参数数量仅限于 2600 万个参数,共计 50 层(ResNet-50)。由于参数数量有限,即使层数增至 152 层(ResNet-152),ResNet 仍然能够很好地泛化,而不会过拟合。以下图表显示了 ResNet-50 的架构:

图 3.27 – ResNet 架构

图 3.27 – ResNet 架构

有两种残差块 – 卷积恒等,两者均具有跳跃连接。对于卷积块,还添加了一个额外的 1x1 卷积层,这进一步有助于降低维度。在 PyTorch 中,可以如下实现 ResNet 的残差块:

class BasicBlock(nn.Module):
    multiplier=1
    def __init__(self, input_num_planes, num_planes, strd=1):
        super(BasicBlock, self).__init__()
        self.conv_layer1 = nn.Conv2d(in_channels=input_num_planes, out_channels=num_planes, kernel_size=3, stride=stride, padding=1, bias=False)
        self.batch_norm1 = nn.BatchNorm2d(num_planes)
        self.conv_layer2 = nn.Conv2d(in_channels=num_planes, out_channels=num_planes, kernel_size=3, stride=1, padding=1, bias=False)
        self.batch_norm2 = nn.BatchNorm2d(num_planes)
        self.res_connnection = nn.Sequential()
        if strd > 1 or input_num_planes != self.multiplier*num_planes:
            self.res_connnection = nn.Sequential(
                nn.Conv2d(in_channels=input_num_planes, out_channels=self.multiplier*num_planes, kernel_size=1, stride=strd, bias=False),
                nn.BatchNorm2d(self.multiplier*num_planes))
    def forward(self, inp):
        op = F.relu(self.batch_norm1(self.conv_layer1(inp)))
        op = self.batch_norm2(self.conv_layer2(op))
        op += self.res_connnection(inp)
        op = F.relu(op)
        return op

要快速开始使用 ResNet,我们可以随时从 PyTorch 的仓库中使用预训练的 ResNet 模型:

import torchvision.models as models
model = models.resnet50(pretrained=True)

ResNet 使用身份函数(通过直接连接输入到输出)来在反向传播过程中保持梯度(梯度将为 1)。然而,对于极深的网络,这个原则可能不足以保持从输出层到输入层的强梯度。

我们接下来将讨论的 CNN 模型旨在确保强大的梯度流动,以及进一步减少所需参数的数量。

DenseNet

ResNet 的跳跃连接将残差块的输入直接连接到其输出。但是,残差块之间的连接仍然是顺序的,即残差块 3 与块 2 有直接连接,但与块 1 没有直接连接。

DenseNet 或密集网络引入了将每个卷积层与称为密集块的每个其他层连接的想法。并且每个密集块都与整体 DenseNet 中的每个其他密集块连接。密集块只是两个 3x3 密集连接的卷积层的模块。

这些密集连接确保每一层都从网络中所有前面的层接收信息。这确保了从最后一层到第一层有一个强大的梯度流动。出乎意料的是,这种网络设置的参数数量也很低。由于每一层都从前面所有层的特征图中接收到信息,所需的通道数(深度)可以更少。在早期的模型中,增加的深度代表了从早期层积累的信息,但现在我们不再需要这些,多亏了网络中到处存在的密集连接。

ResNet 和 DenseNet 之间的一个关键区别是,在 ResNet 中,输入通过跳跃连接添加到输出中。但在 DenseNet 中,前面层的输出与当前层的输出串联在一起。并且串联是在深度维度上进行的。

随着网络的进一步进行,输出大小的急剧增加可能会引发一个问题。为了抵消这种累积效应,为这个网络设计了一种特殊类型的块,称为过渡块。由 1x1 卷积层和随后的 2x2 池化层组成,这个块标准化或重置深度维度的大小,以便将该块的输出馈送到后续的稠密块。下图显示了 DenseNet 的架构:

图 3.28 - DenseNet 架构

图 3.28 - DenseNet 架构

正如前面提到的,涉及两种类型的块 - 密集块过渡块。这些块可以写成 PyTorch 中的几行代码,如下所示:

class DenseBlock(nn.Module):
    def __init__(self, input_num_planes, rate_inc):
        super(DenseBlock, self).__init__()
        self.batch_norm1 = nn.BatchNorm2d(input_num_planes)
        self.conv_layer1 = nn.Conv2d(in_channels=input_num_planes, out_channels=4*rate_inc, kernel_size=1, bias=False)
        self.batch_norm2 = nn.BatchNorm2d(4*rate_inc)
        self.conv_layer2 = nn.Conv2d(in_channels=4*rate_inc, out_channels=rate_inc, kernel_size=3, padding=1, bias=False)
    def forward(self, inp):
        op = self.conv_layer1(F.relu(self.batch_norm1(inp)))
        op = self.conv_layer2(F.relu(self.batch_norm2(op)))
        op = torch.cat([op,inp], 1)
        return op
class TransBlock(nn.Module):
    def __init__(self, input_num_planes, output_num_planes):
        super(TransBlock, self).__init__()
        self.batch_norm = nn.BatchNorm2d(input_num_planes)
        self.conv_layer = nn.Conv2d(in_channels=input_num_planes, out_channels=output_num_planes, kernel_size=1, bias=False)
    def forward(self, inp):
        op = self.conv_layer(F.relu(self.batch_norm(inp)))
        op = F.avg_pool2d(op, 2)
        return op

然后,这些块被密集堆叠以形成整体的 DenseNet 架构。像 ResNet 一样,DenseNet 有各种变体,如DenseNet121DenseNet161DenseNet169DenseNet201,其中数字代表总层数。通过在输入端重复堆叠密集块和过渡块以及固定的 7x7 卷积层和输出端的固定全连接层,可以获得这些大量的层。PyTorch 为所有这些变体提供了预训练模型:

import torchvision.models as models
densenet121 = models.densenet121(pretrained=True)
densenet161 = models.densenet161(pretrained=True)
densenet169 = models.densenet169(pretrained=True)
densenet201 = models.densenet201(pretrained=True)

在 ImageNet 数据集上,DenseNet 优于迄今讨论的所有模型。通过混合和匹配前几节中提出的思想,开发了各种混合模型。Inception-ResNet 和 ResNeXt 模型是这种混合网络的示例。下图显示了 ResNeXt 架构:

图 3.29 – ResNeXt 架构

图 3.29 – ResNeXt 架构

正如您所看到的,它看起来像是ResNet + Inception混合的更广泛变体,因为残差块中有大量并行的卷积分支——并行的概念源于 Inception 网络。

在本章的下一部分,我们将探讨当前表现最佳的 CNN 架构——EfficientNets。我们还将讨论 CNN 架构发展的未来,同时涉及 CNN 架构在超越图像分类的任务中的应用。

理解 EfficientNets 和 CNN 架构的未来

到目前为止,从 LeNet 到 DenseNet 的探索中,我们已经注意到 CNN 架构进步的一个潜在主题。这一主题是通过以下一种方式扩展或缩放 CNN 模型:

  • 层数增加

  • 在卷积层中特征映射或通道数的增加

  • 从 LeNet 的 32x32 像素图像到 AlexNet 的 224x224 像素图像等空间维度的增加

可进行缩放的三个不同方面分别被确定为深度宽度分辨率。EfficientNets 不再手动调整这些属性,这通常会导致次优结果,而是使用神经架构搜索来计算每个属性的最优缩放因子。

增加深度被认为很重要,因为网络越深,模型越复杂,因此可以学习到更复杂的特征。然而,增加深度也存在一定的权衡,因为随着深度的增加,梯度消失问题以及过拟合问题普遍加剧。

类似地,理论上增加宽度应该有助于性能,因为通道数越多,网络应该能够学习更细粒度的特征。然而,对于极宽的模型,精度往往会迅速饱和。

最后,从理论上讲,更高分辨率的图像应该效果更好,因为它们包含更精细的信息。然而,经验上,分辨率的增加并不会线性等效地提高模型性能。总之,这些都表明在确定缩放因子时需要权衡,因此神经架构搜索有助于找到最优缩放因子。

EfficientNet 提出了找到具有正确深度、宽度和分辨率平衡的架构,这三个方面通过全局缩放因子一起进行缩放。EfficientNet 架构分为两步。首先,通过将缩放因子固定为1来设计一个基本架构(称为基础网络)。在这个阶段,决定了给定任务和数据集的深度、宽度和分辨率的相对重要性。所得到的基础网络与一个著名的 CNN 架构非常相似,即MnasNet,全称Mobile Neural Architecture Search Network。PyTorch 提供了预训练的MnasNet模型,可以像这样加载:

import torchvision.models as models
model = models.mnasnet1_0()

一旦在第一步得到了基础网络,就会计算出最优的全局缩放因子,目标是最大化模型的准确性并尽量减少计算量(或浮点运算)。基础网络称为EfficientNet B0,而为不同最优缩放因子衍生的后续网络称为EfficientNet B1-B7。PyTorch 为所有这些变体提供了预训练模型:

import torchvision.models as models
efficientnet_b0 = models.efficientnet_b0(pretrained=True)
efficientnet_b1 = models.efficientnet_b1(pretrained=True)
...
efficientnet_b7 = models.efficientnet_b7(pretrained=True) 

随着我们的进展,CNN 架构的高效扩展将成为研究的一个突出方向,同时还将开发受到启发的更复杂的模块,例如 inception、残差和密集模块。CNN 架构发展的另一个方面是在保持性能的同时最小化模型大小。MobileNets [3.9] 就是一个主要的例子,目前在这个领域有大量的研究。

除了上述从架构上修改预先存在的模型的自上而下方法之外,还将继续采用从根本上重新思考 CNN 单元的自下而上方法,例如卷积核、池化机制、更有效的展平方式等。一个具体的例子是胶囊网络 [3.10] ,它重新设计了卷积单元以适应图像中的第三维度(深度)。

CNN 本身就是一个广泛研究的话题。在本章中,我们主要讨论了 CNN 在图像分类背景下的架构发展。然而,这些相同的架构被广泛应用于各种应用中。一个著名的例子是在对象检测和分割中使用 ResNets 的形式,如RCNNs [3.11] 。RCNNs 的改进变体包括Faster R-CNNMask-RCNNKeypoint-RCNN。PyTorch 为这三个变体提供了预训练模型:

faster_rcnn = models.detection.fasterrcnn_resnet50_fpn()
mask_rcnn = models.detection.maskrcnn_resnet50_fpn()
keypoint_rcnn = models.detection.keypointrcnn_resnet50_fpn()

PyTorch 还提供了预训练模型用于 ResNets,这些模型应用于视频相关任务,比如视频分类。用于视频分类的两个基于 ResNet 的模型分别是ResNet3D混合卷积 ResNet

resnet_3d = models.video.r3d_18()
resnet_mixed_conv = models.video.mc3_18()

虽然我们在本章没有详细涵盖这些不同的应用及相应的 CNN 模型,但我们鼓励您深入了解它们。PyTorch 的网站可以作为一个很好的起点 [3.12]。

总结

本章主要讲述了 CNN 的架构。在下一章中,我们将探索另一类重要的神经网络——递归神经网络。我们将讨论各种递归网络的架构,并使用 PyTorch 来有效地实现、训练和测试它们。

第五章:混合高级模型

加入我们的书籍 Discord 社区

packt.link/EarlyAccessCommunity

img

在前两章中,我们广泛学习了各种卷积和递归网络架构,以及它们在 PyTorch 中的实现。在本章中,我们将深入研究一些在各种机器学习任务中证明成功的其他深度学习模型架构,它们既不是纯卷积的,也不是循环的。我们将继续探讨第三章 深度 CNN 架构第四章 深度递归模型结构中的内容。

首先,我们将探讨 transformers,正如我们在第四章 深度递归模型结构末尾学到的,它们在各种顺序任务上表现优于循环架构。接着,我们将从第三章 深度 CNN 架构末尾的EfficientNets讨论中继续,并探索生成随机连线神经网络的概念,也称为RandWireNNs

通过本章,我们的目标是结束本书中对不同神经网络架构的讨论。完成本章后,您将深入了解 transformers 及如何使用 PyTorch 将这些强大的模型应用于顺序任务。此外,通过构建自己的 RandWireNN 模型,您将有机会在 PyTorch 中执行神经架构搜索。本章分为以下主题:

  • 建立一个用于语言建模的 transformer 模型

  • 从头开始开发 RandWireNN 模型

建立一个用于语言建模的 transformer 模型

在本节中,我们将探索 transformers 是什么,并使用 PyTorch 为语言建模任务构建一个。我们还将学习如何通过 PyTorch 的预训练模型库使用其后继模型,如BERTGPT。在我们开始构建 transformer 模型之前,让我们快速回顾一下语言建模是什么。

回顾语言建模

语言建模是一项确定一个词或一系列词出现概率的任务,它们应该跟在给定的一系列词后面。例如,如果我们给定French is a beautiful _____作为我们的词序列,下一个词将是languageword的概率是多少?这些概率是通过使用各种概率和统计技术对语言建模来计算的。其思想是通过观察文本语料库并学习语法,学习哪些词汇经常一起出现,哪些词汇从不会一起出现。这样,语言模型建立了关于不同词或序列出现概率的概率规则。

递归模型一直是学习语言模型的一种流行方法。然而,与许多序列相关任务一样,变压器在这个任务上也超过了递归网络。我们将通过在维基百科文本语料库上训练一个基于变压器的英语语言模型来实现。

现在,让我们开始训练一个变压器进行语言建模。在这个练习过程中,我们将仅演示代码的最重要部分。完整的代码可以在我们的 github 仓库[5.1]中找到。

在练习之间,我们将深入探讨变压器架构的各个组成部分。

在这个练习中,我们需要导入一些依赖项。其中一个重要的import语句如下所示:

from torch.nn import TransformerEncoder, TransformerEncoderLayer

除了导入常规的torch依赖项外,我们还必须导入一些特定于变压器模型的模块;这些模块直接在 torch 库下提供。我们还将导入torchtext,以便直接从torchtext.datasets下的可用数据集中下载文本数据集。

在接下来的部分中,我们将定义变压器模型的架构并查看模型组件的详细信息。

理解变压器模型架构

这可能是这个练习中最重要的步骤。在这里,我们定义变压器模型的架构。

首先,让我们简要讨论模型架构,然后看一下定义模型的 PyTorch 代码。以下图表显示了模型架构:

图 5.1 – 变压器模型架构

图 5.1 – 变压器模型架构

首先要注意的是,这基本上是一个基于编码器-解码器的架构,左侧是编码器单元(紫色),右侧是解码器单元(橙色)。编码器和解码器单元可以多次平铺以实现更深层的架构。在我们的示例中,我们有两个级联的编码器单元和一个单独的解码器单元。这种编码器-解码器设置的本质是,编码器接受一个序列作为输入,并生成与输入序列中的单词数量相同的嵌入(即每个单词一个嵌入)。然后,这些嵌入与模型迄今为止的预测一起馈送到解码器中。

让我们来看看这个模型中的各个层:

嵌入层:这一层的作用很简单,即将序列的每个输入单词转换为数字向量,即嵌入。在这里,我们使用torch.nn.Embedding模块来实现这一层。

位置编码器:请注意,变压器在其架构中没有任何递归层,然而它们在顺序任务上表现出色。怎么做到的?通过一个称为位置编码的巧妙技巧,模型能够感知数据中的顺序性或顺序。基本上,按照特定的顺序模式添加到输入单词嵌入中的向量。

这些向量是通过一种方法生成的,使模型能够理解第二个词跟在第一个词后面,依此类推。这些向量是使用正弦余弦函数生成的,分别用来表示连续单词之间的系统周期性和距离。我们在这个练习中的这一层的实现如下:

class PosEnc(nn.Module):
    def __init__(self, d_m, dropout=0.2, size_limit=5000):
        # d_m is same as the dimension of the embeddings
        pos = torch.arange(     size_limit, dtype=torch.float).unsqueeze(1)
        divider = torch.exp(torch.arange(0, d_m, 2).float() * (-math.log(10000.0) / d_m))
        # divider is the list of radians, multiplied by position indices of words, and fed to the sinusoidal and cosinusoidal function.  
        p_enc[:, 0, 0::2] = torch.sin(pos * divider)
        p_enc[:, 0, 1::2] = torch.cos(pos * divider)
    def forward(self, x):
        return self.dropout(x + self.p_enc[:x.size(0)     ])

如您所见,交替使用正弦余弦函数来给出顺序模式。虽然有多种实现位置编码的方式。如果没有位置编码层,模型将对单词的顺序一无所知。

多头注意力:在我们看多头注意力层之前,让我们先了解什么是自注意力层。在第四章深度递归模型架构中,我们已经涵盖了关于递归网络中注意力的概念。这里,正如其名称所示,注意机制应用于自身;即序列中的每个单词。序列的每个单词嵌入通过自注意力层,并生成与单词嵌入长度完全相同的单独输出。下面的图解详细描述了这一过程:

图 5.2 – 自注意力层

图 5.2 – 自注意力层

正如我们所见,对于每个单词,通过三个可学习的参数矩阵(PqPkPv)生成三个向量。这三个向量是查询、键和值向量。查询和键向量进行点乘,为每个单词产生一个数字。这些数字通过将每个单词的键向量长度的平方根进行归一化来规范化。然后同时对所有单词的结果进行 Softmax 操作,以产生最终乘以相应值向量的概率。这导致序列的每个单词都有一个输出向量,其长度与输入单词嵌入的长度相同。

多头注意力层是自注意力层的扩展,其中多个自注意力模块为每个单词计算输出。这些个别输出被串联并与另一个参数矩阵(Pm)进行矩阵乘法,以生成最终的输出向量,其长度与输入嵌入向量的长度相等。下图展示了多头注意力层,以及我们在本练习中将使用的两个自注意力单元:

图 5.3 – 具有两个自注意单元的多头注意力层

图 5.3 – 具有两个自注意力单元的多头注意力层

多个自注意力头有助于不同的头集中于序列单词的不同方面,类似于不同特征映射学习卷积神经网络中的不同模式。因此,多头注意力层的性能优于单个自注意力层,并将在我们的练习中使用。

此外,请注意,在解码器单元中,掩码多头注意力层的工作方式与多头注意力层完全相同,除了增加的掩码;也就是说,在处理序列的时间步 t 时,从 t+1n(序列长度)的所有单词都被掩盖/隐藏。

在训练期间,解码器接收两种类型的输入。一方面,它从最终编码器接收查询和键向量作为其(未掩码的)多头注意力层的输入,其中这些查询和键向量是最终编码器输出的矩阵变换。另一方面,解码器接收其自己在前一个时间步的预测作为其掩码多头注意力层的顺序输入。

加法和层归一化:我们在 第三章 深度 CNN 架构 中讨论了残差连接的概念,当讨论 ResNets 时。在 图 5.1 中,我们可以看到在加法和层归一化层之间存在残差连接。在每个实例中,通过直接将输入单词嵌入向量加到多头注意力层的输出向量上,建立了一个残差连接。这有助于网络中更容易的梯度流动,避免梯度爆炸和梯度消失问题。此外,它有助于在各层之间有效地学习身份函数。

此外,层归一化被用作一种规范化技巧。在这里,我们独立地对每个特征进行归一化,以使所有特征具有统一的均值和标准差。请注意,这些加法和归一化逐个应用于网络每个阶段的每个单词向量。

前馈层:在编码器和解码器单元内部,所有序列中单词的归一化残差输出向量通过一个共同的前馈层传递。由于单词间存在一组共同的参数,这一层有助于学习序列中更广泛的模式。

线性和 Softmax 层:到目前为止,每个层都输出一个单词序列的向量。对于我们的语言建模任务,我们需要一个单一的最终输出。线性层将向量序列转换为一个与我们的单词词汇长度相等的单一向量。Softmax层将这个输出转换为一个概率向量,其总和为1。这些概率是词汇表中各个单词(在序列中)作为下一个单词出现的概率。

现在我们已经详细阐述了变压器模型的各种要素,让我们看一下用于实例化模型的 PyTorch 代码。

在 PyTorch 中定义一个变压器模型

使用前面部分描述的架构细节,我们现在将编写必要的 PyTorch 代码来定义一个变压器模型,如下所示:

class Transformer(nn.Module):
    def __init__(self, num_token, num_inputs, num_heads, num_hidden, num_layers, dropout=0.3):
        self.position_enc = PosEnc(num_inputs, dropout)
        layers_enc = TransformerEncoderLayer(num_inputs, num_heads, num_hidden, dropout)
        self.enc_transformer = TransformerEncoder(layers_enc, num_layers)
        self.enc = nn.Embedding(num_token, num_inputs)
        self.num_inputs = num_inputs
        self.dec = nn.Linear(num_inputs, num_token)

正如我们所看到的,在类的__init__方法中,由于 PyTorch 的TransformerEncoderTransformerEncoderLayer函数,我们无需自己实现这些。对于我们的语言建模任务,我们只需要输入单词序列的单个输出。因此,解码器只是一个线性层,它将来自编码器的向量序列转换为单个输出向量。位置编码器也是使用我们之前讨论的定义初始化的。

forward方法中,输入首先进行位置编码,然后通过编码器,接着是解码器:

 def forward(self, source):
        source = self.enc(source) * math.sqrt(self.num_inputs)
        source = self.position_enc(source)
        op = self.enc_transformer(source, self.mask_source)
        op = self.dec(op)
        return op

现在我们已经定义了变压器模型架构,我们将载入文本语料库进行训练。

加载和处理数据集

在本节中,我们将讨论与加载文本数据集和使其适用于模型训练例程有关的步骤。让我们开始吧:

本练习中,我们将使用维基百科的文本,这些文本都可作为WikiText-2数据集使用。

数据集引用

blog.einstein.ai/the-wikitext-long-term-dependency-language-modeling-dataset/.

我们将使用torchtext的功能来下载训练数据集(在torchtext数据集中可用)并对其词汇进行标记化:

tr_iter = WikiText2(split='train')
tkzer = get_tokenizer('basic_english')
vocabulary = build_vocab_from_iterator(map(tkzer, tr_iter), specials=['<unk>'])
vocabulary.set_default_index(vocabulary['<unk>'])
  1. 然后我们将使用词汇表将原始文本转换为张量,用于训练、验证和测试数据集:
def process_data(raw_text):
    numericalised_text = [torch.tensor(vocabulary(tkzer(text)), dtype=torch.long) for text in raw_text]
    return torch.cat(tuple(filter(lambda t: t.numel() > 0, numericalised_text)))
tr_iter, val_iter, te_iter = WikiText2()
training_text = process_data(tr_iter)
validation_text = process_data(val_iter)
testing_text = process_data(te_iter) 
  1. 我们还将为训练和评估定义批量大小,并声明批处理生成函数,如下所示:
def gen_batches(text_dataset, batch_size):
    num_batches = text_dataset.size(0) // batch_size
    text_dataset = text_dataset[:num_batches * batch_size]
    text_dataset = text_dataset.view(batch_size, num_batches).t().contiguous()
    return text_dataset.to(device)
training_batch_size = 32
evaluation_batch_size = 16
training_data = gen_batches(training_text, training_batch_size) 
  1. 接下来,我们必须定义最大序列长度,并编写一个函数,根据每个批次生成输入序列和输出目标:
max_seq_len = 64
def return_batch(src, k):
    sequence_length = min(max_seq_len, len(src) - 1 - k)
    sequence_data = src[k:k+sequence_length]
    sequence_label = src[k+1:k+1+sequence_length].reshape(-1)
    return sequence_data, sequence_label 

定义了模型并准备好训练数据后,我们将开始训练变压器模型。

训练变压器模型

在本节中,我们将为模型训练定义必要的超参数,定义模型训练和评估例程,最后执行训练循环。让我们开始吧:

在这一步中,我们定义所有模型超参数并实例化我们的变压器模型。以下代码是不言而喻的:

num_tokens = len(vocabulary) # vocabulary size
embedding_size = 256 # dimension of embedding layer
num_hidden_params = 256 # transformer encoder's hidden (feed forward) layer dimension
num_layers = 2 # num of transformer encoder layers within transformer encoder
num_heads = 2 # num of heads in (multi head) attention models
dropout = 0.25 # value (fraction) of dropout
loss_func = nn.CrossEntropyLoss()
lrate = 4.0 # learning rate
optim_module = torch.optim.SGD(transformer_model.parameters(), lr=lrate)
sched_module = torch.optim.lr_scheduler.StepLR(optim_module, 1.0, gamma=0.88)
transformer_model = Transformer(num_tokens, embedding_size, num_heads, num_hidden_params, num_layers, dropout).to(device) 

在开始模型训练和评估循环之前,我们需要定义训练和评估例程:

def train_model():     )
    for b, i in enumerate(range(0, training_data.size(0) - 1, max_seq_len)):
        train_data_batch, train_label_batch = return_batch(training_data, i)
        sequence_length = train_data_batch.size(0)
        if sequence_length != max_seq_len:  # only on last batch
            mask_source = mask_source[:sequence_length, :sequence_length]

        op = transformer_model(train_data_batch, mask_source)
        loss_curr = loss_func(op.view(-1, num_tokens), train_label_batch)
       optim_module.zero_grad()
        loss_curr.backward()
torch.nn.utils.clip_grad_norm_(transformer_model.parameters(), 0.6)
        optim_module.step()
        loss_total += loss_curr.item()
def eval_model(eval_model_obj, eval_data_source):
...

最后,我们必须运行模型训练循环。为了演示目的,我们将为模型训练5个时代,但建议您训练更长时间以获得更好的性能:

min_validation_loss = float("inf")
eps = 5
best_model_so_far = None
for ep in range(1, eps + 1):
    ep_time_start = time.time()
    train_model()
    validation_loss = eval_model(transformer_model, validation_data)
    if validation_loss < min_validation_loss:
        min_validation_loss = validation_loss
        best_model_so_far = transformer_model

这应产生以下输出:

图 5.4 - 变压器训练日志

图 5.4 - 变压器训练日志

除了交叉熵损失,还报告了困惑度。困惑度是自然语言处理中常用的指标,用于表示一个概率分布(在我们的情况下是语言模型)对样本的拟合或预测能力。困惑度越低,模型在预测样本时表现越好。从数学上讲,困惑度只是交叉熵损失的指数。直观地说,这个度量用来指示模型在进行预测时的困惑或混乱程度。

一旦模型训练完毕,我们可以通过在测试集上评估模型的性能来完成这个练习:

testing_loss = eval_model(best_model_so_far, testing_data)
print(f"testing loss {testing_loss:.2f}, testing perplexity {math.exp(testing_loss):.2f}")

这应该导致以下输出:

Figure 5.5 – Transformer evaluation results

Figure 5.5 – Transformer evaluation results

在这个练习中,我们使用 PyTorch 构建了一个用于语言建模任务的变压器模型。我们详细探讨了变压器的架构以及如何在 PyTorch 中实现它。我们使用了WikiText-2数据集和torchtext功能来加载和处理数据集。然后我们训练了变压器模型,进行了5个 epochs 的评估,并在一个独立的测试集上进行了评估。这将为我们提供开始使用变压器所需的所有信息。

除了原始的变压器模型,该模型是在 2017 年设计的,多年来已经开发了许多后续版本,特别是在语言建模领域,例如以下几种:

BERTBidirectional Encoder Representations from Transformers),2018

GPTGenerative Pretrained Transformer),2018

GPT-2, 2019

CTRLConditional Transformer Language Model),2019

Transformer-XL, 2019

DistilBERTDistilled BERT),2019

RoBERTaRoBustly optimized BERT pretraining Approach),2019

GPT-3, 2020

虽然我们在本章不会详细介绍这些模型,但是你仍然可以通过 PyTorch 和transformers库开始使用这些模型。我们将在第十九章详细探讨 HuggingFace。transformers 库为各种任务提供了预训练的变压器系列模型,例如语言建模、文本分类、翻译、问答等。

除了模型本身,它还提供了各自模型的分词器。例如,如果我们想要使用预训练的 BERT 模型进行语言建模,我们需要在安装了transformers库后写入以下代码:

import torch
from transformers import BertForMaskedLM, BertTokenizer
bert_model = BertForMaskedLM.from_pretrained('bert-base-uncased')
token_gen = BertTokenizer.from_pretrained('bert-base-uncased')
ip_sequence = token_gen("I love PyTorch !", return_tensors="pt")["input_ids"]
op = bert_model(ip_sequence, labels=ip_sequence)
total_loss, raw_preds = op[:2]

正如我们所看到的,仅需几行代码就可以开始使用基于 BERT 的语言模型。这展示了 PyTorch 生态系统的强大性。你被鼓励使用transformers库探索更复杂的变种,如Distilled BERTRoBERTa。有关更多详细信息,请参阅它们的 GitHub 页面,之前已经提到过。

这结束了我们对 transformer 的探索。我们既通过从头构建一个 transformer,也通过重用预训练模型来做到这一点。在自然语言处理领域,transformer 的发明与计算机视觉领域的 ImageNet 时刻齐头并进,因此这将是一个活跃的研究领域。PyTorch 将在这些模型的研究和部署中发挥关键作用。

在本章的下一个也是最后一个部分中,我们将恢复我们在第三章,深度 CNN 架构末尾提供的神经架构搜索讨论,那里我们简要讨论了生成最优网络架构的想法。我们将探索一种模型类型,我们不决定模型架构的具体形式,而是运行一个网络生成器,为给定任务找到最优架构。由此产生的网络称为随机连线神经网络RandWireNN),我们将使用 PyTorch 从头开始开发一个。

从零开始开发 RandWireNN 模型

我们在第三章,深度 CNN 架构中讨论了 EfficientNets,在那里我们探讨了找到最佳模型架构而不是手动指定的想法。RandWireNNs,或随机连线神经网络,顾名思义,是建立在类似概念上的。在本节中,我们将研究并使用 PyTorch 构建我们自己的 RandWireNN 模型。

理解 RandWireNNs

首先,使用随机图生成算法生成一个预定义节点数的随机图。这个图被转换成一个神经网络,通过对其施加一些定义,比如以下定义:

  • 有向性:图被限制为有向图,并且边的方向被认为是等效神经网络中数据流的方向。

  • 聚合:多个入边到一个节点(或神经元)通过加权和进行聚合,其中权重是可学习的。

  • 转换:在该图的每个节点内部,应用了一个标准操作:ReLU 接着 3x3 可分离卷积(即常规的 3x3 卷积接着 1x1 点卷积),然后是批量归一化。这个操作也被称为ReLU-Conv-BN 三重组合

  • 分布:最后,每个神经元有多个出边,每个出边携带前述三重组合的副本。

拼图中的最后一块是向该图添加一个单一的输入节点(源)和一个单一的输出节点(汇),以完全将随机图转化为神经网络。一旦图被实现为神经网络,它可以被训练用于各种机器学习任务。

ReLU-Conv-BN 三元组单元中,出口通道数/特征与输入通道数/特征相同,出于可重复性考虑。然而,根据手头任务的类型,您可以将几个这样的图阶段性地配置为向下游增加通道数(和减少数据/图像的空间尺寸)。最后,这些分段图可以按顺序连接,将一个的末端连接到另一个的起始端。

接下来,作为一项练习,我们将使用 PyTorch 从头开始构建一个 RandWireNN 模型。

使用 PyTorch 开发 RandWireNN

我们现在将为图像分类任务开发一个 RandWireNN 模型。这将在 CIFAR-10 数据集上执行。我们将从一个空模型开始,生成一个随机图,将其转换为神经网络,为给定的任务在给定的数据集上进行训练,评估训练后的模型,最后探索生成的模型。在这个练习中,我们仅展示代码的重要部分以示范目的。要访问完整的代码,请访问书籍的 GitHub 仓库[5.3]。

定义训练例程和加载数据

在这个练习的第一个子部分中,我们将定义训练函数,该函数将由我们的模型训练循环调用,并定义我们的数据集加载器,该加载器将为我们提供用于训练的数据批次。让我们开始吧:

首先,我们需要导入一些库。本练习中将使用的一些新库如下:

from torchviz import make_dot
import networkx as nx

接下来,我们必须定义训练例程,该例程接受一个经过训练的模型,该模型能够根据 RGB 输入图像产生预测概率:

def train(model, train_dataloader, optim, loss_func, epoch_num, lrate):
    for training_data, training_label in train_dataloader:
        pred_raw = model(training_data)
        curr_loss = loss_func(pred_raw, training_label)
        training_loss += curr_loss.data
    return training_loss / data_size, training_accuracy / data_size

接下来,我们定义数据集加载器。对于这个图像分类任务,我们将使用CIFAR-10数据集,这是一个知名的数据库,包含 60,000 个 32x32 的 RGB 图像,分为 10 个不同的类别,每个类别包含 6,000 张图像。我们将使用torchvision.datasets模块直接从 torch 数据集仓库加载数据。

数据集引用

从小图像中学习多层特征,Alex Krizhevsky,2009 年。

代码如下:

def load_dataset(batch_size):
    train_dataloader = torch.utils.data.DataLoader(
        datasets.CIFAR10('dataset', transform=transform_train_dataset, train=True, download=True),
        batch_size=batch_size,  shuffle=True)
    return train_dataloader, test_dataloader
train_dataloader, test_dataloader = load_dataset(batch_size)

这应该给我们以下输出:

图 5.6 – RandWireNN 数据加载

图 5.6 – RandWireNN 数据加载

现在我们将继续设计神经网络模型。为此,我们需要设计随机连通图。

定义随机连通图

在本节中,我们将定义一个图生成器,以生成稍后将用作神经网络的随机图。让我们开始吧:

如下面的代码所示,我们必须定义随机图生成器类:

class RndGraph(object):
    def __init__(self, num_nodes, graph_probability, nearest_neighbour_k=4, num_edges_attach=5):
    def make_graph_obj(self):
        graph_obj = nx.random_graphs.connected_watts_strogatz_graph(self.num_nodes, self.nearest_neighbour_k,self.graph_probability)
        return graph_obj

在本练习中,我们将使用一个众所周知的随机图模型——Watts Strogatz (WS) 模型。这是在原始研究论文中对 RandWireNN 进行实验的三个模型之一。在这个模型中,有两个参数:

每个节点的邻居数(应严格偶数),K

重连概率,P

首先,图的所有N个节点按环形排列,每个节点与其左侧的K/2个节点和右侧的K/2个节点相连。然后,我们顺时针遍历每个节点K/2次。在第m次遍历(0<m<K/2)时,当前节点与其右侧第m个邻居之间的边以概率P重连

在这里,重连意味着将边替换为与当前节点及其不同的另一节点之间的另一条边,以及第m个邻居。在前面的代码中,我们的随机图生成器类的make_graph_obj方法使用了networkx库来实例化 WS 图模型。

在前面的代码中,我们的随机图生成器类的make_graph_obj方法使用了networkx库来实例化 WS 图模型。

此外,我们还添加了一个get_graph_config方法来返回图中节点和边的列表。在将抽象图转换为神经网络时,这将非常有用。我们还将为缓存生成的图定义一些图保存和加载方法,以提高可重现性和效率:

 def get_graph_config(self, graph_obj):
        return node_list, incoming_edges
    def save_graph(self, graph_obj, path_to_write):
        nx.write_yaml(graph_obj, "./cached_graph_obj/" + path_to_write)
    def load_graph(self, path_to_read):
        return nx.read_yaml("./cached_graph_obj/" + path_to_read)

接下来,我们将开始创建实际的神经网络模型。

定义 RandWireNN 模型模块

现在我们有了随机图生成器,需要将其转换为神经网络。但在此之前,我们将设计一些神经模块来促进这种转换。让我们开始吧:

从神经网络的最低级别开始,首先我们将定义一个可分离的 2D 卷积层,如下所示:

class SepConv2d(nn.Module):
    def __init__(self, input_ch, output_ch, kernel_length=3, dilation_size=1, padding_size=1, stride_length=1, bias_flag=True):
        super(SepConv2d, self).__init__()
        self.conv_layer = nn.Conv2d(input_ch, input_ch, kernel_length, stride_length, padding_size, dilation_size, bias=bias_flag, groups=input_ch)
        self.pointwise_layer = nn.Conv2d(input_ch, output_ch, kernel_size=1, stride=1, padding=0, dilation=1, groups=1, bias=bias_flag)
    def forward(self, x):
        return self.pointwise_layer(self.conv_layer(x))

可分离卷积层是常规 3x3 的 2D 卷积层级联,后跟点态 1x1 的 2D 卷积层。

定义了可分离的 2D 卷积层后,我们现在可以定义 ReLU-Conv-BN 三元组单元:

class UnitLayer(nn.Module):
    def __init__(self, input_ch, output_ch, stride_length=1):
        self.unit_layer = nn.Sequential(
            nn.ReLU(),
            SepConv2d(input_ch, output_ch, stride_length=stride_length),nn.BatchNorm2d(output_ch),nn.Dropout(self.dropout)
        )
    def forward(self, x):
        return self.unit_layer(x)

正如我们之前提到的,三元组单元是 ReLU 层级联,后跟可分离的 2D 卷积层,再跟批量归一化层。我们还必须添加一个 dropout 层进行正则化。

有了三元组单元的存在,我们现在可以定义图中的节点,具备我们在本练习开始时讨论的所有聚合转换分布功能:

class GraphNode(nn.Module):
    def __init__(self, input_degree, input_ch, output_ch, stride_length=1):
        self.unit_layer = UnitLayer(input_ch, output_ch, stride_length=stride_length)
    def forward(self, *ip):
        if len(self.input_degree) > 1:
            op = (ip[0] * torch.sigmoid(self.params[0]))
            for idx in range(1, len(ip)):
                op += (ip[idx] * torch.sigmoid(self.params[idx]))
            return self.unit_layer(op)
        else:
            return self.unit_layer(ip[0])

forward方法中,如果节点的入边数大于1,则计算加权平均值,并使这些权重成为该节点的可学习参数。然后将三元组单元应用于加权平均值,并返回变换后的(ReLU-Conv-BN-ed)输出。

现在我们可以整合所有图和图节点的定义,以定义一个随机连线图类,如下所示:

class RandWireGraph(nn.Module):
    def __init__(self, num_nodes, graph_prob, input_ch, output_ch, train_mode, graph_name):
        # get graph nodes and in edges
        rnd_graph_node = RndGraph(self.num_nodes, self.graph_prob)
        if self.train_mode is True:
            rnd_graph = rnd_graph_node.make_graph_obj()
            self.node_list, self.incoming_edge_list = rnd_graph_node.get_graph_config(rnd_graph)
        else:
        # define source Node
        self.list_of_modules = nn.ModuleList([GraphNode(self.incoming_edge_list[0], self.input_ch, self.output_ch,
stride_length=2)])
        # define the sink Node
self.list_of_modules.extend([GraphNode(self.incoming_edge_list[n], self.output_ch, self.output_ch)
                                     for n in self.node_list if n > 0])

在这个类的 __init__ 方法中,首先生成一个抽象的随机图。推导出其节点和边缘。使用 GraphNode 类,将该抽象随机图的每个抽象节点封装为所需神经网络的神经元。最后,向网络添加一个源或输入节点和一个汇或输出节点,使神经网络准备好进行图像分类任务。

forward 方法同样非传统,如下所示:

 def forward(self, x):
        # source vertex
        op = self.list_of_modules[0].forward(x)
        mem_dict[0] = op
        # the rest of the vertices
        for n in range(1, len(self.node_list) - 1):
            if len(self.incoming_edge_list[n]) > 1:
                op = self.list_of_modules[n].forward(*[mem_dict[incoming_vtx]
                                                       for incoming_vtx in self.incoming_edge_list[n]])
            mem_dict[n] = op
        for incoming_vtx in range(1, len(self.incoming_edge_list[self.num_nodes + 1])):
            op += mem_dict[self.incoming_edge_list[self.num_nodes + 1][incoming_vtx]]
        return op / len(self.incoming_edge_list[self.num_nodes + 1])

首先,为源神经元执行前向传递,然后根据图中 list_of_nodes 运行一系列后续神经元的前向传递。使用 list_of_modules 执行单独的前向传递。最后,通过汇流神经元的前向传递给出了该图的输出。

接下来,我们将使用这些定义的模块和随机连接的图类来构建实际的 RandWireNN 模型类。

将随机图转换为神经网络

在上一步中,我们定义了一个随机连接的图。然而,正如我们在本练习开始时提到的,随机连接的神经网络由多个分阶段的随机连接图组成。其背后的原理是,在图像分类任务中,从输入神经元到输出神经元的通道/特征数量会随着进展而不同(增加)。这是因为设计上,在一个随机连接的图中,通道的数量是恒定的,这是不可能的。让我们开始吧:

在这一步中,我们定义了最终的随机连接的神经网络。这将由三个相邻的随机连接的图级联组成。每个图都会比前一个图的通道数量增加一倍,以帮助我们符合图像分类任务中增加通道数量(在空间上进行下采样)的一般实践:

class RandWireNNModel(nn.Module):
    def __init__(self, num_nodes, graph_prob, input_ch, output_ch, train_mode):
        self.conv_layer_1 = nn.Sequential(
            nn.Conv2d(in_channels=3, out_channels=self.output_ch, kernel_size=3, padding=1),
            nn.BatchNorm2d(self.output_ch) )
        self.conv_layer_2 = …
        self.conv_layer_3 = …
        self.conv_layer_4 = …
        self.classifier_layer = nn.Sequential(
            nn.Conv2d(in_channels=self.input_ch*8, out_channels=1280, kernel_size=1), nn.BatchNorm2d(1280))
        self.output_layer = nn.Sequential(nn.Dropout(self.dropout), nn.Linear(1280, self.class_num))

__init__ 方法以一个常规的 3x3 卷积层开始,然后是三个分阶段的随机连接图,通道数量随着图像分类任务中的增加而增加。接下来是一个完全连接的层,将最后一个随机连接图的卷积输出展平为大小为 1280 的向量。

最后,另一个完全连接的层产生一个大小为 10 的向量,其中包含 10 个类别的概率,如下所示:

 def forward(self, x):
        x = self.conv_layer_1(x)
        x = self.conv_layer_2(x)
        x = self.conv_layer_3(x)
        x = self.conv_layer_4(x)
        x = self.classifier_layer(x)
        # global average pooling
        _, _, h, w = x.size()
        x = F.avg_pool2d(x, kernel_size=[h, w])
        x = torch.squeeze(x)
        x = self.output_layer(x)
        return x

forward 方法相当不言自明,除了在第一个完全连接层之后应用的全局平均池化。这有助于减少网络中的维度和参数数量。

在这个阶段,我们已成功定义了 RandWireNN 模型,加载了数据集,并定义了模型训练流程。现在,我们已经准备好运行模型训练循环。

训练 RandWireNN 模型

在这一节中,我们将设置模型的超参数并训练 RandWireNN 模型。让我们开始吧:

我们已经定义了我们练习的所有构建块。现在是执行它的时候了。首先,让我们声明必要的超参数:

num_epochs = 5
graph_probability = 0.7
node_channel_count = 64
num_nodes = 16
lrate = 0.1
batch_size = 64
train_mode = True

在声明了超参数之后,我们实例化了 RandWireNN 模型,以及优化器和损失函数:

rand_wire_model = RandWireNNModel(num_nodes, graph_probability, node_channel_count, node_channel_count, train_mode).to(device)
optim_module = optim.SGD(rand_wire_model.parameters(), lr=lrate, weight_decay=1e-4, momentum=0.8)
loss_func = nn.CrossEntropyLoss().to(device)

最后,我们开始训练模型。在这里我们演示目的训练了5个 epochs,但建议您延长训练时间以提高性能:

for ep in range(1, num_epochs + 1):
    epochs.append(ep)
    training_loss, training_accuracy = train(rand_wire_model, train_dataloader, optim_module, loss_func, ep, lrate)
    test_accuracy = accuracy(rand_wire_model, test_dataloader)
    test_accuracies.append(test_accuracy)
    training_losses.append(training_loss)
    training_accuracies.append(training_accuracy)
    if best_test_accuracy < test_accuracy:
        torch.save(model_state, './model_checkpoint/' + model_filename + 'ckpt.t7')
    print("model train time: ", time.time() - start_time)

这应该会产生以下输出:

图 5.7 – RandWireNN 训练日志

图 5.7 – RandWireNN 训练日志

从这些日志中可以明显看出,随着 epochs 的增加,模型正在逐步学习。验证集上的性能似乎在持续提升,这表明模型具有良好的泛化能力。

有了这个,我们创建了一个没有特定架构的模型,可以在 CIFAR-10 数据集上合理地执行图像分类任务。

评估和可视化 RandWireNN 模型

最后,在简要探索模型架构之前,我们将查看此模型在测试集上的性能。让我们开始吧:

模型训练完成后,我们可以在测试集上进行评估:

rand_wire_nn_model.load_state_dict(model_checkpoint['model'])
for test_data, test_label in test_dataloader:
    success += pred.eq(test_label.data).sum()
    print(f"test accuracy: {float(success) * 100\. / len(test_dataloader.dataset)} %")

这应该会产生以下输出:

图 5.8 – RandWireNN 评估结果

图 5.8 – RandWireNN 评估结果

最佳表现模型在第四个 epoch 时找到,准确率超过 67%。虽然模型尚未完美,但我们可以继续训练更多 epochs 以获得更好的性能。此外,对于此任务,一个随机模型的准确率将为 10%(因为有 10 个同等可能的类别),因此 67.73%的准确率仍然是有希望的,特别是考虑到我们正在使用随机生成的神经网络架构。

结束这个练习之前,让我们看看所学的模型架构。原始图像太大不能在这里显示。您可以在我们的 github 仓库中找到完整的图像,分别以.svg 格式 [5.4] 和 .pdf 格式 [5.5] 。在下图中,我们垂直堆叠了三部分 - 输入部分,中间部分和输出部分,原始神经网络的:

图 5.9 – RandWireNN 架构

图 5.9 – RandWireNN 架构

从这张图中,我们可以观察到以下关键点:

在顶部,我们可以看到这个神经网络的开头,它由一个 64 通道的 3x3 的 2D 卷积层组成,后面是一个 64 通道的 1x1 的点卷积 2D 层。

在中间部分,我们可以看到第三阶段和第四阶段随机图之间的过渡,其中我们可以看到第三阶段随机图的汇聚神经元conv_layer_3,以及第四阶段随机图的源神经元conv_layer_4

最后,图的最底部显示了最终的输出层 - 随机图第 4 阶段的汇聚神经元(一个 512 通道的可分离二维卷积层),接着是一个全连接的展平层,结果是一个 1280 大小的特征向量,然后是一个全连接的 softmax 层,产生 10 个类别的概率。

因此,我们已经构建、训练、测试和可视化了一个用于图像分类的神经网络模型,未指定任何特定的模型架构。我们确实对结构施加了一些总体约束,比如笔架特征向量的长度(1280),可分离二维卷积层中的通道数(64),RandWireNN 模型中的阶段数(4),每个神经元的定义(ReLU-Conv-BN 三元组),等等。

然而,我们并没有指定这个神经网络架构应该是什么样的。我们使用了一个随机图生成器来为我们完成这项工作,这为找到最佳神经网络架构开启了几乎无限的可能性。

神经架构搜索是深度学习领域一个不断发展且富有前景的研究领域。在很大程度上,这与为特定任务训练定制的机器学习模型的领域相契合,被称为 AutoML。

AutoML 代表自动化机器学习,因为它消除了手动加载数据集,预定义特定神经网络模型架构来解决给定任务,并手动将模型部署到生产系统的必要性。在 第十六章,PyTorch 和 AutoML 中,我们将详细讨论 AutoML,并学习如何使用 PyTorch 构建这样的系统。

总结

在本章中,我们看了两种不同的混合类型的神经网络。首先,我们看了变压器模型 - 基于注意力的模型,没有循环连接,已在多个顺序任务上表现出色。我们进行了一个练习,在这个练习中我们使用 PyTorch 在 WikiText-2 数据集上构建、训练和评估了一个变压器模型来执行语言建模任务。在本章的第二个也是最后一个部分中,我们接着上一章 第三章,深度卷积神经网络架构 的内容,讨论了优化模型架构而不仅仅是优化模型参数的想法。我们探讨了一种方法 - 使用随机连线神经网络(RandWireNNs)- 在这种网络中我们生成随机图,给这些图的节点和边赋予含义,然后将这些图相互连接形成一个神经网络。

在下一章中,我们将转变思路,远离模型架构,看看一些有趣的 PyTorch 应用。我们将学习如何使用 PyTorch 通过生成式深度学习模型来生成音乐和文本。

第七章:使用 PyTorch 进行音乐和文本生成

加入我们的书籍社区在 Discord 上

packt.link/EarlyAccessCommunity

img

PyTorch 既是研究深度学习模型又是开发基于深度学习的应用程序的绝佳工具。在前几章中,我们探讨了跨多个领域和模型类型的模型架构。我们使用 PyTorch 从头开始构建了这些架构,并使用了 PyTorch 模型动物园中的预训练模型。从本章开始,我们将转变方向,深入探讨生成模型。

在前几章中,我们的大多数示例和练习都围绕开发分类模型展开,这是一个监督学习任务。然而,当涉及到无监督学习任务时,深度学习模型也被证明非常有效。深度生成模型就是其中一个例子。这些模型使用大量未标记的数据进行训练。训练完成后,模型可以生成类似的有意义数据。它通过学习输入数据的潜在结构和模式来实现这一点。

在本章中,我们将开发文本和音乐生成器。为了开发文本生成器,我们将利用我们在第五章《混合高级模型》中训练的基于 Transformer 的语言模型。我们将使用 PyTorch 扩展 Transformer 模型,使其成为文本生成器。此外,我们还将演示如何在 PyTorch 中使用先进的预训练 Transformer 模型来设置几行代码中的文本生成器。最后,我们将展示如何使用 PyTorch 从头开始训练一个基于 MIDI 数据集的音乐生成模型。

在本章结束时,您应该能够在 PyTorch 中创建自己的文本和音乐生成模型。您还将能够应用不同的采样或生成策略,以从这些模型生成数据。本章涵盖以下主题:

  • 使用 PyTorch 构建基于 Transformer 的文本生成器

  • 使用预训练的 GPT 2 模型作为文本生成器

  • 使用 PyTorch 生成 MIDI 音乐的 LSTMs

使用 PyTorch 构建基于 Transformer 的文本生成器

在上一章中,我们使用 PyTorch 构建了基于 Transformer 的语言模型。因为语言模型建模了在给定一系列单词之后某个单词出现的概率,所以我们在构建自己的文本生成器时已经过了一半的路程。在本节中,我们将学习如何将这个语言模型扩展为一个深度生成模型,它可以在给定一系列初始文本提示的情况下生成任意但有意义的句子。

训练基于 Transformer 的语言模型

在前一章中,我们对语言模型进行了 5 个 epoch 的训练。在本节中,我们将按照相同的步骤进行训练,但将模型训练更长时间 - 25 个 epoch。目标是获得一个表现更好的语言模型,可以生成更真实的句子。请注意,模型训练可能需要几个小时。因此,建议在后台进行训练,例如过夜。为了按照训练语言模型的步骤进行操作,请在 GitHub [7.1] 上查看完整代码。

在训练了 25 个 epoch 之后,我们得到了以下输出:

图 7 .1 – 语言模型训练日志

图 7 .1 – 语言模型训练日志

现在我们已经成功地训练了 25 个 epoch 的 Transformer 模型,我们可以进入实际的练习阶段,在这里我们将扩展这个训练好的语言模型作为一个文本生成模型。

保存和加载语言模型

在这里,我们将在训练完成后简单保存表现最佳的模型检查点。然后,我们可以单独加载这个预训练模型:

  1. 一旦模型训练完成,最好将其保存在本地,以避免需要从头开始重新训练。可以按以下步骤保存:
mdl_pth = './transformer.pth'
torch.save(best_model_so_far.state_dict(), mdl_pth)
  1. 现在,我们可以加载保存的模型,以便将这个语言模型扩展为文本生成模型:
# load the best trained model
transformer_cached = Transformer(num_tokens, embedding_size, num_heads, num_hidden_params, num_layers, dropout).to(device)
transformer_cached.load_state_dict(torch.load(mdl_pth))

在本节中,我们重新实例化了一个 Transformer 模型对象,然后将预训练的模型权重加载到这个新的模型对象中。接下来,我们将使用这个模型来生成文本。

使用语言模型生成文本

现在模型已经保存和加载完毕,我们可以扩展训练好的语言模型来生成文本:

  1. 首先,我们必须定义我们想要生成的目标单词数量,并提供一个初始单词序列作为模型的线索:
ln = 5     
sntc = 'They are       _'
sntc_split = sntc.split()
mask_source = gen_sqr_nxt_mask(max_seq_len).to(device)
  1. 最后,我们可以在循环中逐个生成单词。在每次迭代中,我们可以将该迭代中预测的单词附加到输入序列中。这个扩展的序列成为下一个迭代中模型的输入,依此类推。添加随机种子是为了确保一致性。通过更改种子,我们可以生成不同的文本,如下面的代码块所示:
torch.manual_seed(34     )
with torch.no_grad():
    for i in range(ln):
        sntc = ' '.join(sntc_split)
        txt_ds = Tensor(vocabulary(sntc_split)).unsqueeze(0).to(torch.long)
        num_b = txt_ds.size(0)
        txt_ds = txt_ds.narrow(0, 0, num_b)
        txt_ds = txt_ds.view(1, -1).t().contiguous().to(device)
        ev_X, _ = return_batch(txt_ds, i+1)
        sequence_length = ev_X.size(0)
        if sequence_length != max_seq_len:
            mask_source = mask_source[:sequence_length, :sequence_length]
        op = transformer_cached(ev_X, mask_source)
        op_flat = op.view(-1, num_tokens)
        res = vocabulary.get_itos()[op_flat.argmax(1)[0]]
        sntc_split.insert(-1, res)

print(sntc[:-2])

这应该会输出以下内容:

图 7 .2 – Transformer 生成的文本

图 7 .2 – Transformer 生成的文本

我们可以看到,使用 PyTorch,我们可以训练一个语言模型(在本例中是基于 Transformer 的模型),然后通过几行额外的代码来生成文本。生成的文本似乎是有意义的。这种文本生成器的结果受到底层语言模型训练数据量和语言模型强度的限制。在本节中,我们基本上是从头开始构建了一个文本生成器。

在下一节中,我们将加载预训练语言模型,并将其用作文本生成器。我们将使用变压器模型的高级继任者 – 生成式预训练变压器GPT-2)。我们将演示如何在不到 10 行代码的情况下,使用 PyTorch 构建一个即时高级文本生成器。我们还将探讨从语言模型生成文本涉及的一些策略。

使用预训练的 GPT-2 模型作为文本生成器

使用transformers库和 PyTorch,我们可以加载大多数最新的先进变压器模型,用于执行诸如语言建模、文本分类、机器翻译等各种任务。我们在第五章混合先进模型中展示了如何做到这一点。

在本节中,我们将加载预训练的基于 GPT-2 的语言模型。然后,我们将扩展此模型,以便我们可以将其用作文本生成器。然后,我们将探索各种策略,以便从预训练的语言模型中生成文本,并使用 PyTorch 演示这些策略。

使用 GPT-2 的即时文本生成

作为练习形式,我们将加载一个使用 transformers 库预训练的 GPT-2 语言模型,并将此语言模型扩展为文本生成模型以生成任意但有意义的文本。为了演示目的,我们仅显示代码的重要部分。要访问完整的代码,请转到 github [7.2] 。按照以下步骤进行:

  1. 首先,我们需要导入必要的库:
from transformers import GPT2LMHeadModel, GPT2Tokenizer
import torch

我们将导入 GPT-2 多头语言模型和相应的分词器来生成词汇表。

  1. 接下来,我们将实例化GPT2Tokenizer和语言模型。然后,我们将提供一组初始单词作为模型的线索,如下所示:
torch.manual_seed(799)
tkz = GPT2Tokenizer.from_pretrained("gpt2")
mdl = GPT2LMHeadModel.from_pretrained('gpt2')
ln = 10
cue = "They     "
gen = tkz(cue, return_tensors="pt")     
to_ret      = gen["input_ids"][0] 
  1. 最后,我们将迭代地预测给定输入单词序列的下一个单词,使用语言模型。在每次迭代中,预测的单词将附加到下一次迭代的输入单词序列中:
prv=None
for i in range(ln):
    outputs = mdl(**gen)
    next_token_logits = torch.argmax(outputs.logits[-1, :])
    to_ret = torch.cat([to_ret, next_token_logits.unsqueeze(0)])
    gen = {"input_ids": to_ret}
seq = tkz.decode(to_ret)
print(seq) 

输出应该如下所示:

图 7.3 – GPT-2 生成的文本

图 7.3 – GPT-2 生成的文本

这种生成文本的方式也称为贪婪搜索。在接下来的部分,我们将更详细地讨论贪婪搜索以及其他一些文本生成策略。

使用 PyTorch 的文本生成策略

当我们使用训练过的文本生成模型生成文本时,通常是逐词预测。然后,我们将预测出的一系列单词序列合并为预测文本。当我们在循环中迭代单词预测时,我们需要指定一种方法来找到/预测给定前k个预测后的下一个单词。这些方法也被称为文本生成策略,我们将在本节讨论一些著名的策略。

贪婪搜索

贪婪一词的正当性在于,该模型选择当前迭代中具有最大概率的单词,而不考虑它们在未来多少时间步骤后。通过这种策略,模型可能会错过一个概率高的单词,而选择了一个概率低的单词,因为模型没有追求概率低的单词。以下图表展示了贪婪搜索策略,用一个假设情景来说明在前一个练习的第 3 步中可能发生的情况。在每个时间步骤,文本生成模型输出可能的单词及其概率:

图 7 .4 – 贪婪搜索

图 7 .4 – 贪婪搜索

我们可以看到,在每个步骤中,根据文本生成的贪婪搜索策略,模型会选择具有最高概率的单词。请注意倒数第二步,在这一步中,模型预测单词systempeoplefuture的概率几乎相等。通过贪婪搜索,system被选为下一个单词,因为它的概率略高于其他单词。然而,你可以认为peoplefuture可能会导致更好或更有意义的生成文本。

这是贪婪搜索方法的核心局限性。此外,贪婪搜索还因缺乏随机性而导致重复结果。如果有人想要艺术地使用这样的文本生成器,贪婪搜索并不是最佳选择,仅仅因为它的单调性。

在前一节中,我们手动编写了文本生成循环。由于transformers库的帮助,我们可以用三行代码编写文本生成步骤:

ip_ids = tkz.encode(cue, return_tensors='pt')
op_greedy = mdl.generate(ip_ids, max_length=ln, pad_token_id=tkz.eos_token_id)
seq = tkz.decode(op_greedy[0], skip_special_tokens=True)
print(seq) 

这应该输出如下内容:

图 7 .5 – GPT-2 生成的简明文本

图 7 .5 – GPT-2 生成的简明文本

请注意,图 7 .5 中生成的句子比图 7 .3 中生成的句子少一个标记(句号)。这一差异是因为在后者的代码中,max_length参数包括了提示词。因此,如果我们有一个提示词,那么仅会预测出九个新单词,正如在这里的情况一样。

光束搜索

生成文本不仅仅是贪婪搜索的一种方式。束搜索是贪婪搜索方法的发展,其中我们维护一个基于整体预测序列概率的潜在候选序列列表,而不仅仅是下一个单词的概率。要追求的候选序列数量即为单词预测树中的光束数。

以下图表展示了如何使用光束搜索和光束大小为 3 来生成五个单词的三个候选序列(按照整体序列概率排序):

图 7 .6 – 光束搜索

图 7 .6 – 光束搜索

在这个束搜索示例的每次迭代中,会保留三个最有可能的候选序列。随着序列的推进,可能的候选序列数呈指数增长。然而,我们只关注前三个序列。这样,我们不会像贪婪搜索那样错过潜在更好的序列。

在 PyTorch 中,我们可以使用一行代码轻松使用束搜索。以下代码演示了基于束搜索的文本生成,使用三个束生成三个最有可能的句子,每个句子包含五个单词:

op_beam = mdl.generate(
    ip_ids,
    max_length=5,
    num_beams=3,
    num_return_sequences=3,
    pad_token_id=tkz.eos_token_id
)
for op_beam_cur in op_beam:
    print(tkz.decode(op_beam_cur, skip_special_tokens=True))

这给我们以下输出:

图 7 .7 – 束搜索结果

图 7 .7 – 束搜索结果

使用束搜索仍然存在重复性或单调性问题。不同的运行会得到相同的结果集,因为它确定性地寻找具有最大总体概率的序列。在接下来的部分中,我们将探讨一些方法,使生成的文本更加不可预测或创造性。

Top-k 和 top-p 抽样

我们可以随机抽样下一个单词,而不总是选择具有最高概率的下一个单词,基于它们相对概率的可能集合。例如,在 图 7 .6 中,单词beknowshow的概率分别为 0.7,0.2 和 0.1。我们可以基于它们的概率随机抽样其中任何一个单词。如果我们重复此过程 10 次以生成 10 个单独的文本,be将被选择大约七次,knowshow将分别被选择两次和一次。这给了我们很多贪婪或束搜索永远无法生成的可能单词组合。

使用抽样技术生成文本的两种最流行方式称为top-ktop-p抽样。在 top-k 抽样中,我们预先定义一个参数k,它是在抽样下一个词时应考虑的候选词数。所有其他词都将被丢弃,并且在前k个词中的概率将被归一化。在我们的先前示例中,如果k为 2,则单词show将被丢弃,单词beknow的概率(分别为 0.7 和 0.2)将被归一化为 0.78 和 0.22。

以下代码演示了 top-k 文本生成方法:

for i in range(3):
    torch.manual_seed(i+10)
    op = mdl.generate(
        ip_ids,
        do_sample=True,
        max_length=5,
        top_k=2,
        pad_token_id=tkz.eos_token_id
    )
    seq = tkz.decode(op[0], skip_special_tokens=True)
    print(seq)

这应该生成以下输出:

图 7 .8 – Top-k 搜索结果

图 7 .8 – Top-k 搜索结果

要从所有可能的单词中抽样,而不仅仅是前k个单词,请在我们的代码中将top-k参数设置为0。如前面的屏幕截图所示,不同的运行产生不同的结果,而不是贪婪搜索,每次运行都会产生相同的结果,如以下代码所示:

for i in range(3):
    torch.manual_seed(i+10)
    op_greedy = mdl.generate(ip_ids, max_length=5, pad_token_id=tkz.eos_token_id)
    seq = tkz.decode(op_greedy[0], skip_special_tokens=True)
    print(seq) 

这应该输出以下内容:

图 7 .9 – 重复的贪婪搜索结果

图 7 .9 – 重复的贪婪搜索结果

在 top-p 抽样策略下,与其定义要查看的前k个词汇不同,我们可以定义一个累积概率阈值(p),然后保留那些概率总和达到p的词汇。在我们的例子中,如果p介于0.70.9之间,则舍弃knowshow;如果p介于0.91.0之间,则舍弃show;如果p1.0,则保留所有三个词汇,即beknowshow

在概率分布是平坦的情况下,top-k 策略有时可能不公平。这是因为它剪切掉几乎与保留的词汇一样可能的词汇。在这些情况下,top-p 策略将保留一个更大的词汇池供抽样,并在概率分布较为尖锐时保留较小的词汇池。

以下代码展示了 top-p 抽样方法的操作:

for i in range(3):
    torch.manual_seed(i+10)
    op = mdl.generate(
        ip_ids,
        do_sample=True,
        max_length=5,
        top_p=0.75,
        top_k=0,
        pad_token_id=tkz.eos_token_id
    )
    seq = tkz.decode(op[0], skip_special_tokens=True)
    print(seq)

这应该输出以下内容:

图 7 .10 – Top-p 搜索结果

图 7 .10 – Top-p 搜索结果

我们可以同时设置 top-k 和 top-p 策略。在这个例子中,我们将top-k设置为0以基本上禁用 top-k 策略,而p设置为0.75。再次运行时,这会导致不同的句子,使我们能够生成更具创造性的文本,而不是贪婪或波束搜索。在这个领域有许多更多的文本生成策略可供选择,并且正在进行大量的研究。我们鼓励您进一步关注此问题。

一个很好的起点是在transformers库中玩转可用的文本生成策略。您可以从他们的博客文章[7.3]中了解更多信息。

这篇文章总结了我们使用 PyTorch 生成文本的探索。在下一节中,我们将执行类似的练习,但这次是针对音乐而不是文本。其思想是在音乐数据集上训练一个无监督模型,并使用训练好的模型生成类似训练数据集中的旋律。

使用 PyTorch 生成 MIDI 音乐的 LSTM 方法

转向音乐,本节中我们将使用 PyTorch 创建一个可以生成类似古典音乐的机器学习模型。在上一节中,我们使用 transformers 生成文本。在这里,我们将使用 LSTM 模型处理序列音乐数据。我们将在莫扎特的古典音乐作品上训练模型。

每个音乐作品本质上将被分解为一系列钢琴音符。我们将以音乐器件数字接口MIDI)文件的形式读取音乐数据,这是一种用于跨设备和环境方便读写音乐数据的常用格式。

在将 MIDI 文件转换为钢琴音符序列(我们称之为钢琴卷轴)之后,我们将使用它们来训练一个下一个钢琴音符检测系统。在这个系统中,我们将构建一个基于 LSTM 的分类器,用于预测给定钢琴音符前序列的下一个钢琴音符,总共有 88 个(符合标准的 88 个钢琴键)。

现在,我们将展示构建 AI 音乐作曲家的整个过程,这是一个练习形式。我们的重点将放在用于数据加载、模型训练和生成音乐样本的 PyTorch 代码上。请注意,模型训练过程可能需要几个小时,因此建议在后台运行训练过程,例如过夜。出于保持文本简短的考虑,此处呈现的代码已被削减。

关于处理 MIDI 音乐文件的详细信息超出了本书的范围,尽管鼓励你探索完整的代码,该代码可在 github [7.4] 找到。

加载 MIDI 音乐数据

首先,我们将演示如何加载以 MIDI 格式可用的音乐数据。我们将简要介绍处理 MIDI 数据的代码,然后说明如何将其转换为 PyTorch 数据加载器。让我们开始吧:

  1. 如往常一样,我们将从导入重要的库开始。在这个练习中,我们将使用一些新的库,具体如下:
import skimage.io as io
from struct import pack, unpack
from io import StringIO, BytesIO

skimage 用于可视化模型生成的音乐样本序列。structio 用于处理将 MIDI 音乐数据转换为钢琴卷轴的过程。

  1. 接下来,我们将编写用于加载 MIDI 文件并将其转换为钢琴音符序列(矩阵)的辅助类和函数。首先,我们定义一些 MIDI 常量,以配置各种音乐控制,如音高、通道、序列开始、序列结束等:
NOTE_MIDI_OFF = 0x80
NOTE_MIDI_ON = 0x90
CHNL_PRESS = 0xD0
MIDI_PITCH_BND = 0xE0
...
  1. 接下来,我们将定义一系列类,用于处理 MIDI 数据的输入和输出流、MIDI 数据解析器等,如下所示:
class MOStrm:
# MIDI Output Stream
...
class MIFl:
# MIDI Input File Reader
...
class MOFl(MOStrm):
# MIDI Output File Writer
...
class RIStrFl:
# Raw Input Stream File Reader
...
class ROStrFl:
# Raw Output Stream File Writer
...
class MFlPrsr:
# MIDI File Parser
...
class EvtDspch:
# Event Dispatcher
...
class MidiDataRead(MOStrm):
# MIDI Data Reader
...
  1. 处理完所有 MIDI 数据 I/O 相关的代码后,我们现在可以实例化我们自己的 PyTorch 数据集类了。在此之前,我们必须定义两个关键函数——一个用于将读取的 MIDI 文件转换为钢琴卷轴,另一个用于用空音符填充钢琴卷轴。这将使数据集中的音乐片段长度标准化:
def md_fl_to_pio_rl(md_fl):
    md_d = MidiDataRead(md_fl, dtm=0.3)
    pio_rl = md_d.pio_rl.transpose()
    pio_rl[pio_rl > 0] = 1    
    return pio_rl
def pd_pio_rl(pio_rl, mx_l=132333, pd_v=0):        
    orig_rol_len = pio_rl.shape[1]    
    pdd_rol = np.zeros((88, mx_l))
    pdd_rol[:] = pd_v    
    pdd_rol[:, - orig_rol_len:] = pio_rl
    return pdd_rol
  1. 现在,我们可以定义我们的 PyTorch 数据集类,如下所示:
class NtGenDataset(data.Dataset):    
    def __init__(self, md_pth, mx_seq_ln=1491):        
        ...    
    def mx_len_upd(self):        
        ...   
    def __len__(self):        
        return len(self.md_fnames_ful)    
    def __getitem__(self, index):        
        md_fname_ful = self.md_fnames_ful[index]        
        pio_rl = md_fl_to_pio_rl(md_fname_ful)
        seq_len = pio_rl.shape[1] - 1
        ip_seq = pio_rl[:, :-1]
        gt_seq = pio_rl[:, 1:]
        ...
        return (torch.FloatTensor(ip_seq_pad),
                torch.LongTensor(gt_seq_pad), torch.LongTensor([seq_len]))
  1. 除了数据集类之外,我们还必须添加另一个辅助函数,将训练数据批次中的音乐序列后处理为三个单独的列表。这些列表将是输入序列、输出序列和序列的长度,按序列长度降序排列:
def pos_proc_seq(btch):
    ip_seqs, op_seqs, lens = btch    
    ...
    ord_tr_data_tups = sorted(tr_data_tups,
                                         key=lambda c: int(c[2]),
                                         reverse=True)
    ip_seq_splt_btch, op_seq_splt_btch, btch_splt_lens = zip(*ord_tr_data_tups)
    ...  
    return tps_ip_seq_btch, ord_op_seq_btch, list(ord_btch_lens_l)
  1. 为了这个练习,我们将使用一组莫扎特的作品。您可以从钢琴 MIDI 网站[7.5]下载数据集:。下载的文件夹包含 21 个 MIDI 文件,我们将它们分成 18 个训练集文件和 3 个验证集文件。下载的数据存储在./mozart/train./mozart/valid下。下载完成后,我们可以读取数据并实例化我们自己的训练和验证数据集加载器:
training_dataset = NtGenDataset('./mozart/train', mx_seq_ln=None)
training_datasetloader = data.DataLoader(training_dataset, batch_size=5,shuffle=True, drop_last=True)
validation_dataset = NtGenDataset('./mozart/valid/', mx_seq_ln=None)
validation_datasetloader = data.DataLoader(validation_dataset, batch_size=3, shuffle=False, drop_last=False)
X_validation = next(iter(validation_datasetloader))
X_validation[0].shape

这应该会给我们以下输出:

图 7 .11 – 莫扎特作品数据示例维度

图 7 .11 – 莫扎特作品数据示例维度

如我们所见,第一个验证批次包含三个长度为 1,587 的序列(音符),每个序列编码为一个 88 大小的向量,其中 88 是钢琴键的总数。对于那些训练有素的音乐家,以下是验证集音乐文件的前几个音符的乐谱等价物:

图 7 .12 – 莫扎特作品的乐谱

图 7 .12 – 莫扎特作品的乐谱

或者,我们可以将音符序列可视化为一个具有 88 行的矩阵,每行代表一个钢琴键。以下是前述旋律(1,587 个音符中的前 300 个)的视觉矩阵表示:

图 7 .13 – 莫扎特作品的矩阵表示

图 7 .13 – 莫扎特作品的矩阵表示

数据集引用

Bernd Krueger 的 MIDI、音频(MP3、OGG)和视频文件受 CC BY-SA Germany 许可证的保护。姓名:Bernd Krueger 来源:www.piano-midi.de。这些文件的分发或公共播放仅允许在相同的许可条件下进行。乐谱是开源的。

现在我们将定义 LSTM 模型和训练例程。

定义 LSTM 模型和训练例程

到目前为止,我们已成功加载了一个 MIDI 数据集,并用它创建了自己的训练和验证数据加载器。在本节中,我们将定义 LSTM 模型架构以及在模型训练循环中运行的训练和评估过程。让我们开始吧:

  1. 首先,我们必须定义模型架构。如前所述,我们将使用一个 LSTM 模型,该模型由编码器层组成,在序列的每个时间步骤将输入数据的 88 维表示编码为 512 维隐藏层表示。编码器之后是两个 LSTM 层,再接一个全连接层,最终通过 softmax 函数映射到 88 个类别。

根据我们在第四章 深度递归模型架构中讨论的不同类型的递归神经网络RNNs),这是一个多对一的序列分类任务,其中输入是从时间步 0 到时间步 t 的整个序列,输出是时间步 t+1 处的 88 个类别之一,如下所示:

class MusicLSTM(nn.Module):    
    def __init__(self, ip_sz, hd_sz, n_cls, lyrs=2):        
        ...       
        self.nts_enc = nn.Linear(in_features=ip_sz, out_features=hd_sz)        
        self.bn_layer = nn.BatchNorm1d(hd_sz)        
        self.lstm_layer = nn.LSTM(hd_sz, hd_sz, lyrs)        
        self.fc_layer = nn.Linear(hd_sz, n_cls)

    def forward(self, ip_seqs, ip_seqs_len, hd=None):
        ...
        pkd = torch.nn.utils.rnn.pack_padded_sequence(nts_enc_ful, ip_seqs_len)
        op, hd = self.lstm_layer(pkd, hd)
        ...
        lgts = self.fc_layer(op_nrm_drp.permute(2,0,1))
        ...
        zero_one_lgts = torch.stack((lgts, rev_lgts), dim=3).contiguous()
        flt_lgts = zero_one_lgts.view(-1, 2)
        return flt_lgts, hd
  1. 一旦模型架构被定义,我们就可以指定模型训练例程。我们将使用 Adam 优化器并进行梯度裁剪以避免过拟合。作为对抗过拟合的另一个措施,我们已经在先前的步骤中使用了一个 dropout 层:
def lstm_model_training(lstm_model, lr, ep=10, val_loss_best=float("inf")):
    ...
    for curr_ep in range(ep):
        ...
        for batch in training_datasetloader:
            ...
            lgts, _ = lstm_model(ip_seq_b_v, seq_l)
            loss = loss_func(lgts, op_seq_b_v)
            ...
        if vl_ep_cur < val_loss_best:
            torch.save(lstm_model.state_dict(), 'best_model.pth')
            val_loss_best = vl_ep_cur
    return val_loss_best, lstm_model
  1. 同样地,我们将定义模型评估例程,其中模型的前向传播在其参数保持不变的情况下运行:
def evaluate_model(lstm_model):
    ...
    for batch in validation_datasetloader:
        ...
        lgts, _ = lstm_model(ip_seq_b_v, seq_l)
        loss = loss_func(lgts, op_seq_b_v)
        vl_loss_full += loss.item()
        seq_len += sum(seq_l)
    return vl_loss_full/(seq_len*88)

现在,让我们来训练和测试音乐生成模型。

训练和测试音乐生成模型

在最后的部分,我们将实际训练 LSTM 模型。然后,我们将使用训练好的音乐生成模型生成一段我们可以听并分析的音乐样本。让我们开始吧:

  1. 我们已准备好实例化我们的模型并开始训练。对于这个分类任务,我们使用了分类交叉熵作为损失函数。我们将以学习率0.01进行10个 epoch 的模型训练:
loss_func = nn.CrossEntropyLoss().cpu()
lstm_model = MusicLSTM(ip_sz=88, hd_sz=512, n_cls=88).cpu()
val_loss_best, lstm_model = lstm_model_training(lstm_model, lr=0.01, ep=10)

这将输出以下内容:

图 7 . 14 – 音乐 LSTM 训练日志

图 7 . 14 – 音乐 LSTM 训练日志

  1. 现在是有趣的部分。一旦我们有了一个下一个音符预测器,我们就可以将其用作音乐生成器。我们所需做的就是通过提供一个初始音符作为线索来启动预测过程。模型然后可以在每个时间步骤递归地预测下一个音符,在此过程中,t时间步的预测将附加到t+1时间步的输入序列中。

在这里,我们将编写一个音乐生成函数,该函数接收训练好的模型对象、生成音乐的长度、序列的起始音符和温度作为输入。温度是在分类层上标准的数学操作。它用于操纵 softmax 概率分布,可以通过扩展或缩小 softmax 概率分布来调整 softmax 概率的分布。代码如下:

def generate_music(lstm_model, ln=100, tmp=1, seq_st=None):
    ...
    for i in range(ln):
        op, hd = lstm_model(seq_ip_cur, [1], hd)
        probs = nn.functional.softmax(op.div(tmp), dim=1)
        ...
    gen_seq = torch.cat(op_seq, dim=0).cpu().numpy()
    return gen_seq

最后,我们可以使用此函数来创建全新的音乐作品:

seq = generate_music(lstm_model, ln=100, tmp=1, seq_st=None)
midiwrite('generated_music.mid', seq, dtm=0.2)

这将创建音乐作品并将其保存为当前目录下的 MIDI 文件。我们可以打开文件并播放它以听听模型产生了什么。此外,我们还可以查看所产生音乐的视觉矩阵表示:

io.imshow(seq)

这将给我们以下输出:

图 7 .15 – AI 生成音乐示例的矩阵表示

图 7 .15 – AI 生成音乐示例的矩阵表示

此外,以下是生成音乐作品作为乐谱的样子:

图 7 .16 – AI 生成音乐示例的乐谱

图 7 . 16 – AI 生成音乐示例的乐谱

在这里,我们可以看到生成的旋律似乎不像莫扎特的原创作品那样悦耳动听。尽管如此,您可以看到模型已学会了一些关键组合的一致性。此外,通过在更多数据上训练模型并增加训练轮次,生成音乐的质量可以得到提升。

这就结束了我们关于使用机器学习生成音乐的练习。在本节中,我们演示了如何使用现有的音乐数据从头开始训练一个音符预测模型,并使用训练好的模型生成音乐。事实上,你可以将生成模型的思路扩展到生成任何类型数据的样本上。PyTorch 在这类用例中非常有效,特别是由于其简单直观的数据加载、模型构建/训练/测试的 API,以及将训练好的模型用作数据生成器的能力。我们鼓励你在不同的用例和数据类型上尝试更多类似的任务。

总结

在本章中,我们探讨了使用 PyTorch 的生成模型。在同样的艺术风格中,在下一章中,我们将学习如何使用机器学习将一幅图像的风格转移到另一幅图像上。有了 PyTorch 的支持,我们将使用 CNN 从各种图像中学习艺术风格,并将这些风格应用于不同的图像上——这一任务更为人熟知的是神经风格转移。

第八章:神经风格转移

加入我们的书籍社区 Discord

packt.link/EarlyAccessCommunity

img

在前一章中,我们开始探索使用 PyTorch 的生成模型。我们构建了可以在文本和音乐数据上无监督训练的机器学习模型,从而能够生成文本和音乐。在本章中,我们将继续探索生成建模,通过类似的方法应用于图像数据。

我们将混合两幅不同图像AB的不同方面,生成一幅结果图像C,其中包含图像A的内容和图像B的风格。这项任务也被称为神经风格转移,因为在某种程度上,我们正在将图像B的风格转移到图像A,以实现图像C,如下图所示:

图 8.1 – 神经风格转移示例

图 8.1 – 神经风格转移示例

首先,我们将简要讨论如何解决这个问题,并理解实现风格转移背后的想法。然后,我们将使用 PyTorch 实现自己的神经风格转移系统,并将其应用于一对图像。通过这个实现练习,我们还将试图理解风格转移机制中不同参数的影响。

到本章末,您将理解神经风格转移背后的概念,并能够使用 PyTorch 构建和测试自己的神经风格转移模型。

本章涵盖以下主题:

  • 理解如何在图像之间转移风格

  • 使用 PyTorch 实现神经风格转移

理解如何在图像之间转移风格

第三章深度 CNN 结构中,我们详细讨论了卷积神经网络CNNs)。当处理图像数据时,CNNs 是最成功的模型类之一。我们已经看到,基于 CNN 的架构在图像分类、物体检测等任务上是表现最佳的神经网络架构之一。这一成功的核心原因之一是卷积层学习空间表示的能力。

例如,在狗与猫分类器中,CNN 模型基本上能够捕捉图像中的内容在其更高级别的特征中,这帮助它检测狗特有的特征与猫特有的特征。我们将利用图像分类器 CNN 的这种能力来把握图像的内容。

我们知道 VGG 是一个强大的图像分类模型,如第三章深度 CNN 结构中所述。我们将使用 VGG 模型的卷积部分(不包括线性层)来从图像中提取与内容相关的特征。

我们知道每个卷积层都会生成 N 个尺寸为 XY* 的特征图。例如,假设我们有一个单通道(灰度)输入图像尺寸为(3,3),一个卷积层的输出通道数 (N) 为 3,核大小为(2,2),步幅为(1,1),且没有填充。这个卷积层将产生 3 个尺寸为 2x2 的特征图,因此在这种情况下 X=2,Y=2。

我们可以将卷积层产生的这些 N 个特征图表示为大小为 NM* 的 2D 矩阵,其中 M=XY。通过定义每个卷积层的输出为 2D 矩阵,我们可以定义一个损失函数,将其附加到每个卷积层上。这个损失函数称为内容损失,是预期输出与卷积层预测输出之间的平方损失,如下图所示,其中 N=3,X=2,Y*=2:

图 8. 2 – 内容损失示意图

图 8. 2 – 内容损失示意图

正如我们所见,输入图像(图像 C,如我们在 图 8. 1 中的标记)在本示例中通过卷积层转换为三个特征图。这三个尺寸为 2x2 的特征图每个都被格式化为一个 3x4 的矩阵。该矩阵与通过相同流程将图像 A(内容图像)通过的预期输出进行比较。然后计算像素逐点的平方和损失,我们称之为内容损失

现在,为了从图像中提取风格,我们将使用由减少的 2D 矩阵表示的行之间内积得出的格拉姆矩阵 [8.1],如下图所示:

图 8. 3 – 风格损失示意图

图 8. 3 – 风格损失示意图

在这里,与内容损失计算相比,格拉姆矩阵的计算是唯一的额外步骤。同时,正如我们所见,像素逐点平方和损失的输出数值相比内容损失而言相当大。因此,通过将其除以 NXY,即特征图的数量 (N) 乘以长度 (X) 乘以宽度 (Y),来对这个数值进行标准化。这也有助于在具有不同 NXY 的不同卷积层之间标准化风格损失指标。关于实现的详细信息可以在引入神经风格迁移的原始论文 [8.2] 中找到。

现在我们理解了内容和风格损失的概念,让我们来看看神经风格迁移的工作原理,如下所示:

  1. 对于给定的 VGG(或任何其他 CNN)网络,我们定义网络中哪些卷积层应该附加内容损失。重复此操作以进行风格损失。

  2. 一旦我们有了这些列表,我们将内容图像通过网络,并计算在应计算内容损失的卷积层处的预期卷积输出(2D 矩阵)。

  3. 接下来,我们将风格图像通过网络并在卷积层计算预期的格拉姆矩阵。这就是风格损失将被计算的地方,如下图所示。

在下图中,例如,将在第二和第三个卷积层计算内容损失,同时在第二、第三和第五个卷积层计算风格损失:

图 8. 4 – 风格转移架构图示

图 8. 4 – 风格转移架构图示

现在我们在决定的卷积层具有内容和风格目标后,我们准备好生成一幅图像,其中包含内容图像的内容和风格图像的风格。

对于初始化,我们可以使用随机噪声矩阵作为生成图像的起始点,或直接使用内容图像作为起点。我们将此图像通过网络并在预选卷积层计算风格和内容损失。我们将风格损失相加以获得总风格损失,并将内容损失相加以获得总内容损失。最后,通过加权的方式将这两个组件相加,我们获得总损失。

如果我们更注重风格组件,生成的图像将更多地反映其风格,反之亦然。使用梯度下降,我们将损失反向传播到输入,以更新我们生成的图像。几个时期后,生成的图像应该以一种方式演变,以产生最小化相应损失的内容和风格表示,从而产生风格转移的图像。

在前面的图表中,池化层是基于平均池化而不是传统的最大池化。平均池化被有意地用于风格转移,以确保平滑的梯度流。我们希望生成的图像不会在像素之间产生剧烈变化。此外,值得注意的是,前面图表中的网络在计算最后一个风格或内容损失的层结束。因此,在这种情况下,因为原始网络的第六个卷积层没有关联的损失,所以在风格转移的背景下谈论第五个卷积层之后的层是没有意义的。

在接下来的部分中,我们将使用 PyTorch 实现自己的神经风格转移系统。借助预训练的 VGG 模型,我们将使用本节讨论的概念生成艺术风格的图像。我们还将探讨调整各种模型参数对生成图像的内容和纹理/风格的影响。

使用 PyTorch 实现神经风格转移

在讨论了神经风格迁移系统的内部之后,我们已经准备好使用 PyTorch 构建一个系统。作为练习,我们将加载一个风格图像和一个内容图像。然后,我们将加载预训练的 VGG 模型。在定义要计算风格和内容损失的层之后,我们将修剪模型,使其仅保留相关层。最后,我们将训练神经风格迁移模型,逐步改进生成的图像。

加载内容和风格图像

在这个练习中,我们只会展示代码的重要部分以示例。要获取完整的代码,请访问我们的 github 代码库 [8.3] 。请按照以下步骤进行:

  1. 首先,我们需要导入必要的库:
from PIL import Image
import matplotlib.pyplot as pltimport torch
import torch.nn as nn
import torch.optim as optim
import torchvisiondvc = torch.device("cuda" if torch.cuda.is_available() else "cpu")

除了其他库外,我们导入torchvision库以加载预训练的 VGG 模型和其他计算机视觉相关的工具。

  1. 接下来,我们需要一个风格图像和一个内容图像。我们将使用 unsplash 网站 [8.4] 下载这两种图像。这些下载的图像已包含在本书的代码库中。在下面的代码中,我们编写一个函数来将图像加载为张量:
def image_to_tensor(image_filepath,  image_dimension=128):
    img = Image.open(image_filepath).convert('RGB')
    # display image
    …
    torch_transformation =       torchvision.transforms.Compose([
        torchvision.transforms.Resize(img_size),
        torchvision.transforms.ToTensor()
                              ])
    img = torch_transformation(img).unsqueeze(0)
    return img.to(dvc, torch.float)
style_image = image_to_tensor("./images/style.jpg")
content_image =image_to_tensor("./images/content.jpg")

输出应如下所示:

图 8. 5 – 风格和内容图像

图 8. 5 – 风格和内容图像

因此,内容图像是泰姬陵的真实照片,而风格图像是一幅艺术画作。通过风格迁移,我们希望生成一幅艺术性的泰姬陵画作。然而,在此之前,我们需要加载并修剪 VGG19 模型。

加载并修剪预训练的 VGG19 模型

在这部分练习中,我们将使用预训练的 VGG 模型并保留其卷积层。我们将对模型进行一些小的更改,使其适用于神经风格迁移。让我们开始吧:

  1. 我们将首先加载预训练的 VGG19 模型,并使用其卷积层生成内容和风格目标,从而产生内容和风格损失:
vgg19_model = torchvision.models.vgg19(pretrained=True).to(dvc)
print(vgg19_model)

输出应如下所示:

图 8. 6 – VGG19 模型

图 8. 6 – VGG19 模型

  1. 我们不需要线性层;也就是说,我们只需要模型的卷积部分。在前面的代码中,可以通过仅保留模型对象的features属性来实现:
vgg19_model = vgg19_model.features

注意

在这个练习中,我们不会调整 VGG 模型的参数。我们只会调整生成图像的像素,即模型输入端。因此,我们将确保加载的 VGG 模型的参数是固定的。

  1. 我们必须使用以下代码冻结 VGG 模型的参数:
for param in vgg19_model.parameters():
    param.requires_grad_(False)
  1. 现在我们已经加载了 VGG 模型的相关部分,我们需要将maxpool层改为平均池化层,如前面讨论的那样。在此过程中,我们将注意到模型中卷积层的位置:
conv_indices = []for i in range(len(vgg19_model)):
    if vgg19_model[i]._get_name() == 'MaxPool2d':
        vgg19_model[i] =  nn.AvgPool2d(kernel_size=vgg19_model[i].kernel_size,
stride=vgg19_model[i].stride, padding=vgg19_model[i].padding)
    if vgg19_model[i]._get_name() == 'Conv2d':
        conv_indices.append(i)
conv_indices = dict(enumerate(conv_indices, 1))print(vgg19_model)

输出应如下所示:

图 8. 7 – 修改后的 VGG19 模型

图 8. 7 – 修改后的 VGG19 模型

正如我们所看到的,线性层已被移除,并且最大池化层已被替换为平均池化层,如前图中的红色框所示。

在前面的步骤中,我们加载了一个预训练的 VGG 模型,并对其进行了修改,以便将其用作神经风格迁移模型。接下来,我们将把这个修改后的 VGG 模型转换成一个神经风格迁移模型。

构建神经风格迁移模型

此时,我们可以定义希望计算内容和风格损失的卷积层。在原始论文中,风格损失是在前五个卷积层上计算的,而内容损失仅在第四个卷积层上计算。我们将遵循相同的惯例,尽管您可以尝试不同的组合并观察它们对生成图像的影响。请按照以下步骤进行:

  1. 首先,我们列出我们需要在其上进行风格和内容损失的层:
layers = {1: 's', 2: 's', 3: 's', 4: 'sc', 5: 's'}

在这里,我们定义了第一到第五个卷积层,这些层与风格损失相关联,并且第四个卷积层与内容损失相关联。

  1. 现在,让我们删除 VGG 模型中不必要的部分。我们将仅保留它到第五个卷积层,如下所示:
vgg_layers = nn.ModuleList(vgg19_model)
last_layer_idx = conv_indices[max(layers.keys())]
vgg_layers_trimmed = vgg_layers[:last_layer_idx+1]
neural_style_transfer_model = nn.Sequential(*vgg_layers_trimmed)
print(neural_style_transfer_model)

这应该给我们以下输出:

图 8. 8 – 神经风格迁移模型对象

图 8. 8 – 神经风格迁移模型对象

正如我们所看到的,我们已经将具有 16 个卷积层的 VGG 模型转换为具有五个卷积层的神经风格迁移模型。

训练风格迁移模型

在本节中,我们将开始处理将生成的图像。我们可以通过多种方式初始化这个图像,例如使用随机噪声图像或使用内容图像作为初始图像。目前,我们将从随机噪声开始。稍后,我们还将看到使用内容图像作为起点对结果的影响。请按照以下步骤进行:

  1. 下面的代码演示了使用随机数初始化torch张量的过程:
# initialize as the content image
# ip_image = content_image.clone()
# initialize as random noise:
ip_image = torch.randn(content_image.data.size(), device=dvc)
plt.figure()
plt.imshow(ip_image.squeeze(0).cpu().detach().numpy().transpose(1,2,0).clip(0,1));

这应该给我们以下输出:

图 8. 9 – 随机噪声图像

图 8. 9 – 随机噪声图像

  1. 最后,我们可以开始模型训练循环。首先,我们将定义训练的时代数,为风格和内容损失提供的相对权重,并使用学习率为0.1的 Adam 优化器进行基于梯度下降的优化实例化:
num_epochs=180
wt_style=1e6
wt_content=1
style_losses = []
content_losses = []
opt = optim.Adam([ip_image.requires_grad_()], lr=0.1)
  1. 在开始训练循环时,我们在时代开始时将风格和内容损失初始化为零,然后为了数值稳定性将输入图像的像素值剪切在01之间。
for curr_epoch in range(1, num_epochs+1):    
    ip_image.data.clamp_(0, 1)
    opt.zero_grad()
    epoch_style_loss = 0
    epoch_content_loss = 0
  1. 在这个阶段,我们已经达到了训练迭代的关键步骤。在这里,我们必须计算每个预定义的风格和内容卷积层的风格和内容损失。将各自层的单独风格损失和内容损失相加,得到当前时代的总风格和内容损失:
 for k in layers.keys():
        if 'c' in layers[k]:
            target = neural_style_transfer_model[:conv_indices[k]+1](content_image).detach()
            ip = neural_style_transfer_model[:conv_indices[k]+1](ip_image)
            epoch_content_loss += torch.nn.functional.mse_loss(ip, target)
        if 's' in layers[k]:
            target = gram_matrix(neural_style_transfer_model[:conv_indices[k]+1](style_image)).detach()
            ip = gram_matrix(neural_style_transfer_model[:conv_indices[k]+1](ip_image))
            epoch_style_loss += torch.nn.functional.mse_loss(ip, target)

正如前面的代码所示,对于风格和内容损失,首先,我们使用风格和内容图像计算风格和内容目标(地面真值)。我们使用.detach()来表示这些目标不可训练,而只是固定的目标值。接下来,我们根据生成的图像作为输入,在每个风格和内容层计算预测的风格和内容输出。最后,我们计算风格和内容损失。

  1. 关于风格损失,我们还需要使用预定义的 Gram 矩阵函数来计算 Gram 矩阵,如下面的代码所示:
def gram_matrix(ip):
    num_batch, num_channels, height, width = ip.size()
    feats = ip.view(num_batch * num_channels, width *   height)
    gram_mat = torch.mm(feats, feats.t())
    return gram_mat.div(num_batch * num_channels *        width * height)

正如我们之前提到的,我们可以使用torch.mm函数计算内部点积。这将计算 Gram 矩阵并通过特征映射数乘以每个特征映射的宽度和高度来归一化矩阵。

  1. 在我们的训练循环中继续进行,现在我们已经计算出了总风格和内容损失,我们需要计算最终的总损失,作为这两者的加权和,使用我们之前定义的权重:
 epoch_style_loss *= wt_style
    epoch_content_loss *= wt_content
    total_loss = epoch_style_loss + epoch_content_loss
    total_loss.backward()

最后,在每k个时代,我们可以通过查看损失以及查看生成的图像来看到我们训练的进展。以下图表显示了前一个代码的生成风格转移图像的演变,总共记录了 180 个时代,每 20 个时代一次:

图 8. 10 – 神经风格转移逐时代生成的图像

图 8. 10 – 神经风格转移逐时代生成的图像

很明显,模型开始时将风格从风格图像应用于随机噪声。随着训练的进行,内容损失开始发挥作用,从而为风格化图像赋予内容。到第180个时代,我们可以看到生成的图像,看起来像是塔吉马哈尔的艺术绘画的良好近似。以下图表显示了从0180个时代随着时代的推移逐渐减少的风格和内容损失:

图 8. 11 – 风格和内容损失曲线

图 8. 11 – 风格和内容损失曲线

显然,风格损失在最初急剧下降,这也在图 8. 10中有所体现,即初始时期更多地将风格施加在图像上而不是内容。在训练的高级阶段,两种损失逐渐下降,导致风格转移图像,这是风格图像艺术性和以相机拍摄的照片逼真性之间的一个不错的折衷。

对风格转移系统进行实验

在上一节成功训练了样式迁移系统后,我们现在将看看系统如何响应不同的超参数设置。按照以下步骤进行:

  1. 在前一节中,我们将内容权重设置为1,将样式权重设置为1e6。让我们进一步增加样式权重 10 倍,即到1e7,并观察它如何影响样式迁移过程。使用新权重进行 600 个时期的训练后,我们得到了以下样式迁移的进展:

图 8. 12 – 高风格权重的样式迁移时期

图 8. 12 – 高风格权重的样式迁移时期

这里我们可以看到,与之前的情况相比,最初需要更多的时期才能达到合理的结果。更重要的是,较高的样式权重似乎对生成的图像有影响。当我们将前一张图像与图 8. 10中的图像进行比较时,我们发现前者更像图 8. 5中展示的样式图像。

  1. 同样地,将样式权重从1e6减少到1e5会产生更加注重内容的结果,如下图所示:

图 8. 13 – 低风格权重的样式迁移时期

图 8. 13 – 低风格权重的样式迁移时期

与较高样式权重的情况相比,降低样式权重意味着需要更少的时期才能得到看起来合理的结果。生成图像中的样式量要小得多,主要填充了内容图像数据。我们仅对此情况进行了 6 个时期的训练,因为在那之后结果就会饱和。

  1. 最后的改变可能是将生成的图像初始化为内容图像,而不是随机噪声,同时使用原始的样式和内容权重1e61。以下图显示了这种情况下的时期逐步进展:

图 8. 14 – 使用内容图像初始化的样式迁移时期

图 8. 14 – 使用内容图像初始化的样式迁移时期

通过比较前一张图与图 8. 10,我们可以看到,将内容图像作为起点确实为我们得到合理的样式迁移图像提供了不同的进展路径。似乎生成图像上同时施加了内容和样式组件,而不像图 8. 10中那样,先施加样式,然后是内容。以下图表证实了这一假设:

图 8. 15 – 使用内容图像初始化的样式和内容损失曲线

图 8. 15 – 使用内容图像初始化的样式和内容损失曲线

正如我们所看到的,随着训练周期的推进,风格损失和内容损失一起减少,最终朝向饱和状态发展。尽管如此,图 8. 10和 8. 14甚至图 8. 12和 8. 13的最终结果都展示了泰姬陵的合理艺术印象。

我们成功地使用 PyTorch 构建了一个神经风格转移模型,在这个模型中,使用了一个内容图像——泰姬陵的照片——和一个风格图像——一幅画布绘画——我们生成了泰姬陵的一个合理的艺术画作近似。这个应用可以扩展到各种其他组合。交换内容和风格图像也可能产生有趣的结果,并更深入地了解模型的内部工作原理。

鼓励您通过以下方式扩展本章中讨论的练习:

  • 更改风格和内容层列表

  • 使用更大的图像尺寸

  • 尝试更多的风格和内容损失权重组合

  • 使用其他优化器,如 SGD 和 LBFGS

  • 使用不同的学习率进行更长的训练周期,以便观察所有这些方法生成的图像之间的差异

总结

在本章中,我们将生成式机器学习的概念应用于图像,通过生成一幅包含一张图像内容和另一张风格的图像,这被称为神经风格转移的任务。在下一章中,我们将扩展这一范式,我们将拥有一个生成器生成虚假数据,还有一个鉴别器区分虚假数据和真实数据。这样的模型通常被称为生成对抗网络(GANs)。在下一章中,我们将探索深度卷积 GANs(DCGANs)。

第六章:Deep Convolutional GANs

加入我们的书籍社区 Discord

packt.link/EarlyAccessCommunity

img

生成神经网络已成为一个流行且活跃的研究和开发领域。这种趋势的巨大推动归功于我们将在本章讨论的一类模型。这些模型被称为生成对抗网络GANs),并于 2014 年提出。自基本 GAN 模型提出以来,各种类型的 GAN 已被发明并被用于不同的应用场景。

本质上,GAN 由两个神经网络组成 - 一个生成器和一个判别器。让我们看一个用于生成图像的 GAN 的示例。对于这样的 GAN,生成器的任务是生成看起来逼真的假图像,而判别器的任务是区分真实图像和假图像。

在联合优化过程中,生成器最终将学会生成如此逼真的假图像,以至于判别器基本无法将其与真实图像区分开来。一旦训练了这样的模型,其生成器部分就可以作为可靠的数据生成器使用。除了用作无监督学习的生成模型外,GAN 在半监督学习中也被证明是有用的。

在图像示例中,例如,判别器模型学习到的特征可以用来提高基于图像数据训练的分类模型的性能。除了半监督学习,GAN 在强化学习中也被证明是有用的,这是我们将在《深度强化学习第十章》中讨论的一个主题。

本章将重点介绍的一种特定类型的 GAN 是深度卷积 GANDCGAN)。DCGAN 本质上是一个无监督卷积神经网络CNN)模型。DCGAN 中的生成器和判别器都是纯粹的CNN,没有全连接层。DCGAN 在生成逼真图像方面表现良好,可以作为学习如何从头开始构建、训练和运行 GAN 的良好起点。

在本章中,我们首先会了解 GAN 内部的各个组件 - 生成器和判别器模型以及联合优化计划。然后,我们将专注于使用 PyTorch 构建 DCGAN 模型。接下来,我们将使用图像数据集来训练和测试 DCGAN 模型的性能。最后,我们将回顾图像的风格转移概念,并探索 Pix2Pix GAN 模型,该模型可以高效地在任意给定的图像对上执行风格转移。

我们还将学习 Pix2Pix GAN 模型的各个组件与 DCGAN 模型的关系。完成本章后,我们将真正理解 GAN 的工作原理,并能够使用 PyTorch 构建任何类型的 GAN 模型。本章分为以下几个主题:

  • 定义生成器和鉴别器网络

  • 使用 PyTorch 训练 DCGAN

  • 使用 GAN 进行风格转移

定义生成器和鉴别器网络

如前所述,GAN 由两个组件组成 – 生成器和鉴别器。这两者本质上都是神经网络。具有不同神经架构的生成器和鉴别器会产生不同类型的 GAN。例如,DCGAN 纯粹将 CNN 作为生成器和鉴别器。您可以在 [9.1] 处找到包含它们 PyTorch 实现的不同类型的 GAN 清单。

对于任何用于生成某种真实数据的 GAN,生成器通常以随机噪声作为输入,并生成与真实数据相同维度的输出。我们称这个生成的输出为假数据。鉴别器则作为二元分类器运作。它接受生成的假数据和真实数据(一个接一个地)作为输入,并预测输入数据是真实的还是假的。图 9 .1 显示了整体 GAN 模型框图:

图 9 .1 – GAN 框图

图 9 .1 – GAN 框图

鉴别器网络像任何二元分类器一样进行优化,即使用二元交叉熵函数。因此,鉴别器模型的目标是正确地将真实图像分类为真实的,将假图像分类为假的。生成器网络具有相反的动机。生成器损失在数学上表示为-log(D(G(x))),其中x是输入到生成器模型G中的随机噪声,G(x)是生成的假图像,D(G(x))是鉴别器模型D的输出概率,即图像为真实的概率。

因此,当鉴别器认为生成的假图像是真实的时,生成器损失最小化。本质上,在这个联合优化问题中,生成器试图欺骗鉴别器。

在执行过程中,这两个损失函数是交替反向传播的。也就是说,在训练的每个迭代中,首先冻结鉴别器,然后通过反向传播生成器损失的梯度来优化生成器网络的参数。

然后,调整好的生成器被冻结,同时通过反向传播鉴别器损失来优化鉴别器。这就是我们所说的联合优化。在原始 GAN 论文中也被称为等效于双人最小最大游戏 [9.2] 。

理解 DCGAN 生成器和鉴别器

对于特定的 DCGAN 情况,让我们看看生成器和鉴别器模型架构是什么样的。如前所述,它们都是纯卷积模型。图 9 .2 展示了 DCGAN 的生成器模型架构:

图 9 .2 – DCGAN 生成器模型架构

图 9 .2 – DCGAN 生成器模型架构

首先,大小为64的随机噪声输入向量被重塑并投影到大小为16x16128个特征图中。这个投影是通过线性层实现的。然后,一系列上采样和卷积层接连而来。第一个上采样层简单地使用最近邻上采样策略将16x16特征图转换为32x32特征图。

然后是一个 2D 卷积层,卷积核大小为3x3,输出128个特征图。这个卷积层输出的12832x32特征图进一步上采样为64x64大小的特征图,然后是两个 2D 卷积层,生成(伪造的)RGB 图像大小为64x64

注意

我们省略了批量归一化和泄漏 ReLU 层,以避免在前面的架构表示中混乱。下一节的 PyTorch 代码将详细说明和解释这些细节。

现在我们知道生成器模型的样子,让我们看看鉴别器模型的样子。图 9.3 展示了鉴别器模型的架构:

图 9 .3 – DCGAN 鉴别器模型架构

图 9 .3 – DCGAN 鉴别器模型架构

正如您所见,在这个架构中,每个卷积层的步幅设置为2有助于减少空间维度,而深度(即特征图的数量)不断增加。这是一种经典的基于 CNN 的二进制分类架构,用于区分真实图像和生成的伪造图像。

理解了生成器和鉴别器网络的架构之后,我们现在可以根据图 9.1 的示意图构建整个 DCGAN 模型,并在图像数据集上训练 DCGAN 模型。

在接下来的部分,我们将使用 PyTorch 完成这个任务。我们将详细讨论 DCGAN 模型的实例化,加载图像数据集,联合训练 DCGAN 生成器和鉴别器,并从训练后的 DCGAN 生成器生成样本假图像。

使用 PyTorch 训练 DCGAN

在本节中,我们将通过一个练习构建、训练和测试一个 PyTorch 中的 DCGAN 模型。我们将使用一个图像数据集来训练模型,并测试训练后的 DCGAN 模型的生成器在生成伪造图像时的性能。

定义生成器

在下一个练习中,我们只展示代码的重要部分以进行演示。要访问完整的代码,您可以参考我们的 github 仓库 [9.3] :

  1. 首先,我们需要import所需的库,如下所示:
import os
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import DataLoader
from torch.autograd import Variable
import torchvision.transforms as transforms
from torchvision.utils import save_image
from torchvision import datasets

在这个练习中,我们只需要torchtorchvision来构建 DCGAN 模型。

  1. 导入库后,我们指定了一些模型超参数,如下所示的代码:
num_eps=10
bsize=32
lrate=0.001
lat_dimension=64
image_sz=64
chnls=1
logging_intv=200

我们将使用批大小为32和学习率为0.001来训练模型10个时期。预期的图像大小为64x64x3lat_dimension是随机噪声向量的长度,这意味着我们将从一个64维的潜在空间中提取随机噪声作为生成模型的输入。

  1. 现在我们定义生成器模型对象。以下代码与图 9 .2中显示的架构直接一致:
class GANGenerator(nn.Module):
    def __init__(self):
        super(GANGenerator, self).__init__()
        self.inp_sz = image_sz // 4
        self.lin =   nn.Sequential(nn.Linear(lat_dimension, 128 * self.inp_sz ** 2))
        self.bn1 = nn.BatchNorm2d(128)
        self.up1 = nn.Upsample(scale_factor=2)
        self.cn1 = nn.Conv2d(128, 128, 3, stride=1, padding=1)
        self.bn2 = nn.BatchNorm2d(128, 0.8)
        self.rl1 = nn.LeakyReLU(0.2, inplace=True)
        self.up2 = nn.Upsample(scale_factor=2)
        self.cn2 = nn.Conv2d(128, 64, 3, stride=1, padding=1)
        self.bn3 = nn.BatchNorm2d(64, 0.8)
        self.rl2 = nn.LeakyReLU(0.2, inplace=True)
        self.cn3 = nn.Conv2d(64, chnls, 3, stride=1, padding=1)
        self.act = nn.Tanh()
  1. 在定义 _init_ 方法之后,我们定义了 forward 方法,本质上只是按顺序调用层:
 def forward(self, x):
        x = self.lin(x)
        x = x.view(x.shape[0], 128, self.inp_sz, self.inp_sz)
        x = self.bn1(x)
        x = self.up1(x)
        x = self.cn1(x)
        x = self.bn2(x)
        x = self.rl1(x)
        x = self.up2(x)
        x = self.cn2(x)
        x = self.bn3(x)
        x = self.rl2(x)
        x = self.cn3(x)
        out = self.act(x)
        return out

在这个练习中,我们使用了逐层显式定义而不是 nn.Sequential 方法;这是因为如果模型出现问题,逐层定义可以更容易调试。我们还可以看到代码中的批量归一化和泄漏的 ReLU 层,这些在图 9 .2中没有提到。

FAQ - 为什么我们使用批量归一化?

批量归一化用于在线性或卷积层之后,既加快训练过程,又减少对初始网络权重的敏感性。

FAQ - 为什么我们使用泄漏的 ReLU?

ReLU 可能会对带有负值输入的信息丢失。设置斜率为 0.2 的泄漏 ReLU 可以给入射的负信息赋予 20% 的权重,这有助于我们在训练 GAN 模型时避免梯度消失。

接下来,我们将查看 PyTorch 代码来定义判别器网络。

定义判别器

类似生成器,我们现在将定义判别器模型如下:

  1. 再次强调,以下代码是 PyTorch 中显示的图 9 .3模型架构的等效代码:
class GANDiscriminator(nn.Module):
    def __init__(self):
        super(GANDiscriminator, self).__init__()
        def disc_module(ip_chnls, op_chnls, bnorm=True):
            mod = [nn.Conv2d(ip_chnls, op_chnls, 3, 2, 1), nn.LeakyReLU(0.2, inplace=True),
                   nn.Dropout2d(0.25)] if bnorm:
                mod += [nn.BatchNorm2d(op_chnls, 0.8)]
            return mod
        self.disc_model = nn.Sequential(
            *disc_module(chnls, 16, bnorm=False),
            *disc_module(16, 32),
            *disc_module(32, 64),
            *disc_module(64, 128),
        )
        # width and height of the down-sized image
        ds_size = image_sz // 2 ** 4
        self.adverse_lyr = nn.Sequential(nn.Linear(128 * ds_size ** 2, 1), nn.Sigmoid())

首先,我们定义了一个通用的判别器模块,它是一个级联的卷积层、可选的批量归一化层、泄漏的 ReLU 层和一个 dropout 层。为了构建判别器模型,我们依次重复这个模块四次——每次使用不同的卷积层参数集。

目标是输入一张 64x64x3 的 RGB 图像,并通过卷积层增加深度(即通道数),同时减少图像的高度和宽度。

最终的判别器模块的输出被展平,并通过对抗层传递。实质上,对抗层将展平的表示完全连接到最终模型输出(即二进制输出)。然后,将该模型输出通过 Sigmoid 激活函数传递,以给出图像为真实图像的概率(或非假图像)。

  1. 下面是判别器的 forward 方法,它将一张 64x64 的 RGB 图像作为输入,并产生它是真实图像的概率:
 def forward(self, x):
        x = self.disc_model(x)
        x = x.view(x.shape[0], -1)
        out = self.adverse_lyr(x)
        return out
  1. 定义了生成器和判别器模型之后,我们现在可以实例化每个模型。我们还将对抗损失函数定义为以下代码中的二元交叉熵损失函数:
# instantiate the discriminator and generator models
gen = GANGenerator()
disc = GANDiscriminator()
# define the loss metric
adv_loss_func = torch.nn.BCELoss()

对抗损失函数将用于定义后续训练循环中生成器和鉴别器损失函数。从概念上讲,我们使用二元交叉熵作为损失函数,因为目标基本上是二进制的——即真实图像或假图像。而二元交叉熵损失是二元分类任务的合适损失函数。

加载图像数据集

对于训练 DCGAN 以生成看起来逼真的假图像的任务,我们将使用著名的 MNIST 数据集,该数据集包含从 0 到 9 的手写数字图像。通过使用 torchvision.datasets,我们可以直接下载 MNIST 数据集,并创建一个 dataset 和一个 dataloader 实例:

# define the dataset and corresponding dataloader
dloader = torch.utils.data.DataLoader(
    datasets.MNIST(
        "./data/mnist/", download=True,
        transform=transforms.Compose(
            [transforms.Resize((image_sz, image_sz)),
             transforms.ToTensor(), transforms.Normalize([0.5], [0.5])]),), batch_size=bsize, shuffle=True,)

这里是 MNIST 数据集中真实图像的示例:

图 9. 4 – MNIST 数据集中的真实图像

图 9. 4 – MNIST 数据集中的真实图像

数据集引用

[LeCun et al., 1998a] Y. LeCun, L. Bottou, Y. Bengio, and P. Haffner. "基于梯度的学习应用于文档识别." 《IEEE 会议录》,86(11):2278-2324,1999 年 11 月。

Yann LeCun(纽约大学库兰特研究所)和 Corinna Cortes(Google 实验室,纽约)拥有 MNIST 数据集的版权,这是原始 NIST 数据集的一个衍生作品。MNIST 数据集根据知识共享署名-相同方式共享 3.0 许可证的条款提供。

到目前为止,我们已经定义了模型架构和数据管道。现在是时候实际编写 DCGAN 模型训练程序了,我们将在下一节中进行。

DCGAN 的训练循环

在本节中,我们将训练 DCGAN 模型:

  1. 定义优化计划:在开始训练循环之前,我们将为生成器和鉴别器定义优化计划。我们将使用 Adam 优化器来优化我们的模型。在原始 DCGAN 论文 [9. 4] 中,Adam 优化器的 beta1beta2 参数设置为 0.50.999,而不是通常的 0.90.999

在我们的练习中,我们保留了 0.90.999 的默认值。但是,您可以使用论文中提到的确切数值以获得类似的结果:

# define the optimization schedule for both G and D
opt_gen = torch.optim.Adam(gen.parameters(), lr=lrate)
opt_disc = torch.optim.Adam(disc.parameters(), lr=lrate)
  1. 训练生成器:最后,我们现在可以运行训练循环来训练 DCGAN。由于我们将联合训练生成器和鉴别器,训练过程将包括以下两个步骤——训练生成器模型和训练鉴别器模型——交替进行。我们将从训练生成器开始,如下代码所示:
os.makedirs("./images_mnist", exist_ok=True)
for ep in range(num_eps):
    for idx, (images, _) in enumerate(dloader):
        # generate ground truths for real and fake images
        good_img = Variable(torch.FloatTensor(images.shape[0], 1).fill_(1.0), requires_grad=False)
        bad_img = Variable(torch.FloatTensor(images.shape[0], 1) .fill_(0.0), requires_grad=False)
        # get a real image
        actual_images = Variable(images.type(torch.FloatTensor))
        # train the generator model
        opt_gen.zero_grad()
        # generate a batch of images based on random noise as input
        noise = Variable(torch.FloatTensor(np.random.normal(0, 1, (images.shape[0], lat_dimension))))
        gen_images = gen(noise)
        # generator model optimization - how well can it fool the discriminator
        generator_loss = adv_loss_func(disc(gen_images), good_img)
        generator_loss.backward()
        opt_gen.step()

在上述代码中,我们首先生成了真实和假图像的地面真值标签。真实图像标记为 1,假图像标记为 0。这些标签将作为鉴别器模型的目标输出,该模型是一个二元分类器。

接下来,我们从 MINST 数据集加载器中加载一批真实图像,并使用生成器生成一批使用随机噪声作为输入的假图像。

最后,我们将生成器的损失定义为以下两者之间的对抗损失:

i) 生成器模型生成的假图像被鉴别器模型预测为真实的概率。

ii) 目标值为1

基本上,如果鉴别器被愚弄成将生成的假图像视为真实图像,那么生成器在其角色上就成功了,生成器损失将很低。一旦我们制定了生成器损失,我们就可以使用它来沿着生成器模型反向传播梯度,以调整其参数。

在生成器模型的前述优化步骤中,我们保持鉴别器模型参数不变,并且仅仅使用鉴别器模型进行一次前向传播。

  1. 训练鉴别器:接下来,我们将执行相反操作,即保留生成器模型的参数并训练鉴别器模型:
 # train the discriminator model
        opt_disc.zero_grad()
        # calculate discriminator loss as average of mistakes(losses) in confusing real images as fake and vice versa
        actual_image_loss = adv_loss_func(disc(actual_images), good_img)
        fake_image_loss = adv_loss_func(disc(gen_images.detach()), bad_img)
        discriminator_loss = (actual_image_loss + fake_image_loss) / 2
        # discriminator model optimization
        discriminator_loss.backward()
        opt_disc.step()
        batches_completed = ep * len(dloader) + idx
        if batches_completed % logging_intv == 0:
            print(f"epoch number {ep} | batch number {idx} | generator loss = {generator_loss.item()} \
            | discriminator loss = {discriminator_loss.item()}")
            save_image(gen_images.data[:25], f"images_mnist/{batches_completed}.png", nrow=5, normalize=True)

请记住我们有一批真实和假图像。为了训练鉴别器模型,我们将需要这两者。我们简单地将鉴别器损失定义为对抗损失或二元交叉熵损失,就像我们对任何二元分类器一样。

我们计算真实图像和假图像批次的鉴别器损失,保持真实图像批次的目标值为1,假图像批次的目标值为0。然后我们使用这两个损失的均值作为最终的鉴别器损失,并用它来反向传播梯度以调整鉴别器模型参数。

每隔几个 epoch 和批次后,我们记录模型的性能结果,即生成器损失和鉴别器损失。对于前述代码,我们应该得到类似以下的输出:

图 9. 5 – DCGAN 训练日志

图 9. 5 – DCGAN 训练日志

注意损失如何波动一点;这通常在训练 GAN 模型期间由于联合训练机制的对抗性质而发生。除了输出日志外,我们还定期保存一些网络生成的图像。图 9. 6 展示了这些生成图像在前几个 epoch 中的进展:

图 9. 6 – DCGAN 逐 epoch 生成图像

图 9. 6 – DCGAN 逐 epoch 生成图像

如果将后续 epoch 的结果与图 9. 4 中的原始 MNIST 图像进行比较,可以看出 DCGAN 已经相当好地学会如何生成看起来逼真的手写数字的假图像。

就是这样了。我们已经学会了如何使用 PyTorch 从头开始构建 DCGAN 模型。原始 DCGAN 论文中有一些微妙的细节,比如生成器和鉴别器模型的层参数的正常初始化,使用 Adam 优化器的特定 beta1beta2 值,等等。出于关注 GAN 代码主要部分的兴趣,我们省略了其中一些细节。鼓励你包含这些细节并查看其如何改变结果。

此外,我们在练习中只使用了 MNIST 数据库。然而,我们可以使用任何图像数据集来训练 DCGAN 模型。鼓励你尝试在其他图像数据集上使用这个模型。用于 DCGAN 训练的一种流行的图像数据集是名人面孔数据集 [9. 5] 。

使用这种模型训练的 DCGAN 可以生成不存在的名人脸部。ThisPersonDoesntExist [9. 6] 就是这样一个项目,它生成了不存在的人类面孔。鬼魅吗?是的。这就是 DCGAN 和总体上的 GAN 的强大之处。还要感谢 PyTorch,现在我们可以用几行代码构建自己的 GAN。

在本章的下一节,我们将超越 DCGAN,简要介绍另一种 GAN 类型——pix2pix 模型。pix2pix 模型可以用于在图像中推广风格转移的任务,更一般地说,是图像到图像的翻译任务。我们将讨论 pix2pix 模型的架构、其生成器和鉴别器,并使用 PyTorch 定义生成器和鉴别器模型。我们还将比较 Pix2Pix 与 DCGAN 在架构和实现方面的不同。

使用 GAN 进行风格转移

到目前为止,我们只详细讨论了 DCGAN。虽然已经存在数百种不同类型的 GAN 模型,并且还有更多正在开发中,但一些著名的 GAN 模型包括以下几种:

  • GAN

  • DCGAN

  • Pix2Pix

  • CycleGAN

  • 超分辨率 GAN(SRGAN)

  • 上下文编码器

  • 文本到图像

  • 最小二乘 GAN(LSGAN)

  • SoftmaxGAN

  • Wasserstein GAN

每种 GAN 变体的不同之处在于它们服务的应用程序,它们的基础模型架构,或者由于一些优化策略的调整,例如修改损失函数。例如,SRGAN 用于增强低分辨率图像的分辨率。CycleGAN 使用两个生成器而不是一个,并且生成器由类似 ResNet 的块组成。LSGAN 使用均方误差作为鉴别器损失函数,而不是大多数 GAN 中使用的交叉熵损失。

不可能在一章甚至一本书中讨论所有这些 GAN 变体。然而,在本节中,我们将探索另一种与前一节讨论的 DCGAN 模型和第八章“神经风格转移”中讨论的神经风格转移模型相关的 GAN 模型。

这种特殊类型的 GAN 推广了图像之间的风格转移任务,并提供了一个通用的图像到图像的翻译框架。它被称为Pix2Pix,我们将简要探讨其架构以及其生成器和鉴别器组件的 PyTorch 实现。

理解 pix2pix 的架构

第八章神经风格转移中,您可能会记得一个完全训练好的神经风格转移模型只能在给定的一对图像上工作。Pix2Pix 是一个更通用的模型,一旦成功训练,可以在任意一对图像之间进行风格转移。事实上,该模型不仅限于风格转移,还可以用于任何图像到图像的翻译应用,如背景掩蔽、调色板补充等。

实质上,Pix2Pix 的工作原理与任何 GAN 模型相似。涉及到一个生成器和一个鉴别器。与接收随机噪声并生成图像不同的是,如图 9. 1所示,pix2pix模型中的生成器接收真实图像作为输入,并尝试生成该图像的翻译版本。如果任务是风格转移,那么生成器将尝试生成风格转移后的图像。

随后,鉴别器现在查看一对图像而不是仅仅单个图像,就像图 9. 1中的情况一样。真实图像及其等效的翻译图像被作为输入馈送到鉴别器。如果翻译图像是真实的,那么鉴别器应该输出1,如果翻译图像是由生成器生成的,则鉴别器应该输出0图 9. 7显示了pix2pix模型的示意图:

图 9. 7 – Pix2Pix 模型示意图

图 9. 7 – Pix2Pix 模型示意图

图 9. 7显示与图 9. 1有显著相似之处,这意味着其基本思想与常规 GAN 相同。唯一的区别在于,鉴别器的真假问题是针对一对图像而不是单个图像提出的。

探索 Pix2Pix 生成器

pix2pix模型中使用的生成器子模型是用于图像分割的著名 CNN——UNet图 9. 8展示了 UNet 的架构,它被用作pix2pix模型的生成器:

图 9. 8 – Pix2Pix 生成器模型架构

图 9. 8 – Pix2Pix 生成器模型架构

首先,UNet 的名称来源于网络的U形状,正如前面的图表所显示的。该网络有两个主要组成部分,如下所示:

  • 从左上角到底部是网络的编码器部分,它将256x256的 RGB 输入图像编码成大小为512的特征向量。

  • 从右上角到底部是网络的解码器部分,它从大小为512的嵌入向量生成图像。

UNet 的一个关键特性是跳跃连接,即来自编码器部分到解码器部分的特征串联(沿深度维度),如 图 9.8 中的虚线箭头所示。

FAQ - 为什么 U-Net 中有编码器-解码器跳跃连接?

使用编码器部分的特征帮助解码器在每个上采样步骤中更好地定位高分辨率信息。

本质上,编码器部分是一系列下卷积块,其中每个下卷积块本身是一系列 2D 卷积层、实例归一化层和渗漏的 ReLU 激活。类似地,解码器部分包括一系列上卷积块,其中每个块是一系列 2D 转置卷积层、实例归一化层和 ReLU 激活层。

这个 UNet 生成器架构的最后部分是一个基于最近邻的上采样层,随后是一个 2D 卷积层,最后是一个 tanh 激活。现在让我们来看看 UNet 生成器的 PyTorch 代码:

  1. 这是定义基于 UNet 的生成器架构的等效 PyTorch 代码:
class UNetGenerator(nn.Module):
    def __init__(self, chnls_in=3, chnls_op=3):
        super(UNetGenerator, self).__init__()
        self.down_conv_layer_1 = DownConvBlock(chnls_in, 64, norm=False)
        self.down_conv_layer_2 = DownConvBlock(64, 128)
        self.down_conv_layer_3 = DownConvBlock(128, 256)
        self.down_conv_layer_4 = DownConvBlock(256, 512, dropout=0.5)
        self.down_conv_layer_5 = DownConvBlock(512, 512, dropout=0.5)
        self.down_conv_layer_6 = DownConvBlock(512, 512, dropout=0.5)
        self.down_conv_layer_7 = DownConvBlock(512, 512, dropout=0.5)
        self.down_conv_layer_8 = DownConvBlock(512, 512, norm=False, dropout=0.5)
        self.up_conv_layer_1 = UpConvBlock(512, 512, dropout=0.5)
        self.up_conv_layer_2 = UpConvBlock(1024, 512, dropout=0.5)
        self.up_conv_layer_3 = UpConvBlock(1024, 512, dropout=0.5)
        self.up_conv_layer_4 = UpConvBlock(1024, 512, dropout=0.5)
        self.up_conv_layer_5 = UpConvBlock(1024, 256)
        self.up_conv_layer_6 = UpConvBlock(512, 128)
        self.up_conv_layer_7 = UpConvBlock(256, 64)
        self.upsample_layer = nn.Upsample(scale_factor=2)
        self.zero_pad = nn.ZeroPad2d((1, 0, 1, 0))
        self.conv_layer_1 = nn.Conv2d(128, chnls_op, 4, padding=1)
        self.activation = nn.Tanh()

正如您所看到的,有 8 个下卷积层和 7 个上卷积层。上卷积层有两个输入,一个来自前一个上卷积层的输出,另一个来自等效的下卷积层的输出,如 图 9. 7 中所示的虚线所示。

  1. 我们使用了 UpConvBlockDownConvBlock 类来定义 UNet 模型的层。以下是这些块的定义,从 UpConvBlock 类开始:
class UpConvBlock(nn.Module):
    def __init__(self, ip_sz, op_sz, dropout=0.0):
        super(UpConvBlock, self).__init__()
        self.layers = [
            nn.ConvTranspose2d(ip_sz, op_sz, 4, 2, 1),
            nn.InstanceNorm2d(op_sz), nn.ReLU(),]
        if dropout:
            self.layers += [nn.Dropout(dropout)]
    def forward(self, x, enc_ip):
        x = nn.Sequential(*(self.layers))(x)
        op = torch.cat((x, enc_ip), 1)
        return op

在这个上卷积块中的转置卷积层由一个步幅为 2 步的 4x4 核组成,这在输出空间维度上将其输出与输入相比几乎增加了一倍。

在这个转置卷积层中,4x4 核通过输入图像的每隔一个像素(由于步幅为 2)传递。在每个像素处,像素值与 4x4 核中的每个 16 个值相乘。

在整个图像中,核乘法结果的重叠值然后相加,导致输出长度和宽度是输入图像的两倍。此外,在前述的 forward 方法中,拼接操作是在通过上卷积块的前向传递完成之后执行的。

  1. 接下来,这里是定义 DownConvBlock 类的 PyTorch 代码:
class DownConvBlock(nn.Module):
    def __init__(self, ip_sz, op_sz, norm=True, dropout=0.0):
        super(DownConvBlock, self).__init__()
        self.layers = [nn.Conv2d(ip_sz, op_sz, 4, 2, 1)]
        if norm:
            self.layers.append(nn.InstanceNorm2d(op_sz))
        self.layers += [nn.LeakyReLU(0.2)]
        if dropout:
            self.layers += [nn.Dropout(dropout)]
    def forward(self, x):
        op = nn.Sequential(*(self.layers))(x)
        return op

下卷积块内的卷积层具有 4x4 大小的核,步幅为 2,并且激活了填充。因为步幅值为 2,所以此层的输出是其输入的空间尺寸的一半。

为了处理类似 DCGANs 中的负输入问题,还使用了一个渗漏的 ReLU 激活,这也有助于缓解消失梯度问题。

到目前为止,我们已经看到了基于 UNet 的生成器的 __init__ 方法。接下来的 forward 方法非常简单:

 def forward(self, x):
        enc1 = self.down_conv_layer_1(x)
        enc2 = self.down_conv_layer_2(enc1)
        enc3 = self.down_conv_layer_3(enc2)
        enc4 = self.down_conv_layer_4(enc3)
        enc5 = self.down_conv_layer_5(enc4)
        enc6 = self.down_conv_layer_6(enc5)
        enc7 = self.down_conv_layer_7(enc6)
        enc8 = self.down_conv_layer_8(enc7)
        dec1 = self.up_conv_layer_1(enc8, enc7)
        dec2 = self.up_conv_layer_2(dec1, enc6)
        dec3 = self.up_conv_layer_3(dec2, enc5)
        dec4 = self.up_conv_layer_4(dec3, enc4)
        dec5 = self.up_conv_layer_5(dec4, enc3)
        dec6 = self.up_conv_layer_6(dec5, enc2)
        dec7 = self.up_conv_layer_7(dec6, enc1)
        final = self.upsample_layer(dec7)
        final = self.zero_pad(final)
        final = self.conv_layer_1(final)
        return self.activation(final)

在讨论了 pix2pix 模型的生成器部分之后,让我们也来看看判别器模型。

探索 Pix2Pix 判别器

在这种情况下,判别器模型也是一个二元分类器,就像 DCGAN 一样。唯一的区别是,这个二元分类器接受两个图像作为输入。两个输入沿深度维度连接。图 9. 9 展示了判别器模型的高级架构:

图 9. 9 – Pix2Pix 判别器模型架构

图 9. 9 – Pix2Pix 判别器模型架构

这是一个 CNN 模型,最后的 3 个卷积层后跟一个归一化层以及一个泄漏 ReLU 激活函数。定义这个判别器模型的 PyTorch 代码如下:

class Pix2PixDiscriminator(nn.Module):
    def __init__(self, chnls_in=3):
        super(Pix2PixDiscriminator, self).__init__()
        def disc_conv_block(chnls_in, chnls_op, norm=1):
            layers = [nn.Conv2d(chnls_in, chnls_op, 4, stride=2, padding=1)]
            if normalization:
                layers.append(nn.InstanceNorm2d(chnls_op))
            layers.append(nn.LeakyReLU(0.2, inplace=True))
            return layers
        self.lyr1 = disc_conv_block(chnls_in * 2, 64, norm=0)
        self.lyr2 = disc_conv_block(64, 128)
        self.lyr3 = disc_conv_block(128, 256)
        self.lyr4 = disc_conv_block(256, 512)

正如您所见,第 4 个卷积层在每个步骤后会加倍空间表示的深度。第 234 层在卷积层后添加了归一化层,并且每个卷积块的末尾应用了泄漏 ReLU 激活,负斜率为 20%。最后,这是判别器模型在 PyTorch 中的 forward 方法:

 def forward(self, real_image, translated_image):
        ip = torch.cat((real_image, translated_image), 1)
        op = self.lyr1(ip)
        op = self.lyr2(op)
        op = self.lyr3(op)
        op = self.lyr4(op)
        op = nn.ZeroPad2d((1, 0, 1, 0))(op)
        op = nn.Conv2d(512, 1, 4, padding=1)(op)
        return op

首先,输入图像被连接并通过四个卷积块传递,最终进入一个单一的二进制输出,告诉我们图像对的真假概率(即由生成器模型生成的)。这样,在运行时训练 pix2pix 模型,使得生成器可以接受任何图像作为输入,并应用其在训练期间学到的图像翻译函数。

如果 pix2pix 模型生成的伪翻译图像很难与原始图像的真实翻译版本区分开来,则认为 pix2pix 模型是成功的。

这结束了我们对 pix2pix 模型的探索。原则上,Pix2Pix 的整体模型框图与 DCGAN 模型非常相似。这两个模型的判别器网络都是基于 CNN 的二元分类器。而 pix2pix 模型的生成器网络则是受 UNet 图像分割模型启发的稍微复杂一些的架构。

总的来说,我们已经成功地使用 PyTorch 定义了 DCGAN 和 Pix2Pix 的生成器和判别器模型,并理解了这两个 GAN 变体的内部工作原理。

完成本节后,您应该能够开始编写其他许多 GAN 变体的 PyTorch 代码。使用 PyTorch 构建和训练各种 GAN 模型可以是一次很好的学习经验,而且肯定是一个有趣的练习。我们鼓励您使用本章的信息来开展自己的 GAN 项目。

总结

近年来,生成对抗网络(GANs)已成为研究和开发的活跃领域,自其 2014 年问世以来如此。本章探讨了 GANs 背后的概念,包括 GANs 的组成部分,即生成器和鉴别器。我们讨论了每个组件的架构以及 GAN 模型的整体框图。

在下一章中,我们将进一步探索生成模型的研究。我们将探讨如何使用尖端深度学习技术从文本生成图像。

第十一章:深度强化学习

在我们的书籍社区 Discord 上加入我们

packt.link/EarlyAccessCommunity

img

机器学习通常分为三种不同的范式:监督学习无监督学习强化学习RL)。监督学习需要标记数据,迄今为止一直是最受欢迎的机器学习范式。然而,基于无监督学习的应用,即不需要标签的学习,近年来稳步增长,尤其是生成模型的形式。

另一方面,RL 是机器学习的一个不同分支,被认为是我们迄今为止模拟人类学习方式最接近的分支。这是一个积极研究和开发的领域,处于早期阶段,已取得一些有 promising 的结果。一个著名的例子是由 Google 的 DeepMind 构建的 AlphaGo 模型,它击败了世界顶级围棋选手。

在监督学习中,我们通常向模型提供原子输入输出数据对,并希望模型学习将输出作为输入的函数。而在 RL 中,我们不关心学习这种单个输入到单个输出的函数。相反,我们感兴趣的是学习一种策略(或政策),使我们能够从输入(状态)开始采取一系列步骤(或行动),以获取最终输出或实现最终目标。

查看照片并决定它是猫还是狗是一个原子输入输出学习任务,可以通过监督学习解决。然而,查看棋盘并决定下一步如何走以达到赢得比赛的目标则需要策略,我们需要 RL 来处理这类复杂任务。

在前几章中,我们遇到了监督学习的例子,比如使用 MNIST 数据集构建分类器对手写数字进行分类。我们还在构建文本生成模型时探索了无监督学习,使用了一个无标签的文本语料库。

在本章中,我们将揭示 RL 和深度强化学习(DRL)的一些基本概念。然后,我们将专注于一种特定且流行的 DRL 模型 - 深度 Q-learning 网络(DQN)模型。使用 PyTorch,我们将构建一个 DRL 应用程序。我们将训练一个 DQN 模型来学习如何与计算机对手(bot)玩乒乓球游戏。

在本章结束时,您将具备开始在 PyTorch 上进行自己的 DRL 项目所需的所有背景知识。此外,您还将亲自体验在真实问题上构建 DQN 模型的经验。本章中您将获得的技能对处理其他 RL 问题也将非常有用。

本章内容分为以下几个主题:

  • 回顾强化学习概念

  • 讨论 Q-learning

  • 理解深度 Q-learning

  • 在 PyTorch 中构建 DQN 模型

回顾强化学习概念

在某种意义上,RL 可以被定义为从错误中学习。与监督学习中每个数据实例都得到反馈的情况不同,RL 在一系列行动之后接收反馈。以下图表显示了 RL 系统的高级示意图:

图 11. 1 – 强化学习示意图

图 11. 1 – 强化学习示意图

在 RL 设置中,通常有一个代理进行学习。代理学习如何根据这些决策做出决定并采取行动。代理操作在一个提供的环境中。这个环境可以被视为一个有限的世界,在这个世界中,代理生活、采取行动并从其行动中学习。在这里,行动就是代理基于其所学内容做出决策的实施。

我们前面提到,与监督学习不同,RL 对于每个输入并不都有一个输出;也就是说,代理不一定会为每个动作都接收到反馈。相反,代理在状态中工作。假设它从初始状态S0 开始。然后它执行一个动作,比如a0。这个动作将代理的状态从S0 转换到S1,之后代理执行另一个动作a1,循环进行。

偶尔,代理根据其状态接收奖励。代理遍历的状态和行动序列也称为轨迹。假设代理在状态S2 收到奖励。在这种情况下,导致该奖励的轨迹将是S0, a0, S1, a1, S2。

奖励可以是正的也可以是负的。

基于奖励,代理学习调整其行为,以便以最大化长期奖励的方式采取行动。这就是 RL 的本质。代理根据给定的状态和奖励学习一种如何行动最优化的策略。

这种学习到的策略,基本上是行动作为状态和奖励的函数表达,被称为代理的策略。RL 的最终目标是计算一个策略,使代理能够始终从其所处的情况中获得最大奖励。

视频游戏是展示 RL 的最佳例子之一。让我们以视频游戏《乒乓球》的虚拟版本——Pong 为例。以下是该游戏的快照:

图 11. 2 – 乒乓视频游戏

图 11. 2 – 乒乓视频游戏

考虑右侧的玩家是代理,用一个短竖线表示。请注意,这里有一个明确定义的环境。环境包括玩区,用棕色像素表示。环境还包括一个球,用白色像素表示。除此之外,环境还包括玩区边界,用灰色条纹和球可能反弹的边缘表示。最后,而且最重要的是,环境包括一个对手,看起来像代理,但位于左侧,与代理相对。

通常,在强化学习设置中,代理在任何给定状态下有一组有限的可能动作,称为离散动作空间(与连续动作空间相对)。在这个例子中,代理在所有状态下有两种可能的动作 - 向上移动或向下移动,但有两个例外。首先,它只能在处于最上位置(状态)时向下移动,其次,它只能在处于最下位置(状态)时向上移动。

在这种情况下,奖励的概念可以直接映射到实际乒乓球比赛中发生的情况。如果你未能击中球,你的对手会得分。首先得到 21 分的人赢得比赛并获得正奖励。输掉比赛意味着负奖励。得分或失分也会导致较小的中间正奖励和负奖励。从 0-0 的得分到任一玩家得分 21 分的玩法序列被称为一个episode

使用强化学习训练我们的代理玩乒乓球游戏相当于从头开始训练某人打乒乓球。训练会产生一个政策,代理在游戏中遵循这个政策。在任何给定的情况下 - 包括球的位置、对手的位置、记分牌以及先前的奖励 - 训练良好的代理会向上或向下移动,以最大化其赢得比赛的机会。

到目前为止,我们已通过提供一个例子来讨论强化学习背后的基本概念。在这样做的过程中,我们反复提到了策略、政策和学习等术语。但是代理实际上是如何学习策略的呢?答案是通过一个基于预定义算法的强化学习模型。接下来,我们将探讨不同类型的强化学习算法。

强化学习算法的类型

在本节中,我们将按照文献中的分类来查看强化学习算法的类型。然后我们将探索这些类型中的一些子类型。广义上讲,强化学习算法可以分为以下两种类型之一:

  • 基于模型

  • 无模型

让我们逐一看看这些。

基于模型

正如名称所示,在基于模型的算法中,智能体了解环境的模型。这里的模型指的是一个数学公式,可用于估计奖励以及环境中状态的转移方式。因为智能体对环境有一定的了解,这有助于减少选择下一步行动的样本空间。这有助于学习过程的效率。

然而,在现实中,建模环境大多数情况下并不直接可用。尽管如此,如果我们想使用基于模型的方法,我们需要让智能体通过自身经验学习环境模型。在这种情况下,智能体很可能会学习到模型的偏见表达,并在真实环境中表现不佳。因此,基于模型的方法在实施强化学习系统时使用较少。在本书中,我们将不详细讨论基于这种方法的模型,但这里有一些示例:

  • 基于模型的深度强化学习与无模型微调MBMF)。

  • 基于模型的价值估计MBVE)用于高效的无模型强化学习。

  • 想象增强智能体I2A)用于深度强化学习。

  • AlphaZero,这位著名的 AI 机器人击败了国际象棋和围棋冠军。

现在,让我们看看另一组采用不同哲学的强化学习算法。

无模型

无模型方法在没有环境模型的情况下运作,目前在强化学习研究与开发中更为流行。在无模型强化学习设置中,主要有两种训练智能体的方法:

  • 政策优化

  • Q 学习

政策优化

在这种方法中,我们将政策制定为一个关于行动的函数形式,给定当前状态,如以下方程所示:

– 方程式 11.1

– 方程式 11.1

在这里,β代表这个函数的内部参数,通过梯度上升更新以优化政策函数。目标函数使用政策函数和奖励定义。在某些情况下,可能会使用目标函数的近似来进行优化过程。此外,在某些情况下,可能会使用政策函数的近似来代替实际的政策函数进行优化过程。

通常,在这种方法下进行的优化是在政策内的,这意味着参数是基于使用最新政策版本收集的数据进行更新的。一些基于政策优化的强化学习算法的示例如下:

  • 政策梯度:这是最基本的政策优化方法,我们直接使用梯度上升优化政策函数。政策函数输出在每个时间步骤下采取不同行动的概率。

  • 演员-批评家:由于在政策梯度算法下的优化是基于政策的,算法的每次迭代都需要更新政策。这需要很长时间。演员-批评家方法引入了值函数和政策函数的使用。演员模拟政策函数,批评家模拟值函数。

通过使用批评者,策略更新过程变得更快。我们将在下一节更详细地讨论价值函数。然而,本书不会深入讨论演员-批评家方法的数学细节。

  • 信任区域策略优化 (TRPO): 类似于策略梯度方法,TRPO 包含一个基于政策的优化方法。在策略梯度方法中,我们使用梯度来更新政策函数参数 β。由于梯度是一阶导数,对于函数中的尖锐曲率可能会产生噪声。这可能导致我们进行大幅度的政策更改,从而可能不稳定代理的学习轨迹。

为了避免这种情况,TRPO 提出了信任区域。它定义了政策在给定更新步骤中可以改变的上限。这确保了优化过程的稳定性。

  • 近端策略优化 (PPO): 类似于 TRPO,PPO 旨在稳定优化过程。在策略梯度方法中,每个数据样本都会进行梯度上升更新。然而,PPO 使用了一个替代的目标函数,可以在数据样本批次上进行更新。这导致更加保守地估计梯度,从而提高了梯度上升算法收敛的可能性。

策略优化函数直接工作于优化策略,因此这些算法非常直观。然而,由于这些算法大多是基于政策的,每次更新政策后都需要重新对数据进行采样。这可能成为解决 RL 问题的限制因素。接下来,我们将讨论另一种更加样本高效的无模型算法,称为 Q 学习。

Q 学习

与策略优化算法相反,Q 学习依赖于值函数而不是策略函数。从这一点开始,本章将重点讨论 Q 学习。我们将在下一节详细探讨 Q 学习的基础知识。

讨论 Q 学习

策略优化和 Q 学习之间的关键区别在于,后者并没有直接优化策略。相反,我们优化一个值函数。什么是值函数?我们已经学到 RL 的关键在于代理学习如何在经过一系列状态和动作的轨迹时获得最大的总奖励。值函数是一个关于当前代理所处状态的函数,其输出为代理在当前回合结束时将获得的预期奖励总和。

在 Q-learning 中,我们优化一种特定类型的值函数,称为动作值函数,它取决于当前状态和动作。在给定状态 S,动作值函数确定代理程序将为采取动作 a 而获得的长期奖励(直到结束的奖励)。此函数通常表示为 Q(S, a),因此也称为 Q 函数。动作值也称为Q 值

每个(状态,动作)对的 Q 值可以存储在一个表中,其中两个维度分别是状态和动作。例如,如果有四个可能的状态 S1、S2、S3 和 S4,并且有两种可能的动作 a1 和 a2,那么这八个 Q 值将存储在一个 4x2 的表中。因此,Q-learning 的目标是创建这个 Q 值表。一旦表格可用,代理程序可以查找给定状态的所有可能动作的 Q 值,并采取具有最大 Q 值的动作。但问题是,我们从哪里获取 Q 值?答案在于贝尔曼方程,其数学表达如下:

– 方程 11.2

– 方程 11.2

贝尔曼方程是计算 Q 值的递归方式。在此方程中,R 是在状态 St 采取动作 at 后获得的奖励,而 γ(gamma)是折扣因子,它是一个介于 01 之间的标量值。基本上,这个方程表明当前状态 St 和动作 at 的 Q 值等于在状态 St 采取动作 at 后获得的奖励 R,加上从下一个状态 St+1 采取的最优动作 at+1 的 Q 值,乘以折扣因子。折扣因子定义了在即时奖励与长期未来奖励之间给予多大的权重。

现在我们已经定义了 Q-learning 的大部分基础概念,让我们通过一个示例来演示 Q-learning 的工作原理。以下图示展示了一个包含五个可能状态的环境:

图 11. 3 – Q-learning 示例环境

图 11. 3 – Q-learning 示例环境

有两种不同的可能动作 – 向上移动(a1)或向下移动(a2)。在不同状态下有不同的奖励,从状态 S4 的 +2 到状态 S0 的 -1。每个环境中的一轮从状态 S2 开始,并以 S0 或 S4 结束。由于有五个状态和两种可能的动作,Q 值可以存储在一个 5x2 的表中。以下代码片段展示了如何在 Python 中编写奖励和 Q 值:

rwrds = [-1, 0, 0, 0, 2]
Qvals = [[0.0, 0.0],
         [0.0, 0.0],
         [0.0, 0.0],
         [0.0, 0.0],
         [0.0, 0.0]]

我们将所有的 Q 值初始化为零。此外,由于有两个特定的结束状态,我们需要以列表的形式指定这些状态,如下所示:

end_states = [1, 0, 0, 0, 1]

这基本上表明状态 S0 和 S4 是终止状态。在运行完整的 Q 学习循环之前,我们还需要查看一个最后的部分。在 Q 学习的每一步,代理有两种选择下一步行动的选项:

  • 选择具有最高 Q 值的动作。

  • 随机选择下一个动作。

为什么代理程序会随机选择一个动作?

记住,在第七章,使用 PyTorch 进行音乐和文本生成中,文本生成部分,我们讨论了贪婪搜索或波束搜索导致重复结果的问题,因此引入随机性有助于产生更好的结果。同样地,如果代理程序总是基于 Q 值选择下一步动作,那么它可能会陷入重复选择立即高奖励的动作的子优化条件。因此,偶尔随机采取行动将有助于代理程序摆脱这种次优条件。

现在我们已经确定代理在每一步都有两种可能的行动方式,我们需要决定代理选择哪种方式。这就是epsilon-greedy-action机制发挥作用的地方。下图展示了它的工作原理:

图 11. 4 – Epsilon-greedy-action 机制

图 11. 4 – Epsilon-greedy-action 机制

在此机制下,每个周期中预先决定一个 epsilon 值,它是一个介于 01 之间的标量值。在给定的周期内,对于每次选择下一个动作,代理生成一个介于 01 之间的随机数。如果生成的数字小于预定义的 epsilon 值,则代理随机从可用的下一个动作集中选择下一个动作。否则,从 Q 值表中检索每个可能的下一个动作的 Q 值,并选择具有最高 Q 值的动作。epsilon-greedy-action 机制的 Python 代码如下:

def eps_greedy_action_mechanism(eps, S):
  rnd = np.random.uniform()
  if rnd < eps:
    return np.random.randint(0, 2)
  else:
    return np.argmax(Qvals[S])

通常情况下,我们在第一个周期以 1 的 epsilon 值开始,然后随着周期的进展线性减少它。这里的想法是,我们希望代理程序最初探索不同的选项。然而,随着学习过程的进行,代理程序对收集短期奖励不那么敏感,因此它可以更好地利用 Q 值表。

现在我们可以编写主要的 Q 学习循环的 Python 代码,如下所示:

n_epsds = 100
eps = 1
gamma = 0.9
for e in range(n_epsds):
  S_initial = 2 # start with state S2
  S = S_initial
  while not end_states[S]:
    a = eps_greedy_action_mechanism(eps, S)
    R, S_next = take_action(S, a)
    if end_states[S_next]:
      Qvals[S][a] = R
    else:
      Qvals[S][a] = R + gamma * max(Qvals[S_next])
    S = S_next
  eps = eps - 1/n_epsds

首先,我们确定代理程序将被训练 100 个周期。我们从 epsilon 值为 1 开始,并定义折扣因子(gamma)为 0.9。接下来,我们运行 Q 学习循环,该循环遍历周期数。在此循环的每次迭代中,我们通过整个周期运行。在周期内,我们首先将代理的状态初始化为 S2

接着,我们运行另一个内部循环,仅在代理达到结束状态时中断。在这个内部循环中,我们使用ε-贪婪动作机制为代理决定下一步动作。代理然后执行该动作,转移代理到一个新状态,并可能获得奖励。take_action函数的实现如下:

def take_action(S, a):
  if a == 0: # move up
    S_next = S - 1
  else:
    S_next = S + 1
  return rwrds[S_next], S_next

一旦我们获得奖励和下一个状态,我们使用方程 11.2 更新当前状态-动作对的 Q 值。下一个状态现在成为当前状态,过程重复进行。在每个 episode 结束时,ε值线性减小。一旦整个 Q 学习循环结束,我们获得一个 Q 值表。这个表基本上是代理在这个环境中操作所需的一切,以获得最大的长期奖励。

理想情况下,针对这个示例训练良好的代理总是向下移动,以获得S4 处的最大奖励+2,并避免向S0 移动,该位置含有-1的负奖励。

这完成了我们关于 Q 学习的讨论。前面的代码应该帮助您在提供的简单环境中开始使用 Q 学习。对于视频游戏等更复杂和现实的环境,这种方法将不起作用。为什么呢?

我们注意到,Q 学习的本质在于创建 Q 值表。在我们的示例中,只有 5 个状态和 2 个动作,因此表的大小为 10,这是可以管理的。但是在如 Pong 等视频游戏中,可能的状态太多了。这导致 Q 值表的大小爆炸增长,使得我们的 Q 学习算法极其占用内存且不可实际运行。

幸运的是,有一个解决方案,可以在不使我们的机器内存不足的情况下仍然使用 Q 学习的概念。这个解决方案将 Q 学习和深度神经网络的世界结合起来,提供了极其流行的 RL 算法,被称为DQN。在下一节中,我们将讨论 DQN 的基础知识和一些其新颖的特性。

理解深度 Q 学习

DQN不创建一个 Q 值表,而是使用一个深度神经网络DNN),该网络为给定的状态-动作对输出一个 Q 值。DQN 在诸如视频游戏之类的复杂环境中使用,这些环境中的状态太多,无法在 Q 值表中管理。视频游戏的当前图像帧用来表示当前状态,并与当前动作一起作为输入传递给底层 DNN 模型。

DNN 为每个这样的输入输出一个标量 Q 值。在实践中,与其仅传递当前图像帧不如将给定时间窗口内的N个相邻图像帧作为输入传递给模型。

我们正在使用一个深度神经网络来解决强化学习(RL)问题。这引发了一个固有的问题。在使用深度神经网络时,我们始终使用独立同分布(iid)的数据样本。然而,在强化学习中,每一个当前输出都会影响到下一个输入。例如,在 Q-learning 中,贝尔曼方程本身表明,Q 值依赖于另一个 Q 值;也就是说,下一个状态-动作对的 Q 值影响了当前状态-动作对的 Q 值。

这意味着我们在处理一个不断移动的目标,并且目标与输入之间有很高的相关性。DQN 通过两个新特性来解决这些问题:

  • 使用两个单独的深度神经网络(DNNs)

  • 经验重放缓冲区

让我们更详细地看一下这些内容。

使用两个单独的深度神经网络(DNNs)

让我们重新写 DQNs 的贝尔曼方程:

– 方程 11.3

– 方程 11.3

这个方程大部分与 Q-learning 的方程相同,只是引入了一个新术语,img(θ)。img代表了 DQN 模型用于获取 Q 值的 DNN 的权重。但是这个方程有些奇怪。注意到img被放在了方程的左边和右边。这意味着在每一步中,我们使用同一个神经网络来获取当前状态-动作对和下一个状态-动作对的 Q 值。这意味着我们在追踪一个非静态目标,因为每一步,img都会被更新,这将改变下一步的方程的左边和右边,导致学习过程中的不稳定性。

通过查看损失函数,可以更清楚地看到这一点。神经网络将试图使用梯度下降来最小化损失函数。损失函数如下:

– 方程 11.4

– 方程 11.4

暂且将R(奖励)放在一边,对于同一个网络生成当前和下一个状态-动作对的 Q 值将导致损失函数的波动性增加。为了解决这个问题,DQN 使用两个独立的网络——一个主 DNN 和一个目标 DNN。两个 DNN 具有完全相同的架构。

主要的 DNN 用于计算当前状态-动作对的 Q 值,而目标 DNN 用于计算下一个(或目标)状态-动作对的 Q 值。然而,虽然主网络的权重在每一次学习步骤中都会更新,目标网络的权重却是冻结的。每经过K次梯度下降迭代,主网络的权重被复制到目标网络。这种机制保持了训练过程的相对稳定性。权重复制机制确保了来自目标网络的准确预测。

经验重放缓冲区

因为 DNN 期望的输入是 iid 数据,我们只需将视频游戏的最后X个步骤(帧)缓存在一个缓冲区内,然后从缓冲区中随机抽样数据批次。这些批次然后作为 DNN 的输入。因为批次由随机抽样的数据组成,其分布看起来类似于 iid 数据样本的分布。这有助于稳定 DNN 训练过程。

注意

如果没有缓冲区技巧,DNN 将接收到相关的数据,这将导致优化结果不佳。

这两个技巧在贡献 DQN 成功方面已被证明非常重要。现在我们对 DQN 模型的工作原理和其新颖特性有了基本了解,让我们继续本章的最后一节,我们将实现自己的 DQN 模型。使用 PyTorch,我们将构建一个基于 CNN 的 DQN 模型,该模型将学习玩名为 Pong 的 Atari 视频游戏,并可能学会击败电脑对手。

在 PyTorch 中构建 DQN 模型

我们在前一节讨论了 DQN 背后的理论。在这一节中,我们将采取实际操作的方式。使用 PyTorch,我们将构建一个基于 CNN 的 DQN 模型,该模型将训练一个代理人玩称为 Pong 的视频游戏。这个练习的目标是展示如何使用 PyTorch 开发强化学习应用程序。让我们直接进入练习。

初始化主要和目标 CNN 模型

在这个练习中,我们仅展示代码的重要部分以演示目的。要访问完整代码,请访问我们的 github 仓库 [11.1]。请按照以下步骤进行:

  1. 首先,我们需要导入必要的库:
# general imports
import cv2
import math
import numpy as np
import random
# reinforcement learning related imports
import re
import atari_py as ap
from collections import deque
from gym import make, ObservationWrapper, Wrapper
from gym.spaces import Box
# pytorch imports
import torch
import torch.nn as nn
from torch import save
from torch.optim import Adam

在这个练习中,除了常规的与 Python 和 PyTorch 相关的导入之外,我们还使用了一个名为gym的 Python 库。这是 OpenAI [11.2]开发的一个 Python 库,提供了一套用于构建强化学习应用的工具。基本上,导入gym库消除了为 RL 系统的内部编写所有支撑代码的需要。它还包括一些内置的环境,包括一个用于视频游戏 Pong 的环境,在这个练习中我们将使用它。

  1. 导入库后,我们必须为 DQN 模型定义 CNN 架构。这个 CNN 模型主要接受当前状态输入,并输出所有可能动作的概率分布。代理人选择具有最高概率的动作作为下一个动作。与使用回归模型预测每个状态-动作对的 Q 值不同,我们巧妙地将其转换为分类问题。

Q 值回归模型将必须单独运行所有可能的动作,并且我们将选择预测 Q 值最高的动作。但是使用这个分类模型将计算 Q 值和预测最佳下一个动作的任务合并为一个:

class ConvDQN(nn.Module):
    def __init__(self, ip_sz, tot_num_acts):
        super(ConvDQN, self).__init__()
        self._ip_sz = ip_sz
        self._tot_num_acts = tot_num_acts
        self.cnv1 = nn.Conv2d(ip_sz[0], 32, kernel_size=8, stride=4)
        self.rl = nn.ReLU()
        self.cnv2 = nn.Conv2d(32, 64, kernel_size=4, stride=2)
        self.cnv3 = nn.Conv2d(64, 64, kernel_size=3, stride=1)
        self.fc1 = nn.Linear(self.feat_sz, 512)
        self.fc2 = nn.Linear(512, tot_num_acts)

正如我们所见,模型由三个卷积层cnv1cnv2cnv3组成,它们之间有 ReLU 激活函数,并跟随两个全连接层。现在,让我们看看通过该模型的前向传播包含哪些内容:

 def forward(self, x):
        op = self.cnv1(x)
        op = self.rl(op)
        op = self.cnv2(op)
        op = self.rl(op)
        op = self.cnv3(op)
        op = self.rl(op).view(x.size()[0], -1)
        op = self.fc1(op)
        op = self.rl(op)
        op = self.fc2(op)
        return op

forward 方法简单地演示了模型的前向传播,其中输入通过卷积层,展平,最后馈送到全连接层。最后,让我们看看其他模型方法:

 @property
    def feat_sz(self):
        x = torch.zeros(1, *self._ip_sz)
        x = self.cnv1(x)
        x = self.rl(x)
        x = self.cnv2(x)
        x = self.rl(x)
        x = self.cnv3(x)
        x = self.rl(x)
        return x.view(1, -1).size(1)
    def perf_action(self, stt, eps, dvc):
        if random.random() > eps:
            stt=torch.from_numpy(np.float32(stt)).unsqueeze(0).to(dvc)
            q_val = self.forward(stt)
            act = q_val.max(1)[1].item()
        else:
            act = random.randrange(self._tot_num_acts)
        return act

在上述代码片段中,feat_size 方法只是用于计算将最后一个卷积层输出展平后的特征向量大小。最后,perf_action 方法与我们之前在讨论 Q 学习部分讨论的 take_action 方法相同。

  1. 在这一步中,我们定义一个函数,实例化主神经网络和目标神经网络:
def models_init(env, dvc):
    mdl = ConvDQN(env.observation_space.shape, env.action_space.n).to(dvc)
    tgt_mdl = ConvDQN(env.observation_space.shape, env.action_space.n).to(dvc)
    return mdl, tgt_mdl

这两个模型是同一类的实例,因此共享相同的架构。然而,它们是两个独立的实例,因此将随不同的权重集合而有所不同。

定义经验重播缓冲区

正如我们在理解深度 Q 学习部分讨论的那样,经验重播缓冲区是 DQN 的一个重要特性。借助该缓冲区,我们可以存储几千个游戏转换(帧),然后随机采样这些视频帧来训练 CNN 模型。以下是定义重播缓冲区的代码:

class RepBfr:
    def __init__(self, cap_max):
        self._bfr = deque(maxlen=cap_max)
    def push(self, st, act, rwd, nxt_st, fin):
        self._bfr.append((st, act, rwd, nxt_st, fin))
    def smpl(self, bch_sz):
        idxs = np.random.choice(len(self._bfr), bch_sz, False)
        bch = zip(*[self._bfr[i] for i in idxs])
        st, act, rwd, nxt_st, fin = bch
        return (np.array(st), np.array(act), np.array(rwd,      dtype=np.float32),np.array(nxt_st), np.array(fin, dtype=np.uint8))
    def __len__(self):
        return len(self._bfr)

在这里,cap_max 是定义的缓冲区大小;即,将存储在缓冲区中的视频游戏状态转换数量。smpl 方法在 CNN 训练循环中用于采样存储的转换并生成训练数据批次。

设置环境

到目前为止,我们主要关注于 DQN 的神经网络方面。在本节中,我们将专注于构建 RL 问题的基础方面之一 - 环境。请按照以下步骤进行操作:

  1. 首先,我们必须定义一些与视频游戏环境初始化相关的函数:
def gym_to_atari_format(gym_env):
    ...
def check_atari_env(env):
    ...

使用 gym 库,我们可以访问预先构建的 Pong 视频游戏环境。但在这里,我们将通过一系列步骤增强环境,包括降低视频游戏图像帧率,将图像帧推送到经验重播缓冲区,将图像转换为 PyTorch 张量等。

  1. 以下是实现每个环境控制步骤的定义类:
class CCtrl(Wrapper):
    ...
class FrmDwSmpl(ObservationWrapper):
    ...
class MaxNSkpEnv(Wrapper):
    ...
class FrRstEnv(Wrapper):
    ...
class FrmBfr(ObservationWrapper):
    ...
class Img2Trch(ObservationWrapper):
    ...
class NormFlts(ObservationWrapper):
    ... 

这些类现在将用于初始化和增强视频游戏环境。

  1. 一旦定义了与环境相关的类,我们必须定义一个最终方法,该方法将原始 Pong 视频游戏环境作为输入,并增强环境,如下所示:
def wrap_env(env_ip):
    env = make(env_ip)
    is_atari = check_atari_env(env_ip)
    env = CCtrl(env, is_atari)
    env = MaxNSkpEnv(env, is_atari)
    try:
        env_acts = env.unwrapped.get_action_meanings()
        if "FIRE" in env_acts:
            env = FrRstEnv(env)
    except AttributeError:
        pass
    env = FrmDwSmpl(env)
    env = Img2Trch(env)
    env = FrmBfr(env, 4)
    env = NormFlts(env)
    return env

在这一步中的部分代码已经省略,因为我们的重点是这个练习中的 PyTorch 方面。请参考本书的 GitHub 仓库 [11.3] 获取完整的代码。

定义 CNN 优化函数

在本节中,我们将定义用于训练我们的深度强化学习模型的损失函数,并定义每个模型训练迭代结束时需要执行的操作。按照以下步骤进行:

  1. 我们在“初始化主神经网络和目标神经网络”部分的“步骤 2”中初始化了我们的主要和目标 CNN 模型。现在我们已经定义了模型架构,我们将定义损失函数,该函数将被训练以最小化:
def calc_temp_diff_loss(mdl, tgt_mdl, bch, gm, dvc):
    st, act, rwd, nxt_st, fin = bch        st = torch.from_numpy(np.float32(st)).to(dvc)
    nxt_st =      torch.from_numpy(np.float32(nxt_st)).to(dvc)
    act = torch.from_numpy(act).to(dvc)
    rwd = torch.from_numpy(rwd).to(dvc)
    fin = torch.from_numpy(fin).to(dvc)     q_vals = mdl(st)
    nxt_q_vals = tgt_mdl(nxt_st)        q_val = q_vals.gather(1, act.unsqueeze(-1)).squeeze(-1)
    nxt_q_val = nxt_q_vals.max(1)[0]
    exp_q_val = rwd + gm * nxt_q_val * (1 - fin)        loss = (q_val -exp_q_val.data.to(dvc)).pow(2).   mean()
    loss.backward()

此处定义的损失函数源自方程 11.4。此损失称为时间差异损失,是 DQN 的基础概念之一。

  1. 现在神经网络架构和损失函数已经就位,我们将定义模型“更新”函数,该函数在神经网络训练的每次迭代时调用:
def upd_grph(mdl, tgt_mdl, opt, rpl_bfr, dvc, log):
    if len(rpl_bfr) > INIT_LEARN:
        if not log.idx % TGT_UPD_FRQ:
            tgt_mdl.load_state_dict(mdl.state_dict())
        opt.zero_grad()
        bch = rpl_bfr.smpl(B_S)
        calc_temp_diff_loss(mdl, tgt_mdl, bch, G, dvc)
        opt.step()

此函数从经验重播缓冲区中抽取一批数据,计算这批数据的时间差损失,并在每 TGT_UPD_FRQ 次迭代时将主神经网络的权重复制到目标神经网络中。TGT_UPD_FRQ 将在稍后分配一个值。

管理和运行剧集

现在,让我们学习如何定义 epsilon 值:

  1. 首先,我们将定义一个函数,该函数将在每个剧集后更新 epsilon 值:
def upd_eps(epd):
    last_eps = EPS_FINL
    first_eps = EPS_STRT
    eps_decay = EPS_DECAY
    eps = last_eps + (first_eps - last_eps) * math.exp(-1 * ((epd + 1) / eps_decay))
    return eps

此函数与我们在“讨论 Q-learning”部分中讨论的 Q-learning 循环中的 epsilon 更新步骤相同。该函数的目标是按剧集线性减少 epsilon 值。

  1. 下一个函数是定义剧集结束时发生的情况。如果当前剧集中得分的总奖励是迄今为止我们取得的最佳成绩,我们会保存 CNN 模型的权重并打印奖励值:
def fin_epsd(mdl, env, log, epd_rwd, epd, eps):
    bst_so_far = log.upd_rwds(epd_rwd)
    if bst_so_far:
        print(f"checkpointing current model weights. highest running_average_reward of\
{round(log.bst_avg, 3)} achieved!")
        save(mdl.state_dict(), f"{env}.dat")
    print(f"episode_num {epd}, curr_reward: {epd_rwd},       best_reward: {log.bst_rwd},\running_avg_reward: {round(log.avg, 3)}, curr_epsilon: {round(eps, 4)}")

每个剧集结束时,我们还会记录剧集编号、剧集结束时的奖励、过去几个剧集奖励值的滚动平均值,以及当前的 epsilon 值。

  1. 我们终于到达了本练习中最关键的函数定义之一。在这里,我们必须指定 DQN 循环。这是我们定义在一个剧集中执行的步骤:
def run_epsd(env, mdl, tgt_mdl, opt, rpl_bfr, dvc, log, epd):
    epd_rwd = 0.0
    st = env.reset()
    while True:
        eps = upd_eps(log.idx)
        act = mdl.perf_action(st, eps, dvc)
        env.render()
        nxt_st, rwd, fin, _ = env.step(act)
        rpl_bfr.push(st, act, rwd, nxt_st, fin)
        st = nxt_st
        epd_rwd += rwd
        log.upd_idx()
        upd_grph(mdl, tgt_mdl, opt, rpl_bfr, dvc, log)
        if fin:
            fin_epsd(mdl, ENV, log, epd_rwd, epd, eps)
            break

奖励和状态会在每个剧集开始时重置。然后,我们运行一个无限循环,只有当代理达到其中一个终止状态时才会退出。在这个循环中,每次迭代执行以下步骤:

i) 首先,按线性折旧方案修改 epsilon 值。

ii) 下一个动作由主 CNN 模型预测。执行此动作会导致下一个状态和一个奖励。这个状态转换被记录在经验重播缓冲区中。

iii) 接下来的状态现在成为当前状态,并计算时间差异损失,用于更新主 CNN 模型,同时保持目标 CNN 模型冻结。

iv) 如果新的当前状态是一个终止状态,那么我们中断循环(即结束剧集),并记录本剧集的结果。

  1. 我们在整个训练过程中提到了记录结果。为了存储围绕奖励和模型性能的各种指标,我们必须定义一个训练元数据类,其中将包含各种指标作为属性:
class TrMetadata:
    def __init__(self):
        self._avg = 0.0
        self._bst_rwd = -float("inf")
        self._bst_avg = -float("inf")
        self._rwds = []
        self._avg_rng = 100
        self._idx = 0

我们将使用这些指标稍后在这个练习中可视化模型性能,一旦我们训练完模型。

  1. 我们在上一步中将模型度量属性存储为私有成员,并公开它们相应的获取函数:
 @property
    def bst_rwd(self):
        ...
    @property
    def bst_avg(self):
        ...
    @property
    def avg(self):
        ...
    @property
    def idx(self):
        ...
    ...

idx 属性对于决定何时从主 CNN 复制权重到目标 CNN 非常关键,而 avg 属性对于计算过去几集收到的奖励的运行平均值非常有用。

训练 DQN 模型以学习 Pong

现在,我们拥有开始训练 DQN 模型所需的所有必要组件。让我们开始吧:

  1. 下面是一个训练包装函数,它将做我们需要做的一切:
def train(env, mdl, tgt_mdl, opt, rpl_bfr, dvc):
    log = TrMetadata()
    for epd in range(N_EPDS):
        run_epsd(env, mdl, tgt_mdl, opt, rpl_bfr, dvc, log, epd)

本质上,我们初始化了一个记录器,只需运行预定义数量的情节的 DQN 训练系统。

  1. 在我们实际运行训练循环之前,我们需要定义以下超参数值:

i) 每次梯度下降迭代的批量大小,用于调整 CNN 模型

ii) 环境,本例中是 Pong 游戏

iii) 第一集的 epsilon 值

iv) 最后一集的 epsilon 值

v) epsilon 值的折旧率

vi) Gamma;即折现因子

vii) 最初仅用于向回放缓冲区推送数据的迭代次数

viii) 学习率

ix) 经验回放缓冲区的大小或容量

x) 训练代理程序的总集数

xi) 多少次迭代后,我们从主 CNN 复制权重到目标 CNN

我们可以在下面的代码中实例化所有这些超参数:

B_S = 64
ENV = "Pong-v4"
EPS_STRT = 1.0
EPS_FINL = 0.005
EPS_DECAY = 100000
G = 0.99
INIT_LEARN = 10000
LR = 1e-4
MEM_CAP = 20000
N_EPDS = 2000
TGT_UPD_FRQ = 1000

这些值是实验性的,我鼓励您尝试更改它们并观察对结果的影响。

  1. 这是练习的最后一步,也是我们实际执行 DQN 训练例程的地方,如下所示:

i) 首先,我们实例化游戏环境。

ii) 然后,我们定义训练将在其上进行的设备 – 根据可用性为 CPU 或 GPU。

iii) 接下来,我们实例化主 CNN 模型和目标 CNN 模型。我们还将 Adam 定义为 CNN 模型的优化器。

iv) 然后,我们实例化经验回放缓冲区。

v) 最后,我们开始训练主 CNN 模型。一旦训练例程完成,我们关闭实例化的环境。

代码如下所示:

env = wrap_env(ENV)
dvc = torch.device("cuda") if torch.cuda.is_available() else torch.device("cpu")
mdl, tgt_mdl = models_init(env, dvc)
opt = Adam(mdl.parameters(), lr=LR)
rpl_bfr = RepBfr(MEM_CAP)
train(env, mdl, tgt_mdl, opt, rpl_bfr, dvc)
env.close()

这应该给我们以下输出:

图 11. 5 – DQN 训练日志

图 11. 5 – DQN 训练日志

此外,下图显示了当前奖励、最佳奖励和平均奖励的进展,以及 epsilon 值与情节进展的关系:

图 11. 6 – DQN 训练曲线

图 11. 6 – DQN 训练曲线

下图显示了在训练过程中 epsilon 值随着回合数的减少情况:

图 11. 7 – 回合内的 epsilon 变化

图 11. 7 – 回合内的 epsilon 变化

注意,在图 11. 6中,一个回合内奖励的运行平均值(红色曲线)从-20开始,这是代理在游戏中得0分而对手得20分的情况。随着回合的进行,平均奖励不断增加,到第1500回合时,越过了零点标记。这意味着在经过1500回合的训练后,代理已经超越了对手。

从这里开始,平均奖励变为正值,这表明代理平均上在对手手上占有优势。我们仅仅训练了2000回合,代理已经以超过7分的平均分数优势击败了对手。我鼓励你延长训练时间,看看代理是否能够始终得分,并以20分的优势击败对手。

这就结束了我们对 DQN 模型实现的深入挖掘。DQN 在强化学习领域取得了巨大成功并广受欢迎,绝对是探索该领域的一个很好的起点。PyTorch 与 gym 库一起,为我们在各种 RL 环境中工作和处理不同类型的 DRL 模型提供了极大的帮助。

在本章中,我们只关注了 DQNs,但我们所学到的经验可以应用到其他变体的 Q 学习模型和其他深度强化学习算法中。

摘要

强化学习是机器学习的一个基础分支,目前是研究和开发中最热门的领域之一。像谷歌 DeepMind 的 AlphaGo 这样基于 RL 的 AI 突破进一步增加了人们对这一领域的热情和兴趣。本章概述了强化学习和深度强化学习,并通过使用 PyTorch 构建 DQN 模型的实际练习带领我们深入探讨。

强化学习是一个广泛的领域,一章篇幅远远不够覆盖所有内容。我鼓励你利用本章的高层次讨论去探索这些细节。从下一章开始,我们将专注于使用 PyTorch 处理实际工作中的各个方面,比如模型部署、并行化训练、自动化机器学习等等。在下一章中,我们将讨论如何有效地使用 PyTorch 将训练好的模型投入生产系统。

第十三章:将 PyTorch 模型操作化为生产环境

加入我们的书籍社区 Discord

packt.link/EarlyAccessCommunity

img

到目前为止,在本书中,我们已经介绍了如何使用 PyTorch 训练和测试不同类型的机器学习模型。我们首先回顾了 PyTorch 的基本元素,使我们能够高效地处理深度学习任务。然后,我们探索了使用 PyTorch 编写的广泛的深度学习模型架构和应用程序。

本章中,我们将重点讨论将这些模型投入生产环境的过程。但这意味着什么呢?基本上,我们将讨论不同的方式,将经过训练和测试的模型(对象)带入一个独立的环境,使其能够对输入数据进行预测或推断。这就是所谓的模型生产化,因为模型正在部署到生产系统中。

我们将从讨论一些常见的方法开始,这些方法可以用来在生产环境中服务 PyTorch 模型,从定义简单的模型推断函数开始,一直到使用模型微服务。然后,我们将介绍 TorchServe,这是一个可扩展的 PyTorch 模型服务框架,由 AWS 和 Facebook 联合开发。

然后,我们将深入探讨使用TorchScript导出 PyTorch 模型的世界,通过序列化使我们的模型独立于 Python 生态系统,从而可以在基于C++的环境中加载,例如。我们还将超越 Torch 框架和 Python 生态系统,探索ONNX - 一种用于机器学习模型的开放源代码通用格式,这将帮助我们将 PyTorch 训练的模型导出到非 PyTorch 和非 Python 环境中。

最后,我们将简要讨论如何使用 PyTorch 在一些著名的云平台(如亚马逊网络服务AWS)、Google CloudMicrosoft Azure)上提供模型服务。

在本章中,我们将使用我们在《第一章,使用 PyTorch 进行深度学习概述》中训练的手写数字图像分类卷积神经网络CNN)模型作为参考。我们将演示如何使用本章讨论的不同方法部署和导出该训练过的模型。

本章分为以下几个部分:

  • PyTorch 中的模型服务

  • 使用 TorchServe 提供 PyTorch 模型服务

  • 使用 TorchScript 和 ONNX 导出通用 PyTorch 模型

  • 在云中提供 PyTorch 模型服务

PyTorch 中的模型服务

在本节中,我们将开始构建一个简单的 PyTorch 推断管道,该管道可以在给定一些输入数据和先前训练和保存的 PyTorch 模型的位置的情况下进行预测。之后,我们将把这个推断管道放在一个模型服务器上,该服务器可以监听传入的数据请求并返回预测结果。最后,我们将从开发模型服务器进阶到使用 Docker 创建模型微服务。

创建 PyTorch 模型推断管道

我们将继续在第一章,PyTorch 使用深度学习的概述中构建的手写数字图像分类 CNN 模型上进行工作,使用MNIST数据集。利用这个训练过的模型,我们将构建一个推断管道,能够为给定的手写数字输入图像预测 0 到 9 之间的数字。

对于构建和训练模型的过程,请参考第一章,PyTorch 使用深度学习的概述中的训练神经网络使用 PyTorch部分。关于这个练习的完整代码,您可以参考我们的 github 仓库[13.1]。

保存和加载训练好的模型

在本节中,我们将演示如何有效地加载保存的预训练 PyTorch 模型,这些模型稍后将用于处理请求。

因此,使用来自第一章,PyTorch 使用深度学习的概述的笔记本代码,我们训练了一个模型,并对测试数据样本进行了评估。但接下来呢?在现实生活中,我们希望关闭这个笔记本,并且以后仍然能够使用我们辛苦训练过的模型来推断手写数字图像。这就是模型服务概念的应用之处。

从这里开始,我们将处于一个位置,可以在一个单独的 Jupyter 笔记本中使用先前训练过的模型,而无需进行任何(重新)训练。关键的下一步是将模型对象保存到一个文件中,稍后可以恢复/反序列化。PyTorch 提供了两种主要的方法来实现这一点:

  • 不推荐的方式是保存整个模型对象,如下所示:
torch.save(model, PATH_TO_MODEL)

然后,稍后可以按如下方式读取保存的模型:

model = torch.load(PATH_TO_MODEL)

尽管这种方法看起来最直接,但在某些情况下可能会有问题。这是因为我们不仅保存了模型参数,还保存了我们源代码中使用的模型类和目录结构。如果以后我们的类签名或目录结构发生变化,加载模型可能会以无法修复的方式失败。

  • 第二种更推荐的方法是仅保存模型参数,如下所示:
torch.save(model.state_dict(), PATH_TO_MODEL)

当我们需要恢复模型时,首先我们实例化一个空模型对象,然后将模型参数加载到该模型对象中,如下所示:

model = ConvNet()
model.load_state_dict(torch.load(PATH_TO_MODEL))

我们将使用更推荐的方式保存模型,如下代码所示:

PATH_TO_MODEL = "./convnet.pth"
torch.save(model.state_dict(), PATH_TO_MODEL)

convnet.pth文件本质上是一个包含模型参数的 pickle 文件。

此时,我们可以安全地关闭我们正在工作的笔记本,并打开另一个可以在我们的 github 仓库[13.2]中找到的笔记本:

  1. 作为第一步,我们再次需要导入库:
import torch
  1. 接下来,我们需要再次实例化一个空的 CNN 模型。理想情况下,模型定义可以写在一个 Python 脚本中(比如 cnn_model.py),然后我们只需要写如下代码:
from cnn_model import ConvNet
model = ConvNet()

然而,在这个练习中,由于我们正在使用 Jupyter 笔记本,我们将重写模型定义,然后像这样实例化它:

class ConvNet(nn.Module):
    def __init__(self):
       …
    def forward(self, x):
        …
model = ConvNet()
  1. 现在,我们可以将保存的模型参数恢复到实例化的模型对象中,方法如下:
PATH_TO_MODEL = "./convnet.pth"
model.load_state_dict(torch.load(PATH_TO_MODEL, map_location="cpu"))

您将看到以下输出:

图 13 .1 – 模型参数加载

图 13 .1 – 模型参数加载

这基本上意味着参数加载成功。也就是说,我们实例化的模型与保存并现在正在恢复其参数的模型具有相同的结构。我们指定在 CPU 设备上加载模型,而不是 GPU(CUDA)。

  1. 最后,我们希望指定不希望更新或更改加载模型的参数值,并将使用以下代码行来执行:
model.eval()

这应该给出以下输出:

图 13 .2 – 加载模型并进行评估模式

图 13 .2 – 加载模型并进行评估模式

这再次验证我们确实使用与训练过的相同模型(架构)进行工作。

构建推断流水线

在前一节成功在新环境(笔记本)中加载了预训练模型后,我们现在将构建我们的模型推断流水线,并用它来运行模型预测:

  1. 此时,我们已经完全恢复了之前训练过的模型对象。现在,我们将加载一张图像,然后可以使用以下代码对其进行模型预测:
image = Image.open("./digit_image.jpg")

图像文件应该在练习文件夹中,并且如下所示:

图 13 .3 – 模型推断输入图像

图 13 .3 – 模型推断输入图像

在本练习中不必使用此特定图像。您可以使用任何图像来检查模型对其的反应。

  1. 在任何推断流水线中,核心有三个主要组件:(a) 数据预处理组件,(b) 模型推断(神经网络的前向传播),以及 (c) 后处理步骤。

我们将从第一部分开始,通过定义一个函数,该函数接收图像并将其转换为作为模型输入的张量:

def image_to_tensor(image):
    gray_image = transforms.functional.to_grayscale(image)
    resized_image = transforms.functional.resize(gray_image, (28, 28))
    input_image_tensor = transforms.functional.to_tensor(resized_image)
    input_image_tensor_norm = transforms.functional.normalize(input_image_tensor, (0.1302,), (0.3069,))
    return input_image_tensor_norm

这可以看作是以下一系列步骤的一部分:

  1. 首先,RGB 图像转换为灰度图像。

  2. 然后,将图像调整为 28x28 像素的图像,因为这是模型训练时使用的图像尺寸。

  3. 接下来,将图像数组转换为 PyTorch 张量。

  4. 最后,对张量中的像素值进行归一化,归一化使用与模型训练时相同的均值和标准差值。

定义了此函数后,我们调用它将加载的图像转换为张量:

input_tensor = image_to_tensor(image)
  1. 接下来,我们定义模型推断功能。这是模型接收张量作为输入并输出预测的地方。在这种情况下,预测将是 0 到 9 之间的任何数字,输入张量将是输入图像的张量化形式:
def run_model(input_tensor):
    model_input = input_tensor.unsqueeze(0)
    with torch.no_grad():
        model_output = model(model_input)[0]
    model_prediction = model_output.detach().numpy().argmax()
    return model_prediction

model_output包含模型的原始预测,其中包含每个图像预测的列表。因为我们只有一个输入图像,所以这个预测列表只有一个在索引0处的条目。索引0处的原始预测本质上是一个张量,其中有 10 个数字 0 到 9 的概率值,按顺序排列。这个张量被转换为一个numpy数组,最后我们选择具有最高概率的数字。

  1. 现在我们可以使用这个函数生成我们的模型预测。下面的代码使用第 3 步run_model模型推断函数来为给定的输入数据input_tensor生成模型预测:
output = run_model(input_tensor)
print(output)
print(type(output))

这应该会输出以下内容:

图 13 .4 – 模型推断输出

图 13 .4 – 模型推断输出

如前面的截图所示,模型输出为一个numpy整数。基于图 13 .3中显示的图像,模型输出似乎相当正确。

  1. 除了仅输出模型预测外,我们还可以编写调试函数来更深入地了解诸如原始预测概率等指标,如下面的代码片段所示:
def debug_model(input_tensor):
    model_input = input_tensor.unsqueeze(0)
    with torch.no_grad():
        model_output = model(model_input)[0]
    model_prediction = model_output.detach().numpy()
    return np.exp(model_prediction)

这个函数与run_model函数完全相同,只是它返回每个数字的原始概率列表。由于模型最终层使用了log_softmax层,所以模型原始返回的是 softmax 输出的对数(参考本练习的第 2 步)。

因此,我们需要对这些数字进行指数运算,以返回 softmax 输出,这些输出等同于模型预测的概率。使用这个调试函数,我们可以更详细地查看模型的表现,比如概率分布是否平坦或者是否有明显的峰值:

print(debug_model(input_tensor))

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

图 13 .5 – 模型推断调试输出

图 13 .5 – 模型推断调试输出

我们可以看到列表中第三个概率远远最高,对应数字 2。

  1. 最后,我们将对模型预测进行后处理,以便其他应用程序可以使用。在我们的情况下,我们将仅将模型预测的数字从整数类型转换为字符串类型。

在其他场景中,后处理步骤可能会更复杂,比如语音识别,我们可能希望通过平滑处理、移除异常值等方式处理输出波形:

def post_process(output):
    return str(output)

因为字符串是可序列化格式,这使得模型预测可以在服务器和应用程序之间轻松传递。我们可以检查我们的最终后处理数据是否符合预期:

final_output = post_process(output)
print(final_output)
print(type(final_output))

这应该会为您提供以下输出:

图 13 .6 – 后处理模型预测

图 13 .6 – 后处理模型预测

如预期,现在输出的类型为字符串。

这结束了我们加载保存的模型架构,恢复其训练权重,并使用加载的模型为样本输入数据(一张图像)生成预测的练习。我们加载了一个样本图像,对其进行预处理以将其转换为 PyTorch 张量,将其作为输入传递给模型以获取模型预测,并对预测进行后处理以生成最终输出。

这是朝着为经过训练的模型提供明确定义的输入和输出接口的方向迈出的一步。在这个练习中,输入是一个外部提供的图像文件,输出是一个包含 0 到 9 之间数字的生成字符串。这样的系统可以通过复制并粘贴提供的代码嵌入到任何需要手写数字转换功能的应用程序中。

在接下来的部分,我们将深入探讨模型服务的更高级别,我们的目标是构建一个可以被任何应用程序交互使用的系统,以使用数字化功能,而无需复制和粘贴任何代码。

构建一个基本的模型服务器

到目前为止,我们已经构建了一个模型推断管道,其中包含独立执行预训练模型预测所需的所有代码。在这里,我们将致力于构建我们的第一个模型服务器,这本质上是一个托管模型推断管道的机器,通过接口主动监听任何传入的输入数据,并通过接口对任何输入数据输出模型预测。

使用 Flask 编写一个基本应用

为了开发我们的服务器,我们将使用一个流行的 Python 库 – Flask [13.3]。Flask将使我们能够用几行代码构建我们的模型服务器。关于该库如何工作的一个很好的示例如下所示:

from flask import Flask
app = Flask(__name__)
@app.route('/')
def hello_world():
    return 'Hello, World!'
if __name__ == '__main__':
    app.run(host='localhost', port=8890)

假设我们将这个 Python 脚本保存为example.py并从终端运行它:

python example.py

它将在终端显示以下输出:

图 13 .7 – Flask 示例应用启动

图 13 .7 – Flask 示例应用启动

基本上,它将启动一个 Flask 服务器,用于提供名为example的应用程序。让我们打开一个浏览器,并转到以下 URL:

http://localhost:8890/

它将在浏览器中产生以下输出:

图 13 .8 – Flask 示例应用测试

图 13 .8 – Flask 示例应用测试

本质上,Flask 服务器在 IP 地址为0.0.0.0(localhost)的端口号8890上监听端点/。当我们在浏览器搜索栏中输入localhost:8890/并按Enter时,该服务器将接收到一个请求。然后,服务器运行hello_world函数,该函数根据example.py中提供的函数定义返回字符串Hello, World!

使用 Flask 构建我们的模型服务器

使用前面部分演示的运行 Flask 服务器的原则,我们现在将使用前一部分构建的模型推理管道来创建我们的第一个模型服务器。 在练习结束时,我们将启动服务器以侦听传入请求(图像数据输入)。

此外,我们还将编写另一个 Python 脚本,通过向此服务器发送图 13 .3中显示的示例图像,向此服务器发出请求。 Flask 服务器将对该图像进行模型推理并输出后处理的预测结果。

此练习的完整代码在 GitHub 上可用,包括 Flask 服务器代码 [13.4] 和客户端(请求生成器)代码 [13.5]。

为 Flask 服务设置模型推理

在本节中,我们将加载预训练模型并编写模型推理管道代码:

  1. 首先,我们将构建 Flask 服务器。 为此,我们再次开始导入必要的库:
from flask import Flask, request
import torch

除了numpyjson等其他基本库外,flasktorch对于这个任务都是至关重要的。

  1. 接下来,我们需要定义模型类(架构):
class ConvNet(nn.Module):
    def __init__(self):
    def forward(self, x):
  1. 现在我们已经定义了空模型类,我们可以实例化一个模型对象,并将预训练模型参数加载到该模型对象中,方法如下:
model = ConvNet()
PATH_TO_MODEL = "./convnet.pth"
model.load_state_dict(torch.load(PATH_TO_MODEL, map_location="cpu"))
model.eval()
  1. 我们将重复使用在“构建推理管道”部分“步骤 3”中定义的精确run_model函数:
def run_model(input_tensor):
    …
    return model_prediction

作为提醒,此函数接受张量化的输入图像并输出模型预测,即介于 0 到 9 之间的任何数字。

  1. 接下来,我们将重复使用在“构建推理管道”部分的“第 6 步”中定义的精确post_process函数:
def post_process(output):
    return str(output)

这将从run_model函数的整数输出转换为字符串。

构建一个用于提供模型的 Flask 应用

在上一节中建立了推理管道之后,我们现在将构建我们自己的 Flask 应用并使用它来提供加载的模型服务:

  1. 我们将如下代码示例化我们的 Flask 应用:
app = Flask(__name__)

这将创建一个与 Python 脚本同名的 Flask 应用,这在我们的案例中是server(.py)

  1. 这是关键步骤,我们将在 Flask 服务器中定义端点功能。 我们将暴露/test端点并定义在服务器上进行POST请求时发生的事件如下:
@app.route("/test", methods=["POST"])
def test():
    data = request.files['data'].read()
    md = json.load(request.files['metadata'])
    input_array = np.frombuffer(data, dtype=np.float32)
    input_image_tensor = torch.from_numpy(input_array).view(md["dims"])
    output = run_model(input_image_tensor)
    final_output = post_process(output)
    return final_output

让我们逐步进行这些步骤:

  1. 首先,在函数下面定义一个装饰器test。 此装饰器告诉 Flask 应用程序,每当有人向/test端点发出POST请求时,运行此函数。

  2. 接下来,我们将定义test函数内部发生的确切事件。 首先,我们从POST请求中读取数据和元数据。 因为数据是序列化形式,所以我们需要将其转换为数值格式 - 我们将其转换为numpy数组。 从numpy数组中,我们迅速将其转换为 PyTorch 张量。

  3. 接下来,我们使用元数据中提供的图像尺寸来重塑张量。

  4. 最后,我们对之前加载的模型执行前向传播。这会给我们模型的预测结果,然后经过后处理并由我们的测试函数返回。

  5. 我们已经准备好启动我们的 Flask 应用程序所需的所有组件。我们将这最后两行添加到我们的server.py Python 脚本中:

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=8890)

这表明 Flask 服务器将托管在 IP 地址0.0.0.0(也称为localhost)和端口号8890。我们现在可以保存 Python 脚本,并在新的终端窗口中执行以下操作:

python server.py

这将运行前面步骤中编写的整个脚本,并将看到以下输出:

图 13 .9 - Flask 服务器启动

图 13 .9 - Flask 服务器启动

这看起来类似于图 13 .7 中演示的示例。唯一的区别是应用程序名称。

使用 Flask 服务器运行预测。

我们已成功启动了我们的模型服务器,它正在积极监听请求。现在让我们继续处理发送请求的工作:

  1. 接下来的几步我们将编写一个单独的 Python 脚本来完成这项工作。我们首先导入库:
import requests
from PIL import Image
from torchvision import transforms

requests库将帮助我们向 Flask 服务器发起实际的POST请求。Image帮助我们读取样本输入图像文件,而transforms则帮助我们预处理输入图像数组。

  1. 接下来,我们读取一个图像文件:
image = Image.open("./digit_image.jpg")

这里读取的图像是 RGB 图像,可能具有任意尺寸(不一定是模型期望的 28x28 尺寸)。

  1. 现在我们定义一个预处理函数,将读取的图像转换为模型可读取的格式:
def image_to_tensor(image):
    gray_image = transforms.functional.to_grayscale(image)
    resized_image = transforms.functional.resize(gray_image, (28, 28))
    input_image_tensor = transforms.functional.to_tensor(resized_image)
    input_image_tensor_norm = transforms.functional.normalize(input_image_tensor, (0.1302,), (0.3069,))
    return input_image_tensor_norm

定义了函数之后,我们可以执行它:

image_tensor = image_to_tensor(image)

image_tensor是我们需要发送给 Flask 服务器的输入数据。

  1. 现在让我们将数据打包在一起发送过去。我们希望发送图像的像素值以及图像的形状(28x28),这样接收端的 Flask 服务器就知道如何将像素值流重构为图像:
dimensions = io.StringIO(json.dumps({'dims': list(image_tensor.shape)}))
data = io.BytesIO(bytearray(image_tensor.numpy()))

我们将张量的形状转换为字符串,并将图像数组转换为字节,使其可序列化。

  1. 这是客户端代码中最关键的一步。这是我们实际发起POST请求的地方:
r = requests.post('http://localhost:8890/test',
                  files={'metadata': dimensions,                          'data' : data})

使用requests库,我们在 URLlocalhost:8890/test发起POST请求。这是 Flask 服务器监听请求的地方。我们将实际图像数据(以字节形式)和元数据(以字符串形式)发送为字典的形式。

  1. 在上述代码中,r变量将接收来自 Flask 服务器请求的响应。这个响应应该包含经过后处理的模型预测结果。我们现在读取该输出:
response = json.loads(r.content)

response变量实际上将包含 Flask 服务器输出的内容,这是一个介于 0 和 9 之间的数字字符串。

  1. 我们可以打印响应以确保一切正常:
print("Predicted digit :", response)

此时,我们可以将此 Python 脚本保存为make_request.py,并在终端中执行以下命令:

python make_request.py

这应该输出以下内容:

图 13 .10 – Flask 服务器响应

图 13 .10 – Flask 服务器响应

基于输入图像(见图 13 .3),响应看起来相当正确。这结束了我们当前的练习。

因此,我们已成功构建了一个独立的模型服务器,可以为手写数字图像进行预测。同样的步骤集可以轻松扩展到任何其他机器学习模型,因此使用 PyTorch 和 Flask 创建机器学习应用程序的可能性是无限的。

到目前为止,我们已经不仅仅是编写推理函数,而是创建了可以远程托管并在网络上进行预测的模型服务器。在我们接下来和最后的模型服务冒险中,我们将再进一步。您可能已经注意到,在遵循前两个练习的步骤时,有一些固有的依赖需要考虑。我们需要安装某些库,保存和加载模型在特定位置,读取图像数据等等。所有这些手动步骤都会减慢模型服务器的开发速度。

接下来,我们将致力于创建一个可以通过一条命令快速启动并在多台机器上复制的模型微服务。

创建模型微服务

想象一下,您对训练机器学习模型一无所知,但希望使用已经训练好的模型,而不必涉及任何 PyTorch 代码。这就是诸如机器学习模型微服务 [13.6] 这样的范式发挥作用的地方。

可以将机器学习模型微服务看作是一个黑盒子,您向其发送输入数据,它向您发送预测。而且,仅需几行代码就可以在给定的机器上快速启动这个黑盒子。最好的部分是它可以轻松扩展。您可以通过使用更大的机器(更多内存、更多处理能力)来垂直扩展微服务,也可以通过在多台机器上复制微服务来水平扩展。

我们如何部署一个机器学习模型作为微服务?多亏了在前面的练习中使用 Flask 和 PyTorch 所做的工作,我们已经领先了几步。我们已经使用 Flask 构建了一个独立的模型服务器。

在本节中,我们将这个想法推向前进,并构建一个独立的模型服务环境,使用Docker。 Docker 有助于容器化软件,这基本上意味着它帮助虚拟化整个操作系统OS),包括软件库、配置文件,甚至数据文件。

注意

Docker 本身是一个广泛讨论的话题。然而,由于本书专注于 PyTorch,我们只会涵盖 Docker 的基本概念和用法,以适应我们有限的目的。如果您有兴趣进一步了解 Docker,他们自己的文档是一个很好的起点 [13.7] 。

在我们的案例中,到目前为止,在构建我们的模型服务器时,我们已经使用了以下库:

  • Python

  • PyTorch

  • Pillow(用于图像 I/O)

  • Flask

此外,我们使用了以下数据文件:

  • 预训练模型检查点文件 (convnet.pth)

我们不得不通过安装库并将文件放置在当前工作目录中手动安排这些依赖关系。如果我们需要在新机器上重新执行所有操作会怎样?我们将不得不手动安装库并再次复制粘贴文件。这种工作方式既不高效,也不可靠,例如,我们可能会在不同的机器上安装不同版本的库。

为了解决这个问题,我们想创建一个可以在各个机器上一致重复的操作系统级蓝图。这就是 Docker 发挥作用的地方。Docker 让我们可以创建一个 Docker 镜像的形式来实现这个蓝图。这个镜像可以在任何空白的机器上构建,不需要假设预先安装了 Python 库或已经可用的模型。

让我们实际上使用 Docker 为我们的数字分类模型创建这样的蓝图。作为一个练习,我们将从基于 Flask 的独立模型服务器转向基于 Docker 的模型微服务。在深入练习之前,您需要安装 Docker [13.8]:

  1. 首先,我们需要列出 Flask 模型服务器的 Python 库需求。需求(及其版本)如下:
torch==1.5.0
torchvision==0.5.0
Pillow==6.2.2
Flask==1.1.1

作为一般惯例,我们将把这个列表保存为一个文本文件 – requirements.txt。这个文件也可以在我们的 GitHub 仓库中找到 [13.9]。这个列表将有助于在任何给定的环境中一致地安装这些库。

  1. 接下来,我们直接进入蓝图,用 Docker 术语来说,这将是 DockerfileDockerfile 是一个脚本,实质上是一系列的指令。运行这个 Dockerfile 的机器需要执行文件中列出的指令。这会生成一个 Docker 镜像,这个过程称为 构建镜像

在这里,一个 镜像 是一个系统快照,可以在任何机器上执行,只要该机器具备最低必要的硬件资源(例如,仅安装 PyTorch 就需要多个 GB 的磁盘空间)。

让我们看看我们的Dockerfile并逐步理解它的作用。完整的Dockerfile代码可在我们的 GitHub 仓库中找到 [13.10]。

  1. FROM 关键字指示 Docker 获取一个预先安装了 python 3.8 的标准 Linux 操作系统:
FROM python:3.8-slim

这确保我们将安装 Python。

  1. 接下来,安装 wget,这是一个 Unix 命令,有助于通过命令行下载互联网资源:
RUN apt-get -q update && apt-get -q install -y wget

&& 符号表示在符号前后写的命令是顺序执行的。

  1. 在这里,我们正在将两个文件从我们的本地开发环境复制到这个虚拟环境中:
COPY ./server.py ./
COPY ./requirements.txt ./

我们复制了在 步骤 1 中讨论过的 requirements 文件,以及在前一个练习中工作过的 Flask 模型服务器代码。

  1. 接下来,我们下载预训练的 PyTorch 模型检查点文件:
RUN wget -q https://github.com/arj7192/MasteringPyTorchV2/raw/main/Chapter13/convnet.pth 

这是我们在本章的保存和加载训练好的模型部分中保存的同一模型检查点文件。

  1. 在这里,我们正在安装requirements.txt下列出的所有相关库:
RUN pip install -r requirements.txt

这个txt文件是我们在步骤 1下编写的文件。

  1. 接下来,我们给 Docker 客户端赋予root访问权限:
USER root

这一步在本练习中非常重要,因为它确保客户端具有执行所有必要操作的凭据,例如在磁盘上保存模型推断日志。

注意

总体而言,建议根据数据安全的最小特权原则[13.11],不要赋予客户端 root 权限。

  1. 最后,我们指定在执行了所有前面的步骤之后,Docker 应该执行python server.py命令:
ENTRYPOINT ["python", "server.py"]

这将确保在虚拟机中启动一个 Flask 模型服务器。

  1. 现在让我们运行这个 Dockerfile。换句话说,让我们使用步骤 2中的 Dockerfile 构建一个 Docker 镜像。在当前工作目录中,只需在命令行上运行以下命令:
docker build -t digit_recognizer .

我们正在为我们的 Docker 镜像分配一个名为digit_recognizer的标签。这应该输出以下内容:

图 13 .11 – 构建 Docker 镜像

图 13 .11 – 构建 Docker 镜像

图 13 .11显示了步骤 2中提到的步骤的顺序执行。根据您的互联网连接速度,此步骤的运行时间可能会有所不同,因为它需要下载整个 PyTorch 库等内容以构建镜像。

  1. 在这个阶段,我们已经有一个名为digit_recognizer的 Docker 镜像。我们已经准备好在任何机器上部署这个镜像。为了暂时在您自己的机器上部署这个镜像,只需运行以下命令:
docker run -p 8890:8890 digit_recognizer

使用这个命令,我们本质上是在我们的机器内部启动一个虚拟机,使用digit_recognizer Docker 镜像。因为我们原来的 Flask 模型服务器设计为监听端口8890,我们使用-p参数将我们实际机器的端口8890转发到虚拟机的端口8890。运行这个命令应该输出以下内容:

图 13 .12 – 运行 Docker 实例

图 13 .12 – 运行 Docker 实例

前面的截图与上一练习中的图 13 .9非常相似,这并不奇怪,因为 Docker 实例正在运行我们之前手动运行的相同 Flask 模型服务器。

  1. 现在,我们可以测试我们的 Docker 化 Flask 模型服务器(模型微服务)是否按预期工作,方法是使用前一练习中使用的make_request.py文件向我们的模型发送预测请求。从当前本地工作目录,简单执行以下命令:
python make_request.py

这应该输出以下内容:

图 13 .13 – 微服务模型预测

图 13 .13 – 微服务模型预测

微服务似乎在发挥作用,因此我们成功地使用 Python、PyTorch、Flask 和 Docker 构建和测试了自己的机器学习模型微服务。

  1. 完成前面的步骤后,可以按照 第 4 步 中指示的方式,通过按下 Ctrl+C 关闭启动的 Docker 实例(见 图 13 .12)。一旦运行的 Docker 实例停止,可以通过运行以下命令删除该实例:
docker rm $(docker ps -a -q | head -1)

此命令基本上移除了最近不活跃的 Docker 实例,也就是我们刚刚停止的 Docker 实例。

  1. 最后,还可以通过运行以下命令删除我们在 第 3 步 下构建的 Docker 镜像:
docker rmi $(docker images -q "digit_recognizer")

这将基本上删除已标记为 digit_recognizer 标签的镜像。

这结束了我们为 PyTorch 编写模型服务的部分。我们首先设计了一个本地模型推理系统。然后,我们将这个推理系统包装成基于 Flask 的模型服务器,创建了一个独立的模型服务系统。

最后,我们使用基于 Flask 的模型服务器放置在 Docker 容器内,实质上创建了一个模型服务微服务。使用本节讨论的理论和练习,您应该能够开始在不同的用例、系统配置和环境中托管/提供您训练好的模型。

在接下来的部分中,我们将继续与模型服务主题保持一致,但会讨论一种特定的工具,该工具正是为了精确为 PyTorch 模型提供服务而开发的:TorchServe。我们还将进行一个快速练习,演示如何使用这个工具。

使用 TorchServe 服务 PyTorch 模型

TorchServe 是一个专用的 PyTorch 模型服务框架,于 2020 年 4 月发布。使用 TorchServe 提供的功能,我们可以同时为多个模型提供服务,具有低预测延迟,并且无需编写大量自定义代码。此外,TorchServe 还提供模型版本控制、指标监控以及数据预处理和后处理等功能。

显然,TorchServe 是一个更高级的模型服务替代方案,比我们在前一节中开发的模型微服务更为先进。然而,创建定制的模型微服务仍然被证明是解决复杂机器学习流水线问题的强大解决方案(这比我们想象的更常见)。

在本节中,我们将继续使用我们的手写数字分类模型,并演示如何使用 TorchServe 进行服务。阅读本节后,您应该能够开始使用 TorchServe 并进一步利用其完整的功能集。

安装 TorchServe

在开始练习之前,我们需要安装 Java 11 SDK 作为先决条件。对于 Linux 操作系统,请运行以下命令:

sudo apt-get install openjdk-11-jdk

对于 macOS,我们需要在命令行上运行以下命令:

brew tap AdoptOpenJDK/openjdk
brew      install --cask adoptopenjdk11

然后,我们需要运行以下命令安装 torchserve

pip install torchserve==0.6.0 torch-model-archiver==0.6.0

有关详细的安装说明,请参阅 torchserve 文档 [13.12]。

注意,我们还安装了一个名为torch-model-archiver的库 [13.13]。这个归档工具旨在创建一个模型文件,该文件将包含模型参数以及模型架构定义,以独立序列化格式存储为.mar文件。

启动和使用 TorchServe 服务器。

现在我们已经安装了所有需要的东西,可以开始组合先前练习中的现有代码来使用 TorchServe 提供我们的模型。以下是我们将通过练习步骤进行的几个步骤:

  1. 首先,我们将现有的模型架构代码放入一个名为convnet.py的模型文件中:
==========================convnet.py===========================
import torch
import torch.nn as nn
import torch.nn.functional as F
class ConvNet(nn.Module):
    def __init__(self):
        …
    def forward(self, x):
        …

我们将需要将这个模型文件作为torch-model-archiver的输入之一,以产生一个统一的.mar文件。您可以在我们的 GitHub 仓库 [13.14] 中找到完整的模型文件。

记得我们曾讨论过模型推断流程的三个部分:数据预处理、模型预测和后处理。TorchServe 提供了处理程序来处理流行的机器学习任务的预处理和后处理部分:image_classifierimage_segmenterobject_detectortext_classifier

由于在撰写本书时 TorchServe 正在积极开发中,因此这个列表可能会在未来增加。

  1. 对于我们的任务,我们将创建一个自定义的图像处理程序,它是从默认的Image_classifier处理程序继承而来。我们选择创建一个自定义处理程序,因为与处理彩色(RGB)图像的常规图像分类模型不同,我们的模型处理特定尺寸(28x28 像素)的灰度图像。以下是我们的自定义处理程序的代码,您也可以在我们的 GitHub 仓库 [13.15] 中找到:
========================convnet_handler.py=======================
from torchvision import transforms
from ts.torch_handler.image_classifier import ImageClassifier
class ConvNetClassifier(ImageClassifier):
    image_processing = transforms.Compose([
        transforms.Grayscale(), transforms.Resize((28, 28)),
        transforms.ToTensor(),  transforms.Normalize((0.1302,), (0.3069,))])
    def postprocess(self, output):
        return output.argmax(1).tolist()

首先,我们导入了image_classifier默认处理程序,它将提供大部分基本的图像分类推断流程处理能力。接下来,我们继承ImageClassifer处理程序类来定义我们的自定义ConvNetClassifier处理程序类。

这里有两个自定义代码块:

  1. 数据预处理步骤,我们将数据应用一系列变换,正如我们在“构建推断流程”部分的“步骤 3”中所做的那样。

  2. 后处理步骤,在postprocess方法下定义,我们从所有类别预测概率的列表中提取预测的类标签。

  3. 在本章的“保存和加载训练模型”部分我们已经生成了一个convnet.pth文件用于创建模型推断流程。使用convnet.pyconvnet_handler.pyconvnet.pth,我们最终可以通过运行以下命令使用torch-model-archiver来创建.mar文件:

torch-model-archiver --model-name convnet --version 1.0 --model-file ./convnet.py --serialized-file ./convnet.pth --handler  ./convnet_handler.py

这个命令应该会在当前工作目录写入一个convnet.mar文件。我们指定了一个model_name参数,它为.mar文件命名。我们指定了一个version参数,在同时处理多个模型变体时有助于模型版本控制。

我们已经找到了我们的 convnet.py(用于模型架构)、convnet.pth(用于模型权重)和 convnet_handler.py(用于前处理和后处理)文件的位置,分别使用了 model_fileserialzed_filehandler 参数。

  1. 接下来,我们需要在当前工作目录中创建一个新目录,并将第 3 步 中创建的 convnet.mar 文件移动到该目录中,通过以下命令完成:
mkdir model_store
mv convnet.mar model_store/

我们必须这样做来遵循 TorchServe 框架的设计要求。

  1. 最后,我们可以使用 TorchServe 启动我们的模型服务器。在命令行上,只需运行以下命令:
torchserve --start --ncs --model-store model_store --models convnet.mar

这将静默地启动模型推断服务器,并在屏幕上显示一些日志,包括以下内容:

图 13 .14 – TorchServe 启动输出

图 13 .14 – TorchServe 启动输出

正如你所见,TorchServe 会检查机器上可用的设备及其他详细信息。它为推断管理指标分配了三个独立的 URL。为了检查启动的服务器是否确实在为我们的模型提供服务,我们可以使用以下命令来 ping 管理服务器:

curl http://localhost:8081/models

这应该输出以下内容:

图 13 .15 – TorchServe 服务模型

图 13 .15 – TorchServe 服务模型

这验证了 TorchServe 服务器确实在托管模型。

  1. 最后,我们可以通过发送推断请求来测试我们的 TorchServe 模型服务器。这一次,我们不需要编写 Python 脚本,因为处理程序已经处理任何输入图像文件。因此,我们可以通过运行以下命令,直接使用 digit_image.jpg 示例图像文件进行请求:
curl http://127.0.0.1:8080/predictions/convnet -T ./digit_image.jpg

这应该在终端输出 2,这确实是正确的预测,正如图 13 .3 所示。

  1. 最后,一旦我们完成了对模型服务器的使用,可以通过在命令行上运行以下命令来停止它:
torchserve --stop

这结束了我们如何使用 TorchServe 快速启动自己的 PyTorch 模型服务器并用其进行预测的练习。这里还有很多内容需要挖掘,比如模型监控(指标)、日志记录、版本控制、性能基准测试等 [13.16] 。TorchServe 的网站是深入研究这些高级主题的好地方。

完成本节后,您将能够使用 TorchServe 来为自己的模型提供服务。我鼓励您为自己的用例编写自定义处理程序,探索各种 TorchServe 配置设置 [13.17] ,并尝试 TorchServe 的其他高级功能 [13.18] 。

注意

TorchServe 在不断发展中,充满了许多潜力。我的建议是密切关注 PyTorch 领域的快速更新。

在下一节中,我们将探讨如何导出 PyTorch 模型,以便在不同的环境、编程语言和深度学习库中使用。

使用 TorchScript 和 ONNX 导出通用 PyTorch 模型

我们已经在本章前几节广泛讨论了提供 PyTorch 模型服务,这也许是在生产系统中实现 PyTorch 模型操作的最关键方面。在这一部分,我们将探讨另一个重要方面 – 导出 PyTorch 模型。我们已经学会了如何在经典的 Python 脚本环境中保存 PyTorch 模型并从磁盘加载它们。但是,我们需要更多导出 PyTorch 模型的方式。为什么呢?

对于初学者来说,Python 解释器一次只允许一个线程运行,使用全局解释器锁GIL)。这使得我们无法并行操作。其次,Python 可能不受我们希望在其上运行模型的每个系统或设备的支持。为了解决这些问题,PyTorch 提供了支持以高效的格式导出其模型,并以与平台或语言无关的方式,使模型能够在与其训练环境不同的环境中运行。

首先,我们将探讨 TorchScript,它使我们能够将序列化和优化的 PyTorch 模型导出为一个中间表示,然后可以在独立于 Python 的程序(比如说,C++ 程序)中运行。

接下来,我们将看看 ONNX 及其如何让我们将 PyTorch 模型保存为通用格式,然后加载到其他深度学习框架和不同编程语言中。

理解 TorchScript 的实用性

当涉及将 PyTorch 模型投入生产时,TorchScript 是一个至关重要的工具的两个关键原因:

  • PyTorch 基于急切执行,正如本书第一章“使用 PyTorch 进行深度学习概述”中讨论的那样。这有其优点,如更容易调试。然而,逐步执行步骤/操作,通过写入和读取中间结果到内存,可能导致高推理延迟,同时限制整体操作优化。为了解决这个问题,PyTorch 提供了自己的即时JIT)编译器,基于 Python 的 PyTorch 中心部分。

JIT 编译器编译 PyTorch 模型而不是解释,这相当于一次性查看模型的所有操作并创建一个复合图。JIT 编译的代码是 TorchScript 代码,它基本上是 Python 的静态类型子集。这种编译带来了多种性能改进和优化,比如去除 GIL,从而实现多线程。

  • PyTorch 本质上是与 Python 编程语言一起使用的。请记住,本书几乎完全使用了 Python。然而,在将模型投入生产时,有比 Python 更高效(即更快)的语言,如 C++。而且,我们可能希望在不支持 Python 的系统或设备上部署训练过的模型。

这就是 TorchScript 的作用。一旦我们将 PyTorch 代码编译成 TorchScript 代码,这是我们的 PyTorch 模型的中间表示,我们可以使用 TorchScript 编译器将这个表示序列化为一个符合 C++ 格式的文件。此后,可以在 C++ 模型推理程序中使用 LibTorch(PyTorch 的 C++ API)读取这个序列化文件。

我们在本节中已多次提到 PyTorch 模型的 JIT 编译。现在让我们看看将我们的 PyTorch 模型编译成 TorchScript 格式的两种可能选项中的两种。

使用 TorchScript 进行模型跟踪

将 PyTorch 代码转换为 TorchScript 的一种方法是跟踪 PyTorch 模型。跟踪需要 PyTorch 模型对象以及一个模型的虚拟示例输入。正如其名称所示,跟踪机制跟踪这个虚拟输入通过模型(神经网络)的流程,记录各种操作,并生成 TorchScript 中间表示IR),可以将其视为图形以及 TorchScript 代码进行可视化。

现在,我们将逐步介绍使用手写数字分类模型跟踪 PyTorch 模型的步骤。此练习的完整代码可在我们的 github 仓库 [13.19] 中找到。

此练习的前五个步骤与“保存和加载训练模型”和“构建推理流水线”部分的步骤相同,我们在这些部分构建了模型推理流水线:

  1. 我们将通过运行以下代码开始导入库:
import torch
...
  1. 接下来,我们将定义并实例化 model 对象:
class ConvNet(nn.Module):
    def __init__(self):
       …
    def forward(self, x):
        …
model = ConvNet()
  1. 接下来,我们将使用以下代码恢复模型权重:
PATH_TO_MODEL = "./convnet.pth"
model.load_state_dict(torch.load(PATH_TO_MODEL, map_location="cpu"))
model.eval()
  1. 然后我们加载一个示例图像:
image = Image.open("./digit_image.jpg")
  1. 接下来,我们定义数据预处理函数:
def image_to_tensor(image):
    gray_image = transforms.functional.to_grayscale(image)
    resized_image = transforms.functional.resize(gray_image, (28, 28))
    input_image_tensor = transforms.functional.to_tensor(resized_image)
    input_image_tensor_norm = transforms.functional.normalize(input_image_tensor, (0.1302,), (0.3069,))
    return input_image_tensor_norm

然后,我们将对样本图像应用预处理函数:

input_tensor = image_to_tensor(image)
  1. 除了 步骤 3 下的代码之外,我们还执行以下代码:
for p in model.parameters():
    p.requires_grad_(False)

如果我们不这样做,跟踪的模型将具有所有需要梯度的参数,我们将不得不在 torch.no_grad() 上下文中加载模型。

  1. 我们已经加载了带有预训练权重的 PyTorch 模型对象。接下来,我们将使用一个虚拟输入跟踪该模型:
demo_input = torch.ones(1, 1, 28, 28)
traced_model = torch.jit.trace(model, demo_input)

虚拟输入是一个所有像素值都设为 1 的图像。

  1. 现在我们可以通过运行这个来查看跟踪的模型图:
print(traced_model.graph)

这将输出以下内容:

图 13 .16 – 跟踪模型图

图 13 .16 – 跟踪模型图

直观地,图中的前几行展示了该模型层的初始化,如cn1cn2等。到了最后,我们看到了最后一层,也就是 softmax 层。显然,该图是用静态类型变量编写的低级语言,与 TorchScript 语言非常相似。

  1. 除了图形之外,我们还可以通过运行以下内容查看跟踪模型背后的确切 TorchScript 代码:
print(traced_model.code)

这将输出以下类似 Python 代码的行,定义了模型的前向传递方法:

Figure 13 .17 – 跟踪模型代码

图 13.17 – 跟踪模型代码

这恰好是我们在步骤 2中使用 PyTorch 编写的代码的 TorchScript 等效代码。

  1. 接下来,我们将导出或保存跟踪模型:
torch.jit.save(traced_model, 'traced_convnet.pt')
  1. 现在我们加载保存的模型:
loaded_traced_model = torch.jit.load('traced_convnet.pt')

注意,我们无需分别加载模型的架构和参数。

  1. 最后,我们可以使用此模型进行推断:
loaded_traced_model(input_tensor.unsqueeze(0))

输出如下:

这应该输出以下内容:Figure 13 .18 – 跟踪模型推断

图 13.18 – 跟踪模型推断

  1. 我们可以通过在原始模型上重新运行模型推断来检查这些结果:
model(input_tensor.unsqueeze(0))

这应该产生与图 13 .18相同的输出,从而验证我们的跟踪模型正常工作。

您可以使用跟踪模型而不是原始 PyTorch 模型对象来构建更高效的 Flask 模型服务器和 Docker 化的模型微服务,这要归功于 TorchScript 无 GIL 的特性。尽管跟踪是 JIT 编译 PyTorch 模型的可行选项,但它也有一些缺点。

例如,如果模型的前向传播包含诸如iffor语句等控制流,则跟踪只会呈现流程中的一条可能路径。为了准确地将 PyTorch 代码转换为 TorchScript 代码,以处理这种情况,我们将使用另一种称为脚本化的编译机制。

使用 TorchScript 进行模型脚本化

请按照上一练习中的 1 到 6 步骤,然后按照此练习中给出的步骤进行操作。完整代码可在我们的 github 仓库[13.20]中找到:

  1. 对于脚本化,我们无需为模型提供任何虚拟输入,并且以下代码行将 PyTorch 代码直接转换为 TorchScript 代码:
scripted_model = torch.jit.script(model)
  1. 让我们通过运行以下代码来查看脚本化模型图:
print(scripted_model.graph)

这应该以与跟踪模型代码图类似的方式输出脚本化模型图,如下图所示:

Figure 13 .19 – 脚本模型图

图 13.20 – 脚本模型代码

再次可以看到类似的冗长低级脚本,按行列出图的各种边缘。请注意,此处的图表与图 13 .16中的不同,这表明在使用跟踪而不是脚本化的代码编译策略时存在差异。

  1. 我们还可以通过运行此命令查看等效的 TorchScript 代码:
print(scripted_model.code)

这应该输出以下内容:

Figure 13 .20 – Scripted model code

图 13.20 – 脚本模型代码

本质上,流程与图 13 .17中的流程类似;但是,由于编译策略的不同,代码签名中存在细微差异。

  1. 再次,可以按以下方式导出脚本化模型并重新加载:
torch.jit.save(scripted_model, 'scripted_convnet.pt')
loaded_scripted_model = torch.jit.load('scripted_convnet.pt')
  1. 最后,我们使用此脚本化模型进行推断:
loaded_scripted_model(input_tensor.unsqueeze(0))

这应该产生与图 13 .18完全相同的结果,从而验证脚本化模型按预期工作。

与追踪类似,脚本化的 PyTorch 模型是 GIL-free 的,因此在与 Flask 或 Docker 一起使用时,可以提高模型服务的性能。表 13 .1 快速比较了模型追踪和脚本化的方法:

表 13 .1 – 追踪与脚本化的比较

表 13 .1 – 追踪与脚本化的比较

到目前为止,我们已经演示了如何将 PyTorch 模型转换并序列化为 TorchScript 模型。在接下来的部分中,我们将完全摆脱 Python,并演示如何使用 C++ 加载 TorchScript 序列化模型。

在 C++ 中运行 PyTorch 模型

Python 有时可能有限,或者我们可能无法运行使用 PyTorch 和 Python 训练的机器学习模型。在本节中,我们将使用在前一节中导出的序列化 TorchScript 模型对象(使用追踪和脚本化)来在 C++ 代码中运行模型推理。

注意

假设你具备基本的 C++ 工作知识 [13.21] 。本节专门讨论了关于 C++ 代码编译的内容 [13.22]

为了完成这个练习,我们需要按照 [13.23] 中提到的步骤安装 CMake 以便能够构建 C++ 代码。之后,我们将在当前工作目录下创建一个名为 cpp_convnet 的文件夹,并从该目录中工作:

  1. 让我们直接开始编写运行模型推理流水线的 C++ 文件。完整的 C++ 代码可以在我们的 github 仓库 [13.24] 中找到:
#include <torch/script.h>
...
int main(int argc, char **argv) {
    Mat img = imread(argv[2], IMREAD_GRAYSCALE);

首先,使用 OpenCV 库将 .jpg 图像文件读取为灰度图像。你需要根据你的操作系统要求安装 OpenCV 库 - Mac [13.25],Linux [13.26] 或 Windows [13.27]。

  1. 灰度图像随后被调整大小为 28x28 像素,因为这是我们 CNN 模型的要求:
resize(img, img, Size(28, 28));
  1. 然后,将图像数组转换为 PyTorch 张量:
auto input_ = torch::from_blob(img.data, { img.rows, img.cols, img.channels() }, at::kByte);

对于所有与 torch 相关的操作,如本步骤中所示,我们使用 libtorch 库,这是所有 torch C++ 相关 API 的家园。如果你已经安装了 PyTorch,就不需要单独安装 LibTorch。

  1. 因为 OpenCV 读取的灰度图像维度是 (28, 28, 1),我们需要将其转换为 (1, 28, 28) 以符合 PyTorch 的要求。然后,张量被重塑为形状为 (1,1,28,28),其中第一个 1 是推断的 batch_size,第二个 1 是通道数,对于灰度图像为 1
    auto input = input_.permute({2,0,1}).unsqueeze_(0).reshape({1, 1, img.rows, img.cols}).toType(c10::kFloat).div(255);
    input = (input – 0.1302) / 0.3069;

因为 OpenCV 读取的图像像素值范围是从 0255,我们将这些值归一化到 01 的范围。然后,我们使用平均值 0.1302 和标准差 0.3069 对图像进行标准化,就像我们在前面的章节中做的那样(参见构建推理流水线的第二步)。

  1. 在这一步中,我们加载了在前一个练习中导出的 JIT-ed TorchScript 模型对象:
    auto module = torch::jit::load(argv[1]);
    std::vector<torch::jit::IValue> inputs;
    inputs.push_back(input);
  1. 最后,我们来到模型预测阶段,在这里我们使用加载的模型对象对提供的输入数据进行前向传播(在本例中是一幅图像):
auto output_ = module.forward(inputs).toTensor();

output_ 变量包含每个类别的概率列表。让我们提取具有最高概率的类别标签并打印出来:

auto output = output_.argmax(1);
cout << output << '\n';

最后,我们成功退出 C++ 程序:

    return 0;
}
  1. 虽然 步骤 1-6 关注于我们 C++ 的各个部分,但我们还需要在相同的工作目录下编写一个 CMakeLists.txt 文件。此文件的完整代码可在我们的 github 仓库 [13.28] 中找到:
cmake_minimum_required(VERSION 3.0 FATAL_ERROR)
project(cpp_convnet)
find_package(Torch REQUIRED)
find_package(OpenCV REQUIRED)
add_executable(cpp_convnet cpp_convnet.cpp)
...

此文件基本上是类似于 Python 项目中的 setup.py 的库安装和构建脚本。除此代码外,还需要将 OpenCV_DIR 环境变量设置为 OpenCV 构建产物的路径,如下面的代码块所示:

export OpenCV_DIR=/Users/ashish.jha/code/personal/MasteringPyTorchV2     /     Chapter13     /cpp_convnet/build_opencv/
  1. 接下来,我们需要实际运行 CMakeLists 文件以创建构建产物。我们通过在当前工作目录中创建一个新目录并从那里运行构建过程来完成这一步。在命令行中,我们只需运行以下命令:
mkdir build
cd build
cmake -DCMAKE_PREFIX_PATH=/Users/ashish.jha/opt/anaconda3/envs/mastering_pytorch/lib/python3.9/site-packages/torch     /share/cmake/ ..
cmake --build . --config Release

在第三行中,您应提供 LibTorch 的路径。要找到您自己的路径,请打开 Python 并执行以下操作:

import torch; torch.__path__

对于我来说,输出如下所示:

['/Users/ashish.jha/opt/anaconda3/envs/mastering_pytorch/lib/python3.9/site-packages/torch     ']_

执行第三行将输出以下内容:

图 13 .21 – C++ CMake 输出

图 13 .21 – C++ CMake 输出的结果

第四行应输出如下内容:

图 13 .22 – C++ 模型构建

图 13 .22 – C++ 模型构建

  1. 在成功执行上一步骤后,我们将生成一个名为 cpp_convnet 的 C++ 编译二进制文件。现在是执行这个二进制程序的时候了。换句话说,我们现在可以向我们的 C++ 模型提供一个样本图像进行推断。我们可以使用脚本化的模型作为输入:
./cpp_convnet ../../scripted_convnet.pt ../../digit_image.jpg

或者,我们可以使用跟踪的模型作为输入:

./cpp_convnet ../../traced_convnet.pt ../../digit_image.jpg

任何一种方法都应该产生以下输出:

图 13 .23 – C++ 模型预测

图 13 .23 – C++ 模型预测

根据 图 13 .3,C++ 模型似乎工作正常。由于我们在 C++ 中使用了不同的图像处理库(即 OpenCV),与 Python(PIL)相比,像素值稍有不同编码,这将导致略有不同的预测概率,但如果应用正确的归一化,两种语言的最终模型预测应该没有显著差异。

这结束了我们关于使用 C++ 进行 PyTorch 模型推断的探索。这个练习将帮助您开始将使用 PyTorch 编写和训练的喜爱深度学习模型转移到 C++ 环境中,这样做不仅可以使预测更高效,还可以在无 Python 环境(例如某些嵌入式系统、无人机等)中托管模型成为可能。

在接下来的部分,我们将远离 TorchScript,并讨论一个通用的神经网络建模格式 – ONNX,它使得模型可以跨深度学习框架、编程语言和操作系统进行使用。我们将在 TensorFlow 中加载一个 PyTorch 训练的模型进行推断。

使用 ONNX 导出 PyTorch 模型

在生产系统的某些场景中,大多数已部署的机器学习模型都是使用某种特定的深度学习库编写的,比如 TensorFlow,并配备了自己复杂的模型服务基础设施。但是,如果某个模型是使用 PyTorch 编写的,我们希望它能够在 TensorFlow 中运行,以符合服务策略。这是 ONNX 等框架在各种其他用例中有用的一个例子。

ONNX 是一个通用格式,用于标准化深度学习模型的基本操作,例如矩阵乘法和激活函数,在不同的深度学习库中编写时会有所不同。它使我们能够在不同的深度学习库、编程语言甚至操作环境中互换地运行相同的深度学习模型。

在这里,我们将演示如何在 TensorFlow 中运行使用 PyTorch 训练的模型。我们首先将 PyTorch 模型导出为 ONNX 格式,然后在 TensorFlow 代码中加载 ONNX 模型。

ONNX 与受限版本的 TensorFlow 兼容,因此我们将使用tensorflow==1.15.0。由于这个原因,我们将使用 Python 3.7,因为tensorflow==1.15.0在更新的 Python 版本中不可用。您可以使用以下命令创建并激活一个新的带有 Python 3.7 的 conda 环境:

conda create -n <env_name> python=3.7
source activate <env_name> 

我们还需要为本练习安装onnx==1.7.0onnx-tf==1.5.0库。本练习的完整代码可在我们的 github 仓库[13.29]中找到。请按照TorchScript 模型跟踪部分的第 1 到 11 步,然后执行本练习中的步骤:

  1. 类似于模型跟踪,我们再次通过加载的模型传递一个虚拟输入:
demo_input = torch.ones(1, 1, 28, 28)
torch.onnx.export(model, demo_input, "convnet.onnx")

这将保存一个模型onnx文件。在底层,序列化模型所使用的机制与模型跟踪中使用的相同。

  1. 接下来,我们加载保存的onnx模型并将其转换为 TensorFlow 模型:
import onnx
from onnx_tf.backend import prepare
model_onnx = onnx.load("./convnet.onnx")
tf_rep = prepare(model_onnx)
tf_rep.export_graph("./convnet.pb")
  1. 接下来,我们加载序列化的tensorflow模型以解析模型图。这将帮助我们验证已正确加载模型架构并标识图的输入和输出节点:
with tf.gfile.GFile("./convnet.pb", "rb") as f:
    graph_definition = tf.GraphDef()
    graph_definition.ParseFromString(f.read())
with tf.Graph().as_default() as model_graph:
    tf.import_graph_def(graph_definition, name="")
for op in model_graph.get_operations():
    print(op.values())

这应输出以下内容:

图 13 .24 – TensorFlow 模型图

图 13 .24 – TensorFlow 模型图

从图中,我们能够识别标记的输入和输出节点。

  1. 最后,我们可以为神经网络模型的输入和输出节点分配变量,实例化 TensorFlow 会话,并运行图以生成样本图像的预测:
model_output = model_graph.get_tensor_by_name('18:0')
model_input = model_graph.get_tensor_by_name('input.1:0')
sess = tf.Session(graph=model_graph)
output = sess.run(model_output, feed_dict={model_input: input_tensor.unsqueeze(0)})
print(output)

这应输出以下内容:

图 13 .25 – TensorFlow 模型预测

图 13 .25 – TensorFlow 模型预测

如您所见,在与 Figure 13 .18 进行比较后,我们模型的 TensorFlow 和 PyTorch 版本的预测完全相同。这验证了 ONNX 框架成功运行的有效性。我鼓励您进一步分析 TensorFlow 模型,了解 ONNX 如何通过利用模型图中的基础数学操作,在不同的深度学习库中重现完全相同的模型。

我们已经讨论了导出 PyTorch 模型的不同方式。本节介绍的技术将在将 PyTorch 模型部署到生产系统以及在各种平台上使用时非常有用。随着深度学习库、编程语言甚至操作系统的新版本不断推出,这一领域将迅速发展。

因此,强烈建议密切关注发展动态,并确保使用最新和最高效的模型导出和操作化方法。

到目前为止,我们一直在本地机器上为服务和导出 PyTorch 模型进行工作。在本章的下一个也是最后一个部分中,我们将简要介绍如何在一些知名的云平台上为 PyTorch 模型提供服务,例如 AWS、Google Cloud 和 Microsoft Azure。

在云中提供 PyTorch 模型

深度学习计算成本高,因此需要强大和复杂的计算硬件。并非每个人都能访问到本地机器,其具备足够的 CPU 和 GPU 来在合理时间内训练庞大的深度学习模型。此外,对于为推断服务提供训练好的模型的本地机器,我们无法保证其百分之百的可用性。出于这些原因,云计算平台是训练和服务深度学习模型的重要选择。

在本节中,我们将讨论如何在一些最流行的云平台上使用 PyTorch — AWSGoogle CloudMicrosoft Azure。我们将探讨在每个平台上为训练好的 PyTorch 模型提供服务的不同方式。我们在本章早期部分讨论的模型服务练习是在本地机器上执行的。本节的目标是让您能够使用云上的 虚拟机 (VMs) 执行类似的练习。

使用 PyTorch 和 AWS

AWS 是最古老和最流行的云计算平台之一。它与 PyTorch 有深度集成。我们已经在 TorchServe 中看到了一个例子,它是由 AWS 和 Facebook 共同开发的。

在本节中,我们将介绍一些使用 AWS 为服务 PyTorch 模型的常见方法。首先,我们将简单了解如何使用 AWS 实例来替代我们的本地机器(笔记本电脑)来服务 PyTorch 模型。然后,我们将简要讨论 Amazon SageMaker,这是一个专门的云机器学习平台。我们将简要讨论如何将 TorchServe 与 SageMaker 结合使用进行模型服务。

注意

本节假设您对 AWS 有基本的了解。因此,我们不会详细讨论诸如 AWS EC2 实例是什么、AMI 是什么、如何创建实例等主题[13.30]。相反,我们将专注于与 PyTorch 相关的 AWS 组件的组成部分。

使用 AWS 实例为 PyTorch 模型提供服务

在本节中,我们将演示如何在 VM 中使用 PyTorch —— 在这种情况下是 AWS 实例。阅读本节后,您将能够在 AWS 实例中执行 PyTorch 模型服务 部分讨论的练习。

首先,如果您还没有 AWS 账户,您需要创建一个。创建账户需要一个电子邮件地址和支付方式(信用卡)[13.31]。

一旦您拥有 AWS 账户,您可以登录 AWS 控制台[13.32] 。从这里,我们基本上需要实例化一个虚拟机(AWS 实例),在这里我们可以开始使用 PyTorch 进行模型训练和服务。创建虚拟机需要做出两个决定[13.33]:

  • 选择虚拟机的硬件配置,也称为AWS 实例类型

  • 选择Amazon Machine ImageAMI),它包含了所需的所有软件,如操作系统(Ubuntu 或 Windows)、Python、PyTorch 等

. 通常情况下,当我们提到 AWS 实例时,我们指的是弹性云计算实例,也称为EC2实例。

根据虚拟机的计算需求(RAM、CPU 和 GPU),您可以从 AWS 提供的 EC2 实例长列表中进行选择[13.34] 。由于 PyTorch 需要大量 GPU 计算能力,建议选择包含 GPU 的 EC2 实例,尽管它们通常比仅有 CPU 的实例更昂贵。

关于 AMI,选择 AMI 有两种可能的方法。您可以选择仅安装操作系统(如 Ubuntu(Linux))的基本 AMI。在这种情况下,您可以手动安装 Python[13.35] ,随后安装 PyTorch[13.36] 。

另一种更推荐的方法是从已安装了 PyTorch 的预构建 AMI 开始。AWS 提供了深度学习 AMI,这大大加快了在 AWS 上开始使用 PyTorch 的过程[13.37] 。

一旦您成功启动了一个实例,可以使用各种可用的方法简单地连接到该实例[13.38] 。

SSH 是连接实例的最常见方式之一。一旦您进入实例,它将与在本地机器上工作的布局相同。然后,其中一个首要逻辑步骤将是测试 PyTorch 是否在该机器内正常工作。

要进行测试,首先在命令行上输入 python 来打开 Python 交互会话。然后执行以下代码:

import torch

如果执行时没有错误,这意味着您已经在系统上安装了 PyTorch。

此时,您可以简单地获取本章前几节中编写的所有代码。在您的主目录命令行中,通过运行以下命令简单地克隆本书的 GitHub 仓库:

git clone https://github.com/arj7192/MasteringPyTorchV2.git 

然后,在Chapter13子文件夹内,您将拥有在前几节中处理的 MNIST 模型的所有代码。您可以基本上重新运行这些练习,这次在 AWS 实例上而不是您的本地计算机上。

让我们回顾一下在 AWS 上使用 PyTorch 需要采取的步骤:

  1. 创建一个 AWS 账号。

  2. 登录到 AWS 控制台。

  3. 在控制台上,单击启动虚拟机按钮。

  4. 选择一个 AMI。例如,选择 Deep Learning AMI(Ubuntu)。

  5. 选择一个 AWS 实例类型。例如,选择p.2x large,因为它包含 GPU。

  6. 单击启动

  7. 单击创建新的密钥对。为密钥对命名并在本地下载。

  8. 通过在命令行上运行以下命令修改此密钥对文件的权限:

chmod 400 downloaded-key-pair-file.pem
  1. 在控制台上,单击查看实例以查看启动实例的详细信息,并特别注意实例的公共 IP 地址。

  2. 使用 SSH,在命令行上运行以下命令连接到实例:

ssh -i downloaded-key-pair-file.pem ubuntu@<Public IP address>

公共 IP 地址与上一步获取的相同。

  1. 连接后,在python shell 中启动并运行import torch,确保 PyTorch 正确安装在实例上。

  2. 在实例的命令行上运行以下命令,克隆本书的 GitHub 仓库:

git clone https://github.com/arj7192/MasteringPyTorchV2.git 
  1. 转到仓库中的chapter13文件夹,并开始处理本章前几节中涉及的各种模型服务练习。

这将带我们来到本节的结束,我们基本上学会了如何在远程 AWS 实例上开始使用 PyTorch [13.39]。接下来,我们将了解 AWS 的完全专用云机器学习平台 – Amazon SageMaker。

使用 TorchServe 与 Amazon SageMaker

我们已经在前面的章节详细讨论了 TorchServe。正如我们所知,TorchServe 是由 AWS 和 Facebook 开发的 PyTorch 模型服务库。您可以使用 TorchServe 而不是手动定义模型推理流水线、模型服务 API 和微服务,TorchServe 提供所有这些功能。

另一方面,Amazon SageMaker 是一个云机器学习平台,提供诸如训练大规模深度学习模型以及在自定义实例上部署和托管训练模型等功能。在使用 SageMaker 时,我们只需执行以下操作:

  • 指定我们想要启动以服务模型的 AWS 实例类型和数量。

  • 提供存储的预训练模型对象的位置。

我们不需要手动连接到实例并使用 TorchServe 提供模型服务。SageMaker 会处理所有事务。AWS 网站有一些有用的博客文章,可以帮助您开始使用 SageMaker 和 TorchServe 在工业规模上使用 PyTorch 模型,并在几次点击内完成 [13.40] 。AWS 博客还提供了在使用 PyTorch 时使用 Amazon SageMaker 的用例 [13.41] 。

诸如 SageMaker 等工具在模型训练和服务期间非常有用。然而,在使用这类一键式工具时,我们通常会失去一些灵活性和可调试性。因此,您需要决定哪一套工具最适合您的用例。这结束了我们关于使用 AWS 作为 PyTorch 的云平台的讨论。接下来,我们将看看另一个云平台 - Google Cloud。

在 Google Cloud 上提供 PyTorch 模型

与 AWS 类似,如果您还没有 Google 账户(*@gmail.com),则首先需要创建一个。此外,要能够登录 Google Cloud 控制台 [13.42] ,您需要添加一个付款方式(信用卡详细信息)。

注意

我们这里不会涵盖 Google Cloud 的基础知识 [13.43] 。相反,我们将专注于在 VM 中用于提供 PyTorch 模型的 Google Cloud 使用方法。

一旦进入控制台,我们需要按照类似 AWS 的步骤启动一个 VM,在其中可以提供我们的 PyTorch 模型。您始终可以从基础的 VM 开始,并手动安装 PyTorch。但是,我们将使用预先安装了 PyTorch 的 Google Deep Learning VM 镜像 [13.44] 。以下是启动 Google Cloud VM 并用其提供 PyTorch 模型的步骤:

  1. 在 Google Cloud Marketplace 上启动深度学习 VM 镜像 [13.45] 。

  2. 在命令窗口中输入部署名称。该名称后缀为 -vm 作为已启动 VM 的名称。该 VM 内的命令提示符如下:

<user>@<deployment-name>-vm:~/

在这里,user 是连接到 VM 的客户端,deployment-name 是在此步骤中选择的 VM 的名称。

  1. 在下一个命令窗口中选择 PyTorch 作为 Framework 。这告诉平台在 VM 中预安装 PyTorch。

  2. 为此机器选择区域。最好选择地理位置最接近您的区域。此外,不同的区域有略微不同的硬件配置(VM 配置),因此您可能需要为特定的机器配置选择特定的区域。

  3. 步骤 3 中指定了软件要求后,现在我们将指定硬件要求。在命令窗口的 GPU 部分,我们需要指定 GPU 类型,并随后指定要包含在 VM 中的 GPU 数量。

Google Cloud 提供各种 GPU 设备/配置 [13.46] 。在 GPU 部分,还要勾选自动安装 NVIDIA 驱动程序的复选框,这是利用深度学习 GPU 所必需的。

  1. 同样,在 CPU 部分下,我们需要提供机器类型[13.47]。关于步骤 5步骤 6,请注意,不同区域提供不同的机器和 GPU 类型,以及不同的 GPU 类型和 GPU 数量的组合。

  2. 最后,点击Deploy按钮。这将启动虚拟机,并带您到一个页面,该页面包含连接本地计算机到虚拟机所需的所有指令。

  3. 在此时,您可以连接到虚拟机,并通过尝试在 Python shell 中导入 PyTorch 来确保 PyTorch 已正确安装。验证后,克隆本书的 GitHub 存储库。转到Chapter13文件夹,并开始在该虚拟机中进行模型服务练习。

您可以阅读有关在 Google Cloud 博客上创建 PyTorch 深度学习虚拟机的更多信息[13.48]。这结束了我们关于使用 Google Cloud 作为与 PyTorch 模型服务相关的云平台的讨论。正如您可能注意到的那样,该过程与 AWS 非常相似。在接下来的最后一节中,我们将简要介绍使用 Microsoft 的云平台 Azure 来使用 PyTorch。

在 Azure 上提供 PyTorch 模型

再次强调,与 AWS 和 Google Cloud 类似,Azure 需要一个 Microsoft 认可的电子邮件 ID 来注册,以及一个有效的支付方式。

注意

我们假设您对 Microsoft Azure 云平台有基本的了解[13.49]。

一旦您访问到 Azure 门户[13.50],有两种推荐的方法可以开始使用 PyTorch 在 Azure 上进行工作:

  • 数据科学虚拟机DSVM

  • Azure 机器学习

现在我们将简要讨论这些方法。

在 Azure 的数据科学虚拟机上工作

与 Google Cloud 的深度学习虚拟机映像类似,Azure 提供了其自己的 DSVM 映像[13.51],这是一个专门用于数据科学和机器学习(包括深度学习)的完全专用虚拟机映像。

这些映像适用于 Windows[13.52]以及 Linux/Ubuntu[13.53]。

使用此映像创建 DSVM 实例的步骤与讨论的 Google Cloud 的步骤非常相似,适用于 Windows[13.54]和 Linux/Ubuntu[13.55]。

创建 DSVM 后,您可以启动 Python shell 并尝试导入 PyTorch 库,以确保其已正确安装。您还可以进一步测试 Linux[13.56]和 Windows[13.57]上此 DSVM 可用的功能。

最后,您可以在 DSVM 实例内克隆本书的 GitHub 存储库,并使用Chapter13文件夹中的代码来进行本章讨论的 PyTorch 模型服务练习。

讨论 Azure 机器学习服务

与 Amazon SageMaker 相似且早于其,Azure 提供了一个端到端的云机器学习平台。Azure 机器学习服务(AMLS)包括以下内容(仅举几例):

  • Azure 机器学习虚拟机

  • 笔记本

  • 虚拟环境

  • 数据存储

  • 跟踪机器学习实验

  • 数据标记

AMLS VMs 和 DSVMs 之间的一个关键区别在于前者是完全托管的。例如,它们可以根据模型训练或服务的需求进行横向扩展或缩减 [13.58]。

就像 SageMaker 一样,Azure 机器学习既适用于训练大规模模型,也适用于部署和提供这些模型的服务。Azure 网站提供了一个很好的教程,用于在 AMLS 上训练 PyTorch 模型,以及在 Windows [13.59] 和 Linux [13.60] 上部署 PyTorch 模型。

Azure 机器学习旨在为用户提供一键式界面,用于所有机器学习任务。因此,重要的是要考虑灵活性的权衡。虽然我们在这里没有涵盖 Azure 机器学习的所有细节,但 Azure 的网站是进一步阅读的好资源 [13.61]。

这就是我们对 Azure 作为云平台为处理 PyTorch 提供的一切的讨论的结束 [13.62]。

这也结束了我们关于在云端使用 PyTorch 为模型提供服务的讨论。在本节中,我们讨论了 AWS、Google Cloud 和 Microsoft Azure。虽然还有更多的云平台可供选择,但它们的提供方式以及在这些平台上使用 PyTorch 的方式与我们讨论的类似。这一节将帮助您开始在云端的 VM 上处理您的 PyTorch 项目。

总结

在本章中,我们探讨了在生产系统中部署训练好的 PyTorch 深度学习模型的世界。

在下一章中,我们将探讨与在 PyTorch 中使用模型相关的另一个实用方面,这在训练和验证深度学习模型时能极大地节省时间和资源。

第十六章:PyTorch 和 AutoML

加入我们的书籍社区,在 Discord 上交流讨论。

packt.link/EarlyAccessCommunity

img

自动化机器学习AutoML)为给定神经网络提供了寻找最佳神经架构和最佳超参数设置的方法。在讨论第五章混合高级模型中详细介绍了神经架构搜索,例如RandWireNN模型。

在本章中,我们将更广泛地探讨用于 PyTorch 的 AutoML 工具——Auto-PyTorch——它既执行神经架构搜索又执行超参数搜索。我们还将研究另一个名为Optuna的 AutoML 工具,它专门为 PyTorch 模型执行超参数搜索。

在本章末尾,非专家将能够设计具有少量领域经验的机器学习模型,而专家将大大加快其模型选择过程。

本章分解为以下主题:

  • 使用 AutoML 寻找最佳神经架构

  • 使用 Optuna 进行超参数搜索

使用 AutoML 寻找最佳神经架构

想象一下机器学习算法的一种方式是它们自动化了学习给定输入和输出之间关系的过程。在传统软件工程中,我们必须明确地编写/编码这些关系,以函数形式接受输入并返回输出。在机器学习世界中,机器学习模型为我们找到这样的函数。尽管我们在一定程度上实现了自动化,但还有很多工作要做。除了挖掘和清理数据外,还有一些例行任务需要完成以获得这些函数:

  • 选择机器学习模型(或者模型家族,然后再选择模型)

  • 决定模型架构(特别是在深度学习情况下)

  • 选择超参数

  • 根据验证集性能调整超参数

  • 尝试不同的模型(或者模型家族)

这些是需要人类机器学习专家的任务类型。大多数步骤都是手动的,要么耗时很长,要么需要大量专业知识以缩短所需时间,而我们缺少足够数量的机器学习专家来创建和部署越来越受欢迎、有价值且有用的机器学习模型,这在工业界和学术界都如此。

这就是 AutoML 发挥作用的地方。AutoML 已成为机器学习领域内的一个学科,旨在自动化前述步骤及更多内容。

在本节中,我们将看看 Auto-PyTorch——一个专为与 PyTorch 配合使用而创建的 AutoML 工具。通过一项练习,我们将找到一个最优的神经网络以及执行手写数字分类的超参数——这是我们在第一章使用 PyTorch 进行深度学习概述中进行的任务。

与第一章的不同之处在于,这一次我们不决定架构或超参数,而是让 Auto-PyTorch 为我们找出最佳方案。我们将首先加载数据集,然后定义一个 Auto-PyTorch 模型搜索实例,最后运行模型搜索例程,以提供最佳性能模型。

工具引用

Auto-PyTorch [16.1] Auto-PyTorch Tabular: 多精度元学习以实现高效和稳健的 AutoDLLucas ZimmerMarius LindauerFrank Hutter [16.2]

使用 Auto-PyTorch 进行最佳 MNIST 模型搜索

我们将以 Jupyter Notebook 的形式执行模型搜索。在文本中,我们只展示代码的重要部分。完整的代码可以在我们的 github 代码库中找到 [16.3]

加载 MNIST 数据集

现在我们将逐步讨论加载数据集的代码,如下所示:

  1. 首先,我们导入相关的库,如下所示:
import torch
from autoPyTorch import AutoNetClassification

最后一行非常关键,因为我们在这里导入相关的 Auto-PyTorch 模块。这将帮助我们设置和执行模型搜索会话。

  1. 接下来,我们使用 Torch 的应用程序编程接口 (APIs)加载训练和测试数据集,如下所示:
train_ds = datasets.MNIST(...)
test_ds = datasets.MNIST(...)
  1. 然后,我们将这些数据集张量转换为训练和测试的输入(X)和输出(y)数组,如下所示:
X_train, X_test, y_train, y_test = train_ds.data.numpy().reshape(-1, 28*28), test_ds.data.numpy().reshape(-1, 28*28) ,train_ds.targets.numpy(), test_ds.targets.numpy()

注意,我们正在将图像重塑为大小为 784 的扁平化向量。在下一节中,我们将定义一个期望扁平化特征向量作为输入的 Auto-PyTorch 模型搜索器,因此我们进行了重塑。

在撰写本文时,Auto-PyTorch 目前仅支持以特征化和图像数据的形式提供支持,分别为AutoNetClassificationAutoNetImageClassification。虽然在本练习中我们使用的是特征化数据,但我们留给读者的练习是改用图像数据[16.4] 。

运行使用 Auto-PyTorch 进行神经架构搜索

在上一节加载了数据集之后,我们现在将使用 Auto-PyTorch 定义一个模型搜索实例,并使用它来执行神经架构搜索和超参数搜索的任务。我们将按以下步骤进行:

  1. 这是练习中最重要的一步,我们在此定义一个autoPyTorch模型搜索实例,如下所示:
autoPyTorch = AutoNetClassification("tiny_cs",  # config preset
             log_level='info', max_runtime=2000, min_budget=100, max_budget=1500)

这里的配置是从 Auto-PyTorch 仓库提供的示例中衍生出来的 [16.5] 。但通常情况下,tiny_cs用于更快速的搜索,且硬件要求较少。

预算参数主要是为了设置对 Auto-PyTorch 过程资源消耗的限制。默认情况下,预算的单位是时间,即我们愿意在模型搜索上花费多少中央处理单元/图形处理单元CPU/GPU)时间。

  1. 实例化了一个 Auto-PyTorch 模型搜索实例后,我们通过尝试将实例适配到训练数据集上来执行搜索,如下所示:
autoPyTorch.fit(X_train, y_train, validation_split=0.1)

内部,Auto-PyTorch 将基于原始论文中提到的方法运行多个试验,尝试不同的模型架构和超参数设置 [16.2] 。

不同的试验将与 10%的验证数据集进行基准测试,并将最佳性能的试验作为输出返回。前述代码片段中的命令应该会输出以下内容:

图 16 .1 – Auto-PyTorch 模型准确性

图 16 .1 – Auto-PyTorch 模型准确性

图 16 .1 基本上展示了 Auto-PyTorch 为给定任务找到的最佳超参数设置,例如学习率为0.068,动量为0.934等。前面的截图还显示了所选最佳模型配置的训练集和验证集准确性。

  1. 已经收敛到最佳训练模型后,我们现在可以使用该模型对测试集进行预测,如下所示:
y_pred = autoPyTorch.predict(X_test)print("Accuracy score", np.mean(y_pred.reshape(-1) == y_test))

它应该输出类似于这样的内容:

图 16 .2 – Auto-PyTorch 模型准确性

图 16 .2 – Auto-PyTorch 模型准确性

正如我们所见,我们获得了一个测试集性能达到了 96.4%的模型。为了对比,随机选择将导致 10%的性能水平。我们在没有定义模型架构或超参数的情况下获得了这样的良好性能。在设置更高预算后,更广泛的搜索可能会导致更好的性能。

此外,性能将根据执行搜索的硬件(机器)而变化。具有更多计算能力和内存的硬件可以在相同的时间预算内运行更多搜索,因此可能导致更好的性能。

可视化最优 AutoML 模型

在本节中,我们将查看通过在前一节中运行模型搜索例程获得的最佳性能模型。我们将按以下步骤进行:

  1. 在前面的章节中已经查看了超参数,现在让我们看一下 Auto-PyTorch 为我们设计的最佳模型架构,如下所示:
pytorch_model = autoPyTorch.get_pytorch_model()
print(pytorch_model)

它应该输出类似于这样的内容:

图 16 .3 – Auto-PyTorch 模型架构

图 16 .3 – Auto-PyTorch 模型架构

该模型由一些结构化的残差块组成,其中包含全连接层、批量归一化层和 ReLU 激活函数。最后,我们看到一个最终的全连接层,具有 10 个输出,每个输出对应于从 0 到 9 的一个数字。

  1. 我们还可以使用torchviz来可视化实际的模型图,如下代码片段所示:
x = torch.randn(1, pytorch_model[0].in_features)
y = pytorch_model(x)
arch = make_dot(y.mean(), params=dict(pytorch_model.named_parameters()))
arch.format="pdf"
arch.filename = "convnet_arch"
arch.render(view=False)

这应该会在当前工作目录中保存一个convnet_arch.pdf文件,在打开时应该看起来像这样:

图 16 .4 – Auto-PyTorch 模型图示

图 16 .4 – Auto-PyTorch 模型图示

  1. 要查看模型如何收敛到此解决方案,我们可以查看在模型查找过程中使用的搜索空间代码如下:
autoPyTorch.get_hyperparameter_search_space()

这应该会输出以下内容:

图 16 .5 – Auto-PyTorch 模型搜索空间

图 16 .5 – Auto-PyTorch 模型搜索空间

它基本上列出了构建模型所需的各种要素,并为每个要素分配了一个范围。例如,学习率被分配了0.00010.1的范围,并且这个空间是以对数尺度进行采样——这不是线性采样而是对数采样。

图 16 .1中,我们已经看到了 Auto-PyTorch 从这些范围中采样的确切超参数值作为给定任务的最优值。我们还可以手动更改这些超参数范围,甚至添加更多超参数,使用 Auto-PyTorch 模块下的HyperparameterSearchSpaceUpdates子模块 [16.6] 。

这就结束了我们对 Auto-PyTorch 的探索——一个用于 PyTorch 的自动机器学习工具。我们成功地使用 Auto-PyTorch 构建了一个 MNIST 数字分类模型,而无需指定模型架构或超参数。此练习将帮助您开始使用此类和其他自动机器学习工具以自动化方式构建 PyTorch 模型。这里列出了一些类似的其他工具 - Hyperopt [16.7]、Tune [16.8]、Hypersearch [16.9]、Skorcj [16.10]、BoTorch [16.11] 和 Optuna [16.12]。

虽然我们在本章中无法涵盖所有这些工具,在下一节中我们将讨论 Optuna,这是一个专注于查找最佳超参数集的工具,并且与 PyTorch 兼容良好。

使用 Optuna 进行超参数搜索

Optuna 是支持 PyTorch 的超参数搜索工具之一。您可以详细了解该工具使用的搜索策略,如TPE(树形结构帕尔森估计)和CMA-ES(协方差矩阵适应进化策略),在Optuna论文 [16.13] 中。除了先进的超参数搜索方法,该工具还提供了一个简洁的 API,我们将在下一节中探讨。

工具引用

Optuna: 下一代超参数优化框架。

Takuya Akiba, Shotaro Sano, Toshihiko Yanase, Takeru OhtaMasanori Koyama(2019 年,KDD)。

在本节中,我们将再次构建和训练MNIST模型,这次使用 Optuna 来找出最佳的超参数设置。我们将逐步讨论代码的重要部分,以练习的形式进行。完整的代码可以在我们的 github [16.14]上找到。

定义模型架构和加载数据集

首先,我们将定义一个符合 Optuna 要求的模型对象。所谓 Optuna 兼容,是指在模型定义代码中添加 Optuna 提供的 API,以便对模型超参数进行参数化。为此,我们将按照以下步骤进行:

  1. 首先,我们导入必要的库,如下所示:
import torch
import optuna

optuna库将在整个练习中管理超参数搜索。

  1. 接下来,我们定义模型架构。因为我们希望对一些超参数(如层数和每层单位数)保持灵活,所以需要在模型定义代码中包含一些逻辑。因此,首先,我们声明需要在 14 个卷积层和之后的 12 个全连接层,如下面的代码片段所示:
class ConvNet(nn.Module):
    def __init__(self, trial):
        super(ConvNet, self).__init__()
        num_conv_layers =  trial.suggest_int("num_conv_layers", 1, 4)
        num_fc_layers = trial.suggest_int("num_fc_layers", 1, 2)
  1. 然后,我们逐个添加卷积层。每个卷积层紧接着一个 ReLU 激活层,对于每个卷积层,我们声明该层的深度在 1664 之间。

步幅和填充分别固定为 3True,整个卷积块之后是一个 MaxPool 层,然后是一个 Dropout 层,dropout 概率范围在 0.10.4 之间(另一个超参数),如下面的代码片段所示:

 self.layers = []
        input_depth = 1 # grayscale image
        for i in range(num_conv_layers):
            output_depth = trial.suggest_int(f"conv_depth_{i}", 16, 64)
            self.layers.append(nn.Conv2d(input_depth, output_depth, 3, 1))
            self.layers.append(nn.ReLU())
            input_depth = output_depth
        self.layers.append(nn.MaxPool2d(2))
        p = trial.suggest_float(f"conv_dropout_{i}", 0.1, 0.4)
        self.layers.append(nn.Dropout(p))
        self.layers.append(nn.Flatten())
  1. 接下来,我们添加一个展平层,以便后续可以添加全连接层。我们必须定义一个 _get_flatten_shape 函数来推导展平层输出的形状。然后,我们逐步添加全连接层,其中单位数声明为介于 1664 之间。每个全连接层后面跟着一个 Dropout 层,再次使用概率范围为 0.10.4

最后,我们附加一个固定的全连接层,输出 10 个数字(每个类别/数字一个),然后是一个 LogSoftmax 层。定义了所有层之后,我们实例化我们的模型对象,如下所示:

 input_feat = self._get_flatten_shape()
        for i in range(num_fc_layers):
            output_feat = trial.suggest_int(f"fc_output_feat_{i}", 16, 64)
            self.layers.append(nn.Linear(input_feat, output_feat))
            self.layers.append(nn.ReLU())
            p = trial.suggest_float(f"fc_dropout_{i}", 0.1, 0.4)
            self.layers.append(nn.Dropout(p))
            input_feat = output_feat
        self.layers.append(nn.Linear(input_feat, 10))
        self.layers.append(nn.LogSoftmax(dim=1))
        self.model = nn.Sequential(*self.layers)
    def _get_flatten_shape(self):
        conv_model = nn.Sequential(*self.layers)
        op_feat = conv_model(torch.rand(1, 1, 28, 28))
        n_size = op_feat.data.view(1, -1).size(1)
        return n_size

这个模型初始化函数是依赖于 trial 对象的条件设置,该对象由 Optuna 轻松处理,并决定我们模型的超参数设置。最后,forward 方法非常简单,可以在下面的代码片段中看到:

 def forward(self, x):
        return self.model(x)

因此,我们已经定义了我们的模型对象,现在可以继续加载数据集。

  1. 数据集加载的代码与 第一章,使用 PyTorch 进行深度学习概述 中相同,并在下面的代码片段中再次显示:
train_dataloader = torch.utils.data.DataLoader(...)
test_dataloader = ...

在本节中,我们成功地定义了我们的参数化模型对象,并加载了数据集。现在,我们将定义模型训练和测试程序,以及优化调度。

定义模型训练程序和优化调度

模型训练本身涉及超参数,如优化器、学习率等。在本练习的这一部分中,我们将定义模型训练过程,同时利用 Optuna 的参数化能力。我们将按以下步骤进行:

  1. 首先,我们定义训练例程。再次强调,这段代码与 第一章,使用 PyTorch 进行深度学习概述 中此模型的训练例程代码相同,并在此处再次显示:
def train(model, device, train_dataloader, optim, epoch):
    for b_i, (X, y) in enumerate(train_dataloader):
        …
  1. 模型测试例程需要稍作调整。为了按照 Optuna API 的要求操作,测试例程需要返回一个模型性能指标——在本例中是准确率,以便 Optuna 可以根据这一指标比较不同的超参数设置,如以下代码片段所示:
def test(model, device, test_dataloader):
    with torch.no_grad():
        for X, y in test_dataloader:
            …
    accuracy = 100\. * success/ len(test_dataloader.dataset)
    return accuracy
  1. 以前,我们会使用学习率来实例化模型和优化函数,并在任何函数外部启动训练循环。但是为了遵循 Optuna API 的要求,我们现在将所有这些都放在一个objective函数中进行,该函数接受与我们模型对象的__init__方法中传递的trial对象相同的参数。

这里也需要trial对象,因为涉及到决定学习率值和选择优化器的超参数,如以下代码片段所示:

def objective(trial):
    model = ConvNet(trial)
    opt_name = trial.suggest_categorical("optimizer", ["Adam", "Adadelta", "RMSprop", "SGD"])
    lr = trial.suggest_float("lr", 1e-1, 5e-1, log=True)
    optimizer = getattr(optim,opt_name)(model.parameters(), lr=lr)    
    for epoch in range(1, 3):
        train(model, device, train_dataloader, optimizer, epoch)
        accuracy = test(model, device,test_dataloader)
        trial.report(accuracy, epoch)
        if trial.should_prune():
            raise optuna.exceptions.TrialPruned()
    return accuracy

对于每个 epoch,我们记录模型测试例程返回的准确率。此外,在每个 epoch,我们还检查是否会剪枝——即是否会跳过当前 epoch。这是 Optuna 提供的另一个功能,用于加速超参数搜索过程,以避免在糟糕的超参数设置上浪费时间。

运行 Optuna 的超参数搜索

在这个练习的最后部分,我们将实例化所谓的Optuna study,并利用模型定义和训练例程,为给定的模型和给定的数据集执行 Optuna 的超参数搜索过程。我们将按如下步骤进行:

  1. 在前面的章节中准备了所有必要的组件后,我们已经准备好开始超参数搜索过程——在 Optuna 术语中称为study。一个trialstudy中的一个超参数搜索迭代。代码可以在以下代码片段中看到:
study = optuna.create_study(study_name="mastering_pytorch", direction="maximize")
study.optimize(objective, n_trials=10, timeout=2000)

direction参数帮助 Optuna 比较不同的超参数设置。因为我们的指标是准确率,我们将需要maximize这个指标。我们允许最多 2000 秒的study或最多 10 个不同的搜索——以先完成者为准。前述命令应输出以下内容:

图 16 .6 – Optuna 日志

图 16 .6 – Optuna 日志

正如我们所见,第三个trial是最优的试验,产生了 98.77%的测试集准确率,最后三个trials被剪枝。在日志中,我们还可以看到每个未剪枝trial的超参数。例如,在最优的trial中,有三个分别具有 27、28 和 46 个特征映射的卷积层,然后有两个分别具有 57 和 54 个单元/神经元的全连接层,等等。

  1. 每个trial都有一个完成或被剪枝的状态。我们可以用以下代码标记它们:
pruned_trials = [t for t in study.trials if t.state == optuna.trial.TrialState.PRUNED]complete_trials = [t for t in study.trials if t.state == optuna.trial.TrialState.COMPLETE]
  1. 最后,我们可以具体查看最成功trial的所有超参数,使用以下代码:
print("results: ")
trial = study.best_trial
for key, value in trial.params.items():
    print("{}: {}".format(key, value))

您将看到以下输出:

图 16 .7 – Optuna 最优超参数

图 16 .7 – Optuna 最优超参数

正如我们所见,输出显示了总trials数和执行的成功trials数。它进一步显示了最成功trial的模型超参数,如层数、层中神经元数量、学习率、优化进度等。

这将我们带到了练习的尾声。我们成功地使用 Optuna 定义了不同类型超参数的值范围,适用于手写数字分类模型。利用 Optuna 的超参数搜索算法,我们运行了 10 个不同的trials,在其中一个trial中获得了 98.77% 的最高准确率。最成功trial中的模型(架构和超参数)可以用于在更大数据集上进行训练,从而服务于生产系统。

使用本节的教训,您可以使用 Optuna 找到任何用 PyTorch 编写的神经网络模型的最佳超参数。如果模型非常庞大和/或需要调整的超参数过多,Optuna 也可以在分布式环境中使用 [16.15]。

最后,Optuna 不仅支持 PyTorch,还支持其他流行的机器学习库,如TensorFlowSklearnMXNet等等。

摘要

在本章中,我们讨论了自动机器学习(AutoML),旨在提供模型选择和超参数优化的方法。AutoML 对于初学者非常有用,他们在做出诸如模型中应放置多少层、使用哪种优化器等决策时缺乏专业知识。AutoML 对于专家也很有用,可以加快模型训练过程,发现给定任务的优越模型架构,这些任务手动推断几乎是不可能的。

在下一章中,我们将研究另一个越来越重要和关键的机器学习方面,特别是深度学习。我们将密切关注如何解释由 PyTorch 模型生成的输出——这一领域通常被称为模型可解释性或可解释性。

第十七章:PyTorch 和可解释 AI

在我们的 Discord 书籍社区中加入我们

packt.link/EarlyAccessCommunity

img

在本书中,我们构建了几个可以为我们执行不同任务的深度学习模型。例如,手写数字分类器,图像字幕生成器,情感分类器等等。尽管我们已经掌握了如何使用 PyTorch 训练和评估这些模型,但我们不知道这些模型在做出预测时内部究竟发生了什么。模型可解释性或解释性是机器学习的一个领域,我们在这个领域的目标是回答这样一个问题,为什么模型做出了那个预测?更详细地说,模型在输入数据中看到了什么,以做出特定的预测?

在本章中,我们将使用来自 第一章《使用 PyTorch 概述深度学习》的手写数字分类模型,来理解其内部工作原理,并因此解释模型为给定输入做出特定预测的原因。我们将首先使用纯粹的 PyTorch 代码来解剖模型。然后,我们将使用一种专门的模型可解释性工具包,称为Captum,进一步调查模型内部发生的情况。Captum 是一个专门为 PyTorch 提供模型解释工具的第三方库,包括基于图像和文本的模型。

本章应该为您提供解开深度学习模型内部的技能所必需的知识。以这种方式查看模型内部可以帮助您理解模型的预测行为。在本章的结尾,您将能够利用实践经验开始解释您自己的深度学习模型,使用 PyTorch(和 Captum)。

本章分解为以下主题:

  • PyTorch 中的模型可解释性

  • 使用 Captum 解释模型

PyTorch 中的模型可解释性

在本节中,我们将使用 PyTorch 对已训练的手写数字分类模型进行解剖,作为一项练习。更确切地说,我们将查看训练的手写数字分类模型的卷积层的详细信息,以了解模型从手写数字图像中学到了哪些视觉特征。我们将查看卷积滤波器/核心以及这些滤波器产生的特征图。

这些细节将帮助我们理解模型如何处理输入图像,从而进行预测。练习的完整代码可以在我们的 github 仓库 [13.1] 中找到。

训练手写数字分类器 - 重温

我们将快速回顾涉及训练手写数字分类模型的步骤,如下所示:

  1. 首先,我们导入相关的库,然后设置随机种子,以便能够重现这次练习的结果:
import torch
np.random.seed(123)
torch.manual_seed(123)
  1. 接下来,我们将定义模型架构:
class ConvNet(nn.Module):
    def __init__(self):
    def forward(self, x):
  1. 接下来,我们将定义模型的训练和测试过程:
def train(model, device, train_dataloader, optim,  epoch):
def test(model, device, test_dataloader):
  1. 然后,我们定义训练和测试数据集加载器:
train_dataloader = torch.utils.data.DataLoader(...)
test_dataloader = torch.utils.data.DataLoader(...)
  1. 接下来,我们实例化我们的模型,并定义优化计划:
device = torch.device("cpu")
model = ConvNet()
optimizer = optim.Adadelta(model.parameters(), lr=0.5)
  1. 最后,我们开始模型训练循环,训练我们的模型进行 20 个 epochs:
for epoch in range(1, 20):
    train(model, device, train_dataloader, optimizer, epoch)
    test(model, device, test_dataloader)

这应该输出以下内容:

图 13.1 – 模型训练日志

图 13.1 – 模型训练日志

  1. 最后,我们可以在一个样本测试图像上测试训练好的模型。这个样本测试图像的加载方式如下:
test_samples = enumerate(test_dataloader)
b_i, (sample_data, sample_targets) = next(test_samples)
plt.imshow(sample_data[0][0], cmap='gray', interpolation='none')
plt.show()

这应该输出以下内容:

图 13.2 – 一个手写图像示例

图 13.2 – 一个手写图像示例

  1. 然后,我们使用这个样本测试图像进行模型预测,如下所示:
print(f"Model prediction is : {model(sample_data).data.max(1)[1][0]}")
print(f"Ground truth is : {sample_targets[0]}")

这应该输出以下内容:

图 13.3 – 模型预测

图 13.3 – 模型预测

因此,我们已经训练了一个手写数字分类模型,并用它对一个样本图像进行了推断。现在我们将看看训练模型的内部结构。我们还将研究这个模型学习到了哪些卷积滤波器。

可视化模型的卷积滤波器

在本节中,我们将详细讨论已训练模型的卷积层,并查看模型在训练期间学习到的滤波器。这将告诉我们卷积层在输入图像上的操作方式,正在提取哪些特征等等:

  1. 首先,我们需要获取模型中所有层的列表,如下所示:
model_children_list = list(model.children())
convolutional_layers = []
model_parameters = []
model_children_list

这应该输出以下内容:

图 13.4 – 模型层

图 13.4 – 模型层

正如您所看到的,这里有 2 个卷积层,它们都有 3x3 大小的滤波器。第一个卷积层使用了16个这样的滤波器,而第二个卷积层使用了32个。我们重点在本练习中可视化卷积层,因为它们在视觉上更直观。然而,您可以通过类似的方式探索其他层,比如线性层,通过可视化它们学到的权重。

  1. 接下来,我们从模型中选择只有卷积层,并将它们存储在一个单独的列表中:
for i in range(len(model_children_list)):
    if type(model_children_list[i]) == nn.Conv2d:
        model_parameters.append(model_children_list[i].w      eight)
        convolutional_layers.append(model_children_list[i])

在这个过程中,我们还确保存储每个卷积层中学到的参数或权重。

  1. 现在我们准备好可视化卷积层学到的滤波器。我们从第一层开始,该层每个都有 16 个 3x3 大小的滤波器。下面的代码为我们可视化了这些滤波器:
plt.figure(figsize=(5, 4))
for i, flt in enumerate(model_parameters[0]):
    plt.subplot(4, 4, i+1)
    plt.imshow(flt[0, :, :].detach(), cmap='gray')
    plt.axis('off')
plt.show()

这应该输出以下内容:

图 13.5 – 第一个卷积层的滤波器

图 13.5 – 第一个卷积层的滤波器

首先,我们可以看到所有学习到的滤波器都略有不同,这是一个好迹象。这些滤波器通常在内部具有对比值,以便在图像周围卷积时提取某些类型的梯度。在模型推断期间,这 16 个滤波器中的每一个都会独立地在输入的灰度图像上操作,并产生 16 个不同的特征图,我们将在下一节中进行可视化。

  1. 类似地,我们可以使用与前一步骤相同的代码来可视化第二个卷积层学习到的 32 个滤波器,但需要进行以下更改:
plt.figure(figsize=(5, 8))
for i, flt in enumerate(model_parameters[1]):
plt.show()

这应该输出以下内容:

图 13.6 – 第二个卷积层的滤波器

图 13.6 – 第二个卷积层的滤波器

再次,我们有 32 个不同的滤波器/内核,它们具有对比值,旨在从图像中提取梯度。这些滤波器已经应用于第一个卷积层的输出,因此产生了更高级别的输出特征图。具有多个卷积层的 CNN 模型通常的目标是持续生成更复杂或更高级别的特征,可以表示复杂的视觉元素,例如面部的鼻子,道路上的交通灯等。

接下来,我们将看看这些卷积层在它们的输入上操作/卷积时产生了什么。

可视化模型的特征图

在这一部分中,我们将通过卷积层运行一个样本手写图像,并可视化这些层的输出:

  1. 首先,我们需要将每个卷积层输出的结果收集到一个列表中,可以通过以下代码实现:
per_layer_results = convolutional_layers[0]
for i in range(1, len(convolutional_layers)):
    per_layer_results.append(convolutional_layersi)

请注意,我们分别为每个卷积层调用前向传播,同时确保第 n 个卷积层接收第(n-1)个卷积层的输出作为输入。

  1. 现在我们可以可视化由这两个卷积层产生的特征图。我们将从第一层开始运行以下代码:
plt.figure(figsize=(5, 4))
layer_visualisation = per_layer_results[0][0, :, :, :]
layer_visualisation = layer_visualisation.data
print(layer_visualisation.size())
for i, flt in enumerate(layer_visualisation):
    plt.subplot(4, 4, i + 1)
    plt.imshow(flt, cmap='gray')
    plt.axis("off")
plt.show()
  1. 这应该输出以下内容:

图 13.7 – 第一个卷积层的特征图

图 13.7 – 第一个卷积层的特征图

数字(16, 26, 26)表示第一卷积层的输出维度。实际上,样本图像尺寸为(28, 28),滤波器尺寸为(3,3),并且没有填充。因此,生成的特征图大小为(26, 26)。由于有 16 个这样的特征图由 16 个滤波器产生(请参考图 13.5),因此总体输出维度为(16, 26, 26)。

正如您所看到的,每个滤波器从输入图像中生成一个特征图。此外,每个特征图代表图像中的不同视觉特征。例如,左上角的特征图基本上颠倒了图像中的像素值(请参考图 13.2),而右下角的特征图表示某种形式的边缘检测。

这些 16 个特征图然后传递到第二个卷积层,其中另外 32 个滤波器分别在这 16 个特征图上卷积,产生 32 个新的特征图。我们接下来将查看这些特征图。

  1. 我们可以使用与前面类似的代码,稍作更改(如下面的代码所示),来可视化下一个卷积层产生的 32 个特征图:
plt.figure(figsize=(5, 8))
layer_visualisation = per_layer_results[1][0, :, :, :]
    plt.subplot(8, 4, i + 1)
plt.show()

这应该输出以下内容:

图 13.8 – 第二个卷积层的特征图

图 13.8 – 第二个卷积层的特征图

与之前的 16 个特征图相比,这 32 个特征图显然更复杂。它们似乎不仅仅是边缘检测,这是因为它们已经在第一个卷积层的输出上操作,而不是原始输入图像。

在这个模型中,2 个卷积层之后是 2 个线性层,分别有(4,608x64)和(64x10)个参数。虽然线性层的权重也有助于可视化,但参数数量(4,608x64)的视觉化分析看起来实在太多了。因此,在本节中,我们将仅限于卷积权重的视觉分析。

幸运的是,我们有更复杂的方法来解释模型预测,而不需要查看如此多的参数。在下一节中,我们将探讨 Captum,这是一个与 PyTorch 配合使用的机器学习模型解释工具包,可以在几行代码内帮助我们解释模型决策。

使用 Captum 解释模型

Captum [13.2] 是由 Facebook 在 PyTorch 上构建的开源模型解释库,目前(撰写本文时)正在积极开发中。在本节中,我们将使用前面章节中训练过的手写数字分类模型。我们还将使用 Captum 提供的一些模型解释工具来解释该模型所做的预测。此练习的完整代码可以在我们的 github 代码库 [13.3] 中找到。

设置 Captum

模型训练代码类似于“训练手写数字分类器 – 总结”部分中显示的代码。在接下来的步骤中,我们将使用训练好的模型和一个样本图像,来理解模型在为给定图像进行预测时内部发生了什么:

  1. 有几个与 Captum 相关的额外导入,我们需要执行,以便使用 Captum 的内置模型解释功能:
from captum.attr import IntegratedGradients
from captum.attr import Saliency
from captum.attr import DeepLift
from captum.attr import visualization as viz
  1. 要对输入图像进行模型的前向传递,我们将输入图像重塑为与模型输入大小相匹配:
captum_input = sample_data[0].unsqueeze(0)
captum_input.requires_grad = True
  1. 根据 Captum 的要求,输入张量(图像)需要参与梯度计算。因此,我们将输入的 requires_grad 标志设置为 True

  2. 接下来,我们准备样本图像,以便通过模型解释方法进行处理,使用以下代码:

orig_image = np.tile(np.transpose((sample_data[0].cpu().detach().numpy() / 2) + 0.5, (1, 2, 0)), (1,1,3))
_ = viz.visualize_image_attr(None, orig_image, cmap='gray', method="original_image", title="Original Image")

这应该输出以下内容:

图 13.9 – 原始图像

图 13.9 – 原始图像

我们已经在深度维度上平铺了灰度图像,以便 Captum 方法能够处理,这些方法期望一个 3 通道图像。

接下来,我们将实际应用一些 Captum 的解释性方法,通过预训练的手写数字分类模型对准备的灰度图像进行前向传递。

探索 Captum 的可解释性工具

在本节中,我们将探讨 Captum 提供的一些模型可解释性方法。

解释模型结果的最基本方法之一是观察显著性,它表示输出(在本例中是类别 0)关于输入(即输入图像像素)的梯度。对于特定输入,梯度越大,该输入越重要。您可以在原始的显著性论文[13.4]中详细了解这些梯度的计算方式。Captum 提供了显著性方法的实现:

  1. 在以下代码中,我们使用 Captum 的 Saliency 模块计算梯度:
saliency = Saliency(model)
gradients = saliency.attribute(captum_input, target=sample_targets[0].item())
gradients = np.reshape(gradients.squeeze().cpu().detach().numpy(), (28, 28, 1))
_ = viz.visualize_image_attr(gradients, orig_image, method="blended_heat_map", sign="absolute_value",
show_colorbar=True, title="Overlayed Gradients")

这应该输出如下结果:

图 13.10 – 叠加梯度

图 13.10 – 叠加梯度

在前面的代码中,我们将获得的梯度重塑为 (28,28,1) 的大小,以便在原始图像上叠加显示,如前面的图示所示。Captum 的 viz 模块为我们处理了可视化。我们还可以使用以下代码仅可视化梯度,而不显示原始图像:

plt.imshow(np.tile(gradients/(np.max(gradients)), (1,1,3)));

我们将获得以下输出:

图 13.11 – 梯度

图 13.11 – 梯度

正如你所看到的,梯度分布在图像中那些可能包含数字0的像素区域。

  1. 接下来,我们将采用类似的代码方式,研究另一种可解释性方法 - 综合梯度。通过这种方法,我们将寻找特征归因特征重要性。也就是说,我们将寻找在进行预测时使用的哪些像素是重要的。在综合梯度技术下,除了输入图像外,我们还需要指定一个基线图像,通常将其设置为所有像素值均为零的图像。

然后,沿着从基线图像到输入图像的路径计算梯度的积分。关于综合梯度技术的实现细节可以在原始论文[13.5]中找到。以下代码使用 Captum 的 IntegratedGradients 模块推导每个输入图像像素的重要性:

integ_grads = IntegratedGradients(model)
attributed_ig, delta=integ_grads.attribute(captum_input, target=sample_targets[0], baselines=captum_input * 0, return_convergence_delta=True)
attributed_ig = np.reshape(attributed_ig.squeeze().cpu().detach().numpy(), (28, 28, 1))
_ = viz.visualize_image_attr(attributed_ig, orig_image, method="blended_heat_map",sign="all",show_colorbar=True, title="Overlayed Integrated Gradients")

这应该输出如下结果:

图 13.12 – 叠加的综合梯度

图 13.12 – 叠加的综合梯度

如预期的那样,梯度在包含数字0的像素区域中较高。

  1. 最后,我们将研究另一种基于梯度的归因技术,称为深度提升。除了输入图像外,深度提升还需要一个基线图像。再次,我们使用所有像素值设置为零的图像作为基线图像。深度提升计算非线性激活输出相对于从基线图像到输入图像的输入变化的梯度(图 13.9)。以下代码使用 Captum 提供的 DeepLift 模块计算梯度,并将这些梯度叠加显示在原始输入图像上:
deep_lift = DeepLift(model)
attributed_dl = deep_lift.attribute(captum_input, target=sample_targets[0], baselines=captum_input * 0, return_convergence_delta=False)
attributed_dl = np.reshape(attributed_dl.squeeze(0).cpu().detach().numpy(), (28, 28, 1))
_ = viz.visualize_image_attr(attributed_dl, orig_image, method="blended_heat_map",sign="all",show_colorbar=True, title="Overlayed DeepLift")

你应该看到以下输出:

图 13.13 – 覆盖的 deeplift

图 13.13 – 覆盖的 deeplift

再次强调,梯度值在包含数字0的像素周围是极端的。

这就结束了本练习和本节。Captum 提供了更多的模型解释技术,例如LayerConductanceGradCAMSHAP [13.6]。模型解释性是一个活跃的研究领域,因此像 Captum 这样的库可能会迅速发展。在不久的将来,可能会开发出更多类似的库,这些库将使模型解释成为机器学习生命周期的标准组成部分。

摘要

在本章中,我们简要探讨了如何使用 PyTorch 解释或解读深度学习模型所做决策的方法。

在本书的下一章中,我们将学习如何在 PyTorch 上快速训练和测试机器学习模型——这是一个用于快速迭代各种机器学习想法的技能。我们还将讨论一些能够使用 PyTorch 进行快速原型设计的深度学习库和框架。

posted @ 2024-07-23 14:53  绝不原创的飞龙  阅读(20)  评论(0编辑  收藏  举报