精通 PyTorch——结合 CNN 和 LSTM

4,195 阅读23分钟

卷积神经网络(CNNs)是一种深度学习模型,用于解决与图像、视频、语音和音频相关的机器学习问题,例如图像分类、目标检测、分割、语音识别、音频分类等。这是因为CNNs使用了一种特殊类型的层,称为卷积层,这些层具有共享的可学习参数。权重或参数共享之所以有效,是因为在图像中要学习的模式(如边缘或轮廓)被假设为与图像中像素的位置无关。正如CNNs被应用于图像一样,长短期记忆网络(LSTM)——一种递归神经网络(RNN)——在解决与序列数据相关的机器学习问题时表现得极为有效。序列数据的一个例子可能是文本。例如,在一个句子中,每个词都依赖于前一个词或词组。LSTM模型旨在建模这种序列依赖关系。

这两种不同类型的网络——CNNs和LSTMs——可以结合起来形成一个多模态模型,该模型接收图像或视频并输出文本。这样一种混合模型的一个著名应用是图像描述生成,其中模型接收一张图像并输出对图像的合理文本描述。自2010年以来,机器学习已被用于执行图像描述生成任务。然而,神经网络首次成功用于这一任务是在2014/2015年左右。自那时以来,图像描述生成一直在积极研究中。随着每年的显著进展,这一深度学习应用有望用于现实世界中的应用,例如生成网站上的替代文本,以提高对视障人士的可访问性。

本章首先讨论这种多模态模型的架构,以及在PyTorch中的相关实现细节。在章节末尾,我们将使用PyTorch从头开始构建一个图像描述生成系统。本章涵盖以下主题:

  • 使用CNNs和LSTMs构建神经网络
  • 使用PyTorch构建图像描述生成器

本章的所有代码文件可以在以下网址找到:github.com/arj7192/Mas…

现在,让我们首先讨论结合CNN和LSTM的架构。

构建一个包含CNN和LSTM的神经网络

CNN-LSTM网络架构包括一个或多个卷积层,用于从输入数据(图像)中提取特征,然后是一个或多个LSTM层,用于执行序列预测。这种模型在空间上深度得益于CNN组件,在时间上则得益于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单元。因此生成的整体架构可以如图3.1所示进行可视化:

image.png

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

在下一节中,我们将实现一个图像描述生成系统,使用PyTorch,包括构建混合模型架构以及数据加载、预处理、模型训练和模型评估的流程。

使用PyTorch构建图像描述生成器

在这个练习中,我们将使用“常见对象背景”(COCO)数据集,这是一份大规模的目标检测、分割和描述数据集。

该数据集包含超过200,000张标注图像,每张图像都有五个描述。COCO数据集在2014年出现,并在目标识别相关的计算机视觉任务的发展中发挥了重要作用。它是用于基准测试任务(如目标检测、目标分割、实例分割和图像描述生成)中最常用的数据集之一。

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

我们将仅引用重要的代码片段以作说明。完整的练习代码可以在我们的GitHub仓库中找到。

下载图像描述数据集

在开始构建图像描述生成系统之前,我们需要下载所需的数据集。如果你尚未下载数据集,可以通过Jupyter Notebook运行以下脚本。这将帮助你在本地下载数据集。

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

训练数据集和验证数据集分别为13 GB和6 GB。下载和解压数据集文件以及清理和处理这些文件可能需要一些时间。一个好的方法是按照以下步骤执行这些操作,并让它们在晚上完成:

# 下载图像和注释到数据目录
!wget http://images.cocodataset.org/annotations/annotations_trainval2014.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/

# 解压缩图像和注释,并删除压缩文件
!unzip ./data_dir/annotations_trainval2014.zip -d ./data_dir/
!rm ./data_dir/annotations_trainval2014.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

你应该会看到如下输出:

--2022-11-30 11:15:15--  http://images.cocodataset.org/annotations/annotations_trainval2014.zip
Resolving images.cocodataset.org (images.cocodataset.org)... 52.217.92.164, 52.216.240.252, 52.217.107.92, …
Connecting to images.cocodataset.org (images.cocodataset.org)|52.217.92.164|:80... connected.
HTTP request sent, awaiting response... 200 OK
Length: 252872794 (241M) [application/zip]
Saving to: './data_dir/annotations_trainval2014.zip'
annotations_trainva 100%[===================>] 241.16M  6.50MB/s    in 34s      
...
extracting: ./data_dir/val2014/COCO_val2014_000000551804.jpg  
extracting: ./data_dir/val2014/COCO_val2014_000000045516.jpg
extracting: ./data_dir/val2014/COCO_val2014_000000347233.jpg
extracting: ./data_dir/val2014/COCO_val2014_000000154202.jpg   
extracting: ./data_dir/val2014/COCO_val2014_000000038210.jpg  

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

