PyTorch 深度学习——预训练网络

27 阅读41分钟

本章涵盖以下内容

  • 运行预训练的图像识别模型
  • 使用预训练的 Transformer 和扩散模型
  • 通过 Hugging Face 访问模型
  • 使用预训练模型为图像生成说明文字

在第一章中,我们已经提到过深度学习所具有的变革性潜力,而现在,是时候真正展示它了。计算机视觉无疑是受深度学习兴起影响最深的领域之一,原因有很多。随着对自然图像内容进行分类或理解的需求不断增长,规模极大的数据集开始出现;与此同时,卷积层之类的新结构也被发明出来,并且能够在 GPU 上快速运行,达到前所未有的精度。所有这些因素,再加上互联网巨头们强烈希望理解数百万用户通过移动设备拍摄、并托管在其平台上的图片,共同构成了一场几乎完美的“风暴”。

如果你是从其他深度学习框架转到 PyTorch,而且更想直接进入 PyTorch 的具体机制与底层细节,那么跳到下一章也无妨。本章涉及的内容,与其说是基础,不如说更偏有趣,而且在某种程度上并不依赖于某一个具体的深度学习工具。这并不是说它们不重要!但如果你已经在其他深度学习框架里使用过预训练模型,那你本来就知道它们有多强大。若你也已经熟悉基于扩散的图像生成与图像修补(inpainting),那我们确实没必要再向你解释这些概念。

不过,我们还是希望你继续读下去,因为这一章把一些很重要的技能“藏”在了这些有趣的内容下面。学会如何用 PyTorch 运行一个预训练模型,本身就是一项非常有用的技能——句号,没什么可补充的。尤其是当这个模型已经在大规模数据集上训练过时,这项技能会格外有价值。我们需要逐渐熟悉这样一整套机制:如何获取一个神经网络、如何把真实世界的数据送入其中运行、以及如何把它的输出可视化并加以评估——不管这个模型是不是我们自己训练出来的。

我们将学习如何借助该领域顶尖研究者的成果:下载并运行一些已经在公开大规模数据集上训练好的、非常有意思的模型。我们可以把一个预训练神经网络看成一个程序:它接收输入,然后生成输出。这样一个“程序”的行为,是由神经网络的架构,以及它在训练过程中看过的样例共同决定的;这些样例可能表现为期望的输入—输出对,也可能表现为输出应当满足的某些性质。直接使用现成模型(off-the-shelf model),往往是快速启动一个深度学习项目的捷径,因为它既借用了设计该模型的研究人员的专业知识,也继承了他们在训练权重时所投入的大量计算时间。

在本章中,我们将探索四种流行的预训练模型:一种能够根据图像内容为图像打标签的模型,一种能够基于真实图像“捏造”出新图像的模型,一种能够替你续写句子的模型,以及一种能够用合乎英语表达习惯的完整句子来描述图像内容的模型。我们将学习如何在 PyTorch 中加载并运行这些预训练模型,同时也会介绍 Hugging Face——一个使 PyTorch 模型(比如我们即将讨论的这些预训练模型)能够通过统一接口方便发布与访问的工具。在这个过程中,我们还会讨论数据来源、定义诸如 label 这样的术语,并把一匹马变成一只斑马。

2.1 一个能够识别图像主体的预训练网络

作为我们首次真正进入深度学习世界的尝试,我们将运行一个在目标识别任务上预训练好的、最先进的深度神经网络。通过 PyTorch 的 torchvision 以及 Hugging Face 这类专门的仓库和接口,目前已经可以获取大量预训练模型。这些平台提供了标准化的访问方式,不但能拿到模型本身,也能拿到预先优化好的权重,因此我们无需从零开始训练。所谓权重,代表的是模型在参考数据集上训练过程中获得的知识。借助这样一个模型,我们就有可能以非常小的代价,为自己的下一个 Web 服务增加图像识别能力。

我们这里要探索的这个预训练网络,是在 ImageNet 数据集(imagenet.stanford.edu)的一个子集上训练得到的。ImageNet 是由斯坦福大学维护的一个超大规模数据集,包含 1400 多万张图像。所有图像都带有标签,而这些标签构成了一个名词层级体系,来源于 WordNet 数据集(wordnet.princeton.edu);而 WordNet 本身又是一个大型英语词汇数据库。

ImageNet 数据集最初源于学术竞赛,比如从 2010 年开始举办的 ImageNet 大规模视觉识别挑战赛(ImageNet Large Scale Visual Recognition Challenge,ILSVRC)。在它的多个任务中,分类挑战要求算法对图像进行识别:从 1000 个类别中给出 5 个按置信度排序的候选标签。也就是说,对于每张图像,算法需要按照“最有把握”到“较没把握”的顺序,输出前五个预测结果。实现自动化分类的潜在实际应用极其广泛,包括自动驾驶车辆导航、制造业质量控制、辅助癌症等疾病的诊断、野生动物监测,等等。

ILSVRC 的训练集由 120 万张图像组成,每张图像都标注为 1000 个名词中的一个(例如 “dog”),这个名词被称为该图像的类别(class)。从这个意义上说,在本章中,我们会将 labelclass 两个术语交替使用,把它们视为同义。我们可以在图 2.1 中先看一眼来自 ImageNet 的一些图像示例。

image.png

图 2.1 一小部分 ImageNet 图像样本

最终,我们将能够把自己的图像拿来,像图 2.2 所示那样送入预训练模型中。模型会返回该图像的一组预测标签,我们随后便可以查看模型“认为”这张图像是什么。有些图像的预测会很准确,而另一些则未必!

image.png

图 2.2 推理过程

输入图像首先会被预处理成一个多维数组类 torch.Tensor 的实例。它是一张具有高度和宽度的 RGB(红、绿、蓝)图像,因此这个张量会有三个维度:颜色通道,以及图像的两个空间维度,而且尺寸是固定的。(我们会在第 3 章详细解释什么是张量;现在你只需要把它暂时想象成一个由浮点数组成的向量或矩阵即可。)我们的模型会把这张经过处理的输入图像送入预训练网络,为每个类别生成一个分数。分数最高的类别,对应的就是按照模型权重来看最有可能的类别。每一个类别都与一个类别标签一一对应。这个输出本身保存在一个包含 1000 个元素的 torch.Tensor 中,其中每个元素都表示相应类别的分数。

不过在真正做到这一切之前,我们还需要先拿到网络本身,稍微看看它的内部结构,并了解在模型能够使用数据之前,数据需要怎样被准备好。

2.1.1 获取一个用于图像识别的预训练网络

正如前面所说,现在我们要为自己配备一个在 ImageNet 上训练好的网络。为此,我们先来看一下 TorchVision 项目(github.com/pytorch/vis…)。它包含了一些著名的计算机视觉神经网络架构,比如 AlexNet(mng.bz/lo6z)、Inception v3(arxiv.org/pdf/1512.00…),以及近一些的 Vision Transformer(arxiv.org/pdf/2010.11…)。它还提供了对ImageNet 等数据集的便捷访问,以及其他一些工具,帮助我们快速上手 PyTorch 中的计算机视觉应用。本书后面我们还会更深入地接触其中一些内容。现在,先来加载并运行我们的第一个模型:AlexNet——它是图像识别历史上早期的重要突破之一。如果你还没有按照第 1 章完成 PyTorch 环境配置,现在可以先花一点时间把它准备好。

预定义模型可以在 torchvision.models 中找到(code/p1ch2/2_pre_trained_networks.ipynb):

# In[1]:
import torchvision
from torchvision import models

我们可以先看看实际都有哪些模型:

# In[2]:
models.list_models()

# Out[2]:
['alexnet', 'convnext_base', 'convnext_large', 'convnext_small', 'convnext_tiny', 'densenet121', 'densenet161', 'densenet169', 'densenet201',... 'googlenet', 'inception_v3', 'maxvit_t', 'mnasnet0_5', 'mnasnet0_75',... 'vit_h_14', 'vit_l_16', 'vit_l_32', 'wide_resnet101_2', 'wide_resnet50_2']

这些名字对应的是一些 Python 类,它们实现了许多流行模型。它们彼此的不同,体现在各自的架构上——也就是输入和输出之间所发生的一系列运算是如何组织起来的。小写名称则是一些便捷函数,用来返回由这些类实例化得到的模型,有时还会带上不同的参数配置。例如,densenet121 返回的是一个具有 121 层的 DenseNet 实例,densenet201 则有 201 层,以此类推。下面我们把注意力转向 AlexNet。

2.1.2 AlexNet

AlexNet 仍然是深度学习历史上的一个里程碑。它重新点燃了这个领域,并把它推向了主流。在 AlexNet 之前,由于计算资源有限,以及在大规模任务上的效果并不理想,神经网络一度不再受重视。然而,在 2012 年,AlexNet 架构以巨大的优势赢得了 ILSVRC 竞赛,其 top-5 测试错误率(也就是正确标签必须出现在前五个预测结果中)为 15.4%。相比之下,排名第二的提交方案并不是基于深度网络,其错误率为 26.2%。这是计算机视觉历史上的一个决定性时刻:也就是整个社区开始真正意识到,深度学习在视觉任务上蕴藏着巨大潜力的那个时刻。此后,随着更现代的架构与训练方法不断出现,top-5 错误率已经进一步降低到了 3% 左右。

以今天的标准来看,与当前最先进的模型相比,AlexNet 已经算是一个相当小的网络了。但对于我们现在的目的而言,它非常合适:我们可以借此第一次真正看一眼“能够做事”的神经网络是什么样子,并学会如何在一张新图像上运行它的预训练版本。

我们可以在图 2.3 中看到 AlexNet 的结构。虽然我们现在还不具备完全理解它的全部知识,但可以先预先感受其中的一些方面。首先,每个模块都由大量乘法与加法组成,外加少量我们会在第 5 章中认识的其他函数作用于输出。我们可以把它理解成一个过滤器——也就是一个以一张或多张图像作为输入,并生成其他图像作为输出的函数。至于它具体是如何做到这一点的,则是在训练过程中,由它所看过的样本及这些样本对应的期望输出共同决定的。

image.png

图 2.3 AlexNet 架构(数字表示每一层的输出)

在图 2.3 中,输入图像从左边进入,依次经过五组过滤器,每一组都会生成图像的中间表示。正如图中所标注的,在每经过一组过滤器后,这些表示的尺寸都会被缩小。最后一组过滤器产生的中间表示会被展开成一个包含 4096 个元素的一维向量,然后进行分类,生成 1000 个输出概率,每一个对应一个输出类别。

要在一张输入图像上运行 AlexNet 架构,我们可以创建一个 AlexNet 类的实例。代码如下:

# In[3]:
alexnet = models.AlexNet()

到这一步,alexnet 已经是一个能够运行 AlexNet 架构的对象了。此刻,我们并不需要立刻理解这个架构的细节。暂时来说,alexnet 只是一个“黑箱对象”,可以像函数一样被调用。只要给它提供某种尺寸精确匹配的输入数据(我们很快就会看到这种输入应该长什么样),我们就可以在网络中运行一次前向传播(forward pass)。也就是说,输入会先流经第一组神经元,它们的输出再被送往下一组神经元,一路传递到最终输出。实际操作上,只要我们有一个类型正确的输入对象,就可以这样执行前向传播:output = alexnet(input)

但是,如果我们现在就这么做,整张网络输出的只会是一堆垃圾!因为这个网络还没有初始化:它的权重,也就是那些用于对输入做加法与乘法的数值,还没有在任何数据上训练过——网络本身仍然是一块空白(更准确地说,是随机初始化的空白)。因此,我们要么从头训练它,要么加载已有训练结果得到的权重;而接下来我们要做的,正是后者。

为此,我们先回到 models 模块。大写名称对应的是实现了流行计算机视觉架构的类;小写名称则是一些函数,用来实例化带有预定义层数与单元数的模型,并且可以选择性地下载并加载预训练权重。需要注意的是,使用这些函数并不是必须的:它们只是提供了一种方便方式,让我们能够用与那些预训练网络构建时相同的层数与单元配置来实例化模型。

2.1.3 Vision Transformer

如今,视觉 Transformer(ViT;arxiv.org/pdf/2010.11…)已经在许多计算机视觉任务中占据了领先地位,并展示出了最先进的性能。自注意力机制(self-attention mechanism)——我们会在第 9 章学习它——能够捕捉图像内部复杂而细致的关系,而这种能力可被用来提升分类精度。

在图像分类任务中,ViT 在 ImageNet 上所达到的错误率,已经明显低于 AlexNet。它的 top-5 错误率可以低到 5% 左右(相比之下,AlexNet 是 15.3%)——只是为了走到这一步,人们整整花了 9 年时间!

接下来,我们将使用 vit_b_16 函数来实例化一个用于图像分类的 ViT。我们会传入一个参数,指示该函数下载 ViT_B_16_Weights.IMAGENET1K_V1 权重;这组权重是在拥有 120 万张图像、1000 个类别的 ImageNet 数据集上训练得到的:

vit = models.vit_b_16(weights=models.ViT_B_16_Weights.IMAGENET1K_V1)

当我们盯着下载进度条的时候,不妨顺便感叹一下:vit_b_16 居然有 8860 万个参数——这可真是一大堆需要自动优化的参数!

2.1.4 预备,开始,差一点就能跑了

好,那么我们刚才到底拿到了什么?既然我们很好奇,不妨先看看一个 vit_b_16 模型长什么样。我们可以直接打印返回模型的值。这样做会得到一段文本表示,内容与图 2.3 中展示的信息属于同一类:也就是网络结构的详细说明。现在看这些内容,你可能会觉得信息量过大;但随着后面不断学习,我们会越来越能看懂这些代码究竟在告诉我们什么:

# In[5]:
vit

