精通-PyTorch-第二版-一-

67 阅读1小时+

精通 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 通过生成式深度学习模型来生成音乐和文本。