预处理描述(文本)数据

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

在这个练习中,我们需要导入一些依赖库。本章中我们将导入的一些关键模块如下:

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')

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

接下来,我们构建词汇表——即一个可以将实际的文本令牌(如单词)转换为数字令牌的字典。这个步骤对于大多数文本相关任务至关重要:

def build_vocabulary(json, threshold):
    """构建词汇表包装器。"""
    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文本注释,并将注释/描述中的单词分词或转换为数字并存储在计数器中。

然后,丢弃出现次数少于一定数量的令牌,将剩余的令牌添加到词汇表对象中,并加入一些通配符令牌——开始(句子)、结束、未知词和填充令牌,如下所示:

    # 如果词频 < 'thres',则丢弃该词。
    tokens = [token for token, cnt in counter.items() if cnt >= threshold]
    # 创建词汇表包装器 + 添加特殊令牌。
    vocab = Vocab()
    vocab.add_token('<pad>')
    vocab.add_token('<start>')
    vocab.add_token('<end>')
    vocab.add_token('<unk>')
    # 将单词添加到词汇表中。
    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))

输出如下:

loading annotations into memory...
Done (t=0.67s)
creating index...
index created!
[1000/414113] Tokenized the captions.
[2000/414113] Tokenized the captions.
[3000/414113] Tokenized the captions.
...
[412000/414113] Tokenized the captions.
[413000/414113] Tokenized the captions.
[414000/414113] Tokenized the captions.
Total vocabulary size: 9948
Saved the vocabulary wrapper to './data_dir/vocabulary.pkl'

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

图像数据的预处理

在下载数据和构建了文本描述的词汇表之后,我们需要对图像数据进行一些预处理:

由于数据集中图像的大小和形状各不相同,我们需要将所有图像调整为固定的形状,以便它们可以被输入到我们的 CNN 模型的第一层中。具体来说,我们将图像调整为 256 x 256 像素:

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)

执行上述代码后,输出将如下所示:

[100/82783] Resized the images and saved into './data_dir/resized_images/'.
[200/82783] Resized the images and saved into './data_dir/resized_images/'.
[300/82783] Resized the images and saved into './data_dir/resized_images/'.
...
[82500/82783] Resized the images and saved into './data_dir/resized_images/'.
[82600/82783] Resized the images and saved into './data_dir/resized_images/'.
[82700/82783] Resized the images and saved into './data_dir/resized_images/'.

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

定义图像描述数据加载器

我们已经下载并预处理了图像描述数据。现在,接下来是将这些数据转换为 PyTorch 数据集对象。这个数据集对象可以随后用于定义一个 PyTorch 数据加载器对象,我们将在训练循环中使用它来提取数据批次:

我们将实现自定义的 Dataset 模块和数据加载器:

class CustomCocoDataset(data.Dataset):
    """COCO 数据集,兼容 torch.utils.data.DataLoader。"""
    def __init__(self, data_path, coco_json_path, vocabulary, transform=None):
        """设置图像、文本和词汇表的路径。

        参数:
            data_path: 图像目录。
            coco_json_path: COCO 注释文件路径。
            vocabulary: 词汇表包装器。
            transform: 图像变换器。
        """
        ...
        
    def __getitem__(self, idx):
        """返回一个数据样本 (X, y)。"""
        ...
        return image, ground_truth

    def __len__(self):
        return len(self.indices)

首先,为了定义自定义的 PyTorch Dataset 对象,我们定义了 __init____getitem____len__ 方法,用于实例化、提取项目和返回数据集的大小。

接下来,我们定义 collate_function,它将数据批次分组为 X 和 y,如下所示:

def collate_function(data_batch):
    """创建数据的小批次
    我们构建自定义的 collate 函数
    而不是使用标准的 collate 函数,
    因为标准版本不支持填充。
    参数:
        data: (图像, 描述) 元组的列表。
            - 图像: 形状为 (3, 256, 256) 的张量。
            - 描述: 形状为 (:); 变长。
    返回:
        images: 形状为 (batch_size, 3, 256, 256) 的张量。
        targets: 形状为 (batch_size, padded_length) 的张量。
        lengths: 列表。
    """
    ...
    return imgs, tgts, cap_lens

通常,我们不需要编写自己的 collate 函数,但为了处理变长句子,我们需要这样做。当句子的长度(比如,k)小于固定长度 n 时,我们需要用填充标记填充 n-k 个标记,通过 pack_padded_sequence 函数来实现。

最后,我们将实现 get_loader 函数,该函数返回 COCO 数据集的自定义数据加载器,如下所示:

