PyTorch 现代计算机视觉第二版(六)
原文:
zh.annas-archive.org/md5/355d709877e6e04dc1540c8ccd0b447d译者:飞龙
第三部分:图像处理
在本节中,我们将探讨各种图像处理技术,包括自编码器和各种类型的生成对抗网络(GANs)。我们将利用这些技术来提高图像质量,调整风格,并从现有图像中生成新图像。
本节包括以下章节:
-
第十一章, 自编码器和图像处理
-
第十二章, 使用 GAN 生成图像
-
第十三章, 高级 GAN 用于图像处理
第十一章:自编码器与图像操作
在之前的章节中,我们学习了如何对图像进行分类、检测图像中的对象以及分割与图像中的对象对应的像素。在本章中,我们将学习如何使用自编码器将图像表示为较低维度,然后利用图像的较低维度表示程度较高地操作(修改)图像。我们还将学习如何生成基于两幅不同图像的内容和风格生成新图像。然后,我们将探讨如何以不改变图像外观的方式修改图像,但在将图像通过图像分类模型时,将图像对应的类从一个类更改为另一个类。最后,我们将学习如何生成深度伪造:给定人物 A 的源图像,我们生成与人物 A 具有相似面部表情的人物 B 的目标图像。
总体而言,在本章中我们将学习以下主题:
-
理解和实现自编码器
-
理解卷积自编码器
-
理解变分自编码器
-
对图像执行对抗攻击
-
执行神经风格迁移
-
生成深度伪造
本章中所有代码片段均可在 Github 存储库的Chapter11文件夹中找到,链接为bit.ly/mcvp-2e。
理解自编码器
到目前为止,在之前的章节中,我们已经学习了通过训练基于输入图像及其对应标签的模型来对图像进行分类。现在让我们想象一种情景,即我们需要根据它们的相似性对图像进行聚类,并且不具有它们对应的标签。自编码器在识别和分组相似图像方面非常有用。
自编码器的工作原理
自编码器将图像作为输入,将其存储在较低维度中,并尝试生成相同的图像作为输出,因此术语自(简而言之,意味着能够重现输入)。然而,如果我们只是在输出中复制输入,那么我们不需要一个网络,只需简单地将输入乘以 1 即可。自编码器与我们迄今为止所学的典型神经网络架构的区别在于,它将图像中的信息编码到较低维度,然后再生成图像,因此称为编码器。这样,相似的图像将具有类似的编码。此外,解码器致力于从编码向量中重建原始图像。
为了进一步理解自编码器,让我们看一下以下图表:
图 11.1:典型自编码器架构
假设输入图像是 MNIST 手写数字的扁平化版本,输出图像与输入相同。中间层是称为 瓶颈 层的编码层。在输入和瓶颈层之间发生的操作表示 编码器,在瓶颈层和输出之间的操作表示 解码器。
通过瓶颈层,我们可以将图像表示为更低维度。此外,借助瓶颈层,我们可以重构原始图像。我们利用瓶颈层来解决识别相似图像和生成新图像的问题,在接下来的部分中我们将学习如何做到这一点。瓶颈层在以下方面帮助:
-
具有相似瓶颈层数值(编码表示)的图像可能彼此相似。
-
通过更改瓶颈层的节点值,我们可以改变输出图像。
在前面的理解基础上,让我们在接下来的部分执行以下操作:
-
从头开始实现自动编码器
-
根据瓶颈层数值可视化图像的相似性
在下一节中,我们将了解如何构建自动编码器及瓶颈层中不同单元对解码器输出的影响。
实现基础自动编码器
要了解如何构建自动编码器,让我们在 MNIST 数据集上实现一个:
您可以在本书的 GitHub 仓库的 Chapter11 文件夹中的 simple_auto_encoder_with_different_latent_size.ipynb 文件中找到代码,网址为 bit.ly/mcvp-2e。
您可以按照以下步骤操作:
-
导入相关包并定义设备:
!pip install -q torch_snippets from torch_snippets import * from torchvision.datasets import MNIST from torchvision import transforms device = 'cuda' if torch.cuda.is_available() else 'cpu' -
指定我们希望图像通过的转换:
img_transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize([0.5], [0.5]), transforms.Lambda(lambda x: x.to(device)) ])
在前面的代码中,我们看到我们正在将图像转换为张量,对其进行归一化,然后将其传递到设备上。
-
创建训练和验证数据集:
trn_ds = MNIST('/content/', transform=img_transform, \ train=True, download=True) val_ds = MNIST('/content/', transform=img_transform, \ train=False, download=True) -
定义数据加载器:
batch_size = 256 trn_dl = DataLoader(trn_ds, batch_size=batch_size, shuffle=True) val_dl = DataLoader(val_ds, batch_size=batch_size, shuffle=False) -
定义网络架构。我们在
__init__方法中定义AutoEncoder类,包括编码器和解码器以及瓶颈层latent_dim的维度:class AutoEncoder(nn.Module): def __init__(self, latent_dim): super().__init__() self.latend_dim = latent_dim self.encoder = nn.Sequential( nn.Linear(28 * 28, 128), nn.ReLU(True), nn.Linear(128, 64), nn.ReLU(True), nn.Linear(64, latent_dim)) self.decoder = nn.Sequential( nn.Linear(latent_dim, 64), nn.ReLU(True), nn.Linear(64, 128), nn.ReLU(True), nn.Linear(128, 28 * 28), nn.Tanh()) -
定义
forward方法:def forward(self, x): x = x.view(len(x), -1) x = self.encoder(x) x = self.decoder(x) x = x.view(len(x), 1, 28, 28) return x -
可视化前述模型:
!pip install torch_summary from torchsummary import summary model = AutoEncoder(3).to(device) summary(model, torch.zeros(2,1,28,28))
结果如下:
图 11.2:UNet 架构
从前述输出中,我们可以看到 Linear: 2-5 layer 是瓶颈层,每个图像表示为三维向量。此外,解码器层使用瓶颈层中的三个值 (Linear: 2-5 layer) 重建原始图像。
-
定义名为
train_batch的函数,以便在批处理数据上训练模型,就像我们在前几章中所做的那样:def train_batch(input, model, criterion, optimizer): model.train() optimizer.zero_grad() output = model(input) loss = criterion(output, input) loss.backward() optimizer.step() return loss -
定义
validate_batch函数以验证批处理数据上的模型:@torch.no_grad() def validate_batch(input, model, criterion): model.eval() output = model(input) loss = criterion(output, input) return loss -
定义模型、损失标准和优化器。确保我们使用
MSELoss因为我们正在重建像素值。model = AutoEncoder(3).to(device) criterion = nn.MSELoss() optimizer = torch.optim.AdamW(model.parameters(), \ lr=0.001, weight_decay=1e-5) -
随着 epoch 的增加训练模型:
num_epochs = 5 log = Report(num_epochs) for epoch in range(num_epochs): N = len(trn_dl) for ix, (data, _) in enumerate(trn_dl): loss = train_batch(data, model, criterion, optimizer) log.record(pos=(epoch + (ix+1)/N), trn_loss=loss, end='\r') N = len(val_dl) for ix, (data, _) in enumerate(val_dl): loss = validate_batch(data, model, criterion) log.record(pos=(epoch + (ix+1)/N), val_loss=loss, end='\r') log.report_avgs(epoch+1) -
可视化随着 epoch 增加的训练和验证损失:
log.plot_epochs(log=True)
output:
图 11.3:随着 epoch 增加的训练和验证损失
-
通过查看
val_ds数据集上未在训练期间提供的几个预测/输出来验证模型:for _ in range(3): ix = np.random.randint(len(val_ds)) im, _ = val_ds[ix] _im = model(im[None])[0] fig, ax = plt.subplots(1, 2, figsize=(3,3)) show(im[0], ax=ax[0], title='input') show(_im[0], ax=ax[1], title='prediction') plt.tight_layout() plt.show()
前述代码的输出如下:
图 11.4:自编码器生成的预测/输出
我们可以看到,尽管瓶颈层仅有三个维度,网络仍能以非常高的准确度重现输入。然而,图像的清晰度并不如预期。这主要是由于瓶颈层中节点数量较少造成的。在接下来的图像中,我们将展示在不同瓶颈层大小(2、3、5、10 和 50)训练网络后的重建图像:
图 11.5:自编码器生成的预测/输出
随着瓶颈层中向量的数量增加,重建图像的清晰度得到了提高。
在接下来的章节中,我们将学习如何使用卷积神经网络(CNN)生成更清晰的图像,并了解如何将相似图像分组。
实现卷积自编码器
在前一节中,我们学习了自编码器并在 PyTorch 中实现了它们。尽管我们已经实现了它们,但我们通过数据集的便利之处在于每个图像只有一个通道(每个图像表示为黑白图像),而且图像相对较小(28 x 28 像素)。因此,网络将输入展平,并能够在 784(28*28)个输入值上进行训练,以预测 784 个输出值。然而,在现实中,我们将遇到具有三个通道且远大于 28 x 28 像素的图像。
在本节中,我们将学习如何实现一个能够处理多维输入图像的卷积自编码器。然而,为了与普通自编码器进行比较,我们将继续使用在前一节中使用过的 MNIST 数据集,但是修改网络结构,使其成为一个卷积自编码器而非普通自编码器。
卷积自编码器的表示如下:
图 11.6:卷积自编码器
从上述图像中,我们可以看到输入图像在用于重建图像的瓶颈层中表示为一个块。图像通过多次卷积来获取瓶颈表示(通过编码器获得的瓶颈层),并且通过上采样瓶颈表示来获取原始图像(原始图像通过解码器重建)。请注意,在卷积自编码器中,与输入层相比,瓶颈层中的通道数量可以非常高。
现在我们知道了卷积自编码器的表示方式,让我们来实现它:
下面的代码可以在本书的 GitHub 代码库Chapter11文件夹中的conv_auto_encoder.ipynb中找到,网址为https://bit.ly/mcvp-2e。
-
步骤 1 到 4,与实现普通自编码器部分完全相同,具体如下:
!pip install -q torch_snippets from torch_snippets import * from torchvision.datasets import MNIST from torchvision import transforms device = 'cuda' if torch.cuda.is_available() else 'cpu' img_transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize([0.5], [0.5]), transforms.Lambda(lambda x: x.to(device)) ]) trn_ds = MNIST('/content/', transform=img_transform, \ train=True, download=True) val_ds = MNIST('/content/', transform=img_transform, \ train=False, download=True) batch_size = 128 trn_dl = DataLoader(trn_ds, batch_size=batch_size, shuffle=True) val_dl = DataLoader(val_ds, batch_size=batch_size, shuffle=False) -
定义神经网络类
ConvAutoEncoder如下:- 定义类和
__init__方法:
class ConvAutoEncoder(nn.Module): def __init__(self): super().__init__()- 定义
encoder架构:
self.encoder = nn.Sequential( nn.Conv2d(1, 32, 3, stride=3, padding=1), nn.ReLU(True), nn.MaxPool2d(2, stride=2), nn.Conv2d(32, 64, 3, stride=2, padding=1), nn.ReLU(True), nn.MaxPool2d(2, stride=1) )注意,在前面的代码中,我们从初始通道数
1开始,增加到32,然后进一步增加到64,同时通过执行nn.MaxPool2d和nn.Conv2d操作减少输出值的大小。- 定义
decoder架构:
self.decoder = nn.Sequential( nn.ConvTranspose2d(64, 32, 3, stride=2), nn.ReLU(True), nn.ConvTranspose2d(32, 16, 5, stride=3,padding=1), nn.ReLU(True), nn.ConvTranspose2d(16, 1, 2, stride=2,padding=1), nn.Tanh() )- 定义
forward方法:
def forward(self, x): x = self.encoder(x) x = self.decoder(x) return x - 定义类和
-
使用
summary方法获取模型的摘要:model = ConvAutoEncoder().to(device) !pip install torch_summary from torchsummary import summary summary(model, torch.zeros(2,1,28,28));
前面的代码结果如下:
图 11.7:模型架构摘要
从前面的总结可以看出,形状为批大小 x 64 x 2 x 2 的MaxPool2d-6层充当了瓶颈层。
一旦我们训练模型,就像我们在前一节中做的那样(在步骤 6、7、8 和 9中),随着增加的时期训练和验证损失的变化以及输入图像的预测如下:
图 11.8:随着时期的变化和样本预测的损失变化
从前面的图像可以看出,卷积自编码器能够比普通自编码器更清晰地预测图像。作为练习,我们建议您在编码器和解码器中改变通道数量,然后分析结果的变化。
在接下来的部分中,当图像的标签不存在时,我们将讨论基于瓶颈层值分组相似图像的问题。
使用 t-SNE 对相似图像进行分组
在前面的部分中,我们假设每个图像在更低的维度中表示,假设相似的图像将具有相似的嵌入,而不相似的图像将具有不相似的嵌入。然而,我们还没有详细查看图像相似性度量或嵌入表示。
在本节中,我们将在二维空间中绘制嵌入(瓶颈)向量。我们可以通过一种称为t-SNE的技术将卷积自编码器的 64 维向量压缩到二维空间中,该技术有助于以一种使得相似数据点聚集在一起而不相似数据点远离彼此的方式压缩信息。 (关于 t-SNE 的更多信息请参见:www.jmlr.org/papers/v9/vandermaaten08a.html。)
通过这种方式,我们可以验证类似图像将具有相似的嵌入,因为相似的图像应该在二维平面上聚集在一起。我们将在二维平面上表示所有测试图像的嵌入:
以下代码在本书的 GitHub 存储库的Chapter11文件夹中的conv_auto_encoder.ipynb中可用,网址为bit.ly/mcvp-2e。
-
初始化列表以存储潜在向量(
latent_vectors)和图像的对应classes(请注意,我们仅存储每个图像的类别,以验证同一类别的图像是否确实在表示上彼此接近):latent_vectors = [] classes = [] -
循环遍历验证数据加载器中的图像(
val_dl)并存储编码器层输出的结果(model.encoder(im).view(len(im),-1))和每个图像(im)对应的类别(clss):for im,clss in val_dl: latent_vectors.append(model.encoder(im).view(len(im),-1)) classes.extend(clss) -
连接 NumPy 数组
latent_vectors:latent_vectors = torch.cat(latent_vectors).cpu().detach().numpy() -
导入 t-SNE(
TSNE)并指定将每个向量转换为二维向量(TSNE(2)),以便我们可以绘制它:from sklearn.manifold import TSNE tsne = TSNE(2) -
运行
fit_transform方法来拟合 t-SNE 到图像嵌入(latent_vectors):clustered = tsne.fit_transform(latent_vectors) -
绘制 t-SNE 拟合后的数据点:
fig = plt.figure(figsize=(12,10)) cmap = plt.get_cmap('Spectral', 10) plt.scatter(*zip(*clustered), c=classes, cmap=cmap) plt.colorbar(drawedges=True)
前面的代码生成了以下输出(你可以参考书的数字版以查看彩色图片):
图 11.9:使用 t-SNE 分组的数据点(图像)
我们可以看到同一类别的图像被聚集在一起,这进一步证实了瓶颈层的值以这样的方式排列,即看起来相似的图像将具有相似的值。
到目前为止,我们已经了解了如何使用自编码器将相似图像分组在一起。在下一节中,我们将学习如何使用自编码器生成新图像。
理解变分自编码器
到目前为止,我们已经看到了一种情况,可以将相似的图像分组到集群中。此外,我们学习到当我们获取落在给定集群中的图像的嵌入时,我们可以重新构建(解码)它们。但是,如果一个嵌入(潜在向量)位于两个集群之间,我们不能保证生成逼真的图像。变分自编码器(VAEs)在这种情况下非常有用。
VAE 的必要性
在深入理解和构建 VAE 之前,让我们探讨从嵌入中生成图像的局限性,这些图像不属于任何一个集群(或位于不同集群中心)。首先,我们通过以下步骤从采样向量生成图像(可在conv_auto_encoder.ipynb文件中找到):
-
计算在前一节中使用的验证图像的潜在向量(嵌入):
latent_vectors = [] classes = [] for im,clss in val_dl: latent_vectors.append(model.encoder(im)) classes.extend(clss) latent_vectors = torch.cat(latent_vectors).cpu()\ .detach().numpy().reshape(10000, -1) -
使用列级均值(
mu)和标准差(sigma)生成随机向量,并在创建来自均值和标准差的向量之前,给标准差添加轻微噪声(torch.randn(1,100))。最后,将它们保存在列表中(rand_vectors):rand_vectors = [] for col in latent_vectors.transpose(1,0): mu, sigma = col.mean(), col.std() rand_vectors.append(sigma*torch.randn(1,100) + mu) -
绘制从步骤 2中获取的向量重建的图像和在前一节中训练的卷积自编码器模型:
rand_vectors=torch.cat(rand_vectors).transpose(1,0).to(device) fig,ax = plt.subplots(10,10,figsize=(7,7)); ax = iter(ax.flat) for p in rand_vectors: img = model.decoder(p.reshape(1,64,2,2)).view(28,28) show(img, ax=next(ax))
上述代码的输出如下:
图 11.10:从潜在向量的均值和标准差生成的图像
从上述输出中我们可以看到,当我们绘制从已知向量的列的均值和加噪标准差生成的图像时,得到的图像不如之前清晰。这是一个现实的情况,因为我们事先不知道会生成逼真图片的嵌入向量的范围。
VAEs通过生成具有均值为 0 和标准差为 1 的向量来帮助我们解决这个问题,从而确保我们生成的图像具有均值为 0 和标准差为 1。
实质上,在 VAE 中,我们指定瓶颈层应遵循某种分布。在接下来的章节中,我们将学习我们在 VAE 中采用的策略,还将学习有助于获取遵循特定分布的瓶颈特征的Kullback-Leibler(KL)散度损失。
VAE 的工作原理
在 VAE 中,我们通过以下方式构建网络,使得从预定义分布生成的随机向量能够生成逼真的图像。简单的自编码器无法做到这一点,因为我们没有在网络中指定生成图像的数据分布。我们通过 VAE 采用以下策略来实现这一点:
-
编码器的输出是每个图像的两个向量:
-
一个向量代表均值。
-
另一个表示标准差。
-
-
从这两个向量中,我们获取一个修改后的向量,该向量是均值和标准差之和(乘以一个随机小数)。修改后的向量将与每个向量具有相同数量的维度。
-
将上一步得到的修改后的向量作为输入传递给解码器以获取图像。
-
我们优化的损失值是以下几种组合:
-
KL 散度损失:衡量均值向量和标准差向量的分布与 0 和 1 的偏差,分别为
-
均方损失:是我们用来重构(解码)图像的优化方法
-
通过指定均值向量应该具有围绕 0 中心的分布,标准差向量应该围绕 1 中心的分布,我们训练网络的方式是,当我们生成均值为 0、标准差为 1 的随机噪声时,解码器能够生成逼真的图像。
此外,请注意,如果我们仅最小化 KL 散度,编码器将预测均值向量为 0,并且每个输入的标准差为 1。因此,同时最小化 KL 散度损失和均方损失是重要的。
在下一节中,让我们学习一下 KL 散度,以便我们可以将其纳入模型损失值的计算中。
KL 散度
KL 散度有助于解释数据两个分布之间的差异。在我们的具体案例中,我们希望我们的瓶颈特征值遵循均值为 0、标准差为 1 的正态分布。
因此,我们使用 KL 散度损失来理解我们的瓶颈特征值与期望的值分布有多不同,期望的分布具有均值为 0 和标准差为 1。
让我们通过详细说明如何计算 KL 散度损失来看看 KL 散度损失如何帮助:
在前述方程中,σ和μ分别表示每个输入图像的均值和标准差值。
让我们讨论前述方程:
- 确保均值向量分布在 0 附近:
最小化均方误差()在前述方程中确保
尽可能接近 0。
- 确保标准差向量分布在 1 附近:
方程的其余项(除了)确保σ(标准差向量)分布在 1 附近。
当均值(µ)为 0 且标准差为 1 时,前述损失函数被最小化。此外,通过指定我们正在考虑标准差的对数,我们确保σ值不能为负。
现在我们理解了构建 VAE 的高级策略以及要最小化的损失函数,以获得编码器输出的预定义分布后,让我们在下一节中实现 VAE。
构建 VAE
在本节中,我们将编写一个 VAE 来生成手写数字的新图像。
以下代码在本书 GitHub 库的Chapter11文件夹中的VAE.ipynb中可用:bit.ly/mcvp-2e。
由于我们具有相同的数据,实现基本自动编码器部分中的所有步骤仍然相同,除了步骤 5 和 6,在其中我们分别定义网络架构和训练模型。相反,我们在以下代码中以不同方式定义它们(在VAE.ipynb文件中可用):
-
步骤 1 到 步骤 4,与实现基本自动编码器部分完全相同,具体如下:
!pip install -q torch_snippets from torch_snippets import * import torch import torch.nn as nn import torch.nn.functional as F import torch.optim as optim from torchvision import datasets, transforms from torchvision.utils import make_grid device = 'cuda' if torch.cuda.is_available() else 'cpu' train_dataset = datasets.MNIST(root='MNIST/', train=True, \ transform=transforms.ToTensor(), \ download=True) test_dataset = datasets.MNIST(root='MNIST/', train=False, \ transform=transforms.ToTensor(), \ download=True) train_loader = torch.utils.data.DataLoader(dataset = train_dataset, \ batch_size=64, shuffle=True) test_loader = torch.utils.data.DataLoader(dataset= test_dataset, \ batch_size=64, shuffle=False) -
定义神经网络类
VAE如下:- 在
__init__方法中定义将在其他方法中使用的层:
class VAE(nn.Module): def __init__(self, x_dim, h_dim1, h_dim2, z_dim): super(VAE, self).__init__() self.d1 = nn.Linear(x_dim, h_dim1) self.d2 = nn.Linear(h_dim1, h_dim2) self.d31 = nn.Linear(h_dim2, z_dim) self.d32 = nn.Linear(h_dim2, z_dim) self.d4 = nn.Linear(z_dim, h_dim2) self.d5 = nn.Linear(h_dim2, h_dim1) self.d6 = nn.Linear(h_dim1, x_dim)-
注意
d1和d2层对应编码器部分,而d5和d6对应解码器部分。d31和d32层分别对应均值向量和标准差向量。然而,为了方便起见,我们假设将使用d32层作为对数方差向量的表示。 -
定义编码器方法:
def encoder(self, x): h = F.relu(self.d1(x)) h = F.relu(self.d2(h)) return self.d31(h), self.d32(h)-
注意编码器返回两个向量:一个用于均值
(self.d31(h)),另一个用于对数方差值(self.d32(h))。 -
定义从编码器输出中进行采样的方法 (
sampling):
def sampling(self, mean, log_var): std = torch.exp(0.5*log_var) eps = torch.randn_like(std) return eps.mul(std).add_(mean)- 注意 0.5log_var* (
torch.exp(0.5*log_var)) 的指数代表标准差 (std)。此外,我们通过随机正态分布生成的噪声乘以均值和标准差的加法来返回值。通过乘以eps,我们确保即使在编码器向量轻微变化时,也能生成图像。
- 在
-
定义解码器方法:
def decoder(self, z): h = F.relu(self.d4(z)) h = F.relu(self.d5(h)) return F.sigmoid(self.d6(h)) -
定义前向方法:
def forward(self, x): mean, log_var = self.encoder(x.view(-1, 784)) z = self.sampling(mean, log_var) return self.decoder(z), mean, log_var
在上述方法中,我们确保编码器返回均值和对数方差值。接下来,我们通过均值加上乘以对数方差的 epsilon 进行采样,并通过解码器传递后返回值。
-
定义用于对一个批次模型进行训练和另一个批次进行验证的函数:
def train_batch(data, model, optimizer, loss_function): model.train() data = data.to(device) optimizer.zero_grad() recon_batch, mean, log_var = model(data) loss, mse, kld = loss_function(recon_batch, data, mean, log_var) loss.backward() optimizer.step() return loss, mse, kld, log_var.mean(), mean.mean() @torch.no_grad() def validate_batch(data, model, loss_function): model.eval() data = data.to(device) recon, mean, log_var = model(data) loss, mse, kld = loss_function(recon, data, mean, log_var) return loss, mse, kld, log_var.mean(), mean.mean() -
定义损失函数:
def loss_function(recon_x, x, mean, log_var): RECON = F.mse_loss(recon_x, x.view(-1, 784), reduction='sum') KLD = -0.5 * torch.sum(1 + log_var - mean.pow(2) - log_var.exp()) return RECON + KLD, RECON, KLD
在上述代码中,我们获取原始图像 (x) 和重构图像 (recon_x) 之间的 MSE 损失 (RECON)。接下来,根据我们在前一节定义的公式计算 KL 散度损失 (KLD)。注意对数方差的指数是方差值。
-
定义模型对象 (
vae) 和优化器函数 (optimizer):vae = VAE(x_dim=784, h_dim1=512, h_dim2=256, z_dim=50).to(device) optimizer = optim.AdamW(vae.parameters(), lr=1e-3) -
在增加 epoch 的过程中训练模型:
n_epochs = 10 log = Report(n_epochs) for epoch in range(n_epochs): N = len(train_loader) for batch_idx, (data, _) in enumerate(train_loader): loss, recon, kld, log_var, mean = train_batch(data, \ vae, optimizer, \ loss_function) pos = epoch + (1+batch_idx)/N log.record(pos, train_loss=loss, train_kld=kld, \ train_recon=recon,train_log_var=log_var, \ train_mean=mean, end='\r') N = len(test_loader) for batch_idx, (data, _) in enumerate(test_loader): loss, recon, kld,log_var,mean = validate_batch(data, \ vae, loss_function) pos = epoch + (1+batch_idx)/N log.record(pos, val_loss=loss, val_kld=kld, \ val_recon=recon, val_log_var=log_var, \ val_mean=mean, end='\r') log.report_avgs(epoch+1) with torch.no_grad(): z = torch.randn(64, 50).to(device) sample = vae.decoder(z).to(device) images = make_grid(sample.view(64, 1, 28, 28)).permute(1,2,0) show(images) log.plot_epochs(['train_loss','val_loss'])
尽管大多数前述代码是熟悉的,让我们看一下网格图像生成过程。我们首先生成一个随机向量 (z),然后通过解码器 (vae.decoder) 获取图像样本。make_grid 函数会绘制图像(如果需要,会自动进行反归一化)。
损失值变化的输出和生成图像的样本如下:
图 11.11:(左)随 epoch 增加损失的变化。(右)使用 VAE 生成的图像
我们可以看到我们能够生成原始图像中不存在的逼真新图像。
到目前为止,我们已经学习了使用 VAE 生成新图像的方法。但是,如果我们希望修改图像以使模型无法识别正确的类别,我们将在下一节中学习用于实现此目的的技术。
对图像执行对抗攻击:
在前一节中,我们学习了使用 VAE 从随机噪声生成图像。但这是一个无监督的练习。如果我们想以如此微小的方式修改图像,以至于对于人类来说与原始图像几乎无法区分,但神经网络模型仍然会将其识别为属于不同类别的对象,那么对图像进行的对抗攻击就非常有用。
对抗攻击是指我们对输入图像值(像素)进行的更改,以便达到特定目标。这在使我们的模型更加健壮以免受轻微修改影响方面特别有帮助。在本节中,我们将学习如何轻微修改图像,使预训练模型将其预测为不同类别(由用户指定),而不是原始类别。我们将采用的策略如下:
-
提供一张大象的图像。
-
指定与图像相对应的目标类别。
-
导入预训练模型,其中模型参数设置为不更新(
gradients = False)。 -
指定我们在输入图像像素值上计算梯度,而不是网络权重值。这是因为在训练中欺骗网络时,我们无法控制模型,只能控制发送到模型的图像。
-
计算与模型预测和目标类别对应的损失。
-
对模型进行反向传播。这一步骤帮助我们理解每个输入像素值相关的梯度。
-
根据每个输入像素值对应的梯度更新输入图像像素值。
-
在多个 epochs 中重复步骤 5、6 和 7。
让我们用代码来做这件事:
本书的 GitHub 存储库中的Chapter11文件夹中提供了adversarial_attack.ipynb代码文件:bit.ly/mcvp-2e。该代码包含用于下载数据的 URL。我们强烈建议您在 GitHub 上执行该笔记本,以重现结果并理解执行步骤以及文本中各种代码组件的解释。
-
导入相关包、我们用于此用例的图像以及预训练的 ResNet50 模型。同时,指定我们要冻结参数,因为我们不会更新模型(但会更新图像以使其欺骗模型):
!pip install torch_snippets from torch_snippets import inspect, show, np, torch, nn from torchvision.models import resnet50 model = resnet50(pretrained=True) for param in model.parameters(): param.requires_grad = False model = model.eval() import requests from PIL import Image url = 'https://lionsvalley.co.za/wp-content/uploads/2015/11/african-elephant-square.jpg' original_image = Image.open(requests.get(url, stream=True)\ .raw).convert('RGB') original_image = np.array(original_image) original_image = torch.Tensor(original_image) -
导入
image_net_classes并为每个类分配 ID:image_net_classes = 'https://gist.githubusercontent.com/yrevar/942d3a0ac09ec9e5eb3a/raw/238f720ff059c1f82f368259d1ca4ffa5dd8f9f5/imagenet1000_clsidx_to_labels.txt' image_net_classes = requests.get(image_net_classes).text image_net_ids = eval(image_net_classes) image_net_classes = {i:j for j,i in image_net_ids.items()} -
指定一个函数来对图像进行归一化(
image2tensor)和反归一化(tensor2image):from torchvision import transforms as T from torch.nn import functional as F normalize = T.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) denormalize=T.Normalize( \ [-0.485/0.229,-0.456/0.224,-0.406/0.225], [1/0.229, 1/0.224, 1/0.225]) def image2tensor(input): x = normalize(input.clone().permute(2,0,1)/255.)[None] return x def tensor2image(input): x = (denormalize(input[0].clone()).permute(1,2,0)*255.)\ .type(torch.uint8) return x -
定义一个函数来预测给定图像的类别(
predict_on_image):def predict_on_image(input): model.eval() show(input) input = image2tensor(input) pred = model(input) pred = F.softmax(pred, dim=-1)[0] prob, clss = torch.max(pred, 0) clss = image_net_ids[clss.item()] print(f'PREDICTION: `{clss}` @ {prob.item()}')
在上述代码中,我们正在将输入图像转换为张量(使用先前定义的image2tensor方法进行归一化)并通过model获取图像中对象的类别(clss)和预测的概率(prob)。
-
定义
attack函数如下:attack函数接受image、model和target作为输入:
from tqdm import trange losses = [] def attack(image, model, target, epsilon=1e-6):- 将图像转换为张量,并指定需要计算梯度的输入:
input = image2tensor(image) input.requires_grad = True- 通过将规范化的输入(
input)通过模型计算预测,然后计算相应目标类别的损失值:
pred = model(input) loss = nn.CrossEntropyLoss()(pred, target)- 执行反向传播以减少损失:
loss.backward() losses.append(loss.mean().item())- 基于梯度方向微调图像:
output = input - epsilon * input.grad.sign()-
在前述代码中,我们通过一个非常小的量(乘以
epsilon)更新输入值。此外,我们仅仅通过梯度的方向(input.grad.sign())进行更新图像,而不是梯度的大小,而且在此之前乘以了一个非常小的值(epsilon)。 -
将张量转换为图像(
tensor2image)并返回输出,这会使图像反标准化:
output = tensor2image(output) del input return output.detach() -
修改图像以属于不同的类别:
- 指定我们想要将图像转换为的目标(
desired_targets):
modified_images = [] desired_targets = ['lemon', 'comic book', 'sax, saxophone']- 循环遍历目标,并在每次迭代中指定目标类别:
for target in desired_targets: target = torch.tensor([image_net_classes[target]])- 修改图像以攻击逐步增加的时期,并将它们收集到一个列表中:
image_to_attack = original_image.clone() for _ in trange(10): image_to_attack = attack(image_to_attack,model,target) modified_images.append(image_to_attack)- 下面的代码会导致修改后的图像和相应的类别:
for image in [original_image, *modified_images]: predict_on_image(image) inspect(image) - 指定我们想要将图像转换为的目标(
前述代码生成如下内容:
图 11.12:修改后的图像及其对应的类别
我们可以看到,当我们轻微地修改图像时,预测类别完全不同,但置信度非常高。
现在我们了解了如何修改图像,使其成为我们希望的类别,接下来的部分中,我们将学习如何修改图像(内容图像)以我们选择的风格。
理解神经风格转移
想象一个场景,你想以梵高的风格绘制一幅图像。在这种情况下,神经风格转移非常有用。在神经风格转移中,我们使用一个内容图像和一个风格图像,将这两个图像以一种方式结合起来,使得合成图像保留内容图像的内容,同时保持风格图像的风格。
理解神经风格转移的工作原理
示例风格图像和内容图像如下:
图 11.13:(左)风格图像。 (右)内容图像
我们希望保留右侧图片(内容图像)中的内容,但同时叠加左侧图片(风格图像)中的颜色和纹理。
执行神经风格转移的过程如下(我们将遵循此论文中概述的技术:arxiv.org/abs/1508.06576)。我们试图修改原始图像,使得损失值分解为内容损失和风格损失。内容损失指生成图像与内容图像有多不同。风格损失指风格图像与生成图像有多相关。
虽然我们提到损失是基于图像差异计算的,但在实践中,我们稍微修改了它,确保使用图像的特征层激活而不是原始图像计算损失。例如,在第二层的内容损失将是通过第二层传递时的内容图像和生成图像的激活之间的平方差。这是因为特征层捕获原始图像的某些属性(例如,高层中对应于原始图像的前景轮廓以及低层中细粒度对象的细节)。
虽然计算内容损失似乎很简单,让我们尝试理解如何计算生成图像与样式图像之间的相似性。一个称为格拉姆矩阵的技术非常方便。格拉姆矩阵计算生成图像和样式图像之间的相似性,计算方法如下:
GM**l 是层l的样式图像S和生成图像G的格拉姆矩阵值。N[l]代表特征图的数量,其中M[l]是特征图的高度乘以宽度。
由于将矩阵乘以其自身的转置得到了格拉姆矩阵。让我们讨论这个操作是如何使用的。假设您正在处理一个具有 32 x 32 x 256 的特征输出层。格拉姆矩阵被计算为通道内每个 32 x 32 值与所有通道中值的相关性。因此,格拉姆矩阵计算结果为 256 x 256 的矩阵形状。现在我们比较样式图像和生成图像的 256 x 256 值,以计算样式损失。
让我们理解为什么格拉姆矩阵对于样式转移如此重要。在成功的情况下,假设我们将毕加索的风格转移到了蒙娜丽莎上。让我们称毕加索的风格为St(代表样式),原始蒙娜丽莎为So(代表源),最终图像为Ta(代表目标)。请注意,在理想情况下,图像Ta中的局部特征与St中的局部特征相同。即使内容可能不同,将样式图像的类似颜色、形状和纹理带入目标图像中是样式转移中的重要部分。
扩展开来,如果我们发送So并从 VGG19 的中间层提取其特征,它们将与通过发送Ta获得的特征不同。然而,在每个特征集内,相应向量将以类似的方式相对于彼此变化。例如,如果两个特征集的第一个通道均值与第二个通道均值的比率将是相似的。这就是我们尝试使用格拉姆损失计算的原因。
通过比较内容图像的特征激活的差异来计算内容损失。通过首先在预定义层中计算格拉姆矩阵,然后比较生成图像和样式图像的格拉姆矩阵来计算样式损失。
现在我们能够计算样式损失和内容损失,最终修改的输入图像是最小化总损失的图像,即样式损失和内容损失的加权平均。
执行神经风格迁移
我们采用的实施神经风格迁移的高层策略如下:
-
将输入图像通过预训练模型传递。
-
提取预定义层的层值。
-
将生成的图像通过模型并在同一预定义层中提取其值。
-
计算与内容图像和生成图像相对应的每个层的内容损失。
-
将样式图像通过模型的多层并计算样式图像的格拉姆矩阵值。
-
将生成的图像通过样式图像传递的相同层,并计算其相应的格拉姆矩阵值。
-
提取两个图像的格拉姆矩阵值的平方差。这将是样式损失。
-
总损失将是样式损失和内容损失的加权平均。
-
使总损失最小化的生成图像将是感兴趣的最终图像。
现在让我们编写前面的策略代码:
本书 GitHub 存储库的Chapter11文件夹中提供了名为neural_style_transfer.ipynb的以下代码:bit.ly/mcvp-2e。代码包含从中下载数据的 URL,并且代码长度适中。我们强烈建议您在 GitHub 上执行笔记本以重现结果,同时理解执行步骤和文本中各种代码组件的解释。
-
导入相关包:
!pip install torch_snippets from torch_snippets import * from torchvision import transforms as T from torch.nn import functional as F device = 'cuda' if torch.cuda.is_available() else 'cpu' -
定义用于预处理和后处理数据的函数:
from torchvision.models import vgg19 preprocess = T.Compose([ T.ToTensor(), T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), T.Lambda(lambda x: x.mul_(255)) ]) postprocess = T.Compose([ T.Lambda(lambda x: x.mul_(1./255)), T.Normalize(\ mean=[-0.485/0.229,-0.456/0.224,-0.406/0.225], std=[1/0.229, 1/0.224, 1/0.225]), ]) -
定义
GramMatrix模块:class GramMatrix(nn.Module): def forward(self, input): b,c,h,w = input.size() feat = input.view(b, c, h*w) G = feat@feat.transpose(1,2) G.div_(h*w) return G
在前述代码中,我们正在计算所有可能的特征与自身的内积,这基本上是在询问所有向量如何相互关联。
-
定义格拉姆矩阵的相应均方误差损失,
GramMSELoss:class GramMSELoss(nn.Module): def forward(self, input, target): out = F.mse_loss(GramMatrix()(input), target) return(out)
一旦我们对两个特征集都有了格拉姆向量,它们尽可能地匹配,因此mse_loss至关重要。
-
定义模型类
vgg19_modified:- 初始化类:
class vgg19_modified(nn.Module): def __init__(self): super().__init__()- 提取特征:
features = list(vgg19(pretrained = True).features) self.features = nn.ModuleList(features).eval()- 定义
forward方法,该方法接受层列表并返回与每个层对应的特征:
def forward(self, x, layers=[]): order = np.argsort(layers) _results, results = [], [] for ix,model in enumerate(self.features): x = model(x) if ix in layers: _results.append(x) for o in order: results.append(_results[o]) return results if layers is not [] else x- 定义模型对象:
vgg = vgg19_modified().to(device) -
导入内容和样式图像:
!wget https://www.dropbox.com/s/z1y0fy2r6z6m6py/60.jpg !wget https://www.dropbox.com/s/1svdliljyo0a98v/style_image.png -
确保图像调整大小为相同形状,512 x 512 x 3:
imgs = [Image.open(path).resize((512,512)).convert('RGB') \ for path in ['style_image.png', '60.jpg']] style_image,content_image=[preprocess(img).to(device)[None] \ for img in imgs] -
指定要使用
requires_grad = True修改内容图像:opt_img = content_image.data.clone() opt_img.requires_grad = True -
指定定义内容损失和样式损失的层次,即我们使用的中间 VGG 层,用于比较风格的 Gram 矩阵和内容的原始特征向量(注意,下面选择的层次纯粹是实验性的):
style_layers = [0, 5, 10, 19, 28] content_layers = [21] loss_layers = style_layers + content_layers
定义内容和样式损失值的损失函数:
-
loss_fns = [GramMSELoss()] * len(style_layers) + \ [nn.MSELoss()] * len(content_layers) loss_fns = [loss_fn.to(device) for loss_fn in loss_fns] -
定义与内容和样式损失相关联的权重:
style_weights = [1000/n**2 for n in [64,128,256,512,512]] content_weights = [1] weights = style_weights + content_weights -
我们需要操作我们的图像,使得目标图像的风格尽可能地与
style_image相似。因此,我们通过计算从 VGG 的几个选择层次获得的特征的 Gram 矩阵来计算style_image的style_targets值。由于整体内容应该保持不变,我们选择content_layer变量来计算来自 VGG 的原始特征:style_targets = [GramMatrix()(A).detach() for A in \ vgg(style_image, style_layers)] content_targets = [A.detach() for A in \ vgg(content_image, content_layers)] targets = style_targets + content_targets -
定义
optimizer和迭代次数(max_iters)。尽管我们可以使用 Adam 或任何其他优化器,但 LBFGS 是一种观察到在确定性场景中工作最佳的优化器。此外,由于我们处理的是一张图像,没有任何随机性。许多实验表明,在神经传递设置中,LBFGS 收敛更快,损失更低,因此我们将使用这个优化器:max_iters = 500 optimizer = optim.LBFGS([opt_img]) log = Report(max_iters) -
执行优化。在确定性场景中,我们在同一个张量上进行迭代时,可以将优化器步骤包装为一个零参数函数,并重复调用它,如下所示:
iters = 0 while iters < max_iters: def closure(): global iters iters += 1 optimizer.zero_grad() out = vgg(opt_img, loss_layers) layer_losses = [weights[a]*loss_fnsa \ for a,A in enumerate(out)] loss = sum(layer_losses) loss.backward() log.record(pos=iters, loss=loss, end='\r') return loss optimizer.step(closure) -
绘制损失变化情况:
log.plot(log=True)
这导致以下输出:
图 11.14:随着 epoch 增加的损失变化
-
绘制具有内容和风格图像组合的图像:
With torch.no_grad() out_img = postprocess(opt_img[0]).permute(1,2,0) show(out_img)
输出如下:
图 11.15:风格转移后的图像
从前述图片可以看出,图像是内容和风格图像的组合。
通过这种方式,我们已经看到了两种操作图像的方法:对图像进行对抗性攻击以修改图像的类别,以及风格转移来将一个图像的风格与另一个图像的内容结合。在接下来的部分,我们将学习生成 Deepfakes,这将表情从一个脸部转移到另一个脸部。
理解 Deepfakes
到目前为止,我们已经了解了两种不同的图像到图像的任务:使用 UNet 进行语义分割和使用自动编码器进行图像重构。Deepfakery 是一个具有非常相似基础理论的图像到图像任务。
深度伪造的工作原理
想象一种情况,你想创建一个应用程序,它可以拍摄一张脸部图片,并以你期望的方式改变表情。在这种情况下,Deepfakes 非常有用。尽管我们有意选择在本书中不讨论最新的 Deepfakes 技术,但一些技术如少样本对抗学习已经发展出来,用于生成具有感兴趣面部表情的逼真图片。了解 Deepfakes 的工作原理和 GANs(你将在下一章节学习到)将帮助你识别假视频。
在深伪造任务中,我们有几百张个人 A 的图片和几百张个人 B 的图片(或者可能是个人 A 和 B 的视频)。目标是重建具有个人 A 表情的个人 B 的面部,反之亦然。
以下图示解释了深度伪造图像生成过程的工作原理:
图 11.16:自动编码器工作流程,其中有一个编码器和两个类/集合的面部的独立解码器
在上述图片中,我们通过编码器(编码器)传递个人 A 和个人 B 的图像。一旦我们得到与个人 A(潜在面部 A)和个人 B(潜在面部 B)对应的潜在向量,我们通过它们对应的解码器(解码器 A 和 解码器 B)传递潜在向量以获取相应的原始图像(重构面部 A 和 重构面部 B)。到目前为止,编码器和两个解码器的概念与我们在理解自动编码器部分看到的非常相似。然而,在这种情况下,我们只有一个编码器,但有两个解码器(每个解码器对应不同的人)。期望从编码器获取的潜在向量表示图像中存在的面部表情的信息,而解码器获取相应的图像。一旦编码器和两个解码器训练好了,在执行深度伪造图像生成时,我们在我们的架构中切换连接如下:
图 11.17:从潜在表示图像重构一旦解码器被交换
当个人 A 的潜在向量通过解码器 B 传递时,个人 B 的重构面部将具有个人 A 的特征(一个微笑的面孔),反之亦然,当个人 B 通过解码器 A 传递时(一个悲伤的面孔)。
另一个有助于生成逼真图像的技巧是对脸部图像进行扭曲,并以这样的方式将它们馈送到网络中,扭曲的脸部作为输入,期望原始图像作为输出。
现在我们了解了它的工作原理,让我们使用自动编码器实现生成具有另一个人表情的假图像。
生成深度伪造
现在让我们来看一个实际例子:
以下代码可在本书的 GitHub 存储库的 Chapter11 文件夹中的 Generating_Deep_Fakes.ipynb 中找到:bit.ly/mcvp-2e。代码包含用于下载数据的 URL,并且长度适中。我们强烈建议您在 GitHub 中执行该笔记本,以重现结果,同时理解文本中执行步骤和各种代码组件的解释。
-
让我们下载数据(我们已经合成创建)和源代码如下:
import os if not os.path.exists('Faceswap-Deepfake-Pytorch'): !wget -q https://www.dropbox.com/s/5ji7jl7httso9ny/person_images.zip !wget -q https://raw.githubusercontent.com/sizhky/deep-fake-util/main/random_warp.py !unzip -q person_images.zip !pip install -q torch_snippets torch_summary from torch_snippets import * from random_warp import get_training_data -
从图像中获取面部截图如下:
- 定义面部级联,它在图像中绘制一个围绕面部的边界框。在 GitHub 存储库的OpenCV 图像分析实用程序PDF 中有关级联的更多信息。但是,目前仅需说明面部级联在图像中绘制紧密的面部边界框:
face_cascade = cv2.CascadeClassifier(cv2.data.haarcascades + \ 'haarcascade_frontalface_default.xml')- 为从图像中裁剪面部定义一个名为
crop_face的函数:
def crop_face(img): gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) faces = face_cascade.detectMultiScale(gray, 1.3, 5) if(len(faces)>0): for (x,y,w,h) in faces: img2 = img[y:(y+h),x:(x+w),:] img2 = cv2.resize(img2,(256,256)) return img2, True else: return img, False在前述函数中,我们通过面部级联将灰度图像(
gray)传递,并裁剪包含面部的矩形。接下来,我们返回一个重新调整大小的图像(img2)。此外,为了考虑到在图像中未检测到面部的情况,我们传递一个标志以显示是否检测到面部。- 裁剪
personA和personB的图像,并将它们放在不同的文件夹中:
!mkdir cropped_faces_personA !mkdir cropped_faces_personB def crop_images(folder): images = Glob(folder+'/*.jpg') for i in range(len(images)): img = read(images[i],1) img2, face_detected = crop_face(img) if(face_detected==False): continue else: cv2.imwrite('cropped_faces_'+folder+'/'+str(i)+ \ '.jpg',cv2.cvtColor(img2, cv2.COLOR_RGB2BGR)) crop_images('personA') crop_images('personB') -
创建一个数据加载器并检查数据:
class ImageDataset(Dataset): def __init__(self, items_A, items_B): self.items_A = np.concatenate([read(f,1)[None] \ for f in items_A])/255. self.items_B = np.concatenate([read(f,1)[None] \ for f in items_B])/255. self.items_A += self.items_B.mean(axis=(0, 1, 2)) \ - self.items_A.mean(axis=(0, 1, 2)) def __len__(self): return min(len(self.items_A), len(self.items_B)) def __getitem__(self, ix): a, b = choose(self.items_A), choose(self.items_B) return a, b def collate_fn(self, batch): imsA, imsB = list(zip(*batch)) imsA, targetA = get_training_data(imsA, len(imsA)) imsB, targetB = get_training_data(imsB, len(imsB)) imsA, imsB, targetA, targetB = [torch.Tensor(i)\ .permute(0,3,1,2)\ .to(device) \ for i in [imsA, imsB,\ targetA, targetB]] return imsA, imsB, targetA, targetB a = ImageDataset(Glob('cropped_faces_personA'), \ Glob('cropped_faces_personB')) x = DataLoader(a, batch_size=32, collate_fn=a.collate_fn)
数据加载器返回四个张量,imsA,imsB,targetA和targetB。第一个张量(imsA)是第三个张量(targetA)的扭曲(变形)版本,第二个(imsB)是第四个张量(targetB)的扭曲(变形)版本。
此外,正如您在a =ImageDataset(Glob('cropped_faces_personA'), Glob('cropped_faces_personB'))行中看到的那样,我们有两个图像文件夹,每个人一个。这些面部之间没有任何关系,并且在__iteritems__数据集中,我们每次随机获取两张面部。
此步骤中的关键函数是get_training_data,出现在collate_fn中。这是一个用于扭曲(变形)面部的增强函数。我们将扭曲的面部作为自编码器的输入,并尝试预测正常的面部。
扭曲的优势不仅在于它增加了我们的训练数据量,而且还作为网络的正则化器,强制它理解关键面部特征,尽管提供的是扭曲的面部。
-
让我们检查一些图像:
inspect(*next(iter(x))) for i in next(iter(x)): subplots(i[:8], nc=4, sz=(4,2))
上述代码的输出如下:
图 11.18:一批图像的输入和输出组合
请注意,输入图像是扭曲的,而输出图像则不是,输入到输出图像现在具有一对一的对应关系。
-
构建模型如下:
-
定义卷积(
_ConvLayer)和上采样(_UpScale)函数以及在构建模型时将利用的Reshape类:def _ConvLayer(input_features, output_features): return nn.Sequential( nn.Conv2d(input_features, output_features, kernel_size=5, stride=2, padding=2), nn.LeakyReLU(0.1, inplace=True) ) def _UpScale(input_features, output_features): return nn.Sequential( nn.ConvTranspose2d(input_features, output_features, kernel_size=2, stride=2, padding=0), nn.LeakyReLU(0.1, inplace=True) ) class Reshape(nn.Module): def forward(self, input): output = input.view(-1, 1024, 4, 4) # channel * 4 * 4 return output -
定义
Autoencoder模型类,它有一个单独的encoder和两个解码器(decoder_A和decoder_B):class Autoencoder(nn.Module): def __init__(self): super(Autoencoder, self).__init__() self.encoder = nn.Sequential( _ConvLayer(3, 128), _ConvLayer(128, 256), _ConvLayer(256, 512), _ConvLayer(512, 1024), nn.Flatten(), nn.Linear(1024 * 4 * 4, 1024), nn.Linear(1024, 1024 * 4 * 4), Reshape(), _UpScale(1024, 512), ) self.decoder_A = nn.Sequential( _UpScale(512, 256), _UpScale(256, 128), _UpScale(128, 64), nn.Conv2d(64, 3, kernel_size=3, padding=1), nn.Sigmoid(), ) self.decoder_B = nn.Sequential( _UpScale(512, 256), _UpScale(256, 128), _UpScale(128, 64), nn.Conv2d(64, 3, kernel_size=3, padding=1), nn.Sigmoid(), ) def forward(self, x, select='A'): if select == 'A': out = self.encoder(x) out = self.decoder_A(out) else: out = self.encoder(x) out = self.decoder_B(out) return out
-
-
生成模型的摘要:
from torchsummary import summary model = Autoencoder() summary(model, torch.zeros(32,3,64,64), 'A');
上述代码生成以下输出:
图 11.19:模型架构总结
-
定义
train_batch逻辑:def train_batch(model, data, criterion, optimizes): optA, optB = optimizers optA.zero_grad() optB.zero_grad() imgA, imgB, targetA, targetB = data _imgA, _imgB = model(imgA, 'A'), model(imgB, 'B') lossA = criterion(_imgA, targetA) lossB = criterion(_imgB, targetB) lossA.backward() lossB.backward() optA.step() optB.step() return lossA.item(), lossB.item()
我们感兴趣的是运行model(imgA, 'B')(它将使用类 A 的输入图像返回类 B 的图像),但我们没有地面真相来进行比较。因此,我们正在预测imgA(其中imgA是targetA的扭曲版本)的_imgA,并使用nn.L1Loss将_imgA与targetA进行比较。
我们不需要validate_batch,因为没有验证数据集。在训练期间,我们将预测新图像,并在质量上看到进展。
-
创建训练模型所需的所有组件:
model = Autoencoder().to(device) dataset = ImageDataset(Glob('cropped_faces_personA'), \ Glob('cropped_faces_personB')) dataloader = DataLoader(dataset, 32, collate_fn=dataset.collate_fn) optimizers=optim.Adam( \ [{'params': model.encoder.parameters()}, \ {'params': model.decoder_A.parameters()}], \ lr=5e-5, betas=(0.5, 0.999)), \ optim.Adam([{'params': model.encoder.parameters()}, \ {'params': model.decoder_B.parameters()}], \ lr=5e-5, betas=(0.5, 0.999)) criterion = nn.L1Loss() -
训练模型:
n_epochs = 1000 log = Report(n_epochs) !mkdir checkpoint for ex in range(n_epochs): N = len(dataloader) for bx,data in enumerate(dataloader): lossA, lossB = train_batch(model, data, criterion, optimizers) log.record(ex+(1+bx)/N, lossA=lossA, lossB=lossB, end='\r') log.report_avgs(ex+1) if (ex+1)%100 == 0: state = { 'state': model.state_dict(), 'epoch': ex } torch.save(state, './checkpoint/autoencoder.pth') if (ex+1)%100 == 0: bs = 5 a,b,A,B = data line('A to B') _a = model(a[:bs], 'A') _b = model(a[:bs], 'B') x = torch.cat([A[:bs],_a,_b]) subplots(x, nc=bs, figsize=(bs*2, 5)) line('B to A') _a = model(b[:bs], 'A') _b = model(b[:bs], 'B') x = torch.cat([B[:bs],_a,_b]) subplots(x, nc=bs, figsize=(bs*2, 5)) log.plot_epochs()
上述代码导致重构的图像如下:
图 11.20:原始和重构图像
损失值的变化如下(您可以参考书籍的数字版获取彩色图像):
图 11.21:随着 epochs 增加,损失的变化
正如您所看到的,我们可以通过调整自编码器以使用两个解码器而不是一个,从而在一个面孔和另一个面孔之间交换表情。此外,随着更多的 epochs,重构的图像变得更加逼真。
摘要
在本章中,我们学习了不同变体的自编码器:香草、卷积和变分。我们还学习了瓶颈层单位数量如何影响重构图像。接下来,我们学习了使用 t-SNE 技术识别与给定图像相似的图像。我们了解到当我们对向量进行采样时,无法获得逼真的图像,通过使用变分自编码器,我们学习了如何通过重构损失和 KL 散度损失生成新图像。接着,我们学习了如何对图像进行对抗攻击,以修改图像的类别而不改变图像的感知内容。然后,我们学习了如何利用内容损失和基于 Gram 矩阵的风格损失的组合来优化图像的内容和风格损失,从而生成两个输入图像的组合图像。最后,我们学习了如何调整自编码器以在没有任何监督的情况下交换两个面孔。
现在我们已经学习了如何从给定的图像集生成新图像,下一章中,我们将进一步讨论这个主题,使用生成对抗网络的变体生成全新的图像。
问题
-
自编码器中的“编码器”是什么?
-
自编码器优化哪种损失函数?
-
自编码器如何帮助分组类似的图像?
-
卷积自编码器在何时有用?
-
如果我们从香草/卷积自编码器获得的嵌入向量空间随机采样,为什么会得到非直观的图像?
-
变分自编码器优化的是哪些损失函数?
-
变分自编码器如何克服香草/卷积自编码器生成新图像的限制?
-
在对抗攻击期间,为什么我们修改输入图像的像素而不是权重值?
-
在神经风格转移中,我们优化哪些损失以获得最佳结果?
-
当计算风格和内容损失时,为什么要考虑不同层的激活而不是原始图像?
-
当计算风格损失时,为什么要考虑 Gram 矩阵损失而不是图像之间的差异?
-
在构建用于生成深度伪造的模型时,为什么我们会扭曲图像?
在 Discord 上了解更多信息
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:
第十二章:使用 GAN 生成图像
在上一章中,我们学习了如何使用神经风格转移操作图片,并将一个图像中的表情叠加到另一个图像上。但是,如果我们给网络一堆图片,并要求它自行创造出一个全新的图片,会怎样呢?
生成对抗网络(GANs)是实现给定一组图片生成图像的尝试。在本章中,我们将从理解使得 GANs 起作用的原理开始,然后从头开始构建一个。这是一个不断扩展的广阔领域,即使我们在撰写本书时,也在扩展中。本章将通过介绍三种变体来奠定 GANs 的基础;在下一章中,我们将学习更高级的 GANs 及其应用。
在本章中,我们将探讨以下主题:
-
引入 GAN
-
使用 GAN 生成手写数字
-
使用 DCGAN 生成人脸图像
-
实现条件 GANs
本章中所有代码片段均可在 Github 仓库的 Chapter12 文件夹中找到,链接为 bit.ly/mcvp-2e。
引入 GAN
要理解 GAN,我们需要理解两个术语:生成器和判别器。首先,我们应该有一个合理数量的对象图片样本(100-1000 张图片)。生成网络(生成器)从图片样本中学习表示,并生成类似样本图片的图片。判别网络(判别器)则会查看生成器网络生成的图片和原始图片样本,并将图片分类为原始或生成的(伪造的)。
生成器网络试图以一种方式生成图片,使得判别器将这些图片分类为真实的。判别器网络试图将生成的图片分类为假的,并将原始样本中的图片分类为真实的。
本质上,GAN 中的对抗术语表示了两个网络相反性质的对立面——一个生成器网络,生成图像以欺骗判别器网络,和一个判别器网络,通过判断图像是生成的还是原始的来分类每张图片。
让我们通过下图了解 GANs 的工作过程:
图 12.1:典型的 GAN 工作流程
在上述图表中,生成器网络从随机噪声生成图像作为输入。鉴别器网络查看生成器生成的图像,并将其与真实数据(提供的图像样本)进行比较,以确定生成的图像是真实还是伪造的。生成器试图生成尽可能多的逼真图像,而鉴别器则试图检测生成器生成的图像中哪些是伪造的。这样一来,生成器通过学习鉴别器的观察对象,学会尽可能多地生成逼真图像。
通常情况下,在每一步训练中,生成器和鉴别器交替训练。这样做类似于警察和小偷的游戏,生成器是试图生成伪造数据的小偷,而鉴别器则是试图辨别现有数据是真实还是伪造的警察。训练 GAN 的步骤如下:
-
训练生成器(而不是鉴别器)以生成被鉴别器分类为真实的图像。
-
训练鉴别器(而不是生成器)以将生成器生成的图像分类为伪造的。
-
重复该过程直到达到平衡。
现在让我们理解如何使用以下图表和步骤计算生成器和鉴别器的损失值,从而同时训练两个网络:
图 12.2:GAN 训练工作流程(虚线表示训练,实线表示过程)
在上述场景中,当鉴别器能够非常好地检测生成的图像时,与生成器相关的损失要比与鉴别器相关的损失高得多。因此,梯度调整得使得生成器的损失更低。然而,这会使得鉴别器的损失偏高。在下一次迭代中,梯度会调整,以使得鉴别器的损失更低。这样一来,生成器和鉴别器不断训练,直到生成器生成逼真图像,而鉴别器无法区分真实图像和生成图像。
在了解这些之后,让我们在下一节生成与 MNIST 数据集相关的图像。
使用 GAN 生成手写数字。
要生成手写数字图像,我们将利用前一节学习的同一网络。我们将采用以下策略:
-
导入 MNIST 数据。
-
初始化随机噪声。
-
定义生成器模型。
-
定义鉴别器模型。
-
交替训练两个模型。
-
让模型训练,直到生成器和鉴别器的损失大致相同。
让我们在下面的代码中执行上述每个步骤。
本书 GitHub 存储库中的Chapter12文件夹中提供了Handwritten_digit_generation_using_GAN.ipynb代码:bit.ly/mcvp-2e。代码相当冗长。我们强烈建议您在 GitHub 上执行此笔记本,以重现结果,并在理解步骤和文本中各种代码组件的解释时进行操作。
-
导入相关包并定义设备:
!pip install -q torch_snippets from torch_snippets import * device = "cuda" if torch.cuda.is_available() else "cpu" from torchvision.utils import make_grid -
导入
MNIST数据,并使用内置数据转换定义数据加载器,以便将输入数据缩放到均值 0.5 和标准差 0.5:from torchvision.datasets import MNIST from torchvision import transforms transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize(mean=(0.5,), std=(0.5,)) ]) data_loader = torch.utils.data.DataLoader(MNIST('~/data', \ train=True, download=True, transform=transform), \ batch_size=128, shuffle=True, drop_last=True) -
定义
Discriminator模型类:class Discriminator(nn.Module): def __init__(self): super().__init__() self.model = nn.Sequential( nn.Linear(784, 1024), nn.LeakyReLU(0.2), nn.Dropout(0.3), nn.Linear(1024, 512), nn.LeakyReLU(0.2), nn.Dropout(0.3), nn.Linear(512, 256), nn.LeakyReLU(0.2), nn.Dropout(0.3), nn.Linear(256, 1), nn.Sigmoid() ) def forward(self, x): return self.model(x)
鉴别器网络摘要如下:
在前述代码中,请注意,我们使用了LeakyReLU(关于不同类型 ReLU 激活的实证评估可在此处找到:arxiv.org/pdf/1505.00853)作为激活函数,而不是ReLU。
-
!pip install torch_summary from torchsummary import summary discriminator = Discriminator().to(device) summary(discriminator,torch.zeros(1,784))
上述代码生成以下输出:
图 12.3:鉴别器架构摘要
-
定义
Generator模型类:class Generator(nn.Module): def __init__(self): super().__init__() self.model = nn.Sequential( nn.Linear(100, 256), nn.LeakyReLU(0.2), nn.Linear(256, 512), nn.LeakyReLU(0.2), nn.Linear(512, 1024), nn.LeakyReLU(0.2), nn.Linear(1024, 784), nn.Tanh() ) def forward(self, x): return self.model(x)
注意,生成器接受 100 维输入(即随机噪声),并从输入生成图像。生成器模型摘要如下:
-
generator = Generator().to(device) summary(generator,torch.zeros(1,100))
上述代码生成以下输出:
图 12.4:生成器架构摘要
-
定义一个生成随机噪声并将其注册到设备的函数:
def noise(size): n = torch.randn(size, 100) return n.to(device) -
定义训练鉴别器的函数如下:
- 鉴别器训练函数(
discriminator_train_step)接受真实数据(real_data)和假数据(fake_data)作为输入:
def discriminator_train_step(real_data, fake_data):- 重置梯度:
d_optimizer.zero_grad()- 在进行损失值反向传播之前,在真数据(
real_data)上进行预测并计算损失(error_real):
prediction_real = discriminator(real_data) error_real = loss(prediction_real, \ torch.ones(len(real_data),1).to(device)) error_real.backward()当我们计算真实数据上的鉴别器损失时,我们期望鉴别器预测输出为
1。因此,在鉴别器训练期间,通过使用torch.ones来计算真实数据上的鉴别器损失,预期鉴别器预测输出为1。- 在进行损失值反向传播之前,在假数据(
fake_data)上进行预测并计算损失(error_fake):
prediction_fake = discriminator(fake_data) error_fake = loss(prediction_fake, \ torch.zeros(len(fake_data),1).to(device)) error_fake.backward()当我们计算假数据上的鉴别器损失时,我们期望鉴别器预测输出为
0。因此,在鉴别器训练期间,通过使用torch.zeros来计算假数据上的鉴别器损失,预期鉴别器预测输出为0。- 更新权重并返回总体损失(将
error_real在real_data上的损失值与error_fake在fake_data上的损失值相加):
d_optimizer.step() return error_real + error_fake - 鉴别器训练函数(
-
以以下方式训练生成器模型:
- 定义生成器训练函数(
generator_train_step),接受假数据(fake_data)作为输入:
def generator_train_step(fake_data):- 重置生成器优化器的梯度:
g_optimizer.zero_grad()- 预测鉴别器在虚假数据(
fake_data)上的输出:
prediction = discriminator(fake_data)- 通过传递
prediction和期望值作为torch.ones来计算生成器损失值,因为我们希望在训练生成器时愚弄鉴别器输出值为1:
error = loss(prediction, torch.ones(len(real_data),1).to(device))- 执行反向传播,更新权重,并返回错误:
error.backward() g_optimizer.step() return error - 定义生成器训练函数(
-
定义模型对象,每个生成器和鉴别器的优化器,以及优化损失函数:
discriminator = Discriminator().to(device) generator = Generator().to(device) d_optimizer= optim.Adam(discriminator.parameters(),lr=0.0002) g_optimizer = optim.Adam(generator.parameters(), lr=0.0002) loss = nn.BCELoss() num_epochs = 200 log = Report(num_epochs) -
在增加的 epochs 上运行模型:
- 循环通过 200 个 epochs(
num_epochs)使用第 2 步获得的data_loader函数:
for epoch in range(num_epochs): N = len(data_loader) for i, (images, _) in enumerate(data_loader):- 加载真实数据(
real_data)和虚假数据,其中虚假数据(fake_data)是通过将noise(批处理大小为real_data中的数据点数:len(real_data))通过generator网络来获得的。注意,重要的是运行fake_data.detach(),否则训练将无法进行。在分离时,我们正在创建张量的新副本,以便在discriminator_train_step中调用error.backward()时,与生成器相关联的张量(用于创建fake_data)不受影响:
real_data = images.view(len(images), -1).to(device) fake_data=generator(noise(len(real_data))).to(device) fake_data = fake_data.detach()- 使用在第 6 步中定义的
discriminator_train_step函数训练鉴别器:
d_loss=discriminator_train_step(real_data, fake_data)- 现在我们已经训练了鉴别器,让我们在这一步中训练生成器。从噪声数据生成一组新的虚假图像(
fake_data),并使用第 6 步中定义的generator_train_step训练生成器:
fake_data=generator(noise(len(real_data))).to(device) g_loss = generator_train_step(fake_data)- 记录损失:
log.record(epoch+(1+i)/N, d_loss=d_loss.item(), \ g_loss=g_loss.item(), end='\r') log.report_avgs(epoch+1) log.plot_epochs(['d_loss', 'g_loss']) - 循环通过 200 个 epochs(
随着 epochs 的增加,鉴别器和生成器的损失如下(您可以参考书籍的数字版本获取彩色图像):
图 12.5:随着 epochs 增加的鉴别器和生成器损失
-
可视化训练后的虚假数据:
z = torch.randn(64, 100).to(device) sample_images = generator(z).data.cpu().view(64, 1, 28, 28) grid = make_grid(sample_images, nrow=8, normalize=True) show(grid.cpu().detach().permute(1,2,0), sz=5)
上述代码生成以下输出:
图 12.6:生成的数字
从这里我们可以看出,我们可以利用 GAN 生成逼真的图像,但仍然有一些改进的空间。在下一节中,我们将学习如何使用深度卷积 GAN 生成更逼真的图像。
使用 DCGAN 生成面部图像
在前一节中,我们学习了如何使用 GAN 生成图像。但是,我们已经在第四章,引入卷积神经网络中看到,卷积神经网络(CNNs)在图像背景下的表现比普通神经网络更好。在本节中,我们将学习如何使用深度卷积生成对抗网络(DCGANs)生成图像,这些网络在模型中使用卷积和池化操作。
首先,让我们了解我们将利用的技术,使用一组 100 个随机数生成图像(我们选择了 100 个随机数,以便网络有合理数量的值来生成图像。我们鼓励读者尝试不同数量的随机数并查看结果)。我们将首先将噪声转换为批量大小 x 100 x 1 x 1的形状。
在 DCGAN 中附加额外的通道信息而不在 GAN 部分中执行的原因是,我们将在本节中利用 CNN,它需要以批量大小 x 通道 x 高度 x 宽度的形式输入。
接下来,我们通过利用
ConvTranspose2d。正如我们在第九章,图像分割中学到的那样,ConvTranspose2d 执行的是卷积操作的相反操作,即使用预定义的内核大小、步长和填充来将输入的较小特征映射大小(高度 x 宽度)上采样到较大的大小。通过这种方式,我们逐渐将大小为批量大小 x 100 x 1 x 1 的随机噪声向量转换为批量大小 x 3 x 64 x 64 的图像。有了这个,我们已经将大小为 100 的随机噪声向量转换为一个人脸图像。
在理解了这一点之后,现在让我们构建一个模型来生成人脸图像:
以下代码在本书 GitHub 存储库的 Chapter12 文件夹中的 Face_generation_using_DCGAN.ipynb 中作为可用。代码包含用于下载数据的 URL,并且长度适中。我们强烈建议您在 GitHub 上执行笔记本以重现结果,同时理解执行步骤和文本中各种代码组件的解释。
-
下载并提取人脸图像(我们通过生成随机人物的面孔来整理的数据集):
!wget https://www.dropbox.com/s/rbajpdlh7efkdo1/male_female_face_images.zip !unzip male_female_face_images.zip
这里展示了一些图像的样本:
图 12.7:示例男性和女性图像
-
导入相关包:
!pip install -q --upgrade torch_snippets from torch_snippets import * import torchvision from torchvision import transforms import torchvision.utils as vutils import cv2, numpy as np, pandas as pd device = "cuda" if torch.cuda.is_available() else "cpu" -
定义数据集和数据加载器:
- 确保我们裁剪图像时仅保留人脸,并且丢弃图像中的额外细节。首先,我们将下载级联过滤器(有关级联过滤器的更多信息可以在 GitHub 上的《使用 OpenCV 工具进行图像分析》PDF 中找到),这将有助于识别图像中的人脸:
face_cascade = cv2.CascadeClassifier(cv2.data.haarcascades + \ 'haarcascade_frontalface_default.xml')- 创建一个新文件夹,并将所有裁剪的人脸图像倒入到这个新文件夹中:
!mkdir cropped_faces images = Glob('/content/females/*.jpg') + \ Glob('/content/males/*.jpg') for i in range(len(images)): img = read(images[i],1) gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) faces = face_cascade.detectMultiScale(gray, 1.3, 5) for (x,y,w,h) in faces: img2 = img[y:(y+h),x:(x+w),:] cv2.imwrite('cropped_faces/'+str(i)+'.jpg', \ cv2.cvtColor(img2, cv2.COLOR_RGB2BGR))
裁剪后的人脸样本如下:
图 12.8:裁剪的男性和女性面孔
请注意,通过裁剪并仅保留面部,我们保留了我们希望生成的信息。这样,我们可以减少 DCGAN 需要学习的复杂性。
-
指定在每个图像上执行的转换:
-
transform=transforms.Compose([ transforms.Resize(64), transforms.CenterCrop(64), transforms.ToTensor(), transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))]) -
定义
Faces数据集类: -
class Faces(Dataset): def __init__(self, folder): super().__init__() self.folder = folder self.images = sorted(Glob(folder)) def __len__(self): return len(self.images) def __getitem__(self, ix): image_path = self.images[ix] image = Image.open(image_path) image = transform(image) return image -
创建数据集对象:
ds: -
ds = Faces(folder='cropped_faces/') -
如下所示定义
dataloader类: -
dataloader = DataLoader(ds, batch_size=64, shuffle=True, num_workers=8) -
定义权重初始化,使得权重的分布更小,正如在对抗训练部分的详细说明中提到的那样,参见
arxiv.org/pdf/1511.06434: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) -
定义
Discriminator模型类,接受形状为批量大小 x 3 x 64 x 64 的图像,并预测其真实性或伪造性:class Discriminator(nn.Module): def __init__(self): super(Discriminator, self).__init__() self.model = nn.Sequential( nn.Conv2d(3,64,4,2,1,bias=False), nn.LeakyReLU(0.2,inplace=True), nn.Conv2d(64,64*2,4,2,1,bias=False), nn.BatchNorm2d(64*2), nn.LeakyReLU(0.2,inplace=True), nn.Conv2d(64*2,64*4,4,2,1,bias=False), nn.BatchNorm2d(64*4), nn.LeakyReLU(0.2,inplace=True), nn.Conv2d(64*4,64*8,4,2,1,bias=False), nn.BatchNorm2d(64*8), nn.LeakyReLU(0.2,inplace=True), nn.Conv2d(64*8,1,4,1,0,bias=False), nn.Sigmoid() ) self.apply(weights_init) def forward(self, input): return self.model(input)
获取定义模型的摘要:
-
!pip install torch_summary from torchsummary import summary discriminator = Discriminator().to(device) summary(discriminator,torch.zeros(1,3,64,64));
上述代码生成如下输出:
图 12.9: 鉴别器架构摘要
-
定义
Generator模型类,从形状为批量大小 x 100 x 1 x 1 的输入生成虚假图像:class Generator(nn.Module): def __init__(self): super(Generator,self).__init__() self.model = nn.Sequential( nn.ConvTranspose2d(100,64*8,4,1,0,bias=False,), nn.BatchNorm2d(64*8), nn.ReLU(True), nn.ConvTranspose2d(64*8,64*4,4,2,1,bias=False), nn.BatchNorm2d(64*4), nn.ReLU(True), nn.ConvTranspose2d( 64*4,64*2,4,2,1,bias=False), nn.BatchNorm2d(64*2), nn.ReLU(True), nn.ConvTranspose2d( 64*2,64,4,2,1,bias=False), nn.BatchNorm2d(64), nn.ReLU(True), nn.ConvTranspose2d( 64,3,4,2,1,bias=False), nn.Tanh() ) self.apply(weights_init) def forward(self,input): return self.model(input)
获取定义模型的摘要:
-
generator = Generator().to(device) summary(generator,torch.zeros(1,100,1,1))
上述代码生成如下输出:
图 12.10: 生成器架构摘要
请注意,我们利用ConvTranspose2d逐渐上采样数组,使其尽可能地类似于图像。
-
定义用于训练生成器(
generator_train_step)和鉴别器(discriminator_train_step)的函数:def discriminator_train_step(real_data, fake_data): d_optimizer.zero_grad() prediction_real = discriminator(real_data) error_real = loss(prediction_real.squeeze(), \ torch.ones(len(real_data)).to(device)) error_real.backward() prediction_fake = discriminator(fake_data) error_fake = loss(prediction_fake.squeeze(), \ torch.zeros(len(fake_data)).to(device)) error_fake.backward() d_optimizer.step() return error_real + error_fake def generator_train_step(fake_data): g_optimizer.zero_grad() prediction = discriminator(fake_data) error = loss(prediction.squeeze(), \ torch.ones(len(real_data)).to(device)) error.backward() g_optimizer.step() return error
在上述代码中,我们在模型输出上执行了.squeeze操作,因为模型的输出形状为批量大小 x 1 x 1 x 1,需要与形状为批量大小 x 1 的张量进行比较。
-
创建生成器和鉴别器模型对象、优化器和要优化的鉴别器损失函数:
discriminator = Discriminator().to(device) generator = Generator().to(device) loss = nn.BCELoss() d_optimizer = optim.Adam(discriminator.parameters(), \ lr=0.0002, betas=(0.5, 0.999)) g_optimizer = optim.Adam(generator.parameters(), \ lr=0.0002, betas=(0.5, 0.999)) -
在逐步增加的周期上运行模型,如下所示:
- 循环通过步骤 3中定义的
dataloader函数 25 个周期:
log = Report(25) for epoch in range(25): N = len(dataloader) for i, images in enumerate(dataloader):- 通过生成器网络传递真实数据(
real_data)加载真实数据,并生成虚假数据(fake_data):
real_data = images.to(device) fake_data = generator(torch.randn(len(real_data), 100, 1, \ 1).to(device)).to(device) fake_data = fake_data.detach()请注意,当生成
real_data时,普通 GAN 和 DCGAN 之间的主要区别在于,在 DCGAN 的情况下,我们不必展平real_data,因为我们正在利用 CNN。- 使用步骤 7中定义的
discriminator_train_step函数训练鉴别器:
d_loss=discriminator_train_step(real_data, fake_data)- 从嘈杂数据(
torch.randn(len(real_data)))生成一组新图像(fake_data),并使用步骤 7中定义的generator_train_step函数训练生成器:
fake_data = generator(torch.randn(len(real_data), \ 100, 1, 1).to(device)).to(device) g_loss = generator_train_step(fake_data)- 记录损失:
log.record(epoch+(1+i)/N, d_loss=d_loss.item(), \ g_loss=g_loss.item(), end='\r') log.report_avgs(epoch+1) log.plot_epochs(['d_loss','g_loss']) - 循环通过步骤 3中定义的
上述代码生成如下输出(您可以参考书籍的数字版本查看彩色图像):
图 12.11: 随着训练周期增加,鉴别器和生成器的损失
请注意,在这种设置中,生成器和鉴别器损失的变化不遵循我们在手写数字生成情况下看到的模式,原因如下:
-
我们处理更大的图像(图像形状为 64 x 64 x 3,与前一节中的 28 x 28 x 1 形状的图像相比)。
-
与人脸图像中存在的特征相比,数字的变化较少。
-
与图像中的信息相比,手写数字中的信息仅在少数像素中可用。
一旦训练过程完成,使用以下代码生成一组样本图像:
generator.eval()
noise = torch.randn(64, 100, 1, 1, device=device)
sample_images = generator(noise).detach().cpu()
grid = vutils.make_grid(sample_images,nrow=8,normalize=True)
show(grid.cpu().detach().permute(1,2,0), sz=10, \
title='Generated images')
前述代码生成了以下一组图像:
图 12.12:从训练过的 DCGAN 模型生成的图像
注意,尽管生成器从随机噪声生成了人脸图像,这些图像看起来还算不错,但仍然不够逼真。一个潜在的原因是并非所有输入图像都具有相同的面部对齐。作为练习,我们建议您仅在原始图像中没有倾斜面孔且人物直视摄像头的图像上训练 DCGAN。
此外,我们建议您尝试将生成图像与具有高鉴别器分数和低鉴别器分数的图像进行对比。
在这一部分中,我们已经学习了如何生成人脸的图像。然而,我们无法指定我们感兴趣的图像的生成(例如,一个有胡须的男人)。在接下来的部分中,我们将致力于生成特定类别的图像。
实现条件生成对抗网络
想象一种情景,我们想要生成我们感兴趣的类别的图像;例如,一只猫、一只狗,或者一位戴眼镜的男人的图像。我们如何指定我们想要生成我们感兴趣的图像呢?**条件生成对抗网络(Conditional GANs)**在这种情况下派上了用场。暂时假设我们只有男性和女性面部图像以及它们对应的标签。在这一部分中,我们将学习如何从随机噪声生成指定类别的图像。
我们采用的策略如下:
-
指定我们要生成的图像的标签为独热编码版本。
-
将标签通过嵌入层传递,以生成每个类别的多维表示。
-
生成随机噪声并与上一步生成的嵌入层串联。
-
训练模型的方法与前几节相同,但这次要将噪声向量与我们希望生成的图像类别的嵌入串联起来。
在下面的代码中,我们将编写前述策略:
此书的 GitHub 存储库中的 Chapter12 文件夹中提供了名为 Face_generation_using_Conditional_GAN.ipynb 的以下代码:bit.ly/mcvp-2e。我们强烈建议您在 GitHub 中执行此笔记本,以重现结果,同时理解执行步骤和文本中各种代码组件的解释。
-
导入图像和相关包:
!wget https://www.dropbox.com/s/rbajpdlh7efkdo1/male_female_face_images.zip !unzip male_female_face_images.zip !pip install -q --upgrade torch_snippets from torch_snippets import * device = "cuda" if torch.cuda.is_available() else "cpu" from torchvision.utils import make_grid from torch_snippets import * from PIL import Image import torchvision from torchvision import transforms import torchvision.utils as vutils -
创建数据集和数据加载器,如下所示:
- 存储男性和女性图像路径:
female_images = Glob('/content/females/*.jpg') male_images = Glob('/content/males/*.jpg')- 确保裁剪图像,仅保留面部并丢弃图像中的其他细节。首先,我们将下载级联滤波器(有关级联滤波器的更多信息可在 GitHub 上的 Using OpenCV Utilities for Image Analysis PDF 中找到,这将帮助识别图像中的面部):
face_cascade = cv2.CascadeClassifier(cv2.data.haarcascades + \ 'haarcascade_frontalface_default.xml')- 创建两个新文件夹(一个对应男性图像,另一个对应女性图像),并将所有裁剪后的面部图像转储到相应的文件夹中:
!mkdir cropped_faces_females !mkdir cropped_faces_males def crop_images(folder): images = Glob(folder+'/*.jpg') for i in range(len(images)): img = read(images[i],1) gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) faces = face_cascade.detectMultiScale(gray, 1.3, 5) for (x,y,w,h) in faces: img2 = img[y:(y+h),x:(x+w),:] cv2.imwrite('cropped_faces_'+folder+'/'+ \ str(i)+'.jpg',cv2.cvtColor(img2, cv2.COLOR_RGB2BGR)) crop_images('females') crop_images('males')- 指定要在每个图像上执行的转换:
transform=transforms.Compose([ transforms.Resize(64), transforms.CenterCrop(64), transforms.ToTensor(), transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))])- 创建
Faces数据集类,返回图像及其中人物的性别:
class Faces(Dataset): def __init__(self, folders): super().__init__() self.folderfemale = folders[0] self.foldermale = folders[1] self.images = sorted(Glob(self.folderfemale)) + \ sorted(Glob(self.foldermale)) def __len__(self): return len(self.images) def __getitem__(self, ix): image_path = self.images[ix] image = Image.open(image_path) image = transform(image) gender = np.where('female' in image_path,1,0) return image, torch.tensor(gender).long()- 定义
ds数据集和dataloader:
ds = Faces(folders=['cropped_faces_females', \ 'cropped_faces_males']) dataloader = DataLoader(ds, batch_size=64, \ shuffle=True, num_workers=8) -
定义权重初始化方法(就像我们在 Using DCGANs to generate face images 部分中所做的那样),以便随机初始化的权重值没有普遍变化:
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) -
如下定义
Discriminator模型类:- 定义模型架构:
class Discriminator(nn.Module): def __init__(self, emb_size=32): super(Discriminator, self).__init__() self.emb_size = 32 self.label_embeddings = nn.Embedding(2, self.emb_size) self.model = nn.Sequential( nn.Conv2d(3,64,4,2,1,bias=False), nn.LeakyReLU(0.2,inplace=True), nn.Conv2d(64,64*2,4,2,1,bias=False), nn.BatchNorm2d(64*2), nn.LeakyReLU(0.2,inplace=True), nn.Conv2d(64*2,64*4,4,2,1,bias=False), nn.BatchNorm2d(64*4), nn.LeakyReLU(0.2,inplace=True), nn.Conv2d(64*4,64*8,4,2,1,bias=False), nn.BatchNorm2d(64*8), nn.LeakyReLU(0.2,inplace=True), nn.Conv2d(64*8,64,4,2,1,bias=False), nn.BatchNorm2d(64), nn.LeakyReLU(0.2,inplace=True), nn.Flatten() ) self.model2 = nn.Sequential( nn.Linear(288,100), nn.LeakyReLU(0.2,inplace=True), nn.Linear(100,1), nn.Sigmoid() ) self.apply(weights_init)-
注意,在模型类中,我们有一个额外的参数
emb_size,它存在于条件 GAN 中,而不是 DCGAN 中。emb_size表示我们将输入类标签(我们要生成的图像类的类别)转换为的嵌入数量,这些嵌入存储为label_embeddings。我们将输入类标签从一热编码版本转换为更高维度的嵌入的原因是,模型具有更高的自由度来学习和调整以处理不同的类别。虽然模型类在很大程度上与我们在 DCGAN 中看到的相同,我们正在初始化另一个模型 (
model2),这个模型执行分类任务。在我们讨论完forward方法后,将详细介绍第二个模型如何帮助解释。在你阅读下面的forward方法和模型摘要后,你也会理解为什么self.model2有 288 个输入值。 -
定义
forward方法,接受图像及其标签作为输入:
def forward(self, input, labels): x = self.model(input) y = self.label_embeddings(labels) input = torch.cat([x, y], 1) final_output = self.model2(input) return final_output-
在定义的
forward方法中,我们获取第一个模型的输出 (self.model(input)) 和通过label_embeddings传递labels的输出,然后连接这些输出。接下来,我们通过早期定义的第二个模型 (self.model2) 传递连接的输出,它获取我们的判别器输出。 -
获取定义模型的摘要:
!pip install torch_summary from torchsummary import summary discriminator = Discriminator().to(device) summary(discriminator,torch.zeros(32,3,64,64).to(device), \ torch.zeros(32).long().to(device));
上述代码生成以下输出:
图 12.13:判别器架构摘要
注意,self.model2 接受 288 个值作为输入,因为 self.model 的输出每个数据点有 256 个值,然后与输入类标签的 32 个嵌入值连接,结果是 256 + 32 = 288 个输入值给 self.model2。
-
定义
Generator网络类:- 定义
__init__方法:
class Generator(nn.Module): def __init__(self, emb_size=32): super(Generator,self).__init__() self.emb_size = emb_size self.label_embeddings = nn.Embedding(2, self.emb_size)- 注意,在前述代码中,我们使用
nn.Embedding将 2D 输入(即类别)转换为 32 维向量(self.emb_size)。
self.model = nn.Sequential( nn.ConvTranspose2d(100+self.emb_size,\ 64*8,4,1,0,bias=False), nn.BatchNorm2d(64*8), nn.ReLU(True), nn.ConvTranspose2d(64*8,64*4,4,2,1,bias=False), nn.BatchNorm2d(64*4), nn.ReLU(True), nn.ConvTranspose2d(64*4,64*2,4,2,1,bias=False), nn.BatchNorm2d(64*2), nn.ReLU(True), nn.ConvTranspose2d(64*2,64,4,2,1,bias=False), nn.BatchNorm2d(64), nn.ReLU(True), nn.ConvTranspose2d(64,3,4,2,1,bias=False), nn.Tanh() )-
注意,在前述代码中,我们利用了
nn.ConvTranspose2d来向上缩放以获取图像作为输出。 -
应用权重初始化:
self.apply(weights_init)- 定义
forward方法,该方法接受噪声值(input_noise)和输入标签(labels)作为输入,并生成图像的输出:
def forward(self,input_noise,labels): label_embeddings = self.label_embeddings(labels).view(len(labels), \ self.emb_size,1, 1) input = torch.cat([input_noise, label_embeddings], 1) return self.model(input)- 获取所定义的
generator函数的摘要:
generator = Generator().to(device) summary(generator,torch.zeros(32,100,1,1).to(device), \ torch.zeros(32).long().to(device)); - 定义
前述代码生成如下输出:
图 12.14:生成器架构摘要
-
定义一个函数(
noise),用于生成具有 100 个值的随机噪声,并将其注册到设备上:def noise(size): n = torch.randn(size, 100, 1, 1, device=device) return n.to(device) -
定义训练鉴别器的函数:
discriminator_train_step:- 鉴别器接受四个输入——真实图像(
real_data)、真实标签(real_labels)、虚假图像(fake_data)和虚假标签(fake_labels):
def discriminator_train_step(real_data, real_labels, \ fake_data, fake_labels): d_optimizer.zero_grad()在这里,我们正在重置鉴别器对应的梯度。
- 计算在真实数据(
prediction_real通过discriminator网络时的损失值。将输出的损失值与期望值(torch.ones(len(real_data),1).to(device))进行比较,以获取error_real,然后执行反向传播:
prediction_real = discriminator(real_data, real_labels) error_real = loss(prediction_real, \ torch.ones(len(real_data),1).to(device)) error_real.backward()- 计算在虚假数据(
prediction_fake通过discriminator网络时的损失值。将输出的损失值与期望值(torch.zeros(len(fake_data),1).to(device))进行比较,以获取error_fake,然后执行反向传播:
prediction_fake = discriminator(fake_data, fake_labels) error_fake = loss(prediction_fake, \ torch.zeros(len(fake_data),1).to(device)) error_fake.backward()- 更新权重并返回损失值:
d_optimizer.step() return error_real + error_fake - 鉴别器接受四个输入——真实图像(
-
定义生成器的训练步骤,其中我们将虚假图像(
fake_data)与虚假标签(fake_labels)作为输入传递:def generator_train_step(fake_data, fake_labels): g_optimizer.zero_grad() prediction = discriminator(fake_data, fake_labels) error = loss(prediction, \ torch.ones(len(fake_data), 1).to(device)) error.backward() g_optimizer.step() return error
注意,generator_train_step函数类似于discriminator_train_step,唯一的区别在于这里期望输出为torch.ones(len(fake_data),1).to(device)),因为我们正在训练生成器。
-
定义
generator和discriminator模型对象、损失优化器和loss函数:discriminator = Discriminator().to(device) generator = Generator().to(device) loss = nn.BCELoss() d_optimizer = optim.Adam(discriminator.parameters(), \ lr=0.0002, betas=(0.5, 0.999)) g_optimizer = optim.Adam(generator.parameters(), \ lr=0.0002, betas=(0.5, 0.999)) fixed_noise = torch.randn(64, 100, 1, 1, device=device) fixed_fake_labels = torch.LongTensor([0]* (len(fixed_noise)//2) \ + [1]*(len(fixed_noise)//2)).to(device) loss = nn.BCELoss() n_epochs = 25 img_list = []
在前述代码中,在定义fixed_fake_labels时,我们指定一半图像对应于一类(类 0),其余图像对应于另一类(类 1)。此外,我们定义了fixed_noise,将用于从随机噪声生成图像。
-
在增加的 epochs (
n_epochs)上训练模型:- 指定
dataloader的长度:
log = Report(n_epochs) for epoch in range(n_epochs): N = len(dataloader)- 循环遍历图像批次及其标签:
for bx, (images, labels) in enumerate(dataloader):- 指定
real_data和real_labels:
real_data, real_labels = images.to(device), labels.to(device)- 初始化
fake_data和fake_labels:
fake_labels = torch.LongTensor(np.random.randint(0, \ 2,len(real_data))).to(device) fake_data=generator(noise(len(real_data)),fake_labels) fake_data = fake_data.detach()- 使用在步骤 7中定义的
discriminator_train_step函数训练鉴别器以计算鉴别器损失(d_loss):
d_loss = discriminator_train_step(real_data, \ real_labels, fake_data, fake_labels)- 重新生成虚假图像 (
fake_data) 和虚假标签 (fake_labels),并使用在 步骤 8 中定义的generator_train_step函数来计算生成器损失 (g_loss):
fake_labels = torch.LongTensor(np.random.randint(0, \ 2,len(real_data))).to(device) fake_data = generator(noise(len(real_data)), \ fake_labels).to(device) g_loss = generator_train_step(fake_data, fake_labels)- 记录如下损失指标:
pos = epoch + (1+bx)/N log.record(pos, d_loss=d_loss.detach(), \ g_loss=g_loss.detach(), end='\r') log.report_avgs(epoch+1) - 指定
-
一旦训练模型,生成男性和女性图像:
with torch.no_grad(): fake = generator(fixed_noise, fixed_fake_labels).detach().cpu() imgs = vutils.make_grid(fake, padding=2, \ normalize=True).permute(1,2,0) img_list.append(imgs) show(imgs, sz=10)
在上述代码中,我们将噪声 (fixed_noise) 和标签 (fixed_fake_labels) 传递给生成器,以获取在训练模型 25 个周期后的 fake 图像,结果如下:
图 12.15:生成的男性和女性面孔
从上述图像中,我们可以看到前 32 张图像对应于男性图像,接下来的 32 张图像对应于女性图像,这证实了条件 GAN 的表现符合预期。
总结
在本章中,我们学习了如何利用两个不同的神经网络使用 GAN 生成手写数字的新图像。接下来,我们使用 DCGAN 生成逼真的面孔。最后,我们学习了条件 GAN,这有助于我们生成特定类别的图像。尽管我们使用不同的技术生成图像,但我们仍然发现生成的图像不够逼真。此外,在条件 GAN 中,虽然我们通过指定要生成的图像类别来生成图像,但我们仍无法执行图像翻译,即要求替换图像中的一个对象,并保留其他一切。此外,我们还没有一种图像生成机制,其中要生成的类别(风格)更无监督。
在下一章中,我们将学习使用一些最新变体的 GAN 生成更逼真的图像。此外,我们将学习以更无监督的方式生成不同风格的图像。
问题
-
如果生成器和鉴别器模型的学习率很高会发生什么?
-
在生成器和鉴别器都经过充分训练的情况下,给定图像是真实的概率是多少?
-
为什么在生成图像时要使用
ConvTranspose2d? -
为什么在条件 GAN 中的嵌入大小比类别数高?
-
如何生成带胡须的男性图像?
-
为什么在生成器的最后一层使用 Tanh 激活而不是 ReLU 或 sigmoid?
-
即使我们没有对生成的数据进行反归一化,为什么我们仍然得到逼真的图像?
-
如果在训练 GAN 前不裁剪与图像对应的面部会发生什么?
-
在训练生成器时,为什么鉴别器的权重不会得到更新(因为
generator_train_step函数涉及鉴别器网络)? -
在训练鉴别器时为什么要获取真实图像和虚假图像的损失,而在训练生成器时只获取虚假图像的损失?
在 Discord 上了解更多信息
加入我们社区的 Discord 空间,与作者和其他读者进行讨论: