原文:
zh.annas-archive.org/md5/ea442d0490aad3d193551ec97eaf414e译者:飞龙
第五章:选择生成图像中的特征
本章涵盖了
-
构建条件生成对抗网络以生成具有特定属性的图像(例如,戴眼镜或不戴眼镜的人类面孔)
-
实现 Wasserstein 距离和梯度惩罚以改善图像质量
-
选择与不同特征相关的向量,以便训练的生成对抗网络模型生成具有特定特征的图像(例如,男性或女性面孔)
-
将条件生成对抗网络与向量选择相结合,以同时指定两个属性(例如,无眼镜的女性面孔或戴眼镜的男性面孔)
我们在第四章中用深度卷积生成对抗网络(DCGAN)生成的动漫面孔看起来很逼真。然而,你可能已经注意到,每个生成的图像都有不同的属性,例如发色、眼色以及头部是否向左或向右倾斜。你可能想知道是否有方法可以调整模型,使得生成的图像具有某些特征(例如,黑色头发且向左倾斜)。实际上,你可以做到。
在本章中,你将学习两种不同的方法来选择生成图像中的特征,以及它们各自的优缺点。第一种方法涉及在潜在空间中选择特定的向量。不同的向量对应不同的特征——例如,一个向量可能导致男性面孔,另一个可能导致女性面孔。第二种方法使用条件生成对抗网络(cGAN),这涉及到在标记数据上训练模型。这允许我们提示模型生成具有指定标签的图像,每个标签代表一个独特的特征——如戴眼镜或不戴眼镜的面孔。
此外,你将学会结合这两种方法,以便你可以同时选择图像的两个独立属性。结果,你可以生成四组不同的图像:戴眼镜的男性、无眼镜的男性、戴眼镜的女性和无眼镜的女性。为了使事情更有趣,你可以使用标签的加权平均值或输入向量的加权平均值来生成从一种属性过渡到另一种属性的图像。例如,你可以生成一系列图像,使得同一人的脸上的眼镜逐渐消失(标签算术)。或者你可以生成一系列图像,使得男性特征逐渐消失,男性面孔变成女性面孔(向量算术)。
能够单独进行向量算术或标签算术就像科幻小说一样,更不用说同时进行这两种操作了。整个体验让我们想起了亚瑟·C·克拉克(2001 太空漫游的作者)的名言:“任何足够先进的技术都与魔法无法区分。”
尽管第四章生成的动漫面孔在现实感上有所提高,但它们受到低分辨率的限制。训练 GAN 模型可能很棘手,并且经常受到样本量小或图像质量低等问题的影响。这些挑战可能会阻止模型收敛,导致图像质量差。为了解决这个问题,我们将在我们的 cGAN 中讨论并实现使用 Wasserstein 距离和梯度惩罚的改进训练技术。这种增强使得生成的人脸更加逼真,与上一章相比图像质量明显更好。
5.1 眼镜数据集
本章我们将使用眼镜数据集来训练一个 cGAN 模型。在下一章中,我们也将使用这个数据集在练习中训练一个 CycleGAN 模型:将戴眼镜的图片转换为不戴眼镜的图片,反之亦然。在本节中,你将学习如何下载数据集并预处理其中的图片。
本章和下一章中的 Python 程序改编自两个优秀的在线开源项目:Yashika Jain 的 Kaggle 项目mng.bz/JNVQ和 Aladdin Persson 的 GitHub 仓库mng.bz/w5yg。我鼓励你在阅读本章和下一章时查看这两个项目。
5.1.1 下载眼镜数据集
我们使用的眼镜数据集来自 Kaggle。登录 Kaggle 并访问链接mng.bz/q0oz下载图片文件夹以及右侧的两个 CSV 文件:train.csv和test.csv。文件夹/faces-spring-2020/中有 5,000 张图片。一旦你有了数据,请将图片文件夹和两个 CSV 文件都放在你电脑上的/fles/文件夹内。
接下来,我们将照片分类到两个子文件夹中:一个只包含戴眼镜的图片,另一个包含不戴眼镜的图片。
首先,让我们看看 train.csv 文件:
!pip install pandas
import pandas as pd
train=pd.read_csv('files/train.csv') ①
train.set_index('id', inplace=True) ②
① 将 train.csv 文件中的数据加载为 pandas DataFrame
② 将 id 列的值设置为观察的索引
之前的代码单元导入train.csv文件并将变量id设置为每个观察的索引。文件中的glasses列有两个值:0 或 1,表示图片中是否有眼镜(0 表示没有眼镜;1 表示有眼镜)。
接下来,我们将图片分为两个不同的文件夹:一个包含戴眼镜的图片,另一个包含不戴眼镜的图片。
列表 5.1 对戴眼镜和不戴眼镜的图片进行分类
import os, shutil
G='files/glasses/G/'
NoG='files/glasses/NoG/'
os.makedirs(G, exist_ok=True) ①
os.makedirs(NoG, exist_ok=True) ②
folder='files/faces-spring-2020/faces-spring-2020/'
for i in range(1,4501):
oldpath=f"{folder}face-{i}.png"
if train.loc[i]['glasses']==0: ③
newpath=f"{NoG}face-{i}.png"
elif train.loc[i]['glasses']==1: ④
newpath=f"{G}face-{i}.png"
shutil.move(oldpath, newpath)
① 创建一个子文件夹/files/glasses/G/来存放戴眼镜的图片
② 创建一个子文件夹/files/glasses/NoG/来存放不戴眼镜的图片
③ 将标签为 0 的图片移动到文件夹 NoG
④ 将标签为 1 的图片移动到文件夹 G
在前面的代码单元中,我们首先使用 os 库在您的计算机上的 /files/ 文件夹内创建两个子文件夹 /glasses/G/ 和 /glasses/NoG/。然后,我们使用 shutil 库根据文件 train.csv 中的标签 glasses 将图像移动到这两个文件夹。标签为 1 的图像被移动到文件夹 G,而标签为 0 的图像被移动到文件夹 NoG。
5.1.2 在眼镜数据集中可视化图像
文件 train.csv 中的分类列 glasses 并不完美。例如,如果您访问计算机上的子文件夹 G,您会看到大多数图像都有眼镜,但大约 10% 的图像没有眼镜。同样,如果您访问子文件夹 NoG,您会发现大约 10% 的图像实际上有眼镜。您需要手动纠正这个问题,通过将图像从一个文件夹移动到另一个文件夹。这对我们后续的训练非常重要,因此您应该手动移动两个文件夹中的图像,使得一个文件夹只包含有眼镜的图像,另一个文件夹只包含无眼镜的图像。欢迎来到数据科学家的生活:修复数据问题是日常工作的一个部分!让我们首先可视化一些有眼镜的图像示例。
列表 5.2 使用眼镜可视化图像
import random
import matplotlib.pyplot as plt
from PIL import Image
imgs=os.listdir(G)
random.seed(42)
samples=random.sample(imgs,16) ①
fig=plt.figure(dpi=200, figsize=(8,2))
for i in range(16): ②
ax = plt.subplot(2, 8, i + 1)
img=Image.open(f"{G}{samples[i]}")
plt.imshow(img)
plt.xticks([])
plt.yticks([])
plt.subplots_adjust(wspace=-0.01,hspace=-0.01)
plt.show()
① 从文件夹 G 中随机选择 16 个图像
② 在 2 × 8 网格中显示 16 个图像
如果您已经手动纠正了文件夹 G 中图像的错误标记,运行列表 5.2 中的代码后,您将看到 16 个有眼镜的图像。输出结果如图 5.1 所示。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-genai-pt/img/CH05_F01_Liu.png
图 5.1 训练数据集中有眼镜的样本图像
您可以将列表 5.2 中的 G 更改为 NoG 来可视化数据集中没有眼镜的 16 个样本图像。完整的代码在本书的 GitHub 仓库 github.com/markhliu/DGAI 中。输出结果如图 5.2 所示。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-genai-pt/img/CH05_F02_Liu.png
图 5.2 训练数据集中没有眼镜的样本图像
5.2 cGAN 和 Wasserstein 距离
cGAN 与您在第三章和第四章中看到的 GAN 模型类似,不同之处在于您为输入数据附加了一个标签。这些标签对应于输入数据中的不同特征。一旦训练好的 GAN 模型“学习”将某个标签与特征关联起来,您就可以向模型提供一个带有标签的随机噪声向量,以生成具有所需特征的输出。^(1)
GAN 模型通常会遇到诸如模式坍塌(生成器找到一种能够很好地欺骗判别器的输出类型,然后将输出坍塌到这些少数模式,忽略其他变化)、梯度消失和收敛缓慢等问题。Wasserstein GAN(WGAN)引入地球迁移距离(或 Wasserstein-1 距离)作为损失函数,提供更平滑的梯度流和更稳定的训练。它缓解了模式坍塌等问题.^(2) 我们将在本章中实现它,在 cGAN 训练中。请注意,WGAN 是一个独立于 cGAN 的概念:它使用 Wasserstein 距离来改进训练过程,可以应用于任何 GAN 模型(例如我们在第三章和第四章中创建的模型)。我们将结合这两个概念在一个设置中,以节省空间。
稳定 GAN 训练的其他方法
训练 GAN 模型时的问题在生成高分辨率图像时最为常见。模型架构通常很复杂,包含许多神经网络层。除了 WGAN 之外,渐进式 GAN 是另一种稳定训练的方法。渐进式 GAN 通过将高分辨率图像生成的复杂任务分解为可管理的步骤,从而增强了 GAN 训练的稳定性,允许更可控和有效的学习。有关详细信息,请参阅 Karas 等人撰写的“用于提高质量、稳定性和变化的 GAN 渐进式生长”,arxiv.org/abs/1710.10196。
5.2.1 带梯度惩罚的 WGAN
WGAN 是一种用于提高 GAN 模型训练稳定性和性能的技术。常规 GAN(例如你在第三章和第四章中看到的)有两个组件——生成器和判别器。生成器创建伪造数据,而判别器评估数据是否真实。训练涉及一个零和博弈的竞争,其中生成器试图欺骗判别器,而判别器试图准确分类真实和伪造数据实例。
研究人员提出了使用 Wasserstein 距离(两个分布之间差异的度量)而不是二元交叉熵作为损失函数,通过梯度惩罚项来稳定训练.^(3) 这种技术提供了更平滑的梯度流,并缓解了模式坍塌等问题。图 5.3 提供了 WGAN 的示意图。如图所示,与真实图像和伪造图像相关的损失是 Wasserstein 损失,而不是常规的二元交叉熵损失。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-genai-pt/img/CH05_F03_Liu.png
图 5.3 带有梯度惩罚的 WGAN。WGAN 中的判别器网络(我们称之为评论家)对输入图像进行评分:它试图将一个负无穷大的分数分配给一个假图像(左下角)和一个正无穷大的分数分配给真实图像(右上角)。此外,还向评论家展示了一个真实和假图像的插值图像(左上角),并在训练过程中将评论家对插值图像的梯度惩罚添加到总损失中。
此外,为了使 Wasserstein 距离能够正确工作,判别器(在 WGAN 中称为评论家)必须是 1-Lipschitz 连续的,这意味着评论家函数的梯度范数必须在任何地方都最多为 1。原始的 WGAN 论文提出了权重裁剪来强制执行 Lipschitz 约束。
为了解决权重裁剪问题,梯度惩罚被添加到损失函数中,以更有效地强制执行 Lipschitz 约束。为了实现带有梯度惩罚的 WGAN,我们首先在真实和生成数据点之间的直线上的点进行随机采样(如图 5.3 左上角的插值图像所示)。由于真实和假图像都附有标签,插值图像也附有标签,这是两个原始标签的插值值。然后,我们计算评论家输出对这些采样点的梯度。最后,我们将与 1(梯度惩罚项称为梯度惩罚)的偏差成比例的惩罚添加到损失函数中。也就是说,WGAN 中的梯度惩罚是一种通过更有效地强制执行 Lipschitz 约束来提高训练稳定性和样本质量的技术,解决了原始 WGAN 模型的局限性。
5.2.2 cGANs
cGAN 是基本 GAN 框架的扩展。在 cGAN 中,生成器和判别器(或评论家,因为我们是在相同的设置下实现 WGAN 和 cGAN)都基于一些额外的信息。这可能是一切,例如类别标签、来自其他模态的数据,甚至是文本描述。这种条件通常是通过将此附加信息输入到生成器和判别器中实现的。在我们的设置中,我们将类别标签添加到生成器和评论家的输入中:我们给戴眼镜的图像贴上标签,给不戴眼镜的图像贴上另一个标签。图 5.4 提供了 cGANs 训练过程的示意图。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-genai-pt/img/CH05_F04_Liu.png
图 5.4 cGANs 的训练过程
如您在图 5.4 的左上角所见,在 cGAN 中,生成器接收一个随机噪声向量和一个条件信息(一个标签,指示图像是否戴眼镜)作为输入。它使用这些信息生成看起来真实且与条件输入一致的数据。
评论家接收来自训练集的真实数据或生成器生成的伪造数据,以及条件信息(在我们的设置中,一个标签表示图像是否有眼镜)。其任务是确定给定数据是真实还是伪造,考虑条件信息(生成的图像中是否有眼镜?)。在图 5.4 中,我们使用评论家网络而不是判别器网络,因为我们同时实现了 cGAN 和 WGAN,但 cGAN 的概念也适用于传统的 GAN。
cGAN 的主要优势在于它们选择生成数据方面的能力,这使得它们更加灵活,适用于输出需要根据某些输入参数进行定向或条件化的场景。在我们的设置中,我们将训练 cGAN,以便我们能够选择生成的图像是否带有眼镜。
总结来说,cGAN 是基本 GAN 架构的一个强大扩展,它能够根据条件输入有针对性地生成合成数据。
5.3 创建 cGAN
在本节中,你将学习如何创建一个 cGAN 来生成带有或不带有眼镜的人类面部图像。你还将学习如何实现带有梯度惩罚的 WGAN 以稳定训练。
cGAN 中的生成器不仅使用随机噪声向量,还使用条件信息(如标签)作为输入来创建带有或不带有眼镜的图像。此外,WGAN 中的评论家网络与传统 GAN 中的判别器网络不同。你还将学习如何在本节中计算 Wasserstein 距离和梯度惩罚。
5.3.1 cGAN 中的评论家
在 cGAN 中,判别器是一个二元分类器,用于根据标签识别输入是真实还是伪造。在 WGAN 中,我们将判别器网络称为评论家。评论家评估输入并给出介于 −∞ 和 ∞ 之间的分数。分数越高,输入来自训练集(即真实)的可能性就越大。
列表 5.3 创建了评论家网络。其架构与我们第四章在生成动漫面孔彩色图像时使用的判别器网络有些相似。特别是,我们使用 PyTorch 中的七个 Conv2d 层逐步下采样输入,以便输出是一个介于 −∞ 和 ∞ 之间的单一值。
列表 5.3:具有 Wasserstein 距离的 cGAN 评论家网络
class Critic(nn.Module):
def __init__(self, img_channels, features):
super().__init__()
self.net = nn.Sequential( ①
nn.Conv2d(img_channels, features,
kernel_size=4, stride=2, padding=1),
nn.LeakyReLU(0.2),
self.block(features, features * 2, 4, 2, 1),
self.block(features * 2, features * 4, 4, 2, 1),
self.block(features * 4, features * 8, 4, 2, 1),
self.block(features * 8, features * 16, 4, 2, 1),
self.block(features * 16, features * 32, 4, 2, 1),
nn.Conv2d(features * 32, 1, kernel_size=4,
stride=2, padding=0)) ②
def block(self, in_channels, out_channels,
kernel_size, stride, padding):
return nn.Sequential( ③
nn.Conv2d(in_channels,out_channels,
kernel_size,stride,padding,bias=False,),
nn.InstanceNorm2d(out_channels, affine=True),
nn.LeakyReLU(0.2))
def forward(self, x):
return self.net(x)
① 评论家网络有两个 Conv2d 层加上五个块。
② 输出只有一个特征,没有激活。
③ 每个块包含一个 Conv2d 层,一个 InstanceNorm2d 层,以及 LeakyReLU 激活。
评论家网络的输入是一个形状为 5 × 256 × 256 的彩色图像。前三个通道是颜色通道(红色、绿色和蓝色)。最后两个通道(第四和第五通道)是标签通道,用于告诉评论家图像是否带有眼镜。我们将在下一节中讨论实现这一机制的确切方法。
批判网络由七个 Conv2d 层组成。在第四章中,我们深入讨论了这些层的工作原理。它们通过在输入图像上应用一组可学习的滤波器来提取特征,以检测不同空间尺度上的模式和特征,从而有效地捕获输入数据的层次表示。然后,批判器根据这些表示评估输入图像。中间的五 Conv2d 层后面都跟着一个 InstanceNorm2d 层和一个 LeakyReLU 激活函数;因此,我们定义了一个 block() 方法来简化批判网络。InstanceNorm2d 层与我们在第四章中讨论的 BatchNorm2d 层类似,不同之处在于我们独立地对批处理中的每个实例进行归一化。
另一个关键点是,输出不再是介于 0 和 1 之间的值,因为我们没有在批判网络的最后一层使用 sigmoid 激活函数。相反,由于我们在 cGAN 中使用 Wasserstein 距离和梯度惩罚,输出是一个介于 −∞ 和 ∞ 之间的值。
5.3.2 cGAN 中的生成器
在 WGAN 中,生成器的任务是创建数据实例,以便它们可以被批判器以高分数评估。在 cGAN 中,生成器必须生成具有条件信息的数据实例(在我们的设置中是带有或不带有眼镜)。由于我们正在实现一个使用 Wasserstein 距离的 cGAN,我们将通过将标签附加到随机噪声向量来告诉生成器我们想要生成哪种类型的图像。我们将在下一节中讨论具体的机制。
我们创建了以下列表中所示的神经网络来表示生成器。
列表 5.4 cGAN 中的生成器
class Generator(nn.Module):
def __init__(self, noise_channels, img_channels, features):
super(Generator, self).__init__()
self.net = nn.Sequential( ①
self.block(noise_channels, features *64, 4, 1, 0),
self.block(features * 64, features * 32, 4, 2, 1),
self.block(features * 32, features * 16, 4, 2, 1),
self.block(features * 16, features * 8, 4, 2, 1),
self.block(features * 8, features * 4, 4, 2, 1),
self.block(features * 4, features * 2, 4, 2, 1),
nn.ConvTranspose2d(
features * 2, img_channels, kernel_size=4,
stride=2, padding=1),
nn.Tanh()) ②
def block(self, in_channels, out_channels,
kernel_size, stride, padding):
return nn.Sequential( ③
nn.ConvTranspose2d(in_channels,out_channels,
kernel_size,stride,padding,bias=False,),
nn.BatchNorm2d(out_channels),
nn.ReLU(),)
def forward(self, x):
return self.net(x)
① 生成器由七个 ConvTranspose2d 层组成。
② 使用 Tanh 激活将值压缩到范围 [–1, 1],与训练集中的图像相同
③ 每个块由一个 ConvTranspose2d 层、一个 BatchNorm2d 层和 ReLU 激活函数组成。
我们将从 100 维潜在空间中随机噪声向量输入到生成器中。我们还将一个 2 值的一热编码图像标签输入到生成器中,以告诉它生成带有或不带有眼镜的图像。我们将这两部分信息连接起来,形成一个 102 维的输入变量输入到生成器中。然后,生成器根据潜在空间和标签信息生成彩色图像。
生成器网络由七个 ConvTranspose2d 层组成,其思路是镜像批判网络中的步骤来生成图像,正如我们在第四章中讨论的那样。前六个 ConvTranspose2d 层后面都跟着一个 BatchNorm2d 层和一个 ReLU 激活函数;因此,我们在生成器网络中定义了一个 block() 方法来简化架构。正如我们在第四章中所做的那样,我们在输出层使用 Tanh 激活函数,以便输出像素都在范围 -1 和 1 之间,与训练集中的图像相同。
5.3.3 权重初始化和梯度惩罚函数
在深度学习中,神经网络中的权重是随机初始化的。当网络架构复杂,存在许多隐藏层(在我们的设置中就是这样)时,权重的初始化方式至关重要。
因此,我们定义以下 weights_init() 函数来初始化生成器和评论家网络中的权重:
def weights_init(m):
classname = m.__class__.__name__
if classname.find('Conv') != -1:
nn.init.normal_(m.weight.data, 0.0, 0.02)
elif classname.find('BatchNorm') != -1:
nn.init.normal_(m.weight.data, 1.0, 0.02)
nn.init.constant_(m.bias.data, 0)
函数使用均值为 0 和标准差为 0.02 的正态分布值初始化 Conv2d 和 ConvTranspose2d 层的权重。它还使用均值为 1 和标准差为 0.02 的正态分布值初始化 BatchNorm2d 层的权重。我们在权重初始化中选择了较小的标准差,以避免梯度爆炸。
接下来,我们根据在上一个子节中定义的 Generator() 和 Critic() 类创建一个生成器和评论家。然后,我们根据之前定义的 weights_init() 函数初始化它们中的权重:
z_dim=100
img_channels=3
features=16
gen=Generator(z_dim+2,img_channels,features).to(device)
critic=Critic(img_channels+2,features).to(device)
weights_init(gen)
weights_init(critic)
如往常一样,我们将使用 Adam 优化器对评论家和生成器进行优化:
lr = 0.0001
opt_gen = torch.optim.Adam(gen.parameters(),
lr = lr, betas=(0.0, 0.9))
opt_critic = torch.optim.Adam(critic.parameters(),
lr = lr, betas=(0.0, 0.9))
生成器试图创建与给定标签的训练集中图像无法区分的图像。它将图像展示给评论家以获得对生成图像的高评分。另一方面,评论家试图在给定标签的条件下,对真实图像给予高评分,对假图像给予低评分。具体来说,评论家的损失函数有三个组成部分:
critic_value(fake) − critic_value(real) + weight × GradientPenalty
第一个项,critic_value(fake),表示如果一幅图像是假的,评论家的目标是将其识别为假并给予低评价。第二个项,− critic_value(real),表示如果图像是真实的,评论家的目标是将其识别为真实并给予高评价。此外,评论家还希望最小化梯度惩罚项,weight × GradientPenalty,其中weight是一个常数,用于确定我们希望分配给梯度范数偏差的惩罚程度。梯度惩罚的计算方法如下所示。
列表 5.5 计算梯度惩罚
def GP(critic, real, fake):
B, C, H, W = real.shape
alpha=torch.rand((B,1,1,1)).repeat(1,C,H,W).to(device)
interpolated_images = real*alpha+fake*(1-alpha) ①
critic_scores = critic(interpolated_images) ②
gradient = torch.autograd.grad(
inputs=interpolated_images,
outputs=critic_scores,
grad_outputs=torch.ones_like(critic_scores),
create_graph=True,
retain_graph=True)[0] ③
gradient = gradient.view(gradient.shape[0], -1)
gradient_norm = gradient.norm(2, dim=1)
gp = torch.mean((gradient_norm - 1) ** 2) ④
return gp
① 创建真实图像和假图像的插值图像
② 获取关于插值图像的评论家值
③ 计算评论家值的梯度
④ 梯度惩罚是梯度范数与值 1 的平方偏差。
在函数 GP() 中,我们首先创建真实图像和假图像的插值图像。这是通过沿真实图像和生成图像之间直线随机采样点来完成的。想象一个滑块:一端是真实图像,另一端是假图像。当你移动滑块时,你会看到从真实到假的连续混合,插值图像代表中间的阶段。
然后,我们将插值图像呈现给评论网络以获取对其的评分,并计算评论网络输出相对于插值图像的梯度。最后,梯度惩罚被计算为梯度范数与目标值 1 的平方偏差。
5.4 训练 cGAN
正如我们在上一节中提到的,我们需要找到一种方法来告诉评论网络和生成网络图像标签是什么,以便它们知道图像是否有眼镜。
在本节中,你将首先学习如何向评论网络和生成网络的输入添加标签,以便生成网络知道要创建哪种类型的图像,而评论网络可以基于标签评估图像。之后,你将学习如何使用 Wasserstein 距离训练 cGAN。
5.4.1 为输入添加标签
我们首先预处理数据并将图像转换为 torch 张量:
import torchvision.transforms as T
import torchvision
batch_size=16
imgsz=256
transform=T.Compose([
T.Resize((imgsz,imgsz)),
T.ToTensor(),
T.Normalize([0.5,0.5,0.5],[0.5,0.5,0.5])])
data_set=torchvision.datasets.ImageFolder(
root=r"files/glasses",
transform=transform)
我们将批处理大小设置为 16,图像大小为 256x256 像素。像素值被选择,以便生成的图像分辨率高于上一章中的图像(64x64 像素)。由于图像尺寸较大,我们选择批处理大小为 16,小于第三章中的批处理大小。如果批处理大小太大,你的 GPU(甚至 CPU)可能会耗尽内存。
提示:如果你使用 GPU 训练,并且你的 GPU 内存较小(比如 6GB),考虑将批处理大小减少到小于 16 的较小数字,例如 10 或 8,这样你的 GPU 就不会耗尽内存。或者,你可以保持批处理大小为 16,但切换到 CPU 训练以解决 GPU 内存问题。
接下来,我们将标签添加到训练数据中。由于有两种类型的图像——有眼镜的图像和无眼镜的图像——我们将创建两个 one-hot 图像标签。有眼镜的图像将有一个 one-hot 标签[1, 0],无眼镜的图像将有一个 one-hot 标签[0, 1]。
生成网络的输入是一个 100 个值的随机噪声向量。我们将 one-hot 标签与随机噪声向量连接,并将 102 个值的输入馈送到生成网络。评论网络输入是一个形状为 3x256x256 的三通道彩色图像(PyTorch 使用通道优先张量来表示图像)。我们如何将形状为 1x2 的标签附加到形状为 3x256x256 的图像上?解决方案是在输入图像中添加两个通道,使图像形状从(3, 256, 256)变为(5, 256, 256):这两个额外的通道是 one-hot 标签。具体来说,如果一个图像中有眼镜,第四个通道将填充 1,第五个通道填充 0;如果一个图像中没有眼镜,第四个通道将填充 0,第五个通道填充 1。
在特征值多于两个的情况下创建标签
您可以轻松地将 cGAN 模型扩展到具有两个以上值的特征。例如,如果您创建一个生成不同头发颜色(黑色、金色和白色)的图像的模型,您馈送到生成器的图像标签可以分别具有值 [1, 0, 0]、[0, 1, 0] 和 [0, 0, 1]。在您将图像馈送到判别器或评论家之前,您可以附加三个通道到输入图像。例如,如果一个图像有黑色头发,第四个通道填充 1s,第五和第六个通道填充 0s。
此外,在眼镜示例中,由于标签中只有两个值,当您将标签馈送到生成器时,您可以潜在地使用值 0 和 1 来表示戴眼镜和不戴眼镜的图像。在您将图像馈送到评论家之前,您可以附加一个通道到输入图像:如果一个图像有眼镜,第四个通道填充 1s;如果一个图像没有眼镜,第四个通道填充 0s。我将把这个留给你作为练习。解决方案在本书的 GitHub 仓库中提供:github.com/markhliu/DGAI。
我们按照以下列表所示实现此更改。
列表 5.6 将标签附加到输入图像
newdata=[]
for i,(img,label) in enumerate(data_set):
onehot=torch.zeros((2))
onehot[label]=1
channels=torch.zeros((2,imgsz,imgsz)) ①
if label==0:
channels[0,:,:]=1 ②
else:
channels[1,:,:]=1 ③
img_and_label=torch.cat([img,channels],dim=0) ④
newdata.append((img,label,onehot,img_and_label))
① 创建两个填充为 0s 的额外通道,每个通道的形状为 256x256,与输入图像中每个通道的维度相同
② 如果原始图像标签是 0,则将第四个通道填充为 1s
③ 如果原始图像标签是 1,则将第五个通道填充为 1s
④ 将第四和第五个通道添加到原始图像中,形成一个五通道标签图像
提示:在我们之前使用 torchvision.datasets.ImageFolder() 方法从文件夹 /files/glasses 加载图像时,PyTorch 按字母顺序将标签分配给每个子文件夹中的图像。因此,/files/glasses/G/ 中的图像被分配标签 0,而 /files/glasses/NoG/ 中的图像被分配标签 1。
我们首先创建一个空的列表 newdata 来保存带有标签的图像。我们创建一个形状为 (2, 256, 256) 的 PyTorch 张量,将其附加到原始输入图像上,以形成一个形状为 (5, 256, 256) 的新图像。如果原始图像标签是 0(这意味着图像来自文件夹 /files/glasses/G/),我们填充第四个通道为 1s,第五个通道为 0s,以便评论家知道这是一张戴眼镜的图像。另一方面,如果原始图像标签是 1(这意味着图像来自文件夹 /files/glasses/NoG/),我们填充第四个通道为 0s,第五个通道为 1s,以便评论家知道这是一张不带眼镜的图像。
我们创建一个具有批次的迭代器(为了提高训练过程中的计算效率、内存使用和优化动态),如下所示:
data_loader=torch.utils.data.DataLoader(
newdata,batch_size=batch_size,shuffle=True)
5.4.2 训练 cGAN
现在我们有了训练数据和两个网络,我们将训练 cGAN。我们将使用视觉检查来确定何时停止训练。
模型训练完成后,我们将丢弃评论家网络,并使用生成器创建具有特定特征(在我们的例子中是带眼镜或不带眼镜)的图像。
我们将创建一个函数来定期检查生成的图像的外观。
列表 5.7 检查生成的图像
def plot_epoch(epoch):
noise = torch.randn(32, z_dim, 1, 1)
labels = torch.zeros(32, 2, 1, 1)
labels[:,0,:,:]=1 ①
noise_and_labels=torch.cat([noise,labels],dim=1).to(device)
fake=gen(noise_and_labels).cpu().detach() ②
fig=plt.figure(figsize=(20,10),dpi=100)
for i in range(32): ③
ax = plt.subplot(4, 8, i + 1)
img=(fake.cpu().detach()[i]/2+0.5).permute(1,2,0)
plt.imshow(img)
plt.xticks([])
plt.yticks([])
plt.subplots_adjust(hspace=-0.6)
plt.savefig(f"files/glasses/G{epoch}.png")
plt.show()
noise = torch.randn(32, z_dim, 1, 1)
labels = torch.zeros(32, 2, 1, 1)
labels[:,1,:,:]=1 ④
… (code omitted)
① 为带眼镜的图像创建一个独热标签
② 将拼接的噪声向量和标签输入生成器以创建带眼镜的图像
③ 绘制带有眼镜的生成图像
④ 为不带眼镜的图像创建一个独热标签
每个训练周期结束后,我们将要求生成器创建一组带眼镜的图像和一组不带眼镜的图像。然后我们绘制这些图像,以便我们可以直观地检查它们。要创建带眼镜的图像,我们首先创建一个独热标签[1, 0],并将其附加到随机噪声向量上,然后再将拼接的向量输入到生成器网络中。由于标签是[1, 0]而不是[0, 1],生成器创建了带眼镜的图像。然后我们在四行八列中绘制生成的图像,并将子图保存在您的计算机上。创建不带眼镜的图像的过程类似,只是我们使用独热标签[0, 1]而不是[1, 0]。我在列表 5.7 中省略了一部分代码,但您可以在本书的 GitHub 仓库中找到它:github.com/markhliu/DGAI。
我们定义了一个train_batch()函数,用于使用一批数据训练模型。
列表 5.8 使用一批数据训练模型
def train_batch(onehots,img_and_labels,epoch):
real = img_and_labels.to(device) ①
B = real.shape[0]
for _ in range(5):
noise = torch.randn(B, z_dim, 1, 1)
onehots=onehots.reshape(B,2,1,1)
noise_and_labels=torch.cat([noise,onehots],dim=1).to(device)
fake_img = gen(noise_and_labels).to(device)
fakelabels=img_and_labels[:,3:,:,:].to(device)
fake=torch.cat([fake_img,fakelabels],dim=1).to(device) ②
critic_real = critic(real).reshape(-1)
critic_fake = critic(fake).reshape(-1)
gp = GP(critic, real, fake)
loss_critic=(-(torch.mean(critic_real) -
torch.mean(critic_fake)) + 10 * gp) ③
critic.zero_grad()
loss_critic.backward(retain_graph=True)
opt_critic.step()
gen_fake = critic(fake).reshape(-1)
loss_gen = -torch.mean(gen_fake) ④
gen.zero_grad()
loss_gen.backward()
opt_gen.step()
return loss_critic, loss_gen
① 带有标签的一批真实图像
② 带有标签的一批生成图像
③ 评论家的总损失有三个组成部分:评估真实图像的损失、评估伪造图像的损失以及梯度惩罚损失。
④ 使用 Wasserstein 损失训练生成器
在train_batch()函数中,我们首先使用真实图像训练评论家。我们还要求生成器根据给定的标签创建一批伪造数据。然后我们使用伪造图像训练评论家。在train_batch()函数中,我们还使用一批伪造数据训练生成器。
注意:评论家的损失有三个组成部分:评估真实图像的损失、评估伪造图像的损失以及梯度惩罚损失。
现在我们对模型进行 100 个周期的训练:
for epoch in range(1,101):
closs=0
gloss=0
for _,_,onehots,img_and_labels in data_loader: ①
loss_critic, loss_gen = train_batch(onehots,\
img_and_labels,epoch) ②
closs+=loss_critic.detach()/len(data_loader)
gloss+=loss_gen.detach()/len(data_loader)
print(f"at epoch {epoch},\
critic loss: {closs}, generator loss {gloss}")
plot_epoch(epoch)
torch.save(gen.state_dict(),'files/cgan.pth') ③
① 遍历训练数据集中的所有批次
② 使用一批数据训练模型
③ 保存训练生成器的权重
每个训练周期结束后,我们打印出评论家损失和生成器损失,以确保损失在合理的范围内。我们还使用我们之前定义的plot_epoch()函数生成 32 张带眼镜的面部图像以及 32 张不带眼镜的面部图像。训练完成后,我们在本地文件夹中保存训练生成器的权重,以便以后可以使用训练模型生成图像。
如果你使用 GPU 训练,这种训练大约需要 30 分钟。否则,可能需要几个小时,具体取决于你电脑的硬件配置。或者,你可以从我的网站下载训练好的模型:gattonweb.uky.edu/faculty/lium/gai/cgan.zip。下载后请解压文件。
5.5 在生成的图像中选择特征
生成具有特定特征的图像至少有两种方法。第一种是在将随机噪声向量输入到训练好的 cGAN 模型之前为其附加标签。不同的标签会导致生成的图像具有不同的特征(在我们的例子中,图像是否包含眼镜)。第二种方法是选择输入到训练模型的噪声向量:一个向量会导致具有男性面部的图像,另一个向量会导致具有女性面部的图像。请注意,第二种方法即使在传统的 GAN(如我们在第四章训练的 GAN)中也有效。它同样适用于 cGAN。
更好的是,在本节中,你将学习如何结合这两种方法,以便你可以同时选择两个特征:带有眼镜的男性面部或无眼镜的女性面部,等等。
在选择生成图像中的特定特征时,这两种方法各有优缺点。第一种方法,cGAN,需要标记数据来训练模型。有时,标记数据可能难以整理。然而,一旦你成功训练了一个 cGAN,你就可以生成具有特定特征的广泛图像。在我们的例子中,你可以生成许多不同的带有眼镜(或无眼镜)的图像;每张图像都与其他图像不同。第二种方法,手动选择噪声向量,不需要标记数据来训练模型。然而,每个手动选择的噪声向量只能生成一张图像。如果你想生成与 cGAN 相同特征的许多不同图像,你将需要事先手动选择许多不同的噪声向量。
5.5.1 选择是否带有眼镜的图像
在将随机噪声向量附加标签 [1, 0] 或 [0, 1] 并将其输入到训练好的 cGAN 模型之前,你可以选择生成的图像是否包含眼镜。
首先,我们将使用训练好的模型生成 32 张带有眼镜的图像,并在 4 × 8 网格中绘制它们。为了使结果可重复,我们将在 PyTorch 中固定随机状态。此外,我们将使用相同的随机噪声向量集,以便我们查看相同的面部集合。
我们将随机状态固定在种子 0,并生成 32 张带有眼镜的面部图像。
列表 5.9 生成带有眼镜的人类面部图像
torch.manual_seed(0) ①
generator=Generator(z_dim+2,img_channels,features).to(device)
generator.load_state_dict(torch.load("files/cgan.pth",
map_location=device)) ②
generator.eval()
noise_g=torch.randn(32, z_dim, 1, 1) ③
labels_g=torch.zeros(32, 2, 1, 1)
labels_g[:,0,:,:]=1 ④
noise_and_labels=torch.cat([noise_g,labels_g],dim=1).to(device)
fake=generator(noise_and_labels)
plt.figure(figsize=(20,10),dpi=50)
for i in range(32):
ax = plt.subplot(4, 8, i + 1)
img=(fake.cpu().detach()[i]/2+0.5).permute(1,2,0)
plt.imshow(img.numpy())
plt.xticks([])
plt.yticks([])
plt.subplots_adjust(wspace=-0.08,hspace=-0.01)
plt.show()
① 固定随机状态以确保结果可重复
② 加载训练好的权重
③ 生成一组随机噪声向量并将其保存,以便我们可以从中选择某些向量进行向量运算
④ 创建标签以生成带有眼镜的图像
我们创建了Generator()类的另一个实例,并将其命名为generator。然后我们加载上一节中保存在本地文件夹中的训练好的权重(或者你也可以从我的网站上下载权重:mng.bz/75Z4)。为了生成 32 张带眼镜的人类面部图像;我们首先在潜在空间中绘制 32 个随机噪声向量。我们还将创建一组标签,并将其命名为labels_g,它们告诉生成器生成 32 张带眼镜的图像。
如果你运行列表 5.9 中的程序,你会看到如图 5.5 所示的 32 张图像。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-genai-pt/img/CH05_F05_Liu.png
图 5.5 由训练好的 cGAN 模型生成的带眼镜的人类面部图像
首先,所有 32 张图像中确实都包含眼镜。这表明训练好的 cGAN 模型能够根据提供的标签生成图像。你可能已经注意到,有些图像具有男性特征,而有些图像具有女性特征。为了让我们为下一节中的向量运算做准备,我们将选择一个导致具有男性特征的图像和一个导致具有女性特征的图像的随机噪声向量。在检查图 5.5 中的 32 张图像后,我们选择了索引值为 0 和 14 的图像,如下所示:
z_male_g=noise_g[0]
z_female_g=noise_g[14]
为了生成 32 张无眼镜的图像,我们首先生成另一组随机噪声向量和标签:
noise_ng = torch.randn(32, z_dim, 1, 1)
labels_ng = torch.zeros(32, 2, 1, 1)
labels_ng[:,1,:,:]=1
新的随机噪声向量集被命名为noise_ng,新的标签集被命名为labels_ng。将它们输入到生成器中,你应该会看到 32 张没有眼镜的图像,如图 5.6 所示。
图 5.6 中的 32 张面孔中没有任何一张带有眼镜:训练好的 cGAN 模型可以根据给定的标签生成图像。我们选择了索引为 8(男性)和 31(女性)的图像,为下一节中的向量运算做准备:
z_male_ng=noise_ng[8]
z_female_ng=noise_ng[31]
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-genai-pt/img/CH05_F06_Liu.png
图 5.6 由训练好的 cGAN 模型生成的无眼镜的人类面部图像
接下来,我们将使用标签插值来执行标签运算。回想一下,两个标签noise_g和noise_ng分别指示训练好的 cGAN 模型创建带眼镜和不带眼镜的图像。如果我们向模型提供一个插值标签(两个标签[1, 0]和[0, 1]的加权平均值)会怎样?训练好的生成器会生成什么类型的图像?让我们来看看。
列表 5.10 cGAN 中的标签运算
weights=[0,0.25,0.5,0.75,1] ①
plt.figure(figsize=(20,4),dpi=300)
for i in range(5):
ax = plt.subplot(1, 5, i + 1)
# change the value of z
label=weights[i]*labels_ng[0]+(1-weights[i])*labels_g[0] ②
noise_and_labels=torch.cat(
[z_female_g.reshape(1, z_dim, 1, 1),
label.reshape(1, 2, 1, 1)],dim=1).to(device)
fake=generator(noise_and_labels).cpu().detach() ③
img=(fake[0]/2+0.5).permute(1,2,0)
plt.imshow(img)
plt.xticks([])
plt.yticks([])
plt.subplots_adjust(wspace=-0.08,hspace=-0.01)
plt.show()
① 创建五个权重
② 创建两个标签的加权平均值
③ 将新的标签给训练好的模型以创建图像
我们首先创建了五个权重(w):0,0.25,0.5,0.75 和 1,它们在 0 和 1 之间均匀分布。这五个 w 值中的每一个都是我们放在无眼镜标签 labels_ng 上的权重。互补权重放在眼镜标签 labels_g 上。因此,插值标签的值为 w*labels_ng+(1-w)*labels_g。然后我们将插值标签输入到训练好的模型中,以及我们之前保存的随机噪声向量 z_female_g。基于五个 w 值生成的五个图像被绘制在一个 1×5 的网格中,如图 5.7 所示。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-genai-pt/img/CH05_F07_Liu.png
图 5.7 cGAN 中的标签算术。我们首先创建两个标签:无眼镜标签 labels_ng 和眼镜标签 labels_g。这两个标签指示训练好的生成器分别生成带眼镜和不带眼镜的图像。然后我们创建了五个插值标签,每个标签都是原始两个标签的加权平均值:w*labels_ng+(1-w)*labels_g,其中权重 w 取五个不同的值,0,0.25,0.5,0.75 和 1。基于五个插值标签生成的五个图像显示在图中。最左边的图像有眼镜。当我们从左到右移动时,眼镜逐渐消失,直到最右边的图像中没有眼镜。
当你从左到右查看图 5.7 中的五个生成图像时,你会注意到眼镜逐渐消失。左边的图像有眼镜,而右边的图像没有眼镜。中间的三张图像显示了一些眼镜的迹象,但眼镜并不像第一张图像中那么显眼。
练习 5.1
由于我们在列表 5.10 中使用了随机噪声向量 z_female_g,图 5.7 中的图像具有女性脸。将噪声向量更改为列表 5.10 中的 z_male_g 并重新运行程序;看看图像是什么样的。
5.5.2 潜在空间中的向量算术
你可能已经注意到,一些生成的人脸图像具有男性特征,而另一些则具有女性特征。你可能想知道:我们能否在生成的图像中选择男性或女性特征?答案是肯定的。我们可以通过在潜在空间中选择噪声向量来实现这一点。
在上一个子节中,我们已经保存了两个随机噪声向量,z_male_ng 和 z_female_ng,分别对应男性脸和女性脸的图像。接下来,我们将这两个向量的加权平均值(即插值向量)输入到训练好的模型中,看看生成的图像是什么样的。
列表 5.11 向量算术以选择图像特征
weights=[0,0.25,0.5,0.75,1] ①
plt.figure(figsize=(20,4),dpi=50)
for i in range(5):
ax = plt.subplot(1, 5, i + 1)
# change the value of z
z=weights[i]*z_female_ng+(1-weights[i])*z_male_ng ②
noise_and_labels=torch.cat(
[z.reshape(1, z_dim, 1, 1),
labels_ng[0].reshape(1, 2, 1, 1)],dim=1).to(device)
fake=generator(noise_and_labels).cpu().detach() ③
img=(fake[0]/2+0.5).permute(1,2,0)
plt.imshow(img)
plt.xticks([])
plt.yticks([])
plt.subplots_adjust(wspace=-0.08,hspace=-0.01)
plt.show()
① 创建五个权重
② 创建两个随机噪声向量的加权平均值
③ 将新的随机噪声向量输入到训练好的模型中,以创建一个图像
我们创建了五个权重,0,0.25,0.5,0.75 和 1。我们遍历这五个权重,创建两个随机噪声向量的五个加权平均值,w*z_female_ng+(1-w)*z_male_ng。然后我们将这五个向量以及标签labels_ng输入到训练好的模型中,以获得五个图像,如图 5.8 所示。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-genai-pt/img/CH05_F08_Liu.png
图 5.8 GAN 中的向量算术。我们首先保存两个随机噪声向量z_female_ng和z_male_ng。这两个向量分别导致女性和男性面孔的图像。然后我们创建五个插值向量,每个向量都是原始两个向量的加权平均值:w*z_female_ng+(1-w)*z_male_ng,其中权重w取五个不同的值,0,0.25,0.5,0.75 和 1。基于五个插值向量的五个生成的图像如图所示。最左边的图像具有男性特征。当我们从左向右移动时,男性特征逐渐消失,女性特征逐渐出现,直到最右边的图像显示女性面孔。
向量算术可以从一个图像实例过渡到另一个图像实例。由于我们偶然选择了男性和女性图像,当你从左到右查看图 5.8 中的五个生成的图像时,你会注意到男性特征逐渐消失,女性特征逐渐出现。第一张图像显示男性面孔,而最后一张图像显示女性面孔。
练习 5.2
由于我们在列表 5.11 中使用了标签labels_ng,图 5.8 中的图像中没有眼镜。在列表 5.11 中将标签更改为labels_g并重新运行程序,以查看图像的外观。
5.5.3 同时选择两个特征
到目前为止,我们一次只选择一个特征。通过选择标签,你已经学会了如何生成带眼镜或不带眼镜的图像。通过选择特定的噪声向量,你已经学会了如何选择生成的图像的特定实例。
如果你想同时选择两个特征(例如眼镜和性别),这两个独立特征的组合有四种可能:有眼镜的男性面孔、无眼镜的男性面孔、有眼镜的女性面孔和无眼镜的女性面孔。接下来我们将生成每种类型的图像。
列表 5.12 同时选择两个特征
plt.figure(figsize=(20,5),dpi=50)
for i in range(4): ①
ax = plt.subplot(1, 4, i + 1)
p=i//2
q=i%2
z=z_female_g*p+z_male_g*(1-p) ②
label=labels_ng[0]*q+labels_g[0]*(1-q) ③
noise_and_labels=torch.cat(
[z.reshape(1, z_dim, 1, 1),
label.reshape(1, 2, 1, 1)],dim=1).to(device) ④
fake=generator(noise_and_labels)
img=(fake.cpu().detach()[0]/2+0.5).permute(1,2,0)
plt.imshow(img.numpy())
plt.xticks([])
plt.yticks([])
plt.subplots_adjust(wspace=-0.08,hspace=-0.01)
plt.show()
① 遍历 0 到 3
② p 的值可以是 0 或 1,用于选择随机噪声向量以生成男性或女性面孔。
③ q 的值可以是 0 或 1,用于选择标签以确定生成的图像中是否包含眼镜。
④ 将随机噪声向量与标签结合以选择两个特征
要生成四个图像以覆盖四种不同的情况,我们需要使用其中一个噪声向量作为输入:z_female_g 或 z_male_g。我们还需要将一个标签附加到输入上,这个标签可以是 labels_ng 或 labels_g。为了使用一个单独的程序覆盖所有四种情况,我们遍历 i 的四个值,0 到 3,并创建两个值,p 和 q,它们是 i 除以 2 的整数商和余数。因此,p 和 q 的值可以是 0 或 1。通过将随机噪声向量的值设置为 z_female_g*p+z_male_g*(1-p),我们可以选择一个随机噪声向量来生成男性或女性面孔。同样,通过将标签的值设置为 labels_ng[0]*q+labels_g[0]*(1-q),我们可以选择一个标签来决定生成的图像中是否包含眼镜。一旦我们将随机噪声向量与标签结合并输入到训练好的模型中,我们就可以同时选择两个特征。
如果你运行列表 5.12 中的程序,你会看到如图 5.9 所示的四个图像。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-genai-pt/img/CH05_F09_Liu.png
图 5.9 同时在生成的图像中选择两个特征。我们从以下两个选项中选择一个噪声向量:z_female_ng 和 z_male_ng。我们还从以下两个选项中选择一个标签:labels_ng 和 labels_g。然后我们将噪声向量和标签输入到训练好的生成器中,以创建一个图像。根据噪声向量和标签的值,训练好的模型可以创建四种类型的图像。通过这种方式,我们有效地在生成的图像中选择了两个独立特征:一个男性或女性面孔,以及图像中是否包含眼镜。
图 5.9 中生成的四个图像有两个独立特征:一个男性或女性面孔,以及图像中是否包含眼镜。第一幅图像显示了一个戴眼镜的男性面孔;第二幅图像是一个不戴眼镜的男性面孔。第三幅图像是一个戴眼镜的女性面孔,而最后一幅图像显示了一个不戴眼镜的女性面孔。
练习 5.3
我们在列表 5.12 中使用了两个随机噪声向量 z_female_g 和 z_male_g。将这两个随机噪声向量改为 z_female_ng 和 z_male_ng,然后重新运行程序,看看图像看起来像什么。
最后,我们可以同时进行标签算术和向量算术。也就是说,我们可以将插值噪声向量和插值标签输入到训练好的 cGAN 模型中,看看生成的图像是什么样子。你可以通过运行以下代码块来实现:
plt.figure(figsize=(20,20),dpi=50)
for i in range(36):
ax = plt.subplot(6,6, i + 1)
p=i//6
q=i%6
z=z_female_ng*p/5+z_male_ng*(1-p/5)
label=labels_ng[0]*q/5+labels_g[0]*(1-q/5)
noise_and_labels=torch.cat(
[z.reshape(1, z_dim, 1, 1),
label.reshape(1, 2, 1, 1)],dim=1).to(device)
fake=generator(noise_and_labels)
img=(fake.cpu().detach()[0]/2+0.5).permute(1,2,0)
plt.imshow(img.numpy())
plt.xticks([])
plt.yticks([])
plt.subplots_adjust(wspace=-0.08,hspace=-0.01)
plt.show()
代码与列表 5.12 中的代码类似,除了 p 和 q 每个都可以取六个不同的值:0、1、2、3、4 和 5。随机噪声向量 z_female_ng*p/5+z_male_ng*(1-p/5) 根据 p 的值取六个不同的值。标签 labels_ng[0]*q/5+labels_g[0]*(1-q/5) 根据 q 的值取六个不同的值。因此,基于插值噪声向量和插值标签,我们有 36 种不同的图像组合。如果你运行前面的程序,你会看到如图 5.10 所示的 36 张图像。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-genai-pt/img/CH05_F10_Liu.png
图 5.10 同时进行向量算术和标签算术。i 的值从 0 变化到 35;p 和 q 分别是 i 除以 6 的整数商和余数。因此,p 和 q 每个都可以取六个不同的值:0、1、2、3、4 和 5。插值噪声向量 z_female_ng*p/5+z_male_ng*(1-p/5) 和插值标签 labels_ng[0]*q/5+labels_g[0]*(1-q/5) 都可以取六个不同的值。在每一行中,从左到右,眼镜逐渐消失。在每一列中,从上到下,图像逐渐从男性面孔变为女性面孔。
图 5.10 中有 36 张图像。插值噪声向量是两个随机噪声向量 z_female_ng 和 z_male_ng 的加权平均值,分别生成女性面孔和男性面孔。标签是两个标签 labels_ng 和 labels_g 的加权平均值,它们决定生成的图像中是否有眼镜。训练模型根据插值噪声向量和插值标签生成 36 张不同的图像。在每一行中,从左到右,眼镜逐渐消失。也就是说,我们在每一行中进行标签算术。在每一列中,从上到下,图像逐渐从男性面孔变为女性面孔。也就是说,我们在每一列中进行向量算术。
练习 5.4
在这个项目中,标签中有两个值:一个表示眼镜,一个表示没有眼镜。因此,我们可以使用二进制值而不是 one-hot 变量作为标签。更改本章的程序,并使用值 1 和 0(而不是 [1, 0] 和 [0, 1])来表示有眼镜和无眼镜的图像。将 1 或 0 附着到随机噪声向量上,以便向生成器提供 101 值的向量。在将图像输入到评论家之前,附加一个通道:如果图像中有眼镜,第四个通道填充 0s;如果图像中没有眼镜,第四个通道填充 1s。然后创建一个生成器和评论家;使用训练数据集来训练它们。解决方案在本书的 GitHub 仓库中提供,包括本章其他三个练习的解决方案。
既然你已经见证了 GAN 模型的能力,你将在下一章通过使用 GAN 进行风格迁移来进一步探索。例如,你将学习如何构建 CycleGAN 模型,并使用名人面部图像对其进行训练,以便在这些图像中将金发变为黑发或将黑发变为金发。相同的模型可以在其他数据集上训练:例如,你可以在本章中使用的人类面部数据集上训练它,以便在人类面部图像中添加或移除眼镜。
摘要
-
通过在潜在空间中选择一个特定的噪声向量并将其输入到训练好的 GAN 模型中,我们可以选择生成图像中的某个特定特征,例如图像中是否有男性或女性面部。
-
cGAN 与传统的 GAN 不同。我们在标记数据上训练模型,并要求训练好的模型生成具有特定属性的数据。例如,一个标签告诉模型生成戴眼镜的人类面部图像,而另一个标签则告诉模型创建不戴眼镜的人类面部图像。
-
在 cGAN 训练完成后,我们可以使用一系列标签的加权平均来生成从由一个标签表示的图像过渡到由另一个标签表示的图像的图像——例如,一系列同一人面部上的眼镜逐渐消失的图像。我们称这种算术为标签算术。
-
我们还可以使用两个不同噪声向量的加权平均来创建从一种属性过渡到另一种属性的图像——例如,一系列男性特征逐渐消失,女性特征逐渐出现的图像。我们称这种向量为向量算术。
-
Wasserstein GAN (WGAN) 是一种通过使用 Wasserstein 距离而不是二元交叉熵作为损失函数来提高 GAN 模型训练稳定性和性能的技术。此外,为了 Wasserstein 距离能够正确工作,WGAN 中的评判器必须是 1-Lipschitz 连续的,这意味着评判器函数的梯度范数必须在任何地方都小于等于 1。WGAN 中的梯度惩罚通过向损失函数添加正则化项来更有效地强制执行 Lipschitz 约束。
^(1) Mehdi Mirza, Simon Osindero, 2014, “Conditional Generative Adversarial Nets.” arxiv.org/abs/1411.1784.
^(2) Martin Arjovsky, Soumith Chintala, 和 Léon Bottou, 2017, “Wasserstein GAN.” arxiv.org/abs/1701.07875.
^(3) Martin Arjovsky, Soumith Chintala, 和 Leon Bottou, 2017, “Wasserstein GAN.” arxiv.org/abs/1701.07875; 以及 Ishaan Gulrajani, Faruk Ahmed, Martin Arjovsky, Vincent Dumoulin, 和 Aaron Courville, 2017, “Improved Training of Wasserstein GANs.” arxiv.org/abs/1704.00028.
第六章:CycleGAN:将金发转换为黑发
本章涵盖
-
CycleGAN 及其循环一致性损失背后的理念
-
构建 CycleGAN 模型以将图像从一个领域转换为另一个领域
-
使用具有两个图像领域的任何数据集训练 CycleGAN
-
将黑发转换为金发,反之亦然
我们在前三章中讨论的生成对抗网络(GAN)模型都在尝试生成与训练集中图像无法区分的图像。
你可能想知道:我们能否将图像从一个领域转换为另一个领域,例如将马变成斑马,将黑发转换为金发或将金发转换为黑发,在图像中添加或去除眼镜,将照片转换为画作,或将冬季场景转换为夏季场景?实际上,你可以做到,你将在本章通过 CycleGAN 获得这样的技能!
CycleGAN 于 2017 年提出.^(1) CycleGAN 的关键创新是其能够在没有成对示例的情况下学习在不同领域之间进行转换。CycleGAN 有各种有趣和有用的应用,例如模拟面部老化或年轻化过程以协助数字身份验证或在不实际创建每个变体的情况下可视化不同颜色或图案的服装,以简化设计过程。
CycleGAN 使用循环一致性损失函数来确保原始图像可以从转换后的图像中重建,从而鼓励保留关键特征。循环一致性损失背后的理念真正巧妙,值得在此强调。本章中的 CycleGAN 有两个生成器:让我们分别称它们为黑发生成器和金发生成器。黑发生成器接收一张带有金发的图像(而不是像之前看到的随机噪声向量),并将其转换为一张带有黑发的图像,而金发生成器接收一张带有黑发的图像并将其转换为一张带有金发的图像。
为了训练模型,我们将一张带有黑发的真实图像给金发生成器,以生成一张带有金发的假图像。然后,我们将假金发图像给黑发生成器,将其转换回一张带有黑发的图像。如果两个生成器都工作得很好,经过一次往返转换后,原始黑发图像与假图像之间的差异很小。为了训练 CycleGAN,我们调整模型参数以最小化对抗损失和循环一致性损失的加权和。与第三章和第四章一样,对抗损失用于量化生成器欺骗判别器的能力以及判别器区分真实和假样本的能力。循环一致性损失,CycleGAN 的独特概念,衡量了原始图像和经过往返转换的假图像之间的差异。将循环一致性损失包含在总损失函数中是 CycleGAN 的关键创新。
在训练 CycleGAN 时,我们将黑发和金发图像作为两个域的例子。然而,该模型可以应用于任何两个图像域。为了强调这一点,我将要求你使用第五章中使用的带眼镜和不带眼镜的图像来训练相同的 CycleGAN 模型。解决方案在本书的 GitHub 仓库中提供(github.com/markhliu/DGAI),你将看到训练好的模型确实可以从人脸图像中添加或移除眼镜。
6.2 CycleGAN 与循环一致性损失
CycleGAN 扩展了基本的 GAN 架构,包括两个生成器和两个判别器。每个生成器-判别器对负责学习两个不同域之间的映射。它旨在将图像从一个域转换到另一个域(例如,马到斑马,夏景到冬景等),同时保留原始图像的关键特征。它使用循环一致性损失来确保可以从转换后的图像重建原始图像,从而鼓励保留关键特征。
在本节中,我们将首先讨论 CycleGAN 的架构。我们将强调 CycleGAN 的关键创新:循环一致性损失。
6.1.1 什么是 CycleGAN?
CycleGAN 由两个生成器和两个判别器组成。生成器将图像从一域转换到另一域,而判别器确定各自域中图像的真实性。这些网络能够将照片转换为模仿著名画家或特定艺术运动风格的美术作品,从而弥合艺术与技术之间的差距。它们还可以用于医疗保健领域,如将 MRI 图像转换为 CT 扫描或反之亦然,这在一种成像类型不可用或过于昂贵的情况下可能很有帮助。
在本章的项目中,我们将转换黑白头发和金发图像。因此,我们使用它们作为例子来解释 CycleGAN 的工作原理。图 6.1 是 CycleGAN 架构的示意图。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-genai-pt/img/CH06_F01_Liu.png
图 6.1 CycleGAN 的架构,用于将黑发图像转换为金发图像,以及将金发图像转换为黑发图像。该图还概述了用于最小化对抗损失的训练步骤。模型如何最小化循环一致性损失将在图 6.2 中解释。
为了训练 CycleGAN,我们使用来自我们希望转换的两个域的无配对数据集。我们将使用 48,472 张黑发名人脸图像和 29,980 张金发图像。我们调整模型参数以最小化对抗损失和循环一致性损失的总和。为了便于解释,我们将在图 6.1 中仅解释对抗损失。我将在下一小节中解释模型如何最小化循环一致性损失。
在每次训练迭代中,我们将真实的黑发图像(图 6.1 顶部左侧)输入到金发生成器中,以获得假的金发图像。然后,我们将假的金发图像与真实的金发图像一起输入到金发判别器(顶部中间)。金发判别器产生一个概率,表明每个图像是真实的金发图像。然后,我们将预测与真实值(图像是否为真实金发图像)进行比较,并计算判别器损失(Loss_D_Blond)以及生成器损失(Loss_G_Blond)。
同时,在每次训练迭代中,我们将真实的金发图像(中间左侧)输入到黑发生成器(底部左侧)以创建假的黑发图像。我们将假的黑发图像与真实图像一起展示给黑发判别器(中间底部),以获取它们是真实的预测。我们比较黑发判别器的预测与真实值,并计算判别器损失(Loss_D_Black)和生成器损失(Loss_G_Black)。我们同时训练生成器和判别器。为了训练两个判别器,我们调整模型参数以最小化判别器损失,即Loss_D_Black和Loss_D_Blond的总和。
6.1.2 循环一致性损失
为了训练两个生成器,我们调整模型参数以最小化对抗损失和循环一致性损失的总和。对抗损失是我们之前小节中讨论的Loss_G_Black和Loss_G_Blond的总和。为了解释循环一致性损失,让我们看看图 6.2。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-genai-pt/img/CH06_F02_Liu.png
图 6.2 CycleGAN 如何最小化原始黑发图像和经过往返后的假黑发图像之间的循环一致性损失,以及原始金发图像和经过往返后的假金发图像之间的循环一致性损失
CycleGAN 中生成器的损失函数由两部分组成。第一部分,对抗损失,确保生成的图像在目标域中与真实图像不可区分。例如,Loss_G_Blond(在之前的小节中定义)确保由金发生成器产生的假金发图像与训练集中真实金发图像相似。第二部分,循环一致性损失,确保从一个域转换到另一个域的图像可以转换回原始域。
循环一致性损失是 CycleGANs 的关键组成部分,确保在经过一次往返翻译后可以恢复原始输入图像。其理念是,如果你将真实的黑色头发图像(图 6.2 的左上角)翻译成假的金色头发图像,并将其转换回假的黑头发图像(右上角),你应该得到一个接近原始黑色头发图像的图像。黑色头发图像的循环一致性损失是假图像与原始真实图像在像素级别的平均绝对误差。让我们称这个损失为 Loss_Cycle_Black。同样的,将金色头发翻译成黑色头发然后再翻译回金色头发,我们称这个损失为 Loss_Cycle_Blond。总的循环一致性损失是 Loss_Cycle_Black 和 Loss_Cycle_Blond 的总和。
6.2 明星面部数据集
我们将使用黑色头发和金色头发的明星面部图像作为两个域。你将首先在本节中下载数据。然后,你将处理图片,以便在本章后面的训练中准备它们。
在本章中,你将使用两个新的 Python 库:pandas 和 albumentations。为了安装这些库,请在你的计算机上的 Jupyter Notebook 应用程序的新单元格中执行以下代码行:
!pip install pandas albumentations
按照屏幕上的说明完成安装。
6.2.1 下载明星面部数据集
要下载明星面部数据集,请登录 Kaggle 并访问链接 mng.bz/Ompo。下载后解压数据集,并将所有图片文件放置在你的计算机上 /files/img_align_celeba/img_align_celeba/ 文件夹内(注意文件夹内有一个同名子文件夹)。该文件夹中大约有 200,000 张图片。同时从 Kaggle 下载文件 list_attr_celeba.csv 并将其放置在你的计算机上的 /files/ 文件夹中。CSV 文件指定了每张图片的各种属性。
明星面部数据集包含许多不同颜色的头发图片:棕色、灰色、黑色、金色等等。我们将选择黑色或金色头发的图片作为我们的训练集,因为这两种类型在明星面部数据集中最为丰富。运行以下列表中的代码以选择所有黑色或金色头发的图片。
列表 6.1 选择黑色或金色头发的图片
import pandas as pd
import os, shutil
df=pd.read_csv("files/list_attr_celeba.csv") ①
os.makedirs("files/black", exist_ok=True)
os.makedirs("files/blond", exist_ok=True) ②
folder="files/img_align_celeba/img_align_celeba"
for i in range(len(df)):
dfi=df.iloc[i]
if dfi['Black_Hair']==1: ③
try:
oldpath=f"{folder}/{dfi['image_id']}"
newpath=f"files/black/{dfi['image_id']}"
shutil.move(oldpath, newpath)
except:
pass
elif dfi['Blond_Hair']==1: ④
try:
oldpath=f"{folder}/{dfi['image_id']}"
newpath=f"files/blond/{dfi['image_id']}"
shutil.move(oldpath, newpath)
except:
pass
① 加载包含图像属性的 CSV 文件
② 创建两个文件夹以存储黑色和金色头发的图片
③ 如果属性 Black_Hair 为 1,则将图片移动到黑色文件夹。
④ 如果属性 Blond_Hair 为 1,则将图片移动到金色文件夹。
我们首先使用 pandas 库加载文件 list_attr_celeba.csv,以便我们知道每张图像中是否包含黑色或金色头发。然后我们在本地创建两个文件夹,/files/black/ 和 /files/blond/,分别存储黑色和金色头发的图像。列表 6.1 然后遍历数据集中的所有图像。如果图像的属性 Black_Hair 为 1,则将其移动到文件夹 /files/black/;如果图像的属性 Blond_Hair 为 1,则将其移动到文件夹 /files/blond/。您将看到 48,472 张黑色头发图像和 29,980 张金色头发图像。图 6.3 显示了一些图像示例。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-genai-pt/img/CH06_F03_Liu.png
图 6.3 黑色或金色头发名人面部样本图像
图 6.3 顶部行的图像为黑色头发,而底部行的图像为金色头发。此外,图像质量很高:所有面部都位于前方中央,头发颜色易于识别。训练数据的数量和质量将有助于 CycleGAN 模型的训练。
6.2.2 处理黑白头发图像数据
我们将泛化 CycleGAN 模型,使其能够训练任何具有两个图像域的数据集。我们还将定义一个 LoadData() 类来处理 CycleGAN 模型的训练数据集。该函数可以应用于任何具有两个域的数据集,无论是不同头发颜色的面部图像,还是带有或没有眼镜的图像,或者是夏季和冬季场景的图像。
为了实现这一点,我们创建了一个本地模块 ch06util。从本书的 GitHub 仓库 (github.com/markhliu/DGAI) 下载文件 ch06util.py 和 __init__.py,并将它们放置在您的计算机上的 /utils/ 文件夹中。在本地模块中,我们定义了以下 LoadData() 类。
列表 6.2 CycleGAN 中处理训练数据的 LoadData() 类
class LoadData(Dataset):
def __init__(self, root_A, root_B, transform=None): ①
super().__init__()
self.root_A = root_A
self.root_B = root_B
self.transform = transform
self.A_images = []
for r in root_A:
files=os.listdir(r)
self.A_images += [r+i for i in files]
self.B_images = []
for r in root_B: ②
files=os.listdir(r)
self.B_images += [r+i for i in files]
self.len_data = max(len(self.A_images),
len(self.B_images))
self.A_len = len(self.A_images)
self.B_len = len(self.B_images)
def __len__(self): ③
return self.len_data
def __getitem__(self, index): ④
A_img = self.A_images[index % self.A_len]
B_img = self.B_images[index % self.B_len]
A_img = np.array(Image.open(A_img).convert("RGB"))
B_img = np.array(Image.open(B_img).convert("RGB"))
if self.transform:
augmentations = self.transform(image=B_img,
image0=A_img)
B_img = augmentations["image"]
A_img = augmentations["image0"]
return A_img, B_img
① 两个文件夹 root_A 和 root_B 是存储两个域中图像的位置
② 加载每个域中的所有图像
③ 定义了一种计算数据集长度的方法
④ 定义了一种访问每个域中各个元素的方法
LoadData() 类继承自 PyTorch 中的 Dataset 类。两个列表 root_A 和 root_B 分别包含域 A 和 B 中的图像文件夹。该类加载两个域中的图像,并生成一对图像,一个来自域 A,一个来自域 B,以便我们稍后可以使用这对图像来训练 CycleGAN 模型。
如前几章所述,我们创建了一个具有批次的迭代器以改进计算效率、内存使用和训练过程中的优化动态。
列表 6.3 处理用于训练的黑白头发图像
transforms = albumentations.Compose(
[albumentations.Resize(width=256, height=256), ①
albumentations.HorizontalFlip(p=0.5),
albumentations.Normalize(mean=[0.5, 0.5, 0.5],
std=[0.5, 0.5, 0.5],max_pixel_value=255), ②
ToTensorV2()],
additional_targets={"image0": "image"})
dataset = LoadData(root_A=["files/black/"],
root_B=["files/blond/"],
transform=transforms) ③
loader=DataLoader(dataset,batch_size=1,
shuffle=True, pin_memory=True) ④
① 将图像调整大小为 256x256 像素
② 将图像归一化到 -1 到 1 的范围
③ 在图像上应用 LoadData() 类
④ 创建用于训练的数据迭代器
我们首先在 albumentations 库(以其快速和灵活的图像增强而闻名)中定义了一个 Compose() 类的实例,并将其命名为 transforms。该类以多种方式转换图像:它将图像调整大小到 256x256 像素,并将值归一化到 -1 到 1 的范围内。列表 6.3 中的 HorizontalFlip() 参数在训练集中创建原始图像的镜像。水平翻转是一种简单而强大的增强技术,可以增强训练数据的多样性,帮助模型更好地泛化并变得更加鲁棒。增强和尺寸增加提高了 CycleGAN 模型的性能,并使生成的图像更加逼真。
然后,我们将 LoadData() 类应用于黑发和金发图像。由于图像文件大小很大,我们将批大小设置为 1,并在每次迭代中使用一对图像来训练模型。将批大小设置为超过 1 可能会导致您的机器内存不足。
6.3 构建 CycleGAN 模型
在本节中,我们将从头开始构建 CycleGAN 模型。我们将非常小心地使我们的 CycleGAN 模型通用,以便可以使用具有两个图像域的任何数据集进行训练。因此,我们将使用 A 和 B 来表示两个域,而不是例如黑发和金发图像。作为一个练习,你将使用第五章中使用的眼镜数据集来训练相同的 CycleGAN 模型。这有助于你通过使用不同的数据集将本章学到的技能应用于其他实际应用。
6.3.1 创建两个判别器
即使 CycleGAN 有两个判别器,它们在事前是相同的。因此,我们将创建一个单一的 Discriminator() 类,然后实例化该类两次:一个实例是判别器 A,另一个是判别器 B。CycleGAN 中的两个域是对称的,我们称之为域 A:黑发图像或金发图像都无关紧要。
打开你刚刚下载的文件 ch06util.py。在其中,我定义了 Discriminator() 类。
列表 6.4 在 CycleGAN 中定义 Discriminator() 类
class Discriminator(nn.Module):
def __init__(self, in_channels=3, features=[64,128,256,512]):
super().__init__()
self.initial = nn.Sequential(
nn.Conv2d(in_channels,features[0], ①
kernel_size=4,stride=2,padding=1,
padding_mode="reflect"),
nn.LeakyReLU(0.2, inplace=True))
layers = []
in_channels = features[0]
for feature in features[1:]: ②
layers.append(Block(in_channels, feature,
stride=1 if feature == features[-1] else 2))
in_channels = feature
layers.append(nn.Conv2d(in_channels,1,kernel_size=4, ③
stride=1,padding=1,padding_mode="reflect"))
self.model = nn.Sequential(*layers)
def forward(self, x):
out = self.model(self.initial(x))
return torch.sigmoid(out) ④
① 第一个 Conv2d 层有 3 个输入通道和 64 个输出通道。
② 另外三个 Conv2d 层,分别有 126、256 和 512 个输出通道
③ 最后一个 Conv2d 层有 512 个输入通道和 1 个输出通道。
④ 在输出上应用 sigmoid 激活函数,以便它可以被解释为概率
之前的代码列表定义了判别器网络。其架构与第四章中的判别器网络和第五章中的评论网络相似。主要组件是五个Conv2d层。我们在最后一层应用 sigmoid 激活函数,因为判别器执行的是二元分类问题。判别器以三通道彩色图像为输入,并产生一个介于 0 到 1 之间的单个数字,这可以解释为输入图像是域中真实图像的概率。
我们在列表 6.4 中使用的padding_mode="reflect"参数意味着添加到输入张量中的填充是输入张量本身的反射。反射填充通过不在边界引入人工零值来帮助保留边缘信息。它在输入张量的边界处创建更平滑的过渡,这对我们在设置中区分不同域中的图像是有益的。
然后我们创建了两个类的实例,分别命名为disc_A和disc_B:
from utils.ch06util import Discriminator, weights_init ①
import torch
device = "cuda" if torch.cuda.is_available() else "cpu"
disc_A = Discriminator().to(device)
disc_B = Discriminator().to(device) ②
weights_init(disc_A)
weights_init(disc_B) ③
① 从本地模块导入判别器类
② 创建判别器类的两个实例
③ 初始化权重
在本地模块ch06util中,我们还定义了一个weights_init()函数来初始化模型权重。该函数的定义方式与第五章中的类似。然后我们在两个新创建的判别器disc_A和disc_B中初始化权重。
现在我们有了两个判别器,接下来我们将创建两个生成器。
6.3.2 创建两个生成器
类似地,我们在本地模块中定义了一个单独的Generator()类,并实例化了该类两次:一个实例是生成器 A,另一个是生成器 B。在您刚刚下载的ch06util.py文件中,我们定义了Generator()类。
列表 6.5 CycleGAN 中的Generator()类
class Generator(nn.Module):
def __init__(self, img_channels, num_features=64,
num_residuals=9):
super().__init__()
self.initial = nn.Sequential(
nn.Conv2d(img_channels,num_features,kernel_size=7,
stride=1,padding=3,padding_mode="reflect",),
nn.InstanceNorm2d(num_features),
nn.ReLU(inplace=True))
self.down_blocks = nn.ModuleList(
[ConvBlock(num_features,num_features*2,kernel_size=3,
stride=2, padding=1),
ConvBlock(num_features*2,num_features*4,kernel_size=3, ①
stride=2,padding=1)])
self.res_blocks = nn.Sequential( ②
*[ResidualBlock(num_features * 4)
for _ in range(num_residuals)])
self.up_blocks = nn.ModuleList(
[ConvBlock(num_features * 4, num_features * 2,
down=False, kernel_size=3, stride=2,
padding=1, output_padding=1),
ConvBlock(num_features * 2, num_features * 1, ③
down=False,kernel_size=3, stride=2,
padding=1, output_padding=1)])
self.last = nn.Conv2d(num_features * 1, img_channels,
kernel_size=7, stride=1,
padding=3, padding_mode="reflect")
def forward(self, x):
x = self.initial(x)
for layer in self.down_blocks:
x = layer(x)
x = self.res_blocks(x)
for layer in self.up_blocks:
x = layer(x)
return torch.tanh(self.last(x)) ④
① 三个Conv2d层
② 九个残差块
③ 两个上采样块
④ 在输出上应用 tanh 激活
生成器网络由几个Conv2d层组成,后面跟着九个残差块(我将在后面详细解释)。然后,网络有两个上采样块,包括一个ConvTranspose2d层、一个InstanceNorm2d层和一个ReLU激活。正如我们在前面的章节中所做的那样,我们在输出层使用 tanh 激活函数,因此输出像素都在-1 到 1 的范围内,与训练集中的图像相同。
生成器中的残差块在本地模块中的定义如下:
class ConvBlock(nn.Module):
def __init__(self, in_channels, out_channels,
down=True, use_act=True, **kwargs):
super().__init__()
self.conv = nn.Sequential(
nn.Conv2d(in_channels, out_channels,
padding_mode="reflect", **kwargs)
if down
else nn.ConvTranspose2d(in_channels,
out_channels, **kwargs),
nn.InstanceNorm2d(out_channels),
nn.ReLU(inplace=True) if use_act else nn.Identity())
def forward(self, x):
return self.conv(x)
class ResidualBlock(nn.Module):
def __init__(self, channels):
super().__init__()
self.block = nn.Sequential(
ConvBlock(channels,channels,kernel_size=3,padding=1),
ConvBlock(channels,channels,
use_act=False, kernel_size=3, padding=1))
def forward(self, x):
return x + self.block(x)
残差连接是深度学习中的一个概念,尤其是在深度神经网络的设计中。你会在本书后面的内容中经常看到它。这是一种用于解决深度网络中经常出现的梯度消失问题的技术。在残差块中,它是具有残差连接的网络的基本单元,输入通过一系列变换(如卷积、激活和批量或实例归一化)传递,然后将其加回到这些变换的输出。图 6.4 展示了之前定义的残差块的架构。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-genai-pt/img/CH06_F04_Liu.png
图 6.4 残差块的架构。输入 x 通过一系列变换(两套 Conv2d 层和 InstanceNorm2d 层以及中间的 ReLU 激活)。然后输入 x 被加回到这些变换的输出 f(x) 上。因此,残差块的输出是 x + f(x)。
每个残差块中的变换是不同的。在这个例子中,输入 x 通过两套 Conv2d 层和 InstanceNorm2d 层以及中间的 ReLU 激活。然后输入 x 被加回到这些变换的输出 f(x) 上,形成最终的输出 x+f(x),因此得名残差连接。
接下来,我们创建两个 Generator() 类的实例,并将其中一个命名为 gen_A,另一个命名为 gen_B:
from utils.ch06util import Generator
gen_A = Generator(img_channels=3, num_residuals=9).to(device)
gen_B = Generator(img_channels=3, num_residuals=9).to(device)
weights_init(gen_A)
weights_init(gen_B)
在训练模型时,我们将使用平均绝对误差(即 L1 损失)来衡量循环一致性损失。我们将使用平均平方误差(即 L2 损失)来衡量对抗损失。当数据有噪声且有许多异常值时,通常使用 L1 损失,因为它对极端值的惩罚小于 L2 损失。因此,我们导入以下损失函数:
import torch.nn as nn
l1 = nn.L1Loss()
mse = nn.MSELoss()
g_scaler = torch.cuda.amp.GradScaler()
d_scaler = torch.cuda.amp.GradScaler()
L1 和 L2 损失都是在像素级别计算的。原始图像和伪造图像的形状都是 (3, 256, 256)。为了计算损失,我们首先计算两个图像在每个 3 × 256 × 256 = 196608 个位置上对应像素值之间的差异(对于 L1 损失是差异的绝对值,对于 L2 损失是差异的平方值),然后对这些位置的差异进行平均。
我们将使用 PyTorch 的自动混合精度包 torch.cuda.amp 来加速训练。PyTorch 张量的默认数据类型是 float32,这是一个 32 位的浮点数,它占用的内存是 16 位浮点数 float16 的两倍。对前者的操作比后者慢。精度和计算成本之间存在权衡。使用哪种数据类型取决于手头的任务。torch.cuda.amp 提供了自动混合精度,其中一些操作使用 float32,而其他操作使用 float16。混合精度试图将每个操作匹配到适当的数据类型以加速训练。
正如我们在第四章中所做的那样,我们将使用 Adam 优化器对判别器和生成器进行优化:
lr = 0.00001
opt_disc = torch.optim.Adam(list(disc_A.parameters()) +
list(disc_B.parameters()),lr=lr,betas=(0.5, 0.999))
opt_gen = torch.optim.Adam(list(gen_A.parameters()) +
list(gen_B.parameters()),lr=lr,betas=(0.5, 0.999))
接下来,我们将使用带有黑发或金发的图像来训练 CycleGAN 模型。
6.4 使用 CycleGAN 在黑发和金发之间进行转换
现在我们已经有了训练数据和 CycleGAN 模型,我们将使用带有黑发或金发的图像来训练模型。与所有 GAN 模型一样,训练完成后我们将丢弃判别器。我们将使用两个训练好的生成器将黑发图像转换为金发图像,并将金发图像转换为黑发图像。
6.4.1 训练 CycleGAN 在黑发和金发之间进行转换
正如我们在第四章中解释的,我们将使用视觉检查来确定何时停止训练。为此,我们创建了一个函数来测试真实图像和相应生成的图像看起来像什么,这样我们就可以比较两者以视觉检查模型的有效性。在本地模块ch06util中,我们定义了一个test()函数:
def test(i,A,B,fake_A,fake_B):
save_image(A*0.5+0.5,f"files/A{i}.png")
save_image(B*0.5+0.5,f"files/B{i}.png") ①
save_image(fake_A*0.5+0.5,f"files/fakeA{i}.png")
save_image(fake_B*0.5+0.5,f"files/fakeB{i}.png") ②
① 在本地文件夹中保存的域 A 和 B 中的真实图像
② 由第 i 批生成器在域 A 和 B 中创建的对应伪造图像
我们在每 100 个批次训练后保存四张图像。我们在本地文件夹中保存两个域中的真实图像和相应伪造图像,这样我们可以定期检查生成的图像,并将它们与真实图像进行比较,以评估训练进度。我们使该函数通用,以便它可以应用于任何两个域的图像。
此外,我们在本地模块ch06util中定义了一个train_epoch()函数,用于在一个 epoch 中训练判别器和生成器。以下列表突出了我们用来训练两个判别器的代码。
列表 6.6 在 CycleGAN 中训练两个判别器
def train_epoch(disc_A, disc_B, gen_A, gen_B, loader, opt_disc,
opt_gen, l1, mse, d_scaler, g_scaler,device):
loop = tqdm(loader, leave=True)
for i, (A,B) in enumerate(loop): ①
A=A.to(device)
B=B.to(device)
with torch.cuda.amp.autocast(): ②
fake_A = gen_A(B)
D_A_real = disc_A(A)
D_A_fake = disc_A(fake_A.detach())
D_A_real_loss = mse(D_A_real,
torch.ones_like(D_A_real))
D_A_fake_loss = mse(D_A_fake,
torch.zeros_like(D_A_fake))
D_A_loss = D_A_real_loss + D_A_fake_loss
fake_B = gen_B(A)
D_B_real = disc_B(B)
D_B_fake = disc_B(fake_B.detach())
D_B_real_loss = mse(D_B_real,
torch.ones_like(D_B_real))
D_B_fake_loss = mse(D_B_fake,
torch.zeros_like(D_B_fake))
D_B_loss = D_B_real_loss + D_B_fake_loss
D_loss = (D_A_loss + D_B_loss) / 2 ③
opt_disc.zero_grad()
d_scaler.scale(D_loss).backward()
d_scaler.step(opt_disc)
d_scaler.update()
…
① 遍历两个域中所有图像的对
② 使用 PyTorch 自动混合精度包加速训练
③ 两个判别器的总损失是两个判别器对抗损失的简单平均值。
我们在这里使用detach()方法来移除张量fake_A和fake_B中的梯度,以减少内存并加快计算速度。两个判别器的训练与我们在第四章中做的是类似的,有一些不同之处。首先,我们这里有两个判别器,而不是一个:一个用于域 A 中的图像,另一个用于域 B 中的图像。两个判别器的总损失是两个判别器对抗损失的简单平均值。其次,我们使用 PyTorch 自动混合精度包来加速训练,将训练时间减少了 50%以上。
我们在同一迭代中同时训练两个生成器。以下列表突出了我们用来训练两个生成器的代码。
列表 6.7 在 CycleGAN 中训练两个生成器
def train_epoch(disc_A, disc_B, gen_A, gen_B, loader, opt_disc,
opt_gen, l1, mse, d_scaler, g_scaler,device):
…
with torch.cuda.amp.autocast():
D_A_fake = disc_A(fake_A)
D_B_fake = disc_B(fake_B)
loss_G_A = mse(D_A_fake, torch.ones_like(D_A_fake))
loss_G_B = mse(D_B_fake, torch.ones_like(D_B_fake)) ①
cycle_B = gen_B(fake_A)
cycle_A = gen_A(fake_B)
cycle_B_loss = l1(B, cycle_B)
cycle_A_loss = l1(A, cycle_A) ②
G_loss=loss_G_A+loss_G_B+cycle_A_loss*10+cycle_B_loss*10 ③
opt_gen.zero_grad()
g_scaler.scale(G_loss).backward()
g_scaler.step(opt_gen)
g_scaler.update()
if i % 100 == 0:
test(i,A,B,fake_A,fake_B) ④
loop.set_postfix(D_loss=D_loss.item(),G_loss=G_loss.item())
① 对两个生成器的对抗损失
② 两个生成器的循环一致性损失
③ 两个生成器的总损失是对抗损失和循环一致性损失的加权总和。
④ 每训练 100 个批次后生成用于视觉检查的图像
对于两个生成器的训练,与我们在第四章所做的方法有两个重要的不同之处。首先,我们在这里同时训练两个生成器,而不是只有一个生成器。其次,两个生成器的总损失是对抗性损失和循环一致性损失的加权总和,我们将后者损失权重提高 10 倍。然而,如果您将 10 的值更改为其他数字,如 9 或 12,您将得到类似的结果。
循环一致性损失是原始图像与转换回原始域的伪造图像之间的平均绝对误差。
现在我们已经准备好了所有东西,我们将开始训练循环:
from utils.ch06util import train_epoch
for epoch in range(1):
train_epoch(disc_A, disc_B, gen_A, gen_B, loader, opt_disc,
opt_gen, l1, mse, d_scaler, g_scaler, device) ①
torch.save(gen_A.state_dict(), "files/gen_black.pth")
torch.save(gen_B.state_dict(), "files/gen_blond.pth") ②
① 使用黑发和金发图像训练 CycleGAN 一个 epoch
② 保存训练好的模型权重
如果使用 GPU 进行训练,之前的训练可能需要几个小时。否则,可能需要整整一天。如果您没有训练模型的计算资源,请从我的网站下载预训练的生成器:gattonweb.uky.edu/faculty/lium/ml/hair.zip。解压文件,并将文件gen_black.pth和gen_blond.pth放置在您计算机上的文件夹/files/中。您将在下一小节中能够将黑发图像转换为金发图像。
练习 6.1
在训练 CycleGAN 模型时,我们假设域 A 包含黑发图像,域 B 包含金发图像。修改列表 6.2 中的代码,使域 A 包含金发图像,域 B 包含黑发图像。
6.4.2 黑发图像和金发图像的往返转换
由于训练数据集的高质量和数量丰富,我们成功地训练了 CycleGAN。我们不仅将在黑发图像和金发图像之间进行转换,还将进行往返转换。例如,我们将黑发图像转换为金发图像,然后再将其转换回黑发图像。这样,我们可以在往返后比较同一域内的原始图像和生成的图像,并查看差异。
以下列表执行了图像在两个域之间的转换,以及每个域内图像的往返转换。
列表 6.8 黑色或金发图像的往返转换
gen_A.load_state_dict(torch.load("files/gen_black.pth",
map_location=device))
gen_B.load_state_dict(torch.load("files/gen_blond.pth",
map_location=device))
i=1
for black,blond in loader:
fake_blond=gen_B(black.to(device))
save_image(black*0.5+0.5,f"files/black{i}.png") ①
save_image(fake_blond*0.5+0.5,f"files/fakeblond{i}.png")
fake2black=gen_A(fake_blond)
save_image(fake2black*0.5+0.5,
f"files/fake2black{i}.png") ②
fake_black=gen_A(blond.to(device))
save_image(blond*0.5+0.5,f"files/blond{i}.png") ③
save_image(fake_black*0.5+0.5,f"files/fakeblack{i}.png")
fake2blond=gen_B(fake_black)
save_image(fake2blond*0.5+0.5,
f"files/fake2blond{i}.png") ④
i=i+1
if i>10:
break
① 原始黑发图像
② 经过一次往返后生成的黑发伪造图像
③ 原始金发图像
④ 经过一次往返后生成的金发伪造图像
我们已经在您的本地文件夹/files/中保存了六组图像。第一组是带有黑色头发的原始图像。第二组是由训练好的金色头发生成器生成的假金色图像:图像保存为fakeblond0.png、fakeblond1.png等。第三组是往返转换后的假黑色头发图像:我们将刚刚创建的假图像输入到训练好的黑色头发生成器中,以获得假黑色头发图像。它们保存为fake2black0.png、fake2black1.png等。图 6.5 显示了这三组图像。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-genai-pt/img/CH06_F05_Liu.png
图 6.5 展示了带有黑色头发的图像往返转换。顶部行的图像是从训练集中提取的带有黑色头发的原始图像。中间行的图像是由训练好的金色头发生成器生成的相应假发金色头发图像。底部行的图像是在往返转换后生成的假发黑色头发图像:我们将中间行的图像输入到训练好的黑色头发生成器中,以创建假发黑色头发图像。
图 6.5 中有三行图像。顶部行显示了从训练集中提取的带有黑色头发的原始图像。中间行显示了由训练好的金色头发生成的假发金色头发图像。底部行包含往返转换后的假发黑色头发图像:图像几乎与顶部行中的图像相同!我们训练好的 CycleGAN 模型工作得非常好。
本地文件夹/files/中的第四组图像是带有金色头发的原始图像。第五组是由训练好的黑色头发生成器生成的假图像。最后,第六组包含往返转换后的假发金色头发图像。图 6.6 比较了这三组图像。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-genai-pt/img/CH06_F06_Liu.png
图 6.6 展示了带有金色头发的图像往返转换。顶部行的图像是从训练集中提取的带有金色头发的原始图像。中间行的图像是由训练好的黑色头发生成器生成的相应假发黑色头发图像。底部行的图像是在往返转换后生成的假发金色头发图像:我们将中间行的图像输入到训练好的金色头发生成器中,以创建假发金色头发图像。
在图 6.6 中,中间行显示了由训练好的黑色头发生成器生成的假发黑色头发图像:它们与顶部行中的人类面部上的黑色头发相同。底部行显示了往返转换后的假发金色头发图像:它们几乎与顶部行中的原始金色头发图像完全相同。
练习 6.2
CycleGAN 模型是通用的,可以应用于任何包含两个图像域的训练数据集。使用你在第五章下载的眼镜图像来训练 CycleGAN 模型。将戴眼镜的图像作为域 A,不带眼镜的图像作为域 B。然后使用训练好的 CycleGAN 在图像中添加和去除眼镜(即,在两个域之间转换图像)。示例实现和结果可以在本书的 GitHub 仓库中找到。
到目前为止,我们关注了一种生成模型类型,即 GANs。在下一章中,你将学习如何使用另一种类型的生成模型,变分自编码器(VAEs),来生成高分辨率图像。你将了解 VAEs 相对于 GANs 的优点和缺点。更重要的是,你将学习 VAE 中的编码器-解码器架构。该架构在生成模型中广泛使用,包括我们将在本书后面学习的 Transformers。
摘要
-
CycleGAN 可以在没有配对示例的情况下在两个域之间翻译图像。它由两个判别器和两个生成器组成。一个生成器将域 A 中的图像转换为域 B,而另一个生成器将域 B 中的图像转换为域 A。两个判别器用于分类给定图像是否来自特定域。
-
CycleGAN 使用循环一致性损失函数来确保可以从转换后的图像重建原始图像,从而鼓励保留关键特征。
-
一个正确构建的 CycleGAN 模型可以应用于任何包含两个域图像的数据集。相同的模型可以用不同的数据集进行训练,并用于在不同域之间翻译图像。
-
当我们有大量高质量的训练数据时,训练好的 CycleGAN 可以将一个域的图像转换为另一个域,并将其转换回原始域。经过一次往返转换后的图像可能几乎与原始图像相同。
^(1) 朱俊彦,朴泰勋,菲利普·伊索拉,亚历克斯·埃弗罗斯,2017,“使用循环一致性对抗网络进行未配对图像到图像的翻译。” arxiv.org/abs/1703.10593。
第七章:使用变分自编码器进行图像生成
本章涵盖了
-
自编码器与变分自编码器比较
-
构建和训练自编码器以重构手写数字
-
构建和训练变分自编码器以生成人脸图像
-
使用训练好的变分自编码器进行编码算术和插值
到目前为止,你已经学习了如何使用生成对抗网络(GANs)生成形状、数字和图像。在本章中,你将学习如何使用另一种生成模型:变分自编码器(VAEs)来创建图像。你还将通过执行编码算术和编码插值来了解 VAEs 的实际应用。
要了解变分自编码器(VAEs)是如何工作的,我们首先需要理解自编码器(AEs)。AEs 具有双组件结构:一个编码器和一个解码器。编码器将数据压缩成一个低维空间(潜在空间)中的抽象表示,而解码器则将编码信息解压缩并重构数据。AE 的主要目标是学习输入数据的压缩表示,重点是最小化重构误差——原始输入与其重构(在像素级别,正如我们在第六章计算循环一致性损失时所看到的)之间的差异。编码器-解码器架构是各种生成模型的基础,包括 Transformers,你将在本书的后半部分详细探索。例如,在第九章,你将构建一个用于机器语言翻译的 Transformer:编码器将英语短语转换为抽象表示,而解码器则根据编码器生成的压缩表示构建法语翻译。像 DALL-E 2 和 Imagen 这样的文本到图像 Transformer 也在其设计中使用了 AE 架构。这涉及到首先将图像编码成一个紧凑的、低维的概率分布。然后,它们从这个分布中进行解码。当然,不同模型中编码器和解码器的构成是不同的。
本章的第一个项目涉及从头开始构建和训练一个 AE 以生成手写数字。你将使用 60,000 个手写数字(0 到 9)的灰度图像作为训练数据,每个图像的大小为 28 × 28 = 784 像素。AE 中的编码器将每个图像压缩成一个只有 20 个值的确定性向量表示。AE 中的解码器重构图像,目的是最小化原始图像与重构图像之间的差异。这是通过最小化两个图像在像素级别的平均绝对误差来实现的。最终结果是能够生成与训练集几乎相同的手写数字的 AE。
虽然 AEs 在复制输入数据方面做得很好,但它们在生成训练集中不存在的新的样本时往往表现不佳。更重要的是,AEs 在输入插值方面并不擅长:它们常常无法生成两个输入数据点之间的中间表示。这让我们转向了 VAEs。VAEs 与 AEs 在两个关键方面有所不同。首先,虽然 AE 将每个输入编码为潜在空间中的一个特定点,VAE 则将其编码为该空间内的一个概率分布。其次,AE 仅关注最小化重建误差,而 VAE 学习潜在变量的概率分布参数,最小化一个包含重建损失和正则化项(库尔巴克-利布勒(KL)散度)的损失函数。
KL-散度鼓励潜在空间逼近某种分布(在我们的例子中是正态分布),并确保潜在变量不仅记住训练数据,而且捕获潜在的分布。它有助于实现一个结构良好的潜在空间,其中相似的数据点被紧密映射在一起,使空间连续且可解释。因此,我们可以操作编码以实现新的结果,这使得在 VAEs 中进行编码算术和输入插值成为可能。
在本章的第二个项目中,你将从头开始构建和训练一个 VAE,以生成人脸图像。在这里,你的训练集包括你在第五章下载的眼镜图像。VAE 的编码器将一个 3 × 256 × 256 = 196,608 像素的图像压缩成一个 100 个值的概率向量,每个向量遵循正态分布。然后解码器根据这个概率向量重建图像。训练好的 VAE 不仅可以从训练集中复制人脸,还可以生成新的图像。
你将学习如何在 VAEs 中进行编码算术和输入插值。你将操作不同输入的编码表示(潜在向量),在解码时达到特定的结果(例如,图像中是否有某些特征)。潜在向量控制解码图像中的不同特征,如性别、图像中是否有眼镜等。例如,你可以首先获得戴眼镜的男性(z1)、戴眼镜的女性(z2)和没戴眼镜的女性(z3)的潜在向量。然后计算一个新的潜在向量,z4 = z1 – z2 + z3。由于 z1 和 z2 在解码时都会导致图像中出现眼镜,z1 – z2 取消了结果图像中的眼镜特征。同样,由于 z2 和 z3 都会导致女性面孔,z3 – z2 取消了结果图像中的女性特征。因此,如果你使用训练好的 VAE 解码 z4 = z1 – z2 + z3,你会得到一个不带眼镜的男性图像。
你还将通过改变分配给潜在向量 z1 和 z2 的权重,创建一系列从戴眼镜的女士到不戴眼镜的女士过渡的图像。这些练习展示了 VAEs 在生成模型领域的多功能性和创意潜力。
与我们在前几章中学习的 GANs 相比,AEs 和 VAEs 具有简单的架构,易于构建。此外,AEs 和 VAEs 通常比 GANs 更容易、更稳定地训练。然而,AEs 和 VAEs 生成的图像通常比 GANs 生成的图像更模糊。GANs 在生成高质量、逼真的图像方面表现出色,但训练困难且资源密集。GANs 和 VAEs 之间的选择很大程度上取决于手头任务的特定要求,包括所需的输出质量、可用的计算资源以及稳定训练过程的重要性。
VAEs 在现实世界中具有广泛的应用。例如,假设你经营一家眼镜店,并成功在线推广了一种新款男士眼镜。现在,你希望针对女性市场推广同样的款式,但缺乏女士佩戴这些眼镜的图片,而且专业摄影的成本很高。这时,VAEs 就派上用场了:你可以将男士佩戴眼镜的现有图片与男士和女士不戴眼镜的图片结合起来。这样,你可以通过编码算术(你将在本章中学习的技术)创建女士佩戴相同眼镜风格的逼真图像,如图 7.1 所示。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-genai-pt/img/CH07_F01_Liu.png
图 7.1 通过执行编码算术生成戴眼镜女士的图像
在另一种场景中,假设你的商店提供深色和浅色框架的眼镜,这两种框架都很受欢迎。你想要引入一个带有中等色调框架的中等选项。使用 VAEs,通过一种称为编码插值的方法,你可以轻松地生成一系列平滑过渡的图像,如图 7.2 所示。这些图像将从深色框架眼镜到浅色框架眼镜变化,为顾客提供视觉选择范围。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-genai-pt/img/CH07_F02_Liu.png
图 7.2 从深色框架眼镜生成到浅色框架眼镜的图像系列
VAEs 的应用不仅限于眼镜;它几乎涵盖了任何产品类别,无论是服装、家具还是食品。这项技术为可视化营销各种产品提供了一种创造性和成本效益的解决方案。此外,尽管图像生成是一个突出的例子,但 VAEs 可以应用于许多其他类型的数据,包括音乐和文本。它们的通用性在实用方面开辟了无限的可能性!
7.1 AEs 概述
本节讨论了自动编码器是什么以及其基本结构。为了让你深入理解自动编码器的内部工作原理,你将在本章的第一个项目中构建和训练一个自动编码器来生成手写数字。本节概述了自动编码器的架构以及完成第一个项目的蓝图。
7.1.1 什么是自动编码器?
自动编码器是用于无监督学习的一种神经网络,特别适用于图像生成、压缩和去噪等任务。自动编码器由两个主要部分组成:编码器和解码器。编码器将输入压缩成低维表示(潜在空间),解码器则从这个表示中重建输入。
压缩表示或潜在空间捕捉了输入数据的最重要特征。在图像生成中,这个空间编码了网络训练过的图像的关键方面。自动编码器(AEs)因其学习数据表示的高效性和处理未标记数据的能力而非常有用,这使得它们适用于降维和特征学习等任务。自动编码器的一个挑战是编码过程中可能会丢失信息,这可能导致重建不够准确。使用具有多个隐藏层的更深层架构可以帮助学习更复杂和抽象的表示,从而可能减轻自动编码器中的信息丢失。此外,训练自动编码器生成高质量图像可能计算量很大,需要大量数据集。
正如我们在第一章中提到的,学习某物的最佳方式是从零开始创建它。为此,你将在本章的第一个项目中学习如何创建一个自动编码器来生成手写数字。下一小节将提供一个如何做到这一点的蓝图。
7.1.2 构建和训练自动编码器的步骤
假设你必须从头开始构建和训练一个自动编码器来生成手写数字的灰度图像,以便你获得使用自动编码器进行更复杂任务(如彩色图像生成或降维)所需的技能。你应该如何进行这项任务?
图 7.3 展示了自动编码器的架构以及训练自动编码器生成手写数字的步骤。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-genai-pt/img/CH07_F03_Liu.png
图 7.3 自动编码器的架构以及训练其生成手写数字的步骤。自动编码器包括一个编码器(中间左侧)和一个解码器(中间右侧)。在训练的每个迭代中,将手写数字的图像输入到编码器(步骤 1)。编码器将图像压缩到潜在空间中的确定性点(步骤 2)。解码器从潜在空间中获取编码向量(步骤 3),并重建图像(步骤 4)。自动编码器调整其参数以最小化重建损失,即原始图像与重建图像之间的差异(步骤 5)。
如从图中所见,自动编码器(AE)有两个主要部分:一个编码器(中间左侧),它将手写数字图像压缩为潜在空间中的向量,以及一个解码器(中间右侧),它根据编码向量重建这些图像。编码器和解码器都是深度神经网络,可能包含不同类型的层,如密集层、卷积层、转置卷积层等。由于我们的例子涉及手写数字的灰度图像,我们将仅使用密集层。然而,自动编码器也可以用来生成更高分辨率的彩色图像;对于这些任务,卷积神经网络(CNNs)通常包含在编码器和解码器中。是否在自动编码器中使用 CNN 取决于你想要生成的图像分辨率。
当构建自动编码器时,其中的参数是随机初始化的。我们需要获得一个训练集来训练模型:PyTorch 提供了 60,000 张手写数字的灰度图像,这些图像在 0 到 9 这 10 个数字之间均匀分布。图 7.3 的左侧展示了三个例子,分别是数字 0、1 和 9 的图像。在训练循环的第一步中,我们将训练集中的图像输入到编码器中。编码器将图像压缩为潜在空间中的 20 值向量(步骤 2)。数字 20 并没有什么神奇之处。如果你在潜在空间中使用 25 值向量,你将得到相似的结果。然后,我们将向量表示传递给解码器(步骤 3),并要求它重建图像(步骤 4)。我们计算重建损失,这是原始图像和重建图像之间所有像素的平均平方误差。然后,我们将这个损失反向传播通过网络,以更新编码器和解码器中的参数,以最小化重建损失(步骤 5),这样在下一个迭代中,自动编码器可以重建更接近原始图像的图像。这个过程在数据集上重复许多个 epoch。
在模型训练完成后,你将向编码器提供未见过的手写数字图像,并获取编码。然后,你将编码传递给解码器以获取重建的图像。你会发现重建的图像几乎与原始图像完全相同。图 7.3 的右侧展示了三个重建图像的例子:它们看起来与图左侧对应的原始图像非常相似。
7.2 构建和训练自动编码器以生成数字
现在你已经有了构建和训练自动编码器以生成手写数字的蓝图,让我们深入到这个项目中,并实现上一节中概述的步骤。
具体来说,在本节中,您将首先学习如何获取手写数字图像的训练集和测试集。然后,您将使用密集层构建编码器和解码器。您将使用训练数据集训练自动编码器,并使用训练好的编码器对测试集中的图像进行编码。最后,您将学习如何使用训练好的解码器重建图像,并将它们与原始图像进行比较。
7.2.1 收集手写数字
您可以使用 Torchvision 库中的datasets包下载手写数字的灰度图像,类似于您在第二章中下载服装物品图像的方式。
首先,让我们下载一个训练集和一个测试集:
import torchvision
import torchvision.transforms as T
transform=T.Compose([
T.ToTensor()])
train_set=torchvision.datasets.MNIST(root=".", ①
train=True,download=True,transform=transform) ②
test_set=torchvision.datasets.MNIST(root=".",
train=False,download=True,transform=transform) ③
① 使用torchvision.datasets中的MNIST()类下载手写数字
② train=True参数表示您下载训练集。
③ train=False参数表示您下载测试集。
与第二章中使用的FashionMNIST()类不同,我们在这里使用MNIST()类。类中的train参数告诉 PyTorch 是下载训练集(当参数设置为True时)还是测试集(当参数设置为False时)。在转换之前,图像像素是介于 0 到 255 之间的整数。前面代码块中的ToTensor()类将它们转换为介于 0 到 1 之间的 PyTorch 浮点张量。训练集中有 60,000 张图像,测试集中有 10,000 张图像,每个集合中均匀分布着 10 个数字,从 0 到 9。
我们将为训练和测试创建数据批次,每个批次包含 32 张图像:
import torch
batch_size=32
train_loader=torch.utils.data.DataLoader(
train_set,batch_size=batch_size,shuffle=True)
test_loader=torch.utils.data.DataLoader(
test_set,batch_size=batch_size,shuffle=True)
现在我们有了准备好的数据,我们将接下来构建和训练一个自动编码器。
7.2.2 构建和训练自动编码器
自动编码器由两部分组成:编码器和解码器。我们将定义一个AE()类,如下面的列表所示,来表示自动编码器。
列表 7.1 创建一个用于生成手写数字的自动编码器
import torch.nn.functional as F
from torch import nn
device="cuda" if torch.cuda.is_available() else "cpu"
input_dim = 784 ①
z_dim = 20 ②
h_dim = 200
class AE(nn.Module):
def __init__(self,input_dim,z_dim,h_dim):
super().__init__()
self.common = nn.Linear(input_dim, h_dim)
self.encoded = nn.Linear(h_dim, z_dim)
self.l1 = nn.Linear(z_dim, h_dim)
self.decode = nn.Linear(h_dim, input_dim)
def encoder(self, x): ③
common = F.relu(self.common(x))
mu = self.encoded(common)
return mu
def decoder(self, z): ④
out=F.relu(self.l1(z))
out=torch.sigmoid(self.decode(out))
return out
def forward(self, x): ⑤
mu=self.encoder(x)
out=self.decoder(mu)
return out, mu
① 自动编码器的输入有 28×28=784 个值。
② 潜在变量(编码)中有 20 个值。
③ 编码器将图像压缩为潜在变量。
④ 解码器根据编码重建图像。
⑤ 编码器和解码器构成了自动编码器。
输入大小为 784,因为手写数字的灰度图像大小为 28×28 像素。我们将图像展平为 1D 张量,并将其输入到自动编码器(AE)中。图像首先通过编码器:它们被压缩到低维空间中的编码。现在每个图像都由一个 20 值的潜在变量表示。解码器根据潜在变量重建图像。自动编码器(AE)的输出包含两个张量:out,重建的图像,和mu,潜在变量(即编码)。
接下来,我们实例化之前定义的AE()类来创建一个自动编码器。我们还在训练过程中使用了 Adam 优化器,就像之前章节中做的那样:
model = AE(input_dim,z_dim,h_dim).to(device)
lr=0.00025
optimizer = torch.optim.Adam(model.parameters(), lr=lr)
我们定义了一个名为plot_digits()的函数,用于在训练的每个 epoch 后可视检查重建的手写数字,如下面的列表所示。
列表 7.2 检查重建图像的 plot_digits() 函数
import matplotlib.pyplot as plt
originals = [] ①
idx = 0
for img,label in test_set:
if label == idx:
originals.append(img)
idx += 1
if idx == 10:
break
def plot_digits():
reconstructed=[]
for idx in range(10):
with torch.no_grad():
img = originals[idx].reshape((1,input_dim))
out,mu = model(img.to(device)) ②
reconstructed.append(out) ③
imgs=originals+reconstructed
plt.figure(figsize=(10,2),dpi=50)
for i in range(20):
ax = plt.subplot(2,10, i + 1)
img=(imgs[i]).detach().cpu().numpy()
plt.imshow(img.reshape(28,28), ④
cmap="binary")
plt.xticks([])
plt.yticks([])
plt.show()
① 收集测试集中每个数字的样本图像
② 将图像输入到自动编码器以获得重建图像
③ 收集每个原始图像的重建图像
④ 视觉比较原始图像与重建数字
我们首先收集 10 张样本图像,每张代表一个不同的数字,并将它们放入一个列表 originals 中。我们将图像输入到自动编码器以获得重建图像。最后,我们绘制原始图像和重建图像,以便我们可以比较它们并定期评估自动编码器的性能。
在训练开始之前,我们调用 plot_digits() 函数来可视化输出:
plot_digits()
你将看到如图 7.4 所示的输出。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-genai-pt/img/CH07_F04_Liu.png
图 7.4 在训练开始之前,自动编码器重建图像与原始图像的比较。第一行显示了测试集中 10 个原始的手写数字图像。第二行显示了自动编码器在训练之前的重建图像。这些重建不过是纯粹的噪声。
尽管我们可以将我们的数据分为训练集和验证集,并训练模型直到在验证集上不再看到进一步的改进(就像我们在第二章中所做的那样),但我们的主要目标是掌握自动编码器的工作原理,而不一定是实现最佳参数调整。因此,我们将自动编码器训练 10 个周期。
列表 7.3 训练自动编码器生成手写数字
for epoch in range(10):
tloss=0
for imgs, labels in train_loader: ①
imgs=imgs.to(device).view(-1, input_dim)
out, mu=model(imgs) ②
loss=((out-imgs)**2).sum() ③
optimizer.zero_grad()
loss.backward()
optimizer.step()
tloss+=loss.item()
print(f"at epoch {epoch} toal loss = {tloss/len(train_loader)}")
plot_digits() ④
① 遍历训练集中的批次
② 使用自动编码器重建图像
③ 通过均方误差计算重建损失
④ 视觉检查自动编码器的性能
在每个训练周期中,我们遍历训练集中的所有数据批次。我们将原始图像输入到自动编码器以获得重建图像。然后我们计算重建损失,即原始图像和重建图像之间的均方误差。具体来说,重建损失是通过首先计算两个图像之间的差异,逐像素平方这些值并平均平方差异来获得的。我们调整模型参数以最小化重建损失,利用 Adam 优化器,这是一种梯度下降方法的变体。
如果你使用 GPU 训练,模型大约需要 2 分钟来训练。或者,你可以从我的网站下载训练好的模型:mng.bz/YV6K。
7.2.3 保存和使用训练好的自动编码器
我们将模型保存在您计算机上的本地文件夹中:
scripted = torch.jit.script(model)
scripted.save('files/AEdigits.pt')
要使用它来重建手写数字的图像,我们加载模型:
model=torch.jit.load('files/AEdigits.pt',map_location=device)
model.eval()
我们可以通过调用我们之前定义的 plot_digits() 函数来使用它生成手写数字:
plot_digits()
输出结果如图 7.5 所示。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-genai-pt/img/CH07_F05_Liu.png
图 7.5 比较训练 AE 重建的图像与原始图像。第一行显示了测试集中 10 个原始的手写数字图像。第二行显示了训练 AE 重建的图像。重建的图像看起来与原始图像相似。
重建的手写数字与原始数字相似,尽管重建并不完美。在编码-解码过程中,一些信息丢失了。然而,与 GANs 相比,AEs 更容易构建,训练时间也更短。此外,编码器-解码器架构被许多生成模型采用。这个项目将帮助您理解后面的章节,尤其是在我们探索 Transformers 时。
7.3 什么是 VAEs?
虽然 AEs 在重建原始图像方面很擅长,但它们在生成训练集中未见过的创新图像方面却失败了。此外,AEs 往往不会将相似输入映射到潜在空间中的邻近点。因此,与 AE 相关的潜在空间既不连续也不容易解释。例如,您无法插值两个输入数据点以生成有意义的中间表示。由于这些原因,我们将研究 AE 的改进:VAEs。
在本节中,您将首先了解 AEs 和 VAEs 之间的关键区别以及这些区别为何导致后者能够生成训练集中未见过的逼真图像。然后,您将学习训练 VAEs 的一般步骤,特别是训练一个用于生成高分辨率人脸图像的 VAE。
7.3.1 AEs 和 VAEs 之间的区别
VAEs 首先由 Diederik Kingma 和 Max Welling 在 2013 年提出。1 它是 AE 的一个变体。与 AE 一样,VAE 也包含两个主要部分:编码器和解码器。
然而,AEs 和 VAEs 之间有两个关键区别。首先,AE 中的潜在空间是确定性的。每个输入都被映射到潜在空间中的一个固定点。相比之下,VAE 中的潜在空间是概率性的。VAE 不是将输入编码为潜在空间中的一个单一向量,而是将输入编码为可能值的分布。例如,在我们的第二个项目中,我们将编码一个彩色图像到一个 100 个值的概率向量。此外,我们假设这个向量中的每个元素都遵循独立的高斯分布。由于定义高斯分布只需要均值(μ)和标准差(σ),我们 100 个元素的概率向量中的每个元素将由这两个参数来表征。为了重建图像,我们从这个分布中采样一个向量并将其解码。VAEs 的独特性体现在每次从分布中采样都会产生略微不同的输出。
在统计术语中,VAE 中的编码器试图学习训练数据 x 的真实分布 p(x|Θ),其中 Θ 是定义分布的参数。为了便于处理,我们通常假设潜在变量的分布是正态的。因为我们只需要均值 μ 和标准差 σ 来定义一个正态分布,我们可以将真实分布重写为 p(x|Θ) = p(x|μ, σ)。VAE 中的解码器根据编码器学习的分布生成一个样本。也就是说,解码器从分布 p(x|μ, σ) 中以概率生成一个实例。
AEs 和 VAEs 之间的第二个关键区别在于损失函数。当训练一个 AE 时,我们最小化重建损失,以便重建的图像尽可能接近原始图像。相比之下,在 VAEs 中,损失函数由两部分组成:重建损失和 KL 散度。KL 散度是衡量一个概率分布如何偏离第二个预期概率分布的度量。在 VAEs 中,KL 散度用于通过惩罚学习到的分布(编码器的输出)与先验分布(标准正态分布)的偏差来正则化编码器。这鼓励编码器学习有意义的和可推广的潜在表示。通过惩罚偏离先验太远的分布,KL 散度有助于避免过拟合。
在我们的设置中,KL 散度是这样计算的,因为我们假设了一个正态分布(如果假设非正态分布,公式则不同):
|
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-genai-pt/img/CH07_F06_Liu_EQ01.png
| (7.1) |
|---|
求和是在潜在空间的 100 个维度上进行的。当编码器将图像压缩到潜在空间中的标准正态分布时,使得 μ=0 和 σ=1,KL 散度变为 0。在任何其他情况下,值都超过 0。因此,当编码器成功将图像压缩到潜在空间中的标准正态分布时,KL 散度被最小化。
7.3.2 训练 VAE 生成人脸图像的蓝图
在本章的第二个项目中,你将从头开始构建和训练一个 VAE,以生成人脸彩色图像。训练好的模型可以生成训练集中未见过的图像。此外,你可以插值输入以生成介于两个输入数据点之间的新颖图像,这些图像是两个输入数据点之间的中间表示。以下是这个第二个项目的蓝图。
图 7.6 提供了 VAE 架构的图示以及训练 VAE 生成人脸图像的步骤。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-genai-pt/img/CH07_F06_Liu.png
图 7.6 展示了 VAE(变分自编码器)的架构以及训练其生成人脸图像的步骤。VAE 由一个编码器(中间左上角)和一个解码器(中间右下角)组成。在训练的每一次迭代中,人脸图像被输入到编码器(步骤 1)。编码器将图像压缩到潜在空间中的概率点(步骤 2;由于我们假设正态分布,每个概率点由均值向量和标准差向量表征)。然后,我们从分布中采样编码并展示给解码器。解码器接收采样的编码(步骤 3)并重建图像(步骤 4)。VAE 调整其参数以最小化重建损失和 KL 散度的总和。KL 散度衡量编码器输出与标准正态分布之间的差异。
图 7.6 显示 VAE 也有两个部分:一个编码器(中间左上角)和一个解码器(中间右下角)。由于第二个项目涉及高分辨率彩色图像,我们将使用 CNN(卷积神经网络)来创建 VAE。正如我们在第四章中讨论的,高分辨率彩色图像包含比低分辨率灰度图像更多的像素。如果我们只使用全连接(密集)层,模型中的参数数量太大,使得学习过程缓慢且无效。与类似大小的全连接网络相比,CNN 需要的参数更少,从而实现更快、更有效的学习。
一旦创建了 VAE,你将使用在第五章中下载的眼镜数据集来训练模型。图 7.6 的左侧展示了训练集中三个原始人脸图像的例子。在训练循环的第一步中,我们将大小为 3 × 256 × 256 = 196,608 像素的图像输入到编码器。编码器将图像压缩到潜在空间中的 100 值概率向量(步骤 2;由于假设正态分布,向量为均值和标准差向量)。然后,我们从分布中采样并输入采样的向量表示到解码器(步骤 3),并要求其重建图像(步骤 4)。我们计算总损失为像素级重建损失和方程 7.1 中指定的 KL 散度的总和。我们将这个损失反向传播通过网络以更新编码器和解码器中的参数,以最小化总损失(步骤 5)。总损失鼓励 VAE 将输入编码成更有意义和可推广的潜在表示,并重建更接近原始图像的图像。
模型训练完成后,你将向编码器输入人脸图像并获取编码。然后,你将编码输入到解码器以获取重建图像。你会发现重建的图像与原始图像非常接近。图 7.6 的右侧展示了三个重建图像的例子:它们与图左侧对应的原始图像相似,尽管并不完全一样。
更重要的是,你可以丢弃编码器,并从潜在空间中随机抽取编码,然后将它们输入到训练好的解码器中,以生成训练集中未见过的创新人脸图像。此外,你可以操纵不同输入的编码表示,以在解码时实现特定的结果。你还可以通过改变分配给任何两个编码的权重,创建一系列从实例到实例过渡的图像。
7.4 使用 VAE 生成人脸图像
本节将从头创建和训练一个 VAE,以生成人脸图像,按照上一节概述的步骤进行。
与我们为构建和训练自动编码器(AEs)所做的工作相比,我们第二个项目的方案包含几个修改。首先,我们计划在变分自编码器(VAEs)的编码器和解码器中都使用卷积神经网络(CNNs),尤其是由于高分辨率彩色图像具有更多的像素。仅依靠全连接(密集)层会导致参数数量过多,从而导致学习速度慢且效率低下。其次,作为我们将图像压缩为在潜在空间中遵循正态分布的向量的过程的一部分,我们将在编码每个图像时生成一个均值向量和标准差向量。这与自动编码器中使用的固定值向量不同。然后,从编码的正态分布中采样以获得编码,这些编码随后被解码以生成图像。值得注意的是,每次我们从该分布中采样时,重建的图像都会略有不同,这赋予了 VAEs 生成新颖图像的能力。
7.4.1 构建 VAE
如果你还记得,你在第五章下载的眼镜数据集在手动纠正了一些标签后,保存在你电脑上的文件夹 /files/glasses/ 中。我们将图像调整大小为 256 x 256 像素,值在 0 到 1 之间。然后,我们创建一个包含每个批次 16 个图像的批量迭代器:
transform = T.Compose([
T.Resize(256), ①
T.ToTensor(), ②
])
data = torchvision.datasets.ImageFolder(
root="files/glasses",
transform=transform) ③
batch_size=16
loader = torch.utils.data.DataLoader(data, ④
batch_size=batch_size,shuffle=True)
① 将图像调整大小为 256 x 256 像素
② 将图像转换为值在 0 到 1 之间的张量
③ 从文件夹中加载图像并应用转换
④ 将数据放入批量迭代器
接下来,我们将创建一个包含卷积层和转置卷积层的 VAE。我们首先定义一个Encoder()类,如下所示。
列表 7.4 VAE 中的编码器
latent_dims=100 ①
class Encoder(nn.Module):
def __init__(self, latent_dims=100):
super().__init__()
self.conv1 = nn.Conv2d(3, 8, 3, stride=2, padding=1)
self.conv2 = nn.Conv2d(8, 16, 3, stride=2, padding=1)
self.batch2 = nn.BatchNorm2d(16)
self.conv3 = nn.Conv2d(16, 32, 3, stride=2, padding=0)
self.linear1 = nn.Linear(31*31*32, 1024)
self.linear2 = nn.Linear(1024, latent_dims)
self.linear3 = nn.Linear(1024, latent_dims)
self.N = torch.distributions.Normal(0, 1)
self.N.loc = self.N.loc.cuda()
self.N.scale = self.N.scale.cuda()
def forward(self, x):
x = x.to(device)
x = F.relu(self.conv1(x))
x = F.relu(self.batch2(self.conv2(x)))
x = F.relu(self.conv3(x))
x = torch.flatten(x, start_dim=1)
x = F.relu(self.linear1(x))
mu = self.linear2(x) ②
std = torch.exp(self.linear3(x)) ③
z = mu + std*self.N.sample(mu.shape) ④
return mu, std, z
① 潜在空间的维度是 100。
② 编码分布的均值
③ 编码的标准差
④ 编码向量表示
编码器网络由几个卷积层组成,这些层提取输入图像的空间特征。编码器将输入压缩成向量表示,z,这些向量具有均值mu和标准差std的正态分布。编码器的输出包括三个张量:mu、std和z。虽然mu和std分别是概率向量的均值和标准差,但z是从该分布中采样的一个实例。
具体来说,输入图像,大小为(3, 256, 256),首先通过一个步长值为 2 的 Conv2d 层。正如我们在第四章中解释的,这意味着滤波器每次在输入图像上移动时跳过两个像素,这导致图像下采样。输出的大小为(8, 128, 128)。然后它通过两个更多的 Conv2d 层,大小变为(32, 31, 31)。它被展平并通过线性层获得mu和std的值。
我们定义一个Decoder()类来表示 VAE 中的解码器。
列表 7.5 VAE 中的解码器
class Decoder(nn.Module):
def __init__(self, latent_dims=100):
super().__init__()
self.decoder_lin = nn.Sequential( ①
nn.Linear(latent_dims, 1024),
nn.ReLU(True),
nn.Linear(1024, 31*31*32), ②
nn.ReLU(True))
self.unflatten = nn.Unflatten(dim=1,
unflattened_size=(32,31,31))
self.decoder_conv = nn.Sequential( ③
nn.ConvTranspose2d(32,16,3,stride=2,
output_padding=1),
nn.BatchNorm2d(16),
nn.ReLU(True),
nn.ConvTranspose2d(16, 8, 3, stride=2,
padding=1, output_padding=1),
nn.BatchNorm2d(8),
nn.ReLU(True),
nn.ConvTranspose2d(8, 3, 3, stride=2,
padding=1, output_padding=1))
def forward(self, x):
x = self.decoder_lin(x)
x = self.unflatten(x)
x = self.decoder_conv(x)
x = torch.sigmoid(x) ④
return x
① 编码首先通过两个密集层。
② 将编码重新塑造成多维对象,以便我们可以对它们执行转置卷积操作
③ 将编码通过三个转置卷积层
④ 将输出挤压到 0 到 1 之间的值,与输入图像中的值相同
解码器是编码器的镜像:它不是执行卷积操作,而是在编码上执行转置卷积操作以生成特征图。它逐渐将潜在空间中的编码转换回高分辨率彩色图像。
具体来说,编码首先通过两个线性层。然后它被反展平到一个形状(32, 31, 31),与编码器中最后一个 Conv2d 层之后的图像大小相匹配。然后它通过三个 ConvTranspose2d 层,与编码器中的 Conv2d 层相匹配。解码器的输出形状为(3, 256, 256),与训练图像相同。
我们将编码器与解码器结合起来创建一个 VAE:
class VAE(nn.Module):
def __init__(self, latent_dims=100):
super().__init__()
self.encoder = Encoder(latent_dims) ①
self.decoder = Decoder(latent_dims) ②
def forward(self, x):
x = x.to(device)
mu, std, z = self.encoder(x) ③
return mu, std, self.decoder(z) ④
① 通过实例化 Encoder()类创建一个编码器
② 通过实例化 Decoder()类创建一个解码器
③ 将输入通过编码器以获取编码
④ VAE 的输出是编码的均值和标准差,以及重建的图像。
VAE 由一个编码器和一个解码器组成,由Encoder()和Decoder()类定义。当我们通过 VAE 传递图像时,输出包括三个张量:编码和重建图像的均值和标准差。
接下来,我们通过实例化VAE()类创建一个 VAE 并定义模型的优化器:
vae=VAE().to(device)
lr=1e-4
optimizer=torch.optim.Adam(vae.parameters(),
lr=lr,weight_decay=1e-5)
我们将在训练期间手动计算重建损失和 KL 散度损失。因此,我们在此处不定义损失函数。
7.4.2 训练 VAE
为了训练模型,我们首先定义一个train_epoch()函数来训练模型一个 epoch。
列表 7.6 定义train_epoch()函数
def train_epoch(epoch):
vae.train()
epoch_loss = 0.0
for imgs, _ in loader:
imgs = imgs.to(device)
mu, std, out = vae(imgs) ①
reconstruction_loss = ((imgs-out)**2).sum() ②
kl = ((std**2)/2 + (mu**2)/2 - torch.log(std) - 0.5).sum() ③
loss = reconstruction_loss + kl ④
optimizer.zero_grad()
loss.backward()
optimizer.step()
epoch_loss+=loss.item()
print(f'at epoch {epoch}, loss is {epoch_loss}')
① 获取重建的图像
② 计算重建损失
③ 计算 KL 散度
④ 重建损失和 KL 散度的总和。
我们遍历训练集中的所有批次。我们通过 VAE 传递图像以获取重建图像。总损失是重建损失和 KL 散度的总和。在每个迭代中调整模型参数以最小化总损失。
我们还定义了一个 plot_epoch() 函数,用于直观检查 VAE 生成的图像:
import numpy as np
import matplotlib.pyplot as plt
def plot_epoch():
with torch.no_grad():
noise = torch.randn(18,latent_dims).to(device)
imgs = vae.decoder(noise).cpu()
imgs = torchvision.utils.make_grid(imgs,6,3).numpy()
fig, ax = plt.subplots(figsize=(6,3),dpi=100)
plt.imshow(np.transpose(imgs, (1, 2, 0)))
plt.axis("off")
plt.show()
一个训练良好的 VAE 可以将相似的输入映射到潜在空间中的邻近点,从而得到一个更连续和可解释的潜在空间。因此,我们可以从潜在空间中随机抽取向量,VAE 可以将这些向量解码成有意义的输出。因此,在前面的 plot_epoch() 函数中,我们随机从潜在空间中抽取 18 个向量,并在每个训练周期后使用它们生成 18 张图像。我们将它们绘制在一个 3 × 6 的网格中,并直观地检查它们以了解 VAE 在训练过程中的表现。
接下来,我们训练 VAE 模型 10 个周期:
for epoch in range(1,11):
train_epoch(epoch)
plot_epoch()
torch.save(vae.state_dict(),"files/VAEglasses.pth")
如果使用 GPU 训练,这个过程大约需要半小时,否则需要几个小时。训练好的模型权重已保存在您的计算机上。或者,您可以从我的网站上下载训练好的权重:mng.bz/GNRR。请确保下载后解压文件。
7.4.3 使用训练好的 VAE 生成图像
现在,VAE 已经训练好了,我们可以用它来生成图像。我们首先加载保存在本地文件夹中的训练好的模型权重:
vae.eval()
vae.load_state_dict(torch.load('files/VAEglasses.pth',
map_location=device))
然后,我们检查 VAE 重建图像的能力,并观察它们与原始图像的相似程度:
imgs,_=next(iter(loader))
imgs = imgs.to(device)
mu, std, out = vae(imgs)
images=torch.cat([imgs[:8],out[:8],imgs[8:16],out[8:16]],
dim=0).detach().cpu()
images = torchvision.utils.make_grid(images,8,4)
fig, ax = plt.subplots(figsize=(8,4),dpi=100)
plt.imshow(np.transpose(images, (1, 2, 0)))
plt.axis("off")
plt.show()
如果您运行前面的代码块,您将看到类似于图 7.7 的输出。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-genai-pt/img/CH07_F07_Liu.png
图 7.7 比较训练好的 VAE 重建的图像与原始图像。第一行和第三行是原始图像。我们将它们输入到训练好的 VAE 模型中,以获得重建的图像,这些图像显示在原始图像下方。
原始图像显示在第一行和第三行,而重建的图像显示在原始图像下方。重建的图像与原始图像相似,如图 7.7 所示。然而,在重建过程中会丢失一些信息:它们看起来没有原始图像那么真实。
接下来,我们通过调用之前定义的 plot_epoch() 函数来测试 VAE 生成训练集中未见过的创新图像的能力:
plot_epoch()
函数从潜在空间随机抽取 18 个向量,并将它们传递给训练好的 VAE 模型以生成 18 张图像。输出结果如图 7.8 所示。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-genai-pt/img/CH07_F08_Liu.png
图 7.8 由训练好的 VAE 生成的创新图像。我们在潜在空间中随机抽取向量表示,并将它们输入到训练好的 VAE 模型的解码器中。解码后的图像显示在本图中。由于向量表示是随机抽取的,因此这些图像与训练集中的任何原始图像都不对应。
这些图像不在训练集中:编码是从潜在空间中随机抽取的,而不是通过编码器处理训练集中的图像后得到的编码向量。这是因为 VAEs 中的潜在空间是连续且可解释的。潜在空间中的新编码可以有意义地解码成与训练集中图像相似但不同的图像。
7.4.4 使用训练好的 VAE 进行编码算术
VAEs 在它们的损失函数中包含一个正则化项(KL 散度),这鼓励潜在空间逼近正态分布。这种正则化确保潜在变量不仅记住训练数据,而且捕获潜在的分布。它有助于实现一个结构良好的潜在空间,其中相似的数据点被紧密映射在一起,使空间连续且可解释。因此,我们可以操纵编码以实现新的结果。
为了使结果可重复,我鼓励您从我的网站下载训练好的权重(mng.bz/GNRR)并使用本章其余部分的相同代码块。正如我们在引言中解释的,编码算术允许我们生成具有特定特征的图像。为了说明编码算术在 VAEs 中的工作原理,让我们首先从以下四个组中手动收集每组三张图像:带眼镜的男性、不带眼镜的男性、带眼镜的女性和不带眼镜的女性。
列表 7.7 收集具有不同特征的图像
torch.manual_seed(0)
glasses=[]
for i in range(25): ①
img,label=data[i]
glasses.append(img)
plt.subplot(5,5,i+1)
plt.imshow(img.numpy().transpose((1,2,0)))
plt.axis("off")
plt.show()
men_g=[glasses[0],glasses[3],glasses[14]] ②
women_g=[glasses[9],glasses[15],glasses[21]] ③
noglasses=[]
for i in range(25): ④
img,label=data[-i-1]
noglasses.append(img)
plt.subplot(5,5,i+1)
plt.imshow(img.numpy().transpose((1,2,0)))
plt.axis("off")
plt.show()
men_ng=[noglasses[1],noglasses[7],noglasses[22]] ⑤
women_ng=[noglasses[4],noglasses[9],noglasses[19]]) ⑥
① 显示 25 张带眼镜的图像
② 选择三张带眼镜男性的图像
③ 选择三张带眼镜女性的图像
④ 显示 25 张不带眼镜的图像
⑤ 选择三张不带眼镜的男性图像
⑥ 选择三张不带眼镜的女性图像
我们在每个组中选择三张图像而不是一张,这样在执行编码算术时,我们可以计算同一组中多个编码的平均值。VAEs 旨在学习潜在空间中输入数据的分布。通过平均多个编码,我们有效地平滑了该空间中的表示。这有助于我们找到一个平均表示,它能够捕捉到组内不同样本之间的共同特征。
接下来,我们将三张带眼镜的男性图像输入到训练好的 VAE 中,以获取它们在潜在空间中的编码。然后我们计算这三个图像的平均编码,并使用它来获取一个带眼镜男性的重建图像。然后我们对其他三个组重复此操作。
列表 7.8 在四个不同组中编码和解码图像
# create a batch of images of men with glasses
men_g_batch = torch.cat((men_g[0].unsqueeze(0), ①
men_g[1].unsqueeze(0),
men_g[2].unsqueeze(0)), dim=0).to(device)
# Obtain the three encodings
_,_,men_g_encodings=vae.encoder(men_g_batch)
# Average over the three images to obtain the encoding for the group
men_g_encoding=men_g_encodings.mean(dim=0) ②
# Decode the average encoding to create an image of a man with glasses
men_g_recon=vae.decoder(men_g_encoding.unsqueeze(0)) ③
# Do the same for the other three groups
# group 2, women with glasses
women_g_batch = torch.cat((women_g[0].unsqueeze(0),
women_g[1].unsqueeze(0),
women_g[2].unsqueeze(0)), dim=0).to(device)
# group 3, men without glasses
men_ng_batch = torch.cat((men_ng[0].unsqueeze(0),
men_ng[1].unsqueeze(0),
men_ng[2].unsqueeze(0)), dim=0).to(device)
# group 4, women without glasses
women_ng_batch = torch.cat((women_ng[0].unsqueeze(0),
women_ng[1].unsqueeze(0),
women_ng[2].unsqueeze(0)), dim=0).to(device)
# obtain average encoding for each group
_,_,women_g_encodings=vae.encoder(women_g_batch)
women_g_encoding=women_g_encodings.mean(dim=0)
_,_,men_ng_encodings=vae.encoder(men_ng_batch)
men_ng_encoding=men_ng_encodings.mean(dim=0)
_,_,women_ng_encodings=vae.encoder(women_ng_batch)
women_ng_encoding=women_ng_encodings.mean(dim=0) ④
# decode for each group
women_g_recon=vae.decoder(women_g_encoding.unsqueeze(0))
men_ng_recon=vae.decoder(men_ng_encoding.unsqueeze(0))
women_ng_recon=vae.decoder(women_ng_encoding.unsqueeze(0)) ⑤
① 创建一批带眼镜男性的图像
② 获取带眼镜男性的平均编码
③ 解码带眼镜男性的平均编码
④ 获取其他三个组的平均编码
⑤ 解码其他三个组的平均编码
四个组的平均编码分别为men_g_encoding、women_g_encoding、men_ng_encoding和women_ng_encoding,其中g代表眼镜,ng代表无眼镜。四个组的解码图像分别为men_g_recon、women_g_recon、men_ng_recon和women_ng_recon。我们绘制了这四幅图像:
imgs=torch.cat((men_g_recon,
women_g_recon,
men_ng_recon,
women_ng_recon),dim=0)
imgs=torchvision.utils.make_grid(imgs,4,1).cpu().numpy()
imgs=np.transpose(imgs,(1,2,0))
fig, ax = plt.subplots(figsize=(8,2),dpi=100)
plt.imshow(imgs)
plt.axis("off")
plt.show()
你将看到如图 7.9 所示的输出。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-genai-pt/img/CH07_F09_Liu.png
图 7.9 基于平均编码的解码图像。我们首先获取以下四个组中的每个组的三幅图像:带眼镜男性、带眼镜女性、不带眼镜男性和不带眼镜女性。我们将 12 幅图像输入到训练好的 VAE 中的编码器,以获取它们在潜在空间中的编码。然后我们计算每个组中三幅图像的平均编码。四个平均编码被输入到训练好的 VAE 中的解码器,以获得四个图像,它们显示在这张图中。
图 7.9 显示了四个解码图像。它们是代表四个组的合成图像。请注意,它们与原始的 12 个图像中的任何一个都不同。同时,它们保留了每个组的定义特征。
接下来,让我们操作编码以创建一个新的编码,然后使用 VAE 中训练好的解码器来解码这个新编码,看看会发生什么。例如,我们可以从带眼镜女性的平均编码中减去带眼镜男性的平均编码,并加上不带眼镜女性的平均编码。然后我们将结果输入到解码器中,查看输出。
列表 7.9 编码算术的示例
z=men_g_encoding-women_g_encoding+women_ng_encoding ①
out=vae.decoder(z.unsqueeze(0)) ②
imgs=torch.cat((men_g_recon,
women_g_recon,
women_ng_recon,out),dim=0)
imgs=torchvision.utils.make_grid(imgs,4,1).cpu().numpy()
imgs=np.transpose(imgs,(1,2,0))
fig, ax = plt.subplots(figsize=(8,2),dpi=100)
plt.imshow(imgs) ③
plt.title("man with glasses - woman \
with glasses + woman without \
glasses = man without glasses ",fontsize=10,c="r") ④
plt.axis("off")
plt.show()
① 定义 z 为带眼镜男性的编码 - 带眼镜女性的编码 + 不带眼镜女性的编码
② 将 z 解码为生成图像
③ 显示四个图像
④ 在图像上方显示标题
如果你运行列表 7.9 中的代码块,你将看到如图 7.10 所示的输出。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-genai-pt/img/CH07_F10_Liu.png
图 7.10 使用训练好的 VAE 进行编码算术的示例。我们首先获取以下三个组的平均编码:带眼镜男性(z1)、带眼镜女性(z2)和不带眼镜女性(z3)。我们定义一个新的编码 z = z1 - z2 + z3。然后我们将 z 输入到训练好的 VAE 中的解码器,获得解码图像,如图中右侧所示。
图 7.10 中的前三幅图像是代表三个输入组的合成图像。最右侧的输出图像是一位不带眼镜的男性的图像。
由于解码后men_g_encoding和women_g_encoding都会在图像中出现眼镜,所以men_g_encoding – women_g_encoding会取消掉结果图像中的眼镜特征。同样,由于women_ng_encoding和women_g_encoding都会导致出现女性面孔,所以women_ng_encoding – women_g_encoding会取消掉结果图像中的女性特征。因此,如果你使用训练好的 VAE 解码men_g_encoding + women_g_encoding –women_ng_encoding,你会得到一个不带眼镜的男性的图像。这个例子中的编码算术表明,可以通过操纵其他三个组中的平均编码来获得不带眼镜的男性的编码。
练习 7.1
通过修改代码列表 7.9 执行以下编码算术:
-
从不带眼镜的男性的平均编码中减去带眼镜的男性的平均编码,并加上不带眼镜的女性的平均编码。将结果输入解码器,看看会发生什么。
-
从不带眼镜的女性的平均编码中减去不带眼镜的男性的平均编码,并加上带眼镜的女性的平均编码。将结果输入解码器,看看会发生什么。
-
从不带眼镜的男性的平均编码中减去不带眼镜的女性的平均编码,并加上带眼镜的男性的平均编码。将结果输入解码器,看看会发生什么。确保你修改图像标题以反映这些变化。解决方案在本书的 GitHub 仓库中提供:
github.com/markhliu/DGAI。
此外,我们可以在潜在空间中通过为它们分配不同的权重来插值任何两个编码,并创建一个新的编码。然后我们可以解码这个新的编码,并创建一个合成图像作为结果。通过选择不同的权重,我们可以创建一系列中间图像,这些图像从一个图像过渡到另一个图像。
让我们以有和无眼镜的女性的编码为例。我们将定义一个新的编码z为w*women_ng_encoding+(1-w)*women_g_encoding,其中w是我们放在women_ng_encoding上的权重。我们将w的值从 0 增加到 1,每次增加 0.2。然后我们解码它们,并显示结果的前六个图像。
列表 7.10 通过插值两个编码创建一系列图像
results=[]
for w in [0, 0.2, 0.4, 0.6, 0.8, 1.0]: ①
z=w*women_ng_encoding+(1-w)*women_g_encoding ②
out=vae.decoder(z.unsqueeze(0)) ③
results.append(out)
imgs=torch.cat((results[0],results[1],results[2],
results[3],results[4],results[5]),dim=0)
imgs=torchvision.utils.make_grid(imgs,6,1).cpu().numpy()
imgs=np.transpose(imgs,(1,2,0))
fig, ax = plt.subplots(dpi=100)
plt.imshow(imgs) ④
plt.axis("off")
plt.show()
① 遍历六个不同的 w 值
② 在两个编码之间进行插值
③ 解码插值编码
④ 显示六个结果图像
运行列表 7.10 中的代码后,你将看到如图 7.11 所示的输出。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-genai-pt/img/CH07_F11_Liu.png
图 7.11 通过插值编码创建一系列中间图像。我们首先获得戴眼镜的妇女的平均编码(women_g_encoding)和不戴眼镜的妇女的平均编码(women_ng_encoding)。插值编码 z 定义为 w*women_ng_encoding+(1-w)*women_g_encoding,其中 w 是 women_ng_encoding 上的权重。我们将 w 的值从 0 到 1 以 0.2 的增量改变,以创建六个插值编码。然后我们解码它们,并在图中显示产生的六个图像。
如图 7.11 所示,当你从左向右移动时,图像逐渐从戴眼镜的妇女过渡到不戴眼镜的妇女。这表明潜在空间中的编码是连续的、有意义的和可插值的。
练习 7.2
将列表 7.10 修改为使用以下编码对创建一系列中间图像:(i)men_ng_encoding 和 men_g_encoding;(ii)men_ng_encoding 和 women_ng_encoding;(iii)men_g_encoding 和 women_g_encoding。解决方案在本书的 GitHub 仓库中提供:github.com/markhliu/DGAI。
从下一章开始,你将踏上自然语言处理之旅。这将使你能够生成另一种形式的内容:文本。然而,你迄今为止使用的许多工具将在后面的章节中再次使用,例如深度神经网络和编码器-解码器架构。
摘要
-
AEs 具有双组件结构:编码器和解码器。编码器将数据压缩到低维空间(潜在空间)中的抽象表示,解码器解压缩编码信息并重建数据。
-
VAEs 也由编码器和解码器组成。它们在两个关键方面与 AEs 不同。首先,虽然 AE 将每个输入编码为潜在空间中的一个特定点,VAE 则将其编码为该空间内的一个概率分布。其次,AE 仅关注最小化重建误差,而 VAE 学习潜在变量的概率分布参数,最小化包括重建损失和正则化项(KL 散度)的损失函数。
-
在训练变分自编码器(VAEs)时,损失函数中的 KL 散度确保潜在变量的分布类似于正态分布。这鼓励编码器学习连续的、有意义的和可泛化的潜在表示。
-
一个训练良好的 VAE 可以将相似的输入映射到潜在空间中的邻近点,从而产生更连续和可解释的潜在空间。因此,VAEs 可以将潜在空间中的随机向量解码为有意义的输出,从而产生在训练集中未见过的图像。
-
在变分自编码器(VAE)中的潜在空间是连续且可解释的,与自编码器(AE)中的不同。因此,我们可以操纵编码以实现新的结果。我们还可以通过在潜在空间中两个编码的权重上进行变化,创建一系列从一个个例过渡到另一个个例的中间图像。
^(1) 迭代克·P·金玛和马克斯·韦林,2013 年,“自动编码变分贝叶斯。” arxiv.org/abs/1312.6114.
第三部分:自然语言处理和 Transformer
第三部分专注于文本生成。
在第八章,你将学习如何构建和训练一个循环神经网络来生成文本。在这个过程中,你将了解标记化和词嵌入是如何工作的。你还将学习如何自回归地生成文本,以及如何使用温度和 top-K 采样来控制生成文本的创造力。在第九章和第十章,你将从头开始构建一个 Transformer,基于论文“Attention Is All You Need”,来翻译英语到法语。在第十一章,你将学习如何从头开始构建 GPT-2XL,这是 GPT-2 的最大版本。之后,你将学习如何从 Hugging Face 提取预训练的权重并将它们加载到自己的 GPT-2 模型中。你将通过向模型输入提示来使用你的 GPT-2 生成文本。在第十二章,你将构建和训练一个 GPT 模型,以生成海明威风格的文本。
第八章:使用循环神经网络进行文本生成
本章涵盖
-
RNN 背后的理念以及为什么它们可以处理序列数据
-
字符标记化、单词标记化和子词标记化
-
词嵌入的工作原理
-
构建和训练 RNN 以生成文本
-
使用温度和 top-K 采样来控制文本生成的创造性
到目前为止,在这本书中,我们已经讨论了如何生成形状、数字和图像。从本章开始,我们将主要关注文本生成。生成文本通常被认为是生成式 AI 的圣杯,有以下几个令人信服的原因。人类语言极其复杂和微妙。它不仅涉及理解语法和词汇,还包括上下文、语气和文化参考。成功生成连贯且上下文适当的文本是一个重大的挑战,需要深入理解和处理语言。
作为人类,我们主要通过语言进行沟通。能够生成类似人类文本的 AI 可以更自然地与用户互动,使技术更加易于访问和用户友好。文本生成有许多应用,从自动化客户服务响应到创建完整的文章、游戏和电影的脚本,帮助创意写作,甚至构建个人助理。其对各个行业的潜在影响是巨大的。
在本章中,我们将尝试构建和训练模型以生成文本。你将学习如何应对文本生成中的三个主要挑战。首先,文本是序列数据,由按特定顺序组织的数据点组成,其中每个点按顺序排列以反映数据内部固有的顺序和相互依赖关系。由于序列的敏感顺序,预测序列的结果具有挑战性。改变元素的顺序会改变其含义。其次,文本表现出长距离依赖性:文本的某个部分的意义取决于文本中较早出现(例如,100 个词之前)的元素。理解和建模这些长距离依赖关系对于生成连贯的文本至关重要。最后,人类语言是模糊和上下文相关的。训练一个模型来理解细微差别、讽刺、习语和文化参考以生成上下文准确的文本具有挑战性。
你将探索一种专门设计用于处理顺序数据(如文本或时间序列)的特定神经网络:循环神经网络(RNN)。传统的神经网络,如前馈神经网络或全连接网络,独立地处理每个输入。这意味着网络分别处理每个输入,不考虑不同输入之间的任何关系或顺序。相比之下,RNNs 是专门设计来处理顺序数据的。在 RNN 中,给定时间步的输出不仅取决于当前输入,还取决于之前的输入。这允许 RNNs 保持一种记忆形式,从之前的时间步捕获信息以影响当前输入的处理。
这种顺序处理使得循环神经网络(RNNs)适用于那些输入顺序很重要的任务,例如语言建模,其目标是根据前面的单词预测句子中的下一个单词。我们将重点关注 RNN 的一种变体,即长短期记忆(LSTM)网络,它可以在文本等顺序数据中识别短期和长期数据模式。LSTM 模型使用一个隐藏状态来捕捉之前时间步的信息。因此,一个训练好的 LSTM 模型可以根据上下文生成连贯的文本。
生成的文本风格取决于训练数据。此外,由于我们计划从头开始训练一个用于文本生成的模型,训练文本的长度是一个关键因素。它需要足够广泛,以便模型能够有效地学习和模仿特定的写作风格,同时又要足够简洁,以避免在训练期间产生过度的计算需求。因此,我们将使用小说《安娜·卡列尼娜》中的文本进行训练,这似乎符合我们的目的,用于训练 LSTM 模型。由于像 LSTM 这样的神经网络不能直接接受文本作为输入,你将学习如何将文本分解成标记(在本章中是单个单词,但在后面的章节中可以是单词的一部分),这个过程称为标记化。然后,你将创建一个字典,将每个唯一的标记映射到一个整数(即索引)。基于这个字典,你将把文本转换成一个长序列的整数,以便输入到神经网络中。
你将使用一定长度的索引序列作为训练 LSTM 模型的输入。你将输入序列向右移动一个标记,并使用它作为输出:你实际上是在训练模型预测句子中的下一个标记。这是自然语言处理(NLP)中所谓的序列到序列预测问题,你将在后面的章节中再次看到它。
一旦 LSTM(长短期记忆网络)被训练,你将用它一次生成一个标记的文本,基于序列中的前一个标记,如下所示:你将一个提示(如“Anna and the”句子的一部分)输入到训练好的模型中。然后模型预测最可能的下一个标记,并将选定的标记附加到提示上。更新后的提示再次作为输入,模型再次被用来预测下一个标记。这个过程迭代进行,直到提示达到一定的长度。这种方法类似于更高级的生成模型(如 ChatGPT)所采用的机制(尽管 ChatGPT 不是一个 LSTM)。你将见证训练好的 LSTM 模型生成语法正确且连贯的文本,其风格与原始小说相匹配。
最后,你还将学习如何通过温度和 top-K 采样来控制生成文本的创造性。温度控制训练模型的预测随机性。高温度使得生成的文本更具创造性,而低温度则使文本更加自信和可预测。top-K 采样是一种方法,其中你从最可能的 K 个标记中选择下一个标记,而不是从整个词汇表中选择。K 值较小会导致在每一步选择高度可能的标记,这反过来又使得生成的文本不那么具有创造性,而更加连贯。
本章的主要目标并不是生成尽可能连贯的文本,正如之前提到的,这提出了巨大的挑战。相反,我们的目标是展示 RNNs 的局限性,从而为后续章节中介绍 Transformers 做准备。更重要的是,本章建立了文本生成的基本原则,包括标记化、词嵌入、序列预测、温度设置和 top-K 采样。因此,在后续章节中,你将牢固地理解 NLP 的基础知识。这个基础将使我们能够专注于 NLP 的其他更高级方面,例如注意力机制的工作原理和 Transformers 的架构。
8.1 RNNs 简介
在本章的开头,我们提到了生成文本所涉及的复杂性,尤其是在追求连贯性和上下文相关性时。本节将进一步深入探讨这些挑战,并探讨 RNNs 的架构。我们将解释为什么 RNNs 适合这项任务以及它们的局限性(这是它们被 Transformers 取代的原因)。
RNNs(循环神经网络)是专门设计来处理序列数据的,这使得它们能够胜任文本生成任务,这项任务在本质上具有序列性。它们利用一种称为隐藏状态的记忆形式,来捕捉和保留序列早期部分的信息。这种能力对于保持上下文和随着序列的进展理解依赖关系至关重要。
在本章中,我们将特别使用 LSTM 网络,这是 RNNs 的高级版本,用于文本生成,利用其高级功能来应对这项任务中的挑战。
8.1.1 文本生成的挑战
文本代表了典型的序列数据,它被定义为任何数据集,其中元素的顺序至关重要。这种结构意味着各个元素之间的相对位置具有重大意义,通常传达了理解数据所必需的重要信息。序列数据的例子包括时间序列(如股价)、文本内容(如句子)和音乐作品(音符的连续序列)。
本书主要关注文本生成,尽管在第十三章和第十四章中也涉及音乐生成。文本生成的过程充满了复杂性。一个主要的挑战在于对句子中单词序列的建模,改变顺序可能会极大地改变句子的含义。例如,在句子“肯塔基在昨晚的足球比赛中击败了范德比尔特”中,将“肯塔基”和“范德比尔特”完全互换位置,尽管使用了相同的单词,却完全颠倒了句子的含义。此外,正如引言中提到的,文本生成在处理长距离依赖关系和解决歧义问题上也面临着挑战。
在本章中,我们将探讨解决这些挑战的一种方法——即使用 RNNs(循环神经网络)。虽然这种方法并不完美,但它为你在后续章节中遇到的更高级技术奠定了基础。这种方法将帮助你了解如何管理词序、解决长距离依赖关系,以及处理文本中的固有歧义,为你提供文本生成的基本技能。通过本章的学习,你将为书中更复杂的方法和深入的理解打下坚实的基础。在这个过程中,你将获得许多在自然语言处理(NLP)领域非常有价值的技术,例如文本分词、词嵌入和序列到序列的预测。
8.1.2 RNNs 是如何工作的?
RNNs 是一种专门的人工神经网络形式,旨在识别数据序列中的模式,如文本、音乐或股价。与处理输入独立的传统神经网络不同,RNNs 内部有循环,允许信息持续存在。
在生成文本的挑战之一是如何根据所有前面的单词预测下一个单词,以便预测能够捕捉到长距离依赖关系和上下文意义。RNNs 的输入不仅是一个独立的项,而是一个序列(例如句子中的单词)。在每一个时间步,预测不仅基于当前输入,还基于通过隐藏状态总结的所有先前输入。以短语“一只青蛙有四条腿”为例。在第一个时间步,我们使用单词“一个”来预测第二个单词“青蛙”。在第二个时间步,我们使用“一个”和“青蛙”来预测下一个单词。当我们预测最后一个单词时,我们需要使用所有四个先前单词“一个青蛙有四条”。
RNNs 的一个关键特性是所谓的隐藏状态,它捕捉了序列中所有先前元素的信息。这一特性对于网络有效处理和生成序列数据至关重要。RNNs 的功能和这种序列处理在图 8.1 中得到了展示,该图说明了循环神经元层是如何随时间展开的。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-genai-pt/img/CH08_F01_Liu.png
图 8.1 展示了通过时间展开的循环神经元层。当一个循环神经网络对序列数据进行预测时,它从上一个时间步的隐藏状态 h(t – 1)以及当前时间步的输入 x(t)中获取信息,并生成输出 y(t)以及更新的隐藏状态 h(t)。时间步 t 的隐藏状态捕捉了所有先前时间步的信息,x(0),x(1),…,x(t)。
RNNs 中的隐藏状态在捕捉所有时间步的信息方面发挥着关键作用。这使得 RNNs 能够做出不仅由当前输入 x(t)而且由所有先前输入的累积知识 x(0),x(1),…,x(t – 1)所指导的预测。这一属性使 RNNs 能够理解时间依赖关系。它们能够从输入序列中把握上下文,这对于语言建模等任务至关重要,在这些任务中,句子中的前一个单词为预测下一个单词设定了场景。
然而,RNNs 并非没有缺点。尽管标准 RNNs 能够处理短期依赖关系,但在文本中的长距离依赖关系上却显得力不从心。这种困难源于梯度消失问题,在长序列中,梯度(对于训练网络至关重要)会减小,阻碍模型学习长距离关系的能力。为了减轻这一问题,已经开发出了 RNNs 的高级版本,例如 LSTM 网络。
LSTM 网络是由 Hochreiter 和 Schmidhuber 在 1997 年提出的.^(1) LSTM 网络由 LSTM 单元(或细胞)组成,每个单元的结构都比标准 RNN 神经元更复杂。细胞状态是 LSTM 的关键创新:它充当一种传送带,沿着整个 LSTM 单元链直接运行。它具有在网络中携带相关信息的能力。向细胞状态添加或删除信息的能力使 LSTM 能够捕捉长期依赖关系并长时间记住信息。这使得它们在语言建模和文本生成等任务上更加有效。在本章中,我们将利用 LSTM 模型进行文本生成项目,旨在模仿小说 安娜·卡列尼娜 的风格。
然而,值得注意的是,即使是像 LSTM 这样的高级 RNN 变体在捕捉序列数据中的极长距离依赖关系时也会遇到障碍。我们将在下一章讨论这些挑战并提供解决方案,继续探索用于有效序列数据处理和生成的复杂模型。
8.1.3 训练 LSTM 模型的步骤
接下来,我们将讨论训练 LSTM 模型生成文本所涉及的步骤。这个概述旨在在开始项目之前,提供一个对训练过程的坚实基础理解。
训练文本的选择取决于期望的输出。一部篇幅较长的小说是一个良好的起点。其丰富的内容使模型能够有效地学习和复制特定的写作风格。大量的文本数据增强了模型在此风格上的熟练度。同时,小说通常不会过长,这有助于管理训练时间。对于我们 LSTM 模型的训练,我们将利用来自 安娜·卡列尼娜 的文本,符合我们之前概述的训练数据标准。
与其他深度神经网络类似,LSTM 模型不能直接处理原始文本。相反,我们将文本转换为数值形式。这始于将文本分解成更小的片段,这个过程称为分词,其中每个片段都是一个标记。标记可以是完整的单词、标点符号(如感叹号或逗号),或特殊字符(如 & 或 %)。在本章中,这些元素都将被视为单独的标记。尽管这种分词方法可能不是最有效的,但它易于实现,因为我们只需要将单词映射到标记。在后续章节中,我们将使用子词分词,其中一些不常见的单词被分解成更小的片段,如音节。在分词之后,我们为每个标记分配一个唯一的整数,创建文本的数值表示,即整数序列。
为了准备训练数据,我们将这个长序列划分为长度相等的更短序列。在我们的项目中,我们将使用由 100 个整数组成的序列。这些序列构成了我们模型的特征(即x变量)。然后,我们通过将输入序列向右移动一个标记来生成输出y。这种设置使得 LSTM 模型能够根据序列中的先前标记预测下一个标记。输入和输出的配对作为训练数据。我们的模型包括 LSTM 层来理解文本中的长期模式,以及一个嵌入层来把握语义含义。
让我们回顾一下之前提到的预测句子“a frog has four legs”的例子。图 8.2 展示了 LSTM 模型训练的工作原理。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-genai-pt/img/CH08_F02_Liu.png
图 8.2 展示了如何训练 LSTM 模型的一个示例。我们首先将训练文本分解为标记,并为每个标记分配一个唯一的整数,从而将文本作为索引序列的数值表示。然后,我们将这个长序列划分为长度相等的更短序列。这些序列构成了我们模型的特征(即 x 变量)。然后,我们通过将输入序列向右移动一个标记来生成输出 y。这种设置使得 LSTM 模型能够根据序列中的先前标记预测下一个标记。
在第一个时间步,模型使用单词“a”来预测单词“frog”。由于“a”没有前面的单词,我们用零初始化隐藏状态。LSTM 模型接收“a”的索引和这个初始隐藏状态作为输入,并输出预测的下一个单词以及更新的隐藏状态 h0。在随后的时间步,使用单词“frog”和更新的状态 h0 来预测“has”并生成新的隐藏状态 h1。预测下一个单词和更新隐藏状态的这一序列持续进行,直到模型预测出句子中的最后一个单词,“legs”。
然后,将预测结果与句子中的实际下一个单词进行比较。由于模型实际上是在预测词汇表中所有可能的标记中的下一个标记,因此存在一个多类别分类问题。我们在每次迭代中调整模型参数,以最小化交叉熵损失,从而使模型在下一个迭代中的预测结果更接近训练数据中的实际输出。
模型训练完成后,生成文本的过程从将种子序列输入模型开始。模型预测下一个标记,然后将该标记附加到序列中。这种预测和序列更新的迭代过程重复进行,直到生成所需的文本长度。
8.2 自然语言处理基础
深度学习模型,包括我们之前讨论的 LSTM 模型以及你将在后续章节中学习的 Transformer,不能直接处理原始文本,因为它们被设计成与数值数据一起工作,通常是向量或矩阵的形式。神经网络的处理和学习能力基于数学运算,如加法、乘法和激活函数,这些运算需要数值输入。因此,首先将文本分解成更小、更易于管理的元素,即标记,是至关重要的。这些标记可以是从单个字符和单词到子词单元。
NLP 任务中的下一个关键步骤是将这些标记转换为数值表示。这种转换对于将它们输入深度神经网络是必要的,这是训练我们模型的基本部分。
在本节中,我们将讨论不同的分词方法,以及它们的优缺点。此外,你还将深入了解将标记转换为密集向量表示的过程——这种方法称为词嵌入。这项技术对于捕捉语言的意义,使其以深度学习模型能够有效利用的格式至关重要。
8.2.1 不同的分词方法
分词涉及将文本划分为更小的部分,称为标记(tokens),这些标记可以是单词、字符、符号或其他有意义的单元。分词的主要目标是简化文本数据分析和处理的过程。
从广义上讲,分词有三种方法。第一种是字符分词,其中文本被划分为其构成字符。这种方法用于具有复杂形态结构的语言,如土耳其语或芬兰语,在这些语言中,单词的意义可能会因字符的微小变化而显著改变。以英语短语“它好得令人难以置信!”为例;它被分解为以下单个字符:['I', 't', ' ', 'i', 's', ' ', 'u', 'n', 'b', 'e', 'l', 'i', 'e', 'v', 'a', 'b', 'l', 'y', ' ', 'g', 'o', 'o', 'd', '!']。字符分词的一个关键优势是唯一标记的数量有限。这种限制显著减少了深度学习模型中的参数数量,从而实现了更快、更有效的训练。然而,主要的缺点是单个字符通常缺乏显著的意义,这使得机器学习模型难以从字符序列中提取有意义的见解。
练习 8.1
使用字符分词将短语“Hi, there!”划分为单个标记。
第二种方法是词元化,其中文本被分割成单个单词和标点符号。它常用于唯一单词数量不是太多的情况下。例如,同样的短语“它好得令人难以置信!”变成了五个标记:['It', 'is', 'unbelievably', 'good', '!']。这种方法的主要优点是每个单词本身携带语义意义,这使得模型更容易解释文本。然而,缺点是独特标记的数量大幅增加,这增加了深度学习模型中的参数数量。这种增加可能导致训练过程变慢和效率降低。
练习 8.2
使用词元化将短语“Hi, how are you?”分解成单个标记。
第三种方法是子词元化。这种方法是 NLP 中的一个关键概念,将文本分解成更小、更有意义的组件,称为子词。例如,短语“它好得令人难以置信!”将被分解成如['It', 'is', 'un', 'believ', 'ably', 'good', '!']这样的标记。大多数高级语言模型,包括 ChatGPT,都使用子词元化,你将在接下来的几章中使用这种方法。子词元化在更传统的词元化技术之间取得了平衡,这些技术通常将文本分割成单个单词或字符。基于单词的词元化虽然能捕捉更多意义,但会导致词汇量巨大。相反,基于字符的词元化会导致词汇量较小,但每个标记的语义价值较低。
子词元化通过在词汇表中保留常用单词的完整性,同时将不太常见或更复杂的单词分解成子组件,有效地缓解了这些问题。这种技术在词汇量大的语言或表现出高度词形变化的语言中特别有利。通过采用子词元化,整体词汇量大幅减少。这种减少提高了语言处理任务的效率和有效性,尤其是在处理广泛的语结构时。
在本章中,我们将重点关注词元化,因为它为初学者提供了一个直接的起点。随着我们进入后面的章节,我们的注意力将转向子词元化,使用已经通过这种技术训练过的模型。这种方法使我们能够专注于更高级的主题,例如理解 Transformer 架构和探索注意力机制的内部工作原理。
8.2.2 词嵌入
词嵌入是一种将标记转换为紧凑的向量表示的方法,捕捉它们的语义信息和相互关系。这项技术在 NLP 中至关重要,尤其是在深度神经网络,包括 LSTM 和 Transformer 等模型,需要数值输入的情况下。
Traditionally, tokens are converted into numbers using one-hot encoding before being fed into NLP models. In one-hot encoding, each token is represented by a vector where only one element is ‘1’, and the rest are ‘0’s. For example, in this chapter, there are 12,778 unique word-based tokens in the text for the novel Anna Karenina. Each token is represented by a vector of 12,778 dimensions. Consequently, a phrase like “happy families are all alike” is represented as a 5 × 12,778 matrix, where 5 represents the number of tokens. This representation, however, is highly inefficient due to its large dimensionality, leading to an increased number of parameters, which can hinder training speed and efficiency.
LSTMs, Transformers, and other advanced NLP models address this inefficiency through word embedding. Instead of bulky one-hot vectors, word embedding uses continuous, lower-dimensional vectors (e.g., 128-value vectors we use in this chapter). As a result, the phrase “happy families are all alike” is represented by a more compact 5 × 128 matrix after word embedding. This streamlined representation drastically reduces the model’s complexity and enhances training efficiency.
Word embedding not only reduces word complexity by condensing it into a lower-dimensional space but also effectively captures the context and the nuanced semantic relationships between words, a feature that simpler representations like one-hot encoding lack, for the following reasons. In one-hot encoding, all tokens have the same distance from each other in vector space. However, in word embeddings, tokens with similar meanings are represented by vectors close to each other in the embedding space. Word embeddings are learned from the text in the training data; the resulting vectors capture contextual information. Tokens that appear in similar contexts will have similar embeddings, even if they are not explicitly related.
Word embedding in NLP
Word embeddings are a powerful method for representing tokens in NLP that offer significant advantages over traditional one-hot encoding in capturing context and semantic relationships between words.
One-hot encoding represents tokens as sparse vectors with a dimension equal to the size of the vocabulary, where each token is represented by a vector with all zeros except for a single one at the index corresponding to the token. In contrast, word embeddings represent tokens as dense vectors with much lower dimensions (e.g., 128 dimensions in this chapter and 256 dimensions in chapter 12). This dense representation is more efficient and can capture more information.
具体来说,在一维编码中,所有标记在向量空间中彼此距离相同,这意味着标记之间没有相似性的概念。然而,在词嵌入中,相似的标记由在嵌入空间中彼此靠近的向量表示。例如,“king”(国王)和“queen”(王后)会有相似的嵌入,反映了它们语义上的关系。
词嵌入是从训练数据中的文本学习得到的。嵌入过程使用标记出现的上下文来学习它们的嵌入,这意味着生成的向量捕捉了上下文信息。出现在相似上下文中的标记将具有相似的嵌入,即使它们没有明确的相关性。
总体而言,词嵌入提供了对词语更细腻和高效的表示,它捕捉了语义关系和上下文信息,这使得它们比一维编码更适合自然语言处理任务。
在实际应用中,尤其是在 PyTorch 等框架中,词嵌入是通过通过一个线性层传递索引来实现的,它将这些索引压缩到一个低维空间。也就是说,当你向 nn.Embedding() 层传递一个索引时,它会查找嵌入矩阵中对应的行,并返回该索引的嵌入向量,从而避免了创建可能非常大的一个热向量。这个嵌入层的权重不是预先定义的,而是在训练过程中学习的。这一学习特性使得模型能够根据训练数据细化其对词义的理解,从而在神经网络中实现更细腻和上下文感知的语言表示。这种方法显著提高了模型处理和解释语言数据的有效性和意义。
8.3 准备数据以训练 LSTM 模型
在本节中,我们将处理文本数据并为其训练做好准备。我们首先将文本分解成单个标记。接下来的步骤是创建一个字典,将每个标记分配一个索引,本质上是将它们映射到整数。完成这些设置后,我们将这些标记组织成训练数据的批次,这对于在下一节训练 LSTM 模型至关重要。
我们将以详细、分步骤的方式介绍标记化过程,确保你彻底理解标记化是如何工作的。我们将使用词标记化,因为它在将文本分割成单词方面简单,而不是更复杂的子词标记化,后者需要细微地掌握语言结构。在后面的章节中,我们将使用更复杂的方法来使用预训练的子词标记化器。这将使我们能够专注于高级主题,如注意力机制和 Transformer 架构,而不会在文本处理的初始阶段陷入困境。
8.3.1 下载和清理文本
我们将使用小说《安娜·卡列尼娜》的文本来训练我们的模型。请访问mng.bz/znmX下载文本文件,并将其保存为电脑上文件夹/files/中的 anna.txt。之后,打开文件并删除第 39888 行之后的所有内容,该行内容为"END OF THIS PROJECT GUTENBERG EBOOK ANNA KARENINA."。或者,您可以直接从书籍的 GitHub 仓库下载 anna.txt 文件:github.com/markhliu/DGAI。
首先,我们加载数据并打印出一些段落,以了解数据集:
with open("files/anna.txt","r") as f:
text=f.read()
words=text.split(" ")
print(words[:20])
输出结果为
['Chapter', '1\n\n\nHappy', 'families', 'are', 'all', 'alike;', 'every',
'unhappy', 'family', 'is', 'unhappy', 'in', 'its',
'own\nway.\n\nEverything', 'was', 'in', 'confusion', 'in', 'the',
"Oblonskys'"]
如您所见,行断(用\n 表示)被视为文本的一部分。因此,我们应该将这些行断替换为空格,这样它们就不会出现在词汇表中。此外,将所有单词转换为小写在我们的设置中很有帮助,因为它确保像“The”和“the”这样的单词被视为相同的标记。这一步对于减少独特标记的多样性至关重要,从而使得训练过程更加高效。此外,标点符号应与它们后面的单词保持一定的距离。如果没有这种分隔,像“way.”和“way”这样的组合会被错误地视为不同的标记。为了解决这些问题,我们将清理文本:
clean_text=text.lower().replace("\n", " ") ①
clean_text=clean_text.replace("-", " ") ②
for x in ",.:;?!$()/_&%*@'`":
clean_text=clean_text.replace(f"{x}", f" {x} ")
clean_text=clean_text.replace('"', ' " ') ③
text=clean_text.split()
① 替换行断为空格
② 替换连字符为空格
③ 在标点符号和特殊字符周围添加空格
接下来,我们获取独特标记:
from collections import Counter
word_counts = Counter(text)
words=sorted(word_counts, key=word_counts.get,
reverse=True)
print(words[:10])
列表words包含文本中所有的独特标记,最频繁出现的标记排在第一位,最不频繁的标记排在最后。前一个代码块输出的结果是
[',', '.', 'the', '"', 'and', 'to', 'of', 'he', "'", 'a']
前面的输出显示了最频繁的 10 个标记。逗号(,)是最频繁的标记,句号(.)是第二频繁的标记。单词“the”是第三频繁的标记,以此类推。
现在,我们创建两个字典:一个将标记映射到索引,另一个将索引映射到标记。
列表 8.1 将标记映射到索引和索引映射到标记的字典
text_length=len(text) ①
num_unique_words=len(words) ②
print(f"the text contains {text_length} words")
print(f"there are {num_unique_words} unique tokens")
word_to_int={v:k for k,v in enumerate(words)} ③
int_to_word={k:v for k,v in enumerate(words)} ④
print({k:v for k,v in word_to_int.items() if k in words[:10]})
print({k:v for k,v in int_to_word.items() if v in words[:10]})
① 文本长度(文本中有多少个标记)
② 独特标记的长度
③ 将标记映射到索引
④ 将索引映射到标记
前一个代码块输出的结果是
the text contains 437098 words
there are 12778 unique tokens
{',': 0, '.': 1, 'the': 2, '"': 3, 'and': 4, 'to': 5, 'of': 6, 'he': 7,
"'": 8, 'a': 9}
{0: ',', 1: '.', 2: 'the', 3: '"', 4: 'and', 5: 'to', 6: 'of', 7: 'he',
8: "'", 9: 'a'}
小说《安娜·卡列尼娜》的文本总共有 437,098 个标记。其中,有 12,778 个独特的标记。字典word_to_int为每个独特的标记分配一个索引。例如,逗号(,)被分配了索引 0,而句号(.)被分配了索引 1。字典int_to_word将索引转换回标记。例如,索引 2 转换回标记“the”。索引 4 转换回标记“and”,以此类推。
最后,我们将整个文本转换为索引:
print(text[0:20])
wordidx=[word_to_int[w] for w in text]
print([word_to_int[w] for w in text[0:20]])
输出结果为
['chapter', '1', 'happy', 'families', 'are', 'all', 'alike', ';', 'every',
'unhappy', 'family', 'is', 'unhappy', 'in', 'its', 'own', 'way', '.',
'everything', 'was']
[208, 670, 283, 3024, 82, 31, 2461, 35, 202, 690, 365, 38, 690, 10, 234,
147, 166, 1, 149, 12]
我们将文本中的所有标记转换为相应的索引,并将它们保存在列表wordidx中。前一个输出显示了文本中的前 20 个标记以及相应的索引。例如,文本中的第一个标记是chapter,其索引值为 208。
练习 8.3
查找字典word_to_int中标记anna的索引值。
8.3.2 创建训练数据批次
接下来,我们创建用于训练的(x, y)对。每个 x 是一个包含 100 个索引的序列。100 这个数字并没有什么神奇之处,你可以轻松地将其更改为 90 或 110,并得到相似的结果。设置数字太大可能会减慢训练速度,而设置数字太小可能会导致模型无法捕捉到长距离依赖关系。然后我们将窗口向右滑动一个标记,并将其用作目标 y。在序列生成期间,将序列向右移动一个标记并用作输出是训练语言模型(包括 Transformers)中的常见技术。以下列表中的代码块创建了训练数据。
列表 8.2 创建训练数据
import torch
seq_len=100 ①
xys=[]
for n in range(0, len(wordidx)-seq_len-1): ②
x = wordidx[n:n+seq_len] ③
y = wordidx[n+1:n+seq_len+1] ④
xys.append((torch.tensor(x),(torch.tensor(y))))
① 每个输入包含 100 个索引。
② 从文本的第一个标记开始,每次向右滑动一个标记
③ 定义输入 x
④ 将输入 x 向右移动一个标记,并将其用作输出 y
通过将序列向右移动一个标记并将其用作输出,模型被训练来根据前面的标记预测下一个标记。例如,如果输入序列是 "how are you",则移动后的序列将是 "are you today"。在训练过程中,模型学会在看到 'how' 后预测 'are',在看到 'are' 后预测 'you',依此类推。这有助于模型学习序列中下一个标记的概率分布。你将在本书后面的内容中再次看到这种做法。
我们将为训练创建数据批次,每个批次包含 32 对(x, y)。
from torch.utils.data import DataLoader
torch.manual_seed(42)
batch_size=32
loader = DataLoader(xys, batch_size=batch_size, shuffle=True)
我们现在有了训练数据集。接下来,我们将创建一个 LSTM 模型,并使用我们刚刚处理的数据来训练它。
8.4 构建和训练 LSTM 模型
在本节中,你将开始使用 PyTorch 的内置 LSTM 层构建一个 LSTM 模型。这个模型将从词嵌入层开始,将每个索引转换为一个 128 维的密集向量。你的训练数据将通过这个嵌入层,然后输入到 LSTM 层。这个 LSTM 层被设计成按顺序处理序列的元素。在 LSTM 层之后,数据将进入一个线性层,其输出大小与你的词汇表大小相匹配。LSTM 模型生成的输出本质上是 logits,作为 softmax 函数的输入来计算概率。
一旦你构建了 LSTM 模型,下一步将涉及使用你的训练数据来训练这个模型。这个训练阶段对于提高模型理解和生成与提供的数据一致的模式的能力至关重要。
8.4.1 构建 LSTM 模型
在列表 8.3 中,我们定义了一个WordLSTM()类,作为我们的 LSTM 模型,用于训练以生成《安娜·卡列尼娜》风格的文本。该类定义如下所示。
列表 8.3 定义WordLSTM()类
from torch import nn
device="cuda" if torch.cuda.is_available() else "cpu"
class WordLSTM(nn.Module):
def __init__(self, input_size=128, n_embed=128,
n_layers=3, drop_prob=0.2):
super().__init__()
self.input_size=input_size
self.drop_prob = drop_prob
self.n_layers = n_layers
self.n_embed = n_embed
vocab_size=len(word_to_int)
self.embedding=nn.Embedding(vocab_size,n_embed) ①
self.lstm = nn.LSTM(input_size=self.input_size,
hidden_size=self.n_embed,
num_layers=self.n_layers,
dropout=self.drop_prob,batch_first=True) ②
self.fc = nn.Linear(input_size, vocab_size)
def forward(self, x, hc):
embed=self.embedding(x)
x, hc = self.lstm(embed, hc) ③
x = self.fc(x)
return x, hc
def init_hidden(self, n_seqs): ④
weight = next(self.parameters()).data
return (weight.new(self.n_layers,
n_seqs, self.n_embed).zero_(),
weight.new(self.n_layers,
n_seqs, self.n_embed).zero_())
① 训练数据首先通过嵌入层。
② 使用 PyTorch LSTM() 类创建一个 LSTM 层
③ 在每个时间步中,LSTM 层使用前一个标记和隐藏状态来预测下一个标记和下一个隐藏状态。
④ 初始化输入序列中第一个标记的隐藏状态
之前定义的 WordLSTM() 类有三个层:词嵌入层、LSTM 层和最终的线性层。我们将 n_layers 参数的值设置为 3,这意味着 LSTM 层将三个 LSTM 堆叠在一起形成一个堆叠的 LSTM,最后两个 LSTM 将前一个 LSTM 的输出作为输入。init_hidden() 方法在模型使用序列的第一个元素进行预测时将隐藏状态填充为零。在每个时间步中,输入是当前标记和前一个隐藏状态,输出是下一个标记和下一个隐藏状态。
torch.nn.Embedding() 类的工作原理
PyTorch 中的 torch.nn.Embedding() 类用于在神经网络中创建嵌入层。嵌入层是一个可训练的查找表,它将整数索引映射到密集的、连续的向量表示(嵌入)。
当你创建 torch.nn.Embedding() 的实例时,你需要指定两个主要参数:num_embeddings,词汇表的大小(唯一标记的总数),以及 embedding_dim,每个嵌入向量的大小(输出嵌入的维度性)。
在内部,该类创建一个形状为 (num_embeddings, embedding_dim) 的矩阵(或查找表),其中每一行对应于特定索引的嵌入向量。最初,这些嵌入是随机初始化的,但在训练过程中通过反向传播进行学习和更新。
当你将索引张量传递给嵌入层(在网络的前向传递过程中)时,它会在查找表中查找相应的嵌入向量并返回它们。关于该类的更多信息可在 PyTorch mng.bz/n0Zd 获取。
我们创建 WordLSTM() 类的实例,并将其用作我们的 LSTM 模型,如下所示:
model=WordLSTM().to(device)
当创建 LSTM 模型时,权重是随机初始化的。当我们使用 (x, y) 对来训练模型时,LSTM 通过调整模型参数学习根据序列中的所有先前标记预测下一个标记。正如我们在图 8.2 中所展示的,LSTM 学习根据当前标记和当前隐藏状态预测下一个标记和下一个隐藏状态,这是所有先前标记信息的总结。
我们使用学习率为 0.0001 的 Adam 优化器。损失函数是交叉熵损失,因为这是一个多类别分类问题:模型试图从包含 12,778 个选择的字典中预测下一个标记:
lr=0.0001
optimizer = torch.optim.Adam(model.parameters(), lr=lr)
loss_func = nn.CrossEntropyLoss()
现在 LSTM 模型已经构建,我们将使用之前准备好的训练数据批次来训练模型。
8.4.2 训练 LSTM 模型
在每个训练周期中,我们遍历训练集中的所有数据批次(x, y)。LSTM 模型接收输入序列 x,并生成一个预测输出序列 ŷ。这个预测与实际输出序列 y 进行比较,以计算交叉熵损失,因为我们实际上在这里进行多类别分类。然后我们调整模型的参数以减少这个损失,就像我们在第二章中分类服装物品时做的那样。
虽然我们可以将我们的数据分为训练集和验证集,直到在验证集上不再看到进一步的改进(就像我们在第二章中所做的那样)来训练模型,但我们的主要目标是掌握 LSTM 模型的工作原理,而不一定是实现最佳参数调整。因此,我们将模型训练 50 个周期。
列表 8.4 训练 LSTM 模型以生成文本
model.train()
for epoch in range(50):
tloss=0
sh,sc = model.init_hidden(batch_size)
for i, (x,y) in enumerate(loader): ①
if x.shape[0]==batch_size:
inputs, targets = x.to(device), y.to(device)
optimizer.zero_grad()
output, (sh,sc) = model(inputs, (sh,sc)) ②
loss = loss_func(output.transpose(1,2),targets) ③
sh,sc=sh.detach(),sc.detach()
loss.backward()
nn.utils.clip_grad_norm_(model.parameters(), 5)
optimizer.step() ④
tloss+=loss.item()
if (i+1)%1000==0:
print(f"at epoch {epoch} iteration {i+1}\
average loss = {tloss/(i+1)}")
① 遍历训练数据中的所有(x,y)批次
② 使用模型来预测输出序列
③ 将预测结果与实际输出进行比较,并计算损失
④ 调整模型参数以最小化损失
在前面的代码列表中,sh 和 sc 一起构成了隐藏状态。特别是,细胞状态 sc 作为传送带,在许多时间步中携带信息,每个时间步都会添加或删除信息。sh 是 LSTM 单元在给定时间步的输出。它包含有关当前输入的信息,并用于将信息传递给序列中的下一个 LSTM 单元。
如果您有 CUDA 支持的 GPU,这次训练大约需要 6 个小时。如果您只使用 CPU,可能需要一两天,具体取决于您的硬件。或者您可以从我的网站下载预训练的权重:mng.bz/vJZa。
接下来,我们将训练好的模型权重保存在本地文件夹中:
import pickle
torch.save(model.state_dict(),"files/wordLSTM.pth")
with open("files/word_to_int.p","wb") as fb:
pickle.dump(word_to_int, fb)
字典 word_to_int 也保存在您的计算机上,这是一个实用的步骤,确保您可以在不重复分词过程的情况下,使用训练好的模型生成文本。
8.5 使用训练好的 LSTM 模型生成文本
现在您已经有一个训练好的 LSTM 模型,您将在这个部分学习如何使用它来生成文本。目标是查看训练好的模型是否可以通过迭代预测下一个标记来生成语法正确且连贯的文本。您还将学习如何使用温度和 top-K 采样来控制生成文本的创造性。
在使用训练好的 LSTM 模型生成文本时,我们以提示作为模型的初始输入。我们使用训练好的模型来预测最可能的下一个标记。将下一个标记附加到提示后,我们将新的序列输入到训练好的模型中,再次预测下一个标记。我们重复这个过程,直到序列达到一定的长度。
8.5.1 通过预测下一个标记来生成文本
首先,我们从本地文件夹中加载训练好的模型权重和字典 word_to_int:
model.load_state_dict(torch.load("files/wordLSTM.pth",
map_location=device))
with open("files/word_to_int.p","rb") as fb:
word_to_int = pickle.load(fb)
int_to_word={v:k for k,v in word_to_int.items()}
书籍的 GitHub 仓库中也提供了word_to_int.p文件。我们将字典word_to_int中的键和值的顺序进行交换,以创建字典int_to_word。
要使用训练好的 LSTM 模型生成文本,我们需要一个提示作为生成文本的起点。我们将默认提示设置为“Anna and the”。一个简单的方法是限制生成的文本长度,例如 200 个标记:一旦达到所需长度,我们就要求模型停止生成。
下面的列表定义了一个sample()函数,用于根据提示生成文本。
列表 8.5:用于生成文本的sample()函数
import numpy as np
def sample(model, prompt, length=200):
model.eval()
text = prompt.lower().split(' ')
hc = model.init_hidden(1)
length = length - len(text) ①
for i in range(0, length):
if len(text)<= seq_len:
x = torch.tensor([[word_to_int[w] for w in text]])
else:
x = torch.tensor([[word_to_int[w] for w \
in text[-seq_len:]]]) ②
inputs = x.to(device)
output, hc = model(inputs, hc) ③
logits = output[0][-1]
p = nn.functional.softmax(logits, dim=0).detach().cpu().numpy()
idx = np.random.choice(len(logits), p=p) ④
text.append(int_to_word[idx]) ⑤
text=" ".join(text)
for m in ",.:;?!$()/_&%*@'`":
text=text.replace(f" {m}", f"{m} ")
text=text.replace('" ', '"')
text=text.replace("' ", "'")
text=text.replace('" ', '"')
text=text.replace("' ", "'")
return text
① 确定需要生成多少个标记
② 输入是当前序列;如果它超过 100 个标记,则对其进行修剪
③ 使用训练模型进行预测
④ 根据预测概率选择下一个标记
⑤ 将预测的下一个标记添加到序列中并重复
sample()函数接受三个参数。第一个是您将要使用的训练好的 LSTM 模型。第二个是文本生成的起始提示,可以是任何长度的短语,用引号括起来。第三个参数指定要生成的文本长度,以标记为单位,默认值为 200 个标记。
在函数内部,我们首先从总期望长度中减去提示中的标记数量,以确定需要生成的标记数量。在生成下一个标记时,我们考虑当前序列的长度。如果它少于 100 个标记,我们将整个序列输入到模型中;如果它超过 100 个标记,则只使用序列的最后 100 个标记作为输入。然后将这个输入送入训练好的 LSTM 模型来预测下一个标记,我们将这个预测的标记添加到当前序列中。我们继续这个过程,直到序列达到所需长度。
当生成下一个标记时,模型使用 NumPy 中的 random.choice(len(logits), p = p)方法。在这里,该方法的第一参数表示选择范围,在这个例子中是 len(logits) = 12778。这意味着模型将随机选择一个从 0 到 12,777 的整数,每个整数对应词汇表中的一个不同标记。第二个参数 p 是一个包含 12,778 个元素的数组,其中每个元素表示从词汇表中选择相应标记的概率。在这个数组中,概率较高的标记更有可能被选中。
让我们使用“Anna and the prince”作为提示(确保在使用自己的提示时在标点符号前加上空格)来生成一段文本:
torch.manual_seed(42)
np.random.seed(42)
print(sample(model, prompt='Anna and the prince'))
在这里,我将 PyTorch 和 NumPy 中的随机种子数固定为 42,以防你想要重现结果。生成的段落是
anna and the prince did not forget what he had not spoken. when the softening barrier was not so long as he had talked to his brother, all the hopelessness of the impression. "official tail, a man who had tried him, though he had been able to get across his charge and locked close, and the light round the snow was in the light of the altar villa. the article in law levin was first more precious than it was to him so that if it was most easy as it would be as the same. this was now perfectly interested. when he had got up close out into the sledge, but it was locked in the light window with their one grass, and in the band of the leaves of his projects, and all the same stupid woman, and really, and i swung his arms round that thinking of bed. a little box with the two boys were with the point of a gleam of filling the boy, noiselessly signed the bottom of his mouth, and answering them took the red
你可能已经注意到生成的文本完全是小写的。这是因为,在文本处理阶段,我们将所有大写字母转换为小写,以最小化唯一标记的数量。
经过 6 小时的训练生成的文本相当令人印象深刻!大多数句子都遵循语法规范。虽然它可能无法与 ChatGPT 等高级系统生成的文本的复杂程度相媲美,但这仍然是一个重大的成就。通过在这个练习中获得的能力,你将准备好在后面的章节中训练更高级的文本生成模型。
8.5.2 文本生成中的温度和 top-K 采样
可以通过使用温度和 top-K 采样等技术来控制生成文本的创造力。
温度调整在选择下一个标记之前分配给每个潜在标记的概率分布。它通过温度的值有效地缩放 logits,即计算这些概率的 softmax 函数的输入。logits 是应用 softmax 函数之前的 LSTM 模型的输出。
在我们刚刚定义的sample()函数中,我们没有调整 logits,这意味着默认温度为 1。较低的温度(低于 1;例如,0.8)会导致更少的变体,使模型更加确定性和保守,更倾向于更可能的选择。相反,较高的温度(高于 1;例如,1.5)使得在文本生成中选择不可能的单词的可能性更大,导致更加多样化和有创造性的输出。然而,这也可能使文本变得不那么连贯或相关,因为模型可能会选择不太可能的单词。
Top-K 采样是另一种影响输出的方法。这种方法涉及从模型预测的前 K 个最可能选项中选择下一个单词。概率分布被截断,只包括前 K 个单词。当 K 值较小时,例如 5,模型的选项仅限于几个高度可能的单词,导致输出更加可预测和连贯,但可能不那么多样化和有趣。在我们之前定义的sample()函数中,我们没有应用 top-K 采样,因此 K 的值实际上是词汇表的大小(在我们的例子中为 12,778)。
接下来,我们介绍一个新的函数generate(),用于文本生成。这个函数与sample()函数类似,但包括两个额外的参数:temperature和top_k,允许对生成文本的创造性和随机性有更多的控制。generate()函数的定义如下所示。
列表 8.6 使用温度和 top-K 采样生成文本
def generate(model, prompt , top_k=None,
length=200, temperature=1):
model.eval()
text = prompt.lower().split(' ')
hc = model.init_hidden(1)
length = length - len(text)
for i in range(0, length):
if len(text)<= seq_len:
x = torch.tensor([[word_to_int[w] for w in text]])
else:
x = torch.tensor([[word_to_int[w] for w in text[-seq_len:]]])
inputs = x.to(device)
output, hc = model(inputs, hc)
logits = output[0][-1]
logits = logits/temperature ①
p = nn.functional.softmax(logits, dim=0).detach().cpu()
if top_k is None:
idx = np.random.choice(len(logits), p=p.numpy())
else:
ps, tops = p.topk(top_k) ②
ps=ps/ps.sum()
idx = np.random.choice(tops, p=ps.numpy()) ③
text.append(int_to_word[idx])
text=" ".join(text)
for m in ",.:;?!$()/_&%*@'`":
text=text.replace(f" {m}", f"{m} ")
text=text.replace('" ', '"')
text=text.replace("' ", "'")
text=text.replace('" ', '"')
text=text.replace("' ", "'")
return text
① 使用温度缩放 logits
② 仅保留 K 个最可能的候选词
③ 从前 K 个候选词中选择下一个标记
与sample()函数相比,新的函数generate()有两个额外的可选参数:top_k和temperature。默认情况下,top_k设置为None,而temperature设置为 1。因此,如果你在调用generate()函数时没有指定这两个参数,输出将与从sample()函数获得的输出相同。
让我们通过关注单个标记的创建来展示生成文本的变化。为此,我们将使用“我不打算去看”作为提示(注意撇号前的空格,就像我们在本章中之前所做的那样)。我们调用generate()函数 10 次,将其长度参数设置为比提示长度多一个。这种方法确保函数只向提示添加一个额外的标记:
prompt="I ' m not going to see"
torch.manual_seed(42)
np.random.seed(42)
for _ in range(10):
print(generate(model, prompt, top_k=None,
length=len(prompt.split(" "))+1, temperature=1))
输出结果为
i'm not going to see you
i'm not going to see those
i'm not going to see me
i'm not going to see you
i'm not going to see her
i'm not going to see her
i'm not going to see the
i'm not going to see my
i'm not going to see you
i'm not going to see me
在默认的top_k = None和temperature = 1设置下,输出中存在一定程度的重复。例如,“you”这个词重复了三次。总共有六个独特的标记。
然而,当你调整这两个参数时,generate()函数的功能会扩展。例如,设置一个低的温度,如 0.5,以及一个小的top_k值,例如 3,会导致生成的文本更可预测且更缺乏创意。
让我们重复单个标记的示例。这次,我们将温度设置为 0.5,top_k值设置为 3:
prompt="I ' m not going to see"
torch.manual_seed(42)
np.random.seed(42)
for _ in range(10):
print(generate(model, prompt, top_k=3,
length=len(prompt.split(" "))+1, temperature=0.5))
输出结果为
i'm not going to see you
i'm not going to see the
i'm not going to see her
i'm not going to see you
i'm not going to see you
i'm not going to see you
i'm not going to see you
i'm not going to see her
i'm not going to see you
i'm not going to see her
输出的变化较少:在 10 次尝试中,只有 3 个独特的标记,“you”、“the”和“her”。
让我们通过将“安娜和王子”作为起始提示,在设置温度为 0.5 和top_k值为 3 时来观察这一效果:
torch.manual_seed(42)
np.random.seed(42)
print(generate(model, prompt='Anna and the prince',
top_k=3,
temperature=0.5))
输出结果为
anna and the prince had no milk. but, "answered levin, and he stopped. "i've been skating to look at you all the harrows, and i'm glad. . . ""no, i'm going to the country. ""no, it's not a nice fellow. ""yes, sir. ""well, what do you think about it? ""why, what's the matter? ""yes, yes, "answered levin, smiling, and he went into the hall. "yes, i'll come for him and go away, "he said, looking at the crumpled front of his shirt. "i have not come to see him, "she said, and she went out. "i'm very glad, "she said, with a slight bow to the ambassador's hand. "i'll go to the door. "she looked at her watch, and she did not know what to say
练习 8.4
通过设置温度为 0.6 和top_k为 10,并使用“安娜和护士”作为起始提示来生成文本。在 PyTorch 和 NumPy 中都设置随机种子数为 0。
相反,选择一个更高的温度值,例如 1.5,以及一个更高的top_k值,例如None(允许从 12,778 个标记的全集中进行选择),会导致更富有创造性和更不可预测的输出。这将在下面的单个标记示例中演示。这次,我们将温度设置为 2,top_k值设置为None:
prompt="I ' m not going to see"
torch.manual_seed(42)
np.random.seed(42)
for _ in range(10):
print(generate(model, prompt, top_k=None,
length=len(prompt.split(" "))+1, temperature=2))
输出结果为
i'm not going to see them
i'm not going to see scarlatina
i'm not going to see behind
i'm not going to see us
i'm not going to see it
i'm not going to see it
i'm not going to see a
i'm not going to see misery
i'm not going to see another
i'm not going to see seryozha
输出几乎没有重复:在 10 次尝试中有 9 个独特的标记;只有“it”这个词重复了。
让我们再次使用“安娜和王子”作为初始提示,但将温度设置为 2,top_k值设置为None,看看会发生什么:
torch.manual_seed(42)
np.random.seed(42)
print(generate(model, prompt='Anna and the prince',
top_k=None,
temperature=2))
生成的文本是
anna and the prince took sheaves covered suddenly people. "pyotr marya borissovna, propped mihail though her son will seen how much evening her husband; if tomorrow she liked great time too. "adopted heavens details for it women from this terrible, admitting this touching all everything ill with flirtation shame consolation altogether: ivan only all the circle with her honorable carriage in its house dress, beethoven ashamed had the conversations raised mihailov stay of close i taste work? "on new farming show ivan nothing. hat yesterday if interested understand every hundred of two with six thousand roubles according to women living over a thousand: snetkov possibly try disagreeable schools with stake old glory mysterious one have people some moral conclusion, got down and then their wreath. darya alexandrovna thought inwardly peaceful with varenka out of the listen from and understand presented she was impossible anguish. simply satisfied with staying after presence came where he pushed up his hand as marya her pretty hands into their quarters. waltz was about the rider gathered; sviazhsky further alone have an hand paused riding towards an exquisite
生成的文本没有重复,尽管在许多地方缺乏连贯性。
练习 8.5
通过设置温度为 2 和top_k为 10000,并使用“安娜和护士”作为起始提示来生成文本。在 PyTorch 和 NumPy 中都设置随机种子数为 0。
在本章中,你已经掌握了 NLP 的基础技能,包括词级分词、词嵌入和序列预测。通过这些练习,你学会了基于词级分词构建语言模型,并使用 LSTM 进行文本生成训练。接下来,接下来的几章将向你介绍训练 Transformers,这是 ChatGPT 等系统中使用的模型类型。这将为你提供更深入的高级文本生成技术理解。
摘要
-
RNNs(循环神经网络)是一种专门的人工神经网络形式,旨在识别数据序列中的模式,如文本、音乐或股价。与传统神经网络不同,后者独立处理输入,RNNs 在其内部有循环,允许信息持续存在。LSTM 网络是 RNNs 的改进版本。
-
有三种分词方法。第一种是字符分词,将文本分割成其构成字符。第二种方法是词分词,将文本分割成单个单词。第三种方法是子词分词,它将单词分解成更小的、有意义的组件,称为子词。
-
词嵌入是一种将单词转换成紧凑向量表示的方法,捕捉其语义信息和相互关系。这项技术在 NLP 中至关重要,尤其是在深度神经网络,包括 LSTM 和 Transformers 等模型,需要数值输入的情况下。
-
温度是影响文本生成模型行为的一个参数。它通过在应用 softmax 之前缩放 logits(概率计算的 softmax 函数的输入)来控制预测的随机性。低温使模型在预测上更加保守但也更加重复。在较高温度下,模型变得不那么重复,更具创新性,增加了生成文本的多样性。
-
Top-K 采样是另一种影响文本生成模型行为的方法。它涉及从模型确定的 K 个最可能的候选词中选择下一个词。概率分布被截断,只保留前 K 个词。K 的值较小会使输出更加可预测和连贯,但可能不那么多样化和有趣。
^(1) Sepp Hochreiter 和 Jurgen Schmidhuber,1997 年,“长短期记忆”,神经计算 9(8):1735-1780。
1669



被折叠的 条评论
为什么被折叠?