def get_loader(data_path, coco_json_path, vocabulary, transform, batch_size, shuffle):
    # COCO 数据集
    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 模型

现在我们已经设置了数据管道,接下来我们将定义模型架构,具体如下:

class CNNModel(nn.Module):
    def __init__(self, embedding_size):
        """加载预训练的 ResNet-152 并替换最后的全连接层。"""
        super(CNNModel, self).__init__()
        resnet = models.resnet152(pretrained=True)
        module_list = list(resnet.children())[:-1]
        # 删除最后的全连接层
        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):
        """从图像中提取特征。"""
        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 架构。如我们在第 2 章《深度 CNN 架构》中所学,这个深度 CNN 模型包含 152 层,并在 ImageNet 数据集上进行了预训练。ImageNet 数据集包含超过 140 万张 RGB 图像,标记在 1000 个类别中。这些 1000 个类别包括植物、动物、食物、体育等。

我们移除了这个预训练 ResNet 模型的最后一层,并用一个全连接层和一个批量归一化层替换了它。

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

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

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

批量归一化层将全连接层的输出标准化为均值为 0、标准差为 1。这类似于我们使用 torch.transforms 进行的标准输入数据归一化。批量归一化有助于限制隐藏层输出值的波动范围,并通常有助于更快的学习。我们可以使用更高的学习率,因为优化超平面更加均匀(均值为 0,标准差为 1)。

由于这是 CNN 子模型的最后一层,批量归一化有助于保护 LSTM 子模型免受 CNN 可能引入的数据偏移。如果不使用批量归一化,最坏的情况下,CNN 最后一层可能输出均值 > 0.5 和标准差 = 1 的值(假设训练数据有有限的数据分布)。但在推理过程中,如果某张图像的 CNN 输出均值 < 0.5 和标准差 = 1 的值,那么 LSTM 子模型将难以处理这种未预见的数据分布。因此,我们需要使用批量归一化来标准化 CNN 输出,这些输出实际上成为了 LSTM 的输入,从而确保 LSTM 不会产生意外的输出。

接下来,我们将这个嵌入向量输入到 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> 标记会被追加到输出句子中,完成的句子就是我们对图像的预测描述。

注意,我们还指定了最大允许的序列长度为 20,设置在 max_seq_len 变量中。这意味着任何短于 20 个单词的句子将在末尾填充空单词标记,长度超过 20 个单词的句子将被缩减到前 20 个单词。

为什么这么做,为什么是 20? 如果我们真的希望 LSTM 处理任何长度的句子,可能会将这个变量设置为一个非常大的值,比如 9,999 个单词。然而,(a) 很少有图像描述有这么多单词,并且 (b) 更重要的是,如果有这种超长的离群句子,LSTM 将难以学习跨这么多时间步的时间模式。

我们知道 LSTM 在处理更长序列时优于 RNN,但保留如此长序列的记忆是困难的。我们选择 20 作为一个合理的数字,考虑到通常的图像描述长度和我们希望模型生成的最大描述长度。

LSTM 层和线性层对象均继承自 nn.Module,我们定义了 __init__forward 方法,以构造模型和通过模型运行前向传递。对于 LSTM 模型,我们还实现了一个 sample 方法,如下所示,该方法将用于生成给定图像的描述:

def sample(self, input_features, lstm_states=None):
    """使用贪婪搜索生成描述。"""
    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 模型。让我们逐步查看这一过程的详细信息:

首先,我们定义设备。如果有 GPU 可用,则使用 GPU 进行训练;否则,使用 CPU:

python
复制代码
# 设备配置
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

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

常见问题 – 为什么需要对数据进行归一化?

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

我们将使用 PyTorch 的变换模块来归一化输入图像的像素值:

python
复制代码
# 图像预处理,针对预训练的 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))])

此外,我们还会对数据集进行增强。

常见问题 – 为什么需要数据增强?

数据增强不仅有助于生成更大的训练数据集,而且有助于使模型对输入数据的潜在变化具有鲁棒性。

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

  1. 随机裁剪:将图像的大小从 (256, 256) 减少到 (224, 224)。这帮助我们从单张图像中生成多张图像,通过裁剪图像的不同部分,从而使模型学习到更加鲁棒的图像表示,而不仅仅是固定的 (256, 256) 图像。
  2. 水平翻转:将图像水平翻转,这将产生比原来更多的图像,数据总是越多越好。对于给定的数据集,水平翻转不会扭曲图像的基本意义——翻转后的狗仍然是狗。因此,这种增强方法是合理的。如果我们检测的是数字,那么水平翻转数字 5 的图像,并仍然训练模型将其分类为 5,会使模型感到困惑,因为翻转后的图像看起来更像 2 而不是 5。