# Out[5]:
VisionTransformer(
  (conv_proj): Conv2d(3, 768, kernel_size=(16, 16), stride=(16, 16))
  (encoder): Encoder(
    (dropout): Dropout(p=0.0, inplace=False)
    (layers): Sequential(
      (encoder_layer_0): EncoderBlock(
        (ln_1): LayerNorm((768,), eps=1e-06, elementwise_affine=True)
        (self_attention): MultiheadAttention(
          (out_proj): NonDynamicallyQuantizableLinear(in_features=768, out_features=768, bias=True)
        )
        (dropout): Dropout(p=0.0, inplace=False)
        (ln_2): LayerNorm((768,), eps=1e-06, elementwise_affine=True)
        (mlp): MLPBlock(
          (0): Linear(in_features=768, out_features=3072, bias=True)
          (1): GELU(approximate='none')
          (2): Dropout(p=0.0, inplace=False)
          (3): Linear(in_features=3072, out_features=768, bias=True)
          (4): Dropout(p=0.0, inplace=False)
        )
      )
      (encoder_layer_1): EncoderBlock(
        (ln_1): LayerNorm((768,), eps=1e-06, elementwise_affine=True)
        (self_attention): MultiheadAttention(
          (out_proj): NonDynamicallyQuantizableLinear(in_features=768, out_features=768, bias=True)
        )
...
  )
  (heads): Sequential(
    (head): Linear(in_features=768, out_features=1000, bias=True)
  )
)

我们在这里看到的是一行一个 module(模块) 。注意,它们和 Python 模块没有关系:这里的模块指的是一个个独立的运算单元,是构成神经网络的基本构件。在其他深度学习框架里,它们通常也被称作 layer(层)

模块之间可以嵌套:较大的构件(例如 Encoder)中会包含子模块(例如重复出现的 EncoderBlock),而这些子模块内部又会继续包含其他模块。模型打印出来的摘要,会通过缩进来展示这种层级结构;从概念上讲,它就像一棵树,其中容器模块负责对自己的子模块进行分组和排序(例如 Sequential),这会让我们更容易理解网络结构,也更方便访问网络中的某一部分。

我们可以看到,EncoderBlock 模块中就包含了本节前面提到的注意力机制,以及其他一些模块。这就是典型计算机视觉深度神经网络的“解剖结构”:一个或多或少按顺序排列的操作与非线性函数级联,最终以一个层(fc)结束,用来为 1000 个输出类别中的每一个产生分数(out_features)。

变量 vit 可以像函数一样被调用:输入一张或多张图像,输出则是相同数量图像对应的、针对 1000 个 ImageNet 类别的分数。在真正调用它之前,我们必须先对输入图像进行预处理,使其尺寸正确,并让其中的数值(颜色)大致落在模型训练时所使用的数值范围内。为此,torchvision 模块提供了 transforms(变换) ,使我们能够非常方便地定义一条由基础预处理函数组成的流水线:

# In[6]:
from torchvision import transforms
preprocess = transforms.Compose([
        transforms.Resize(256),
        transforms.CenterCrop(224),
        transforms.ToTensor(),
        transforms.Normalize(
            mean=[0.485, 0.456, 0.406],
            std=[0.229, 0.224, 0.225]
        )])

在这个例子中,我们定义了一个 preprocess 函数,它会把输入图像缩放到 256 × 256,再从中心裁剪成 224 × 224,接着把它转成一个张量(也就是 PyTorch 的多维数组;在这里是一个三维数组,对应颜色、高度和宽度),最后再对它的 RGB 分量做归一化,使其具有指定的均值和标准差。如果我们希望网络能够给出有意义的结果,这些数值就必须和模型训练时看到的数据保持一致。等到我们在第 7.1.3 节自己动手构建图像识别模型时,还会更深入地讨论 transforms。

现在,我们可以拿一张自己最喜欢的狗狗照片(比如 GitHub 仓库里的 bobby.jpg),先做预处理,然后看看模型觉得它是什么。我们先用 Pillow(pillow.readthedocs.io/en/stable)从本地文件系统中加载图像。Pillow是 Python 中一个用于图像处理的模块:

# In[7]:
from PIL import Image
img = Image.open("../data/p1ch2/bobby.jpg")

如果我们是在 Jupyter Notebook 里跟着操作,那么只需这样写,就可以把图像以内联方式显示出来(也就是显示在下面 <PIL.JpegImagePlugin…> 所在的位置):

# In[8]:
img
# Out[8]:
<PIL.JpegImagePlugin.JpegImageFile image mode=RGB size=1280x720 at
 0x1B1601360B8>

否则,我们也可以调用 show 方法,这会弹出一个图像查看窗口,显示图 2.4 所示的图片:

>>> img.show()

image.png

图 2.4 Bobby,我们非常特别的输入图像

接下来,我们可以把这张图像送入预处理流水线:

# In[9]:
img_t = preprocess(img)

然后,我们再以模型所期望的方式,对输入张量进行整形、扩维、裁剪和归一化。关于这些操作,我们会在后面两章中理解得更清楚;现在先耐心跟着做:

# In[10]:
import torch
batch_t = torch.unsqueeze(img_t, 0)

到这里,我们终于准备好运行模型了。

2.1.5 运行!

在深度学习领域,把一个训练好的模型运行在新数据上的过程,称为 inference(推理) 。要进行推理,我们需要先把网络切换到 eval 模式:

# In[11]:
vit.eval()

# Out[11]:
VisionTransformer(
  ...
)

如果我们忘了这么做,那么模型中的某些部分,比如 batch normalization 和 dropout,就不会产生有意义的结果——这完全是由它们内部的工作方式所决定的。现在既然 eval 已经设置好了,我们就可以开始推理了:

# In[12]:
out = vit(batch_t)
out

# Out[12]:
tensor([[-2.0532e-01, -6.0847e-02, -5.4069e-02,  1.3571e-01,  5.3633e-02,
         -4.3015e-02,  1.0885e-01, -4.5990e-02, -2.2472e-01,  3.2890e-01,
...
          6.5441e-03, -1.2580e-01,  1.4917e-01,  1.6322e-01, -7.2893e-02,
         -1.3575e-01,  2.0132e-01,  3.9502e-02,  1.4893e-01,  1.8419e-01]])

刚才,涉及 8860 万个参数的一整套惊人的运算已经完成,最终生成了一个包含 1000 个分数的向量,每个分数对应一个 ImageNet 类别。是不是并没有花太久时间?

现在,我们需要找出得分最高的那个类别对应的标签。这会告诉我们,模型在这张图像里“看到了”什么。如果这个标签和人类对图像的描述一致,那当然很好!这说明一切都工作正常。如果不一致,那要么是训练过程中出了什么问题,要么是这张图像和模型预期的输入差异太大,以至于模型无法正确处理它,或者存在其他类似的问题。

为了查看预测标签列表,我们会先加载一个文本文件,其中按照训练时送入网络的顺序,列出了所有标签;然后再取出那个对应网络输出最高分下标的标签。几乎所有用于图像识别的模型,它们的输出形式都和我们现在要处理的这种形式非常相似。

先来加载包含 ImageNet 1000 个类别标签的文件:

# In[13]:
with open('../data/p1ch2/imagenet_classes.txt') as f:
    labels = [line.strip() for line in f.readlines()]

此时,我们需要确定先前 out 张量中最大分数所对应的下标。可以使用 PyTorch 的 max 函数来完成:它不仅会返回张量中的最大值,还会返回该最大值出现的位置索引:

# In[14]:
_, index = torch.max(out, 1)

现在,我们就可以使用这个索引来访问对应的标签了。这里的 index 不是普通的 Python 数字,而是一个只包含一个元素的一维张量(具体来说,就是 tensor([207])),因此我们需要用 .item() 先取出其中真正的数值,才能把它作为索引去访问 labels 列表。这个方法只适用于只有一个元素的张量,并会把该张量中的值转换成标准的 Python 数字。与此同时,我们还会使用 torch.nn.functional.softmaxmng.bz/BYnq)把输出归一化到 [0, 1] 区间,并按总和进行归一化。这样,我们就能得到一个大致可以看作模型“置信度”的数值。在这个例子中,模型有 96% 的把握认为自己看见的是一只金毛猎犬:

# In[15]:
percentage = torch.nn.functional.softmax(out, dim=1)[0] * 100
labels[index.item()], percentage[index.item()].item()

# Out[15]:
('golden retriever', 86.44094848632812)

哎呀,这是谁家的乖狗狗?

既然模型输出的是一组分数,我们当然也可以进一步看看第二高分、第三高分,依此类推分别是什么。为此,可以使用 sort 函数,它既会按升序或降序对数值排序,也会给出这些排序后数值在原始数组中的索引:

# In[16]:
_, indices = torch.sort(out, descending=True)
[(labels[idx], percentage[idx].item()) for idx in indices[0][:5]]

# Out[16]:
[('golden retriever', 86.44094848632812), ('Labrador retriever', 0.35488149523735046), ('tennis ball', 0.32364416122436523), ('cocker spaniel, English cocker spaniel, cocker', 0.15340152382850647), ('Airedale, Airedale terrier', 0.14846619963645935)]

我们看到,这五个结果里有四个都是狗(Airedale 原来也是一种犬种,谁知道呢?),但其中有一个答案显得有点滑稽。“网球”这个结果,大概是因为训练数据里有足够多“狗附近有网球”的图片,以至于模型本质上是在说:“有 0.32% 的概率,我完全误解了‘网球’到底是什么。” 这正是一个非常好的例子,它体现了人类与神经网络看待世界方式上的根本差异,也说明了各种奇怪而微妙的偏差,是多么容易潜入我们的数据之中。

现在可以开始玩了!我们完全可以继续拿各种随机图像来“拷问”这个网络,看看它会给出什么答案。网络识别成功的程度,很大程度上取决于图像中的主体在训练集中是否被充分表示过。如果我们给它一张包含训练集之外主体的图像,那么它很可能会以相当高的置信度给出一个错误答案。亲自做些实验,体会一个模型面对未见过数据时会如何反应,是非常有帮助的。

我们刚刚运行了一个被认为是最先进的图像分类网络。它通过看过大量狗的示例,以及大量其他真实世界对象的示例,学会了识别我们的狗。接下来,我们将看看,不同的架构是如何完成其他类型任务的,而我们要从图像生成开始。

2.2 生成与编辑图像

设想这样一个场景:一位画家受命修复一幅画作中的一小块受损区域。他得到的指令是:“把这块损坏区域的颜色修复好,但画面其他部分必须保持原样不动。” 画家进行修复时,会先用低黏性的遮蔽胶带把整幅画其余部分盖住,只让需要修补的那一小块露出来。

就像画家用遮蔽胶带盖住其余所有区域,只留下待修补的部分暴露在外一样,我们传给预训练扩散模型的 mask(掩码) ,本质上就是一个数字模板。黑色像素标记的是受保护区域,在每一步修复过程中,这些区域都会被原样拷贝回去,不发生变化。白色像素标记的则是可编辑的、“预备好接受修改”的区域,模型可以在这里重新绘制内容。

简言之,这就是现代基于扩散模型的图像修补(inpainting):由文本引导、受掩码约束、通过迭代不断细化,从而在无需重新训练模型的前提下,对图像的局部区域做出逼真的修改。借助 inpainting,我们可以很方便地替换或编辑图像中的特定区域。下面,我们就来说明这一切是如何工作的。

2.2.1 图像修补过程

现代图像修补方法依赖于扩散模型。从高层次上看,扩散模型学习的是:如何逆转一个逐步“加噪”的过程。在训练阶段,干净图像会一次又一次地被加入少量噪声,直到最终几乎变成随机噪声。模型被训练去反向执行这个过程:每次去掉一点噪声,使图像结构逐步重新显现,最终恢复成一幅连贯的图片。关于这个过程,我们会在第 10 章学到更多内容,所以现在不必担心所有细节。

在推理阶段,这种学到的“逆转过程”就变成了一个强大的编辑工具。如果我们从纯噪声开始,并用一段文本描述来引导去噪过程,模型就可以合成一张与这段描述相匹配的全新图像。如果我们从一张已有图片开始,模型则可以在尽量保留原始布局与几何结构的同时,按照我们的描述重新渲染这张图像。而当我们提供一个掩码时,模型只会在掩码指定的区域内应用修改(白色区域),其余部分(黑色区域)则保持不变——这就是 inpainting。图 2.5 展示了模型可接收的所有输入。

image.png

图 2.5 模型输入

用这样的方式去理解,各个角色就会很清楚:prompt(提示词) 说明我们想要什么,输入图像锚定了我们的起点,而掩码则约束了修改可以发生在哪里。真正承担“重体力活”的,是去噪过程——它把带噪像素一点点还原成既真实、又符合指令要求的结果。

在图 2.6 中,我们可以看到图像被逐步生成的过程。输入是一张马的照片,外加一个指明需要修改区域的掩码。随着步骤不断推进,模型逐渐细化带噪输入,最终生成一张逼真的斑马图像,并且与原始照片中未被修改的部分自然融合在一起。

image.png

图 2.6 扩散模型逐步处理图像的过程

掩码保证了修改的局部性:掩码之外的像素会在每一步中通过重新插入原始内容而被保留下来,而掩码内部的像素则可以自由变化,以满足提示词(例如“把马变成斑马”)的要求。经过一系列去噪步骤之后,我们得到一张图像:未被掩码覆盖的场景与姿态得以保留,而只有被选中的区域被重新绘制。如果我们省略掩码,那么同样的过程就会变成图像到图像生成(image-to-image generation):整张图都可以依据提示词重新渲染,同时仍然尽量尊重原图的构图。

2.2.2 一个能把马变成斑马的网络

我们现在就可以来玩一玩这个模型。Stable Diffusion 网络是在一个由各种(彼此无关的)图像组成的数据集上训练出来的,其中包括许多马和斑马。虽然过去几千年里,人类并没有一直屏住呼吸、苦苦等待一个“把马变成斑马”的工具问世,但这个任务很好地展示了这类架构如何在远程监督(distant supervision)的条件下,对复杂的现实世界过程进行建模。尽管它们也有自己的局限,但种种迹象表明,在不久的将来,我们或许将无法在实时视频流中区分真假——这个话题会引出一大堆麻烦,我们现在先及时把这只“罐头”盖上。

使用一个预训练模型,可以让我们看到如何实例化并运行一个图像修补工作流。我们将使用 diffusers 提供的 inpaint pipeline(修补流水线) ,它把训练好的各个组件封装起来,并暴露出一个简单函数:接收输入图像、掩码和文本提示词。我们此处的目标是使用它,而不是重新实现它,因此我们会关注如何调用它,而不是它内部的工作机制(见 code/p1ch2/3_inpainting.ipynb):

# In[2]:
from diffusers import StableDiffusionInpaintPipeline
import torch
import PIL.Image as Image

device = "cuda" if torch.cuda.is_available() else "cpu"
pipe = StableDiffusionInpaintPipeline.from_pretrained(
    "sd2-community/stable-diffusion-2-inpainting",
    torch_dtype=torch.float16
).to(device)

这里我们创建了 pipe,也就是一个用于图像修补的 diffuser pipeline。一个 pipeline 会把生成所需的全部部件打包在一起——包括处理文本提示词的 tokenizer 和 text encoder、一个 UNet 去噪器、一个变分自编码器(VAE),它负责把图像压缩到一个紧凑的内部表示(即 latent representation,潜在表示)中,然后再将其重建出来,以及一个 scheduler——并把这些部件都移动到选定的设备上:

# In[]:
pipe

# Out[]:
StableDiffusionInpaintPipeline {
  "_class_name": "StableDiffusionInpaintPipeline",
  "_diffusers_version": "0.33.1",
  "_name_or_path": "stabilityai/stable-diffusion-2-inpainting",
  "feature_extractor": [
    "transformers",
    "CLIPImageProcessor"
  ],
  "image_encoder": [
    null,
    null
  ],
  "requires_safety_checker": false,
  "safety_checker": [
    null,
    null
  ],
  "scheduler": [
    "diffusers",
    "PNDMScheduler"
  ],
  "text_encoder": [
    "transformers",
    "CLIPTextModel"
  ],
...
  "vae": [
    "diffusers",
    "AutoencoderKL"
  ]
}

像前面那样把模型打印出来后,我们会感受到:考虑到它所能完成的事情,这个模型其实相当紧凑。它接收一张图像,识别出掩码以及需要修改的区域,然后逐个修改那些像素的取值,使最终输出看起来像一个可信的替换结果。

现在我们已经准备好加载一张随便找来的马的图片,看看模型会生成什么。先打开一张马的图片(见图 2.7):

# In[4]:
img = Image.open("../data/p1ch2/horse.jpg")
img

image.png

图 2.7 一匹马,当然是马。

好,接下来我们把这张图和我们的掩码一起送进去。这个掩码是通过一个单独的过程生成的。为了简洁起见,我们这里跳过生成掩码图像的过程,不过相关代码片段已经包含在同一个文件中了。现在,把提示词、原图和掩码一起传给 pipeline:

# In[5]:
mask_img = Image.open("../data/p1ch2/horse_mask.jpg")

prompt = ("a zebra replacing the original horse, same pose, same lighting, background unchanged")
negative = "distorted background, blurry, text, watermark"   #1

out = pipe(
    prompt=prompt,
    image=img,
    mask_image=mask_img,
    negative_prompt=negative,
    guidance_scale=7.5,
    strength=0.8,
    generator=torch.Generator(device).manual_seed(42)
)
#1 negative_prompt 参数是可选的,但很有用:它可以指定生成结果中不希望出现的特征,从而帮助引导模型避开那些不想要的伪影或属性。

现在,out 就是生成器的输出,其中包含了我们的新图像:

# In[6]:
out.images[0]

image.png

图 2.8 斑马?算是吧。

如果我们想在一门传统编程语言里手工写出图 2.8 这样的图像,那简直会是一场噩梦;但对于这里用到的这种模型来说,不过就是调用一个函数而已。我们既不会在模型打印输出中看见任何“专门针对斑马”的东西(在源代码里也看不到),因为那里本来就没有任何“斑马专用”的部分!网络只是一个脚手架——真正有价值的内容,全都在权重里。

图 2.8 中得到的结果并不完美,但还是值得再次强调:这个学习过程并没有经过那种直接监督——并没有人类去描出成千上万匹马的轮廓,也没有人手工给成千上万张图 P 上斑马条纹。模型仅仅根据它所训练过的图像和指令,就学会了生成与之相匹配的图像!

采用这种方法训练出来的有趣模型还有很多。有些能够生成看起来可信、但现实中并不存在的人脸;有些则可以把草图转换成看起来真实的幻想风景图片。生成模型也正在被探索用于生成逼真的音频、可信的文本,以及悦耳的音乐。很可能,未来许多支持创作过程的工具,都会建立在这类模型之上。

严肃地说,这类工作的影响怎么高估都不为过。像我们刚刚下载的这种工具,只会变得越来越高质量,也越来越无处不在。其中,换脸技术尤其获得了大量媒体关注。只要搜索 “deep fakes”,你就会看到海量示例内容。

注意 关于换脸技术,一个很有代表性的案例可以参见 Vox 的文章 “Jordan Peele’s Simulated Obama PSA Is a Double-Edged Warning Against Fake News”,作者为 Aja Romano(mng.bz/PwEv;注意:文中包含较粗俗的语言)。

到目前为止,我们已经玩过一个“能看懂图像”的模型,也玩过一个“能生成新图像”的模型。我们已经看到,如何使用自然语言来引导图像生成与编辑。接下来,为了收尾,我们要把方向反过来:来看一个视觉—语言模型,它接收图像,然后输出自然语言描述。不过在那之前,我们先花一点时间看看,到目前为止我们都用了哪些从互联网下载预训练模型的方式。

2.3 模型动物园:Hugging Face

刚才我们已经愉快地使用了来自不同模型动物园(model zoos)中的各种模型,它们分布在不同的软件包中(例如 torchvisiontransformersdiffusers)。所谓 model zoo(模型动物园) ,指的是一个预训练模型的集合或仓库。预训练模型从深度学习早期就已经开始被发布了,但从历史上看,用户并没有一种标准化的方法,能够通过统一接口来访问这些模型。TorchVision 就是一个很好的“干净接口”的例子,正如我们在本章前面所看到的那样。

如今,在深度学习生态中最著名的模型仓库之一,就是 Hugging Face。它是开源领域公认的领导者之一,也是目前最全面的模型动物园。Hugging Face 拥有种类极其丰富的 Transformer 和扩散模型(这些架构我们会在后续章节中从零开始构建)。

Hugging Face 最流行的库是 transformers 包,顾名思义,它收纳了大量 Transformer 模型。作为一个例子,我们将使用预训练库 GPT-2 来进行文本生成(code/p1ch2/4_model_zoos.ipynb):

from transformers import pipeline
generator = pipeline('text-generation', model='gpt2')   #1
generator("AI models are so smart they can replace my", max_length=10)   #2

# [{'generated_text': 'AI models are so smart they can replace my brain with a totally different one.'}]
#1 pipeline 函数提供了一种方便方式,用来加载模型并将其应用到特定任务上,例如文本生成。
#2 根据所加载的 pipeline,可以传入不同的参数。在这里,我们要求生成一个长度为 10 的输出。

在前面的代码示例中,我们实际上是在要求模型补全我们那句由 9 个单词组成的输入短语。它给出的结果——虽然多少有点让人不安(它们真的能替换掉我们的大脑吗?)——很好地展示了下载预训练模型并将其直接应用起来是多么容易。这个补全建议,是模型基于自己对语言模式与上下文的内在理解,再结合在数 GB 数据上训练出来的采样策略所生成的。

我们会在第 9 章更仔细地考察 Transformer 进行文本生成的具体机制。不过现在,先让我们用最后一个图文结合的例子来结束这一部分。

2.4 一个能够描述场景的预训练网络

我们从图像分类开始,接着进入图像生成,然后又用 Transformer 做了一个文本生成任务。现在,我们来看看,能否完成这样一项任务:利用自然语言处理来对图像进行标注。视觉和语言,是人类理解世界时两项最基础的能力,而图像描述(image captioning)正是一个很好的例子,它展示了深度学习如何完成那些过去一度被认为只有人类才能解决的任务。

我们将使用一个预训练的图像到文本模型,名为 BLIP,它由 Salesforce Research 的一个团队开发。BLIP 是一个多模态模型(multimodal model);也就是说,它被设计用来处理不同类型的数据(也就是不同模态)作为输入——在这里,分别是文本和图像。

BLIP 在训练时使用了各种图像与文本的组合,以学习二者之间的关系。它是在大规模图像及其配对句子描述的数据集上训练的——例如:“一只虎斑猫靠在一张木桌上,一只爪子放在激光鼠标上,另一只爪子放在一台黑色笔记本电脑上。” 在训练 BLIP 之前,研究团队还做了一些预处理技巧,用来从嘈杂的网络数据中过滤掉某些说明文字,并生成合成说明文字来扩充数据集。

既然这个模型是通过图像和说明文字成对训练出来的,那么它为什么能够为自己从未见过的图像生成标注呢?图像数据集确实很大,但还远不足以囊括人们所能想象出的每一张图像。从高层次上看,在训练过程中,图像会被编码成一个由数字组成的向量,这个向量叫作 embedding(嵌入) 。同样地,与之对应的说明文字也会被编码成另一个 embedding。随后,这两组数值会被拿来比较,以判断图像和说明文字之间的相似程度,而这个过程会反复进行,从而逐步训练模型。此外,模型中还有一个解码器,它会根据图像来生成对说明文字的猜测。这个过程其实还有许多更多细节,但我们尽量不在这里陷入过多技术细枝末节之中。

注意 想深入一点的读者,可以参考论文 “BLIP: Bootstrapping Language-Image Pre-training for Unified Vision-Language Understanding and Generation”,地址为 arxiv.org/pdf/2201.12…

在推理时,用户只需要使用模型中的图像编码器和文本解码器部分,就可以生成文本,如图 2.9 所示。这个图像描述模型有两半:模型的前半部分是一个图像编码器,它学习生成场景的“描述性”数值表示(例如虎斑猫、激光鼠标、爪子)。这个 embedding 编码随后会被送入模型的后半部分。后半部分是一个文本解码器,它通过把这个编码转换成文本表示,生成一条连贯的句子。最终得到的输出,就是对图像内容进行描述的一条说明文字。关于编码器和解码器究竟是如何工作的,我们会在第 9 章进一步学习。

image.png

图 2.9 BLIP 生成图像描述的概念图

2.4.1 BLIP 实战

我们可以利用前面在模型动物园部分学到的方法,在 Hugging Face 上找到 BLIP。按照 code/p1ch2/4_model_zoos.ipynb 中的代码,我们可以加载图像处理器以及模型本身:

# In[5]:
from PIL import Image
from transformers import BlipProcessor, BlipForConditionalGeneration

processor = BlipProcessor.from_pretrained("Salesforce/blip-image-captioning-large")
model = BlipForConditionalGeneration.from_pretrained("Salesforce/blip-image-captioning-large")

我们还会定义一个辅助函数,用来调用它们并打印图像描述:

# In[6]:
def annotate_image(image: Image) -> None:
    display(image)
    inputs = processor(image, return_tensors="pt")
    out = model.generate(**inputs)
    print(processor.decode(out[0], skip_special_tokens=True))

先拿我们之前的 horse.jpg 试一下:

# In[7]
annotate_image(Image.open("../data/p1ch2/horse.jpg"))
# Out[7]:
there is a brown horse grazing in a field of green grass

描述得还真细致!现在,纯粹为了好玩,我们来看看扩散模型生成的图像,能不能也骗过这个 BLIP 模型。我们把数据文件夹里的 zebra.jpg 图像加进来,再跑一次模型,得到的结果是:“一只斑马正在一片绿色草地上吃草,背景里有树。” 看来它并没有被糊弄过去;它连动物种类都识别对了。除此之外,斑马通常也不太可能生活在这么绿的草场和这样的环境里。这其实相当了不起:我们生成了一张包含不可能场景的假图像,而这个图像描述网络依然足够灵活,能够正确抓住图像中的主要主体。

我们想强调的是,这种事情在深度学习出现之前,原本是极难做到的;而现在,只需要不到 1000 行代码、一个对马和斑马本身一无所知的通用架构,再加上一批图像和它们的描述语料,就可以实现。没有任何硬编码的判别准则,也没有任何手工写死的语法——一切,包括最终生成的句子,都是从数据中的模式中自然涌现出来的。

从某种意义上说,本章中我们看到的这个网络,比前面 AlexNet 那类网络还要更复杂,因为它包含了多个彼此衔接的网络,它们相互提供输入,也相互促进学习。这让我们提前体会到,如何使用复杂模型去完成真实世界任务;而关于这个过程底层到底是怎么运作的,我们很快就会学到。

2.5 结语

希望这一章读起来还算有趣。我们花了一些时间,玩了玩那些用 PyTorch 构建出来、并针对特定任务做过优化的模型。事实上,更有行动力的人,甚至已经可以把其中某个模型挂到 Web 服务器后面,直接开始做生意了,再和原作者分润。(想了解加盟机会,请联系出版社!)

等到我们学会这些模型是如何构建出来之后,也就能利用本章里获得的这些知识,下载一个预训练模型,并在一个稍有不同的新任务上快速对它进行微调,而不必从零开始。

我们还将看到,即便面对的问题不同、数据类型不同,构建模型时其实依然可以使用同样的基础构件。而 PyTorch 做得特别好的一点,就是它以一套核心工具集的形式,提供了这些基础构件。

本书并不打算把整个 PyTorch API 从头到尾讲一遍,也不是为了系统回顾各种深度学习架构;相反,我们要做的是建立起对这些基础构件的动手型理解。这样一来,你就能够站在扎实基础之上,更有效地阅读优秀的在线文档和代码仓库。

从下一章开始,我们将踏上一段旅程,使我们能够使用 PyTorch 从零开始教会计算机掌握本章中所展示的那些能力。我们也会学到:当数据量并不是特别大时,从一个预训练网络出发,在新数据上进行微调,而不是完全从头开始训练,往往是一种非常有效的问题求解方式。这也是为什么,预训练网络会成为深度学习实践者必须掌握的重要工具之一。

现在,是时候学习第一个基础构件了:张量(tensors)

2.6 练习

把那张金毛犬的图像送入“马变斑马”模型中:

  • 你需要对图像做哪些准备处理?
  • 输出结果看起来是什么样?

在 Hugging Face 模型中心中搜索提供预训练模型的项目:

  • 模型中心里一共有多少个模型?
  • 找一个看起来有意思的模型。你能从它的文档中理解这个模型的用途吗?
  • 把这个项目收藏起来,等你读完整本书之后再回来看看。到那时,你能理解它的实现了吗?

小结

预训练网络,是指已经在某个数据集上训练完成的模型。这样的网络通常在加载权重之后,就可以立刻产生有用的结果。

使用预训练模型,可以让我们在无需自己构建或训练神经网络的情况下,把一个神经网络集成到项目中。

推理(inference) ,是指在新数据上运行模型的过程,使模型能够基于它已经学到的知识做出预测或决策。

AlexNet 是一种深度卷积网络,在它发布的那个时代,为图像识别树立了新的性能标杆。

扩散模型通过逆转一个逐步加噪的过程来生成或编辑图像,在文本提示词的引导下,通过反复去噪逐渐逼近一个连贯的结果。

图像修补(inpainting) 会对输入图像进行重新渲染;可选的掩码可以把修改限制在指定区域内,同时保持其余部分不变。

Hugging Face 模型通过 transformers 库,为访问深度学习社区中的预训练模型提供了一种方便且被广泛使用的方式。

BLIP 是一个多模态模型,它接收图像作为输入,并输出对图像内容的文本描述。