Sklearn 和 PyTorch 机器学习实用指南(六)
原文:
zh.annas-archive.org/md5/df49bc55d26e1ce9dd788648afcb9604译者:飞龙
第十六章. 视觉和多模态转换器
在上一章中,我们从零开始实现了一个转换器并将其转变为翻译系统,然后我们探讨了仅编码器模型用于 NLU,仅解码器模型用于 NLG,我们甚至构建了一个小型的聊天机器人——这是一段相当漫长的旅程!然而,关于转换器还有很多话要说。特别是,我们迄今为止只处理了文本,但转换器实际上在处理各种输入方面表现出色。在本章中,我们将介绍能够处理图像的 视觉转换器(ViTs),然后是能够处理包括文本、图像、音频、视频、机器人传感器和执行器以及任何类型数据的 多模态转换器。
在本章的第一部分,我们将讨论一些最有影响力的纯视觉转换器:
DETR (检测转换器)
一个用于目标检测的早期编码器-解码器转换器。
原始的 ViT (视觉转换器)
这个里程碑式的仅编码器转换器将图像块视为词标记,如果在大数据集上训练,可以达到最先进的状态。
DeiT (数据高效图像转换器)
使用蒸馏在规模上训练的更具数据效率的 ViT。
PVT (金字塔视觉转换器)
一个可以生成多尺度特征图用于语义分割和其他密集预测任务分层模型。
Swin Transformer (移动窗口转换器)
一个速度更快的分层模型。
DINO (无标签的自蒸馏)
这引入了一种新颖的自监督视觉表示学习方法。
在本章的第二部分,我们将深入探讨多模态转换器:
VideoBERT
一种训练用于处理文本和视频标记的 BERT 模型。
ViLBERT (视觉语言 BERT)
一个用于图像加文本的双编码器模型,它引入了共注意力(即双向交叉注意力)。
CLIP (对比语言-图像预训练)
这又是一个使用对比预训练训练的图像加文本双编码器模型。
DALL·E (对艺术家萨尔瓦多·达利和皮克斯角色瓦力名字的恶搞)
一种能够从文本提示生成图像的模型。
Perceiver
它使用交叉注意力技巧有效地将任何高分辨率模态压缩成短序列。
Perceiver IO (输入/输出)
为 Perceiver 添加了一个灵活的输出机制,使用类似的交叉注意力技巧。
Flamingo
它不是从头开始,而是重用了两个大型预训练模型——一个用于视觉,一个用于语言(两者都冻结)——并使用名为 Resampler 的 Perceiver 风格适配器将它们连接起来。这种架构使得开放式的视觉对话成为可能。
BLIP-2 (自举语言-图像预训练)
这又是一个开放式的视觉对话模型,它重用了两个大型预训练模型,使用轻量级的查询转换器(Q-Former)将它们连接起来,并采用了一种强大的两阶段训练方法,具有多个训练目标。
所以,打开灯光,变换器即将睁开眼睛。
视觉变换器
视觉变换器并非凭空出现:在它们被发明之前,已经有了带视觉注意力的 RNN 和混合 CNN-Transformer 模型。在我们深入研究一些最有影响力的 ViT 之前,让我们看看这些 ViT 的祖先。
带视觉注意力的 RNN
注意力机制在 NLP 之外的第一项应用之一是使用视觉注意力生成图像标题。^(1) 在这里,卷积神经网络首先处理图像并输出一些特征图,然后一个配备注意力机制的解码器 RNN 逐个生成标题。
解码器在每个解码步骤使用一个注意力层来专注于图像的恰当部分。例如,在图 16-1 中,模型生成了标题“一位女士在公园里扔飞盘”,你可以看到当解码器即将输出“飞盘”这个词时,它关注了输入图像的哪个部分:很明显,大部分注意力都集中在飞盘上。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-skl-pt/img/hmls_1601.png
图 16-1. 视觉注意力:输入图像(左)和模型在输出“飞盘”这个词之前关注的焦点(右)^(2)
变换器被发明后,它们很快就被应用于视觉任务,通常是通过替换现有架构中的 RNN(例如,用于图像标题)。然而,大部分视觉工作仍然由 CNN 完成,因此尽管它们是用于视觉任务的变换器,我们通常不把它们视为 ViT。检测变换器(DETR)是这种类型的典型例子。
DETR:用于目标检测的 CNN-Transformer 混合模型
2020 年 5 月,一组 Facebook 研究人员提出了一种用于目标检测的混合 CNN-变换器架构,命名为检测变换器(DETR,见图 16-2)。^(4) CNN 首先处理输入图像并输出一系列特征图,然后这些特征图被转换成一系列视觉标记,这些标记被输入到编码器-解码器变换器中,最后变换器输出一系列边界框预测。
在某个时刻,肯定有人会想知道我们是否可以完全去掉 CNN。毕竟,注意力就是一切,对吧?在 DETR 之后几个月,原始的 ViT 诞生了。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-skl-pt/img/hmls_1602.png
图 16-2. 用于目标检测的检测变换器(DETR)
原始 ViT
2020 年 10 月,一组谷歌研究人员发布了一篇论文^(5),介绍了第一个没有 CNN 的视觉变换器(参见图 16-3)。它简单地被命名为视觉变换器(ViT)。这个想法非常简单:将图像切割成小的 16 × 16 补丁,并将补丁序列视为一个词表示序列。实际上,这篇论文的标题是“一张图片值 16 × 16 个词”。
要更精确地说,这些补丁首先被展平成 16 × 16 × 3 = 768 维度的向量(3 代表 RGB 颜色通道)。例如,一个 224 × 224 的图像被切割成 14 × 14 = 196 个补丁,因此我们得到了 196 个每个维度为 768 的向量。这些向量随后通过一个线性层,将向量投影到变换器的嵌入大小。得到的向量序列可以像处理词嵌入序列一样处理:添加可学习的位置嵌入,然后将结果传递给变换器,这是一个常规的仅编码器模型。在序列的开始插入一个具有可训练表示的类别标记,并在相应的输出上方添加一个分类头(即这是 BERT 风格的分类)。
就这样!这个模型在 ImageNet 图像分类上击败了当时的最佳水平,但公平地说,作者们不得不使用超过 3 亿张额外的图像进行训练。这很合理,因为变换器不像卷积神经网络那样具有许多归纳偏差,因此它们需要额外的数据来学习 CNN 隐含假设的东西。
注意
归纳偏差是模型由于其架构而做出的隐含假设。例如,线性模型隐含地假设数据是线性的。CNN 具有平移不变性,因此它们隐含地假设在一个位置学习到的模式在其他位置也可能有用。它们还强烈倾向于局部性。RNN 隐含地假设输入是有序的,并且最近的标记比旧的标记更重要。一个模型具有的归纳偏差越多,假设它们是正确的,那么模型所需的训练数据就越少。但如果隐含的假设是错误的,那么即使在大数据集上训练,模型也可能表现不佳。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-skl-pt/img/hmls_1603.png
图 16-3. 用于分类的视觉变换器(ViT)
现在你已经知道了一切,可以从头开始实现一个 ViT 了!
使用 PyTorch 从头开始实现 ViT
我们将首先实现一个自定义模块来处理补丁嵌入。为此,我们可以实际使用一个 nn.Conv2d 模块,将 kernel_size 和 stride 都设置为补丁大小(16)。这相当于将图像切割成补丁,展平它们,并通过一个线性层(然后重塑结果)。这正是我们所需要的!
import torch
import torch.nn as nn
class PatchEmbedding(nn.Module):
def __init__(self, in_channels, embed_dim, patch_size=16):
super().__init__()
self.conv2d = nn.Conv2d(embed_dim, in_channels,
kernel_size=patch_size, stride=patch_size)
def forward(self, X):
X = self.conv2d(X) # shape [B=Batch, C=Channels, H=Height, W=Width]
X = X.flatten(start_dim=2) # shape [B, C, H * W]
return X.transpose(1, 2) # shape [B, H * W, C]
在卷积层之后,我们必须展平空间维度并转置最后两个维度,以确保嵌入维度最终位于最后,这是 nn.TransformerEncoder 模块所期望的。现在我们已准备好实现我们的 ViT 模型:
class ViT(nn.Module):
def __init__(self, img_size=224, patch_size=16, in_channels=3,
num_classes=1000, embed_dim=768, depth=12, num_heads=12,
ff_dim=3072, dropout=0.1):
super().__init__()
self.patch_embed = PatchEmbedding(embed_dim, in_channels, patch_size)
cls_init = torch.randn(1, 1, embed_dim) * 0.02
self.cls_token = nn.Parameter(cls_init) # shape [1, 1, E=embed_dim]
num_patches = (img_size // patch_size) ** 2 # num_patches (denoted L)
pos_init = torch.randn(1, num_patches + 1, embed_dim) * 0.02
self.pos_embed = nn.Parameter(pos_init) # shape [1, 1 + L, E]
self.dropout = nn.Dropout(p=dropout)
encoder_layer = nn.TransformerEncoderLayer(
d_model=embed_dim, nhead=num_heads, dim_feedforward=ff_dim,
dropout=dropout, activation="gelu", batch_first=True)
self.encoder = nn.TransformerEncoder(encoder_layer, num_layers=depth)
self.layer_norm = nn.LayerNorm(embed_dim)
self.output = nn.Linear(embed_dim, num_classes)
def forward(self, X):
Z = self.patch_embed(X) # shape [B, L, E]
cls_expd = self.cls_token.expand(Z.shape[0], -1, -1) # shape [B, 1, E]
Z = torch.cat((cls_expd, Z), dim=1) # shape [B, 1 + L, E]
Z = Z + self.pos_embed
Z = self.dropout(Z)
Z = self.encoder(Z) # shape [B, 1 + L, E]
Z = self.layer_norm(Z[:, 0]) # shape [B, E]
logits = self.output(Z) # shape [B, C]
return logits
让我们来看一下这段代码:
-
构造函数首先创建
PatchEmbedding模块。 -
然后它创建类别标记的可训练嵌入,使用具有小标准差(0.02 是常见的)的正态分布进行初始化。其形状为 [1, 1, E],其中 E 是嵌入维度。
-
接下来,我们初始化位置嵌入,形状为 [1, 1 + L, E],其中 L 是补丁标记的数量。我们需要一个额外的位置嵌入来处理类别标记,因此是 1 + L。同样,我们使用具有小标准差的正态分布来初始化它。
-
接下来,我们创建其他模块:
nn.Dropout、nn.TransformerEncoder(基于nn.TransformerEncoderLayer)、nn.LayerNorm以及我们将用作分类头的输出线性层。 -
在
forward()方法中,我们首先创建补丁标记。 -
然后我们使用
expand()方法在批处理轴上复制类别标记,并将补丁标记连接起来。这确保了每个补丁标记序列都以类别标记开头。 -
其余部分都很直接:我们添加位置嵌入,应用一些 dropout,运行编码器,只保留类别标记的输出 (
Z[:, 0]) 并对其进行归一化,最后通过输出层,该层产生 logits。
您可以创建模型并使用随机的一批图像进行测试,如下所示:
vit_model = ViT(
img_size=224, patch_size=16, in_channels=3, num_classes=1000, embed_dim=768,
depth=12, num_heads=12, ff_dim=3072, dropout=0.1)
batch = torch.randn(4, 3, 224, 224)
logits = vit_model(batch) # shape [4, 1000]
您可以使用 nn.CrossEntropyLoss 训练这个模型,就像通常一样。然而,这会花费相当长的时间,所以除非您的图像数据集非常具有领域特定性,否则您通常更好的做法是使用 Transformers 库下载预训练的 ViT,然后在您的数据集上对其进行微调。让我们看看如何操作。
使用 Transformers 库微调预训练的 ViT
让我们下载一个小型的预训练的 ViT 并在牛津-IIIT Pet 数据集上对其进行微调,该数据集包含超过 7,000 张宠物图片,分为 37 个不同的类别。首先,让我们下载数据集:
from datasets import load_dataset
pets = load_dataset("timm/oxford-iiit-pet")
接下来,让我们下载 ViT:
from transformers import ViTForImageClassification, AutoImageProcessor
model_id = "google/vit-base-patch16-224-in21k"
vit_model = ViTForImageClassification.from_pretrained(model_id, num_labels=37)
vit_processor = AutoImageProcessor.from_pretrained(model_id, use_fast=True)
我们正在加载一个在 ImageNet-21k 数据集上预训练的基础 ViT 模型。这个数据集包含大约 1400 万张图片,跨越 21,800 多个类别。我们使用 ViTForImageClassification 类,该类会自动用一个新的(未训练的)分类头替换原始的分类头,以适应所需的类别数量。这就是我们现在需要训练的部分。
我们还加载了该模型的图像处理器。我们将使用它来预处理每个图像,正如模型所期望的那样:它将被缩放到 224 × 224,像素值将被归一化到-1 和 1 之间,通道维度将被移动到空间维度之前。我们还设置了use_fast=True,因为有一个快速的图像处理器实现可用,所以我们不妨使用它。处理器接收一个图像作为输入,并返回一个包含“pixel_values”条目的字典,该条目等于预处理后的图像。
接下来,我们需要一个数据收集器,它将预处理一批中的所有图像,并将图像和标签作为 PyTorch 张量返回:
def vit_collate_fn(batch):
images = [example["image"] for example in batch]
labels = [example["label"] for example in batch]
inputs = vit_processor(images, return_tensors="pt", do_convert_rgb=True)
inputs["labels"] = torch.tensor(labels)
return inputs
我们设置了do_convert_rgb=True,因为模型期望 RGB 图像,但数据集中的一些图像是 RGBA(即它们有一个额外的透明度通道),因此我们必须强制转换为 RGB 以避免训练过程中出现错误。现在我们准备好使用熟悉的 Hugging Face 训练 API 来训练我们的模型:
from transformers import Trainer, TrainingArguments
args = TrainingArguments("my_pets_vit", per_device_train_batch_size=16,
eval_strategy="epoch", num_train_epochs=3,
remove_unused_columns=False)
trainer = Trainer(model=vit_model, args=args, data_collator=vit_collate_fn,
train_dataset=pets["train"], eval_dataset=pets["test"])
train_output = trainer.train()
警告
默认情况下,训练器会自动移除forward()方法未使用的输入属性:我们的模型期望pixel_values和可选的labels,但任何其他内容都将被丢弃,包括"image"属性。由于未使用的属性在调用collate_fn()函数之前被丢弃,因此example["image"]代码将导致错误。这就是为什么我们必须设置remove_unused_columns=False。
仅经过 3 个 epoch,我们的 ViT 模型就达到了约 91.8%的准确率。通过一些数据增强和更多的训练,你可能会达到 93%到 95%的准确率,这接近了当前的最佳水平。太棒了!但我们才刚刚开始:自 2020 年以来,ViT 在许多方面都得到了改进。特别是,现在可以使用蒸馏以更高效的方式从头开始训练它们。让我们看看如何。
数据高效图像转换器
在谷歌的 ViT 论文发布仅仅两个月后,一支 Facebook 的研究团队发布了数据高效图像转换器 (DeiT)。^(6) 他们的 DeiT 模型在 ImageNet 上取得了有竞争力的结果,而无需为训练提供任何额外的数据。该模型的架构几乎与原始 ViT 相同(参见图 16-4),但作者们使用了一种蒸馏技术,将知识从教师模型传递到他们的学生 ViT 模型(蒸馏技术在第十五章中介绍)。
作者使用了一个冻结的、最先进的 CNN 作为教师模型。在训练过程中,他们向学生 ViT 模型添加了一个特殊的蒸馏标记。就像类别标记一样,蒸馏标记的表示是可训练的,其输出通过一个专门的分类头。两个分类头(类别标记和蒸馏标记)同时训练,都使用交叉熵损失,但类别标记的分类头使用正常的硬目标(即 one-hot 向量)进行训练,而蒸馏头使用教师模型输出的软目标进行训练。最终的损失是两个分类损失(通常权重相等)的加权总和。在推理时间,蒸馏标记及其分类头被丢弃。这就是全部内容!如果你在相同的 pets 数据集上微调一个 DeiT 模型,使用model_id = "facebook/deit-base-distilled-patch16-224"和DeiTForImageClassification,你只需经过三个 epoch 就应该能达到大约 94.4%的验证准确率。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-skl-pt/img/hmls_1604.png
图 16-4. 数据高效图像 Transformer (DeiT) = ViT + 蒸馏
到目前为止,我们只使用了 ViTs 进行分类任务,那么对于密集预测任务,如目标检测或语义分割(在第十二章中介绍)怎么办呢?为此,ViT 架构需要稍作调整;欢迎来到分层视觉 Transformer。
用于密集预测任务的 Pyramid Vision Transformer
2021 年是 ViTs(视觉 Transformer)丰收的一年:几乎每隔一周就有新的模型推动了技术的进步。一个重要的里程碑是 2021 年 2 月发布的Pyramid Vision Transformer (PVT),由南京大学、香港大学、IIAI 和 SenseTime Research 的研究团队开发。他们指出,原始的 ViT 架构在分类任务上表现良好,但在需要精细分辨率的大规模预测任务上表现不佳。为了解决这个问题,他们提出了一种金字塔架构,其中图像被处理成越来越小但越来越深的图像(即语义上更丰富),这与 CNN 非常相似。图 16-5 展示了如何将一个 256 × 192 像素、3 个通道(RGB)的图像首先转换成一个 64 × 48 像素、64 个通道的图像,然后转换成一个 32 × 24 像素、128 个通道的图像,接着是一个 16 × 12 像素、320 个通道的图像,最后是一个 8 × 6 像素、512 个通道的图像。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-skl-pt/img/hmls_1605.png
图 16-5. 用于密集预测任务的金字塔视觉 Transformer
在金字塔的每一层,输入图像的处理方式与常规 ViT 非常相似。它首先被切割成补丁,并转换成一系列补丁标记,然后添加可训练的位置嵌入,最后将得到的标记通过一个仅编码器的 transformer,由多个编码器层组成。
由于编码器输出一系列向量(即,上下文化的嵌入),这个序列必须被重塑成一个 网格 的向量,然后可以将其视为一个图像(具有许多通道)并传递到金字塔的下一层。例如,第一层的编码器接收一个包含 3,072 个补丁标记的序列,因为图像被切割成一个 64 × 48 的 4 × 4 补丁网格(64 × 48 = 3,072)。每个补丁标记表示为一个 64 维的向量。编码器还输出 3,072 个向量(即,上下文化的嵌入),每个 64 维,并且它们被组织成一个 64 × 48 的网格。这给我们提供了一个 64 × 48 的图像,具有 64 个通道,可以传递到下一层。在金字塔的第二、三、四层,补丁标记分别是 128 维、320 维和 512 维。
重要的是,这些补丁的大小比原始的 ViT 小得多:在第一层,它们只是 4 × 4,而在第二、三、四层则是 2 × 2。这些微小的补丁提供了更高的空间分辨率,这对于密集预测任务至关重要。然而,这也带来了代价:较小的补丁意味着需要更多的它们,而且由于多头注意力的复杂度是二次的,对 ViT 的简单适配将需要大量的计算。这就是为什么 PVT 作者引入了 空间减少注意力(SRA):它就像 MHA 一样,只是键和值首先在空间上进行了减少(但不是查询)。为此,作者提出了一系列操作,通常实现为一个步长卷积层,随后是层归一化(尽管一些实现使用平均池化层代替)。
让我们看看 SRA 在金字塔第一层的影响。这里有 3,072 个补丁标记。在常规 MHA 中,每个标记都会关注每个标记,因此我们需要计算 3,072²个注意力分数:这超过 900 万个分数!在 SRA 中,查询保持不变,因此仍然涉及 3,072 个标记,但键和值在空间上减少了 8 倍,水平和垂直方向都是如此(在金字塔的 2、3 和 4 层,减少因子分别是 4、2 和 1)。因此,不再是 3,072 个标记代表一个 64 × 48 的网格,键和值仅由 48 个标记组成,代表一个 8 × 6 的网格(因为 64 / 8 = 8 和 48 / 8 = 6)。因此,我们只需要计算 3,072 × 48 = 147,456 个注意力分数:这比计算成本降低了 64 倍。而且好消息是,这不会影响输出分辨率,因为我们根本就没有减少查询:编码器仍然输出 3,072 个标记,代表一个 64 × 48 的图像。
好的,所以 PVT 模型接收一个图像并输出四个逐渐变小且更深的图像。那么接下来呢?我们如何使用这些多尺度特征图来实现目标检测或其他密集预测任务呢?嗯,没有必要重新发明轮子:现有的解决方案通常涉及一个生成多尺度特征图的 CNN 骨干网络,因此我们可以简单地将其替换为 PVT(通常在 ImageNet 上预训练)。例如,我们可以使用 FCN 方法进行语义分割(在第十二章末尾介绍),通过上采样和组合 PVT 输出的多尺度特征图,并添加一个最终的分类头以输出每个像素的一个类别。同样,我们可以使用 Mask R-CNN 进行目标检测和实例分割,用 PVT 替换其 CNN 骨干网络。
简而言之,PVT 的层次结构是视觉 Transformer 的一个重大里程碑,尽管有空间减少注意力,但它仍然计算成本高昂。Swin Transformer 在一个月后发布,具有更高的可扩展性。让我们看看原因。
Swin Transformer:一种快速且通用的 ViT
2021 年 3 月,一支微软研究团队发布了Swin Transformer。^(8) 就像 PVT 一样,它具有层次结构,生成可用于密集预测任务的多尺度特征图。但 Swin 使用了一种非常不同的多头注意力变体:每个补丁只关注同一窗口内的补丁。这被称为基于窗口的多头自注意力(W-MSA),它允许自注意力的成本与图像大小(即面积)线性缩放,而不是平方缩放。
例如,在图 16-6 的左侧,图像被切割成 28 × 28 的补丁网格,这些补丁被分组到非重叠的窗口中。在 Swin 金字塔的第一层,补丁通常是 4 × 4 像素,每个窗口包含一个 7 × 7 的补丁网格。因此总共有 784 个补丁标记(28 × 28),但每个标记只关注 49 个标记(7 × 7),所以 W-MSA 层只需要计算 784 × 49 = 38,416 个关注分数,而不是常规 MHA 的 784² = 614,656 个分数。
最重要的是,如果我们将图像的宽度和高度加倍,补丁标记的数量将增加到四倍,但每个标记仍然只关注 49 个标记,所以我们只需要计算 4 倍的关注分数:Swin Transformer 的计算成本与图像面积成线性关系,因此它可以处理大图像。相反,ViT、DeiT 和 PVT 都呈二次方增长:如果你将图像的宽度和高度加倍,面积将增加到四倍,计算成本将乘以 16!因此,这些模型对于非常大的图像来说速度太慢,这意味着你必须首先下采样图像,这可能会损害模型的准确性,尤其是在密集预测任务中。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-skl-pt/img/hmls_1606.png
图 16-6. Swin Transformer:交替使用 W-MSA(左)和 SW-MSA(中);SW-MSA 可以被优化为需要与 W-MSA 相同数量的窗口(右)
但等等!如果每个标记只关注同一窗口内的补丁,我们如何希望捕捉到长距离模式呢?答案就在架构的名字中,Swin,代表移位窗口:每隔一个编码器层使用移位 W-MSA(SW-MSA),这就像 W-MSA 一样,只是窗口偏移了半个窗口大小。正如你在图 16-6 的中间部分可以看到的,窗口向右下角偏移了 3 个补丁(因为 7 的一半是 3.5,我们将其四舍五入到 3)。这有什么帮助呢?好吧,在上一层中分别位于不同窗口的邻近补丁现在在同一窗口中,因此它们可以相互看到。通过交替使用 W-MSA 和 SW-MSA,图像任何部分的信息可以逐渐传播到整个图像。此外,由于架构是分层的,随着我们向上金字塔移动,补丁变得越来越粗糙,因此信息可以传播得越来越快。
SW-MSA 的简单实现需要处理许多额外的窗口。例如,如果你比较图 16-6 中的 W-MSA 和 SW-MSA,你可以看到 W-MSA 使用 16 个窗口,而 SW-MSA 使用 25 个(至少在这个例子中)。为了避免这种额外成本,作者提出了一个优化的实现:不是移动窗口,而是移动图像本身并将其包裹在边缘,如图图 16-6 的右侧所示。这样,我们回到了 16 个窗口。然而,这需要对包含包裹补丁的边缘窗口进行仔细的掩码处理;例如,标记为①、②、③、④的区域不应该相互看到,即使它们位于同一个窗口内,因此必须应用适当的注意力掩码。
总体来说,Swin 比 PVT 更难实现,但它的线性扩展和优秀性能使其成为最好的视觉 Transformer 之一。但 2021 年还没有结束:Swin v2于 2021 年 11 月发布。^(9)它在各个方面都改进了 Swin:对大型 ViT 的更稳定训练,更容易在大图像上进行微调,减少了对标记数据的需要,等等。Swin v2 至今仍在视觉任务中得到广泛应用。
我们的工具箱现在包含了用于分类(例如,ViT 和 DeiT)和密集预测任务(例如,PVT 和 Swin)的视觉 Transformer。现在让我们探索最后一个纯视觉 Transformer,DINO,它引入了一种革命性的自监督技术,用于视觉表示学习。
DINO:自监督视觉表示学习
2021 年 4 月,Mathilde Caron 等人介绍了DINO,^(10)一种令人印象深刻的自监督训练技术,能够生成能够生成优秀图像表示的模型。这些表示可以用于分类和其他任务。
这就是它的工作原理:在训练过程中,模型被复制,一个网络充当教师,另一个网络充当学生(参见图 16-7)。梯度下降只影响学生,而教师的权重只是学生权重的指数移动平均(EMA)。这被称为动量教师。学生被训练以匹配教师的预测:由于它们几乎是同一个模型,这被称为自蒸馏(因此模型的名称是 self-distillation with no labels)。
在每个训练步骤中,输入图像以各种方式进行增强:颜色抖动、灰度、高斯模糊、水平翻转等。重要的是,它们以不同的方式增强教师和学生:教师总是看到完整图像,仅略有增强,而学生通常只看到图像的放大部分,增强更强烈。简而言之,教师和学生看到的原始图像的变体不同,但他们的预测必须仍然匹配。这迫使他们就高级表示达成一致。
然而,使用这种机制存在模式坍塌的强烈风险。这是当学生和教师始终输出完全相同的东西,完全忽略输入图像时。为了防止这种情况,DINO 跟踪教师预测对数的移动平均值,并从预测对数中减去这个平均值。这被称为居中,迫使教师将其预测均匀地分布在所有类别上(平均而言,随着时间的推移)。
但仅居中可能会导致教师始终为每个类别输出相同的概率,始终忽略图像。为了避免这种情况,DINO 还迫使教师对其最高预测有很高的信心:这被称为锐化。它是通过将教师的对数几率(即,除以小于 1 的温度)应用低温度来实现的。居中和锐化共同保留了教师输出的多样性;这给模型留下了没有捷径。它必须基于图像的实际内容进行预测。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-skl-pt/img/hmls_1607.png
图 16-7. DINO,或无标签的自蒸馏
训练完成后,你可以丢弃教师:学生就是最终的 DINO 模型。如果你给它一个新图像,它将输出一系列上下文化的补丁嵌入。这些嵌入可以用各种方式使用。例如,你可以在类别标记的输出嵌入之上训练一个分类头。实际上,你甚至不需要一个新的分类头:你可以在每个训练图像上运行 DINO 来获取它们的表示(即类别标记的输出),然后计算每个类别的平均表示。然后,当给定一个新图像时,使用 DINO 来计算其表示,并寻找与最近平均表示最接近的类别。这种方法在 ImageNet 上达到了 78.3% 的 top-1 准确率,相当令人印象深刻。
但这不仅仅是关于分类!有趣的是,DINO 的作者们注意到,在最后一层的类别标记的注意力图中,通常关注图像中感兴趣的主要对象,尽管它们完全是未经标签训练的!实际上,每个注意力头似乎都关注对象的不同部分,正如你在图 16-8 中可以看到。^(11) 请参阅笔记本,其中包含使用 DINO 绘制类似注意力图的代码示例。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-skl-pt/img/hmls_1608.png
图 16-8. 使用 DINO 进行无监督图像分割——不同的注意力头关注主对象的不同部分
后续技术,如TokenCut^(12),在 DINO 的基础上检测和分割图像和视频中的对象。然后在 2023 年 4 月,Meta 发布了DINOv2^(13),它在精心策划的更大数据集上进行了训练,并调整以输出每个块的特性,使其不仅成为分类的强大基础模型,而且对于密集预测任务也非常有用。
让我们回顾一下:我们已经介绍了基于 CNN 的 transformers,如 DETR,接着是原始的 ViT(通过编码器处理图像块),DeiT(蒸馏 ViT),PVT(具有空间减少注意力的分层 ViT),Swin(基于窗口的注意力的分层 ViT),以及 DINO(无标签的自蒸馏)。在我们继续到多模态 transformers 之前,让我们快速浏览一些更多的纯视觉 transformer 模型和技术。
其他主要视觉模型和技术
视觉 transformers 的进展一直持续到今天。以下是关于一些里程碑论文的简要概述:
“扩展视觉 Transformer”,^(14) 2021 年 6 月
Google 研究人员展示了如何根据可用数据量扩展或缩小 ViT。他们成功地创建了一个包含 20 亿个参数的巨大模型,在 ImageNet 上达到了超过 90.4%的 top-1 准确率。相反,他们还训练了一个缩小后的模型,在 ImageNet 上达到了超过 84.8%的 top-1 准确率,仅使用了 10,000 张图片:每个类别的图片只有 10 张!
“BEiT:图像 Transformer 的 BERT 预训练”,^(15) 2021 年 6 月
Hangbo Bao 等人提出了一种受 BERT 的掩码语言建模(MLM)启发的掩码图像建模(MIM)方法。BEiT 被预训练以从可见图像块重建掩码图像块。这种预训练技术显著提高了下游任务。
注意,BEiT 并未训练来预测遮挡补丁的原始像素;相反,它必须预测遮挡标记 ID。但这些标记 ID 从何而来?嗯,原始图像通过一个 离散变分自动编码器(dVAE,见第十八章)传递,将每个补丁编码成一个视觉标记 ID(一个整数),来自一个固定词汇表。这些就是 BEiT 尝试预测的 ID。目标是避免在不必要的细节上浪费模型的能力。
“Masked Autoencoders Are Scalable Vision Learners”,^(16) 2021 年 11 月
这篇由 Facebook 研究团队(由多产的 Kaiming He 领导)撰写的论文也提出了一种基于遮挡图像建模的预训练技术,但它去除了 BEiT 的 dVAE 的复杂性:遮挡自动编码器(MAE)直接预测原始像素值。关键的是,它使用非对称的编码器-解码器架构:一个大型编码器仅处理可见补丁,而一个轻量级的解码器重建整个图像。由于 75% 的补丁被遮挡,这种设计大大降低了计算成本,并允许 MAE 在非常大的数据集上进行预训练。这导致了在下游任务上的强大性能。
“Model Soups”,^(17) 2022 年 3 月
本文证明了首先训练多个变压器,然后平均它们的权重以创建一个新且改进的模型是可能的。这与集成(见第六章)类似,但最终只有一个模型,这意味着没有推理成本。
“EVA: Exploring the Limits of Masked Visual Representation Learning at Scale”,^(18) 2022 年 5 月
EVA 是一系列在规模上预训练的大型 ViT,使用增强的 MAE 和强大的增强。它是 ViT 的领先基础模型之一。2023 年 3 月发布的 EVA-02,尽管参数较少,但表现同样出色甚至更好。大型变体有 304M 个参数,在 ImageNet 上达到了令人印象深刻的 90.0%。
I-JEPA,^(19) 2023 年 1 月
Yann LeCun 在一篇 2022 年的论文中提出了联合嵌入预测架构(JEPA),^(20) 作为他世界模型框架的一部分,旨在加深 AI 对世界的理解并提高其预测的可靠性。I-JEPA 是 JEPA 的图像实现。2024 年很快推出了 V-JEPA,2025 年推出了 V-JEPA 2,两者都处理视频。
在训练过程中,JEPA 涉及两个编码器和预测器:教师编码器看到完整的输入(例如,一张猫的照片),而学生编码器只看到输入的一部分(例如,同一张猫的照片但没有耳朵)。两个编码器都将它们的输入转换为嵌入,然后预测器试图根据输入其余部分的学生嵌入(例如,没有耳朵的猫)预测缺失部分(例如,耳朵)的教师嵌入。学生编码器和预测器联合训练,而教师编码器只是学生编码器的一个移动平均值(类似于 DINO)。JEPA 主要在嵌入空间而不是像素空间中工作,这使得它快速、参数高效且更具语义性。
训练完成后,教师编码器和预测器不再需要,但学生编码器可以用来为下游任务生成优秀且具有意义的表示。
列表可以一直继续:
-
NesT 或 DeiT-III 用于图像分类
-
MobileViT、EfficientFormer、EfficientViT 或 TinyViT,用于小型高效的图像分类模型(例如,用于移动设备)
-
类似于 Twins-SVT、FocalNet、MaxViT 和 InternImage 这样的分层 Transformer,常被用作密集预测任务的主干网络
-
Mask2Former 或 OneFormer 用于通用分割,SEEM 用于通用分割,SAM 或 MobileSAM 用于交互式分割
-
ViTDet 或 RT-DETR 用于目标检测
-
TimeSformer、VideoMAE 或 OmniMAE 用于视频理解
此外,还有一些技术,如token merging(ToMe),通过在运行时合并相似标记来加速推理,token pruning在处理过程中去除不重要的标记(即,具有低注意力分数的标记),early exiting只计算最重要的标记的深层,patch selection只选择最具有信息量的块进行处理,以及像 SimMIM、iBOT、CAE 或 DINOv2 这样的自监督训练技术,等等。
希望我们已经涵盖了足够多的模型和技术,以便您能够进一步探索。
小贴士
其中一些仅用于视觉的模型是在多模态数据上预训练的(例如,图像-文本对或输入提示):OmniMAE、SEEM、SAM、MobileSAM 和 DINOv2。这很自然地引出了本章的第二部分。
我们已经有了能够阅读和写作(以及聊天!)的 Transformer,现在我们有了能够看到的视觉 Transformer。是时候构建能够同时处理文本和图像,以及其他模态的 Transformer 了。
多模态 Transformer
人类是多模态生物:我们通过多种感官感知世界——视觉、听觉、嗅觉、味觉、触觉、平衡感、本体感觉(即身体位置感)以及其他几种——并通过运动、言语、书写等方式作用于世界。这些模态可以被视为非常低级(例如,声波)或高级(例如,单词、语调、旋律)。重要的是,模态是异质的:一个模态可能是连续的,而另一个是离散的;一个可能是时间的,而另一个是空间的;一个可能是高分辨率的(例如,48 kHz 音频),而另一个则不是(例如,文本);一个可能是嘈杂的,而另一个是干净的,等等。
此外,模态可能以各种方式相互作用。例如,当我们与人交谈时,我们可能同时听到他们的声音并观察他们嘴唇的运动:这两种模态(听觉和视觉)携带重叠的信息,这有助于我们的大脑更好地解析单词。但多模态不仅仅是提高信号/噪声比:面部表情可能携带它们自己的意义(例如,微笑和皱眉),不同的模态可能结合产生新的意义。例如,如果你说“他是个专家”同时翻白眼或做出引号手势,你显然是在讽刺,这颠倒了你的句子的意义并传达了额外信息(例如,幽默或轻蔑),这些信息任何一个模态单独都不具备。
因此,多模态机器学习需要设计能够处理非常异质数据并捕捉它们之间相互作用的模型。这有两个主要挑战。第一个被称为融合,它涉及到找到一种方法来结合不同的模态,例如,通过将它们编码到同一个表示空间中。第二个被称为对齐,其目标是发现模态之间的关系。例如,你可能有一个语音的录音,以及相应的文本转录,你想要找到每个单词的时间戳。或者,你想要根据文本查询“树旁的狗”找到图像中最相关的对象(这被称为视觉定位)。许多其他常见任务涉及两个或更多模态,例如图像标题、图像搜索、视觉问答(VQA)、语音转文本(STT)、文本转语音(TTS)、具身人工智能(即能够与物理环境进行物理交互的模型)等等。
多模态机器学习已经存在了几十年,但由于深度学习,尤其是自变压器兴起以来,最近进展加速。确实,只要你能将其切割成一系列有意义的标记(例如,将文本切割成单词,将图像切割成小块,将音频或视频切割成短剪辑等),变压器几乎可以处理任何模态。一旦你准备好了标记嵌入的序列,你就可以将其输入到变压器中。不同模态的嵌入可以通过各种方式融合:相加、连接、通过融合编码器传递,等等。这可以解决融合问题。而且,变压器还有多头注意力,这是一个强大的工具,可以检测和利用复杂模式,无论是模态内部还是跨模态。这可以解决对齐问题。
研究人员很快理解了变压器在多模态架构中的潜力。第一个多模态变压器是在 2018 年初原 Transformer 论文发布后的几个月内发布的,包括图像字幕、视频字幕等。让我们看看一些最有影响力的多模态变压器架构,从 VideoBERT 开始。
VideoBERT:一种用于文本加视频的 BERT 变体
2019 年 4 月,谷歌研究人员发布了VideoBERT。^(21) 如其名所示,这个模型与 BERT 非常相似,但它可以处理文本和视频。事实上,作者只是将预训练的 BERT-large 模型扩展到允许额外的视频标记(稍后会有更多介绍),并继续使用文本加视频训练集上的自监督训练模型。这个数据集是从大量教学 YouTube 视频中构建的,特别是烹饪视频。这些视频通常涉及某人描述执行一系列动作的同时进行(例如,“像这样将番茄切成薄片”)。为了将这些视频输入到 VideoBERT 中,作者必须将视频编码成文本和视觉序列(参见图 16-9)):
-
对于视觉模态,他们提取了非重叠的 1.5 秒剪辑,每秒 20 帧(即每个剪辑 30 帧),并将这些剪辑通过一个名为 S3D 的 3D CNN。这个 CNN 基于 Inception 模块和可分离卷积(参见第十二章),并在包含许多 YouTube 视频中人们执行各种动作的 Kinetics 数据集上进行了预训练。作者在 S3D 之上添加了一个 3D 平均池化层,为每个视频剪辑得到一个 1,024 维的向量。每个向量编码了关于视频剪辑相当高级的信息。
-
为了从视频中提取文本,作者使用了 YouTube 的内部语音到文本软件,之后他们从视频中删除了音频轨道。然后,他们通过添加标点符号使用现成的 LSTM 模型将文本分离成句子。最后,他们像对 BERT 一样预处理和标记化文本。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-skl-pt/img/hmls_1609.png
图 16-9. VideoBERT—将视频编码为文本序列和视觉序列
太好了!我们现在有一个描述一些动作的文本标记序列,以及代表这些动作视频剪辑的向量序列。然而,我们遇到了一个问题。回想一下,BERT 是使用 MLM 进行预训练的,其中模型必须从固定词汇表中预测被掩码的标记。我们确实有一个用于文本标记的固定词汇表,但没有用于视频标记的。所以,让我们构建一个吧!为此,作者收集了 S3D 在他们的训练集中产生的所有视觉向量,并使用 k-均值(见第八章第八章)将这些向量聚类成 k = 12 个簇。然后,他们在每个簇上再次使用 k-均值,得到 12² = 144 个簇,然后又再次这样做,得到 12⁴ = 20,736 个簇。这个过程被称为层次 k-均值,它比只使用 k = 20,736 运行 k-均值一次要快得多,而且通常会产生更好的簇。现在,每个向量都可以用其簇 ID 来替换:这样,每个视频剪辑都由固定视觉词汇中的一个单一 ID 来表示,因此整个视频现在由一个视觉标记 ID 序列(例如,194,3912,…)来表示,就像标记化文本一样。简而言之,我们已经从连续的 1,024 维空间下降到只有 20,736 个可能值的离散空间。在这个步骤中会有大量的信息损失,但 VideoBERT 出色的性能表明,大部分重要信息仍然保留。
注意
由于作者使用了预训练的 BERT-large 模型,因此在 VideoBERT 的额外训练开始之前,文本标记嵌入已经非常优秀了。对于视觉标记嵌入,作者没有使用从零开始初始化的可训练嵌入,而是使用了使用 k-均值簇质心的 1,024 维向量表示初始化的冻结嵌入。
作者使用了三种不同的训练模式:仅文本、仅视频和文本加视频。在仅文本和仅视频模式下,VideoBERT 只接受单一模态输入,并训练预测掩码标记(文本标记或视频标记)。对于文本加视频,模型同时接受文本标记和视频标记,简单拼接(中间加上一个不重要的分隔标记),并需要预测文本标记和视频标记是否来自原始视频的同一部分。这被称为语言-视觉对齐。为此,作者在类别标记输出之上添加了一个二分类头(这取代了 BERT 的下一句预测头)。对于负例,作者只是随机采样句子和视频片段。图 16-10 同时显示了所有三种模式,但请注意,它们实际上是分开的。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-skl-pt/img/hmls_1610.png
图 16-10. VideoBERT—使用掩码标记预测和语言-视觉对齐进行预训练(显示在一起但实际上是分开的)
语言-视觉对齐是一个有噪声的任务,因为厨师可能会解释他们已经完成或将要做的某事,因此作者将随机相邻的句子拼接起来,以给模型提供更多上下文。作者还有一些其他的技巧,例如随机改变视频采样率,使模型对不同的动作速度更加鲁棒,因为有些厨师比其他人更快;更多细节请参阅论文。
这是一项大量工作,但作者最终完成了:他们拥有一个完全训练好的 VideoBERT 模型。为了展示其有效性,他们在一些下游任务上评估了 VideoBERT,包括:
零样本动作分类
给定一个视频片段,确定执行了哪个动作,而不需要微调 VideoBERT。作者通过将视频输入到 VideoBERT 中,并附带以下掩码句子:“现在让我给你演示如何
视频字幕
给定一个视频片段,生成一个字幕。为此,作者使用了最早的视频字幕 Transformer 架构^(22),但他们用 VideoBERT 输出的视觉特征替换了编码器的输入。更具体地说,他们取了 VideoBERT 最终输出表示的平均值,包括所有视觉标记的表示和被遮蔽的文本标记的表示。他们使用的遮蔽句子是:“现在让我们把刀切向番茄,然后切向土豆,最后切向洋葱”。在微调这个新模型后,他们获得了比原始字幕模型更好的结果。
使用类似的方法,VideoBERT 可以适应许多其他任务,例如多选题视觉问答:给定一个图像、一个问题以及多个可能的答案,模型必须找到正确的答案。例如:“厨师在做什么?”→“切番茄”。为此,一种方法是在每个可能的答案上简单地运行 VideoBERT,包括视频,并比较语言-视觉对齐得分:正确的答案应该有最高的得分。
VideoBERT 的成功激励了许多基于 BERT 的多模态 Transformer,其中许多在 2019 年 8 月和 9 月发布:ViLBERT、VisualBERT、Unicoder-VL、LXMERT、VL-BERT 和 UNITER。其中大多数是单流模型,类似于 VideoBERT,意味着模态在网络的早期就融合在一起,通常是通过简单地连接序列。然而,ViLBERT 和 LXMERT 是双流 Transformer,意味着每个模态都由自己的编码器处理,并有一个机制允许编码器相互影响。这使得模型在尝试理解它们之间的相互作用之前,能更好地理解每个模态。ViLBERT 尤其有影响力,因此让我们更详细地看看它。
ViLBERT:一种用于文本加图像的双流 Transformer
ViLBERT 是由乔治亚理工学院、Facebook AI Research 和俄勒冈州立大学的研究团队在 2019 年 8 月提出^(23)的。他们指出,单流方法(VideoBERT 和许多其他方法所使用)对两种模态的处理是相同的,尽管它们可能需要不同级别的处理。例如,如果视觉特征来自深度 CNN,那么我们已经有很好的高级视觉特征,而文本在模型能够访问高级文本特征之前需要更多的处理。此外,研究人员假设“图像区域可能比句子中的单词关系更弱”。^(24) 最后,BERT 最初仅使用文本进行预训练,因此强迫它处理其他模态可能会得到次优结果,甚至可能在多模态训练期间损坏其权重。
因此,作者选择了一种双流方法:每个模态都通过自己的编码器,在上层,两个编码器通过一个新的双向交叉注意力机制连接并交换信息,称为共注意力(见图 16-11)。具体来说,在每一对连接的编码层中,一个编码器的 MHA 查询被另一个编码器用作 MHA 键/值。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-skl-pt/img/hmls_1611.png
图 16-11. 通过共注意力连接的两个编码层:一个编码器的 MHA 查询被另一个编码器用作 MHA 键/值
文本编码器的底层使用 BERT 的权重初始化(作者使用了一个 BERT 基础模型,它有 12 层),并在其上方放置了 6 个共注意力层(见图 16-12 的右下象限)。视觉特征由一个预训练并冻结的 Faster R-CNN 模型产生,并假设这些特征足够高级,因此不需要进一步处理;因此,视觉编码器仅由六个共注意力层组成,与文本编码器的六个共注意力层配对(见图的左下象限)。Faster R-CNN 模型的输出为每个检测到的区域通过一个均值池化层,因此我们得到每个区域的特征向量,并且低置信度区域被丢弃:每个图像最终由 10 到 36 个向量表示。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-skl-pt/img/hmls_1612.png
图 16-12. ViLBert 使用掩码标记预测和语言-视觉对齐进行预训练(再次,一起显示但实际上是分开的)
由于区域没有像单词那样的自然顺序,视觉编码器不使用位置编码。相反,它使用如下计算的空间编码:每个区域的边界框被编码为一个包含归一化左上角和右下角坐标以及边界框覆盖的图像比例的 5D 向量。然后,这个 5D 向量被线性投影到与视觉向量相同的维度,并简单地添加到它上面。
最后,一个特殊的[IMG]标记被添加到视觉序列的开头:它具有与类别标记相同的作用(即,生成整个序列的表示),但它不是一个可训练的嵌入,而是计算为特征向量的平均值(在空间编码之前),加上覆盖整个图像的边界框的空间编码。
现在转到训练!与 VideoBERT 类似,作者使用了掩码标记预测和语言-视觉对齐:
-
对于掩码标记预测,作者在文本编码器中使用了类似于 BERT 的常规 MLM。然而,对于视觉编码器,由于 ViLBERT 不使用固定大小的视觉词汇表(没有聚类步骤),模型被训练来预测 CNN 对给定图像区域预测的类别分布(这是一个软目标)。作者选择这个任务而不是预测原始像素,因为区域可能相当大,并且通常周围区域和文本中的信息不足以正确重建掩码区域:目标是追求更高层次的目标。
-
对于语言-视觉对齐,模型取[IMG]和[CLS]标记的输出,然后计算它们的逐项乘积,并将结果传递给必须预测文本和图像是否匹配的二分类头。乘法优于加法,因为它放大了在两种表示中都很强的特征(有点像逻辑 AND 门),因此更好地捕捉了对齐。
就这样。该模型在包括图像定位、基于描述的图像检索(甚至零样本)、视觉问答和视觉常识推理(VCR)在内的多个下游任务上显著超越了现有技术。VCR 涉及回答关于图像的多项选择题(如 VQA),然后选择适当的理由。例如,给定一张服务员在桌子上为一些煎饼服务的图像,以及问题“为什么第 4 个人指着第 1 个人”,模型必须选择正确的答案“他正在告诉第 3 个人第 1 个人点了煎饼”,然后它必须选择理由“第 3 个人正在上菜,他们可能不知道谁的订单是谁的”。
ViLBERT 由于其双流架构、协同注意力的发明以及在许多下游任务上的出色结果,对多模态机器学习领域产生了强烈影响。这是大规模自监督预训练使用 transformers 的又一伟大展示。下一个重大里程碑出现在 2021 年,它以非常不同的方式处理这个问题,使用了对比预训练:遇见 CLIP。
CLIP:使用对比预训练训练的双编码器文本加图像模型
OpenAI 于 2021 年 1 月发布的对比语言-图像预训练(CLIP)^(25)是一个重大突破,不仅因为其惊人的能力,还因为其基于对比学习的出人意料简单的方法:模型学习将文本和图像编码成向量表示,当文本和图像匹配时相似,不匹配时不相似。
一旦训练完成,该模型可以用于许多任务,尤其是零样本图像分类。例如,CLIP 可以用作昆虫分类器,而无需任何额外训练:只需将所有可能的类名输入到 CLIP 中,例如“蟋蟀”、“瓢虫”、“蜘蛛”等等,为每个类名获得一个向量表示。然后,无论何时你想对图像进行分类,只需将其输入到 CLIP 中,以获得一个向量表示,然后使用余弦相似度找到最相似类名表示。这通常在文本类似于网络上常见的典型图像标题时效果更好,因为 CLIP 就是这样训练的,例如,“这是一张瓢虫的照片”而不是仅仅“瓢虫”。一点提示工程可以帮助(即,尝试各种提示模板)。
好消息是,CLIP 是完全开源的^(26),Hugging Face Hub 上有几个预训练模型可用,Transformers 库提供了一个方便的零样本图像分类管道:
from transformers import pipeline
model_id = "openai/clip-vit-base-patch32"
clip_pipeline = pipeline(task="zero-shot-image-classification", model=model_id,
device_map="auto", dtype="auto")
candidate_labels = ["cricket", "ladybug", "spider"]
image_url = "https://homl.info/ladybug" # a photo of a ladybug on a dandelion
results = clip_pipeline(image_url, candidate_labels=candidate_labels,
hypothesis_template="This is a photo of a {}.")
注意,我们提供了一个提示模板,因此模型实际上会编码“这是一张瓢虫的照片”,而不仅仅是“瓢虫”(如果你没有提供任何模板,该管道实际上默认为“这是一张{}的照片”。)现在让我们看看结果,这些结果按分数排序:
[{'score': 0.9972853660583496, 'label': 'ladybug'},
{'score': 0.0016511697322130203, 'label': 'spider'},
{'score': 0.0010634352220222354, 'label': 'cricket'}]
太棒了!CLIP 以超过 99.7%的置信度预测了瓢虫。现在,如果你想有一个花卉分类器,只需将候选标签替换为花卉的名称。如果你在列表中包含“蒲公英”并分类相同的图像,模型应该以高置信度选择“蒲公英”(忽略瓢虫)。令人印象深刻!
那么,这个魔法是如何工作的呢?好吧,CLIP 的架构基于一个常规文本编码器和常规视觉编码器,没有共注意力或任何花哨的东西(见图 16-13)。实际上,你可以使用几乎任何你想要的文本和视觉编码器,只要它们可以产生文本或图像的向量表示。作者尝试了各种编码器,包括几个 ResNet 和 ViT 模型用于视觉,以及一个类似 GPT-2 的模型用于文本,所有这些都是从零开始训练的。你听到我说 GPT-2 不是一个编码器吗?这是真的,它是一个仅解码器模型,但我们不是为下一个标记预测进行预训练,所以最后一个标记的输出可以自由地用作整个输入序列的表示,这正是 CLIP 所做的事情。你可能想知道为什么我们不使用像 BERT 这样的常规文本编码器?好吧,我们可以,但 OpenAI 创建了 GPT——Alex Radford 是 GPT 和 CLIP 的首席作者——所以这很可能是选择 GPT-2 的原因:作者只是对这个模型有更多的经验,并且已经建立了一个良好的训练基础设施。使用因果编码器还使得在多个文本以相同方式开始时缓存模型的中间状态成为可能;例如,“这是一张瓢虫的照片”。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-skl-pt/img/hmls_1613.png
图 16-13. CLIP:将一批图像-文本对编码为向量,然后匹配的向量被拉近,不匹配的向量被推开
还要注意,在视觉编码器之上添加了一个池化层,以确保它输出整个图像的单个向量,而不是特征图。此外,在每个编码器之上添加了一个线性层,将最终表示投影到相同的输出空间(即具有相同数量的维度)。因此,给定m个图像-文本对批次,我们得到m个图像的向量表示和m个文本的向量表示,所有向量具有相同数量的维度。图 16-13 显示了m = 4,但在训练期间,作者使用了令人震惊的大批次大小m = 2¹⁵ = 32,768。
该模型随后在从互联网上抓取的包含 4 亿个图像-文本对的庞大数据集上进行了预训练,使用对比损失^(27)将匹配对的表示拉近,同时将不匹配对的表示推开。以下是它是如何工作的:
-
所有向量首先进行ℓ[2]归一化,这意味着它们被缩放为单位向量:我们只关心它们的方向,而不是它们的长度。
-
接下来,我们计算每个可能的图像-文本对图像表示和文本表示之间的余弦相似度。结果是包含介于-1(对于相反向量)和+1(对于相同向量)之间的数字的m × m矩阵。在图 16-13 中,这个矩阵由 4 × 4 网格表示(黑色为+1,白色为-1)。每一列衡量每个批次中的图像与同一批次中给定文本的匹配程度,而每一行衡量每个文本与给定图像的匹配程度。
-
由于第i个图像对应于第i个文本,我们希望这个矩阵的主对角线包含接近+1 的相似度分数,而所有其他分数应接近 0。为什么不是接近-1 呢?好吧,如果一个图像和文本完全无关,我们可以将它们的表示视为两个随机向量。回想一下,两个随机的高维向量很可能接近正交(如第七章所述),因此它们的余弦相似度将接近 0,而不是-1。换句话说,假设不匹配对的文本和图像表示无关(分数接近 0),而不是相反(分数接近-1)是有意义的。
-
在第 i^(th) 行中,我们知道匹配的标题在第 i^(th) 列,因此我们希望模型在该列产生高相似度分数,在其他地方产生低分数。这类似于一个分类任务,其中目标类别是第 i^(th) 类。实际上,我们可以将每个相似度分数视为类别 logit,并简单地计算该行的交叉熵损失,其中 i 作为目标。我们可以对每一列采用完全相同的推理。如果我们为每一行和每一列计算交叉熵损失(使用类别 i 作为第 i^(th) 行和第 i^(th) 列的目标),并计算平均值,我们得到最终的损失。
-
只有一个额外的技术细节:相似度分数的范围在 –1 和 +1 之间,这不太可能是任务的理想 logit 尺度,因此 CLIP 在计算损失之前将所有相似度分数除以一个可训练的温度(一个标量)。
警告
这个损失需要一个大的批量大小以确保模型看到足够的负例来与正例进行对比,否则它可能会过度拟合正例的细节。CLIP 的成功部分归因于作者能够实现的巨大的批量大小。
作者在许多图像分类数据集上评估了 CLIP,对于其中大约 60% 的数据集,它在没有任何额外训练(即零样本)的情况下表现优于在 ResNet-50 特征上训练的 线性探针(这是一个在预训练并冻结的 ResNet-50 模型输出的特征上训练的线性分类器),包括在 ImageNet 上,尽管 ResNet-50 模型实际上是在 ImageNet 上预训练的。CLIP 在每个类别只有少量示例的数据集上特别强大,例如日常场景的图片(即你在网上找到的那种图片)。事实上,CLIP 甚至在斯坦福汽车数据集上击败了当时最先进的 ViTs,因为汽车图片在网络上非常常见,而且数据集每个类别的示例并不多。然而,CLIP 在特定领域的图像上表现不佳,例如卫星或医学图像。
重要的是,CLIP 输出的视觉特征对扰动也非常鲁棒,这使得它们非常适合下游任务,例如图像检索:如果你将图像存储在向量数据库中,通过它们的 CLIP 编码的视觉特征进行索引,那么你可以根据文本查询或图像查询来搜索它们。为此,只需将查询通过 CLIP 获取向量表示,然后在具有相似表示的图像中进行数据库搜索。
要使用 Transformers 库获取文本和视觉特征,您必须直接运行 CLIP 模型,而不是通过管道:
import PIL
import urllib.request
from transformers import CLIPProcessor, CLIPModel
clip_processor = CLIPProcessor.from_pretrained(model_id)
clip_model = CLIPModel.from_pretrained(model_id)
image = PIL.Image.open(urllib.request.urlopen(image_url)).convert("RGB")
captions = [f"This is a photo of a {label}." for label in candidate_labels]
inputs = clip_processor(text=captions, images=[image], return_tensors="pt",
padding=True)
with torch.no_grad():
outputs = clip_model(**inputs)
text_features = outputs.text_embeds # shape [3, 512] # 3 captions
image_features = outputs.image_embeds # shape [1, 512] # 1 image (ladybug)
小贴士
如果你需要分别对图像和文本进行编码,可以使用 CLIP 模型的get_image_features()和get_text_features()方法。你必须首先使用CLIPTokenizer对文本进行标记化,并使用CLIPImageProcessor处理图像。生成的特征没有进行ℓ[2]归一化,因此你必须将它们除以features.norm(dim=1, keepdim=True)(请参阅笔记本中的代码示例)。
特征已经进行了ℓ[2]归一化,因此如果你要计算相似度分数,只需要一次矩阵乘法即可:
>>> similarities = image_features @ text_features.T # shape [1, 3]
>>> similarities
tensor([[0.2337, 0.3021, 0.2381]])
这之所以有效,是因为矩阵乘法计算了第一个矩阵中每一行向量与第二个矩阵中每一列向量的点积,每个点积等于向量之间角度的余弦值乘以向量的范数。由于在这种情况下向量已经进行了ℓ[2]归一化,范数等于 1,所以结果只是角度的余弦值,这正是我们想要的相似度分数。正如你所见,最相似的表现是第二个,对于瓢虫类别。如果你更喜欢估计概率而不是相似度分数,你必须首先使用模型学习到的温度对相似度进行缩放,然后将结果通过 softmax 函数(很高兴看到我们得到了与管道相同的结果):
>>> temperature = clip_model.logit_scale.detach().exp()
>>> rescaled_similarities = similarities * temperature
>>> probabilities = torch.nn.functional.softmax(rescaled_similarities , dim=1)
>>> probabilities
tensor([[0.0011, 0.9973, 0.0017]])
CLIP 并不是 OpenAI 在 2021 年唯一的惊喜。就在下一个月,OpenAI 宣布了 DALL·E,它可以根据文本描述生成令人印象深刻的图像。现在让我们来讨论它。
DALL·E:从文本提示生成图像
OpenAI 于 2021 年 2 月发布的DALL·E,^(28)是一个能够根据文本提示生成图像的模型,例如“一个形状像鳄梨的扶手椅”。其架构相当简单(参见图 16-14 的左侧):一个类似于 GPT 的模型,经过训练以预测下一个标记,但与 GPT 不同,它是在数百万个图像-标题对上预训练的,并输入由文本标记后跟视觉标记组成的序列。在推理时,你只需提供文本标记,然后模型逐个生成视觉标记,直到生成完整的图像。视觉标记是由一个 dVAE 模型生成的,它接受一个图像并输出一个来自固定词汇表的标记序列。遗憾的是,该模型从未向公众发布,但论文描述得足够详细,因此有一些开源复制品可用,例如DALL·E mini,也称为 Craiyon。
一年后,即 2022 年 4 月,OpenAI 发布了DALL·E 2,^(29),能够生成更高质量的图像。其架构实际上非常不同:文本被输入到 CLIP 模型中,该模型输出文本嵌入,然后这个文本嵌入被输入到一个扩散模型中,该模型使用它来指导其图像生成过程(我们将在第十八章中讨论扩散模型)。该模型不是开源的,但可以通过付费 API 获取,并通过一些产品,如 Microsoft Designer、Bing Image Creator、Canva、ChatGPT 等。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-skl-pt/img/hmls_1614.png
图 16-14。DALL·E(左)和 DALL·E 2(右)
DALL·E 3 于 2023 年 10 月发布。遗憾的是,到那时,OpenAI 已经完全转变了其最初的开源策略:没有经过同行评审的论文,没有代码,没有权重,没有数据。与上一版本一样,DALL·E 3 可以通过 API 和一些产品获取。我们知道它是基于扩散的,不使用 CLIP,并且与 GPT-4 紧密集成,GPT-4 在生成图像之前会重写提示。它的工作效果令人印象深刻:它输出的图像令人惊叹,与提示的匹配程度比之前的版本更加精确。这种差异对于组合提示(例如,“一只毛茸茸的白色猫坐在一个红色的天鹅绒垫子上,后面是一个盛满向日葵的花瓶,沐浴在金色时光中。猫正直视观众”。)尤其明显。DALL·E 1 和 2 通常只会遵循此类提示中的一个或两个元素,而 DALL·E 3 则更紧密地遵循指令。图像质量、逼真度、艺术风格和一致性令人惊叹。最后,DALL·E 3 还集成了某些监管能力。
我们多模态旅程的下一个里程碑是在第一个 DALL·E 模型发布后一个月出现的:Perceiver。
Perceiver:通过潜在空间连接高分辨率模态
到目前为止,每个 Transformer 都需要将输入切割成有意义的标记。在文本的情况下,标记代表单词或子单词。在 ViTs 的情况下,它们代表 16×16 像素的补丁。在 VideoBERT 中,是短 1.5 秒的剪辑。在音频 Transformer 中,是短音频剪辑。如果我们直接将单个字符、像素或音频帧输入到 Transformer 中,输入序列会非常长,我们会遇到二次注意力问题。此外,我们还会失去重要的归纳偏差:例如,通过将图像切割成补丁,我们强制执行了强烈的归纳偏差,即邻近性(即,附近的像素被认为比远处的像素具有更强的相关性)。
然而,这种标记化是模态特定的,这使得处理新的模态或将其混合到模型中变得更加困难。此外,当您没有大量训练数据时(假设偏差是正确的),归纳偏差非常有用,但如果您的数据集很大,您通常会通过使用具有非常少隐含假设的无偏模型获得更好的性能。当然,模型将不得不自己找出附近的像素通常相关,但另一方面,它将足够灵活,可以发现可能被忽视的模式。
正因如此,DeepMind 在 2021 年 3 月推出了 Perceiver^(30)。这种架构能够在最低级别直接处理任何模态:字符、像素、音频帧等。此外,它以模态无关的设计来实现这一点,因此相同的模型可以处理不同的模态。Perceiver 架构在 图 16-15 中展示。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-skl-pt/img/hmls_1615.png
图 16-15. Perceiver 架构:输入通过交叉注意力层摄取,而主要输入是一系列学习到的潜在标记
让我们来看看这个架构:
-
输入首先被分割成其最小的组成部分。在这个例子中,输入是一个图像,因此它被分割成单个像素:我们现在有一个 3D 向量序列(红色、绿色、蓝色)。
-
位置编码被连接到这些特征向量中。Perceiver 使用傅里叶位置编码,这与原始 Transformer 的正弦位置编码非常相似,但它们编码了输入的所有维度。由于图像是二维的,每个像素的水平坐标和垂直坐标都会被编码;例如,如果一个像素位于坐标 x 和 y(归一化在 –1 和 1 之间),那么位置编码向量将包括 x 和 y,然后是 sin(π_fx_),sin(π_fy_),cos(π_fx_),cos(π_fy_),重复 K 次(通常是 6 次)频率 f 从 1 开始,增加到 μ / 2(均匀分布),其中 μ 是目标分辨率(例如,如果图像是 224 × 224 像素,则 μ = 224)。^(31) 位置编码向量的维度是 d(2^K_ + 1),其中 d 是输入维度的数量(即音频为 1,图像为 2,视频为 3 等)。
-
现在像素标记有 3 + 2 × (2 × 6 + 1) = 29 维。然后我们通过一个线性层将它们投影到 Perceiver 的维度(例如,512)。
-
感知器的架构本身由重复的处理块(例如,八个)组成,其中每个块由一个交叉注意力多头注意力层(MHA)和一个常规的变换器编码器(例如,具有六个编码器层)组成。最后的块由一个交叉注意力 MHA 层和一个平均池化层组成,以将输入序列减少到一个单一的向量,然后将其输入到分类头(即线性加 softmax)。
-
像素标记仅通过 MHA 层输入到感知器中,并扮演键和值的角色。换句话说,感知器仅通过交叉注意力关注像素标记。
-
关键的是,感知器的主要输入是一个相当短的潜在标记序列(例如,512)。这些标记类似于 RNN 的隐藏状态:一个初始序列(在训练期间学习)被输入到感知器中,并且随着模型通过交叉注意力学习越来越多的关于像素标记的信息,它逐渐得到更新。由于这是一个短序列,它不会受到二次注意力问题的很大影响。这被称为“潜在瓶颈技巧”,是感知器成功的关键。
-
作者们尝试了在处理块之间共享权重(不包括第一个交叉注意力层),并且取得了良好的结果。当处理块共享相同的权重时,感知器实际上是一个循环神经网络,而潜在标记确实是其隐藏状态。
注意
正如我们在第七章中看到的,流形假设指出,大多数现实世界的数据都生活在一个低维流形附近,就像一张卷起的纸张生活在三维空间中,但本质上是一个二维对象。这个二维空间是潜在的(即隐藏的、可能的),直到我们展开纸张。同样,感知器的目标是将其高维输入“展开”,以便模型可以在潜在空间中工作,使用低维表示。
重要的是,这种架构可以有效地处理高分辨率输入。例如,一个 224 × 224 的图像有 50,176 个像素,如果我们尝试直接将如此长的像素标记序列输入到常规编码器中,每个自注意力层将不得不计算 50,176² ≈ 25 亿个注意力分数!但是,由于感知器仅通过交叉注意力关注像素标记,它只需要计算 50,176 乘以潜在标记的数量。即使是最大的感知器变体,这也只是总共 50,176 × 512 ≈ 2570 万个注意力分数,这大约是 100 倍的计算量。
注意
多亏了潜在瓶颈,感知器的扩展与像素标记的数量呈线性关系,而不是二次关系。
作者们使用常规的监督学习在多个模态的分类任务上训练了 Perceiver,包括仅图像(ImageNet)、音频加视频(AudioSet)^(32) 或点云(ModelNet40)^(33),所有这些都使用了相同的模型架构。他们得到了有竞争力的结果,在某些情况下甚至达到了现有技术的水平。
AudioSet 数据集中的视频被下采样到每秒 25 帧的 224 × 224 像素,音频采样率为 48 kHz。理论上,你可以单独将每个像素和每个音频帧输入到 Perceiver 中,但这会有些极端,因为每个 10 秒的视频将表示为一个由 224 × 224 × 25 × 10 ≈ 12.5 百万像素标记组成的序列,以及 48,000 × 10 = 480,000 音频标记。
因此,作者们不得不妥协。他们在 32 帧剪辑(每秒 25 帧,每段 1.28 秒,而不是 10 秒)上进行了训练,并将视频切割成 2 × 8 × 8 个块(即 2 帧 × 8 × 8 像素),从而得到每个包含 128 个 RGB 像素的 12,544 个视频标记(加上位置编码)。他们还将音频切割成每段 128 帧的剪辑,从而得到 480 个音频标记。他们还尝试将音频转换为梅尔频谱图(这产生了 4,800 个音频标记)。使用频谱图而不是原始音频是音频处理中的标准做法,但这几乎没有对模型性能产生影响,这表明 Perceiver 能够在没有任何帮助的情况下从原始数据中提取有用的特征。
然后他们简单地连接了视频和音频标记序列(在位置编码之后),还连接了一个模态嵌入,以帮助模型区分模态。
Perceiver 架构的一个局限性是它仅设计用于多模态分类。话虽如此,我们不是简单地平均潜在标记并将它们输入到分类头中,而是可以尝试将它们用于其他下游任务。当然,DeepMind 的研究人员想到了这一点,仅仅几个月后,他们就发布了 Perceiver IO 架构。
Perceiver IO:Perceiver 的灵活输出机制
DeepMind 于 2021 年 7 月发布了Perceiver IO。它能够执行与 Perceiver 类似的分类任务,但比 BERT 更好地执行许多其他任务,如掩码语言建模(MLM)、光流(即预测每个像素在下一个视频帧中的移动位置),实际上超越了现有技术,甚至可以玩星际争霸 II。
模型在输出潜在标记之前与 Perceiver 相同,但池化层和分类头被一个非常灵活的输出机制所取代(见图 16-16):
因此,Google 和 DeepMind 的研究人员在 2022 年 2 月发布了Perceiver AR 架构来解决这一限制(AR 代表自回归)。该模型的工作方式与感知器非常相似,除了输入序列的最后几个标记被用作潜在标记,模型对这些潜在标记是因果的,并且使用下一个标记预测进行训练。Perceiver AR 并没有像 Perceiver 和 Perceiver IO 那样产生同样大的影响,但它由于其线性扩展能力,在非常长的输入序列上取得了出色的结果。
但 DeepMind 的研究人员并没有停止多模态机器学习;他们很快又发布了一个基于感知器的另一个令人惊叹的多模态模型:Flamingo。
Flamingo:开放式视觉对话
DeepMind 于 2022 年 4 月发布的Flamingo 论文,介绍了一种视觉语言模型(VLM),它可以接受任意序列的文本和图像作为输入,并生成连贯的自由形式文本。最重要的是,它在各种任务上的少样本性能都非常出色。
例如,假设你想构建一个模型,该模型接受一张图片并输出关于该图片的诗篇:无需训练新的模型;你只需向 Flamingo 提供几个示例,并在最后添加新的图像,它就会愉快地生成关于这张新图像的诗篇。如果你想让它检测汽车照片上的车牌号码,只需提供几张带有相应车牌号码(作为文本)的照片,然后添加一张新的汽车照片,Flamingo 就会输出车牌号码。你同样可以轻松地使用 Flamingo 进行图像标题生成。或者视觉问答。或者你可以要求它比较两张图像。实际上,你甚至可以给模型提供视频的几个帧,并要求它描述动作。这是一个功能极其强大且无需微调的模型。
让我们看看 Flamingo 的架构(见图 16-17):
-
Flamingo 不是从头开始,而是基于两个大型预训练模型,这两个模型都是冻结的:一个是视觉模型,另一个是仅解码的语言模型。作者分别使用了 Chinchilla 和 CLIP,但许多其他强大的模型也能很好地工作。
-
每个输入图像都会被输入到视觉模型中,其输出经过一个名为 Resampler 的感知器模型,该模型生成一系列潜在标记表示。这确保了每个图像都能以相当短的潜在表示序列(通常比视觉模型的输出短得多)来表示。这解决了二次注意力问题。
-
Resampler 输出的序列被用作许多 gated xattn-dense 模块的键/值,这些模块被插入到冻结的 LLM 中的每个块之前:
-
每个门控 xattn-dense 模块由一个掩码多头注意力层和一个前馈模块组成,每个都有跳跃连接,就像标准 Transformer 解码器层的前馈交叉注意力部分一样。
-
然而,掩码 MHA 层和前馈模块后面都跟着一个tanh 门控器。这些门控器将它们的输入乘以 tanh(α),其中α是一个可学习的标量参数,初始化为 0(每个门控器一个)。由于 tanh(0) = 0,训练开始时所有门控器都是关闭的,因此输入只能通过跳跃连接流动,门控的 xattn-dense 模块对 LLM 没有影响。但随着训练的进行,模型逐渐学会打开门控器,允许门控模块影响 LLM 的输出。
-
在门控 xattn-dense 模块中,每个文本标记只能关注位于其之前的最近图像中的视觉标记;来自所有其他图像的视觉标记都被掩码了。例如,最后一个文本标记(“是”)只能关注中国塔的照片,它不能直接关注花朵照片。然而,由于前面的文本标记有关于花朵照片的信息,最后一个标记确实通过冻结的 LLM 的自注意力层间接访问了花朵照片。
-
-
文本被标记化成 LLM 期望的形式(例如,Chinchilla 期望序列开始和结束标记,我标记为
和
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-skl-pt/img/hmls_1617.png
图 16-17. Flamingo 可以接受任何文本和图像的序列,并输出连贯的自由形式文本
坏消息是 DeepMind 没有将 Flamingo 公开。好消息是开源复制品和变体是可用的:
-
OpenFlamingo,由 MLFoundations 团队创建,该团队是非营利组织 LAION 的一部分。它是完全开源的,可在 Hugging Face Hub 上获得(例如,openflamingo/OpenFlamingo-9B-vitl-mpt7b,基于 CLIP ViT-L/14 视觉编码器和 MPT-7B LLM)。
-
Hugging Face 的IDEFICS,在名为 OBELICS 的巨大数据集上训练,该数据集由来自 Common Crawl 的 1.41 亿个交错文本-图像文档组成(包括 3.5 亿张图像和 1150 亿个文本标记)。IDEFICS 和 OBELICS 都可在平台上找到(例如,Idefics3-8B-Llama3 和 HuggingFaceM4 的 OBELICS)。该架构在 Flamingo 的基础上进行了一些改进;例如,你可以更容易地替换不同的 LLM 或视觉编码器。IDEFICS 本身是开源的,但基于它的模型可能存在许可限制。特别是,IDEFICS 1 和 3 基于 Llama,它在商业使用上存在一些限制,而 IDEFICS 2 基于 Mistral,它是完全开源的。
-
Nvidia 的AudioFlamingo,与 Flamingo 非常相似,但处理的是音频而不是图像。
-
其他变体也很有用,例如针对特定领域的模型,如Med-Flamingo,这是一个在医疗文档上训练的 OpenFlamingo 模型。
我们将要讨论的最后一种多模态架构是由 Salesforce 提出的语言-图像预训练,或称 BLIP。它的第二个版本,BLIP-2,也成功地重用了两个大型预训练模型——一个视觉模型和一个 LLM——来创建一个能够同时处理图像和文本的 VLM,并生成自由形式的文本。让我们看看它是如何做到的。
BLIP 和 BLIP-2
Salesforce 在 2022 年 1 月发布的原始BLIP 模型是一个优秀的视觉-语言模型。其架构是一个由纯文本编码器、纯视觉编码器、基于图像的文本编码器和基于图像的文本解码器组成的编码器-解码器混合体(MED),它们共享许多层。这种灵活的架构使得模型能够同时针对三个不同的目标进行训练:图像-文本匹配(ITM),一个图像-文本对比损失(ITC)来对齐图像和文本表示(类似于 CLIP),以及语言建模(LM),其中模型必须尝试使用下一个标记预测来生成标题。
BLIP 成功的一个重要原因是它在一个非常大且干净的数据集上进行了预训练。为了构建这个数据集,作者同时训练了一个标题生成模块来为图像生成合成标题,以及一个过滤模块来移除噪声数据。这种方法被称为CapFilt,它从原始的网页抓取数据集中移除了低质量的标题,并添加了许多新的高质量合成标题。在这个自举阶段之后,作者在刚刚构建的大且干净的数据集上训练了最终的模型。这是一个两阶段的过程,因此得名 BLIP:自举语言-图像预训练。
一年后,2023 年 1 月,Salesforce 发布了BLIP-2,^(37),它基于相同的核心思想,但通过重用两个大型预训练模型(一个视觉模型和一个语言模型,两者都冻结)大大提高了模型性能。BLIP-2 甚至以更小的模型超越了 Flamingo。
训练分为两个阶段。BLIP-2 在第一阶段的结构如图 16-18 所示。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-skl-pt/img/hmls_1618.png
图 16-18. BLIP-2 预训练,第一阶段:训练 Q-Former
-
核心组件被称为Q-Former(查询转换器)。其架构与 BERT-base 相同,实际上它甚至使用 BERT-base 的预训练权重进行初始化,但它也有一些额外的交叉注意力层,使其能够关注由预训练视觉编码器产生的视觉标记。交叉注意力层插入到每个编码器层的每隔一层,在自注意力层和前馈模块之间,并且它们是随机初始化的。
-
Q-Former 处理三个序列:一个文本标记序列(使用 BERT 标记化和标记嵌入),一个由预训练视觉编码器产生的视觉标记序列,最后是一个可训练的 Perceiver 风格的潜在标记序列。在 BLIP-2 中,这些潜在标记被称为查询标记,因为它们的输出表示将后来用于查询预训练的 LLM。
-
Q-Former 使用与 BLIP 相同的三个目标进行训练:ITM、ITC 和 LM。对于每个目标,使用不同的掩码:
-
对于 ITM,查询标记和文本标记可以相互关注。换句话说,查询标记的输出表示代表基于文本的视觉特征,而文本标记的输出表示代表基于图像的文本特征。查询标记的输出通过一个线性层,每个查询标记产生两个 logits(图像-文本匹配或不匹配),然后模型计算所有查询标记的平均 logits,然后计算二元交叉熵。
-
对于 ITC,查询标记和文本标记不能相互关注。换句话说,Q-Former 的输出代表仅视觉特征和仅文本特征。对于批次中每个可能的图像/标题对,模型计算查询标记输出和类别标记输出之间的最大相似度。我们得到一个最大相似度的矩阵,损失函数推动主对角线上的值趋向于+1,推动其他值趋向于 0,类似于 CLIP。
-
对于 LM,文本标记只能关注前面的标记(即,我们使用因果掩码),但它们可以关注所有查询标记。然而,查询标记不能关注任何文本标记。换句话说,查询标记输出代表仅视觉特征,而文本标记输出代表基于图像的因果文本特征。该模型使用下一个标记预测进行训练:每个文本标记的输出通过一个分类头,该头必须预测标题中的下一个标记。
-
你可能会惊讶,Q-Former 既用于编码文本(用于 ITM 和 ITC),也用于生成文本(用于 LM)。由于 Q-Former 是使用预训练的 BERT-base 模型的权重初始化的,因此它从一开始就非常擅长文本编码,但它最初并不知道它必须预测 LM 任务中的下一个标记。幸运的是,它可以相当快地学习,因为它不是从零开始;它有很好的 BERT 特征可以工作。然而,我们需要告诉它我们想要它编码文本还是预测下一个标记。为此,我们在 LM 期间用解码标记替换了类别标记。^(38)
一旦第一阶段完成,Q-Former 已经是一个强大的模型,可以将图像和文本编码到同一个空间,因此一只黑猩猩的照片会产生与标题“一只黑猩猩的照片”非常相似的结果表示。但它甚至更好:查询标记输出被训练为对下一个标记预测最有帮助。
小贴士
为了产生 ITM 的负例,一种策略是从同一个批次中随机选择一个标题,排除图像的真实标题。然而,这使得任务过于简单,因此模型学不到很多东西。相反,作者使用了一种硬负例挖掘策略,其中困难的标题更有可能被采样。例如,给定一只黑猩猩的照片,标题“一只大猩猩”比“一艘宇宙飞船”更有可能被采样。为了找到困难的标题,算法使用 ITC 任务的相似度分数。
因此,现在是训练的第二阶段(见图 16-19):
-
我们保留了视觉转换器和 Q-Former,但丢弃了其余部分,并在 Q-Former 之上添加了一个新的随机初始化的线性层。
-
对于每个图像/标题对,Q-Former 关注由预训练的视觉编码器产生的视觉特征,输出通过线性层产生一系列视觉查询标记。
-
将视觉查询标记和文本标记表示连接起来,然后输入到(冻结的)预训练的 LLM 中。我们训练 BLIP-2 来预测下一个标题标记。
在第二阶段,模型学习将视觉查询标记正确映射到 LLM 的输入空间。一旦训练完成,该模型就可以像第二阶段一样使用,生成视觉基础文本。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-skl-pt/img/hmls_1619.png
图 16-19。BLIP-2 预训练,阶段 2:训练线性层将查询标记映射到 LLM 的输入空间
让我们使用 BLIP-2 为一张图片生成标题:
from transformers import Blip2Processor, Blip2ForConditionalGeneration
model_id = "Salesforce/blip2-opt-2.7b"
blip2_processor = Blip2Processor.from_pretrained(model_id)
blip2_model = Blip2ForConditionalGeneration.from_pretrained(
model_id, device_map=device, dtype=torch.float16)
image_url = "http://images.cocodataset.org/val2017/000000039769.jpg" # two cats
image = Image.open(urllib.request.urlopen(image_url))
inputs = blip2_processor(images=image, return_tensors="pt")
inputs = inputs.to(device, dtype=torch.float16)
with torch.no_grad():
generated_ids = blip2_model.generate(**inputs)
generated_text = blip2_processor.batch_decode(generated_ids)
BLIP-2 看到了什么?
>>> generated_text
['<image><image><image><image>[...]<image></s>two cats laying on a couch\n']
这是对照片的良好描述,但没有特殊标记会更好,所以让我们在解码模型输出时去掉它们:
>>> generated_text = blip2_processor.batch_decode(generated_ids,
... skip_special_tokens=True)
...
>>> generated_text
>>>
['two cats laying on a couch\n']
完美!
小贴士
还可以查看 InstructBLIP,这是一个具有视觉语言指令调整的 BLIP-2 模型。
其他多模态模型
我们已经介绍了很多多模态模型,它们具有非常不同的架构和预训练技术,但当然还有很多其他模型。以下是一些最引人注目的模型的快速概述:
LayoutLM (Microsoft, Dec. 2019)
基于文本、视觉和文档布局的文档理解。第 3 版于 2022 年 4 月发布。
GLIP (Microsoft, Dec. 2021)
用于视觉定位和目标检测的视觉语言模型。GLIP-2 于 2022 年发布。
Stable Diffusion (Stability AI, Dec. 2021)
一个强大的文本到图像模型。
OFA (Microsoft, Feb. 2022)
统一(适用于所有)视觉语言预训练框架,处理各种视觉语言任务。
CoCa (Google, May 2022)
使用对比和标题目标预训练的视觉语言模型。CoCa 影响了后来的模型,如 PaLI-X 和 Flamingo-2。
PaLI (Google, Sep. 2022)
用于视觉语言任务(如 VQA 和标题)的多语言多模态模型,具有强大的零样本性能。下一版本 PaLI-X 和 PaLI-3 于 2023 年发布,PaliGemma 于 2024 年 5 月发布。
Kosmos-1 (Microsoft, Feb. 2023)
一个具有强大视觉定位支持的视觉语言模型。Kosmos-2 和 Kosmos-2.5 于 2023 年发布。
PaLM-E (Google, Mar. 2023)
PaLM-E 通过视觉输入和具身传感器数据扩展了 Google 的 PaLM 系列。一个仅解码器的大型语言模型生成文本命令,如“拿起锤子”,这些命令通过下游系统被解释并由机器人执行。
LLaVA (H. Liu et al., Apr. 2023)
在最好的开源视觉语言聊天模型中。
ImageBind (Meta, May 2023)
一种扩展到六个模态(图像、文本、音频、IMU、深度和热成像)的 CLIP 风格模型。
RT-2 (DeepMind, Jul. 2023)
一个能够进行机器人控制的视觉语言模型,在大型指令遵循数据集上训练。
SeamlessM4T (Meta, Aug. 2023)
一个可以执行语音转文本、语音转语音、文本转语音和文本转文本翻译的单一模型,支持近 100 种语言。
Qwen-VL (Alibaba, Sep. 2023)
开放视觉语言家族(7B 到 72B),成为最强的开源多模态基线之一。导致了 Qwen2-VL(2024 年 8 月)和 Qwen3-Omni(2025 年 9 月),扩展到视频和音频并达到万亿参数规模。
Fuyu(Adept AI,2023 年 10 月)
使用统一的 Transformer 实时处理交织的图像和文本。
EMO(阿里巴巴,2024 年 2 月)
捕捉一个人的图像,加上某人说话或唱歌的音频记录,模型生成匹配音频的该人的视频。EMO-2 于 2025 年 1 月发布。
GLaMM(H. Rasheed 等人,2024 年 6 月)
一种视觉对话模型,生成包含对象分割掩码的文本响应。
LaViDa(加州大学洛杉矶分校,松下,Adobe,Salesforce,2025 年 5 月)
一系列基于扩散的开源视觉-语言模型。
小贴士
我为这一章中讨论的所有模型创建了 homl.info 短链接;只需使用不带连字符的小写名称,例如,https://homl.info/qwen2vl。
还有几个商业多模态模型,其详细架构尚未公开,例如 OpenAI 的 GPT-4.1 和 Sora,Google 的 Gemini 2.5 Pro,DeepMind 的 Veo-3,以及 Anthropic 的 Claude 4 Opus。要访问这些模型,你首先需要创建一个账户并获取订阅(或使用免费层),然后你可以使用提供的应用程序(例如,Google AI Studio,https://aistudio.google.com),或者通过 API 查询模型。以下是一个简短的代码示例,展示如何通过 API 查询 Gemini 2.5 Pro。你首先需要在 Google AI Studio 中获取一个 API 密钥,然后你可以使用你喜欢的任何秘密管理方法来存储它并在你的代码中加载它(例如,如果你正在使用 Colab,我建议你使用 Colab 的秘密管理器,正如我们在第十五章中看到的)。
from google import genai
gemini_api_key = [...] # load from Colab secrets, or from a file, or hardcode
gemini_client = genai.Client(api_key=gemini_api_key)
cats_photo = gemini_client.files.upload(file="my_cats_photo.jpg")
question = "What animal and how many? Format: [animal, number]"
response = gemini_client.models.generate_content(
model="gemini-2.5-flash", # or "gemini-2.5-pro"
contents=[cats_photo, question])
print(response.text) # prints: "[cat, 2]"
这段代码使用了已安装在 Colab 上的google-genai库。它还假设在笔记本所在的目录中存在一个名为my_cats_photo.jpg的文件。
这就结束了这一章;希望你喜欢。Transformer 现在可以看、听、触摸等等!在下一章中,我们将探讨一些旨在加快和扩展 Transformer 的相当高级的技术。正如 Daft Punk 所说:更难、更好、更快、更强。
练习
-
你能描述一下原始 ViT 的架构吗?为什么这很重要?
-
常规 ViT(即非分层)最适合哪些任务?它们的局限性是什么?
-
DeiT 的主要创新是什么?这个想法是否可以推广到其他架构?
-
有哪些分层 ViT 的例子?它们适合哪些任务?
-
如何通过 PVTs 和 Swin Transformers 降低处理高分辨率图像的计算成本?
-
DINO 是如何工作的?DINOv2 中有什么变化?你会在什么情况下想使用 DINOv2?
-
JEPA 架构的目标是什么?它是如何工作的?
-
什么是多模态模型?你能给出五个多模态任务的例子吗?
-
解释在多模态学习中的融合和对齐问题是什么。为什么 Transformer 非常适合解决这些问题?
-
你能为一行总结 VideoBERT、ViLBERT、CLIP、DALL·E、Perceiver IO、Flamingo 和 BLIP-2 的主要思想吗?
-
如果您正在使用 Perceiver IO 模型,并且将输入和输出的长度加倍,大约需要多少额外的计算?
-
尝试在Food 101 数据集(
torchvision.datasets.Food101)上微调预训练的 ViT 模型。您能达到多少准确率?使用 CLIP 模型,零样本呢? -
为您自己的照片创建一个简单的搜索引擎:首先,编写一个函数,使用 CLIP 模型嵌入所有照片并保存结果向量。接下来,编写一个函数,它接受一个搜索查询(文本或图像),使用 CLIP 进行嵌入,然后找到最相似的图像嵌入并显示相应的照片。您可以手动实现相似性搜索算法,或者使用专门的库,如FAISS 库,甚至是一个完整的向量数据库。
-
使用 BLIP-2 自动为您的所有照片添加标题。
这些练习的解决方案可在本章笔记本的末尾找到,在https://homl.info/colab-p。
^(1) 凯文·许等,“展示、关注和讲述:具有视觉注意力的神经图像标题生成”,第 32 届国际机器学习会议论文集(2015 年):2048–2057。
^(2) 这部分内容来自论文中的图 3。它是在作者们的友好授权下复制的。
^(3) 马可·图利奥·里贝罗等,“‘我为什么要相信你?’:解释任何分类器的预测”,第 22 届 ACM SIGKDD 国际知识发现和数据挖掘会议论文集(2016 年):1135–1144。
^(4) 尼古拉斯·卡里翁等,“使用 Transformer 进行端到端目标检测”,arXiv 预印本 arXiv:2005.12872(2020 年)。
^(5) 亚历克谢·多索夫斯基等,“一张图片等于 16x16 个单词:用于大规模图像识别的 Transformer”,arXiv 预印本 arXiv:2010.11929(2020 年)。
^(6) 雨果·图尔弗朗等,“训练数据高效图像 Transformer 与通过注意力进行蒸馏”,arXiv 预印本 arXiv:2012.12877(2020 年)。
^(7) 王文海等,“金字塔视觉 Transformer:无需卷积的密集预测的多功能骨干网络”,arXiv 预印本 arXiv:2102.12122(2021 年)。
^(8) 刘泽等,“Swin Transformer:使用平移窗口的层次视觉 Transformer”,arXiv 预印本 arXiv:2103.14030(2021 年)。
^(9) 刘泽等,“Swin Transformer V2:提升容量和分辨率”,arXiv 预印本 arXiv:2111.09883(2021 年)。
^(10) 玛蒂尔德·卡隆等,“自监督视觉 Transformer 中的新兴特性”,arXiv 预印本 arXiv:2104.14294(2021 年)。
^(11) 这是 DINO 论文图 3 的右侧部分,在作者们的友好授权下复制。
^(12) 王扬涛等人,“TokenCut: 使用自监督 Transformer 和归一化 Cut 在图像和视频中分割对象”,arXiv 预印本 arXiv:2209.00383 (2022)。
^(13) “DINOv2: 无监督学习鲁棒视觉特征”,arXiv 预印本 arXiv:2304.07193 (2023)。
^(14) 赵晓华等人,“扩展视觉 Transformer”,arXiv 预印本 arXiv:2106.04560 (2021)。
^(15) 包航波等人,“BEiT:图像 Transformer 的 BERT 预训练”,arXiv 预印本 arXiv:2106.08254 (2021)。
^(16) 何凯明等人,“掩码自编码器是可扩展的视觉学习者”,arXiv 预印本 arXiv:2111.06377 (2021)。
^(17) Mitchell Wortsman 等人,“模型汤:平均多个微调模型的权重可以提高准确率,而不会增加推理时间”,arXiv 预印本 arXiv:2203.05482 (2022)。
^(18) 方宇新等人,“EVA: 在大规模上探索掩码视觉表示学习的极限”,arXiv 预印本 arXiv:2211.07636 (2022)。
^(19) “使用联合嵌入预测架构从图像中进行自监督学习”,arXiv 预印本 arXiv:2301.08243 (2023)。
^(20) Yann LeCun,“通往自主机器智能之路”(2022)。
^(21) 陈孙等人,“VideoBERT:视频和语言表示学习的联合模型”,arXiv 预印本 arXiv:1904.01766 (2019)。
^(22) 周磊,周勇等人,“使用掩码 Transformer 进行端到端密集视频字幕生成”,IEEE 计算机视觉与模式识别会议论文集 (2018)。
^(23) 陆嘉森等人,“ViLBERT: 针对视觉和语言任务的预训练任务无关的视语言表示”,神经信息处理系统进展 32 (2019)。
^(24) 曹继泽等人后来在他们的论文“幕后:揭示预训练视觉和语言模型之谜”中提供了一些支持这一观点的经验证据:特别是,他们发现更多的注意力头集中在文本模态上,而不是视觉模态上。
^(25) Alec Radford 等人,“从自然语言监督中学习可迁移的视觉模型”,arXiv 预印本 arXiv:2103.00020 (2021)。
^(26) 训练代码和数据并未由 OpenAI 发布,但 Gabriel Ilharco 等人创建了OpenCLIP,这是一个具有完整训练代码和数据的灵活开源 CLIP 复制品。
^(27) 这种对比损失最初是在 2016 年由 Kihyuk Sohn 的 一篇论文 中作为 多类 n 对损失 介绍,然后用于对比表示学习,并在 2018 年由 Aaron van den Oord 等人发表的 一篇论文 中更名为 InfoNCE(信息噪声对比估计)。
^(28) 阿迪亚·拉梅什等人,“零样本文本到图像生成”,arXiv 预印本 arXiv:2102.12092(2021)。
^(29) 阿迪亚·拉梅什等人,“使用 CLIP 潜在的分层文本条件图像生成”,arXiv 预印本 arXiv:2204.06125(2022)。
^(30) 安德鲁·耶格尔等人,“Perceiver:具有迭代注意力的通用感知”,arXiv 预印本 arXiv:2103.03206(2021)。
^(31) 如果 Δ 是样本之间的间距,那么奈奎斯特-香农采样定理告诉我们,我们可以测量的最大频率是 f = 1 / 2Δ。这就是为什么 f 停在 μ / 2 而不是 μ:以更高的分辨率采样不会增加任何信息,还可能引入混叠伪影。
^(32) AudioSet 包含超过 200 万个 10 秒的视频片段,分为超过 500 个类别。
^(33) ModelNet40 是一个包含各种形状的 3D 点云合成数据集,例如飞机或汽车。现实生活中点云的常见来源是激光雷达传感器。
^(34) 安德鲁·耶格尔等人,“Perceiver IO:一种用于结构化输入和输出的通用架构”,arXiv 预印本 arXiv:2107.14795(2021)。
^(35) 在法国漫画系列《阿斯特 rix》中,奥贝利克斯是一个高大友善的高卢人,而伊德菲克斯是他的聪明小狗。
^(36) 李俊南等人,“BLIP:用于统一视觉语言理解和生成的自举语言-图像预训练”,arXiv 预印本 arXiv:2201.12086(2022)。
^(37) 李俊南等人,“BLIP-2:使用冻结图像编码器和大型语言模型的自举语言-图像预训练”,arXiv 预印本 arXiv:2301.12597(2023)。
^(38) 能够同时编码和生成文本的单个模型的想法是在 2019 年由微软研究人员李东等人提出的,他们推出了 UniLM 模型。
^(39) 大多数现代智能手机都包含一个惯性测量单元(IMU)传感器:它测量加速度、角速度,以及通常的磁场强度。
第十七章. 加速变压器
在第十五章和第十六章中,我们构建了各种变压器,从分类器、翻译器和聊天机器人,到视觉和多模态变压器。虽然变压器非常灵活且强大,但它们远非完美。特别是,它们在处理长输入序列时可能会非常慢。
幸运的是,已经开发出许多技术来加速任何规模的变压器:
-
为了加速生成型变压器的解码,我们将使用键/值缓存和推测性解码,然后我们将简要介绍几种并行化文本生成的方案。
-
为了加速多头注意力(MHA),这是变压器中最计算密集的组件之一,我们将探讨稀疏注意力、近似注意力、共享投影和 FlashAttention。
-
为了加速具有高达万亿参数的巨型变压器,我们将讨论专家混合(MoE)。
-
为了高效训练大型变压器,我们将讨论使用如低秩适应(LoRA)等适配器的参数高效微调(PEFT),激活检查点、序列打包、梯度累积和并行性。
小贴士
加速变压器的另一种方法是使其变得更小。这可以通过使用降低精度和量化来实现,这些内容在附录 B 中有讨论。
需要介绍的技术非常多,而且相当高级,所以如果你是初学者,现在可以安全地跳过这一章,并在需要时再回来。这也是为什么这一章仅在线上提供,可在https://homl.info找到,为其他章节留出空间。
第十八章。自动编码器、GANs 和扩散模型
自动编码器是能够学习输入数据的密集表示的人工神经网络,这种表示被称为潜在表示或编码,无需任何监督(即,训练集是无标签的)。这些编码通常比输入数据具有更低的维度,这使得自动编码器在降维(见第七章)方面非常有用,特别是用于可视化目的。自动编码器还充当特征检测器,并且可以用于深度神经网络的非监督预训练(正如我们在第十一章中讨论的那样)。它们也常用于异常检测,我们将会看到。最后,一些自动编码器是生成模型:它们能够随机生成与训练数据非常相似的新数据。例如,您可以在面部图片上训练一个自动编码器,然后它就能够生成新的面部。
生成对抗网络(GANs)也是一种能够生成数据的神经网络。实际上,它们可以生成逼真的面部图片,以至于很难相信它们所代表的人不存在。您可以通过访问https://thispersondoesnotexist.com,一个展示由名为StyleGAN的 GAN 架构生成的面部网站的链接,来判断这一点。GANs 已被广泛应用于超分辨率(提高图像分辨率)、着色、强大的图像编辑(例如,用逼真的背景替换照片中的捣乱者)、将简单的素描变成逼真的图像、预测视频中的下一帧、增强数据集(以训练其他模型)、生成其他类型的数据(如文本、音频和时间序列)、识别其他模型的弱点以增强它们,以及更多。
然而,自 2020 年代初以来,GANs 在很大程度上已被扩散模型所取代,这些模型可以生成比 GANs 更丰富、质量更高的图像,同时训练起来也容易得多。然而,扩散模型的运行速度要慢得多,因此当您需要非常快速的生成时,GANs 仍然很有用。
自动编码器、GANs 和扩散模型都是无监督的,学习潜在表示,可以用作生成模型,并且有许多类似的应用。然而,它们的工作方式非常不同:
自动编码器
自编码器简单地学习将它们的输入复制到输出。这听起来可能像是一项微不足道的任务,但正如你将看到的,以各种方式约束网络可以使任务变得任意困难。例如,你可以限制潜在表示的大小,或者你可以在输入中添加噪声并训练网络恢复原始输入。这些约束阻止了自编码器直接将输入复制到输出,这迫使它学习高效的数据表示方法。简而言之,编码是自编码器在某种约束下学习恒等函数的副产品。
生成对抗网络(GANs)
生成对抗网络(GANs)由两个神经网络组成:一个生成器,它试图生成看起来与训练数据相似的数据,以及一个判别器,它试图区分真实数据和假数据。这种架构在深度学习中非常独特,因为在训练过程中生成器和判别器相互竞争;这被称为对抗训练。生成器通常被比作一个试图制作逼真假币的罪犯,而判别器则像是一个试图区分真币和假币的警察调查员。
扩散模型
扩散模型被训练以逐渐从图像中去除噪声。如果你然后取一个完全充满随机噪声的图像,并反复在该图像上运行扩散模型,一个高质量的图像将逐渐出现,类似于训练图像(但并不相同)。
在本章中,我们将首先深入探讨自编码器的工作原理以及如何使用它们进行降维、特征提取、无监督预训练或作为生成模型。这自然会引导我们进入生成对抗网络(GANs)。我们将构建一个简单的 GAN 来生成假图像,但我们会看到训练通常相当困难。我们将讨论你在对抗训练中会遇到的主要困难,以及一些解决这些困难的主要技术。最后,我们将构建和训练一个扩散模型——具体来说是一个去噪扩散概率模型(DDPM)——并使用它来生成图像。让我们从自编码器开始吧!
高效的数据表示
你觉得以下哪个数字序列最容易记住?
-
40, 27, 25, 36, 81, 57, 10, 73, 19, 68
-
50, 48, 46, 44, 42, 40, 38, 36, 34, 32, 30, 28, 26, 24, 22, 20, 18, 16, 14
乍一看,似乎第一个序列应该更容易,因为它要短得多。然而,如果你仔细观察第二个序列,你会注意到它只是从 50 到 14 的偶数列表。一旦你注意到这个模式,第二个序列就比第一个序列更容易记住,因为你只需要记住模式(即递减的偶数)以及起始和结束数字(即 50 和 14)。请注意,如果你能够快速轻松地记住非常长的序列,你就不太会在意第二个序列中是否存在模式。你只需逐个记住每个数字,然后就可以了。难以记住长序列的事实使得识别模式变得有用,希望这能阐明为什么在训练过程中对自动编码器进行约束会推动它发现并利用数据中的模式。
记忆、感知和模式匹配之间的关系在 20 世纪 70 年代初由威廉·蔡斯和赫伯特·西蒙^(1)进行了著名的研究。他们观察到,专家棋手只需看五秒钟棋盘,就能记住游戏中所有棋子的位置,这对于大多数人来说是不可能的任务。然而,这只在棋子放置在现实位置(来自实际比赛)时才成立,而不是当棋子随机放置时。象棋专家的记忆并不比我们好多少;他们只是更容易看到棋局模式,这得益于他们对游戏的经验。注意模式有助于他们有效地存储信息。
就像这个记忆实验中的棋手一样,自动编码器查看输入,将它们转换为有效的潜在表示,然后能够重建出(希望)非常接近输入的东西。自动编码器始终由两部分组成:一个编码器(或识别网络),它将输入转换为潜在表示,然后是一个解码器(或生成网络),它将内部表示转换为输出。
在图 18-1 所示的示例中,自动编码器是一个常规的多层感知器(MLP;参见第九章)。由于它必须重建其输入,输出层的神经元数量必须等于输入的数量(即在这个例子中是三个)。网络的下半部分是编码器(在这种情况下它是一个包含两个神经元的单层),上半部分是解码器。输出通常被称为重建,因为自动编码器试图重建输入。成本函数总是包含一个重建损失,当重建与输入不同时,会对模型进行惩罚。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-skl-pt/img/hmls_1801.png
图 18-1。棋盘记忆实验(左)和简单的自动编码器(右)
由于内部表示的维度低于输入数据(在这个例子中,它是 2D 而不是 3D),因此自动编码器被称为欠完整。欠完整自动编码器不能简单地复制其输入到编码中,但它必须找到一种方法来输出其输入的副本。它被迫压缩数据,从而学习输入数据中最重要的特征(并丢弃不重要的特征)。
让我们看看如何实现一个非常简单的欠完整自动编码器来进行降维。
使用不完整线性自动编码器执行 PCA
如果自动编码器仅使用线性激活函数,并且损失函数是均方误差(MSE),则它最终执行主成分分析(PCA;见第七章)。
以下代码构建了一个简单的线性自动编码器,它接受 3D 输入,将其投影到 2D,然后再将其投影回 3D。由于我们将使用等于输入的目标来训练模型,梯度下降必须找到最接近训练数据的 2D 平面,就像 PCA 一样。
import torch
import torch.nn as nn
torch.manual_seed(42)
encoder = nn.Linear(3, 2)
decoder = nn.Linear(2, 3)
autoencoder = nn.Sequential(encoder, decoder).to(device)
这段代码与我们过去章节中构建的所有 MLP(多层感知器)并没有太大区别,但有一些需要注意的地方:
-
我们将自动编码器组织成两个子组件:编码器和解码器,在这个例子中每个都由一个
Linear层组成,自动编码器是一个包含编码器然后是解码器的Sequential模型。 -
自动编码器的输出数量等于输入数量(即,3)。
-
要执行 PCA,我们不使用任何激活函数(即,所有神经元都是线性的),并且损失函数是 MSE。这是因为 PCA 是一种线性变换。我们很快就会看到更复杂和非线性的自动编码器。
现在,让我们在第七章中使用的相同简单生成的 3D 数据集上训练模型,并使用它来编码该数据集(即,将其投影到 2D):
from torch.utils.data import DataLoader, TensorDataset
X_train = [...] # generate a 3D dataset, like in Chapter 7
train_set = TensorDataset(X_train, X_train) # the inputs are also the targets
train_loader = DataLoader(train_set, batch_size=32, shuffle=True)
注意X_train既用作输入也用作目标。接下来,让我们使用与第十章中相同的train()函数来训练自动编码器(笔记本使用了一个稍微复杂一些的函数,它在每个 epoch 打印一些信息并评估模型):
import torchmetrics
optimizer = torch.optim.NAdam(autoencoder.parameters(), lr=0.2)
mse = nn.MSELoss()
rmse = torchmetrics.MeanSquaredError(squared=False).to(device)
train(autoencoder, optimizer, mse, train_loader, n_epochs=20)
现在自动编码器已经训练好了,我们可以使用它的编码器将 3D 输入压缩到 2D。例如,让我们压缩整个训练集:
codings = encoder(X_train.to(device))
图 18-2 展示了原始的 3D 数据集(在左侧)和自动编码器隐藏层的输出(即编码层,在右侧)。正如您所看到的,自动编码器找到了最佳二维平面来投影数据,尽可能多地保留了数据中的方差(就像 PCA 一样)。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-skl-pt/img/hmls_1802.png
图 18-2. 由欠完备线性自动编码器执行的近似 PCA
注意
您可以将自动编码器视为执行一种形式的自监督学习,因为它基于一种带有自动生成标签的监督学习技术(在这种情况下,简单地等于输入)。
堆叠自动编码器
就像我们讨论过的其他神经网络一样,自动编码器可以有多个隐藏层。在这种情况下,它们被称为堆叠自动编码器(或深度自动编码器)。添加更多层有助于自动编码器学习更复杂的编码。但必须小心不要使自动编码器过于强大。想象一下,一个编码器如此强大,以至于它只学会了将每个输入映射到一个任意的单一数字(解码器学习反向映射)。显然,这样的自动编码器将完美地重建训练数据,但在过程中它并没有学习到任何有用的数据表示,并且不太可能很好地泛化到新的实例。
堆叠自动编码器的架构通常在中心隐藏层(编码层)方面是对称的。简单来说,它看起来像一个三明治。例如,用于 Fashion MNIST(在第九章中介绍)的自动编码器可能有 784 个输入,然后是一个包含 128 个神经元的隐藏层,接着是一个 32 个神经元的中心隐藏层,然后是另一个包含 128 个神经元的隐藏层,最后是一个 784 个神经元的输出层。这个堆叠自动编码器在图 18-3 中展示。请注意,所有隐藏层都必须有一个激活函数,例如 ReLU。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-skl-pt/img/hmls_1803.png
图 18-3. 堆叠自动编码器
使用 PyTorch 实现堆叠自动编码器
您可以像实现常规深度 MLP 一样实现堆叠自动编码器。例如,这里有一个可以用于处理 Fashion MNIST 图像的自动编码器:
stacked_encoder = nn.Sequential(
nn.Flatten(),
nn.Linear(1 * 28 * 28, 128), nn.ReLU(),
nn.Linear(128, 32), nn.ReLU(),
)
stacked_decoder = nn.Sequential(
nn.Linear(32, 128), nn.ReLU(),
nn.Linear(128, 1 * 28 * 28), nn.Sigmoid(),
nn.Unflatten(dim=1, unflattened_size=(1, 28, 28))
)
stacked_ae = nn.Sequential(stacked_encoder, stacked_decoder).to(device)
让我们来看一下这段代码:
-
就像之前一样,我们将自动编码器模型分为两个子模型:编码器和解码器。
-
编码器接收 28 × 28 像素的灰度图像(即,单通道),将它们展平,使得每个图像都表示为一个大小为 784 的向量,然后通过 2 个
线性层(128 个单元,然后 32 个单元)对这些向量进行处理,每个层后面都跟着 ReLU 激活函数。对于每个输入图像,编码器输出一个大小为 32 的向量。 -
解码器接收大小为 32 的编码(由编码器输出)并通过 2 个
线性层(128 个单元,然后 784 个单元)进行处理,并将最终的向量重塑为 1 × 28 × 28 数组,这样解码器的输出形状与编码器的输入相同。注意,我们使用 sigmoid 函数而不是 ReLU 作为输出层的激活函数,以确保输出像素值在 0 到 1 之间。
我们现在可以使用 TorchVision 库加载 Fashion MNIST 数据集,并将其拆分为train_data、valid_data和test_data(就像我们在第十章中所做的那样),然后像之前的自编码器一样训练自编码器,使用输入作为目标并最小化均方误差损失。试一试,这是一个很好的练习!别忘了更改目标,使它们与输入匹配——我们正在训练一个自编码器,而不是一个分类器。^(2)如果你遇到困难,请查看本章笔记本中的实现。
可视化重建图像
一旦你训练了堆叠自编码器,你怎么知道它是否表现良好?检查自编码器是否正确训练的一种方法是比较输入和输出:差异不应太大。让我们绘制一些来自验证集的图像以及它们的重建图像:
import matplotlib.pyplot as plt
def plot_image(image):
plt.imshow(image.permute(1, 2, 0).cpu(), cmap="binary")
plt.axis("off")
def plot_reconstructions(model, images, n_images=5):
images = images[:n_images]
with torch.no_grad():
y_pred = model(images.to(device))
fig = plt.figure(figsize=(len(images) * 1.5, 3))
for idx in range(len(images)):
plt.subplot(2, len(images), 1 + idx)
plot_image(images[idx])
plt.subplot(2, len(images), 1 + len(images) + idx)
plot_image(y_pred[idx])
X_valid = torch.stack([x for x, _ in valid_data])
plot_reconstructions(stacked_ae, X_valid)
plt.show()
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-skl-pt/img/hmls_1804.png
图 18-4. 原始图像(顶部)及其重建图像(底部)
图 18-4 显示了生成的图像。重建的图像可以辨认,但损失有点太大。我们可能需要更长时间地训练模型,或者使编码器和解码器更强大,或者使编码更大。现在,让我们使用这个模型并看看我们如何使用它。
使用自编码器进行异常检测
自动编码器的一个常见用途是异常检测。确实,如果自动编码器接收到一个看起来不像它训练过的图像(该图像被称为分布外),那么重构将非常糟糕。例如,图 18-5 展示了一些 MNIST 数字及其使用我们在 Fashion MNIST 上刚刚训练的模型进行重构的结果。如你所见,这些重构与输入非常不同。如果你计算重构损失(即输入和输出之间的均方误差),它将非常高。要使用该模型进行异常检测,你只需定义一个阈值,然后任何重构损失大于该阈值的图像都可以被认为是异常。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-skl-pt/img/hmls_1805.png
图 18-5。分布外图像的重构效果不佳
就这些了!现在让我们看看自动编码器的另一个用例。
可视化 Fashion MNIST 数据集
如我们在这章前面看到的,欠完备的自动编码器可以用于降维。然而,对于大多数数据集,它们在将维度降低到两维或三维方面可能不会做得很好;它们需要足够的维度来正确地重构输入。因此,它们通常不直接用于可视化。然而,它们在处理大型数据集方面非常出色,所以一种策略是使用自动编码器将维度降低到一个合理的水平,然后使用另一个降维算法进行可视化,例如我们在第七章中讨论的那些。
让我们使用这种策略来可视化 Fashion MNIST。首先,我们将使用堆叠自动编码器的编码器将维度降低到 32,然后我们将使用 Scikit-Learn 实现的 t-SNE 算法将维度降低到 2 以进行可视化:
from sklearn.manifold import TSNE
with torch.no_grad():
X_valid_compressed = stacked_encoder(X_valid.to(device))
tsne = TSNE(init="pca", learning_rate="auto", random_state=42)
X_valid_2D = tsne.fit_transform(X_valid_compressed.cpu())
现在,我们可以绘制数据集:
plt.scatter(X_valid_2D[:, 0], X_valid_2D[:, 1], c=y_valid, s=10, cmap="tab10")
plt.show()
图 18-6 展示了生成的散点图,通过显示一些图像进行了一些美化。t-SNE 算法识别出几个与类别匹配得相当好的簇(每个类别用不同的颜色表示)。请注意,如果你使用不同的随机种子、略微不同的数据或在不同的平台上运行 t-SNE,其输出可能会有很大差异,因此你的图表可能看起来不同。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-skl-pt/img/hmls_1806.png
图 18-6。使用自动编码器,然后是 t-SNE 进行 Fashion MNIST 可视化
接下来,让我们看看如何使用自动编码器进行无监督预训练。
使用堆叠自动编码器进行无监督预训练
正如我们在第十一章中讨论的那样,如果你正在处理一个复杂的监督任务,但你没有很多标记的训练数据,一个解决方案是找到一个执行类似任务的神经网络并重用其底层。这样,你就可以使用很少的训练数据训练一个高性能模型,因为你的神经网络不需要学习所有低级特征;它只需重用现有网络学习到的特征检测器。
同样,如果你有一个大型数据集,但其中大部分是无标记的,你可以首先使用所有数据训练一个堆叠自动编码器,然后重用底层来创建用于实际任务的神经网络,并使用标记数据对其进行训练。例如,图 18-7 展示了如何使用堆叠自动编码器对分类神经网络进行无监督预训练。在训练分类器时,如果你真的没有很多标记的训练数据,你可能想要冻结预训练层(至少是底层的)。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-skl-pt/img/hmls_1807.png
图 18-7. 使用自动编码器进行无监督预训练
注意
拥有大量无标记数据和少量标记数据是很常见的。构建一个大型无标记数据集通常很便宜(例如,一个简单的脚本可以从互联网上下载数百万张图片),但标记这些图片(例如,将它们分类为可爱或不可爱)通常只能由人类可靠地完成。标记实例既耗时又昂贵,因此通常只有几千个或更少的人类标记实例。尽管如此,使用高级 AI 来标记数据集的趋势正在增长。
实现上并没有什么特别之处:只需使用所有训练数据(标记的和无标记的)训练一个自动编码器,然后重用其编码器层来创建一个新的神经网络,并在标记实例上对其进行训练(参见本章末尾的练习以获取示例)。
让我们现在看看一些用于训练堆叠自动编码器的技巧。
权重绑定
当一个自动编码器结构对称,就像我们刚刚构建的那样,一个常见的技巧是将解码器层的权重与编码器层的权重绑定。这使模型中的权重数量减半,从而加快训练速度并限制过拟合的风险。具体来说,如果自动编码器总共有N层(不包括输入层),并且W[L]表示L^(th)层的连接权重(例如,第 1 层是第一个隐藏层,第N/2 层是编码层,第N层是输出层),那么解码器层的权重可以定义为W[L] = W[N–L+1]^⊺(其中L = N / 2 + 1, …, N)。
例如,这里有一个与之前相同的自编码器,除了解码器权重绑定到编码器权重:
import torch.nn.functional as F
class TiedAutoencoder(nn.Module):
def __init__(self):
super().__init__()
self.enc1 = nn.Linear(1 * 28 * 28, 128)
self.enc2 = nn.Linear(128, 32)
self.dec1_bias = nn.Parameter(torch.zeros(128))
self.dec2_bias = nn.Parameter(torch.zeros(1 * 28 * 28))
def encode(self, X):
Z = X.view(-1, 1 * 28 * 28) # flatten
Z = F.relu(self.enc1(Z))
return F.relu(self.enc2(Z))
def decode(self, X):
Z = F.relu(F.linear(X, self.enc2.weight.t(), self.dec1_bias))
Z = F.sigmoid(F.linear(Z, self.enc1.weight.t(), self.dec2_bias))
return Z.view(-1, 1, 28, 28) # unflatten
def forward(self, X):
return self.decode(self.encode(X))
这个模型实现了比之前模型更小的重建误差,使用大约一半的参数数量。
逐个训练一个自编码器
与我们刚才一次性训练整个堆叠自编码器不同,可以一次训练一个浅层自编码器,然后将所有这些堆叠成一个单一的自编码器堆叠(因此得名),如图图 18-8 所示。这种技术被称为贪婪层状训练。
在训练的第一个阶段,第一个自编码器学会重建输入。然后我们使用这个第一个自编码器对整个训练集进行编码,这给我们提供了一个新的(压缩的)训练集。然后我们在这个新数据集上训练第二个自编码器。这是训练的第二阶段。最后,我们使用所有这些自编码器构建一个大的三明治,如图图 18-8 所示(即我们首先堆叠每个自编码器的编码器层,然后以相反的顺序堆叠解码器层)。这给我们带来了最终的堆叠自编码器。我们可以轻松地以这种方式训练更多的自编码器,构建一个非常深的堆叠自编码器。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-skl-pt/img/hmls_1808.png
图 18-8. 逐个训练一个自编码器
正如我在第十一章中提到的,深度学习海啸的触发因素之一是 2006 年杰弗里·辛顿及其同事发现,深度神经网络可以使用这种贪婪层状方法进行无监督预训练。他们为此目的使用了受限玻尔兹曼机(RBMs;见https://homl.info/extra-anns),但在 2007 年约书亚·本吉奥^(3)等人展示了自编码器同样有效。在接下来的几年里,这是训练深度网络的唯一有效方法,直到第十一章中介绍的技术使得一次性训练深度网络成为可能。
自编码器不仅限于密集网络:你还可以构建卷积自编码器。现在让我们来看看这些。
卷积自编码器
如果你正在处理图像,那么我们之前看到的自动编码器可能不会很好地工作(除非图像非常小)。正如你在第十二章中看到的,卷积神经网络比密集网络更适合处理图像。因此,如果你想为图像构建一个自动编码器(例如,用于无监督预训练或降维),你需要构建一个卷积自动编码器。^(4) 编码器是一个由卷积层和池化层组成的常规 CNN。它通常减少输入的空间维度(即高度和宽度),同时增加深度(即特征图的数量)。解码器必须执行相反的操作(放大图像并减少其深度以回到原始维度),为此你可以使用转置卷积层(或者,你也可以将上采样层与卷积层结合使用)。以下是一个用于 Fashion MNIST 的基本卷积自动编码器:
conv_encoder = nn.Sequential(
nn.Conv2d(1, 16, kernel_size=3, padding="same"), nn.ReLU(),
nn.MaxPool2d(kernel_size=2), # output: 16 × 14 × 14
nn.Conv2d(16, 32, kernel_size=3, padding="same"), nn.ReLU(),
nn.MaxPool2d(kernel_size=2), # output: 32 × 7 × 7
nn.Conv2d(32, 64, kernel_size=3, padding="same"), nn.ReLU(),
nn.MaxPool2d(kernel_size=2), # output: 64 × 3 × 3
nn.Conv2d(64, 32, kernel_size=3, padding="same"), nn.ReLU(),
nn.AdaptiveAvgPool2d((1, 1)), nn.Flatten()) # output: 32
conv_decoder = nn.Sequential(
nn.Linear(32, 16 * 3 * 3),
nn.Unflatten(dim=1, unflattened_size=(16, 3, 3)),
nn.ConvTranspose2d(16, 32, kernel_size=3, stride=2), nn.ReLU(),
nn.ConvTranspose2d(32, 16, kernel_size=3, stride=2, padding=1,
output_padding=1), nn.ReLU(),
nn.ConvTranspose2d(16, 1, kernel_size=3, stride=2, padding=1,
output_padding=1), nn.Sigmoid())
conv_ae = nn.Sequential(conv_encoder, conv_decoder).to(device)
还可以创建具有其他架构类型的自动编码器,例如 RNN(请参阅笔记本中的示例)。
好的,让我们退后一步。到目前为止,我们已经探讨了各种类型的自动编码器(基本、堆叠和卷积)以及如何训练它们(一次性或逐层)。我们还探讨了几个应用:降维(例如,用于数据可视化)、异常检测和无监督预训练。
到目前为止,为了迫使自动编码器学习有趣的特征,我们限制了编码层的大小,使其不完整。实际上还有许多其他类型的约束可以使用,包括允许编码层与输入大小相同,甚至更大的约束,从而产生一个过完备自动编码器。因此,在接下来的几节中,我们将探讨更多类型的自动编码器:降噪自动编码器、稀疏自动编码器和变分自动编码器。
降噪自动编码器
强迫自动编码器学习有用特征的一种简单方法是在其输入中添加噪声,训练它恢复原始的无噪声输入。这个想法自 20 世纪 80 年代以来一直存在(例如,它在 Yann LeCun 的 1987 年硕士论文中提到)。在2008 年的一篇论文,^(5)中 Pascal Vincent 等人表明自动编码器也可以用于特征提取。在2010 年的一篇论文,^(6)中 Vincent 等人引入了堆叠降噪自动编码器。
噪声可以是添加到输入的纯高斯噪声,也可以是随机关闭的输入,就像在 dropout 中(在第十一章中介绍)一样。图 18-9 显示了这两种选项。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-skl-pt/img/hmls_1809.png
图 18-9. 噪声消除自动编码器,带有高斯噪声(左侧)或 dropout(右侧)
噪声消除自动编码器的 dropout 实现很简单:它是一个常规的堆叠自动编码器,在编码器的输入上应用了一个额外的Dropout层(回想一下,Dropout层仅在训练期间是活跃的)。请注意,编码层不需要对数据进行过多的压缩,因为噪声已经使得重建任务变得非平凡:
dropout_encoder = nn.Sequential(
nn.Flatten(),
nn.Dropout(0.5),
nn.Linear(1 * 28 * 28, 128), nn.ReLU(),
nn.Linear(128, 128), nn.ReLU(),
)
dropout_decoder = nn.Sequential(
nn.Linear(128, 128), nn.ReLU(),
nn.Linear(128, 1 * 28 * 28), nn.Sigmoid(),
nn.Unflatten(dim=1, unflattened_size=(1, 28, 28))
)
dropout_ae = nn.Sequential(dropout_encoder, dropout_decoder).to(device)
注意
这可能会让你想起 BERT 的 MLM 预训练任务(参见第十五章):重建掩码输入(除了 BERT 没有分成编码器和解码器)。
图 18-10 展示了一些噪声图像(一半的像素被关闭),以及经过训练后由基于 dropout 的去噪自动编码器重建的图像。注意自动编码器如何猜测实际上不在输入中的细节,例如最右侧鞋子的顶部。正如你所看到的,去噪自动编码器不仅可以用于数据可视化或无监督预训练,就像我们之前讨论的其他自动编码器一样,而且它们还可以非常简单且高效地用于从图像中去除噪声。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-skl-pt/img/hmls_1810.png
图 18-10. 噪声图像(顶部)及其重建图像(底部)
稀疏自动编码器
另一种常导致良好特征提取的约束是稀疏性:通过在成本函数中添加一个适当的项,自动编码器被推动减少编码层中活跃神经元的数量。这迫使自动编码器将每个输入表示为少量激活的组合。因此,编码层中的每个神经元通常最终代表一个有用的特征(如果你每月只能说几个词,你可能会尝试使它们值得聆听)。
一种基本的方法是在编码层中使用 sigmoid 激活函数(将编码约束在 0 到 1 之间的值),使用一个大的编码层(例如,有 256 个单元),并在编码层的激活上添加一些ℓ[1]正则化。这意味着将编码的ℓ[1]范数(即它们的绝对值之和)添加到损失中,并乘以一个稀疏超参数。这种稀疏损失将鼓励神经网络产生接近 0 的编码。然而,总损失仍然包括重建损失,因此模型将被迫输出至少几个非零值以正确重建输入。使用ℓ[1]范数而不是ℓ[2]范数将推动神经网络保留最重要的编码,同时消除对于输入图像不需要的编码(而不仅仅是减少所有编码)。
另一种方法——通常会产生更好的结果——是测量编码层中每个神经元的平均稀疏度,跨每个训练批次,并在平均稀疏度与目标稀疏度(例如,10%)不同时惩罚模型。批大小不能太小,否则平均值将不准确。例如,如果我们测量到一个神经元的平均激活为 0.3,但目标稀疏度是 0.1,那么这个神经元必须被惩罚以减少激活。一种方法可能是简单地将平方误差(0.3 – 0.1)²加到损失函数中,但在实践中,最好使用 Kullback–Leibler(KL)散度(在第四章中简要讨论),因为它比均方误差具有更强的梯度,如图 18-11 所示。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-skl-pt/img/hmls_1811.png
图 18-11. 目标稀疏度 p = 0.1 的稀疏度损失
给定两个离散概率分布 P 和 Q,这些分布之间的 KL 散度,记为 DKL,可以使用方程 18-1 计算。
方程 18-1. Kullback–Leibler 散度
u p p e r D S u b s c r i p t K L B a s e l i n e l e f t − p a r e n t h e s i s u p p e r P p a r a l l e l − t o u p p e r Q r i g h t − p a r e n t h e s i s e q u a l s s i g m a − s u m m a t i o n U n d e r s c r i p t i E n d s c r i p t s u p p e r P l e f t − p a r e n t h e s i s i r i g h t − p a r e n t h e s i s l o g S t a r t F r a c t i o n u p p e r P l e f t − p a r e n t h e s i s i r i g h t − p a r e n t h e s i s O v e r u p p e r Q l e f t − p a r e n t h e s i s i r i g h t − p a r e n t h e s i s E n d F r a c t i o n upper D Subscript KL Baseline left-parenthesis upper P parallel-to upper Q right-parenthesis equals sigma-summation Underscript i Endscripts upper P left-parenthesis i right-parenthesis log StartFraction upper P left-parenthesis i right-parenthesis Over upper Q left-parenthesis i right-parenthesis EndFraction upperDSubscriptKLBaselineleft−parenthesisupperPparallel−toupperQright−parenthesisequalssigma−summationUnderscriptiEndscriptsupperPleft−parenthesisiright−parenthesislogStartFractionupperPleft−parenthesisiright−parenthesisOverupperQleft−parenthesisiright−parenthesisEndFraction
在我们的情况下,我们想要测量编码层中神经元激活的目标概率 p 和通过测量训练批次中的平均激活估计的实际概率 q 之间的差异。因此,KL 散度简化为方程 18-2。
方程 18-2. 目标稀疏度 p 和实际稀疏度 q 之间的 KL 散度
u p p e r D S u b s c r i p t K L B a s e l i n e l e f t − p a r e n t h e s i s p p a r a l l e l − t o q r i g h t − p a r e n t h e s i s e q u a l s p l o g S t a r t F r a c t i o n p O v e r q E n d F r a c t i o n p l u s l e f t − p a r e n t h e s i s 1 m i n u s p r i g h t − p a r e n t h e s i s l o g S t a r t F r a c t i o n 1 m i n u s p O v e r 1 m i n u s q E n d F r a c t i o n upper D Subscript KL Baseline left-parenthesis p parallel-to q right-parenthesis equals p log StartFraction p Over q EndFraction plus left-parenthesis 1 minus p right-parenthesis log StartFraction 1 minus p Over 1 minus q EndFraction upperDSubscriptKLBaselineleft−parenthesispparallel−toqright−parenthesisequalsplogStartFractionpOverqEndFractionplusleft−parenthesis1minuspright−parenthesislogStartFraction1minuspOver1minusqEndFraction
要在 PyTorch 中实现这种方法,我们首先必须确保自动编码器输出重建和编码,因为它们都是计算损失所必需的。在此代码中,自动编码器的 forward() 方法返回一个包含两个字段的 namedtuple ——output(即重建)和codings:
from collections import namedtuple
AEOutput = namedtuple("AEOutput", ["output", "codings"])
class SparseAutoencoder(nn.Module):
def __init__(self):
super().__init__()
self.encoder = nn.Sequential(
nn.Flatten(),
nn.Linear(1 * 28 * 28, 128), nn.ReLU(),
nn.Linear(128, 256), nn.Sigmoid())
self.decoder = nn.Sequential(
nn.Linear(256, 128), nn.ReLU(),
nn.Linear(128, 1 * 28 * 28), nn.Sigmoid(),
nn.Unflatten(dim=1, unflattened_size=(1, 28, 28)))
def forward(self, X):
codings = self.encoder(X)
output = self.decoder(codings)
return AEOutput(output, codings)
注意
您可能需要调整训练和评估函数以支持这些 namedtuple 预测。例如,您可以在调用模型后立即在 evaluate_tm() 函数中添加 y_pred = y_pred.output。
接下来,我们可以定义损失函数:
def mse_plus_sparsity_loss(y_pred, y_target, target_sparsity=0.1,
kl_weight=1e-3, eps=1e-8):
p = torch.tensor(target_sparsity, device=y_pred.codings.device)
q = torch.clamp(y_pred.codings.mean(dim=0), eps, 1 - eps) # actual sparsity
kl_div = p * torch.log(p / q) + (1 - p) * torch.log((1 - p) / (1 - q))
return mse(y_pred.output, y_target) + kl_weight * kl_div.sum()
此函数返回重建损失(均方误差)加上一个加权稀疏损失。稀疏损失是目标稀疏度与批次平均稀疏度之间的 KL 散度。kl_weight 是一个超参数,你可以调整它来控制鼓励稀疏度的程度:如果这个超参数太高,模型将紧密地遵循目标稀疏度,但它可能无法正确地重建输入,使得模型变得无用。相反,如果它太低,模型将主要忽略稀疏度目标,并且不会学习任何有趣的特征。eps 参数是一个平滑项,用于避免在计算 KL 散度时除以零。
现在我们已经准备好创建模型并进行训练(使用与之前相同的 train() 函数,来自 第十章):
torch.manual_seed(42)
sparse_ae = SparseAutoencoder().to(device)
optimizer = torch.optim.NAdam(sparse_ae.parameters(), lr=0.002)
train(sparse_ae, optimizer, mse_plus_sparsity_loss, train_loader, n_epochs=10)
在对 Fashion MNIST 上的稀疏自编码器进行训练后,编码层将大约有 10%的稀疏度。成功!
小贴士
稀疏自编码器通常会产生相当可解释的编码,其中每个组件对应于图像中的一个可识别特征。例如,你可以绘制所有 n^(th) 编码值大于通常值(例如,高于 90^(th) 分位数)的图像:你通常会注意到所有图像都有一些共同点(例如,它们都是鞋子)。
现在让我们继续讨论变分自编码器!
变分自编码器
2013 年,Diederik Kingma 和 Max Welling^(7) 引入了一个重要的自编码器类别,很快它就成为了最受欢迎的变体之一:变分自编码器 (VAEs)。
与我们之前讨论的所有自编码器相比,VAEs 在以下特定方面相当不同:
-
它们是 概率自编码器,这意味着即使经过训练,它们的输出也部分由机会决定(与仅在使用训练时使用随机性的去噪自编码器相反)。
-
最重要的是,它们是 生成式自编码器,这意味着它们可以生成看起来像是从训练集中采样的新实例。^(8)
让我们看看变分自动编码器是如何工作的。图 18-12(左侧)展示了一个变分自动编码器。您可以识别出大多数自动编码器的基本三明治结构,即编码器后面跟着解码器(在这个例子中,它们都有两个隐藏层),但有一个转折:编码器不是直接为给定的输入产生编码,而是产生一个均值编码 μ 和一个标准差 σ。实际的编码随后从具有均值 μ 和标准差 σ 的高斯分布中随机采样。之后,解码器正常解码采样的编码。图例的右侧部分展示了训练实例通过这个自动编码器的过程。首先,编码器产生 μ 和 σ,然后随机采样一个编码(注意它并不正好位于 μ 上),最后对这个编码进行解码。最终的输出类似于训练实例。
如您在图 18-12 中可以看到,尽管输入可能具有非常复杂的分布,变分自动编码器往往会产生看起来像是从简单高斯分布中采样的编码。在训练过程中,成本函数(将在下一节讨论)推动编码在编码空间(也称为潜在空间)内逐渐迁移,最终看起来像是一团多维高斯点。一个很好的结果是,在训练完变分自动编码器后,您可以非常容易地生成一个新的实例:只需从高斯分布中随机采样一个编码,解码它,然后就可以了!
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-skl-pt/img/hmls_1812.png
图 18-12. 变分自动编码器(左侧)及其通过实例(右侧)
注意
从随机分布中采样不是一个可微的操作,它将阻止反向传播,那么我们如何希望训练编码器呢?嗯,使用一个重新参数化技巧:从𝒩(0, 1)中采样 ε 并计算 μ + σ ⊗ ε(逐元素乘法)。这相当于从𝒩(μ, σ²)中采样,但它将过程的确定性和随机性部分分开,允许梯度通过 μ 和 σ 流回编码器。结果编码器梯度是随机的(由于 ε),但它们是无偏估计,并且在训练过程中随机性会平均化。
成本函数由两部分组成。第一部分是通常的重建损失,它推动自动编码器重现其输入。我们可以使用 MSE 来实现这一点,就像我们之前做的那样。第二部分是 潜在损失,它推动自动编码器具有看起来像是从简单高斯分布中采样的编码:它是编码的实际分布和期望的潜在分布(即高斯分布)之间的 KL 散度。数学上比稀疏自动编码器复杂一些,特别是由于高斯噪声,这限制了可以传输到编码层的信 息量。幸运的是,方程简化了,因此可以使用 方程 18-3(对于完整的数学细节,请参阅变分自动编码器的原始论文或 Carl Doersch 的 优秀 2016 教程)来计算潜在损失。
方程 18-3. 变分自动编码器的潜在损失
s c r i p t u p p e r L e q u a l s m i n u s o n e − h a l f s i g m a − s u m m a t i o n U n d e r s c r i p t i e q u a l s 1 O v e r s c r i p t n E n d s c r i p t s l e f t − b r a c k e t 1 p l u s l o g l e f t − p a r e n t h e s i s s i g m a S u b s c r i p t i S u p e r s c r i p t 2 B a s e l i n e r i g h t − p a r e n t h e s i s m i n u s s i g m a S u b s c r i p t i S u p e r s c r i p t 2 B a s e l i n e m i n u s m u S u b s c r i p t i S u p e r s c r i p t 2 B a s e l i n e r i g h t − b r a c k e t script upper L equals minus one-half sigma-summation Underscript i equals 1 Overscript n Endscripts left-bracket 1 plus log left-parenthesis sigma Subscript i Superscript 2 Baseline right-parenthesis minus sigma Subscript i Superscript 2 Baseline minus mu Subscript i Superscript 2 Baseline right-bracket scriptupperLequalsminusone−halfsigma−summationUnderscriptiequals1OverscriptnEndscriptsleft−bracket1pluslogleft−parenthesissigmaSubscriptiSuperscript2Baselineright−parenthesisminussigmaSubscriptiSuperscript2BaselineminusmuSubscriptiSuperscript2Baselineright−bracket
在这个方程中,ℒ 是潜在损失,n 是编码的维度,而 μ[i] 和 σ[i] 是编码的第 i 个分量的均值和标准差。向量 μ 和 σ(包含所有 μ[i] 和 σ[i])由编码器输出,如图 图 18-12(左)所示。
变分自动编码器架构的一个常见调整是使编码器输出 γ = log(σ²) 而不是 σ。然后可以像 方程 18-4 中所示计算潜在损失。这种方法在数值上更稳定,并且可以加快训练速度。
方程 18-4. 变分自动编码器的潜在损失,使用 γ = log(σ²) 重新编写
s c r i p t u p p e r L e q u a l s m i n u s o n e − h a l f s i g m a − s u m m a t i o n U n d e r s c r i p t i e q u a l s 1 O v e r s c r i p t n E n d s c r i p t s l e f t − b r a c k e t 1 p l u s g a m m a S u b s c r i p t i B a s e l i n e m i n u s e x p l e f t − p a r e n t h e s i s g a m m a S u b s c r i p t i B a s e l i n e r i g h t − p a r e n t h e s i s m i n u s m u S u b s c r i p t i S u p e r s c r i p t 2 B a s e l i n e r i g h t − b r a c k e t script upper L equals minus one-half sigma-summation Underscript i equals 1 Overscript n Endscripts left-bracket 1 plus gamma Subscript i Baseline minus exp left-parenthesis gamma Subscript i Baseline right-parenthesis minus mu Subscript i Superscript 2 Baseline right-bracket scriptupperLequalsminusone−halfsigma−summationUnderscriptiequals1OverscriptnEndscriptsleft−bracket1plusgammaSubscriptiBaselineminusexpleft−parenthesisgammaSubscriptiBaselineright−parenthesisminusmuSubscriptiSuperscript2Baselineright−bracket
让我们为 Fashion MNIST 构建一个变分自动编码器,使用 图 18-12 中所示的架构,除了使用 γ 调整:
VAEOutput = namedtuple("VAEOutput",
["output", "codings_mean", "codings_logvar"])
class VAE(nn.Module):
def __init__(self, codings_dim=32):
super(VAE, self).__init__()
self.codings_dim = codings_dim
self.encoder = nn.Sequential(
nn.Flatten(),
nn.Linear(1 * 28 * 28, 128), nn.ReLU(),
nn.Linear(128, 2 * codings_dim)) # output both the mean and logvar
self.decoder = nn.Sequential(
nn.Linear(codings_dim, 128), nn.ReLU(),
nn.Linear(128, 1 * 28 * 28), nn.Sigmoid(),
nn.Unflatten(dim=1, unflattened_size=(1, 28, 28)))
def encode(self, X):
return self.encoder(X).chunk(2, dim=-1) # returns (mean, logvar)
def sample_codings(self, codings_mean, codings_logvar):
codings_std = torch.exp(0.5 * codings_logvar)
noise = torch.randn_like(codings_std)
return codings_mean + noise * codings_std
def decode(self, Z):
return self.decoder(Z)
def forward(self, X):
codings_mean, codings_logvar = self.encode(X)
codings = self.sample_codings(codings_mean, codings_logvar)
output = self.decode(codings)
return VAEOutput(output, codings_mean, codings_logvar)
让我们分析一下这段代码:
-
首先,我们定义
VAEOutput。这允许模型输出一个包含重建(output)、μ(codings_mean)和 γ(codings_logvar)的namedtuple。 -
编码器和解码器架构与之前的自动编码器非常相似,但请注意,编码器的输出是编码的两倍大小。这是因为编码器不是直接输出编码;相反,它输出从其中采样编码的高斯分布的参数:均值(μ)和方差的对数(γ)。
-
encode()方法调用encoder模型,并使用chunk()方法将输出分成两部分,以获得 μ 和 γ。 -
sample_codings()方法接受 μ 和 γ 并从中采样实际的编码。为此,它首先计算torch.exp(0.5 * codings_logvar)以获得编码的标准差 σ(你可以验证这在数学上是可行的)。然后它使用torch.randn_like()函数从均值为 0、标准差为 1 的高斯分布中采样与 σ 相同形状的随机向量,在同一设备和相同数据类型上。最后,它将这个高斯噪声乘以 σ,加上 μ,并返回结果。这就是我们之前讨论过的重新参数化技巧。 -
decode()方法简单地调用解码器模型以生成重建图像。 -
forward()方法调用编码器以获取 μ 和 γ,然后使用这些参数来采样编码,将其解码,并最终返回一个包含重建和参数 μ 和 γ 的VAEOutput对象,这些都是计算 VAE 损失所必需的。
说到这里,让我们现在定义损失函数,它是重建损失(MSE)和潜在损失(KL 散度)的和:
def vae_loss(y_pred, y_target, kl_weight=1.0):
output, mean, logvar = y_pred
kl_div = -0.5 * torch.sum(1 + logvar - logvar.exp() - mean.square(), dim=-1)
return F.mse_loss(output, y_target) + kl_weight * kl_div.mean() / 784
函数首先使用 方程 18-4 计算批次中每个实例的潜在损失(kl_div)(通过在最后一个维度上求和),然后计算批次中所有实例的平均潜在损失(kl_div.mean())。请注意,重建损失是批次中所有实例以及所有 784 个像素的平均值:这就是为什么我们要将潜在损失除以 784,以确保重建损失和潜在损失具有相同的尺度。
最后,我们可以在 Fashion MNIST 数据集上训练模型:
torch.manual_seed(42)
vae = VAE().to(device)
optimizer = torch.optim.NAdam(vae.parameters(), lr=1e-3)
train(vae, optimizer, vae_loss, train_loader, n_epochs=20)
生成 Fashion MNIST 图像
现在让我们使用这个 VAE 来生成看起来像时尚商品的图像。我们只需要从均值为 0、方差为 1 的高斯分布中采样随机编码,并将它们解码:
torch.manual_seed(42)
vae.eval()
codings = torch.randn(3 * 7, vae.codings_dim, device=device)
with torch.no_grad():
images = vae.decode(codings)
图 18-13 展示了 21 个生成的图像。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-skl-pt/img/hmls_1813.png
图 18-13. 变分自动编码器生成的 Fashion MNIST 图像
大多数这些图像看起来相当令人信服,尽管有点模糊。其余的图像不是很好,但不要对自动编码器太苛刻——它只花了几分钟来学习,如果你使用卷积层,你会得到更好的结果!
变分自编码器使得进行语义插值成为可能:而不是在像素级别上对两张图像进行插值,这样看起来就像是两张图像只是简单地叠加在一起,我们可以在编码级别上进行插值。例如,如果我们采样两个随机编码并在它们之间进行插值,然后解码所有插值编码,我们得到一系列图像,这些图像逐渐从一个时尚单品过渡到另一个(参见图 18-14):
codings = torch.randn(2, vae.codings_dim) # start and end codings
n_images = 7
weights = torch.linspace(0, 1, n_images).view(n_images, 1)
codings = torch.lerp(codings[0], codings[1], weights) # linear interpolation
with torch.no_grad():
images = vae.decode(codings.to(device))
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-skl-pt/img/hmls_1814.png
图 18-14. 语义插值
VAEs 有一些变体,例如,对于潜在变量使用不同的分布。一个重要的变体是离散 VAEs:现在让我们来讨论它们。
离散变分自编码器
一种离散变分自编码器(dVAE)与 VAE 非常相似,除了编码是离散的而不是连续的:每个编码向量包含潜在编码(也称为类别),每个编码都是一个介于 0 和k – 1 之间的整数,其中k是可能的潜在编码的数量。编码向量的长度通常表示为d。例如,如果你选择k = 10 和d = 6,那么就有 100 万个可能的编码向量(10⁶),例如[3, 0, 3, 9, 1, 4]。离散 VAEs 对于对连续输入进行标记以用于 transformers 和其他模型非常有用。例如,它们是 BEiT 和 DALL·E 等模型的核心(参见第十六章)。
使 VAEs 离散的最自然方式是使用分类分布而不是高斯分布。这暗示了一些变化:
-
首先,编码器必须输出 logits 而不是均值和方差。对于每个输入图像,它输出一个形状为[d, k]的张量,包含 logits,例如如果d = 2 和k = 3,则为[[1.2, –0.8, 0.5], [–1.3, 0.4, 0.3]]。
-
其次,由于分类采样不是一个可微操作,我们必须再次使用重新参数化技巧,但我们不能像在常规 VAEs 中那样重用:我们需要一个为分类分布设计的。最受欢迎的是 Gumbel-softmax 技巧。我们不是直接从分类分布中采样,而是调用
F.gumble_softmax()函数:这实现了分类采样的可微近似。给定之前的 logits,这个函数可能会输出离散编码向量[0, 2]。
注意
Gumbel 分布用于模拟从另一个分布的样本集中取出的最大值。例如,它可以用来估计未来 10 年内河流溢出的概率。如果你在 logits 上添加 Gumbel 噪声,然后取结果的最大值,这在数学上等同于分类采样。然而,argmax 操作是不可微的,所以在反向传播期间我们用 softmax 来替换它:这给了我们分类采样的可微近似。
这个想法在 2016 年由两个独立的研究团队几乎同时提出,一个来自DeepMind 和牛津大学,(9),另一个来自[谷歌、剑桥大学和斯坦福大学](https://homl.info/dvae2)。(10)
让我们实现一个用于 Fashion MNIST 的 dVAE:
DiscreteVAEOutput = namedtuple("DiscreteVAEOutput",
["output", "logits", "codings_prob"])
class DiscreteVAE(nn.Module):
def __init__(self, coding_length=32, n_codes=16, temperature=1.0):
super().__init__()
self.coding_length = coding_length
self.n_codes = n_codes
self.temperature = temperature
self.encoder = nn.Sequential([...]) # outputs [coding_length, n_codes]
self.decoder = nn.Sequential([...]) # outputs [1, 28, 28]
def forward(self, X):
logits = self.encoder(X)
codings_prob = F.gumbel_softmax(logits, tau=self.temperature, hard=True)
output = self.decoder(codings_prob)
return DiscreteVAEOutput(output, logits, codings_prob)
如您所见,此代码与 VAE 代码非常相似。请注意,我们在调用F.gumbel_softmax()函数时设置hard=True,以确保正向传播使用 Gumbel-argmax(以获得采样代码的 one-hot 向量),而反向传播使用 Gumbel-softmax 近似。此外,请注意我们向此函数传递一个温度(一个标量):在调用 softmax 函数之前,logits 将被除以这个温度。温度越低,输出分布就越接近 one-hot 向量(这仅影响反向传播)。通常,我们在训练开始时使用温度为 1,然后在训练过程中逐渐降低它,直到一个较小的值,例如 0.1。
损失函数也与常规 VAE 损失类似:它是重建损失(MSE)和加权潜在损失(KL 散度)的总和。然而,由于潜在分布已改变,KL 散度方程略有不同。现在是一个均匀的分类分布,其中所有可能的代码都是等可能的,因此每个代码都有 1 / k的概率。由于 log(1 / k) = –log(k),我们可以在 KL 散度方程中添加 log(k)而不是减去 log(1 / k):
def d_vae_loss(y_pred, y_target, kl_weight=1.0):
output, logits, _ = y_pred
codings_prob = F.softmax(logits, -1)
k = logits.new_tensor(logits.size(-1)) # same device and dtype as logits
kl_div = (codings_prob * (codings_prob.log() + k.log())).sum(dim=(1, 2))
return F.mse_loss(output, y_target) + kl_weight * kl_div.mean() / 784
你现在可以训练模型了。记得更新你的训练循环,以便在训练过程中逐渐降低温度,例如:
model.temperature = 1 - 0.9 * epoch / n_epochs
模型训练完成后,你可以通过从均匀分布中采样随机编码,然后对结果进行 one-hot 编码,最后解码得到的 one-hot 分布来生成新的图像:
codings = torch.randint(0, d_vae.n_codes, # from 0 to k – 1
(n_images, d_vae.coding_length), device=device)
codings_prob = F.one_hot(codings, num_classes=d_vae.n_codes).float()
with torch.no_grad():
images = d_vae.decoder(codings_prob)
另一种流行的离散 VAE 方法被称为向量量化(VQ-VAE),由 DeepMind 研究人员于 2017 年提出。^(11) 与生成 logits 不同,编码器输出d个嵌入,每个嵌入的维度为e。然后,VQ-VAE 将每个嵌入映射到形状为[k, e]的可训练嵌入矩阵中最近的嵌入的索引,称为代码簿。这产生了整数代码。最后,这些代码使用嵌入矩阵进行嵌入,然后传递给解码器。
由于用最近的代码簿嵌入替换嵌入不是一个可微的操作,反向传播过程假装代码簿查找步骤是恒等函数,因此梯度直接通过这个操作:这就是为什么这个技巧被称为直通估计器(STE)。这是一个假设编码嵌入周围的梯度与最近的代码簿嵌入周围的梯度相似的近似。
小贴士
VQ-VAEs 正确实现起来可能有点棘手,但你可以使用像https://github.com/lucidrains/vector-quantize-pytorch这样的库。从积极的一面来看,训练更加稳定,代码也更容易解释。
离散 VAEs 对于小图像工作得相当好,但对于大图像则不太适用:小尺度特征可能看起来不错,但通常会有大尺度的不一致性。为了改进这一点,你可以使用训练好的 dVAE 来编码你的整个训练集(因此每个实例都变成一个整数序列),然后使用这个新的训练集来训练一个变换器:只需将代码视为标记,并使用下一个标记预测来训练变换器。直观地说,dVAE 学习词汇表,而变换器学习语法。一旦变换器被训练,你就可以通过首先使用变换器生成一系列代码,然后将这个序列传递给 dVAE 的解码器来生成一个新的图像。
这种两阶段方法也使得控制图像生成过程变得更加容易:在训练变换器时,可以将图像的文本描述输入到变换器中,例如作为代码序列的前缀。我们说变换器是基于描述的,这有助于它预测正确的下一个代码。这样,经过训练后,我们可以通过提供我们想要的图像描述来引导图像生成过程。变换器将使用这个描述来生成适当的代码序列。这正是第一个 DALL·E 系统工作的方式。
在实践中,编码器和解码器通常是卷积网络,因此潜在表示通常组织成一个网格(但仍然被展平成一个序列以训练变换器)。例如,编码器可能输出形状为[256, 32, 32]的张量:这是一个 32 × 32 的网格,每个单元格包含 256 维的嵌入(或者 Gumbel-Softmax dVAEs 的情况下的 256 个 logits)。将这些嵌入映射到代码簿中最近嵌入的索引(或进行分类采样后),每个图像都表示为一个 32 × 32 的整数网格(代码),代码范围在 0 到 255 之间。要生成一个新图像,你使用变换器来预测一系列 1,024 个代码,将它们组织成一个 32 × 32 的网格,将每个代码替换为其代码簿向量,然后将结果传递给解码器以生成最终的图像。
小贴士
为了提高图像质量,你也可以堆叠两个或更多个 dVAEs,每个生成的网格比前一个更小:这被称为分层 VAE(HVAE)。编码器是堆叠的,然后是解码器,顺序相反,所有这些都是联合训练的。损失是单个重建损失加上多个 KL 散度损失(每个 dVAE 一个)的总和。
现在,让我们将注意力转向 GANs。它们更难训练,但当你设法让它们工作的时候,它们会产生相当惊人的图像。
生成对抗网络
生成对抗网络(GAN)是由 Ian Goodfellow 等人于 2014 年在一篇论文^(12)中提出的,尽管这个想法几乎立即让研究人员们兴奋起来,但克服 GAN 训练的一些困难还是花了几年的时间。像许多伟大的想法一样,事后看来这似乎很简单:让神经网络相互竞争,希望这种竞争能推动它们表现得更好。如图图 18-15 所示,GAN 由两个神经网络组成:
生成器
以随机编码作为输入(通常从高斯分布中采样)并输出一些数据——通常是图像。编码是待生成图像的潜在表示。所以,正如你所看到的,生成器提供了与变分自编码器中的解码器相同的功能,并且可以以相同的方式生成新的图像:只需给它一个随机向量,它就会输出一个全新的图像。然而,它的训练方式与生成器非常不同,你很快就会看到。
判别器
以生成器产生的假图像或训练集中的真实图像作为输入,并必须猜测输入图像是假还是真。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-skl-pt/img/hmls_1815.png
图 18-15. 生成对抗网络
在训练过程中,生成器和判别器有相反的目标:判别器试图区分假图像和真实图像,而生成器试图生成足够真实的图像以欺骗判别器。由于 GAN 由两个具有不同目标的网络组成,因此不能像常规神经网络那样进行训练。每个训练迭代分为两个阶段:
第一阶段:训练判别器
从训练集中采样一批真实图像,并补充与生成器产生的假图像数量相等的假图像。标签设置为 0 表示假图像,1 表示真实图像,判别器在这个标记批次上训练一步,使用二元交叉熵损失。重要的是,反向传播只在这个阶段优化判别器的权重。
第二阶段:训练生成器
我们首先使用生成器生成另一批假图像,然后再次使用判别器来判断图像是否为假。这次我们不添加真实图像到批次中,所有标签都设置为 1(真实);换句话说,我们希望生成器生成判别器(错误地)认为真实的图像!关键的是,在这个步骤中,判别器的权重被冻结,因此反向传播只影响生成器的权重。
注意
生成器实际上从未真正看到任何真实图像,然而它逐渐学会了产生令人信服的假图像!它所获得的一切只是通过判别器流回的梯度。幸运的是,判别器越好,这些二手梯度中包含的真实图像信息就越多,因此生成器可以取得显著的进步。
让我们继续构建一个简单的针对 Fashion MNIST 的 GAN。
首先,我们需要构建生成器和判别器。生成器类似于自编码器的解码器——它以编码向量作为输入并输出一个图像——而判别器是一个常规的二分类器——它以图像作为输入,并以包含单个单元的密集层结束,使用 sigmoid 激活函数:
codings_dim = 32
generator = nn.Sequential(
nn.Linear(codings_dim, 128), nn.ReLU(),
nn.Linear(128, 256), nn.ReLU(),
nn.Linear(256, 1 * 28 * 28), nn.Sigmoid(),
nn.Unflatten(dim=1, unflattened_size=(1, 28, 28))).to(device)
discriminator = nn.Sequential(
nn.Flatten(),
nn.Linear(1 * 28 * 28, 256), nn.ReLU(),
nn.Linear(256, 128), nn.ReLU(),
nn.Linear(128, 1), nn.Sigmoid()).to(device)
由于训练循环不寻常,我们需要一个新的训练函数:
def train_gan(generator, discriminator, train_loader, codings_dim, n_epochs=20,
g_lr=1e-3, d_lr=5e-4):
criterion = nn.BCELoss()
generator_opt = torch.optim.NAdam(generator.parameters(), lr=g_lr)
discriminator_opt = torch.optim.NAdam(discriminator.parameters(), lr=d_lr)
for epoch in range(n_epochs):
for real_images, _ in train_loader:
real_images = real_images.to(device)
pred_real = discriminator(real_images)
batch_size = real_images.size(0)
ones = torch.ones(batch_size, 1, device=device)
real_loss = criterion(pred_real, ones)
codings = torch.randn(batch_size, codings_dim, device=device)
fake_images = generator(codings).detach()
pred_fake = discriminator(fake_images)
zeros = torch.zeros(batch_size, 1, device=device)
fake_loss = criterion(pred_fake, zeros)
discriminator_loss = real_loss + fake_loss
discriminator_opt.zero_grad()
discriminator_loss.backward()
discriminator_opt.step()
codings = torch.randn(batch_size, codings_dim, device=device)
fake_images = generator(codings)
for p in discriminator.parameters():
p.requires_grad = False
pred_fake = discriminator(fake_images)
generator_loss = criterion(pred_fake, ones)
generator_opt.zero_grad()
generator_loss.backward()
generator_opt.step()
for p in discriminator.parameters():
p.requires_grad = True
如前所述,你可以在每个迭代中看到两个阶段:首先判别器执行梯度下降步骤,然后轮到生成器。我们为每个使用一个单独的优化器。让我们更详细地看看:
第一阶段
我们向判别器输入一批真实图像,并计算等于一的标签给出的损失;确实,我们想让判别器预测这些图像是真实的。然后我们生成一些随机编码,并将它们输入到生成器中以生成一些假图像。注意,我们对这些图像调用detach(),因为我们不希望梯度下降影响生成器在这个阶段。然后我们将这些假图像传递给判别器,并计算等于零的标签给出的损失;我们想让判别器预测这些图像是假的。判别器的总损失是real_loss加上fake_loss。最后,我们执行梯度下降步骤,改进判别器。
第二阶段
我们使用生成器生成一些假图像,并将它们传递给判别器,就像我们刚才做的那样。然而,这次我们不对假图像调用detach(),因为我们想训练生成器。此外,我们通过将每个参数p的p.required_grad设置为False来使判别器不可训练。然后我们使用等于一的标签来计算损失:确实,我们想让生成器欺骗判别器,所以想让判别器错误地预测这些是真实图像。最后,我们为生成器执行梯度下降步骤,并再次使判别器可训练。
就这样!训练完成后,你可以从高斯分布中随机采样一些编码,并将它们输入到生成器中,以生成新的图像:
generator.eval()
codings = torch.randn(n_images, codings_dim, device=device)
with torch.no_grad():
generated_images = generator(codings)
如果你展示了生成的图像(见图 18-16),你将看到在第一个训练周期结束时,它们已经开始看起来像(非常嘈杂的)Fashion MNIST 图像。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-skl-pt/img/hmls_1816.png
图 18-16. GAN 在训练一个周期后生成的图像
很不幸,图像的质量永远不会比这更好,你甚至可能会发现某些时期,GAN 似乎忘记了它学到的内容。这是为什么?好吧,事实证明,训练 GAN 可能具有挑战性。让我们看看原因。
训练 GAN 的困难
在训练过程中,生成器和判别器不断在零和游戏中试图智胜对方。随着训练的进行,游戏可能最终陷入一种博弈论学家称之为纳什均衡的状态,以数学家约翰·纳什的名字命名。这种情况发生在没有任何玩家会因改变自己的策略而变得更好,假设其他玩家不会改变他们的策略。例如,当每个人都开车在道路的左侧时,就会达到纳什均衡:没有司机会因为成为唯一一个改变车道的人而变得更好。当然,还有第二个可能的纳什均衡:当每个人都开车在右侧的道路上。不同的初始状态和动态可能导致一个均衡或另一个均衡。在这个例子中,一旦达到均衡,就有一个单一的优化策略(即与其他人一样开车),但纳什均衡可以涉及多个竞争策略(例如,捕食者追逐猎物,猎物试图逃跑,并且双方都不会因改变策略而变得更好)。
那么,这如何适用于 GAN?嗯,GAN 论文的作者证明了 GAN 只能达到一个纳什均衡:那就是生成器产生完全逼真的图像,而判别器被迫猜测(50%真实,50%伪造)。这个事实非常鼓舞人心,因为它似乎意味着你只需要训练 GAN 足够长的时间,它最终会达到这个均衡,给你一个完美的生成器。不幸的是,事情并没有那么简单:没有任何保证均衡会被达到。
最大的困难被称为模式崩溃:当生成器的输出逐渐变得不那么多样化。这怎么可能发生呢?假设生成器在制作令人信服的鞋子方面比其他任何类别都做得更好。它将用鞋子稍微欺骗判别器,这将鼓励它产生更多鞋子的图像。渐渐地,它将忘记如何产生其他任何东西。同时,判别器将看到的唯一伪造图像将是鞋子,因此它也会忘记如何区分其他类别的伪造图像。最终,当判别器设法区分伪造的鞋子与真实的鞋子时,生成器将被迫转移到另一个类别。然后它可能擅长衬衫,忘记鞋子,判别器也会跟随。GAN 可能逐渐在几个类别之间循环,但从未真正擅长任何一个(参见图 18-17 的顶部行)。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-skl-pt/img/hmls_1817.png
图 18-17. 训练 GAN 时的模式坍塌(顶部行)与无模式坍塌的成功训练(底部行)
此外,由于生成器和判别器不断相互对抗,它们的参数最终可能会振荡并变得不稳定。训练可能一开始进行得很顺利,然后突然由于这些不稳定性而突然发散,没有任何明显的原因。由于许多因素会影响这些复杂的动态,GANs 对超参数非常敏感:你可能需要花费大量精力来微调它们。
这些问题自 2014 年以来一直让研究人员非常忙碌。关于这个主题已经发表了多篇论文,其中一些提出了新的成本函数^(13)(尽管谷歌研究人员在 2018 年的一篇论文中 14 对其效率提出了质疑)或用于稳定训练或避免模式坍塌问题的技术。例如,一种流行的技术称为经验回放,它包括在每个迭代中将生成器产生的图像存储在回放缓冲区中(逐渐丢弃较旧的生成图像)并使用真实图像加上从该缓冲区中抽取的假图像来训练判别器(而不是仅使用当前生成器产生的假图像)。这减少了判别器过度拟合最新生成器输出的可能性。另一种常见的技术称为小批量判别:它衡量批次中图像之间的相似性,并将此统计数据提供给判别器,以便它可以轻松拒绝缺乏多样性的整个批次假图像。这鼓励生成器产生更多样化的图像,减少模式坍塌的可能性(见图 18-17 的底部行)。
简而言之,这是一个非常活跃的研究领域,直到最近都取得了很大的进展:从基于卷积层的深度卷积生成对抗网络(DCGANs)(见笔记本中的示例),到能够生成高分辨率图像的渐进式增长生成对抗网络,或允许用户对图像生成过程进行精细控制的风格生成对抗网络(StyleGANs),GANs 似乎有着光明的未来。但是,当扩散模型也开始产生令人惊叹的图像,并且具有更稳定的训练过程和更多样化的图像时,GANs 很快就被边缘化了。因此,现在让我们将注意力转向扩散模型。
扩散模型
扩散模型背后的思想已经存在很多年了,但它们首次以现代形式在斯坦福大学和加州大学伯克利分校的 Jascha Sohl-Dickstein 等人于 2015 年发表的一篇论文^(15)中得到正式化。作者将统计力学的工具应用于模拟一个扩散过程,类似于牛奶在茶杯中扩散的过程。核心思想是训练一个模型来学习逆向过程:从完全混合的状态开始,逐渐“去混合”茶中的牛奶。利用这个想法,他们在图像生成方面获得了有希望的结果,但由于当时的 GANs 生成的图像更加令人信服,而且速度更快,扩散模型并没有得到太多的关注。
然后,在 2020 年,来自加州大学伯克利分校的Jonathan Ho 等人成功构建了一个能够生成高度逼真图像的扩散模型,他们将这个模型称为去噪扩散概率模型(DDPM)。^(16)几个月后,OpenAI 的研究员 Alex Nichol 和 Prafulla Dhariwal 发表了一篇2021 年的论文^(17),分析了 DDPM 架构并提出了几个改进,使得 DDPM 最终能够击败 GANs:DDPM 的训练比 GANs 容易得多,而且生成的图像更加多样化,质量更高。DDPM 的主要缺点,正如你将看到的,是它们生成图像所需的时间非常长,与 GANs 或 VAEs 相比。
那么,DDPM 究竟是如何工作的呢?好吧,假设你从一个猫的图片(如图 18-18 中的图片)开始,记为x[0],在每一个时间步长t,你向图像中添加一点高斯噪声,均值为 0,方差为β[t](一个标量)。这种噪声对每个像素是独立的(使用相同的均值和方差):我们称之为各向同性。你首先得到图像x[1],然后x[2],以此类推,直到猫被噪声完全掩盖,无法看见。最后一个时间步长记为T。在原始的 DDPM 论文中,作者使用了T = 1,000,并且他们安排方差β[t]的方式使得猫的信号在时间步长 0 和T之间线性衰减。在改进的 DDPM 论文中,T被提高到 4,000,方差的时间表被调整以在开始和结束时变化得更慢。简而言之,我们是在逐渐将猫淹没在噪声中:这被称为正向过程。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-skl-pt/img/hmls_1818.png
图 18-18. 正向过程q和逆向过程p
随着我们在正向过程中添加越来越多的高斯噪声,像素值的分布变得越来越像高斯分布。我遗漏的一个重要细节是,像素值在每一步都会稍微重新缩放,缩放因子为 S t a r t R o o t 1 m i n u s b e t a S u b s c r i p t t B a s e l i n e E n d R o o t StartRoot 1 minus beta Subscript t Baseline EndRoot StartRoot1minusbetaSubscripttBaselineEndRoot 。这确保了像素值的平均值逐渐接近 0,因为缩放因子略小于 1(想象一下反复将一个数字乘以 0.99)。这也确保了方差将逐渐收敛到 1。这是因为像素值的标准差也会被 S t a r t R o o t 1 m i n u s b e t a S u b s c r i p t t B a s e l i n e E n d R o o t StartRoot 1 minus beta Subscript t Baseline EndRoot StartRoot1minusbetaSubscripttBaselineEndRoot 缩放,因此方差会被缩放为 1 – β[t](即缩放因子的平方)。但由于我们在每一步都添加了具有方差 β[t] 的高斯噪声,方差不能缩小到 0。并且由于高斯分布的方差相加,方差必须收敛到 1 – β[t] + β[t] = 1。
正向扩散过程总结在 方程 18-5 中。这个方程不会教你关于正向过程的新知识,但它有助于理解这种数学符号,因为它在机器学习论文中经常被使用。这个方程定义了给定 x[t–1] 作为具有平均值 x[t–1] 乘以缩放因子的高斯分布,并且协方差矩阵等于 β[t]I 的概率分布 q。这是单位矩阵 I 乘以 β[t],这意味着噪声是各向同性的,方差为 β[t]。
方程 18-5. 正向扩散过程的概率分布 q
q l e f t − p a r e n t h e s i s b o l d x S u b s c r i p t t B a s e l i n e v e r t i c a l − b a r b o l d x S u b s c r i p t t m i n u s 1 B a s e l i n e r i g h t − p a r e n t h e s i s e q u a l s s c r i p t u p p e r N l e f t − p a r e n t h e s i s S t a r t R o o t 1 m i n u s b e t a S u b s c r i p t t B a s e l i n e E n d R o o t b o l d x S u b s c r i p t t m i n u s 1 B a s e l i n e c o m m a b e t a S u b s c r i p t t B a s e l i n e b o l d u p p e r I r i g h t − p a r e n t h e s i s q left-parenthesis bold x Subscript t Baseline vertical-bar bold x Subscript t minus 1 Baseline right-parenthesis equals script upper N left-parenthesis StartRoot 1 minus beta Subscript t Baseline EndRoot bold x Subscript t minus 1 Baseline comma beta Subscript t Baseline bold upper I right-parenthesis qleft−parenthesisboldxSubscripttBaselinevertical−barboldxSubscripttminus1Baselineright−parenthesisequalsscriptupperNleft−parenthesisStartRoot1minusbetaSubscripttBaselineEndRootboldxSubscripttminus1BaselinecommabetaSubscripttBaselineboldupperIright−parenthesis
有趣的是,对于正向过程有一个快捷方式:在不需要首先计算 x[1],x[2],…,x[t–1] 的情况下,可以采样图像 x[t]。确实,由于多个独立高斯分布的总和也是一个高斯分布,所有噪声可以在一次操作中添加。如果我们定义 α[t] = 1 – β[t],并且 α̅[t] = α[1] × α[2] × …× α[t] = a l p h a o v e r b a r S u b s c r i p t t B a s e l i n e e q u a l s p r o d u c t U n d e r s c r i p t i e q u a l s 1 O v e r s c r i p t t E n d s c r i p t s a l p h a S u b s c r i p t t alpha overbar Subscript t Baseline equals product Underscript i equals 1 Overscript t Endscripts alpha Subscript t alphaoverbarSubscripttBaselineequalsproductUnderscriptiequals1OverscripttEndscriptsalphaSubscriptt ,那么我们可以使用 方程 18-6 来计算 x[t]。这就是我们将要使用的方程,因为它要快得多。
方程 18-6. 正向扩散过程的快捷方式
q l e f t − p a r e n t h e s i s b o l d x S u b s c r i p t t B a s e l i n e v e r t i c a l − b a r b o l d x 0 r i g h t − p a r e n t h e s i s e q u a l s s c r i p t u p p e r N l e f t − p a r e n t h e s i s S t a r t R o o t a l p h a o v e r b a r S u b s c r i p t t B a s e l i n e E n d R o o t b o l d x 0 c o m m a l e f t − p a r e n t h e s i s 1 m i n u s a l p h a o v e r b a r S u b s c r i p t t B a s e l i n e r i g h t − p a r e n t h e s i s b o l d u p p e r I r i g h t − p a r e n t h e s i s q left-parenthesis bold x Subscript t Baseline vertical-bar bold x 0 right-parenthesis equals script upper N left-parenthesis StartRoot alpha overbar Subscript t Baseline EndRoot bold x 0 comma left-parenthesis 1 minus alpha overbar Subscript t Baseline right-parenthesis bold upper I right-parenthesis qleft−parenthesisboldxSubscripttBaselinevertical−barboldx0right−parenthesisequalsscriptupperNleft−parenthesisStartRootalphaoverbarSubscripttBaselineEndRootboldx0commaleft−parenthesis1minusalphaoverbarSubscripttBaselineright−parenthesisboldupperIright−parenthesis
当然,我们的目标不是在噪声中淹死猫。相反,我们想要创造许多新的猫!我们可以通过训练一个能够执行 反向过程 的模型来实现这一点:从 x[t] 到 x[t–1]。然后我们可以用它来从图像中移除一小部分噪声,并重复操作多次,直到所有噪声都消失。这不是一个仅依赖于相邻像素的基本噪声过滤器:相反,当噪声被移除时,它会被根据训练数据替换为逼真的像素。例如,如果我们在一个包含许多猫图像的数据集上训练模型,那么我们可以给它一张完全由高斯噪声组成的图片,模型将逐渐使一只全新的猫出现(参见 图 18-18)。
好的,那么让我们开始编码!我们首先需要做的是编码前向过程。为此,我们首先需要实现方差调度。我们如何控制猫消失的速度?在每一个时间步 t,像素值会被乘以 S t a r t R o o t 1 m i n u s b e t a S u b s c r i p t t B a s e l i n e E n d R o o t StartRoot 1 minus beta Subscript t Baseline EndRoot StartRoot1minusbetaSubscripttBaselineEndRoot,并添加均值为 0 和方差 β[t] 的噪声(如前所述)。因此,图像方差中来自原始猫图像的部分在每个步骤中会以 α[t] = 1 – \beta_t 的因子缩小。经过 t 个时间步后,它将缩小到因子 α̅[t] = α[1] × α[2] × … × α[t]。我们想要调度这个“猫信号”因子 α̅[t],使其在时间步 0 和 T 之间逐渐从 1 缩小到 0。在改进的 DDPM 论文中,作者根据 方程 18-7 调度 α̅[t]。这个调度在 图 18-19 中表示。
方程 18-7. 前向扩散过程的方差调度方程
b e t a S u b s c r i p t t B a s e l i n e e q u a l s 1 m i n u s S t a r t F r a c t i o n a l p h a o v e r b a r S u b s c r i p t t B a s e l i n e O v e r a l p h a o v e r b a r S u b s c r i p t t m i n u s 1 B a s e l i n e E n d F r a c t i o n beta Subscript t Baseline equals 1 minus StartFraction alpha overbar Subscript t Baseline Over alpha overbar Subscript t minus 1 Baseline EndFraction betaSubscripttBaselineequals1minusStartFractionalphaoverbarSubscripttBaselineOveralphaoverbarSubscripttminus1BaselineEndFraction with alpha overbar Subscript t Baseline equals StartFraction f left-parenthesis t right-parenthesis Over f left-parenthesis 0 right-parenthesis EndFraction and f left-parenthesis t right-parenthesis equals cosine squared left-parenthesis StartStartFraction StartFraction t Over upper T EndFraction plus s OverOver 1 plus s EndEndFraction dot StartFraction pi Over 2 EndFraction right-parenthesis$
在这个方程中:
-
s 是一个非常小的值,它防止 β[t] 在 t = 0 附近变得太小。在论文中,作者使用了 s = 0.008。
-
β[t] 被限制在不超过 0.999,以避免在 t = T 附近的稳定性问题。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-skl-pt/img/hmls_1819.png
图 18-19. 噪声方差计划 β[t],以及剩余的信号方差 α̅[t]
让我们创建一个小的函数来计算 α[t],β[t],和 α̅[t],使用 方程 18-7,并使用 T = 4,000 调用此函数:
def variance_schedule(T, s=0.008, max_beta=0.999):
t = torch.linspace(0, T, T + 1)
f = torch.cos((t / T + s) / (1 + s) * torch.pi / 2) ** 2
alpha_bars = f / f[0]
betas = (1 - (f[1:] / f[:-1])).clamp(max=max_beta)
betas = torch.cat([torch.zeros(1), betas]) # for easier indexing
alphas = 1 - betas
return alphas, betas, alpha_bars
T = 4000
alphas, betas, alpha_bars = variance_schedule(T)
为了训练我们的模型以逆转扩散过程,我们需要来自正向过程不同时间步长的噪声图像。为此,让我们创建一个函数,该函数将使用 方程 18-6 接收一个图像 x[0] 和一个时间步 t,并返回一个噪声图像 x[t]:
def forward_diffusion(x0, t):
eps = torch.randn_like(x0) # this unscaled noise will be the target
xt = alpha_bars[t].sqrt() * x0 + (1 - alpha_bars[t]).sqrt() * eps
return xt, eps
模型需要噪声图像 x[t] 和时间步 t,所以让我们创建一个小的类来保存这两个值。我们将给它一个方便的 to() 方法,将 x[t] 和 t 移动到 GPU:
class DiffusionSample(namedtuple("DiffusionSampleBase", ["xt", "t"])):
def to(self, device):
return DiffusionSample(self.xt.to(device), self.t.to(device))
接下来,让我们创建一个数据集包装器类。它接收一个图像数据集——在我们的例子中是 Fashion MNIST,并预处理图像,使其像素值介于 -1 和 +1 之间(这是可选的,但通常效果更好),并使用 forward_diffusion() 函数向图像添加噪声。然后它将生成的噪声图像以及时间步封装在 DiffusionSample 对象中,并返回它以及目标,即未缩放的噪声 eps,在它被 S t a r t R o o t 1 减去 a l p h a b a r S u b s c r i p t t B a s e l i n e E n d R o o t StartRoot 1 减去 alpha bar Subscript t Baseline EndRoot StartRoot1减去alphabarSubscripttBaselineEndRoot 缩放并添加到图像之前:
class DiffusionDataset:
def __init__(self, dataset):
self.dataset = dataset
def __getitem__(self, i):
x0, _ = self.dataset[i]
x0 = (x0 * 2) - 1 # scale from –1 to +1
t = torch.randint(1, T + 1, size=[1])
xt, eps = forward_diffusion(x0, t)
return DiffusionSample(xt, t), eps
def __len__(self):
return len(self.dataset)
train_set = DiffusionDataset(train_data) # wrap Fashion MNIST
train_loader = DataLoader(train_set, batch_size=32, shuffle=True)
你可能想知道为什么不直接预测原始图像,而不是未缩放的噪声?一个原因是经验性的:作者尝试了两种方法,并观察到预测噪声而不是图像导致了更稳定的训练和更好的结果。另一个原因是噪声是高斯分布,这允许进行一些数学简化:特别是,两个高斯分布之间的 KL 散度与它们均值之间的平方距离成正比,因此我们可以使用 MSE 损失,它简单、快速且相当稳定。
现在我们已经准备好构建实际的扩散模型本身。它可以是你想要的任何模型,只要它以DiffusionSample作为输入,并输出与输入图像相同形状的图像。DDPM 的作者使用了一个修改过的U-Net 架构,^(18),它与我们在第十二章中讨论的 FCN 架构在语义分割方面有很多相似之处。U-Net 是一种卷积神经网络,它逐渐下采样输入图像,然后再次逐渐上采样,通过跳过连接从下采样部分的每一级跨越到上采样部分的相应级。为了考虑时间步长,他们使用固定的正弦编码(即与原始 Transformer 架构中的位置编码相同的技巧)。在 U-Net 架构的每一级,他们都通过Linear层传递这些时间编码,并将它们输入到 U-Net 中。最后,他们在各个级别也使用了多头注意力层。参见本章的笔记本以获取基本实现(这里复制太长,细节不重要:许多其他模型架构也可以很好地工作)。
class DiffusionModel(nn.Module): # see the notebook for full details
def __init__(self, T=T, embed_dim=64):
[...] # create all the required modules to build the U-Net
def forward(self, sample):
[...] # process the sample and predict the noise for each image
对于训练,作者指出使用 MAE 损失比 MSE 更有效。你也可以使用 Huber 损失:
diffusion_model = DiffusionModel().to(device)
huber = nn.HuberLoss()
optimizer = torch.optim.NAdam(diffusion_model.parameters(), lr=3e-3)
train(diffusion_model, optimizer, huber, train_loader, n_epochs=20)
一旦模型训练完成,你可以通过从均值为 0、方差为 1 的高斯分布中随机采样x[T]来使用它生成新的图像,然后使用方程 18-8 得到x[T–1]。然后使用这个方程再重复 3,999 次,直到得到x[0]。如果一切顺利,x[0]应该看起来像一张普通的 Fashion MNIST 图像!
方程 18-8. 在 DDPM 扩散过程中的反向一步
𝐱 t − 1 = 1 α t ( 𝐱 t − β t 1 − α t ε θ ( 𝐱 t , t ) ) + β t 𝐳
在这个方程中,ε[θ](x[t], t)表示模型根据输入图像x[t]和时间步长t预测的噪声。θ代表模型参数。此外,z是均值为 0、方差为 1 的高斯噪声。这使得反向过程是随机的:如果你多次运行它,你会得到不同的图像。
这方法效果不错,但生成一张图片需要 4,000 次迭代!这太慢了。幸运的是,在 DDPM 论文发表后的几个月里,斯坦福大学的研究人员提出了一种名为去噪扩散隐式模型(DDIM)的技术^(19),可以在更少的步骤中生成图片:DDIM 可以一次下降任意多个时间步,而不是每次只下降一个时间步从t = 4,000 下降到 0,使用方程 18-9。此外,训练过程与 DDPM 完全相同,因此我们可以简单地重用我们训练好的 DDPM 模型。
方程 18-9. 使用 DDIM 进行多步反向操作
𝐱 p = α p 𝐱 ^ 0 + 1 − α p − σ 2 ⋅ ε θ ( 𝐱 t , t ) + σ 𝐳 其中 𝐱 ^ 0 = 1 α t ( 𝐱 t − 1 − α t ε θ ( 𝐱 t , t ) ) 并且 σ 2 = η ( 1 − α p 1 − α t ) β t
在这个方程中:
-
ε[θ](x[t], t), θ, 和 z 与 方程 18-8 中的含义相同。
-
p 代表 t 之前的任何时间步。例如,它可以是 p = t – 50。
-
η 是一个超参数,它控制生成过程中应该使用多少随机性,从 0(无随机性,完全确定)到 1(就像 DDPM)。
让我们编写一个实现这个反向过程的函数,并将其命名为生成几个图像:
def generate_ddim(model, batch_size=32, num_steps=50, eta=0.85):
model.eval()
with torch.no_grad():
xt = torch.randn([batch_size, 1, 28, 28], device=device)
times = torch.linspace(T - 1, 0, steps=num_steps + 1).long().tolist()
for t, t_prev in zip(times[:-1], times[1:]):
t_batch = torch.full((batch_size, 1), t, device=device)
sample = DiffusionSample(xt, t_batch)
eps_pred = model(sample)
x0 = ((xt - (1 - alpha_bars[t]).sqrt() * eps_pred)
/ (alpha_bars[t].sqrt()))
abar_t_prev = alpha_bars[t_prev]
variance = eta * (1 - abar_t_prev) / (1 - alpha_bars[t]) * betas[t]
sigma_t = variance.sqrt()
pred_dir = (1 - abar_t_prev - sigma_t**2).sqrt() * eps_pred
noise = torch.randn_like(xt)
xt = abar_t_prev.sqrt() * x0 + pred_dir + sigma_t * noise
return torch.clamp((xt + 1) / 2, 0, 1) # from [–1, 1] range to [0, 1]
X_gen_ddim = generate_ddim(diffusion_model, num_steps=500)
这次生成将只需几秒钟,并将产生如图图 18-20 所示的图像。诚然,它们并不非常令人印象深刻,但我们只在 Fashion MNIST 上训练了模型几分钟。尝试在更大的数据集上训练它几个小时,以获得更令人印象深刻的结果。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-skl-pt/img/hmls_1820.png
图 18-20. 由 DDIM 加速扩散生成的图像
自 2020 年以来,扩散模型取得了巨大的进步。特别是,2021 年 12 月由 Robin Rombach 等人发表的一篇论文介绍了潜在扩散模型,其中扩散过程发生在潜在空间,而不是像素空间。为了实现这一点,使用了一个强大的自动编码器将每个训练图像压缩到一个更小的潜在空间,其中扩散过程发生,然后使用自动编码器来解压缩最终的潜在表示,生成输出图像。这大大加快了图像生成速度,并显著减少了训练时间和成本。重要的是,生成的图像质量非常出色。
此外,研究人员还采用了各种条件化技术,通过文本提示、图像或其他任何输入来引导扩散过程。这使得快速生成任何你想要的图像成为可能。你也可以使用输入图像来条件化图像生成过程。这使许多应用成为可能,例如扩展画布——将输入图像扩展到其边界之外,或者修复画布——在图像中填充洞。
最后,一个名为Stable Diffusion(SD)的强大预训练潜在扩散模型在 2022 年 8 月由慕尼黑大学与 StabilityAI、Runway 等几家公司的合作开源,得到了 EleutherAI 和 LAION 的支持。现在任何人都可以在几秒钟内免费生成令人惊叹的图像,甚至在普通的笔记本电脑上也可以。例如,你可以使用 Hugging Face Diffusers 库来加载 SD(例如,turbo 变体),创建一个从文本到图像的图像生成管道,并生成一只猩猩读书的图像:
from diffusers import AutoPipelineForText2Image
pipe = AutoPipelineForText2Image.from_pretrained(
"stabilityai/sd-turbo", variant="fp16", dtype=torch.float16)
pipe.to(device)
prompt = "A closeup photo of an orangutan reading a book"
torch.manual_seed(26)
image = pipe(prompt=prompt, num_inference_steps=1, guidance_scale=0.0).images[0]
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-skl-pt/img/hmls_1821.png
图 18-21. 使用 Diffusers 库生成的 Stable Diffusion 图像
可能性是无限的!
在下一章中,我们将转向深度学习的另一个完全不同的分支:深度强化学习。
练习
-
自动编码器主要用于哪些主要任务?
-
假设你想训练一个分类器,你有很多未标记的训练数据,但只有几千个标记实例。自动编码器如何帮助?你会如何进行?
-
如果一个自编码器完美地重建了输入,它是否必然是一个好的自编码器?你如何评估自编码器的性能?
-
什么是欠完备和过完备的自编码器?一个过度欠完备的自编码器的主要风险是什么?过完备的自编码器的主要风险又是什么?
-
你如何在堆叠自编码器中绑定权重?这样做有什么意义?
-
什么是生成模型?你能命名一种生成自编码器的类型吗?
-
什么是 GAN?你能列举一些 GAN 可以大放异彩的任务吗?
-
训练 GAN 时主要有哪些困难?
-
扩散模型擅长什么?它们的主要局限性是什么?
-
尝试使用去噪自编码器来预训练一个图像分类器。你可以使用 MNIST(最简单的选项),或者一个更复杂的图像数据集,如CIFAR10,如果你想要更大的挑战。无论你使用什么数据集,请遵循以下步骤:
-
将数据集分为训练集和测试集。在完整的训练集上训练一个深度去噪自编码器。
-
检查图像是否得到了相当好的重建。可视化激活编码层中每个神经元的图像。
-
构建一个分类深度神经网络(DNN),重用自编码器的底层。仅使用训练集中的 500 张图像进行训练。是否有预训练的情况下表现更好?
-
-
在你选择的数据集上训练一个变分自编码器,并使用它来生成图像。或者,你可以尝试找到一个你感兴趣的无标签数据集,看看你是否可以生成新的样本。
-
训练一个 DCGAN 来处理你选择的图像数据集,并使用它来生成图像。添加经验回放并看看这是否有帮助。
-
在你喜欢的图像数据集(例如,
torchvision.datasets.Flowers102)上训练一个扩散模型,并生成漂亮的图像。接下来,将图像类别作为额外的输入添加到模型中,并重新训练它:你现在应该能够控制生成图像的类别。
这些练习的解决方案可在本章笔记本的末尾找到,在https://homl.info/colab-p。
^(1) William G. Chase 和 Herbert A. Simon,“国际象棋中的感知”,认知心理学 4, 第 1 期 (1973): 55–81.
^(2) 提示:一种方法是为给定的数据集创建一个自定义的 AutoencoderDataset 类,该类包装给定数据集并将目标替换为输入。
^(3) Yoshua Bengio 等人,“深度网络贪婪层逐层训练”,第 19 届国际神经网络信息处理系统会议论文集 (2006): 153–160.
^(4) Jonathan Masci 等人,“用于分层特征提取的堆叠卷积自编码器”,第 21 届国际人工神经网络会议论文集 1 (2011): 52–59.
^(5) Pascal Vincent 等人, “使用去噪自编码器提取和组合鲁棒特征”, 第 25 届国际机器学习会议论文集 (2008): 1096–1103.
^(6) Pascal Vincent 等人, “堆叠去噪自编码器:使用局部去噪标准在深度网络中学习有用的表示”, 机器学习研究杂志 11 (2010): 3371–3408.
^(7) Diederik Kingma 和 Max Welling, “自动编码器变分贝叶斯”, arXiv 预印本 arXiv:1312.6114 (2013).
^(8) 这两个特性使得 VAEs 与 RBMs 非常相似,但它们更容易训练,采样过程也更快(使用 RBMs,你需要等待网络稳定到“热平衡”状态后才能采样新的实例)。
^(9) Chris J. Maddison 等人, “具体分布:离散随机变量的连续松弛”, arXiv 预印本 arXiv:1611.00712 (2016).
^(10) Eric Jang 等人, “使用 Gumbel-Softmax 进行分类重参数化”, arXiv 预印本 arXiv:1611.01144 (2016).
^(11) Aaron van den Oord 等人, “神经离散表示学习”, arXiv 预印本 arXiv:1711.00937 (2017).
^(12) Ian Goodfellow 等人, “生成对抗网络”, 第 27 届国际神经网络信息处理系统会议论文集 2 (2014): 2672–2680.
^(13) 想要比较主要的 GAN 损失,可以查看 Hwalsuk Lee 的这个优秀的 GitHub 项目。
^(14) Mario Lucic 等人, “GANs 是否平等?一项大规模研究”, 第 32 届国际神经网络信息处理系统会议论文集 (2018): 698–707.
^(15) Jascha Sohl-Dickstein 等人, “使用非平衡热力学进行深度无监督学习”, arXiv 预印本 arXiv:1503.03585 (2015).
^(16) Jonathan Ho 等人, “去噪扩散概率模型” (2020).
^(17) Alex Nichol 和 Prafulla Dhariwal, “改进的去噪扩散概率模型” (2021).
^(18) Olaf Ronneberger 等人, “U-Net:用于生物医学图像分割的卷积网络”, arXiv 预印本 arXiv:1505.04597 (2015).
^(19) Jiaming Song 等人, “去噪扩散隐式模型”, arXiv 预印本 arXiv:2010.02502 (2020).
^(20) Robin Rombach, Andreas Blattmann 等人, “使用潜在扩散模型进行高分辨率图像合成”, arXiv 预印本 arXiv:2112.10752 (2021).
第十九章。强化学习
强化学习(RL)是当今机器学习中最激动人心的领域之一,同时也是最古老的领域之一。它自 20 世纪 50 年代以来一直存在,多年来产生了许多有趣的应用,尤其是在游戏(例如,TD-Gammon,一种国际象棋程序)和机器控制方面,但很少成为头条新闻。然而,在 2013 年发生了一场革命,当时来自一家名为 DeepMind 的英国初创公司的研究人员展示了一个可以从零开始学习几乎任何 Atari 游戏的系统,最终在大多数游戏中超过了人类,仅使用原始像素作为输入,并且没有任何关于游戏规则的先验知识。这是系列惊人壮举中的第一个:
-
在 2016 年,DeepMind 的 AlphaGo 击败了传奇围棋职业选手李世石;在 2017 年,它又击败了世界冠军柯洁。没有任何程序曾接近击败这个游戏的宗师,更不用说最顶尖的选手了。
-
在 2020 年,DeepMind 发布了 AlphaFold,它可以以前所未有的准确性预测蛋白质的 3D 形状。这在生物学、化学和医学上是一个变革性的成果。事实上,Demis Hassabis(创始人兼首席执行官)和 John Jumper(总监)因 AlphaFold 获得了诺贝尔化学奖。
-
在 2022 年,DeepMind 发布了 AlphaCode,它可以生成具有竞技编程水平的代码。
-
在 2023 年,DeepMind 发布了 GNoME,它可以预测新的晶体结构,包括数十万种预测的稳定材料。
那么,DeepMind 的研究人员是如何实现这一切的呢?嗯,他们将深度学习的力量应用于强化学习领域,结果超出了他们的预期:深度强化学习诞生了。今天,尽管 DeepMind 继续引领潮流,但许多其他组织也加入了进来,整个领域充满了新的想法,应用范围广泛。
在本章中,我将首先解释什么是强化学习以及它的优势,然后介绍深度强化学习中最重要的一些技术家族:策略梯度、深度 Q 网络(包括马尔可夫决策过程的讨论),最后是演员-评论家方法,包括流行的 PPO,我们将用它来击败 Atari 游戏。那么,让我们开始吧!
什么是强化学习?
在强化学习中,一个软件代理在环境中做出观察并采取行动,作为回报,它从环境中获得奖励。其目标是学习以最大化其预期奖励的方式行事。如果你不介意有一点拟人化,你可以把正奖励看作是快乐,负奖励看作是痛苦(在这种情况下,“奖励”这个词有点误导)。简而言之,代理在环境中行动,并通过试错来学习最大化快乐和最小化痛苦。
这是一个相当广泛的设置,可以应用于各种任务。以下是一些例子(参见图 19-1):
-
代理可以是控制机器人的程序。在这种情况下,环境是现实世界,代理通过一组传感器(如摄像头和触觉传感器)观察环境,其动作包括发送信号以激活电机。它可以编程为在接近目标目的地时获得正奖励,而在浪费时间或走错方向时获得负奖励。
-
代理可以是控制Ms. Pac-Man的程序。在这种情况下,环境是 Atari 游戏的模拟,动作是九个可能的摇杆位置(左上角、向下、中心等),观察是屏幕截图,奖励只是游戏分数。
-
同样,代理可以是玩围棋等棋类游戏的程序。只有当它获胜时才会获得奖励。
-
代理不必控制物理上(或虚拟上)移动的东西。例如,它可以是智能恒温器,在接近目标温度并节省能源时获得正奖励,当人类需要调整温度时获得负奖励,因此代理必须学会预测人类的需求。
-
代理可以观察股票市场价格,并决定每秒买卖多少。奖励显然是货币的盈亏。
注意,可能根本没有任何正奖励;例如,代理可能在迷宫中移动,每一步都获得负奖励,所以它最好尽快找到出口!强化学习非常适合许多其他任务,例如自动驾驶汽车、推荐系统、在网页上放置广告,或控制图像分类系统应该关注的焦点。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-skl-pt/img/hmls_1901.png
图 19-1. 强化学习示例:(a)机器人,(b)Ms. Pac-Man,(c)围棋选手,(d)恒温器,(e)自动交易员^(6)
现在我们转向强化学习算法的一个大型家族:策略梯度。
策略梯度
软件代理用来确定其行为的算法被称为其策略。策略可以是任何你能想到的算法,例如一个以观察为输入并输出要采取的行动的神经网络(参见图 19-2)。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-skl-pt/img/hmls_1902.png
图 19-2. 使用神经网络策略的强化学习
策略甚至不必是确定性的。实际上,在某些情况下,它甚至不必观察环境,只要它能获得奖励!例如,考虑一个盲目的机器人吸尘器,其奖励是在 30 分钟内收集的灰尘量。其策略可以是每秒以概率p向前移动,或者以概率 1 – p随机左转或右转。旋转角度将是介于 –r 和 +r 之间的随机角度。由于这个策略涉及一些随机性,它被称为随机策略。机器人将会有一个不规则的轨迹,这保证了它最终会到达它能到达的任何地方并收集所有灰尘。问题是,它在 30 分钟内能收集多少灰尘?
你会如何训练这样的机器人?你只能调整两个策略参数:概率p和角度范围r。一个可能的学习算法是尝试这些参数的许多不同值,并选择表现最好的组合(参见图 19-3)。这是一个策略搜索的例子,在这种情况下使用的是穷举法。当策略空间太大时(这通常是情况),以这种方式找到一组好的参数就像在大堆稻草中寻找一根针。
另一种探索策略空间的方法是使用遗传算法。例如,你可以随机创建第一代 100 个策略并尝试它们,然后“淘汰”80 个最差的策略^(7),让 20 个幸存者各自产生 4 个后代。后代是其父母的副本(8)加上一些随机变异。幸存策略及其后代共同构成了第二代。你可以继续这样迭代通过几代,直到找到好的策略。(9)
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-skl-pt/img/hmls_1903.png
图 19-3. 策略空间中的四个点(左)和代理的对应行为(右)
另一种方法是使用优化技术,通过评估奖励相对于策略参数的梯度来调整这些参数,然后沿着梯度方向调整这些参数以获得更高的奖励。遵循这种策略的算法被称为策略梯度(PG)算法。但在我们能够实现它们之前,我们首先需要为智能体创建一个生存的环境——因此,现在是介绍 Gymnasium 库的时候了。
Gymnasium 库简介
强化学习的一个挑战是,为了训练一个智能体,您首先需要一个工作的环境。如果您想编写一个学习玩 Atari 游戏的智能体,您需要一个 Atari 游戏模拟器。如果您想编写一个行走机器人,那么环境就是现实世界,您可以直接在那个环境中训练您的机器人。然而,这有其局限性:如果机器人从悬崖上掉下来,您不能只是点击撤销。您也不能加快时间——增加更多的计算能力不会让机器人移动得更快——并且并行训练 1000 个机器人通常太昂贵了。简而言之,在现实世界中训练既困难又缓慢,因此您至少需要一个模拟环境来进行启动训练。例如,您可能使用像PyBullet或MuJoCo这样的库来进行 3D 物理模拟。
体育馆图书馆是一个开源工具包,它提供了各种模拟环境(如 Atari 游戏、棋盘游戏、2D 和 3D 物理模拟等),您可以使用这些环境来训练智能体、比较它们或开发新的强化学习算法。它是 OpenAI Gym 的继任者,现在由一群研究人员和开发者维护。
Gymnasium 在 Colab 上预先安装,包括 Arcade Learning Environment(ALE)库ale_py,这是一个 Atari 2600 游戏的模拟器,对于所有 Atari 环境都是必需的,以及 Box2D 库,它是用于几个具有 2D 物理的环境。如果您在自己的机器上编码而不是在 Colab 上,并且您遵循了https://homl.info/install-p上的安装说明,那么您应该可以开始了。
让我们从导入 Gymnasium 并创建一个环境开始:
import gymnasium as gym
env = gym.make("CartPole-v1", render_mode="rgb_array", max_episode_steps=1000)
在这里,我们创建了一个 CartPole 环境(版本 1)。这是一个 2D 模拟,其中一辆小车可以加速向左或向右,以平衡放在它顶部的一根杆子(参见图 19-4)——这是一个经典的控制任务。我很快会解释render_mode和max_episode_steps。
小贴士
gym.envs.registry字典包含了所有可用环境的名称和规范。您可以使用gym.pprint_registry()打印出一个漂亮的列表。Atari 环境将只有在启动 ALE 模拟器后才能使用。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-skl-pt/img/hmls_1904.png
图 19-4. 小车杆环境
创建环境后,您必须使用 reset() 方法对其进行初始化,可选地指定一个随机种子。这将返回第一个观察结果。观察结果取决于环境类型。对于 CartPole 环境,每个观察结果都是一个包含四个浮点数的 NumPy 数组,分别表示小车的水平位置(0.0 = 中心)、速度(正值表示向右)、杆的角度(0.0 = 垂直)和角速度(正值表示顺时针)。reset() 方法还返回一个可能包含额外环境特定信息的字典。这可以用于调试,有时也用于训练。例如,在许多 Atari 环境中,它包含剩余的生命数。然而,在 CartPole 环境中,此字典为空:
>>> obs, info = env.reset(seed=42)
>>> obs
array([ 0.0273956 , -0.00611216, 0.03585979, 0.0197368 ], dtype=float32)
>>> info
{}
让我们调用 render() 方法将此环境渲染为图像。由于我们在创建环境时设置了 render_mode="rgb_array",因此图像将以 NumPy 数组的形式返回(然后您可以使用 Matplotlib 的 imshow() 函数来显示此图像):
>>> img = env.render()
>>> img.shape # height, width, channels (3 = Red, Green, Blue)
(400, 600, 3)
现在,让我们询问环境可能有哪些动作:
>>> env.action_space
Discrete(2)
Discrete(2) 表示可能的动作是整数 0 和 1,分别代表向左或向右加速。其他环境可能有额外的离散动作,或其他类型的动作(例如连续动作)。由于杆向右倾斜 (obs[2] > 0),让我们加速小车向右:
>>> action = 1 # accelerate right
>>> obs, reward, done, truncated, info = env.step(action)
>>> obs
array([ 0.02727336, 0.18847767, 0.03625453, -0.26141977], dtype=float32)
>>> reward, done, truncated, info
(1.0, False, False, {})
step() 方法执行所需的动作并返回五个值:
obs
这是一条新的观察。小车现在正向右移动 (obs[1] > 0)。杆仍然向右倾斜 (obs[2] > 0),但其角速度现在是负的 (obs[3] < 0),因此它很可能会在下一步后向左倾斜。
reward
在这个环境中,无论您做什么,每一步都会获得 1.0 的奖励,因此目标是尽可能长时间地保持剧集运行。一个 剧集 是环境的一次运行,直到游戏结束或中断。
done
当剧集结束时,此值将为 True。这将在杆倾斜过多或超出屏幕时发生。之后,必须重置环境才能再次使用。
truncated
当一个剧集提前中断时,此值将为 True,通常是由施加每个剧集最大步数的环境包装器引起的(有关环境包装器的更多详细信息,请参阅 Gymnasium 的文档)。默认情况下,CartPole 的环境规范将最大步数设置为 500,但我们在创建环境时将其更改为 1000。一些强化学习算法将截断剧集与正常完成的剧集(即 done 为 True)区别对待,但在本章中我们将它们视为相同。
info
这个特定环境的字典可能提供额外信息,就像reset()方法返回的信息一样。
小贴士
一旦你完成了一个环境的使用——可能是在许多回合之后——你应该调用它的close()方法来释放资源。
让我们硬编码一个简单的策略,当杆子向左倾斜时加速向左,当杆子向右倾斜时加速向右。我们将运行这个策略,看看它在 500 个回合中获得的平均奖励:
def basic_policy(obs):
angle = obs[2]
return 0 if angle < 0 else 1 # go left if leaning left, otherwise go right
totals = []
for episode in range(500):
total_rewards = 0
obs, info = env.reset(seed=episode)
while True: # no risk of infinite loop: will be truncated after 1000 steps
action = basic_policy(obs)
obs, reward, done, truncated, info = env.step(action)
total_rewards += reward
if done or truncated:
break
totals.append(total_rewards)
这段代码是自我解释的。让我们看看结果:
>>> import numpy as np
>>> np.mean(totals), np.std(totals), min(totals), max(totals)
(np.float64(41.698), np.float64(8.389445512070509), 24.0, 63.0)
即使尝试了 500 次,这项政策也从未能将杆子保持直立超过 63 个连续步骤。这并不理想。如果你查看本章笔记本中的模拟,你会看到小车左右摆动越来越剧烈,直到杆子倾斜过度。神经网络可以做得更好!
神经网络策略
让我们创建一个神经网络策略。这个神经网络将观察作为输入,并将输出要执行的操作,就像我们之前硬编码的策略一样。更确切地说,它将为每个操作估计一个概率,然后根据估计的概率随机选择一个操作(参见图 19-5)。在 CartPole 环境中,只有两个可能的操作(左或右),所以我们只需要一个输出神经元。它将输出操作 1(右)的概率p,当然操作 0(左)的概率将是 1 – p。例如,如果它输出 0.7,那么我们将以 70%的概率选择操作 1,或者以 30%的概率选择操作 0(这是一个p = 0.7 的伯努利分布)。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-skl-pt/img/hmls_1905.png
图 19-5. 神经网络策略
你可能会想知道,为什么我们根据神经网络给出的概率选择随机动作,而不是只选择得分最高的动作。这种方法让智能体在探索新动作和利用已知效果良好的动作之间找到正确的平衡。这里有一个类比:假设你第一次去餐厅,所有的菜看起来都同样吸引人,所以你随机选择一个。如果它结果很好,你可以增加下次点这个菜的概率,但你不应该将这个概率增加到 100%,否则你将永远无法尝试其他菜肴,其中一些可能比你所尝试的更好。这种探索/利用的困境是强化学习中的核心问题。
还要注意,在这个特定的环境中,过去的行为和观察可以安全地忽略,因为每个观察都包含了环境的完整状态。如果有某些隐藏状态,那么你可能需要考虑过去的行为和观察。例如,如果环境只揭示了滑车的位置但没有其速度,那么你必须考虑当前观察以及之前的观察,以便估计当前速度。另一个例子是当观察有噪声时;在这种情况下,你通常想使用过去几项观察来估计最可能的状态。因此,CartPole 问题是最简单的;观察是无噪声的,并且它们包含了环境的完整状态。
让我们使用 PyTorch 来实现一个基本的神经网路策略,用于 CartPole:
import torch
import torch.nn as nn
class PolicyNetwork(nn.Module):
def __init__(self):
super().__init__()
self.net = nn.Sequential(nn.Linear(4, 5), nn.ReLU(), nn.Linear(5, 1))
def forward(self, state):
return self.net(state)
我们的政策网络是一个小型 MLP,因为这个任务相对简单。输入的数量是环境状态的尺寸:在 CartPole 的情况下,它只是单个观察的尺寸,即四个。我们只有一个包含五个单元的隐藏层(在这种情况下不需要更多)。最后,我们希望输出一个概率,所以我们有一个输出神经元。如果有超过两个可能的行为,那么每个行为将有一个输出神经元。为了性能和数值稳定性,我们不在最后添加 sigmoid 函数,因此网络实际上会输出 logits 而不是概率。
接下来让我们定义一个函数,它将使用这个策略网络来选择一个动作:
def choose_action(model, obs):
state = torch.as_tensor(obs)
logit = model(state)
dist = torch.distributions.Bernoulli(logits=logit)
action = dist.sample()
log_prob = dist.log_prob(action)
return int(action.item()), log_prob
该函数接受单个观察,将其转换为张量,并将其传递给策略网络以获取动作 1(向右)的 logit。然后它使用这个 logit 创建一个Bernoulli概率分布,并从中采样一个动作:这个分布将以概率 p = exp(logit) / (1 + exp(logit)) 输出 1(向右),以概率 1 – p 输出 0(向左)。如果有超过两个可能的行为,你将使用一个Categorical分布代替。最后,我们计算采样动作的对数概率(即 log(p)或 log(1 – p)):这个对数概率将在训练时被需要。
小贴士
如果动作空间是连续的,你可以使用高斯分布而不是 Bernoulli 或 Categorical 分布。策略网络必须预测分布的均值和标准差(或标准差的对数)。标准差的对数通常会被截断,以确保分布既不太宽也不太窄。
好的,我们现在有一个神经网络策略,它可以接受环境状态(在这种情况下,单个观察)并选择一个动作。但我们是怎样训练它的呢?
评估动作:信用分配问题
如果我们知道在每一步的最佳动作是什么,我们可以通过最小化估计概率分布与目标概率分布之间的交叉熵来像往常一样训练神经网络。这将是常规的监督学习。然而,在强化学习中,智能体得到的唯一指导是通过奖励,而奖励通常是稀疏和延迟的。例如,如果一个智能体成功平衡杆 100 步,它如何知道它所采取的 100 个动作中哪些是好的,哪些是坏的?它所知道的就是在最后一步后杆子倒了,但显然这一步并不完全负责。这被称为信用分配问题:当智能体获得奖励(或惩罚)时,它很难知道哪些动作应该得到认可(或责备)。想象一下一只狗在表现良好数小时后得到奖励;它会明白自己为什么被奖励吗?
为了简化信用分配,一个常见的策略是评估一个动作基于其之后所有奖励的总和,并在每一步应用一个折现因子,_γ*(伽马)。这个折现奖励的总和被称为动作的回报。考虑图 19-6 中的例子。如果一个智能体连续三次选择向右移动,并在第一步后获得+10 的奖励,第二步后获得 0,最后在第三步后获得-50,那么如果我们使用折现因子 γ = 0.8,第一个动作的回报将是 10 + γ × 0 + γ² × (–50) = –22。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-skl-pt/img/hmls_1906.png
图 19-6. 计算动作的回报:折现未来奖励的总和
以下函数计算回报,给定奖励和折现因子:
def compute_returns(rewards, discount_factor):
returns = rewards[:] # copy the rewards
for step in range(len(returns) - 1, 0, -1):
returns[step - 1] += returns[step] * discount_factor
return torch.tensor(returns)
这个函数产生预期的结果:
>>> compute_returns([10, 0, -50], discount_factor=0.8)
tensor([-22., -40., -50.])
如果折现因子接近 0,那么与即时奖励相比,未来的奖励不会太多。相反,如果折现因子接近 1,那么远期奖励将几乎与即时奖励一样重要。典型的折现因子范围从 0.9 到 0.99。以 0.95 的折现因子为例,13 步后的奖励大约相当于即时奖励的一半(因为 0.95¹³ ≈ 0.5),而以 0.99 的折现因子为例,69 步后的奖励相当于即时奖励的一半。在 CartPole 环境中,动作有相当短期的效果,因此选择一个较低的折现因子 0.95 似乎是合理的,并且它将有助于信用分配,使训练更快更稳定。然而,如果折现因子设置得太低,那么智能体将学会次优策略,过分关注短期收益。
现在我们有了评估每个动作的方法,我们就可以使用策略梯度来训练我们的第一个智能体了。让我们看看怎么做。
使用策略梯度解决 CartPole 问题
如前所述,策略梯度算法通过跟随梯度指向更高的奖励来优化策略的参数。一个流行的 PG 算法,称为REINFORCE(或蒙特卡洛 PG),是由 Ronald Williams 在 1992 年首次提出。^(11) 它有许多变体,各种调整,但基本原则是这样的:
-
首先,让神经网络策略玩一个游戏,并记录奖励和估计的对数概率。
-
然后使用上一节中定义的函数计算每个动作的回报。
-
如果一个动作的回报是正的,这意味着这个动作可能是好的,你希望这个动作在未来更有可能被选择。相反,如果一个动作的回报是负的,你希望这个动作更少可能。为了实现这一点,你可以最小化方程 19-1 中定义的 REINFORCE 损失:这将最大化期望的折现回报。
方程 19-1. REINFORCE 损失
ℒ ( θ ) = - ∑ t log π θ ( a t | s t ) · r t
在这个方程中,πθ是策略网络的估计概率,表示在状态s[t](其中t是时间步)下动作a[t]的概率,r[t]是观察到的这个动作的回报;θ代表模型参数。
让我们使用 PyTorch 来实现这个算法。首先,我们需要一个函数来让策略网络玩一个游戏,并记录奖励和对数概率:
def run_episode(model, env, seed=None):
log_probs, rewards = [], []
obs, info = env.reset(seed=seed)
while True: # the environment will truncate the episode if it is too long
action, log_prob = choose_action(model, obs)
obs, reward, done, truncated, _info = env.step(action)
log_probs.append(log_prob)
rewards.append(reward)
if done or truncated:
return log_probs, rewards
该函数首先将环境重置以开始一个新的游戏。为了可重复性,我们向reset()方法传递一个种子。然后是游戏循环:在每次迭代中,我们将当前环境状态(即最后的观察)传递给之前定义的choose_action()方法。它返回所选动作及其对数概率。然后我们调用环境的step()方法来执行动作。这返回一个新的观察(NumPy 数组)、一个奖励、两个布尔值表示游戏是否结束或截断,以及一个 info 字典(在 CartPole 的情况下我们可以安全地忽略它)。我们记录对数概率和奖励在两个列表中,当游戏结束时返回这些列表。
我们最终可以编写训练函数:
def train_reinforce(model, optimizer, env, n_episodes, discount_factor):
for episode in range(n_episodes):
seed = torch.randint(0, 2**32, size=()).item()
log_probs, rewards = run_episode(model, env, seed=seed)
returns = compute_returns(rewards, discount_factor)
std_returns = (returns - returns.mean()) / (returns.std() + 1e-7)
losses = [-logp * rt for logp, rt in zip(log_probs, std_returns)]
loss = torch.cat(losses).sum()
optimizer.zero_grad()
loss.backward()
optimizer.step()
print(f"\rEpisode {episode + 1}, Reward: {sum(rewards):.2f}", end=" ")
这很好,也很简洁,不是吗?在每次训练迭代中,函数运行一个回合并获取对数概率和奖励。^(12) 然后,它计算每个动作的回报。接下来,它标准化回报(即,它减去平均回报并除以标准差,再加上一个小的值以避免除以零)。这个标准化步骤是可选的,但它是 REINFORCE 算法的一个常见且推荐的调整,因为它可以稳定训练。接下来,函数使用方程式 19-1 计算 REINFORCE 损失,并执行优化器步骤以最小化损失。
就这样,我们已经准备好构建和训练策略网络了!
torch.manual_seed(42)
model = PolicyNetwork()
optimizer = torch.optim.NAdam(model.parameters(), lr=0.06)
train_reinforce(model, optimizer, env, n_episodes=200, discount_factor=0.95)
训练将不到一分钟。如果你使用这个策略网络运行一个回合,你会看到它完美地平衡了杆。成功!
我们刚刚训练的简单策略梯度算法解决了 CartPole 任务,但它不会很好地扩展到更大和更复杂的任务。事实上,它非常样本低效,这意味着它需要探索游戏很长时间才能取得显著的进步。这是因为它的回报估计非常嘈杂,尤其是在好的动作和坏的动作混合在一起时。然而,它是更强大算法的基础,例如演员-评论家算法(我们将在本章末尾讨论)。
小贴士
研究者们试图寻找即使在智能体最初对环境一无所知的情况下也能良好工作的算法。然而,除非你正在撰写论文,否则你不应该犹豫将先验知识注入智能体中,因为这会显著加快训练速度。例如,既然你知道杆应该尽可能垂直,你可以添加与杆角度成比例的负奖励。这将使奖励更加稀疏,并加快训练速度。此外,如果你已经有一个相当不错的策略(例如,硬编码),在使用策略梯度改进它之前,你可能想要训练神经网络来模仿它。
此外,REINFORCE 算法相当不稳定:智能体在训练过程中可能会进步一段时间,然后突然忘记一切,再次学习,忘记,学习,等等。这是一段过山车般的经历。这在很大程度上是因为训练样本不是独立同分布的(IID);事实上,训练样本包括智能体现在能够达到的任何状态。随着智能体的进步,它会探索环境的不同部分,并且它可能会忘记其他部分的一切。例如,一旦它学会了正确地保持杆竖直,它就再也不会看到非垂直的杆,并且它将完全忘记如何处理它们。而且这个问题在更复杂的环境中会变得更加严重。
注意
强化学习因其训练的不稳定性和对超参数值和随机种子选择的巨大敏感性而闻名,这是一个非常困难的领域。^(13) 如研究者安德烈·卡帕西所说,“[监督学习]希望工作。[……] RL 必须被迫工作”。你需要时间、耐心、毅力,也许还需要一点运气。这也是强化学习不像常规深度学习那样被广泛采用的主要原因。
我们现在将探讨另一组流行的算法:基于价值的算法。
基于价值的算法
与 PG 算法直接尝试优化策略以增加奖励不同,基于价值的算法更为间接:智能体学习估计每个状态的价值(即预期的回报),或者给定状态下每个动作的价值,然后它使用这些知识来决定如何行动。为了理解这些算法,我们首先必须讨论马尔可夫决策过程(MDP)。
马尔可夫决策过程
在 20 世纪初,数学家安德烈·马尔可夫研究了没有记忆的随机过程,称为马尔可夫链。这样的过程具有固定数量的状态,并且它在每一步随机地从一种状态演变到另一种状态。从状态 s 到状态 s′ 的演变概率是固定的,并且它只依赖于对 (s, s′),而不依赖于过去的状态。这就是为什么我们说系统没有记忆。
图 19-7 展示了一个具有四个状态的马尔可夫链的示例。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-skl-pt/img/hmls_1907.png
图 19-7. 马尔可夫链的示例
假设过程从状态 s[0] 开始,有 70%的几率在下一步仍然处于该状态。最终它必然会离开该状态并且永远不再回来,因为没有其他状态指向 s[0]。如果它进入状态 s[1],那么它接下来最有可能进入状态 s[2](90%的概率),然后立即回到状态 s[1](100%的概率)。它可能在这两个状态之间交替多次,但最终它会陷入状态 s[3] 并永远停留在这里,因为没有出路:这被称为终止状态。马尔可夫链可以具有非常不同的动态,并且它们在热力学、化学、统计学等领域被广泛使用。
马尔可夫决策过程最早在 20 世纪 50 年代由理查德·贝尔曼描述。^(14) 它们类似于马尔可夫链,但有一个转折点:在每一步,一个智能体可以选择几种可能的行为之一,并且转移概率取决于所选择的行为。此外,某些状态转移会返回一些奖励(正面或负面),智能体的目标是找到一个策略,使其在时间上的累积奖励最大化。
例如,图 19-8 所示的 MDP 有三个状态(用圆圈表示)和每个步骤最多三个可能的离散行动(用菱形表示)。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-skl-pt/img/hmls_1908.png
图 19-8. 马尔可夫决策过程的示例
如果它从状态 s[0] 开始,代理可以选择行动 a[0]、a[1] 或 a[2]。如果它选择行动 a[1],它将肯定地停留在状态 s[0] 而没有任何奖励。因此,如果它想的话,它可以永远留在那里。但是,如果它选择行动 a[0],它有 70%的概率获得+10 的奖励并停留在状态 s[0]。然后它可以一次又一次地尝试以获得尽可能多的奖励,但最终它可能会结束在状态 s[1]。在状态 s[1] 中,它只有两种可能的行为:a[0] 或 a[2]。它可以反复选择行动 a[0] 以保持原位,或者它可以选择移动到状态 s[2] 并获得-50 的负面奖励(ouch)。在状态 s[2] 中,它别无选择,只能采取行动 a[1],这很可能会将它带回到状态 s[0],并在路上获得+40 的奖励。你明白这个意思了。通过观察这个 MDP,你能猜出哪种策略在长时间内能获得最多的奖励吗?在状态 s[0] 中,显然行动 a[0] 是最佳选择,在状态 s[2] 中,代理别无选择,只能采取行动 a[1],但在状态 s[1] 中,并不明显代理应该留在原位 (a[0]) 还是穿过火海 (a[2])。
贝尔曼找到了一种方法来估计任何状态 s 的 最优状态值,表示为 V*(s),这是代理从状态 s 开始,平均期望获得的折扣未来奖励的总和,假设它采取最优行动。他证明了如果代理采取最优行动,那么 贝尔曼最优方程 适用(见 方程 19-2)。这个递归方程表明,如果代理采取最优行动,那么当前状态的最优值等于采取一个最优行动后平均获得的奖励,加上所有可能后续状态期望的最优值的总和。
方程 19-2. 贝尔曼最优方程
u p p e r V S u p e r s c r i p t a s t e r i s k B a s e l i n e l e f t − p a r e n t h e s i s s r i g h t − p a r e n t h e s i s e q u a l s m a x U n d e r s c r i p t a E n d s c r i p t s s i g m a − s u m m a t i o n U n d e r s c r i p t s S u p e r s c r i p t p r i m e B a s e l i n e E n d s c r i p t s u p p e r T l e f t − p a r e n t h e s i s s c o m m a a c o m m a s S u p e r s c r i p t p r i m e B a s e l i n e r i g h t − p a r e n t h e s i s l e f t − b r a c k e t u p p e r R l e f t − p a r e n t h e s i s s c o m m a a c o m m a s S u p e r s c r i p t p r i m e B a s e l i n e r i g h t − p a r e n t h e s i s p l u s g a m m a d o t u p p e r V S u p e r s c r i p t a s t e r i s k B a s e l i n e l e f t − p a r e n t h e s i s s S u p e r s c r i p t p r i m e B a s e l i n e r i g h t − p a r e n t h e s i s r i g h t − b r a c k e t f o r a l l s upper V Superscript asterisk Baseline left-parenthesis s right-parenthesis equals max Underscript a Endscripts sigma-summation Underscript s Superscript prime Baseline Endscripts upper T left-parenthesis s comma a comma s Superscript prime Baseline right-parenthesis left-bracket upper R left-parenthesis s comma a comma s Superscript prime Baseline right-parenthesis plus gamma dot upper V Superscript asterisk Baseline left-parenthesis s Superscript prime Baseline right-parenthesis right-bracket for all s upperVSuperscriptasteriskBaselineleft−parenthesissright−parenthesisequalsmaxUnderscriptaEndscriptssigma−summationUnderscriptsSuperscriptprimeBaselineEndscriptsupperTleft−parenthesisscommaacommasSuperscriptprimeBaselineright−parenthesisleft−bracketupperRleft−parenthesisscommaacommasSuperscriptprimeBaselineright−parenthesisplusgammadotupperVSuperscriptasteriskBaselineleft−parenthesissSuperscriptprimeBaselineright−parenthesisright−bracketforalls
在这个方程中:
-
T(s, a, s′) 是在智能体选择了动作 a 的情况下,从状态 s 到状态 s′ 的转移概率。例如,在图 19-8 中,T(s[2], a[1], s[0]) = 0.8。注意, s i g m a − s u m m a t i o n U n d e r s c r i p t s S u p e r s c r i p t p r i m e B a s e l i n e E n d s c r i p t s u p p e r T l e f t − p a r e n t h e s i s s c o m m a a c o m m a s S u p e r s c r i p t p r i m e B a s e l i n e r i g h t − p a r e n t h e s i s e q u a l s 1 sigma-summation Underscript s Superscript prime Baseline Endscripts upper T left-parenthesis s comma a comma s Superscript prime Baseline right-parenthesis equals 1 sigma−summationUnderscriptsSuperscriptprimeBaselineEndscriptsupperTleft−parenthesisscommaacommasSuperscriptprimeBaselineright−parenthesisequals1 。
-
R(s, a, s′) 是当智能体从状态 s 转移到状态 s′ 时获得的奖励,前提是智能体选择了动作 a。例如,在图 19-8 中,R(s[2], a[1], s[0]) = +40。
-
γ 是折扣因子。
注意
在贝尔曼方程和本章的其余部分中,最优策略是指最大化期望的折扣未来奖励总和的策略:这意味着它依赖于折扣因子 γ。然而,在现实世界的任务中,我们通常对每轮期望的奖励总和更感兴趣,没有任何折扣(实际上,我们通常就是这样评估智能体的)。为了达到这个目标,我们通常选择一个接近 1 的折扣因子(但不要太接近,否则训练会变得缓慢且不稳定)。
这个方程直接导出了一个可以精确估计每个可能状态的最优状态值的算法:首先将所有状态值估计初始化为零,然后使用值迭代算法(见方程 19-3)迭代更新它们。一个显著的结果是,给定足够的时间,这些估计将保证收敛到最优状态值,对应于最优策略。
方程 19-3. 值迭代算法
u p p e r V S u b s c r i p t k p l u s 1 B a s e l i n e l e f t − p a r e n t h e s i s s r i g h t − p a r e n t h e s i s l e f t − a r r o w m a x U n d e r s c r i p t a E n d s c r i p t s s i g m a − s u m m a t i o n U n d e r s c r i p t s S u p e r s c r i p t p r i m e B a s e l i n e E n d s c r i p t s u p p e r T l e f t − p a r e n t h e s i s s c o m m a a c o m m a s S u p e r s c r i p t p r i m e B a s e l i n e r i g h t − p a r e n t h e s i s l e f t − b r a c k e t u p p e r R l e f t − p a r e n t h e s i s s c o m m a a c o m m a s S u p e r s c r i p t p r i m e B a s e l i n e r i g h t − p a r e n t h e s i s p l u s g a m m a d o t u p p e r V S u b s c r i p t k B a s e l i n e l e f t − p a r e n t h e s i s s S u p e r s c r i p t p r i m e B a s e l i n e r i g h t − p a r e n t h e s i s r i g h t − b r a c k e t f o r a l l s upper V Subscript k plus 1 Baseline left-parenthesis s right-parenthesis left-arrow max Underscript a Endscripts sigma-summation Underscript s Superscript prime Baseline Endscripts upper T left-parenthesis s comma a comma s Superscript prime Baseline right-parenthesis left-bracket upper R left-parenthesis s comma a comma s Superscript prime Baseline right-parenthesis plus gamma dot upper V Subscript k Baseline left-parenthesis s Superscript prime Baseline right-parenthesis right-bracket for all s upperVSubscriptkplus1Baselineleft−parenthesissright−parenthesisleft−arrowmaxUnderscriptaEndscriptssigma−summationUnderscriptsSuperscriptprimeBaselineEndscriptsupperTleft−parenthesisscommaacommasSuperscriptprimeBaselineright−parenthesisleft−bracketupperRleft−parenthesisscommaacommasSuperscriptprimeBaselineright−parenthesisplusgammadotupperVSubscriptkBaselineleft−parenthesissSuperscriptprimeBaselineright−parenthesisright−bracketforalls
在这个方程中,V**k 是算法第 k 次迭代时状态 s 的估计值。
注意
这个算法是动态规划的一个例子,它将复杂问题分解为可处理的子问题,这些子问题可以迭代解决。
了解最优状态值可能很有用,特别是为了评估策略,但它并不给我们提供智能体的最优策略。幸运的是,贝尔曼找到了一个非常相似的算法来估计最优的状态-动作值,通常称为Q 值(质量值)。状态-动作对 (s, a) 的最优 Q 值,表示为 Q*(s, a),是智能体从状态 s 出发,如果选择动作 a,在看到该动作的结果之前,平均期望的折扣未来奖励的总和,假设它在该动作之后采取最优行动。
让我们看看它是如何工作的。再次,你首先将所有 Q 值估计初始化为零,然后使用 Q 值迭代 算法(见 方程式 19-4)来更新它们。
方程式 19-4. Q 值迭代算法
u p p e r Q S u b s c r i p t k p l u s 1 B a s e l i n e l e f t − p a r e n t h e s i s s c o m m a a r i g h t − p a r e n t h e s i s l e f t − a r r o w s i g m a − s u m m a t i o n U n d e r s c r i p t s S u p e r s c r i p t p r i m e B a s e l i n e E n d s c r i p t s u p p e r T l e f t − p a r e n t h e s i s s c o m m a a c o m m a s S u p e r s c r i p t p r i m e B a s e l i n e r i g h t − p a r e n t h e s i s l e f t − b r a c k e t u p p e r R l e f t − p a r e n t h e s i s s c o m m a a c o m m a s S u p e r s c r i p t p r i m e B a s e l i n e r i g h t − p a r e n t h e s i s p l u s g a m m a d o t m a x U n d e r s c r i p t a S u p e r s c r i p t p r i m e B a s e l i n e E n d s c r i p t s u p p e r Q S u b s c r i p t k B a s e l i n e l e f t − p a r e n t h e s i s s p r i m e c o m m a a S u p e r s c r i p t p r i m e B a s e l i n e r i g h t − p a r e n t h e s i s r i g h t − b r a c k e t f o r a l l l e f t − p a r e n t h e s i s s c o m m a a r i g h t − p a r e n t h e s i s upper Q Subscript k plus 1 Baseline left-parenthesis s comma a right-parenthesis left-arrow sigma-summation Underscript s Superscript prime Baseline Endscripts upper T left-parenthesis s comma a comma s Superscript prime Baseline right-parenthesis left-bracket upper R left-parenthesis s comma a comma s Superscript prime Baseline right-parenthesis plus gamma dot max Underscript a Superscript prime Baseline Endscripts upper Q Subscript k Baseline left-parenthesis s prime comma a Superscript prime Baseline right-parenthesis right-bracket for all left-parenthesis s comma a right-parenthesis upperQSubscriptkplus1Baselineleft−parenthesisscommaaright−parenthesisleft−arrowsigma−summationUnderscriptsSuperscriptprimeBaselineEndscriptsupperTleft−parenthesisscommaacommasSuperscriptprimeBaselineright−parenthesisleft−bracketupperRleft−parenthesisscommaacommasSuperscriptprimeBaselineright−parenthesisplusgammadotmaxUnderscriptaSuperscriptprimeBaselineEndscriptsupperQSubscriptkBaselineleft−parenthesissprimecommaaSuperscriptprimeBaselineright−parenthesisright−bracketforallleft−parenthesisscommaaright−parenthesis
一旦你有了最优 Q 值,定义最优策略,记为 π^*(s), 就变得简单了:当智能体处于状态 s 时,它应该选择该状态具有最高 Q 值的动作。这个复杂的数学符号表示为 p i S u p e r s c r i p t a s t e r i s k B a s e l i n e l e f t − p a r e n t h e s i s s r i g h t − p a r e n t h e s i s e q u a l s a r g m a x U n d e r s c r i p t a E n d s c r i p t s u p p e r Q S u p e r s c r i p t a s t e r i s k B a s e l i n e l e f t − p a r e n t h e s i s s c o m m a a r i g h t − p a r e n t h e s i s pi Superscript asterisk Baseline left-parenthesis s right-parenthesis equals argmax Underscript a Endscripts upper Q Superscript asterisk Baseline left-parenthesis s comma a right-parenthesis piSuperscriptasteriskBaselineleft−parenthesissright−parenthesisequalsargmaxUnderscriptaEndscriptsupperQSuperscriptasteriskBaselineleft−parenthesisscommaaright−parenthesis .
让我们将此算法应用于 图 19-8 中表示的 MDP。首先,我们需要定义 MDP:
transition_probabilities = [ # shape=[s, a, s']
[[0.7, 0.3, 0.0], [1.0, 0.0, 0.0], [0.8, 0.2, 0.0]],
[[0.0, 1.0, 0.0], None, [0.0, 0.0, 1.0]],
[None, [0.8, 0.1, 0.1], None]
]
rewards = [ # shape=[s, a, s']
[[+10, 0, 0], [0, 0, 0], [0, 0, 0]],
[[0, 0, 0], [0, 0, 0], [0, 0, -50]],
[[0, 0, 0], [+40, 0, 0], [0, 0, 0]]
]
possible_actions = [[0, 1, 2], [0, 2], [1]]
例如,要知道在执行动作 a[1] 后从 s[2] 转移到 s[0] 的转移概率,我们将查找 transition_probabilities[2][1][0](这是 0.8)。同样,为了得到相应的奖励,我们将查找 rewards[2][1][0](这是 +40)。为了得到 s[2] 中可能动作的列表,我们将查找 possible_actions[2](在这种情况下,只有动作 a[1] 是可能的)。接下来,我们必须将所有 Q 值初始化为零(除了不可能的动作,我们将 Q 值设置为 –∞):
Q_values = np.full((3, 3), -np.inf) # -np.inf for impossible actions
for state, actions in enumerate(possible_actions):
Q_values[state, actions] = 0.0 # for all possible actions
现在我们来运行 Q 值迭代算法。它反复应用 方程式 19-4,针对所有 Q 值,对每个状态和每个可能动作进行迭代:
gamma = 0.90 # the discount factor
for iteration in range(50):
Q_prev = Q_values.copy()
for s in range(3):
for a in possible_actions[s]:
Q_values[s, a] = np.sum([
transition_probabilities[s][a][sp]
* (rewards[s][a][sp] + gamma * Q_prev[sp].max())
for sp in range(3)])
就这样!得到的 Q 值看起来是这样的:
>>> Q_values
array([[18.91891892, 17.02702702, 13.62162162],
[ 0\. , -inf, -4.87971488],
[ -inf, 50.13365013, -inf]])
例如,当智能体处于状态 s[0] 并选择动作 a[1] 时,期望的折现未来奖励总和大约为 17.0。
对于每个状态,我们可以找到具有最高 Q 值的动作:
>>> Q_values.argmax(axis=1) # optimal action for each state
array([0, 0, 1])
这给出了使用折现因子 0.90 的 MDP 的最优策略:在状态 s[0] 选择动作 a[0],在状态 s[1] 选择动作 a[0](即保持原位),在状态 s[2] 选择动作 a[1](唯一可能动作)。有趣的是,如果我们把折现因子提高到 0.95,最优策略就会改变:在状态 s[1] 最好的动作变为 a[2](穿过火焰!)。这很有道理,因为你对未来奖励的重视程度越高,你现在就越愿意为了未来的幸福忍受一些痛苦。
时间差分学习
具有离散动作的强化学习问题通常可以建模为马尔可夫决策过程,但代理最初并不知道状态转移概率是什么(它不知道 T(s, a, s′)),也不知道奖励将会是什么(它不知道 R(s, a, s′))。它必须至少体验每个状态和每个转移一次才能知道奖励,如果它想要对转移概率有一个合理的估计,它必须多次体验它们。
时间差分 (TD) 学习 算法与 Q 值迭代算法非常相似,但经过调整以考虑代理对 MDP 只有部分知识的事实。一般来说,我们假设代理最初只知道可能的状态和动作,没有更多。代理使用 探索策略——例如,一个完全随机的策略——来探索 MDP,随着它的进展,TD 学习算法根据实际观察到的转移和奖励来更新状态值的估计(参见 方程 19-5)。
方程 19-5. TD 学习算法
S t a r t L a y o u t 1 s t R o w 1 s t C o l u m n 上标 k 加 1 基线左括号 s 右括号 2 n d C o l u m n 左箭头左括号 1 减去 a l p h a 右括号上标 k 基线左括号 s 右括号加上 a l p h a 左括号 r 加 g a m m a 点上标 k 基线左括号 s ′ 右括号右括号 2 n d R o w 1 s t C o l u m n 或逗号 2 n d C o l u m n 相当于冒号 3 r d R o w 1 s t C o l u m n 上标 k 加 1 基线左括号 s 右括号 2 n d C o l u m n 左箭头上标 k 基线左括号 s 右括号加上 a l p h a 点 d e l t a 上标 k 基线左括号 s , r , s 上标 p r i m e 基线右括号 4 t h R o w 1 s t C o l u m n 与 2 n d C o l u m n d e l t a 上标 k 基线左括号 s , r , s 上标 p r i m e 基线右括号等于 r 加 g a m m a 点上标 k 基线左括号 s ′ 右括号减去上标 k 基线左括号 s 右括号 E n d L a y o u t StartLayout 1st Row 1st Column 上标 k 加 1 基线 左括号 s 右括号 2nd Column 左箭头 左括号 1 减去 alpha 右括号 上标 k 基线 左括号 s 右括号 加上 alpha 左括号 r 加 gamma 点 上标 k 基线 左括号 s' 右括号 右括号 2nd Row 1st Column 或逗号 2nd Column 相当于冒号 3rd Row 1st Column 上标 k 加 1 基线 左括号 s 右括号 2nd Column 左箭头 上标 k 基线 左括号 s 右括号 加上 alpha 点 delta 上标 k 基线 左括号 s ,r ,s 上标 prime 基线 右括号 4th Row 1st Column 与 2nd Column delta 上标 k 基线 左括号 s ,r ,s 上标 prime 基线 右括号 等于 r 加 gamma 点 上标 k 基线 左括号 s' 右括号 减去 上标 k 基线 左括号 s 右括号 EndLayout StartLayout1stRow1stColumn上标k加1基线左括号s右括号2ndColumn左箭头左括号1减去alpha右括号上标k基线左括号s右括号加上alpha左括号r加gamma点上标k基线左括号s′右括号右括号2ndRow1stColumn或逗号2ndColumn相当于冒号3rdRow1stColumn上标k加1基线左括号s右括号2ndColumn左箭头上标k基线左括号s右括号加上alpha点delta上标k基线左括号s,r,s上标prime基线右括号4thRow1stColumn与2ndColumndelta上标k基线左括号s,r,s上标prime基线右括号等于r加gamma点上标k基线左括号s′右括号减去上标k基线左括号s右括号EndLayout
在这个方程中:
-
α 是学习率(例如,0.01)。
-
r + γ · V**k 被称为 TD 目标。
-
δ**k 被称为 TD 错误。
写这个方程的第一种形式的一个更简洁的方法是使用符号 a l e f t − a r r o w U n d e r s c r i p t a l p h a E n d s c r i p t s b a left-arrow Underscript alpha Endscripts b aleft−arrowUnderscriptalphaEndscriptsb ,它意味着 a[k+1] ← (1 – α) · a[k] + α ·b[k]。因此,方程 19-5 的第一行可以重写如下: u p p e r V l e f t − p a r e n t h e s i s s r i g h t − p a r e n t h e s i s l e f t − a r r o w U n d e r s c r i p t a l p h a E n d s c r i p t s r p l u s g a m m a d o t u p p e r V l e f t − p a r e n t h e s i s s p r i m e r i g h t − p a r e n t h e s i s upper V left-parenthesis s right-parenthesis left-arrow Underscript alpha Endscripts r plus gamma dot upper V left-parenthesis s prime right-parenthesis upperVleft−parenthesissright−parenthesisleft−arrowUnderscriptalphaEndscriptsrplusgammadotupperVleft−parenthesissprimeright−parenthesis .
提示
TD 学习与随机梯度下降有很多相似之处,包括它一次处理一个样本的事实。此外,就像 SGD 一样,只有当你逐渐降低学习率时,它才能真正收敛;否则,它将围绕最优 Q 值不断震荡。
对于每个状态 s,此算法会跟踪智能体离开该状态后获得的即时奖励的运行平均值,以及它期望在以后获得的奖励,假设它采取最优行动。
Q-Learning
类似地,Q 学习算法是 Q 值迭代算法对初始时过渡概率和奖励未知的情况的改编(见 方程 19-6)。Q 学习通过观察智能体(例如,随机)玩游戏并逐渐改进其对 Q 值的估计来工作。一旦它有了准确的 Q 值估计(或者足够接近),那么最优策略就是选择具有最高 Q 值的动作(即贪婪策略)。
方程 19-6. Q 学习算法
u p p e r Q l e f t − p a r e n t h e s i s s c o m m a a r i g h t − p a r e n t h e s i s l e f t − a r r o w U n d e r s c r i p t a l p h a E n d s c r i p t s r p l u s g a m m a d o t m a x U n d e r s c r i p t a S u p e r s c r i p t p r i m e B a s e l i n e E n d s c r i p t s u p p e r Q l e f t − p a r e n t h e s i s s p r i m e c o m m a a p r i m e r i g h t − p a r e n t h e s i s upper Q left-parenthesis s comma a right-parenthesis left-arrow Underscript alpha Endscripts r plus gamma dot max Underscript a Superscript prime Baseline Endscripts upper Q left-parenthesis s prime comma a prime right-parenthesis upperQleft−parenthesisscommaaright−parenthesisleft−arrowUnderscriptalphaEndscriptsrplusgammadotmaxUnderscriptaSuperscriptprimeBaselineEndscriptsupperQleft−parenthesissprimecommaaprimeright−parenthesis
对于每个状态-动作对 (s, a),此算法会跟踪一个运行平均奖励 r,这是智能体在执行动作 a 离开状态 s 后获得的奖励,以及它期望获得的未来折扣奖励的总和。为了估计这个总和,我们取下一个状态 s′ 的 Q 值估计的最大值,因为我们假设从那时起目标策略将采取最优行动。
让我们实现 Q 学习算法。首先,我们需要让智能体探索环境。为此,我们需要一个步进函数,以便智能体可以执行一个动作并获得相应的状态和奖励:
def step(state, action):
probas = transition_probabilities[state][action]
next_state = np.random.choice([0, 1, 2], p=probas)
reward = rewards[state][action][next_state]
return next_state, reward
现在,让我们实现智能体的探索策略。由于状态空间相当小,一个简单的随机策略就足够了。如果我们运行足够长的时间,智能体将多次访问每个状态,并且也会多次尝试每个可能的动作:
def exploration_policy(state):
return np.random.choice(possible_actions[state])
接下来,在初始化 Q 值就像之前一样之后,我们就可以运行带有学习率衰减的 Q 学习算法(使用在第十一章中引入的幂调度):
alpha0 = 0.05 # initial learning rate
decay = 0.005 # learning rate decay
gamma = 0.90 # discount factor
state = 0 # initial state
for iteration in range(10_000):
action = exploration_policy(state)
next_state, reward = step(state, action)
next_value = Q_values[next_state].max() # greedy policy at the next step
alpha = alpha0 / (1 + iteration * decay)
Q_values[state, action] *= 1 - alpha
Q_values[state, action] += alpha * (reward + gamma * next_value)
state = next_state
此算法将收敛到最优 Q 值,但需要许多迭代,并且可能需要进行大量的超参数调整。正如你在 图 19-9 中可以看到,Q 值迭代算法(左)在不到 20 次迭代内就非常快地收敛了,而 Q 学习算法(右)则需要大约 8,000 次迭代才能收敛。显然,不知道过渡概率或奖励会使找到最优策略变得显著更难!
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-skl-pt/img/hmls_1909.png
图 19-9. Q 值迭代算法与 Q 学习算法的学习曲线
Q 学习算法被称为 离线策略算法,因为正在训练的策略不一定是训练期间使用的策略。例如,在我们刚刚运行的代码中,正在执行的策略(探索策略)是完全随机的,而正在训练的策略从未被使用过。训练后,最佳策略对应于系统地选择具有最高 Q 值的动作。相反,REINFORCE 算法是 在线策略:它使用正在训练的策略来探索世界。令人惊讶的是,Q 学习仅通过观察代理随机行动就能学会最佳策略。想象一下,当你的老师是一个蒙上眼睛的猴子时,你学习打高尔夫球。我们能做得更好吗?
探索策略
当然,Q 学习只有在探索策略足够彻底地探索 MDP 时才能工作。尽管纯随机策略保证最终会多次访问每个状态和每个转换,但这可能需要非常长的时间。因此,更好的选择是使用 ε-greedy 策略(ε是 epsilon):在每一步,它以概率 ε 随机行动,或者以概率 1–ε(即选择具有最高 Q 值的动作)贪婪行动。与完全随机的策略相比,ε-greedy 策略的优势在于,随着 Q 值估计越来越好,它将越来越多地花时间探索环境的有趣部分,同时仍然花一些时间访问 MDP 中的未知区域。通常,开始时将 ε 的值设得较高(例如,1.0),然后逐渐降低它(例如,降至 0.05)。
或者,而不是仅仅依赖机会进行探索,另一种方法是通过鼓励探索策略尝试之前很少尝试过的动作。这可以通过向 Q 值估计中添加一个奖励来实现,如方程式 19-7 所示。
方程式 19-7. 使用探索函数的 Q 学习
u p p e r Q l e f t − p a r e n t h e s i s s c o m m a a r i g h t − p a r e n t h e s i s l e f t − a r r o w U n d e r s c r i p t a l p h a E n d s c r i p t s r p l u s g a m m a d o t m a x U n d e r s c r i p t a S u p e r s c r i p t p r i m e B a s e l i n e E n d s c r i p t s f l e f t − p a r e n t h e s i s u p p e r Q l e f t − p a r e n t h e s i s s p r i m e c o m m a a S u p e r s c r i p t p r i m e B a s e l i n e r i g h t − p a r e n t h e s i s c o m m a u p p e r N l e f t − p a r e n t h e s i s s p r i m e c o m m a a p r i m e r i g h t − p a r e n t h e s i s r i g h t − p a r e n t h e s i s upper Q left-parenthesis s comma a right-parenthesis left-arrow Underscript alpha Endscripts r plus gamma dot max Underscript a Superscript prime Baseline Endscripts f left-parenthesis upper Q left-parenthesis s prime comma a Superscript prime Baseline right-parenthesis comma upper N left-parenthesis s prime comma a prime right-parenthesis right-parenthesis upperQleft−parenthesisscommaaright−parenthesisleft−arrowUnderscriptalphaEndscriptsrplusgammadotmaxUnderscriptaSuperscriptprimeBaselineEndscriptsfleft−parenthesisupperQleft−parenthesissprimecommaaSuperscriptprimeBaselineright−parenthesiscommaupperNleft−parenthesissprimecommaaprimeright−parenthesisright−parenthesis
在这个方程中:
-
N(s′, a′) 计算在状态 s′ 下动作 a′ 被选择的次数。
-
f(Q, N) 是一个 探索函数,例如 f(Q, N) = Q + κ/(1 + N),其中 κ(kappa)是一个好奇心超参数,它衡量代理对未知事物的吸引力。
近似 Q 学习和深度 Q 学习
Q 学习的主要问题是它不能很好地扩展到具有许多状态和动作的大(甚至中等)MDP。例如,假设你想使用 Q 学习来训练一个智能体来玩 Ms. Pac-Man(见 图 19-1)。Ms. Pac-Man 可以吃大约 240 个豆子,每个豆子可以是存在或不存在(即,已经被吃掉)。因此,可能的豆子状态数约为 2²⁴⁰ ≈ 10⁷³。如果你加上所有幽灵和 Ms. Pac-Man 的所有可能位置组合,可能的状态数将大于我们银河系中的原子数,因此你绝对无法跟踪每个 Q 值的估计。
解决方案是找到一个函数 Qθ,它近似于任何状态-动作对 (s, a) 的 Q 值,其中向量 θ 参数化该函数。这被称为 近似 Q 学习。多年来,推荐使用从状态中提取的手工特征(例如,最近幽灵的距离、他们的方向等)的线性组合来估计 Q 值,但 2013 年,DeepMind 展示了使用深度神经网络可以工作得更好,特别是对于复杂问题,而且不需要任何特征工程。用于估计 Q 值的 DNN 被称为 深度 Q 网络 (DQN),使用 DQN 进行近似 Q 学习被称为 深度 Q 学习。
现在,我们如何训练一个 DQN 呢?好吧,考虑 DQN 为给定的状态-动作对 (s, a) 计算的近似 Q 值。多亏了 Bellman,我们知道我们希望这个近似 Q 值尽可能接近我们在状态 s 中执行动作 a 后实际观察到的奖励 r,以及从那时起最优地玩下去的折现值。为了估计这个未来折现奖励的总和,我们只需在下一个状态 s′ 上对所有可能动作 a′ 执行 DQN。我们得到每个可能动作的近似未来 Q 值。然后我们选择最高的(因为我们假设我们将进行最优游戏),并对其进行折现,这给我们提供了未来折现奖励总和的估计。通过将奖励 r 和未来折现价值估计相加,我们得到状态-动作对 (s, a) 的目标 Q 值 y(s, a),如 方程式 19-8 所示。
方程式 19-8. 目标 Q 值
y ( s , a ) = r + γ · max a ‘ Q θ ( s ’ , a ' )
使用这个目标 Q 值,我们可以使用任何梯度下降算法运行一个训练步骤。通常,我们试图最小化估计 Q 值Qθ和目标 Q 值y(s, a)之间的平方误差,或者使用 Huber 损失来减少算法对大错误的敏感性。这就是深度 Q 学习算法!让我们看看如何实现它来解决 CartPole 环境。
实现深度 Q 学习
我们首先需要一个深度 Q 网络。从理论上讲,我们需要一个神经网络,它以状态-动作对作为输入,并输出一个近似的 Q 值。然而,在实践中,使用一个仅以状态作为输入,并为每个可能的动作输出一个近似 Q 值的神经网络要高效得多。为了解决 CartPole 环境,我们不需要一个非常复杂的神经网络;几个隐藏层就足够了:
class DQN(nn.Module):
def __init__(self):
super().__init__()
self.net = nn.Sequential(nn.Linear(4, 32), nn.ReLU(),
nn.Linear(32, 32), nn.ReLU(),
nn.Linear(32, 2))
def forward(self, state):
return self.net(state)
我们的 DQN 与我们早期的策略网络非常相似,只是它为每个动作输出一个 Q 值而不是 logits。现在让我们定义一个基于这个 DQN 选择动作的函数:
def choose_dqn_action(model, obs, epsilon=0.0):
if torch.rand(()) < epsilon: # epsilon greedy policy
return torch.randint(2, size=()).item()
else:
state = torch.as_tensor(obs)
Q_values = model(state)
return Q_values.argmax().item() # optimal according to the DQN
这个函数接受一个环境状态(一个单独的观察值)并将其传递给神经网络以预测 Q 值,然后它简单地返回具有最大预测 Q 值的动作(argmax())。为了确保代理探索环境,我们使用ε-贪婪策略,这意味着我们以概率ε选择一个随机动作。
警告
DQN 通常不适用于连续动作空间,除非你能将空间离散化(这仅适用于非常小的空间)或者将它们与策略梯度结合。这是因为 DQN 代理必须在每一步找到具有最高 Q 值的动作。在连续动作空间中,这需要在每一步对 Q 值函数运行优化算法,这在实际中是不可行的。
我们不会仅基于最新的经验来训练 DQN,而是将所有经验存储在重放缓冲区(或重放记忆)中,并在每次训练迭代中从中随机抽取一个训练批次。这有助于减少训练批次中经验之间的相关性,通过使数据分布更加一致来稳定训练。每个经验将表示为一个包含六个元素的元组:一个状态s*,代理采取的动作a*,得到的奖励r*,代理达到的下一个状态s′*,一个布尔值表示在该点是否结束剧集(done),以及最后另一个布尔值表示在该点是否截断剧集。我们还需要一个函数来从重放缓冲区中随机抽取一个经验批次。它将返回一个包含六个张量的元组,每个字段一个:
def sample_experiences(replay_buffer, batch_size):
indices = torch.randint(len(replay_buffer), size=[batch_size])
batch = [replay_buffer[index] for index in indices.tolist()]
return [to_tensor([exp[index] for exp in batch]) for index in range(6)]
def to_tensor(data):
array = np.stack(data)
dtype = torch.float32 if array.dtype == np.float64 else None
return torch.as_tensor(array, dtype=dtype)
sample_experiences() 函数接受一个重放缓冲区和批次大小,并从缓冲区中随机采样所需数量的经验元组。然后,对于经验元组中的六个字段,它从批次中的每个经验中提取该字段,并使用 to_tensor() 函数将该列表转换为张量。最后,它返回六个张量的列表。所有张量都具有 batch size 的形状,除了观察张量,它们的形状为 batch size, 4。
to_tensor() 函数接受一个包含观察(即形状为 4 的 64 位 NumPy 数组)的 Python 列表(即动作(整数)、奖励(浮点数)或布尔值(完成或截断)),并返回适当 PyTorch 类型的张量。请注意,包含观察的 64 位 NumPy 数组被转换为 32 位张量。
重放缓冲区可以是任何支持追加和索引的数据结构,并且可以限制大小以避免在训练过程中内存爆炸。为了简单起见,我们将使用来自标准 collections 包的 Python deque。这是一个双端队列,其中元素可以在两端高效地追加或弹出(即移除)。如果您设置了一个大小限制,并且该限制被达到,则将元素追加到队列的一端会自动从另一端弹出。这意味着每个新的经验会替换最老的经验,这正是我们想要的。
小贴士
在 deque 的两端追加和弹出项非常快,但当队列非常长时(例如,100,000 个项或更多),随机访问可能会很慢。如果您需要一个非常大的重放缓冲区,您应该使用循环缓冲区(请参阅笔记本中的实现),或者查看 DeepMind 的 Reverb 库。
让我们再创建一个函数,该函数将使用我们的 DQN 播放一个完整剧集,并将生成的经验存储在重放缓冲区中。我们将以评估模式运行并使用 torch.no_grad(),因为我们目前不需要梯度。为了日志记录的目的,我们还将使该函数汇总剧集中的所有奖励并返回结果:
def play_and_record_episode(model, env, replay_buffer, epsilon, seed=None):
obs, _info = env.reset(seed=seed)
total_rewards = 0
model.eval()
with torch.no_grad():
while True:
action = choose_dqn_action(model, obs, epsilon)
next_obs, reward, done, truncated, _info = env.step(action)
experience = (obs, action, reward, next_obs, done, truncated)
replay_buffer.append(experience)
total_rewards += reward
if done or truncated:
return total_rewards
obs = next_obs
接下来,让我们创建一个函数,该函数将从重放缓冲区中采样一批经验,并通过对这个批次执行单次梯度下降步骤来训练 DQN:
def dqn_training_step(model, optimizer, criterion, replay_buffer, batch_size,
discount_factor):
experiences = sample_experiences(replay_buffer, batch_size)
state, action, reward, next_state, done, truncated = experiences
with torch.inference_mode():
next_Q_value = model(next_state)
max_next_Q_value, _ = next_Q_value.max(dim=1)
running = (~(done | truncated)).float() # 0 if s' is over, 1 if running
target_Q_value = reward + running * discount_factor * max_next_Q_value
all_Q_values = model(state)
Q_value = all_Q_values.gather(dim=1, index=action.unsqueeze(1))
loss = criterion(Q_value, target_Q_value.unsqueeze(1))
optimizer.zero_grad()
loss.backward()
optimizer.step()
小贴士
torch.inference_mode() 上下文类似于 torch.no_grad(),在上下文中,模型以评估模式运行,并且不能在反向传播中使用新张量。
下面是这段代码中发生的事情:
-
函数首先从重放缓冲区中采样一批经验。
-
然后它使用 DQN 来计算批次中每个经验的预期 Q 值。为此,代码实现了方程 19-8:DQN 以推理模式评估下一个状态 s’ 的所有 Q 值,然后我们只保留最大 Q 值,因为我们假设从现在开始代理将最优地玩游戏,并将这个最大 Q 值乘以折扣因子。如果剧集已经结束(完成或截断),则折扣后的最大 Q 值乘以零,因为我们不能期望获得更多奖励。否则,它乘以 1(即不变)。最后,我们添加经验的奖励。所有这些操作都是同时对批次中的所有经验执行的。
-
接下来,该函数再次使用模型(这次是训练模式)来计算当前状态 s 的所有 Q 值,并使用
gather()方法提取实际选择的动作对应的 Q 值。同样,这是对批次中的所有经验同时进行的。 -
最后,我们计算损失,这通常是目标 Q 值和预测 Q 值之间的均方误差,并执行优化器步骤以最小化损失。
呼!那是最难的部分。现在我们可以编写主训练函数并运行它:
from collections import deque
def train_dqn(model, env, replay_buffer, optimizer, criterion, n_episodes=800,
warmup=30, batch_size=32, discount_factor=0.95):
totals = []
for episode in range(n_episodes):
epsilon = max(1 - episode / 500, 0.01)
seed = torch.randint(0, 2**32, size=()).item()
total_rewards = play_and_record_episode(model, env, replay_buffer,
epsilon, seed=seed)
print(f"\rEpisode: {episode + 1}, Rewards: {total_rewards}", end=" ")
totals.append(total_rewards)
if episode >= warmup:
dqn_training_step(model, optimizer, criterion, replay_buffer,
batch_size, discount_factor)
return totals
torch.manual_seed(42)
dqn = DQN()
optimizer = torch.optim.NAdam(dqn.parameters(), lr=0.03)
mse = nn.MSELoss()
replay_buffer = deque(maxlen=100_000)
totals = train_dqn(dqn, env, replay_buffer, optimizer, mse)
训练算法运行 800 个剧集。在每个训练迭代中,我们使用 play_and_record_episode() 函数让 DQN 玩一个完整的剧集,然后使用 dqn_training_step() 函数运行一个训练步骤。请注意,我们只在几个预热剧集之后开始训练,以确保重放缓冲区包含足够多的经验。我们还从 500 个剧集后线性减少ε-greedy 策略的 epsilon 值,从 1.0 下降到 0.01(然后保持在 0.01)。这样,代理的行为将逐渐变得不那么随机,更多地关注利用而不是探索。该函数还记录每个剧集的总奖励,并返回这些总数;它们在图 19-10 中绘制。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-skl-pt/img/hmls_1910.png
图 19-10. 深度 Q 学习算法的学习曲线
注意
为什么不绘制损失?嗯,它是一个很差的模型性能指标,因此绘制每集的总奖励更可取。确实,损失可能会下降,而代理的表现可能会变差(例如,如果代理卡在环境的一个小区域,DQN 开始过度拟合它)。相反,损失可能会上升,而代理的表现可能会变好(例如,如果 DQN 低估了目标 Q 值,它开始正确地增加它们)。
好消息是算法起作用了:训练的智能体完美地平衡了小车上的杆子,达到了最大总奖励 1,000。坏消息是训练过程完全不稳定。事实上,它甚至比 REINFORCE 算法还要不稳定。我不得不调整了很多超参数,才偶然找到了这个成功的训练运行。正如你所见,智能体在大约 200 个回合后达到了 200 分的奖励,这已经很不错了,但很快它就忘记了所有东西,表现糟糕,直到第 ~550 个回合,它才迅速解决了问题。
那么,为什么这个 DQN 实现不稳定呢?是不是数据分布的问题?嗯,重放缓冲区相当大,所以数据分布肯定比 REINFORCE 算法更稳定。那么问题出在哪里呢?嗯,在这个基本的深度 Q 学习实现中,模型既用于做出预测,也用于设置自己的目标。这可能导致类似于狗追自己的尾巴的情况。这个反馈循环可以使网络不稳定:它可能发散、振荡、冻结等等。幸运的是,有方法可以改进这一点;让我们看看如何。
DQN 改进
在他们 2013 年的论文中,DeepMind 研究人员提出了一种使用两个 DQN 而不是一个来稳定 DQN 训练的方法:第一个是 在线模型,它在每一步学习,并用于移动智能体,另一个是 目标模型,仅用于定义目标。目标模型只是在线模型的克隆,其权重定期从在线模型复制(例如,在他们 Atari 模型中,每 10,000 步复制一次)。这使得 Q 值目标更加稳定,因此反馈循环被抑制,其影响要小得多。他们将这一主要改进与几个其他调整相结合:一个非常大的重放缓冲区、一个非常小的学习率、一个非常长的训练时间(5000 万步)、一个非常缓慢减少的 epsilon(超过 100 万步),以及一个强大的神经网络(CNN)。
然后,在 2015 年的一篇论文2015 paper^(15)中,DeepMind 的研究人员再次调整了他们的 DQN 算法,提高了其性能并使训练更加稳定。他们将这个变体称为double DQN。这次更新基于这样一个观察:目标网络容易高估 Q 值。确实,假设所有动作都是同样好的:目标模型估计的 Q 值应该是相同的,但由于它们是近似值,一些值可能会因为纯粹的机会而略大于其他值。目标模型将始终选择最大的 Q 值,这个值将略大于平均 Q 值,很可能会高估真实 Q 值(有点像在测量泳池深度时计算最高随机波浪的高度)。为了解决这个问题,研究人员提出在为下一个状态选择最佳动作时使用在线模型而不是目标模型,并且仅使用目标模型来估计这个最佳动作的 Q 值。
另一个重要的改进是引入了优先经验回放(PER),这是 DeepMind 研究人员在 2015 年的一篇论文2015 paper^(16)中提出的(又一次!)。不是从重放缓冲区中均匀地采样经验,为什么不更多地采样重要的经验呢?
更具体地说,如果经验可能导致快速的学习进步,则认为这些经验是“重要的”。但我们如何估计这一点呢?一个合理的方法是测量 TD 误差δ = r + γ·V(s′) – V(s)的大小。大的 TD 误差表明一个转换(s, a, s′)非常令人惊讶,因此可能值得学习。^(17) 当一个经验被记录在重放缓冲区中时,其优先级被设置为一个非常大的值,以确保它至少被采样一次。然而,一旦它被采样(并且每次被采样时),就会计算 TD 误差δ,并将这个经验的优先级设置为p = |δ|(加上一个小的常数,以确保每个经验都有非零的采样概率)。具有优先级p的经验采样的概率P与p^(ζ)成正比,其中ζ(zeta)是一个超参数,它控制我们希望重要性采样有多贪婪:当ζ = 0 时,我们只得到均匀采样,当ζ = 1 时,我们得到完整的重要性采样。在论文中,作者使用了ζ = 0.6,但最佳值将取决于任务。
但是有一个问题:由于样本将偏向于重要的经验,我们必须在训练过程中通过根据其重要性降低经验权重来补偿这种偏差,否则模型将只是过度拟合重要的经验。为了清楚起见,我们希望重要的经验被更频繁地采样,但这同时也意味着我们必须在训练期间给予它们更低的权重。为此,我们定义每个经验的训练权重为 w = (n P)^(–β),其中 n 是重放缓冲区中的经验数量,而 β 是一个超参数,它控制我们想要补偿的重要性采样偏差的程度(0 表示完全不补偿,而 1 表示完全补偿)。在论文中,作者在训练开始时使用了 β = 0.4,并在训练结束时将其线性增加到 β = 1。再次强调,最佳值将取决于任务,但如果你增加一个,你通常也会想增加另一个。
最后一个值得注意的 DQN 变体是对抗 DQN算法(DDQN,不要与 double DQN 混淆,尽管这两种技术可以很容易地结合)。它是由 DeepMind 研究人员在另一篇2015 年的论文^(18)中引入的。为了理解它是如何工作的,我们必须首先注意,状态-动作对(s, a)的 Q 值可以表示为 Q(s, a) = V(s) + A(s, a),其中 V(s) 是状态 s 的值,而 A(s, a) 是在状态 s 中采取动作 a 相对于该状态下所有其他可能动作的优势。此外,状态的值等于该状态下最佳动作 a^* 的 Q 值(因为我们假设最优策略会选择最佳动作),所以 V(s) = Q(s, a^),这意味着 A(s, a^) = 0。在对抗 DQN 中,模型估计每个可能动作的价值和优势。由于最佳动作应该具有 0 的优势,模型从所有预测的优势中减去最大预测的优势。算法的其余部分与之前相同。
这些技术可以以各种方式组合,正如 DeepMind 在 2017 年的一篇论文中展示的那样:^(19)该论文的作者将六种不同的技术组合成一个名为Rainbow的代理,它在很大程度上超越了当时的最佳水平。
谈到组合不同的方法,为什么不将策略梯度与基于价值的方法结合起来,以获得两者的最佳效果呢?这就是 actor-critic 算法背后的核心思想。现在让我们来讨论它们。
Actor-Critic 算法
演员评论家是一系列结合策略梯度与基于价值方法的强化学习算法。一个演员评论家由一个策略(演员)和一个价值网络(评论家)组成,它们同时训练。演员依赖于评论家来估计动作或状态的价值(或优势),以指导其策略更新。由于评论家可以使用大的重放缓冲区,它稳定了训练并提高了数据效率。这有点像一名运动员(演员)在教练(评论家)的帮助下学习。
此外,演员-评论家方法支持随机策略和连续动作空间,就像策略梯度一样。所以我们确实得到了两者的最佳结合。
让我们实现一个基本的演员-评论家:
class ActorCritic(nn.Module):
def __init__(self):
super().__init__()
self.body = nn.Sequential(nn.Linear(4, 32), nn.ReLU(),
nn.Linear(32, 32), nn.ReLU())
self.actor_head = nn.Linear(32, 1) # outputs action logits
self.critic_head = nn.Linear(32, 1) # outputs state values
def forward(self, state):
features = self.body(state)
return self.actor_head(features), self.critic_head(features)
在构造函数中,我们构建演员和评论家网络。在这个实现中,它们共享相同的底层(称为 body)。这是一种常见的做法,因为它减少了总参数数量,从而提高了数据效率,但它也使得训练稍微不稳定,因为演员和评论家耦合得更紧密(另一种追逐自己的尾巴的情况)。演员网络接受一批环境状态,并为每个状态输出一个动作 logit(这就是动作 1 的 logit,就像 REINFORCE 一样)。评论家网络估计每个给定状态的价值。forward() 方法接受一批状态,并将它们通过两个网络(共享身体)运行,并返回动作 logit 和状态值。
现在,让我们编写一个函数来选择一个动作。它与我们之前为 REINFORCE 策略网络编写的 choose_action() 函数相同,除了它还返回评论家网络估计的状态值。这将在训练中需要:
def choose_action_and_evaluate(model, obs):
state = torch.as_tensor(obs)
logit, state_value = model(state)
dist = torch.distributions.Bernoulli(logits=logit)
action = dist.sample()
log_prob = dist.log_prob(action)
return int(action.item()), log_prob, state_value
太好了!现在让我们看看如何训练我们的演员-评论家。我们将首先定义一个函数,该函数将执行一个训练步骤:
def ac_training_step(optimizer, criterion, state_value, target_value, log_prob,
critic_weight):
td_error = target_value - state_value
actor_loss = -log_prob * td_error.detach()
critic_loss = criterion(state_value, target_value)
loss = actor_loss + critic_weight * critic_loss
optimizer.zero_grad()
loss.backward()
optimizer.step()
首先,我们计算 TD 误差,这是目标值 y = r + γV(s’) 与状态值 V(s) 之间的差异。演员的损失与 REINFORCE 相同,只是我们将对数概率乘以 TD 误差而不是(标准化的)回报。换句话说,我们鼓励那些比价值网络预期表现更好的动作。至于评论家的损失,它鼓励评论家的价值估计 V(s) 与目标值 y 匹配(例如,使用 MSE)。最后,整体损失是演员损失和评论家损失的加权总和。为了稳定训练,通常给评论家损失更少的权重。然后我们执行一个优化器步骤来最小化损失。哦,注意,我们调用 td_error.detach() 是因为我们不希望梯度下降通过演员的损失影响评论家网络。
我们还需要一个函数来计算目标值:
def get_target_value(model, next_obs, reward, done, truncated, discount_factor):
with torch.inference_mode():
_, _, next_state_value = choose_action_and_evaluate(model, next_obs)
running = 0.0 if (done or truncated) else 1.0
target_value = reward + running * discount_factor * next_state_value
return target_value
此代码首先使用choose_action_and_evaluate()函数评估 V(s’)(我们忽略所选动作及其对数概率)。我们在推理模式下运行此操作,因为我们正在计算目标:我们不希望梯度下降影响它。接下来,我们简单地评估目标y = r + γV(s’)。如果回合结束,则y = r。
这样,我们就有了编写一个函数所需的所有内容,该函数将运行整个回合并在每个步骤中训练演员-评论家(我们还在回合结束时计算总奖励并返回它):
def run_episode_and_train(model, optimizer, criterion, env, discount_factor,
critic_weight, seed=None):
obs, _info = env.reset(seed=seed)
total_rewards = 0
while True:
action, log_prob, state_value = choose_action_and_evaluate(model, obs)
next_obs, reward, done, truncated, _info = env.step(action)
target_value = get_target_value(model, next_obs, reward, done,
truncated, discount_factor)
ac_training_step(optimizer, criterion, state_value, target_value,
log_prob, critic_weight)
total_rewards += reward
if done or truncated:
return total_rewards
obs = next_obs
最后,我们可以编写我们的主要训练函数,该函数只是多次调用run_episode_and_train()函数,并返回每个回合的总奖励:
def train_actor_critic(model, optimizer, criterion, env, n_episodes=400,
discount_factor=0.95, critic_weight=0.3):
totals = []
model.train()
for episode in range(n_episodes):
seed = torch.randint(0, 2**32, size=()).item()
total_rewards = run_episode_and_train(model, optimizer, criterion, env,
discount_factor, critic_weight,
seed=seed)
totals.append(total_rewards)
print(f"\rEpisode: {episode + 1}, Rewards: {total_rewards}", end=" ")
return totals
让我们运行它!
torch.manual_seed(42)
ac_model = ActorCritic()
optimizer = torch.optim.NAdam(ac_model.parameters(), lr=1.1e-3)
criterion = nn.MSELoss()
totals = train_actor_critic(ac_model, optimizer, criterion, env)
它确实有效!我们得到了一个非常稳定的 CartPole,能够收集最大奖励。尽管如此,此实现仍然非常敏感于超参数和随机种子的选择,训练仍然非常不稳定。幸运的是,研究人员已经提出了各种技术,可以稳定演员-评论家。以下是一些最受欢迎的技术:
异步优势演员-评论家(A3C)^(20)
这是 DeepMind 研究人员在 2016 年引入的一个重要演员-评论家变体,其中多个智能体并行学习,探索环境的不同副本。在固定间隔内,但异步(因此得名),每个智能体将一些权重更新推送到主网络,然后从该网络拉取最新权重。因此,每个智能体都为改进主网络做出贡献,并从其他智能体学到的内容中受益。此外,评论家不仅估计状态值,甚至 Q 值,而是估计每个动作的优势(因此名称中的第二个 A),就像在 Dueling DQN 中一样。
A2C 是 A3C 算法的一个变体,它去除了异步性。所有模型更新都是同步的,因此梯度更新是在更大的批次上进行的,这使得模型能够更好地利用 GPU 的强大功能。
软演员-评论家(SAC)^(21)
SAC 是 2018 年由 Tuomas Haarnoja 和其他加州大学伯克利分校研究人员提出的一种演员-评论家变体。它不仅学习奖励,还学习如何最大化其动作的熵。换句话说,它试图尽可能不可预测,同时获得尽可能多的奖励。这鼓励智能体探索环境,从而加快训练速度,并减少评论家产生不完美估计时重复执行相同动作的可能性。该算法已经展示了惊人的样本效率(与所有之前的算法相反,这些算法学习速度非常慢)。
近端策略优化(PPO)^(22)
约翰·舒尔曼和其他 OpenAI 研究人员提出的这个算法基于 A2C,但它剪辑损失函数以避免过大的权重更新(这通常会导致训练不稳定)。PPO 是 OpenAI 另一个信任域策略优化 (TRPO) 算法的简化,同样也是 OpenAI 提出的。2019 年 4 月,OpenAI 因其基于 PPO 算法的 AI 产品 OpenAI Five 而闻名,该产品在多人游戏 Dota 2 中击败了世界冠军。
最后两个算法,SAC 和 PPO,是目前最广泛使用的强化学习算法之一,几个库提供了易于使用且高度优化的实现。例如,让我们使用流行的 Stable-Baselines3 库在 Breakout Atari 游戏上训练一个 PPO 代理。
小贴士
你应该使用哪种强化学习算法?PPO 是一个优秀的通用强化学习算法——如果你不确定,这是一个不错的选择。SAC 对于连续动作任务来说是最高效的采样算法,使其非常适合机器人技术。DQN 在诸如 Atari 游戏或棋类游戏等离散任务上仍然表现强劲。
使用 Stable-Baselines3 PPO 实现掌握 Atari Breakout
由于 Stable-Baselines3 (SB3) 在 Colab 上默认未安装,我们必须首先运行 %pip install -q stable_baselines3,这将花费几分钟时间。然而,如果你在自己的机器上运行代码,并且遵循了安装说明,那么它已经安装了。
接下来,我们必须创建一个 ALE 接口:它将运行 Atari 2600 模拟器,并允许 Gymnasium 与其接口(Atari 游戏将出现在可用环境列表中):
import ale_py
ale = ale_py.ALEInterface()
Atari 游戏存储在只读存储器(ROM)卡上。现在,这些 ROM 可以免费下载并用于研究和教育目的。在 Colab 上,它们是预安装的,如果你按照安装说明在本地运行代码,那么它们也是预安装的。
现在我们有了 SB3、ALE 接口和 ROM,我们准备创建 Breakout 环境。但不是直接使用 Gymnasium 创建它,我们将使用 SB3 的 make_atari_env() 函数:它创建了一个包含多个 Breakout 环境的包装环境,这些环境将并行运行。包装环境中的每个观察结果将包含每个 Breakout 环境的一个观察结果。同样,包装环境的 step() 函数将接受一个包含每个 Breakout 环境一个动作的数组。最后,包装环境将负责预处理图像,将它们从 210 × 160 RGB 图像转换为 84 × 84 灰度图像。非常方便!所以让我们创建一个包含四个 Breakout 环境的 SB3 环境,并将其重置以获取一个观察结果:
from stable_baselines3.common.env_util import make_atari_env
envs = make_atari_env("BreakoutNoFrameskip-v4", n_envs=4)
obs = envs.reset() # a 4 × 84 × 84 × 1 NumPy array (note: no info dict)
小贴士
env.get_images() 方法返回预处理前的原始图像(见图 19-11)。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-skl-pt/img/hmls_1911.png
图 19-11. 预处理前后的 Breakout 帧对比(左)和(右)
ALE 接口以每秒 60 帧的速度运行,这相当快,因此连续帧看起来非常相似,这浪费了计算资源。为了避免这种情况,默认的 Breakout 环境将每个动作重复四次,并只返回最终观察结果;这被称为 帧跳过。然而,与其跳过帧,不如将它们堆叠成一个单通道四通道图像,并使用该图像作为观察。为此,我们首先必须避免帧跳过:这就是为什么我们使用了 BreakoutNoFrameskip-v4 环境而不是 Breakout-v4。^(24) 然后,我们必须将环境包裹在 VecFrameStack 中;这个包装环境将重复每个动作多次(在我们的例子中是四次)并将结果帧沿通道维度(即最后一个)堆叠:
from stable_baselines3.common.vec_env import VecFrameStack
envs_stacked = VecFrameStack(envs, n_stack=4)
obs = envs_stacked.reset() # returns a 4 × 84 × 84 × 4 NumPy array
现在,让我们创建一个具有一些良好超参数的 PPO 模型:
from stable_baselines3 import PPO
ppo_model = PPO("CnnPolicy", envs_stacked, device=device, learning_rate=2.5e-4,
batch_size=256, n_steps=256, n_epochs=4, clip_range=0.1,
vf_coef=0.5, ent_coef=0.01, gamma=0.99, verbose=0)
这有很多参数!让我们看看它们的作用:
-
第一个参数是策略网络。由于我们指定了
CnnPolicy,SB3 将根据选择的算法(本例中为 PPO)和观察空间为我们构建一个良好的 CNN。如果您好奇,可以查看ppo_model.policy以了解 CNN 的架构:它是一个具有演员头(用于动作对数)和评论家头(用于状态值)的深度 CNN。有四种可能的行为:左、右、开火(发射球)和空操作(不执行任何操作)。如果您想使用自定义神经网络,您必须创建ActorCriticPolicy类的子类,该类位于stable_baselines3.common.policies模块中。有关更多详细信息,请参阅 SB3 的文档。 -
env、device、learning_rate和batch_size是不言自明的。 -
n_steps是在每个策略更新之前(每个环境)运行的环环境步骤数。 -
n_epochs是在优化过程中每个批次上运行的训练步骤数。 -
clip_range限制了策略更新的幅度,以避免可能引起灾难性遗忘的大幅变化。 -
vf_coef是价值函数损失在总损失中的权重(类似于我们演员-评论家的critic_weight超参数)。 -
ent_coef是鼓励探索的熵项的权重。 -
gamma是折扣率。 -
verbose是日志详细程度(0 = 静默,1 = 信息,2 = 调试)。
小贴士
对于新任务,默认的PPO超参数是一个良好的起点。如果学习过程太慢,首先尝试使用更多的并行环境;然后考虑使用更高的学习率或更大的 clip 范围。你也可以减小n_steps或batch_size,但这可能会增加梯度噪声。如果学习不稳定,尝试降低学习率或 clip 范围,并使用更大的 rollouts(即,n_steps)或批量大小。对于短期任务,使用gamma接近 0.95,对于长期任务,使用 0.995 到 0.999。最后,如果你想鼓励更多的探索,增加ent_coef。
现在让我们开始训练。以下代码将对模型进行 3000 万步的训练。这将需要很多小时,可能对于 Colab 会话来说时间太长(除非你有付费订阅),因此笔记本还包括了如果你更喜欢的话可以下载训练好的模型的代码。每次你长时间训练一个模型时,定期保存检查点(例如,每 100,000 次step()方法的调用)都很重要,以避免在发生崩溃或断电时需要从头开始。为此,我们可以创建一个检查点回调并将其传递给learn()方法:
from stable_baselines3.common.callbacks import CheckpointCallback
cb = CheckpointCallback(save_freq=100_000, save_path="my_ppo_breakout.ckpt")
ppo_model.learn(total_timesteps=30_000_000, progress_bar=True, callback=cb)
ppo_model.save("my_ppo_breakout") # save the final model
注意
save_freq参数计算step()方法的调用次数。由于有 4 个环境并行运行,50,000 次调用对应于 200,000 个总时间步。
要在训练过程中查看进度,一个选项是在另一个笔记本中加载最新的检查点,并尝试它。一个更简单的选项是使用 TensorBoard 来可视化学习曲线,特别是每集的平均奖励。为此,你必须在 Colab 或 Jupyter 中首先激活 TensorBoard 扩展,通过运行%load_ext tensorboard(这是在本章笔记本的开始处完成的)。接下来,你必须启动 TensorBoard 服务器,将其指向日志目录,并选择它将监听的 TCP 端口。以下“魔法”命令(即以%开头)将执行此操作,并直接在 Colab 或 Jupyter 中打开 TensorBoard 客户端界面:
tensorboard_logdir = "my_ppo_breakout_tensorboard" # path to the log directory
%tensorboard --logdir={tensorboard_logdir} --port 6006
接下来,你必须告诉 PPO 模型在哪里保存其 TensorBoard 日志。这是在创建模型时完成的:
ppo_model = PPO("CnnPolicy", [...], tensorboard_log=tensorboard_logdir)
就这些了。一旦开始训练,你将在 TensorBoard 界面(或点击刷新按钮)中看到学习曲线大约每 30 秒变化一次。最重要的指标是rollout/ep_rew_mean,即每集的平均奖励:它应该缓慢上升,尽管有时会略有下降。在总共 1 百万步之后,它通常会达到大约 20;这不是一个非常好的智能体。但如果你让训练运行到 1000 万步,它应该达到人类水平。在 5000 万步之后,它通常将是超人类的。
恭喜你,你已经知道如何训练一个超人类 AI 了!你可以这样尝试:
ppo_model = PPO.load("my_ppo_agent_breakout") # or load the best checkpoint
eval_env = make_atari_env("BreakoutNoFrameskip-v4", n_envs=1, seed=42)
eval_stacked = VecFrameStack(eval_env, n_stack=4)
frames = []
obs = eval_stacked.reset()
for _ in range(5000): # some limit in case the agent never loses
frames.append(eval_stacked.render())
action, _ = ppo_model.predict(obs, deterministic=True) # for reproducibility
obs, reward, done, info = eval_stacked.step(action)
if done[0]: # note: there's no `truncated`
break
eval_stacked.close()
这将捕捉到一集中所有的帧。你可以使用 Matplotlib 将它们渲染成动画(参见笔记本中的示例)。如果你训练代理足够长的时间(或者使用了预训练模型),你会发现代理玩得相当不错,甚至找到了在侧面挖隧道并将球通过它们的策略:这是这款游戏中最好的策略之一!
一些流行强化学习算法概述
在我们结束这一章之前,让我们简要地看看一些其他流行的算法:
AlphaGo^(25)
AlphaGo 使用基于深度神经网络的变体蒙特卡洛树搜索(MCTS)来击败围棋的人类冠军。MCTS 是由尼古拉斯·梅特罗波利斯和斯坦尼斯瓦夫·乌拉姆在 1949 年发明的。它在运行了许多模拟后选择最佳移动,反复从当前位置开始探索搜索树,并在最有希望的分支上花费更多时间。当它到达一个之前未访问过的节点时,它会随机移动直到游戏结束,并更新每个访问节点的估计(不包括随机移动),根据最终结果增加或减少每个估计。
AlphaGo 基于同样的原理,但它使用策略网络来选择移动,而不是随机移动。这个策略网络使用策略梯度进行训练。原始算法涉及三个额外的神经网络,更复杂,但在AlphaGo Zero 论文^(26)中得到了简化,该论文使用单个神经网络来选择移动和评估游戏状态。AlphaZero 论文^(27)将这个算法推广,使其能够处理不仅仅是围棋,还有象棋和国际象棋。最后,MuZero 论文^(28)继续改进这个算法,即使代理一开始甚至不知道游戏规则,也优于之前的迭代!
注意
围棋的规则被硬编码到 AlphaGo 中。相比之下,MuZero 逐渐学习环境的模型:给定状态s和动作a,它学习预测奖励r和达到状态s’的概率。拥有环境模型(硬编码或学习)允许这些算法进行前瞻性规划(在这种情况下使用 MCTS)。因此,这两个算法都属于广泛的基于模型的强化学习算法类别。相比之下,策略梯度、基于值的方法和演员-评论员方法都是无模型强化学习算法:它们有一个策略模型和/或一个值模型,但没有环境模型。
基于好奇的探索^(29)
在强化学习(RL)中,一个反复出现的问题是奖励的稀疏性,这使得学习变得非常缓慢且效率低下。Deepak Pathak 和其他加州大学伯克利分校的研究人员提出了一种解决这个问题的激动人心的方法:为什么不忽略奖励,而是让智能体对探索环境极度好奇呢?因此,奖励对智能体来说是内在的,而不是来自环境。同样,激发孩子的求知欲比仅仅因为孩子取得好成绩而奖励孩子更有可能取得好的结果。
这是如何工作的?智能体持续尝试预测其行动的结果,并寻找结果与其预测不符的情况。换句话说,它想要感到惊讶。如果结果是可预测的(无聊的),它会去别处。然而,如果结果是不可预测的,但智能体注意到它无法控制它,那么它过一段时间后也会感到无聊。仅凭好奇心,作者成功地在许多视频游戏中训练了智能体:尽管智能体在失败时不会受到惩罚,但它发现失败很无聊,因为游戏会重新开始,所以它学会了避免失败。
开放式学习(OEL)
OEL 的目标是训练能够无限学习新且有趣任务的智能体,这些任务通常是通过程序生成的。我们还没有达到那里,但过去几年已经取得了一些惊人的进展。例如,来自 Uber AI 的研究团队在 2019 年发表的一篇论文2019 paper^(30)中介绍了POET 算法,该算法生成多个带有凹凸和孔洞的模拟 2D 环境,并为每个环境训练一个智能体。智能体的目标是尽可能快地行走,同时避开障碍物。
算法从简单的环境开始,但随着时间的推移,它们会逐渐变得更具挑战性:这被称为课程学习。此外,尽管每个智能体只在一个环境中进行训练,但它必须定期与其他环境中的所有智能体竞争。在每个环境中,获胜者会复制并取代之前的智能体。这样,知识就会定期在不同环境之间转移,并且选择最适应的智能体。
最后,这些智能体比仅训练单一任务的智能体走得更好,并且它们可以应对更困难的环境。当然,这个原则也可以应用于其他环境和任务。如果你对 OEL 感兴趣,务必查看Enhanced POET 论文,^(31)以及 DeepMind 关于这个主题的 2021 年论文2021 paper^(32)。
小贴士
如果你想了解更多关于强化学习的信息,可以阅读 Phil Winder(O’Reilly)所著的Reinforcement Learning一书。
本章我们涵盖了众多主题。我们学习了策略梯度方法;我们使用 Gymnasium 实现了 REINFORCE 算法来解决 CartPole 问题;我们探讨了马尔可夫链和马尔可夫决策过程,这引导我们到基于价值的方法;然后我们实现了一个深度 Q-Learning 模型。然后我们讨论了演员-评论家方法,并使用 Stable-Baselines3 库实现了一个击败 Atari 游戏 Breakout 的 PPO 模型。最后,我们瞥了一眼强化学习的其他一些领域,包括基于模型的方法等。强化学习是一个庞大且令人兴奋的领域,每天都有新的想法和算法涌现,所以我希望这一章激起了你的好奇心。有一个广阔的世界等待你去探索!
练习
-
你会如何定义强化学习?它与常规的监督学习或无监督学习有何不同?
-
你能想到本章未提及的三个可能的强化学习应用吗?对于每个应用,环境是什么?智能体是什么?一些可能的行为有哪些?奖励有哪些?
-
折扣因子是什么?如果你修改折扣因子,最优策略会改变吗?
-
你如何衡量强化学习智能体的性能?
-
信用分配问题是什么?它何时发生?你如何减轻它?
-
使用重放缓冲区有什么意义?
-
什么是离策略强化学习算法?它的好处是什么?
-
基于模型强化学习算法是什么?你能给出一些例子吗?
-
使用策略梯度解决 Gymnasium 的 LunarLander-v2 环境。
-
使用你选择的强化学习算法解决 BipedalWalker-v3 环境。
-
如果你有多余的约 $100,你可以购买一个 Raspberry Pi 3 和一些便宜的机器人组件,在 Pi 上安装 PyTorch,然后尽情探索!从简单的目标开始,比如让机器人转圈找到最亮的角度(如果它有光传感器)或最近的对象(如果它有声纳传感器),然后朝那个方向移动。然后你可以开始使用深度学习。例如,如果机器人有摄像头,你可以尝试实现一个目标检测算法,使其能够检测到人并朝他们移动。你也可以尝试使用强化学习让智能体自己学习如何使用电机来实现那个目标。祝你好玩!
这些练习的解答可以在本章笔记本的末尾找到,在https://homl.info/colab-p。
谢谢!
在我们关闭这本书的最后一章之前,我想感谢你阅读到最后一句话。我真心希望你在阅读这本书时和我写作时一样感到快乐,并且它对你的项目(无论大小)都很有用。
如果你发现错误,请发送反馈。更普遍地说,我很乐意知道你的想法,所以请不要犹豫,通过 O’Reilly 或通过 ageron/handson-mlp GitHub 项目联系我。
未来的最佳建议是:多加练习:尝试完成所有练习(如果你还没有这样做的话),玩玩 Jupyter 笔记本,加入 Kaggle.com 或其他机器学习社区,观看机器学习课程,阅读论文,参加会议,并结识专家。有一个具体的项目来做也会非常有帮助,无论是为了工作还是为了乐趣(理想情况下两者都有),所以如果你一直梦想着构建某个东西,那就试试吧!逐步工作;不要一开始就瞄准月亮,但要专注于你的项目,逐步构建。这需要耐心和毅力,但当你拥有一个行走的机器人、一个工作的聊天机器人或你想要构建的任何其他东西时,这将是非常有回报的。
我最大的希望是这本书能激励你构建一个能让我们所有人受益的精彩机器学习应用!那会是什么呢?
—奥雷利安·热隆
2025 年 10 月 22 日
^(1) 想要了解更多细节,请务必查看理查德·萨顿和安德鲁·巴特罗关于强化学习的书籍,《强化学习:入门》(麻省理工学院出版社)。
^(2) DeepMind 在 2014 年被谷歌以超过 5 亿美元的价格收购。
^(3) Volodymyr Mnih 等人,“使用深度强化学习玩 Atari 游戏”,arXiv 预印本 arXiv:1312.5602(2013),https://homl.info/dqn。
^(4) Volodymyr Mnih 等人,“通过深度强化学习实现人类水平控制”,自然 518(2015):529–533,https://homl.info/dqn2。
^(5) 查看 DeepMind 的系统学习玩《太空侵略者》、《Breakout》和其他电子游戏的视频,请访问https://homl.info/dqn3。
^(6) 图像(a)、(d)和(e)属于公有领域。图像(b)是《Ms. Pac-Man》游戏的截图,版权属于 Atari(本章中为合理使用)。图像(c)来自维基百科;它是由用户 Stevertigo 创建并发布在Creative Commons BY-SA 2.0下。
^(7) 通常给表现不佳的人一点生存的机会,以保留“基因池”中的多样性。
^(8) 如果只有一个父母,这被称为无性繁殖。如果有两个(或更多)父母,则称为有性繁殖。后代(在这种情况下是一组策略参数)的基因组是随机由其父母基因组的部分组成的。
^(9) 强化学习中使用的遗传算法的一个有趣例子是增强拓扑结构的神经进化(NEAT)算法。还可以查看 2025 年提出的进化策略优化(EPO),其中主代理从一群代理的经验中稳定且高效地学习。
^(10) 这被称为梯度上升。它就像梯度下降,但方向相反:最大化而不是最小化。
^(11) 罗纳德·J·威廉姆斯,“用于连接主义强化学习的简单统计梯度跟随算法”,《机器学习》8 期(1992 年):229–256。
^(12) 我们为每个回合生成一个新的随机种子,使用torch.randint()。这确保了每个回合都是不同的,但如果我们在调用train_reinforce()之前设置 PyTorch 的随机种子,整个训练过程是可重现的。
^(13) 亚历克斯·伊尔潘在 2018 年发表的一篇优秀的博客文章很好地概述了强化学习最大的困难和局限性。
^(14) 理查德·贝尔曼,“马尔可夫决策过程”,《数学与力学杂志》6 卷第 5 期(1957 年):679–684。
^(15) 霍多·范·哈塞尔特等人,“使用双重 Q 学习的深度强化学习”,《第 30 届 AAAI 人工智能会议论文集》(2015 年):2094–2100。
^(16) 托马斯·沙乌等人,“优先经验回放”,arXiv 预印本 arXiv:1511.05952(2015 年)。
^(17) 也可能只是奖励是噪声的,在这种情况下,有更好的方法来估计经验的重要性(例如,参见“优先经验回放”中的某些示例)。
^(18) 王子瑜等人,“用于深度强化学习的对抗网络架构”,arXiv 预印本 arXiv:1511.06581(2015 年)。
^(19) 马泰奥·赫塞尔等人,“Rainbow:结合深度强化学习的改进”,arXiv 预印本 arXiv:1710.02298(2017 年):3215–3222。
^(20) 瓦洛迪米尔·米欣等人,“深度强化学习的异步方法”,《第 33 届国际机器学习会议论文集》(2016 年):1928–1937。
^(21) 托马斯·哈诺亚等人,“软演员-评论家:具有随机演员的离策略最大熵深度强化学习”,《第 35 届国际机器学习会议论文集》(2018 年):1856–1865。
^(22) 约翰·舒尔曼等人,“近端策略优化算法”,arXiv 预印本 arXiv:1707.06347(2017 年)。
^(23) 约翰·舒尔曼等人,“信任域策略优化”,《第 32 届国际机器学习会议论文集》(2015 年):1889–1897。
^(24) “v4”后缀是版本号;它与帧跳过或并行环境数量无关。
^(25) 大卫·西尔弗等人,“使用深度神经网络和树搜索掌握围棋”,《自然》529 期(2016 年):484–489。
^(26) 大卫·西尔弗等人,“无需人类知识的围棋掌握”,《自然》550 期(2017 年):354–359。
^(27) 大卫·西尔弗等,“通过通用强化学习算法自我对弈掌握国际象棋和将棋”,arXiv 预印本 arXiv:1712.01815.
^(28) 朱利安·施里特维瑟等,“通过学习模型进行规划以掌握 Atari、围棋、国际象棋和将棋”,arXiv 预印本 arXiv:1911.08265 (2019).
^(29) Deepak Pathak 等人,“通过自监督预测驱动的探索”,第 34 届国际机器学习会议论文集 (2017): 2778–2787.
^(30) 王瑞等,“成对开放式开拓者(POET):无限生成越来越复杂和多样化的学习环境及其解决方案”,arXiv 预印本 arXiv:1901.01753 (2019).
^(31) 王瑞等,“通过无界创造学习挑战及其解决方案增强 POET:开放式强化学习”,arXiv 预印本 arXiv:2003.08536 (2020).
^(32) 开放式学习团队等,“开放式学习导致通用智能体”,arXiv 预印本 arXiv:2107.12808 (2021).
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)