接下来,我们加载在“预处理标题(文本)数据”部分构建的词汇表。我们还使用在“定义图像描述数据加载器”部分定义的 get_loader() 函数初始化数据加载器:

python
复制代码
# 加载词汇表包装器
with open('data_dir/vocabulary.pkl', 'rb') as f:
    vocabulary = pickle.load(f)

# 实例化数据加载器
custom_data_loader = get_loader('data_dir/resized_images',
    'data_dir/annotations/captions_train2014.json',
    vocabulary, transform, 128, shuffle=True)

接下来,我们进入本步骤的主要部分,即以编码器和解码器模型的形式实例化 CNN 和 LSTM 模型。此外,我们还定义了损失函数——交叉熵损失——以及优化计划——Adam 优化器,如下所示:

python
复制代码
# 构建模型
encoder_model = CNNModel(256).to(device)
decoder_model = LSTMModel(256, 512,
                          len(vocabulary), 1).to(device)

# 损失函数与优化器
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 优化器可能是处理稀疏数据时的最佳选择。在这里,我们处理的是图像和文本——它们都是稀疏数据的典型例子,因为并非所有像素都包含有用信息,而数字化/向量化的文本本身就是一个稀疏矩阵。

最后,我们运行训练循环(五个 Epoch),在训练过程中使用数据加载器获取 COCO 数据集的小批次,通过编码器和解码器网络进行前向传播,并最终使用反向传播(LSTM 网络的时间反向传播)调整 CNN-LSTM 模型的参数:

python
复制代码
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]
        # 前向传播,反向传播
        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 的训练,如下所示:

python
复制代码
# 记录训练步骤
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())))
# 保存模型检查点
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)))

输出将如下所示:

yaml
复制代码
加载注释到内存中...
完成 (t=0.65s)
创建索引...
索引创建完成!
Epoch [0/5], Step [0/3236], Loss: 9.2049, Perplexity: 9946.0549
Epoch [0/5], Step [10/3236], Loss: 5.7688, Perplexity: 320.1389
Epoch [0/5], Step [20/3236], Loss: 5.4142, Perplexity: 224.5734
...
Epoch [2/5], Step [300/3236], Loss: 2.0740, Perplexity: 7.9563
Epoch [2/5], Step [310/3236], Loss: 1.9858, Perplexity: 7.2848
Epoch [2/5], Step [320/3236], Loss: 2.0391, Perplexity: 7.6838

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

在前面的部分,我们已经训练了图像标题生成模型。在本节中,我们将使用训练好的模型为模型之前未见过的图像生成标题:

我们已经存储了一张示例图像 sample.jpg 以进行推断。我们定义一个函数来加载图像并将其调整为 (224, 224) 像素。然后,我们定义变换模块来归一化图像像素,如下所示:

image_file_path = 'sample.jpg'
# 设备配置
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

# 图像预处理
transform = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize((0.485, 0.456, 0.406),
                         (0.229, 0.224, 0.225))])

接下来,我们加载词汇表并实例化编码器和解码器模型:

# 加载词汇表包装器
with open('data_dir/vocabulary.pkl', 'rb') as f:
    vocabulary = pickle.load(f)

# 构建模型
encoder_model = CNNModel(256).eval()
# 评估模式(batchnorm 使用移动均值/方差)
decoder_model = LSTMModel(256, 512, len(vocabulary), 1)
encoder_model = encoder_model.to(device)
decoder_model = decoder_model.to(device)

一旦模型框架准备好,我们将使用训练的两个 Epoch 中的最新保存检查点来设置模型参数:

# 加载训练好的模型参数
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'))

此时,模型已准备好进行推断。

接下来,我们加载图像并运行模型推断——首先,我们使用编码器模型从图像中生成嵌入,然后将嵌入输入到解码器网络中以生成序列,如下所示:

# 准备图像
img = load_image(image_file_path, transform)
img_tensor = img.to(device)

# 从图像生成标题文本
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)

此时,标题预测仍然是数字令牌的形式。我们需要使用词汇表将数字令牌转换为实际文本:

# 将数字令牌转换为文本令牌
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)

一旦我们将输出转换为文本,就可以同时可视化图像和生成的标题:

# 打印图像和生成的标题文本
print(predicted_sentence)
img = Image.open(image_file_path)
plt.imshow(np.asarray(img))

image.png

尽管模型尚不完美,但在两个 Epoch 内,它已经被训练得足够好,可以生成合理的标题。

总结

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

虽然我们在之前的章节中主要使用了 CNN,但在下一章中,我们将深入探讨递归模型,例如本章中使用的 LSTM 模型。我们将探索不同的递归网络架构,并学习如何使用 PyTorch 来应用它们。