PyTorch-1-x-生成对抗网络实用指南-全-

PyTorch 1.x 生成对抗网络实用指南(全)

原文:annas-archive.org/md5/4ceb429939c3f8c63a03485ea60a4ebe

译者:飞龙

协议:CC BY-NC-SA 4.0

序言

随着研究与开发的持续进展,生成对抗网络GANs)已成为深度学习领域的下一个重要突破。本书重点介绍了 GAN 相对于传统生成模型的主要改进,并通过动手实例向你展示如何最大化 GAN 的优势。

本书将帮助你理解如何利用 PyTorch 工作的 GAN 架构。你将熟悉这个最灵活的深度学习工具包,并用它将想法转化为实际可运行的代码。你将通过样本生成方法将 GAN 模型应用于计算机视觉、多媒体和自然语言处理等领域。

本书适合的人群

本书面向那些希望通过 PyTorch 1.0 实现 GAN 模型的机器学习从业人员和深度学习研究人员。你将通过实际案例熟悉最先进的 GAN 架构。掌握 Python 编程语言的基础知识对于理解本书中的概念是必要的。

本书内容概述

第一章,生成对抗网络基础,利用了 PyTorch 的新特性。你还将学习如何用 NumPy 构建一个简单的 GAN 来生成正弦信号。

第二章,开始使用 PyTorch 1.3,介绍了如何安装 CUDA,以便利用 GPU 加速训练和评估。我们还将详细介绍在 Windows 和 Ubuntu 上安装 PyTorch 的步骤,并从源代码构建 PyTorch。

第三章,模型设计与训练最佳实践,探讨了模型架构的整体设计以及选择所需卷积操作时需要遵循的步骤。

第四章,用 PyTorch 构建你的第一个 GAN,介绍了一种经典且高效的 GAN 模型——DCGAN,用于生成 2D 图像。你还将了解 DCGAN 的架构,并学习如何训练和评估它们。接下来,你将学习如何使用 DCGAN 生成手写数字和人脸,并了解如何利用自编码器进行对抗学习。你还将学习如何高效地组织源代码,以便于调整和扩展。

第五章,基于标签信息生成图像,展示了如何使用 CGAN 根据给定标签生成图像,以及如何通过自编码器实现对抗学习。

第六章,图像到图像的翻译及其应用,展示了如何使用逐像素标签信息,利用 pix2pix 进行图像到图像的翻译,以及如何使用 pix2pixHD 翻译高分辨率图像。你还将学习如何灵活设计模型架构来实现你的目标,包括生成更大的图像和在不同类型的图像之间转移纹理。

第七章,使用 GAN 进行图像修复,向你展示如何使用 SRGAN 进行图像超分辨率,将低分辨率图像生成高分辨率图像,以及如何使用数据预取器加速数据加载,提高 GPU 训练效率。你还将学习如何训练 GAN 模型来进行图像修复,并填补图像中缺失的部分。

第八章,训练你的 GAN 打破不同模型,探讨了对抗样本的基本原理以及如何使用FGSM快速梯度符号法)攻击并混淆卷积神经网络(CNN)模型。之后,我们将了解如何使用 accimage 库进一步加速图像加载,并训练一个 GAN 模型来生成对抗样本,欺骗图像分类器。

第九章,从描述文本生成图像,提供了关于词嵌入的基本知识以及它们在自然语言处理(NLP)领域中的应用。你还将学习如何设计一个文本到图像的生成对抗网络(GAN)模型,根据一段描述文本生成图像。

第十章,使用 GAN 进行序列合成,涵盖了自然语言处理领域常用的技术,如循环神经网络(RNN)和长短期记忆网络(LSTM)。你还将学习强化学习的一些基本概念,并了解它与监督学习(如基于 SGD 的 CNN)有何不同。你还将学习如何使用 SEGAN 去除背景噪声并提升语音音频的质量。

第十一章,使用 GAN 重建 3D 模型,展示了计算机图形学CG)中如何表示 3D 物体。我们还将探讨计算机图形学的基本概念,包括相机和投影矩阵。你将学习如何构建一个具有 3D 卷积的 3D-GAN 模型,并训练它来生成 3D 物体。

为了从本书中获得最大收益

你应该具备基本的 Python 和 PyTorch 知识。

下载示例代码文件

你可以从 www.packt.com 账户下载本书的示例代码文件。如果你是从其他地方购买本书的,可以访问 www.packtpub.com/support 并注册,将文件直接通过电子邮件发送给你。

你可以通过以下步骤下载代码文件:

  1. www.packt.com 登录或注册。

  2. 选择支持标签。

  3. 点击代码下载。

  4. 在搜索框中输入书名并按照屏幕上的指示操作。

文件下载完成后,请确保使用最新版本的工具解压或提取文件夹:

  • Windows 版 WinRAR/7-Zip

  • Mac 版 Zipeg/iZip/UnRarX

  • Linux 版 7-Zip/PeaZip

本书的代码包也托管在 GitHub 上,链接为 github.com/PacktPublishing/Hands-On-Generative-Adversarial-Networks-with-PyTorch-1.x。如果代码有更新,将会更新至现有的 GitHub 库。

我们还提供来自丰富书籍和视频目录的其他代码包,访问github.com/PacktPublishing/。快来看看吧!

下载彩色图片

我们还提供了包含本书中使用的截图/图表的彩色图片的 PDF 文件。你可以在此下载:www.packtpub.com/sites/default/files/downloads/9781789530513_ColorImages.pdf

使用的约定

本书中使用了多种文本约定。

CodeInText:表示文本中的代码词汇、数据库表名、文件夹名称、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名。例如:“将下载的WebStorm-10*.dmg磁盘映像文件挂载为系统中的另一个磁盘。”

代码块设置如下:

    # Derivative with respect to w3
    d_w3 = np.matmul(np.transpose(self.x2), delta)
    # Derivative with respect to b3
    d_b3 = delta.copy()

任何命令行输入或输出如下所示:

$ python -m torch.distributed.launch --nproc_per_node=NUM_GPUS YOUR_SCRIPT.py --YOUR_ARGUMENTS

粗体:表示一个新术语、重要词汇或你在屏幕上看到的词汇。例如,菜单或对话框中的文字会以这种方式出现在文本中。示例:“从管理面板中选择系统信息。”

警告或重要提示如下所示。

提示和技巧如下所示。

联系我们

我们始终欢迎读者的反馈。

一般反馈:如果你对本书的任何方面有疑问,请在邮件主题中提到书名,并通过customercare@packtpub.com联系我们。

勘误表:尽管我们已尽最大努力确保内容的准确性,但错误仍然会发生。如果你在本书中发现错误,我们将感激你向我们报告。请访问 www.packtpub.com/support/errata,选择你的书籍,点击勘误表提交表单链接,并输入详细信息。

盗版:如果你在互联网上遇到我们作品的任何非法复制形式,我们将感激你提供该位置地址或网站名称。请通过copyright@packt.com与我们联系,并附上相关材料的链接。

如果你有兴趣成为作者:如果你在某个领域有专业知识,并且有兴趣撰写或参与书籍的编写,请访问 authors.packtpub.com

评论

请留下您的评论。一旦您阅读并使用了本书,为什么不在购买书籍的网站上留下一条评论呢?潜在的读者可以看到并使用您的客观意见来做出购买决策,我们在 Packt 可以了解您对我们产品的看法,我们的作者也可以看到您对他们书籍的反馈。谢谢!

关于 Packt 的更多信息,请访问 packt.com

第一部分:GAN 和 PyTorch 简介

在本节中,您将了解 GAN 的基本概念,如何安装 PyTorch 1.0,以及如何使用 PyTorch 构建您自己的模型。

本节包含以下章节:

  • 第一章,生成对抗网络基础

  • 第二章,PyTorch 1.3 入门

  • 第三章,模型设计与训练的最佳实践

第一章:生成对抗网络基础

生成对抗网络GANs)在 机器学习ML)社区中掀起了一场革命性的风暴。它们在一定程度上改变了人们解决 计算机视觉CV)和 自然语言处理NLP)实际问题的方式。在我们深入探讨这场风暴之前,让我们先为你准备一些关于 GAN 的基础知识。

在本章中,你将理解对抗学习背后的理念和 GAN 模型的基本组成部分。你还将简要了解 GAN 的工作原理,以及如何使用 NumPy 构建 GAN。

在开始探索 PyTorch 中的新特性之前,我们将首先学习如何用 NumPy 构建一个简单的 GAN 来生成正弦信号,以便你能深刻理解 GAN 的机制。在本章结束时,你可以稍微放松一下,我们将通过多个实例展示 GAN 如何在 CV 和 NLP 领域解决实际问题。

本章将涵盖以下主题:

  • 机器学习基础

  • 生成器和判别器网络

  • GAN 的作用是什么?

  • 参考资料和有用的阅读清单

机器学习基础

为了介绍 GAN 的工作原理,我们可以用一个类比:

很久很久以前,在一个岛屿上有两个相邻的王国,一个叫做 Netland,另一个叫做 Ganland。两个王国都生产优质的酒、盔甲和武器。在 Netland,国王要求专门制造盔甲的铁匠在城堡的东角工作,而制造剑的铁匠则在西侧工作,以便贵族和骑士可以选择王国提供的最佳装备。而 Ganland 的国王则把所有铁匠安排在同一个角落,并要求盔甲制造者和剑匠每天互相测试他们的作品。如果剑能突破盔甲,那么这把剑就会以好价钱卖出,而盔甲则会被熔化重新铸造。如果不能,剑将被重做,人们也会争相购买盔甲。一天,两个国王因哪一个王国酿造的酒更好而争论不休,直到争吵升级为战争。尽管 Ganland 的士兵数量较少,但他们身穿经过多年日常对抗测试改进的盔甲,手持锐利的剑,而 Netland 的士兵既无法突破他们强大的盔甲,也无法抵挡他们锋利的剑。最终,尽管 Netland 的国王不情愿,还是承认 Ganland 的酒和铁匠技艺更胜一筹。

机器学习 – 分类与生成

机器学习(ML)是研究如何从数据中识别模式,而无需人为硬编码规则。模式识别(Pattern RecognitionPR)是自动发现原始数据中的相似性和差异性,这是实现人工智能(AI)的关键方式,人工智能这一概念最初只存在于小说和电影中。虽然我们很难确切预测未来何时会出现真正的人工智能,但近年来机器学习的发展已经让我们充满信心。机器学习已经在许多领域得到了广泛应用,如计算机视觉(CV)、自然语言处理(NLP)、推荐系统、智能交通系统(ITS)、医疗诊断、机器人技术和广告。

一个机器学习(ML)模型通常被描述为一个接受数据并根据其包含的参数给出某些输出的系统。模型的学习实际上是调整参数以获得更好的输出。如下面的图所示,我们将训练数据输入模型,得到一个输出。然后,我们使用一个或多个标准来衡量输出,以判断我们的模型表现如何。在这一步,关于训练数据的一组期望输出(或实际结果)将非常有帮助。如果训练中使用了实际结果数据,这个过程通常被称为监督学习。如果没有使用,则通常被视为无监督学习

我们根据模型的表现(换句话说,是否给出我们想要的结果)不断调整模型的参数,以便未来能够产生更好的结果。这个过程叫做模型训练。模型的训练时间由我们决定。通常,我们在经过一定数量的迭代后,或者当模型的表现足够好时,就会停止训练。训练过程完成后,我们将训练好的模型应用于对新数据(测试数据)的预测,这个过程叫做模型测试。有时,人们会使用不同的数据集进行训练和测试,以观察模型在遇到从未见过的样本时的表现,这被称为泛化能力。有时,模型评估(model evaluation)是一个额外的步骤,当模型的参数过于复杂,我们需要使用另一组数据来验证我们的模型或训练过程是否设计得当。

一个典型的机器学习系统,包括模型训练和测试

这个模型可以解决哪些类型的问题,实质上取决于我们想要的输入和输出数据的类型。例如,分类模型接受任意维度的数据输入(如音频、文本、图像或视频),并给出一维的输出(表示预测标签的单一值)。而生成模型通常接受一维输入(潜在向量),并生成高维的输出(图像、视频或 3D 模型)。它将低维数据映射到高维数据,同时尽力使输出样本看起来尽可能真实。然而,值得指出的是,在后续章节中,我们将遇到不遵循这一规则的生成模型。直到第五章,基于标签信息生成图像,这是一个需要牢记的简单规则。

说到 AI,社区里有两派信徒。符号主义者承认人类经验和知识的必要性。他们认为低级模式构成了基于人类明确规则的高级决策。连接主义者则认为,AI 可以通过类似于人类神经系统的类比网络实现,调整简单神经元之间的连接是这个系统的关键。显然,深度学习的爆炸性发展为连接主义者的一方加分。你怎么看?

引入对抗学习

传统上,生成问题是通过基于统计的方法解决的,如玻尔兹曼机马尔可夫链变分编码器。尽管这些方法在数学上非常深奥,但生成的样本还远未完美。分类模型将高维数据映射到低维数据,而生成模型通常将低维数据映射到高维数据。两者领域的人们一直在努力改进他们的模型。让我们回顾一下那个虚构的开场故事。我们能让这两种不同的模型相互对抗并同时提升自己吗?如果我们把生成模型的输出作为分类模型的输入,就可以用分类模型(剑)来衡量生成模型(盔甲)的性能。与此同时,我们也可以通过将生成样本(盔甲)与真实样本一起输入,来改善分类模型(剑),因为我们都同意更多的数据通常有利于机器学习模型的训练。

两个模型相互削弱并因此相互提升的训练过程被称为对抗学习。如下面的图所示,模型 A 和 B 有完全相反的目标(例如分类和生成)。然而,在每一步训练过程中,模型 A 的输出提升了模型 B,而模型 B 的输出又提升了模型 A:

一个典型的对抗学习系统

生成对抗网络(GANs) 是基于这一理念设计的,该理念由 Goodfellow、Pouget-Abadie、Mirza 等人在 2014 年提出。如今,生成对抗网络已成为机器学习领域中最为繁荣和流行的方法,用于合成音频、文本、图像、视频和 3D 模型。在本书中,我们将带您了解不同类型生成对抗网络的基本组成部分和机制,并学习如何使用它们解决各种实际问题。在接下来的部分,我们将介绍生成对抗网络的基本结构,以便向您展示它们是如何以及为何能如此有效地工作的。

生成器和判别器网络

在这里,我们将展示生成对抗网络的基本组件,并解释它们如何相互作用或对立,以实现我们生成真实样本的目标。以下是生成对抗网络的典型结构示意图。它包含两个不同的网络:生成器网络和判别器网络。生成器 网络通常以随机噪声为输入,并生成假样本。我们的目标是让这些假样本尽可能接近真实样本。此时,判别器就发挥了作用。判别器 实际上是一个分类网络,它的任务是判断给定的样本是假的还是现实的。生成器尽力欺骗并混淆判别器,使其做出错误的判断,而判别器则尽力区分真假样本。

在这个过程中,通过利用真假样本之间的差异来改进生成器。因此,生成器在生成看起来更真实的样本方面会变得越来越好,而判别器在识别这些样本时也会变得越来越强。由于使用真实样本来训练判别器,因此这个训练过程是有监督的。尽管生成器在没有真实标签的情况下总是生成假样本,生成对抗网络的整体训练仍然是有监督的

生成对抗网络的基本过程

生成对抗网络的数学背景

让我们看看这个过程背后的数学原理,以便更好地理解其机制。假设 分别代表生成器和判别器网络。假设 代表系统的性能标准。优化目标描述如下:

在这个方程中,是实际样本,是生成的样本,用来生成假样本的随机噪声。是对的期望,表示对所有样本中任何函数的平均值。

如前所述,判别器的目标,,是最大化真实样本的预测置信度。因此, 需要通过梯度上升(目标中的算子)进行训练。更新规则如下:

在这个公式中,的参数(例如卷积核和全连接层中的权重),是小批量的大小(简称批量大小),是小批量中样本的索引。这里,我们假设使用小批量来输入训练数据,这是一种相当合理的假设,因为这是最常用且经验上有效的策略。因此,梯度需要在样本上进行平均。

向模型提供训练数据有三种不同的方式:(1)一次一个样本,这通常称为随机(例如,随机梯度下降SGD);(2)一次几个样本,这称为小批量;(3)一次所有样本,这实际上称为批量。随机方式引入了过多的随机性,导致一个坏样本可能会危及之前几个训练步骤的良好效果。全批量需要过多的内存来计算。因此,在本书中,我们通过小批量将数据提供给所有模型,尽管我们可能懒散地称其为批量。

生成器网络的目标,,是欺骗判别器,,让 认为生成的样本是真实的。因此, 的训练就是最大化 或最小化 。因此, 需要通过 梯度下降 进行训练(目标函数中的 操作符)。 的更新规则如下:

在这个公式中, 的参数, 是迷你批次的大小, 是迷你批次中样本的索引。

如果你不熟悉梯度下降(GD)的概念,可以把它想象成一个小男孩在崎岖的地形上踢一个粘乎乎的球。小男孩希望球停在最低的坑里,这样他就可以结束今天的任务回家了。球是粘的,所以它在落地后不会滚动,即使是在斜坡上。因此,球停在哪里是由小男孩踢球的方向和力度决定的。小男孩踢球的力度由步长(或 学习率)来描述。踢球的方向则由他脚下地形的特征决定。一个有效的选择是下坡方向,即损失函数相对于参数的负梯度。因此,我们通常使用梯度下降来最小化目标函数。然而,男孩太专注于球了,他只盯着球看,拒绝抬头去寻找更广范围内的最低坑。因此,梯度下降方法有时效率不高,因为它可能需要很长时间才能到达底部。我们将在 第三章 模型设计和训练的最佳实践 中介绍一些如何提高梯度下降效率的技巧。梯度上升 是梯度下降的相反操作,用于寻找最高峰。

使用 NumPy 训练正弦信号生成器

对一些人来说,数学可能比一大段代码还要让人困惑。现在,让我们看一些代码来消化我们刚才给你们抛出的方程式。在这里,我们将使用 Python 实现一个非常简单的对抗学习示例,用来生成正弦(sin)信号。

在以下示例中,我们将仅使用 NumPy,一个强大的线性代数 Python 库来实现 GAN 模型。我们需要自己计算梯度,以便你能深入理解在流行的深度学习工具包(如 PyTorch)背后可能发生的事情。放心,未来章节我们不再需要手动计算梯度,因为我们可以使用 PyTorch 提供的强大计算图来自动为我们计算梯度!

设计网络架构

生成器网络的架构如以下图所示。它以一个一维随机值作为输入,并给出一个十维向量作为输出。它有两个隐藏层,每个隐藏层包含 10 个神经元。每一层的计算都是矩阵乘法。因此,该网络实际上是一个多层感知器MLP):

生成器网络的结构

判别器网络的架构如以下图所示。它以一个十维向量作为输入,并给出一个一维值作为输出。该输出是对输入样本的预测标签(真实或假)。判别器网络同样是一个 MLP,具有两个隐藏层,每个隐藏层包含 10 个神经元:

判别器网络的结构

定义激活函数和损失函数

我们将仅使用 NumPy (www.numpy.org)来计算和训练我们的 GAN 模型(可选地使用 Matplotlib(matplotlib.org)来可视化信号)。如果你的机器上还没有 Python 环境,请参考第二章,PyTorch 1.3 入门,了解如何设置 Python 环境。如果你的 Python 环境已正确设置,让我们开始实际代码吧。

以下所有代码可以放入一个 simple*.*py 文件中(例如 simple_gan.py)。我们将一步一步地查看代码:

  1. 导入 NumPy 库:
import numpy as np
  1. 定义模型中所需的一些常量变量:
Z_DIM = 1
G_HIDDEN = 10
X_DIM = 10
D_HIDDEN = 10

step_size_G = 0.01
step_size_D = 0.01
ITER_NUM = 50000

GRADIENT_CLIP = 0.2
WEIGHT_CLIP = 0.25
  1. 定义我们想要估计的真实正弦样本(使用 numpy.sin):
def get_samples(random=True):
    if random:
        x0 = np.random.uniform(0, 1)
        freq = np.random.uniform(1.2, 1.5)
        mult = np.random.uniform(0.5, 0.8)
    else:
        x0 = 0
        freq = 0.2
        mult = 1
    signal = [mult * np.sin(x0+freq*i) for i in range(X_DIM)]
    return np.array(signal)

在之前的代码片段中,我们使用了一个 bool 变量 random 来引入真实样本中的随机性,因为现实生活中的数据具有这种特性。真实样本如下所示(50 个样本,random=True):

真实正弦样本

  1. 定义激活函数及其导数。如果你不熟悉激活函数的概念,记住它们的作用是调整一层的输出,以便其下一层能够更好地理解这些输出值:
def ReLU(x):
    return np.maximum(x, 0.)

def dReLU(x):
    return ReLU(x)

def LeakyReLU(x, k=0.2):
    return np.where(x >= 0, x, x * k)

def dLeakyReLU(x, k=0.2):
    return np.where(x >= 0, 1., k)

def Tanh(x):
    return np.tanh(x)

def dTanh(x):
    return 1\. - Tanh(x)**2

def Sigmoid(x):
    return 1\. / (1\. + np.exp(-x))

def dSigmoid(x):
    return Sigmoid(x) * (1\. - Sigmoid(x))

  1. 定义一个 helper 函数来初始化层的参数:
def weight_initializer(in_channels, out_channels):
    scale = np.sqrt(2\. / (in_channels + out_channels))
    return np.random.uniform(-scale, scale, (in_channels, out_channels))
  1. 定义 loss 函数(包括前向和反向):
class LossFunc(object):
    def __init__(self):
        self.logit = None
        self.label = None

    def forward(self, logit, label):
        if logit[0, 0] < 1e-7:
            logit[0, 0] = 1e-7
        if 1\. - logit[0, 0] < 1e-7:
            logit[0, 0] = 1\. - 1e-7
        self.logit = logit
        self.label = label
        return - (label * np.log(logit) + (1-label) * np.log(1-logit))

    def backward(self):
        return (1-self.label) / (1-self.logit) - self.label / self.logit

这被称为二元交叉熵,通常用于二分类问题(即一个样本要么属于类别 A,要么属于类别 B)。有时,某个网络训练得过好,以至于判别器的sigmoid输出可能过于接近 0 或 1。两种情况都会导致log函数的数值错误。因此,我们需要限制输出值的最大值和最小值。

进行前向传播和反向传播

现在,让我们创建生成器和判别器网络。我们将代码放在与simple_gan.py相同的文件中:

  1. 定义生成器网络的参数:
class Generator(object):
    def __init__(self):
        self.z = None
        self.w1 = weight_initializer(Z_DIM, G_HIDDEN)
        self.b1 = weight_initializer(1, G_HIDDEN)
        self.x1 = None
        self.w2 = weight_initializer(G_HIDDEN, G_HIDDEN)
        self.b2 = weight_initializer(1, G_HIDDEN)
        self.x2 = None
        self.w3 = weight_initializer(G_HIDDEN, X_DIM)
        self.b3 = weight_initializer(1, X_DIM)
        self.x3 = None
        self.x = None

我们跟踪所有层的输入和输出,因为我们需要它们来计算导数,以便稍后更新参数。

  1. 定义前向计算(根据随机噪声生成样本):
    def forward(self, inputs):
        self.z = inputs.reshape(1, Z_DIM)
        self.x1 = np.matmul(self.z, self.w1) + self.b1
        self.x1 = ReLU(self.x1)
        self.x2 = np.matmul(self.x1, self.w2) + self.b2
        self.x2 = ReLU(self.x2)
        self.x3 = np.matmul(self.x2, self.w3) + self.b3
        self.x = Tanh(self.x3)
        return self.x

基本上是重复相同的计算过程三次。每一层都按照这个公式计算它的输出:

在这个方程中,表示某一层的输出值,f表示激活函数,下标l表示层的索引。这里我们在隐藏层使用ReLU,在输出层使用Tanh

现在是定义生成器网络的反向计算的时候了(计算导数并更新参数)。这部分代码有点长,实际上是重复同样的过程三次:

    1. 计算损失相对于此层输出的导数(例如,相对于outputx2的导数)。

    2. 计算损失相对于参数的导数(例如,相对于w3b3的导数)。

    3. 使用导数更新参数。

    4. 将梯度传递给前一层。导数计算如下:

在这个过程中,损失相对于输出的导数,在代码中表示为delta,是关键,它用于从层l+1传播梯度到层l。因此,这个过程被称为反向传播。从层l+1到层l的传播过程描述如下:

  1. 计算相对于输出的导数:
    def backward(self, outputs):
        # Derivative with respect to output
        delta = outputs
        delta *= dTanh(self.x)

计算相对于第三层参数的导数:

        # Derivative with respect to w3
        d_w3 = np.matmul(np.transpose(self.x2), delta)
        # Derivative with respect to b3
        d_b3 = delta.copy()

将梯度传递给第二层:

        # Derivative with respect to x2
        delta = np.matmul(delta, np.transpose(self.w3))

并且更新第三层的参数:

        # Update w3
        if (np.linalg.norm(d_w3) > GRADIENT_CLIP):
            d_w3 = GRADIENT_CLIP / np.linalg.norm(d_w3) * d_w3
        self.w3 -= step_size_G * d_w3
        self.w3 = np.maximum(-WEIGHT_CLIP, np.minimum(WEIGHT_CLIP,  
         self.w3))

        # Update b3
        self.b3 -= step_size_G * d_b3
        self.b3 = np.maximum(-WEIGHT_CLIP, np.minimum(WEIGHT_CLIP,  
         self.b3))
        delta *= dReLU(self.x2)
  1. 更新第二层的参数并将梯度传递给第一层:
        # Derivative with respect to w2
        d_w2 = np.matmul(np.transpose(self.x1), delta)
        # Derivative with respect to b2
        d_b2 = delta.copy()

        # Derivative with respect to x1
        delta = np.matmul(delta, np.transpose(self.w2))

        # Update w2
        if (np.linalg.norm(d_w2) > GRADIENT_CLIP):
            d_w2 = GRADIENT_CLIP / np.linalg.norm(d_w2) * d_w2
        self.w2 -= step_size_G * d_w2
        self.w2 = np.maximum(-WEIGHT_CLIP, np.minimum(WEIGHT_CLIP, 
          self.w2))

        # Update b2
        self.b2 -= step_size_G * d_b2
        self.b2 = np.maximum(-WEIGHT_CLIP, np.minimum(WEIGHT_CLIP, 
          self.b2))
        delta *= dReLU(self.x1)
  1. 更新第一层的参数:
        # Derivative with respect to w1
        d_w1 = np.matmul(np.transpose(self.z), delta)
        # Derivative with respect to b1
        d_b1 = delta.copy()

        # No need to calculate derivative with respect to z
        # Update w1
        if (np.linalg.norm(d_w1) > GRADIENT_CLIP):
            d_w1 = GRADIENT_CLIP / np.linalg.norm(d_w1) * d_w1
        self.w1 -= step_size_G * d_w1
        self.w1 = np.maximum(-WEIGHT_CLIP, np.minimum(WEIGHT_CLIP, 
          self.w1))

        # Update b1
        self.b1 -= step_size_G * d_b1
        self.b1 = np.maximum(-WEIGHT_CLIP, np.minimum(WEIGHT_CLIP, 
          self.b1))

你会注意到以下代码与前面的代码相似。这里只是为了指出这些代码行有助于保持数据的稳定性。你不必添加这三行:

if (np.linalg.norm(d_w3) > GRADIENT_CLIP):
    d_w3 = GRADIENT_CLIP / np.linalg.norm(d_w3) * d_w3
self.w3 = np.maximum(-WEIGHT_CLIP, np.minimum(WEIGHT_CLIP, self.w3))

包括这段代码是因为 GAN 的训练可能非常不稳定,我们需要裁剪梯度和参数以确保稳定的训练过程。

我们将在第三章中详细阐述激活函数、损失函数、权重初始化、梯度裁剪、权重裁剪等话题,模型设计和训练的最佳实践。这些对于稳定和提高 GAN 的训练非常有用。

现在,让我们定义判别器网络:

class Discriminator(object):
    def __init__(self):
        self.x = None
        self.w1 = weight_initializer(X_DIM, D_HIDDEN)
        self.b1 = weight_initializer(1, D_HIDDEN)
        self.y1 = None
        self.w2 = weight_initializer(D_HIDDEN, D_HIDDEN)
        self.b2 = weight_initializer(1, D_HIDDEN)
        self.y2 = None
        self.w3 = weight_initializer(D_HIDDEN, 1)
        self.b3 = weight_initializer(1, 1)
        self.y3 = None
        self.y = None

现在定义它的前向计算(根据输入样本预测标签):

    def forward(self, inputs):
        self.x = inputs.reshape(1, X_DIM)
        self.y1 = np.matmul(self.x, self.w1) + self.b1
        self.y1 = LeakyReLU(self.y1)
        self.y2 = np.matmul(self.y1, self.w2) + self.b2
        self.y2 = LeakyReLU(self.y2)
        self.y3 = np.matmul(self.y2, self.w3) + self.b3
        self.y = Sigmoid(self.y3)
        return self.y

这里,我们使用 LeakyReLU 作为隐藏层的激活函数,输出层使用 sigmoid 激活函数。现在,让我们定义判别器网络的反向计算(计算导数并更新参数):

    def backward(self, outputs, apply_grads=True):
        # Derivative with respect to output
        delta = outputs
        delta *= dSigmoid(self.y)
        # Derivative with respect to w3
        d_w3 = np.matmul(np.transpose(self.y2), delta)
        # Derivative with respect to b3
        d_b3 = delta.copy()
        # Derivative with respect to y2
        delta = np.matmul(delta, np.transpose(self.w3))
        if apply_grads:
            # Update w3
            if np.linalg.norm(d_w3) > GRADIENT_CLIP:
                d_w3 = GRADIENT_CLIP / np.linalg.norm(d_w3) * d_w3
            self.w3 += step_size_D * d_w3
            self.w3 = np.maximum(-WEIGHT_CLIP, np.minimum(WEIGHT_CLIP,  
              self.w3))
            # Update b3
            self.b3 += step_size_D * d_b3
            self.b3 = np.maximum(-WEIGHT_CLIP, np.minimum(WEIGHT_CLIP,  
              self.b3))
        delta *= dLeakyReLU(self.y2)
        # Derivative with respect to w2
        d_w2 = np.matmul(np.transpose(self.y1), delta)
        # Derivative with respect to b2
        d_b2 = delta.copy()
        # Derivative with respect to y1
        delta = np.matmul(delta, np.transpose(self.w2))
        if apply_grads:
            # Update w2
            if np.linalg.norm(d_w2) > GRADIENT_CLIP:
                d_w2 = GRADIENT_CLIP / np.linalg.norm(d_w2) * d_w2
            self.w2 += step_size_D * d_w2
            self.w2 = np.maximum(-WEIGHT_CLIP, np.minimum(WEIGHT_CLIP, 
              self.w2))
            # Update b2
            self.b2 += step_size_D * d_b2
            self.b2 = np.maximum(-WEIGHT_CLIP, np.minimum(WEIGHT_CLIP, 
              self.b2))
        delta *= dLeakyReLU(self.y1)
        # Derivative with respect to w1
        d_w1 = np.matmul(np.transpose(self.x), delta)
        # Derivative with respect to b1
        d_b1 = delta.copy()
        # Derivative with respect to x
        delta = np.matmul(delta, np.transpose(self.w1))
        # Update w1
        if apply_grads:
            if np.linalg.norm(d_w1) > GRADIENT_CLIP:
                d_w1 = GRADIENT_CLIP / np.linalg.norm(d_w1) * d_w1
            self.w1 += step_size_D * d_w1
            self.w1 = np.maximum(-WEIGHT_CLIP, np.minimum(WEIGHT_CLIP, 
              self.w1))
            # Update b1
            self.b1 += step_size_D * d_b1
            self.b1 = np.maximum(-WEIGHT_CLIP, np.minimum(WEIGHT_CLIP, 
              self.b1))
        return delta

请注意,判别器反向计算的主要区别在于它是通过梯度上升进行训练的。因此,为了更新它的参数,我们需要加上梯度。因此,在前面的代码中,你会看到类似这样的代码行,它为我们处理了这一过程:

self.w3 += step_size_D * d_w3

训练我们的 GAN 模型

现在所有必要的组件都已定义,我们可以开始训练我们的 GAN 模型:

G = Generator()
D = Discriminator()
criterion = LossFunc()

real_label = 1
fake_label = 0

for itr in range(ITER_NUM):
    # Update D with real data
    x_real = get_samples(True)
    y_real = D.forward(x_real)
    loss_D_r = criterion.forward(y_real, real_label)
    d_loss_D = criterion.backward()
    D.backward(d_loss_D)

    # Update D with fake data
    z_noise = np.random.randn(Z_DIM)
    x_fake = G.forward(z_noise)
    y_fake = D.forward(x_fake)
    loss_D_f = criterion.forward(y_fake, fake_label)
    d_loss_D = criterion.backward()
    D.backward(d_loss_D)

    # Update G with fake data
    y_fake_r = D.forward(x_fake)
    loss_G = criterion.forward(y_fake_r, real_label)
    d_loss_G = D.backward(loss_G, apply_grads=False)
    G.backward(d_loss_G)
    loss_D = loss_D_r + loss_D_f
    if itr % 100 == 0:
        print('{} {} {}'.format(loss_D_r.item((0, 0)), loss_D_f.item((0, 0)), loss_G.item((0, 0))))

如前面的代码所示,GAN 模型的训练主要分为 3 个步骤:

  1. 用真实数据训练判别器(并将其识别为真实数据)。

  2. 用虚假数据训练判别器(并将其识别为虚假数据)。

  3. 用虚假数据训练生成器(并将其识别为真实数据)。

前两步教判别器如何区分真实数据和虚假数据。第三步教生成器如何通过生成与真实数据相似的虚假数据来欺骗判别器。这是对抗学习的核心思想,也是 GAN 能够生成相对逼真的音频、文本、图像和视频的原因。

这里,我们使用 SGD 训练模型 50,000 次。如果你感兴趣,欢迎实现 mini-batch GD,看看它是否能在更短的时间内产生更好的结果。你也可以更改网络架构(例如层数、每层的神经元数量以及数据维度 X_DIM),看看超参数变化对结果的影响。

最后,让我们使用 Matplotlib 来可视化生成的样本:

import matplotlib.pyplot as plt
x_axis = np.linspace(0, 10, 10)
for i in range(50):
    z_noise = np.random.randn(Z_DIM)
    x_fake = G.forward(z_noise)
    plt.plot(x_axis, x_fake.reshape(X_DIM))
plt.ylim((-1, 1))
plt.show()

训练可能需要几秒钟,具体取决于你的 CPU 性能。训练完成后,生成器网络生成的样本可能看起来像这样(50 个样本):

生成的正弦样本

相当有说服力吧?看到它如何捕捉原始正弦波的峰值和谷值真是令人惊叹。想象一下,GAN 在更复杂结构下能够做什么!

GAN 能做什么?

GAN 能做的事情远不止生成正弦信号。通过改变生成器的输入和输出维度并结合其他方法,我们可以将 GAN 应用到许多不同的实际问题中。例如,我们可以基于随机输入生成文本和音频(一维),图像(二维),视频和 3D 模型(三维)。如果我们保持输入和输出的维度相同,就可以对这些类型的数据进行去噪和翻译。我们还可以将真实数据输入生成器,让它输出更大维度的数据,例如图像超分辨率。我们也可以输入一种类型的数据,让它输出另一种类型的数据,例如基于文本生成音频、基于文本生成图像等等。

尽管 GAN 首次提出仅仅过了 4 年(截至写作时),人们一直在努力改进 GAN,新的 GAN 模型几乎每周都会发布。如果你查看 github.com/hindupuravinash/the-gan-zoo,你会发现至少有 500 种不同的 GAN 模型。要想学习并评估每一个模型几乎是不可能的。你会惊讶地发现,实际上有很多模型共享相同的名字!因此,在本书中,我们不会尝试向你介绍大多数现有的 GAN 模型。然而,我们将帮助你熟悉在不同应用中最典型的 GAN 模型,并学习如何使用它们来解决实际问题。

我们还将介绍一些实用的技巧和技术,以提高 GAN 的性能。我们希望,当你完成本书时,能够对各种 GAN 模型的机制有广泛而深入的理解,从而让你有信心设计自己的 GAN 来创造性地解决未来可能遇到的问题。

让我们来看看 GAN 能做些什么,以及它们在这些领域(图像处理、自然语言处理和 3D 建模)与传统方法相比有哪些优势。

图像处理

在图像处理领域,GAN 被应用于许多应用场景,包括图像合成、图像翻译和图像修复。这些话题是 GAN 学习和应用中最常见的内容,也是本书的主要内容。图像是互联网上最容易展示和传播的媒体形式之一;因此,GAN 在图像应用方面的任何最新突破都会在深度学习社区引起极大的关注。

图像合成

简而言之,图像合成就是创造新的图像。早在 2015 年,DCGANs深度卷积生成对抗网络)就问世了。这是最早解决了之前 GAN 模型中难以训练问题的稳定且表现良好的方法之一。它基于一个长度为 100 的随机向量生成 64 x 64 的图像。以下截图展示了部分 DCGAN 生成的图像。你可能会注意到,由于像素的方块状外观,有些图像看起来远没有那么逼真。在 Radford、Metz 和 Chintala(2015)的论文中,他们展示了许多有趣且启发性的视觉实验,揭示了 GAN 更大的潜力。我们将在第四章中讨论 DCGAN 的架构和训练过程,用 PyTorch 构建你的第一个 GAN

DCGAN 生成的图像(左:人脸;右:卧室)

目前,GAN 在图像合成方面表现异常出色。以 BigGAN 为例,它是在 ICLR 2019(第 7 届国际学习表示会议)上,Brock、Donahue 和 Simonyan 提交的论文中提出的。即使在开放评审过程中,它就受到了社交媒体的广泛关注。它能够生成高质量、最大达到 512 x 512 尺寸的图像。

在接下来的章节中,我们还将探讨一些 GAN 模型,这些模型不仅关注图像的类别条件,还深入探讨图像的其他属性。我们将讨论条件 GAN(Conditional GANs),它允许你互动式地生成图像,以及 Age-cGAN,它能够根据你的需求生成任意年龄段的人脸。我们还将研究如何利用 GAN 生成对抗样本,这些样本连最好的分类器也无法正确识别,相关内容请见第八章,训练你的 GAN 以突破不同的模型

图像翻译

如果我们将图像合成描述为一个过程,即我们期望将 1 维向量输入模型后,输出为 2 维图像(再次强调,这里有例外,因为如果你愿意,也可以基于其他类型的数据生成图像),那么图像翻译(更准确地说,是图像到图像的翻译)就是将 2 维图像输入模型,并且模型输出仍为 2 维数据的过程。通过图像翻译,可以做很多有趣的事情。例如,pix2pix(Isola, Zhu, Zhou 等人,2016)可以将标签图转换为图像,包括将边缘草图转为彩色图像、根据语义分割信息生成街景照片、进行图像风格迁移等。我们将在第六章中详细探讨 pix2pix 的升级版 pix2pixHD,图像到图像翻译及其应用,并介绍其他图像到图像翻译方法,如 CycleGAN 和 DiscoGAN。

图像到图像的翻译可以应用于其他计算机视觉应用,并解决更传统的问题,比如图像恢复、图像修补和超分辨率。图像恢复是计算机视觉中最重要的研究领域之一。数学家和计算机科学家们几十年来一直在努力解决如何消除照片上的令人讨厌的噪音,或者从模糊图像中揭示更多信息的问题。传统上,这些问题通过迭代数值计算来解决,通常需要深厚的数学背景才能掌握。现在,有了生成对抗网络(GANs),这些问题可以通过图像到图像的翻译来解决。例如,SRGAN(Ledig, Theis, Huszar 等人,2016 年)可以高质量地将图像放大至原始尺寸的 4 倍,我们将在第七章中详细讨论,使用 GAN 进行图像恢复。是的,Chen, Lim 等人(2016 年)提出使用类似 DCGAN 的模型来解决人脸修补问题。更近期,Yu, Lin, Yang 等人(2018 年)设计了一个 GAN 模型,可以填补图像中任意形状的空白区域,生成的像素也非常令人信服。

文本到图像的翻译也是 GAN 的一个良好应用,根据描述文本生成新的图像。Reed, Akata, Yan 等人(2016 年)提出了一种从详细描述文本中提取区分特征并利用这些信息生成与描述完美匹配的花卉或鸟类图像的过程。几个月后,Zhang, Xu, Li 等人(2016 年)提出了 StackGAN,根据描述文本生成高保真度的 256 x 256 图像。我们将在第九章中详细讨论,从描述文本生成图像

视频合成与翻译

视频是一系列图像。因此,大多数图像翻译方法可以直接应用于视频。然而,视频合成或翻译的一个关键性能指标是计算速度。例如,如果我们希望为移动设备开发具有不同图像风格的相机应用程序,我们的用户肯定希望能实时看到处理后的结果。以视频监控系统为例。完全可以使用 GAN 来去噪和增强视频信号(前提是您的客户完全信任您的模型)。一个快速处理每帧图像的模型,以毫秒级速度保持帧率,无疑是值得考虑的。

我们想指出一个有趣的手势转移项目,名为Everybody Dance Now。它从源视频中提取舞者的动作,然后通过图像到图像的翻译将相同的动作映射到目标视频中的人物。这样,任何人都可以使用这个模型制作自己的舞蹈视频!

NLP

NLP 是研究如何使用计算机处理和分析自然人类语言的学科。除了生成图像外,GANs 还可以用于生成序列和时间相关的数据,如文本和音频。SeqGAN,由 Yu、Zhang、Wang 等人(2016)提出,旨在生成序列信号,如诗歌和音乐。随后,Mogren(2016)提出了 C-RNN-GAN,旨在生成受声学约束的古典音乐。2017 年,Dong、Hsiao、Yang 等人设计了 MuseGAN,用于生成多乐器的复调音乐,包括贝斯、鼓、吉他、钢琴和弦乐。可以随时访问以下网站¹⁰ 享受生成的音乐!

语音增强是音频信号处理的主要研究领域之一。传统上,人们使用频谱减法、维纳滤波、子空间方法等来去除音频或语音信号中的噪声。然而,这些方法的性能只有在特定情况下才令人满意。Pascual、Bonafonte 和 Serrà(2017)设计了 SEGAN 来解决这个问题,并取得了令人印象深刻的结果¹¹。我们将在 第十章,使用 GAN 进行序列合成 中讨论 GAN 在 NLP 领域的应用。

3D 建模

既然我们知道 GANs 可以基于 1D 输入生成 2D 数据,那么考虑将其升级到基于 1D 或 2D 信号生成 3D 数据也是自然而然的事情。3D-GAN(Wu、Zhang、Xue 等人,2016)正是为此目的设计的。它学习潜在向量与 3D 模型之间的映射,从而基于 1D 向量生成 3D 对象。使用 GANs 基于 2D 轮廓预测 3D 模型也是完全可行的。Gadelha、Maji 和 Wang(2016)设计了 PrGAN,用于基于任何视角的二进制轮廓图像生成 3D 对象。我们将在 第十一章,使用 GAN 重建 3D 模型 中详细讨论如何使用 GAN 生成 3D 对象。

总结

我们在第一章中已经涵盖了大量信息。你已经了解了 GANs 的起源,并且对生成器和判别器的角色有了基本的理解。你甚至已经看到了 GANs 可以做的一些示例。我们甚至使用 NumPy 创建了一个 GAN 程序。更不用说,现在我们知道为什么 Ganland 的铁匠和酒更好。

接下来,我们将深入探索 PyTorch 的神奇世界,了解它是什么以及如何安装它。

以下是参考文献和其他有用链接的列表。

参考文献和有用阅读列表

  1. Goodfellow I,Pouget-Abadie J,Mirza M 等(2014)。生成对抗网络。NIPS,2672-2680。

  2. Wang, J.(2017 年 12 月 23 日)。符号主义与联结主义:人工智能中的一个逐渐弥合的鸿沟,摘自 wangjieshu.com/2017/12/23/symbol-vs-connectionism-a-closing-gap-in-artificial-intelligence

  3. Radford A, Metz L, Chintala S.(2015)。无监督表示学习与深度卷积生成对抗网络。arXiv 预印本 arXiv:1511.06434。

  4. "Dev Nag"。(2017 年 2 月 11 日)。生成对抗网络GANs)在 50 行代码中的实现(PyTorch),检索自medium.com/@devnag/generative-adversarial-networks-gans-in-50-lines-of-code-pytorch-e81b79659e3f

  5. Brock A, Donahue J, Simonyan K. (2018). 大规模 GAN 训练用于高保真自然图像合成。arXiv 预印本 arXiv:1809.11096。

  6. Isola P, Zhu J Y, Zhou T, Efros A. (2016). 基于条件对抗网络的图像到图像转换。arXiv 预印本 arXiv:1611.07004。

  7. Ledig C, Theis L, Huszar F, et al (2016). 使用生成对抗网络的照片真实单图像超分辨率。arXiv 预印本 arXiv:1609.04802。

  8. Yeh R A, Chen C, Lim T Y, et al (2016). 基于深度生成模型的语义图像修复。arXiv 预印本 arXiv:1607.07539。

  9. Yu J, Lin Z, Yang J, et al (2018). 自由形式图像修复与门控卷积。arXiv 预印本 arXiv:1806.03589。

  10. Reed S, Akata Z, Yan X, et al (2016). 生成对抗文本到图像合成。arXiv 预印本 arXiv:1605.05396。

  11. Zhang H, Xu T, Li H, et al (2016). StackGAN:基于堆叠生成对抗网络的文本到照片真实图像合成。arXiv 预印本 arXiv:1612.03242。

  12. Yu L, Zhang W, Wang J, et al (2016). SeqGAN:具有策略梯度的序列生成对抗网络。arXiv 预印本 arXiv:1609.05473。

  13. Mogren O. (2016). C-RNN-GAN:具有对抗训练的连续递归神经网络。arXiv 预印本 arXiv:1611.09904。

  14. Dong H W, Hsiao W Y, Yang L C, et al (2017). MuseGAN:用于符号音乐生成与伴奏的多轨序列生成对抗网络。arXiv 预印本 arXiv:1709.06298。

  15. Pascual S, Bonafonte A, Serrà J. (2017). SEGAN:语音增强生成对抗网络。arXiv 预印本 arXiv:1703.09452。

  16. Wu J, Zhang C, Xue T, et al (2016). 通过 3D 生成对抗建模学习物体形状的概率潜在空间。arXiv 预印本 arXiv:1610.07584。

  17. Gadelha M, Maji S, Wang R. (2016). 从多个物体的 2D 视图推导 3D 形状。arXiv 预印本 arXiv:1612.05872。

第二章:开始使用 PyTorch 1.3

PyTorch 1.3 终于发布了!你准备好利用它的新特性和功能,让你的研究和生产工作变得更加轻松吗?

本章将带你了解 PyTorch 中引入的重大变化,包括从 eager 模式切换到图模式。我们将探讨如何将旧代码迁移到 1.x 版本,并带你了解 PyTorch 生态系统以及云端支持。

此外,我们还将介绍如何安装 CUDA,以便你可以利用 GPU 加速来加快 PyTorch 代码的训练和评估过程。我们将展示在 Windows 10 和 Ubuntu 18.04(无论是纯 Python 环境还是 Anaconda 环境)上安装 PyTorch 的逐步过程,以及如何从源代码构建 PyTorch。

最后,作为额外内容,我们将展示如何为 PyTorch 开发配置 Microsoft VS Code,以及一些最好的扩展插件,以使你的工作更加愉快。

本章将涵盖以下主题:

  • PyTorch 1.3 的新特性是什么?

  • CUDA - 用于快速训练和评估的 GPU 加速

  • 在 Windows 和 Linux 上安装 PyTorch

  • 参考资料和有用的阅读书单

PyTorch 1.3 的新特性是什么?

PyTorch (pytorch.org) 是一个开源的 Python 机器学习平台。它专门为深度学习应用设计,如卷积神经网络CNNs)、递归神经网络RNNs)和生成对抗网络GANs),并为这些应用提供了广泛的层定义。它内置了张量操作,旨在像 NumPy 数组一样使用,同时也经过优化,能够在 GPU 上快速计算。它提供了自动计算图机制,因此你无需手动计算导数。

经过大约 3 年的开发和改进,PyTorch 终于迎来了它的最新版本——1.3 版!新版本带来了大量的新特性和新功能。你不用担心需要重新学习这个工具;即使是全新的版本,PyTorch 一直擅长保持其核心功能的一致性。事实上,自从其 alpha 版(版本 0.1.1)发布以来,其核心模块变化不大:torch.nntorch.autogradtorch.optim,这与其他一些平台不同。(没错!我们说的就是你,TensorFlow!)现在,让我们来看看 PyTorch 的一些新特性。

从 eager 模式到图模式的轻松切换

当 PyTorch 大约两年前首次引起人们的关注时,它相对于其他深度学习工具的一个最大优势就是支持动态计算图。可能正是这个特点促使人们放弃旧有工具,转而采用 PyTorch。正如你可能已经注意到的,最近,越来越多的最新深度学习论文的作者开始使用 PyTorch 来实现他们的实验。

然而,这并不意味着 PyTorch 不适合生产环境。在 1.0 版本中,PyTorch 提供了一个混合前端,可以轻松地将你的代码从动态图模式(eager mode)转换为图模式(静态图)。你可以像以前一样灵活地编写代码。当你对代码满意时,只需通过修改几行代码,它就能准备好在图模式下进行高效优化。这个过程是通过 torch.jit 编译器完成的。JIT即时-编译)编译器旨在将 PyTorch 代码序列化并优化为TorchScript,后者可以在没有 Python 解释器的情况下运行。

这意味着现在你可以轻松地将模型导出到一个没有 Python 的环境中,或者效率极为重要的地方,并通过 C++代码调用你的模型。提供了两种方式将传统的 PyTorch 代码转换为 TorchScript:追踪和脚本化。追踪非常适合直接将固定输入的固定模型结构转换为图模式。

然而,如果你的模型中存在任何数据依赖的控制流(例如 RNN),脚本化是为这种场景设计的,其中所有可能的控制流路径都会转换为 TorchScript。请记住,至少在编写本书时,脚本化仍然存在一些局限性。

动态图意味着每次运行模型时都会建立计算图,并且可以在不同的运行之间进行更改。就像每个人开着自己的车在街上行驶,每次离开家时都可以去任何地方。它对研究来说非常灵活。然而,在每次运行之前建立计算图所需的额外资源开销是不可忽视的。因此,它可能对于生产环境来说效率较低。静态图意味着计算图必须在第一次运行之前建立,并且一旦建立就不能再更改。就像每个人都坐公共汽车上班。如果乘客想去不同的目的地,他们必须与公交车司机沟通,司机再与公共交通部门联系。然后,第二天公交路线可以进行调整。

这是如何将模型转换为图模式的示例。

假设我们已经在给定的device上拥有了model

model = Net().to(device)

我们只需添加这些代码行来trace模型:

trace_input = torch.rand(BATCH_SIZE, IMG_CHANNEL, IMG_HEIGHT, IMG_WIDTH).to(device)
traced_model = torch.jit.trace(model, trace_input)

然后,我们可以将追踪后的模型保存到文件中:

traced_model.save("model_jit.pth")

请注意,你应避免使用torch.save(traced_model.state_dict(), "model_jit.pth")来保存追踪后的模型,因为在编写本书时,以这种方式创建的检查点文件无法被 C++ API 正确处理。

现在,追踪模型可以像在 Python 中使用普通的 torch.nn.Module 一样使用,也可以被其他 C++ 代码使用,稍后我们会讲解这一点。这个示例的完整代码,包含我们训练并导出用于 MNIST 分类的 CNN,可以在代码仓库中的 jit/mnist_jit.py 文件中找到。你可以参考官方教程,了解更多关于混合前端的信息:pytorch.org/tutorials/beginner/deploy_seq2seq_hybrid_frontend_tutorial.html

C++ 前端

尽管 PyTorch 的后端主要是由 C++ 实现的,但其前端 API 一直以来都集中在 Python 上。部分原因是 Python 在数据科学家中已经非常流行,并且拥有大量开源包,帮助你专注于解决问题,而不是重新发明轮子。此外,Python 还非常易于阅读和编写。然而,Python 并不以计算和内存资源效率著称。大公司通常会用 C++ 开发自己的工具,以实现更好的性能。但小公司或个人开发者往往很难将主要精力转向开发自己的 C++ 工具。幸运的是,PyTorch 在版本 1.0 中发布了 C++ API,现在,任何人都可以利用它来构建高效的项目。

请注意,目前 PyTorch 的 C++ API 仍在开发中,未来可能会发生一些变化。事实上,v1.0.1 和 v1.0.0 之间的变化非常大,以至于 v1.0.0 的官方文档和教程无法适用于 v1.0.1。

这是如何使用 PyTorch 提供的 C++ API 的一个示例。

让我们加载之前导出的追踪模型:

torch::Device device = torch::kCUDA;
std::shared_ptr<torch::jit::script::Module> module = torch::jit::load("model_jit.pth");
module->to(device);

接下来,让我们给模型输入一个虚拟的图片:

std::vector<torch::jit::IValue> inputs;
inputs.push_back(torch::ones({BATCH_SIZE, IMG_CHANNEL, IMG_HEIGHT, IMG_WIDTH}).to(device));
at::Tensor output = module->forward(inputs).toTensor();

C++ 示例的完整代码可以在本章的代码仓库中的 jit 目录下找到,其中包括用于编译 .cpp 文件的 CMakeLists.txt 文件。你可以参考官方文档,了解更多关于 C++ API 的信息:pytorch.org/cppdocs

重新设计的分布式库

在 CPU 上调试多线程程序非常痛苦,而在分布式系统上设计高效的 GPU 程序则可能更为复杂。幸运的是,PyTorch 不断推出易于使用的分布式解决方案,正是为了这个目的。在版本 1.0 中,torch.distributed 模块是以性能为驱动,并对所有后端(包括 Gloo、NCCL 和 MPI)进行异步运行。新的分布式库旨在为单节点和多节点系统提供接近最佳的性能。它还特别针对较不先进的网络通信场景进行了优化,通过减少带宽交换,从而提升这些系统的性能。

NCCL 后端用于分布式 GPU 训练,而 Gloo 后端则用于分布式 CPU 训练。新的分布式包还提供了一个辅助工具 torch.distributed.launch,旨在启动单节点和多节点系统上的多个进程。以下是如何使用它进行分布式训练的示例:

  • 单节点 distributed 训练:
$ python -m torch.distributed.launch --nproc_per_node=NUM_GPUS YOUR_SCRIPT.py --YOUR_ARGUMENTS
  • 多节点 distributed 训练:
# Node 1
$ python -m torch.distributed.launch --nproc_per_node=NUM_GPUS --nnodes=2 --node_rank=0 --master_addr=MASTER_IP --master_port=MASTER_PORT YOUR_SCRIPT.py --YOUR_ARGUMENTS
# Node 2
$ python -m torch.distributed.launch --nproc_per_node=NUM_GPUS --nnodes=2 --node_rank=1 --master_addr=MASTER_IP --master_port=MASTER_PORT YOUR_SCRIPT.py --YOUR_ARGUMENTS

在上面的代码中,MASTER_IP 是一个包含主节点 IP 地址的字符串,例如 192.168.1.1

请随时查看关于使用 PyTorch 1.3 进行分布式训练的官方教程:pytorch.org/docs/master/distributed.htmlpytorch.org/tutorials/intermediate/dist_tuto.htmlpytorch.org/tutorials/beginner/former_torchies/parallelism_tutorial.htmlpytorch.org/tutorials/beginner/aws_distributed_training_tutorial.html

更好的研究可复现性

您可能听说过关于深度学习论文中实验结果难以复现的抱怨。显然,我们需要信任评审专家,尽管他们每年必须为每个顶级会议审阅成千上万的论文。然而,这是否意味着我们不能信任自己按照论文中确切步骤进行操作的能力?现在 PyTorch 已宣布推出 torch.hub 来帮助解决研究可复现性的问题。作者现在可以通过 Torch Hub 发布他们的训练模型,用户则可以直接下载并在代码中使用它们。

这是一个如何使用 Torch Hub 发布和使用预训练模型的示例。

要发布您的模型,您需要在 GitHub 仓库中创建一个 hubconf.py 文件,并定义入口点(例如,命名为 cnn),如下所示:

dependencies = ['torch']

def cnn(pretrained=True, *args, **kwargs):
    model = Net()
    checkpoint = 'models/cnn.pth'
    if pretrained:
        model.load_state_dict(torch.load(checkpoint))
    return model

在上面的代码中,dependencies 是运行您的模型所需的依赖项列表,而 Net() 是定义您模型的类。请注意,发布的模型必须位于特定的分支/标签下,例如 master 分支。您还可以将您的 pretrained 模型文件上传到其他网站,并通过这种方式下载它们:

    if pretrained:
        import torch.utils.model_zoo as model_zoo
        model.load_state_dict(model_zoo.load_url(checkpoint))

假设我们已经将模型发布到 github.com/johnhany/torchhub。要使用已发布的模型,您只需要调用 torch.hub

import torch.hub as hub
model = hub.load("johnhany/torchhub:master", "cnn", force_reload=True, pretrained=True).to(device)

本章的 Torch Hub 示例的完整代码可以在代码库中的 torchhub 目录下找到。

其他杂项

除了我们之前提到的内容,PyTorch 新版本还带来了其他好处。在本节结束时,我们还将讨论如何将您的旧 PyTorch 代码迁移到 1.x 版本。

PyTorch 生态系统

有许多精彩的工具和项目是基于 PyTorch 平台构建的。它们在多个领域充分挖掘了 PyTorch 的潜力。例如,AllenNLP (allennlp.org) 是一个开源的自然语言处理库。你可以查看他们的演示网站,了解最前沿的 NLP 算法能够实现什么:demo.allennlp.orgFastai (docs.fast.ai) 提供了一个简化的 PyTorch 模型训练流程,并且在 course.fast.ai 上提供了实用的深度学习课程。Translate (github.com/pytorch/translate) 是一个专注于自然语言翻译的 PyTorch 库。

访问这个网站了解更多关于 PyTorch 生态系统的信息:pytorch.org/ecosystem

云端支持

PyTorch 完全支持主流云平台,如 Amazon AWS、Google Cloud Platform 和 Microsoft Azure。如果你目前没有支持 CUDA 的 GPU(我们将在下一节讨论此问题),可以随时租用前述平台提供的 GPU 服务器。以下是关于在 Amazon AWS 上使用 PyTorch 进行分布式训练的官方教程:pytorch.org/tutorials/beginner/aws_distributed_training_tutorial.html

将你之前的代码迁移到 1.x

尽管 PyTorch 1.x 引入了许多破坏性变化,大部分的 API 或编码规范变化并不大。因此,如果你已经熟悉 PyTorch 0.4,那么你的代码应该可以大部分照常工作。从 v0.4 到 v1.3 的 API 变化可以在 Breaking Changes 中找到,链接为:github.com/pytorch/pytorch/releases

你在将旧代码迁移到 PyTorch 1.x 时,最常遇到的问题通常源于索引 0 维度的张量。比如,当你打印损失值时,需要使用 loss.item(),而不是 loss[0]。这个示例的完整代码可以在代码仓库中找到,路径为 pytorch_test 目录下的 ind-0-dim.py 文件。

如果你的代码针对的是早于 0.4 版本的 PyTorch,你或许应该首先查看 PyTorch 0.4 的迁移指南:pytorch.org/blog/pytorch-0_4_0-migration-guide。不过,后续版本(大于 0.4)的官方迁移指南并不存在,然而,你肯定可以通过简单的网络搜索找到很多相关信息。

CUDA – 用于快速训练和评估的 GPU 加速

NVIDIA CUDA Toolkit (developer.nvidia.com/cuda-toolkit) 是一个完全优化的并行计算平台,用于图形处理单元(GPGPU)上的通用计算。它允许我们在 NVIDIA 显卡上进行科学计算,包括线性代数、图像和视频处理、深度学习以及图形分析。许多商业和开源软件都使用它,以便在不同领域实现 GPU 加速计算。如果我们回顾深度学习的发展,就会意识到没有 CUDA 和强大 GPU 的帮助,GAN 的最新突破几乎是不可能实现的。因此,我们强烈建议你在支持 CUDA 的 GPU 上尝试本书中的实验;否则,在 CPU 上训练神经网络的时间可能会非常漫长。

在本节中,我们将指导你在 Windows 10 和 Ubuntu 18.04 上安装 CUDA。在开始安装 CUDA 之前,你应该确保你的显卡支持 CUDA,并且你已安装了最新的显卡驱动程序。要检查你的 GPU 是否与 CUDA 兼容(或者与你想安装的具体 CUDA 版本兼容),你首先需要确保你的机器上安装了 NVIDIA 显卡。

在 Windows 上,你可以使用第三方工具,如 GPU-Z (www.techpowerup.com/gpuz) 或 GPU Caps Viewer (www.ozone3d.net/gpu_caps_viewer) 来检查显卡的规格。你也可以随时访问这个网页,查看你的显卡是否在支持列表上:www.geforce.com/hardware/technology/cuda/supported-gpus。然而,检查最新的 CUDA 是否在你的系统上完美运行的最直接且实用的方法,是按照下面小节中的安装和评估步骤,确保没有任何问题地完成安装。

在撰写本书时,CUDA 的最新版本是 10.1。

安装 NVIDIA 驱动程序

在 Windows 10 上,访问 www.nvidia.com/Download/index.aspx,根据你的显卡和操作系统选择产品并下载驱动程序。Windows 上的安装应该非常简单,因为它具有图形用户界面GUI)。你可以在安装过程中保持默认设置。

在 Ubuntu 18.04 上,你可以随时从 如何在 Ubuntu 18.04 上安装 CUDA 10.1 (gist.github.com/eddex/707f9cbadfaec9d419a5dfbcc2042611) 下载 CUDA。然而,我们建议你按以下方式安装 NVIDIA 驱动程序,这样你可以像其他软件一样更新显卡驱动。首先,打开终端,并通过输入以下命令将适当的仓库添加到你的包管理源列表中:

$ sudo add-apt-repository ppa:graphics-drivers/ppa
$ sudo apt-get update

现在,你可以通过执行以下操作检查你的显卡型号和推荐的驱动程序版本:

$ ubuntu-drivers devices

输出可能如下所示:

== /sys/devices/pci0000:00/0000:00:01.0/0000:01:00.0 ==
modalias : pci:v000010DEd00001B06sv00001458sd00003752bc03sc00i00
vendor : NVIDIA Corporation
model : GP102 [GeForce GTX 1080 Ti]
driver : nvidia-driver-390 - third-party free
driver : nvidia-driver-396 - third-party free
driver : nvidia-driver-415 - third-party free recommended
driver : nvidia-driver-410 - third-party free
driver : xserver-xorg-video-nouveau - distro free builtin

然后,使用以下命令安装推荐的驱动程序:

$ sudo ubuntu-drivers autoinstall

如果你已经安装了 CUDA,并计划安装不同版本的 CUDA,我们建议你卸载 NVIDIA 驱动程序和 CUDA 工具包,重启系统,并在重新安装 CUDA 之前安装最新的驱动程序。

安装完成后,重启你的系统。

安装 CUDA

这是完整的 CUDA 工具包列表:developer.nvidia.com/cuda-toolkit-archive。点击 CUDA Toolkit 10.1 来访问 CUDA 10.1 的下载页面。

Windows 10上,选择 Windows | x86_64 | 10 | exe(local),并下载基础安装程序。安装程序文件约为 2.1 GB。再次提醒,由于安装过程是基于 GUI 的,我们不会详细讨论。安装时只需保持默认设置。

确保在安装过程中也安装官方的 CUDA 示例。它们对于我们后续评估 CUDA 是否成功安装至关重要,并且对于学习 CUDA 编程非常有用(如果你有兴趣的话)。此外,如果你计划在 Windows 上安装 Microsoft Visual Studio,请确保在 CUDA 之前安装它,因为 CUDA 会自动检测 Visual Studio 并安装相应的集成工具。

在 Ubuntu 18.04 上,选择 Linux | x86_64 | Ubuntu | 18.04 | runfile(local),并下载基础安装程序。安装程序文件大约 2.0 GB。下载完成后(可能需要一段时间,假设它下载在~/Downloads目录下),打开终端并输入以下命令:

$ cd ~/Downloads
$ sudo chmod +x cuda_10.1.243_418.86.00_linux.run
$ sudo sh cuda_10.1.243_418.86.00_linux.run

在安装过程中,接受所有默认设置,除了当提示时我们不需要安装 NVIDIA 驱动程序,因为我们之前已经安装了更新版本。

在 CUDA 安装结束时,可能会有一些警告信息,例如缺少推荐的库:libGLU.so。只需运行apt-get install libglu1-mesa libxi-dev libxmu-dev libglu1-mesa-dev来安装这些可选库。

最后,将 CUDA 目录添加到你的~/.bashrc文件,以便其他软件可以找到你的 CUDA 库:

$ export PATH=$PATH:/usr/local/cuda/bin
$ export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/usr/local/cuda/lib:/usr/local/cuda/lib64:/usr/local/cuda/extras/CUPTI/lib64

另外,你可以打开文件gedit ~/.bashrc并手动在文件末尾添加以下两行:

PATH=$PATH:/usr/local/cuda/bin
LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/usr/local/cuda/lib:/usr/local/cuda/lib64:/usr/local/cuda/extras/CUPTI/lib64

运行sudo ldconfig以刷新我们对 .bashrc 文件所做的更改。确保在运行任何其他 bash 命令之前,关闭并重新打开终端。

对于其他平台,请访问docs.nvidia.com/cuda/archive/10.0并按照该页面上的说明安装 CUDA 10.0。

安装 cuDNN

为了启用 CUDA 为神经网络提供的快速计算能力,我们需要安装 cuDNN。NVIDIA CUDA 深度神经网络库cuDNN)是一个为深度神经网络提供 GPU 加速的库。它本质上是一个运行在 GPU 上的低级驱动程序,提供了多个完全优化的前向和反向计算,以支持常见的神经网络操作。它已经被许多深度学习平台使用,包括 PyTorch,这样平台开发者就不需要担心实现基本的神经网络组件,而可以专注于提供更好的 API 供我们使用。

首先,我们需要从这个网站下载 cuDNN:developer.nvidia.com/rdp/cudnn-download。之前的版本可以在developer.nvidia.com/rdp/cudnn-archive找到。请根据您的 CUDA 版本和操作系统选择合适的 cuDNN 版本。通常,任何版本的 cuDNN,只要大于7.0,都可以适用于 PyTorch。当然,您也可以始终下载最新版本。在这里,我们将从上面链接中下载cuDNN v7.5.0 for CUDA 10.1。请注意,您需要注册一个有效电子邮件地址的 NVIDIA 开发者账户,以成为 NVIDIA 开发者计划的成员;然后所有的 cuDNN 发布文件都可以免费下载安装。

在 Windows 10 上,点击下载 cuDNN v7.5.0(2019 年 2 月 21 日);对于 CUDA 10.0,点击Windows 10 的 cuDNN 库。这将下载一个名为cudnn-10.0-windows10-x64-v7.5.0.56.zip的文件,大小约为 224MB。解压下载的文件并将解压后的文件复制到 CUDA 目录,如下所示:

  • [UNZIPPED_DIR]\cuda\bin\cudnn64_7.dll -> C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\v10.0\bin\cudnn64_7.dll

  • [UNZIPPED_DIR]\cuda\include\cudnn.h -> C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\v10.0\include\cudnn.h

  • [UNZIPPED_DIR]\cuda\lib\x64\cudnn.lib -> C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\v10.0\lib\x64\cudnn.lib

在 Ubuntu 18.04 上,点击下载 cuDNN v7.5.0(2019 年 2 月 21 日);对于 CUDA 10.0,点击Linux 的 cuDNN 库。一个名为cudnn-10.0-linux-x64-v7.5.0.56.tgz的文件将被下载。文件大小约为 433MB。下载完成后,让我们打开终端并运行以下脚本(假设您的文件已下载到~/Downloads目录):

解压下载的文件:

$ cd ~/Downloads
$ tar -xzvf cudnn-10.0-linux-x64-v7.5.0.56.tgz

将文件复制到系统目录,并为所有用户授予读取权限(您可能需要先cd到解压文件夹):

$ sudo cp cuda/include/cudnn.h /usr/local/cuda/include
$ sudo cp cuda/lib64/libcudnn* /usr/local/cuda/lib64
$ sudo chmod a+r /usr/local/cuda/include/cudnn.h /usr/local/cuda/lib64/libcudnn*

在其他平台上,请参阅docs.nvidia.com/deeplearning/sdk/cudnn-install/index.html上的说明来安装 cuDNN。

评估您的 CUDA 安装

让我们来看看 CUDA 在您的机器上是否正常工作。这里,我们假设您也已安装了官方的 CUDA 示例。

在此,构建和测试 CUDA 示例程序需要使用 Microsoft Visual Studio。我们在此示例中使用的是 Visual Studio Community 2017。

在 Windows 10 上,进入 CUDA 示例目录(例如,C:\ProgramData\NVIDIA Corporation\CUDA Samples\v10.0)。使用 Visual Studio 2017 打开 1_Utilities\deviceQuery\deviceQuery_vs2017.sln 解决方案文件。

在 Visual Studio 中,将 解决方案配置 切换为 发布。然后,点击构建 | 构建 deviceQuery 来构建示例代码。构建完成后,进入 C:\ProgramData\NVIDIA Corporation\CUDA Samples\v10.0\bin\win64\Release 目录,在该目录下打开 PowerShell。输入以下命令:

> .\deviceQuery.exe

输出应该类似于以下内容:

CUDA Device Query (Runtime API) version (CUDART static linking)

Detected 1 CUDA Capable device(s)

Device 0: "GeForce GTX 1080 Ti"
 CUDA Driver Version / Runtime Version 10.0 / 10.0
 CUDA Capability Major/Minor version number: 6.1
 Total amount of global memory: 11175 MBytes (11718230016 bytes)
...
Result = PASS

这表示 CUDA 10.0 已成功安装。

在 Ubuntu 18.04 上,进入 CUDA 示例目录(例如,~/NVIDIA_CUDA-10.0_Samples)。打开终端并输入以下内容:

$ cd 1_Utilities/deviceQuery
$ make

这应该能够顺利编译 deviceQuery 程序。然后,进入构建目录并运行该程序:

$ cd ../../bin/x86_64/linux/release
$ ./deviceQuery

输出应类似于 Windows 10 上的输出。

现在我们可以开始安装 PyTorch 1.0 了!

在 Windows 和 Linux 上安装 PyTorch

要安装和使用 PyTorch,我们首先需要正确设置 Python 开发环境。因此,在本节中,我们将首先讨论如何设置 Python 环境,然后介绍如何通过官方发布的二进制文件或从源代码构建来安装 PyTorch。在本节结束时,我们还会向你介绍一个轻量但非常强大的代码编辑器工具——Microsoft VS Code,并展示如何配置它用于 PyTorch 编程。

设置 Python 环境

在接下来的章节中,我们将逐步讲解如何在 Windows 10 和 Ubuntu 18.04 上设置 Python 环境,以及如何安装或构建 PyTorch。当然,我们假设你已经成功安装了 CUDA(例如,CUDA 10.1)。

安装 Python

在 Windows 10 上,访问 www.python.org/downloads/windows 下载 Windows x86-64 可执行安装程序。你可以安装任何版本,本文以最新版本(撰写时为 3.7.5)为例。实际上,3.8.0 是最新版本,但最好还是停留在 3.7.x 版本轨道上。下载的 python-3.7.5-amd64.exe 文件约为 25 MB。在安装过程中保持默认设置,唯一需要更改的是安装路径,选择一个更容易找到的位置,即 C:\Python37

安装过程中确保勾选了将 Python 3.7 添加到 PATH 选项,否则你需要手动添加环境变量:C:\Python37\C:\Python37\Scripts\。关于在 Windows 10 上添加环境变量的详细过程将在本章后面介绍。

在 Ubuntu 18.04 上,Python 2.7.15 和 3.7.1 已经随系统一起安装。因此,现在不需要做任何操作。

在 Ubuntu 上,如果你计划使用系统提供的默认 Python 版本,在修改它之前(三思而后行),因为这可能会影响系统中的许多其他功能。始终确保你使用的是正确的 Python 版本(即 Python 2 或 Python 3)。有时候,跨 Python 2 和 Python 3 安装和使用包可能会有些混乱。

安装 Anaconda Python

在 Windows 10 上,从 www.anaconda.com/distribution/#windows 下载安装程序。我们将以 Python 3.7 版本为例进行下载和安装。这将下载一个约 614 MB 的 Anaconda3-2018.12-Windows-x86_64.exe 文件。打开此文件安装 Anaconda,保持默认设置不变。请注意,我们不需要勾选 Register Anaconda as the system Python 3.7 选项,因为我们稍后会手动创建新的 Python 环境并添加相应的环境变量。

安装结束时,系统会询问是否安装 Microsoft VS Code。我们建议你安装一个用于 Python 开发的编辑器。

在 Ubuntu 18.04 上,从 www.anaconda.com/distribution/#linux 下载安装程序。这里,我们以 Python 3.7 为例进行下载和安装。将会下载一个 Anaconda3-2018.12-Linux-x86_64.sh 文件,文件大小约为 684 MB。运行此文件进行安装(假设它位于 ~/Downloads 目录下):

$ cd ~/Downloads
$ chmod +x Anaconda3-2018.12-Linux-x86_64.sh
$./Anaconda3-2018.12-Linux-x86_64.sh

在安装过程中,接受所有默认设置。安装结束时,系统会提示是否安装 Microsoft VS Code。如果你还没有安装,可以选择安装。

在继续之前的先决条件

在继续下一部分之前,我们需要安装一些重要的,甚至是必需的 Python 工具和库,包括:

  • Pip (必需): 管理 Python 包所需的工具。在 Ubuntu 上,运行 sudo apt-get install python-pip 安装 Python 2,或运行 sudo apt-get install python3-pip 安装 Python 3。在 Windows 上,通常会随着 Python 一起安装。

  • NumPy (必需): 一个用于张量表示、操作和计算的科学计算库,包含线性代数、傅里叶变换和随机数功能。它是安装 PyTorch 的必要库。

  • SciPy (可选): 一组包括信号处理、优化和统计的数值算法库。我们将主要使用它的统计功能,例如基于某种随机分布初始化参数。

  • OpenCV (可选): 一个跨平台的开源计算机视觉库,用于高效的实时图像处理和模式识别。我们将使用它来预处理或可视化神经网络中的数据、参数和特征图。

  • Matplotlib (可选): 一个高质量的绘图库。我们将使用它来展示损失曲线或其他图表。

在 Windows 10 上,你可以访问 www.lfd.uci.edu/~gohlke/pythonlibs 下载这些库的 .whl 文件,并使用 pip install [FILENAME](对于 Python 2)或 pip3 install [FILENAME](对于 Python 3)进行安装。

在 Ubuntu 18.04 上,你可以使用以下命令安装这些软件包:

#For Python 2
$ pip install numpy scipy opencv-python matplotlib
#For Python 3
$ pip3 install numpy scipy opencv-python matplotlib

安装过程中可能会由于用户权限问题导致失败。如果你是 Windows 系统的管理员用户,请确保以管理员身份打开命令提示符。如果你在 Ubuntu 上拥有 root 权限,只需在安装命令前添加 sudo。如果你完全没有管理员或 root 权限,可以使用 pip3 install --user 来安装这些软件包。

安装 PyTorch

你可以通过使用官方发布的二进制文件或从源代码构建的方式来安装 PyTorch。你可以直接在系统上安装 PyTorch,或者使用包管理器(例如 Anaconda)来避免与其他工具的潜在冲突。在撰写本书时,PyTorch 的最新版本是 v1.3.1。由于我们希望利用 PyTorch 提供的前沿功能,因此在本书的剩余章节中,我们将安装并使用 PyTorch 1.3。当然,你可以选择任何你希望的其他版本,或者安装比本书中使用的版本更新的版本。只需在遵循以下安装步骤时,将版本号更改为你自己的版本。

如果你使用的是 Ubuntu,我们强烈建议你通过 Anaconda 安装 PyTorch,因为它不会影响系统自带的默认 Python 环境。如果你使用的是 Windows,如果安装出了问题,你基本可以删除 Python 安装并重新安装你想要的任何版本。

安装官方二进制文件

不久前,安装 PyTorch 是一项大工程。然而,PyTorch.org 的开发者们已经使你在系统上安装 PyTorch 变得非常容易。前往 pytorch.org/get-started/locally/ 开始。你会在这里找到一个非常简单的点击式方法来获取正确的安装信息。

你应该从你想要安装的版本开始,然后选择操作系统。接下来,你应该确定安装 PyTorch 的方式,例如通过 Conda、pip 等。然后,选择你将使用的 Python 版本,最后,选择你使用的 CUDA 版本,或者是否不使用 GPU:

最后一步是从网格底部的框中选择并复制命令。将其粘贴到终端或命令提示符中并执行。大约一两分钟后,你就完成了安装。

你还可能想要将创建的 Python 环境设为系统的默认 Python。为此,你只需添加这些环境变量:C:\Users\John\Anaconda3\envs\torchC:\Users\John\Anaconda3\envs\torch\Scripts

如何在 Windows 10 上添加 环境变量: (1) 右键点击 开始 按钮,点击 系统。 (2) 在 设置 窗口右侧点击 系统信息,这将打开 系统控制面板(如果你使用的是比较旧版本的 Windows 10,可能不需要这一步)。 (3) 在左侧点击 高级系统设置,这将打开 系统属性 窗口。 (4) 点击 环境变量 按钮,打开 环境变量 窗口。 (5) 双击 用户变量 中的 Path 变量行。现在你可以添加或编辑指向 Anaconda 或 Python 目录的路径。每次编辑环境变量时,确保关闭 环境变量 窗口,并在 PowerShell 中运行此脚本:$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User")

就这样!现在 PyTorch 已经成功安装在你的机器上,你可以按照 评估你的 PyTorch 安装部分的说明,检查它是否正常工作。

从源代码构建 PyTorch

在这里,我们只讨论如何在 Ubuntu 18.04 上使用 Anaconda Python 从源代码构建 PyTorch,因为在 Windows 上构建过程失败的几率非常高。首先,让我们创建一个名为 torch-nt 的新 Python 环境,用于构建并安装夜间版本,使用命令 conda create -n torch-nt python=3.7,然后使用 conda activate torch-nt 激活它。

接下来,安装构建 PyTorch 所需的依赖:

(torch-nt)$ conda install numpy pyyaml mkl mkl-include setuptools cmake cffi typing
(torch-nt)$ conda install magma-cuda100 -c pytorch

然后,使用 Git 下载 PyTorch 的源代码:

(torch-nt)$ git clone --recursive https://github.com/pytorch/pytorch
(torch-nt)$ cd pytorch
(torch-nt)$ export CMAKE_PREFIX_PATH="/home/john/anaconda3/envs/torch-nt"
(torch-nt)$ python setup.py install

在这里,CMAKE_PREFIX_PATH 指向你的 Python 环境的根目录。所有由 Anaconda 创建的环境都位于 ~/anaconda3/envs 文件夹下。

等待一会儿直到完成。当完成时,在终端中运行 python,输入 import torch 并按回车。如果没有错误弹出,说明 PyTorch 已成功构建并安装。

你记得吗?不要在构建 PyTorch 的同一目录下运行 import torch,因为 Python 会尝试从源代码文件中加载 Torch 库,而不是加载已安装的包。

评估你的 PyTorch 安装

从现在开始,我们将使用之前创建的名为 torch 的 Anaconda Python 环境作为本书的默认 Python 环境。我们还将省略脚本前的(torch)指示符。此外,本书中的所有代码默认都为 Python 3(特别是 Python 3.7)。如果你在寻找 Python 2 的实现,你可以查看 3to2(pypi.org/project/3to2)。

让我们编写一个使用 PyTorch 进行矩阵乘法的简短代码片段。创建一个名为 pytorch_test.py 的 Python 源代码文件,并将以下代码复制到该文件中:

import torch

print("PyTorch version: {}".format(torch.__version__))
print("CUDA version: {}".format(torch.version.cuda))

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(device)

a = torch.randn(1, 10).to(device)
b = torch.randn(10, 1).to(device)
c = a @ b
print(c.item())

打开终端并运行以下代码段:

$ conda activate torch
$ python pytorch_test.py

输出可能如下所示:

PyTorch version: 1.3.1
CUDA version: 10.1.243
cuda
-2.18083119392395

最后一行完全是随机的,所以如果你得到不同的结果,不用担心。代码也可以在本章代码库中的pytorch_test目录下找到。

你可以随时使用前面章节中的 jit 或 torchhub 示例来验证 PyTorch 的安装。也可以随时查看官方示例:github.com/pytorch/examples

还记得我们在第一章中使用 NumPy 实现的简单 GAN 吗?现在你已经安装并配置好了 PyTorch,可以考虑如何用 PyTorch 来实现它。

奖励:为 Python 编程配置 VS Code

VS Code 是微软开发的轻量级开源代码编辑器。它具有内置的语法高亮、自动补全、调试、Git 管理功能,并且拥有超过 10,000 个社区开发的扩展。它支持 Windows、macOS 和 Linux,根据 StackOverflow 的一项调查,它是软件开发人员中最受欢迎的开发工具:insights.stackoverflow.com/survey/2018/#technology-most-popular-development-environments。如果你主要在自己的机器上使用本书学习 GAN,我们强烈建议你使用 VS Code 进行 PyTorch 开发。

如果你经常进行远程工作,这意味着你需要在本地编写 Python 代码并在远程服务器上运行这些代码,你可以考虑使用 PyCharm 专业版(www.jetbrains.com/pycharm)来完成这个任务。它提供的远程开发功能比免费的 VS Code 插件更加成熟。

为 Python 开发配置 VS Code

本质上,你只需要安装 Python 扩展 ms-python.python 来进行 VS Code 中的 Python 编程。

在 Windows 10 上,点击 文件 | 首选项 | 设置,点击右上角的 {} 按钮(打开设置(JSON)),然后输入以下内容:

    "python.pythonPath": "C:\\Users\\John\\Anaconda3\\envs\\torch"

在 Ubuntu 18.04 上,点击 文件 | 首选项 | 设置,点击右上角的 {} 按钮(打开设置(JSON)),然后输入以下内容:

    "python.pythonPath": "~/anaconda3/envs/torch/bin/python3"

现在,VS Code 将自动识别它为一个 Anaconda Python 环境,你可以开始使用它来编写 Python 代码了!

推荐的 VS Code 扩展

这里有一些我个人认为在 Python 开发中非常有用的 VS Code 扩展。我相信它们也能让你的工作变得更轻松。非常感谢它们的创造者!

  • Bracket Pair Colorizer (coenraads.bracket-pair-colorizer): 这个扩展为每一对括号匹配不同的颜色,让你可以轻松识别它们。

  • Code Runner (formulahendry.code-runner): 这个扩展允许你通过点击按钮运行 Python(以及其他许多语言)的代码。然而,我们不建议你用它来运行神经网络的训练代码,因为日志信息可能会非常长,一些信息可能会在 VS Code 中丢失。

  • GitLens - Git supercharged (eamodio.gitlens): 如果你依赖 Git 来管理源代码,这是一个强大的工具。例如,它会在你当前查看的每一行显示 Git 历史记录,显示本地和远程的所有变更,采用树状结构展示等等。

  • indent-switcher (ephoton.indent-switcher): 每个人的编程习惯不同。有些人喜欢使用两个空格作为缩进,而有些人喜欢四个空格。你可以通过这个扩展在两空格和四空格缩进之间切换。

  • Partial Diff (ryu1kn.partial-diff): 这个扩展允许你在不同文件中比较两个代码片段。

  • Path Intellisense (christian-kohler.path-intellisense): 这个扩展会自动完成代码中的文件名。

  • Search - Open All Results (fabiospampinato.vscode-search-open-all-results): 这个扩展支持跨多个源文件搜索关键词。

  • Settings Sync (shan.code-settings-sync): 这个扩展将已安装的扩展和用户设置保存到一个 Gist 文件,并可以从该文件恢复。如果你在多台机器和系统上工作,这个工具非常有用。

参考资料和有用的阅读列表

  1. Udacity India. (2018, Mar 8). 为什么 Python 是机器学习中最流行的语言。取自medium.com/@UdacityINDIA/why-use-python-for-machine-learning-e4b0b4457a77

  2. S Bhutani. (2018 年 10 月 7 日). PyTorch 1.0 - PTDC '18 概要:PyTorch 1.0 预览与承诺. 来源:hackernoon.com/pytorch-1-0-468332ba5163.

  3. C Perone. (2018 年 10 月 2 日). PyTorch 1.0 跟踪 JIT 和 LibTorch C++ API 以将 PyTorch 集成到 NodeJS 中. 来源:blog.christianperone.com/2018/10/pytorch-1-0-tracing-jit-and-libtorch-c-api-to-integrate-pytorch-into-nodejs.

  4. T Wolf. (2018 年 10 月 15 日). 在更大的批量上训练神经网络:1-GPU、Multi-GPU 和分布式设置的实用技巧. 来源:medium.com/huggingface/training-larger-batches-practical-tips-on-1-gpu-multi-gpu-distributed-setups-ec88c3e51255.

摘要

哇!那真是做了很多工作和学习了大量信息。花点时间,拿杯咖啡或茶,休息一下,再回来。我等你。

让我们来看看我们做了哪些工作。

我们已经确保了 Python 安装是最新的,安装了 CUDA(假设我们有一张 NVIDIA GPU 显卡)并安装了 PyTorch。如果你和我一样,你一定是急不可待想开始编程了。

然而,在我们真正提高生产力之前,我们需要先定义一些基本概念,这也是我们的目标。在下一章中,我们将一起回顾一些基础知识。

第三章:模型设计与训练的最佳实践

在本章中,我们将利用到目前为止所学的内容,并提供一些基本信息,帮助您向前推进。我们将研究模型架构的整体设计,以及在选择需要的卷积操作时需要遵循的步骤。我们还将学习如何调整和优化损失函数与学习率。

在本章中,我们将讨论以下内容:

  • 模型设计备忘单

  • 模型训练备忘单

  • 高效的 Python 编程

  • 对深度学习初学者的建议

模型设计备忘单

在本节中,我们将为您概述在设计 GAN 模型架构以及一般深度学习模型时可以做出的各种选择。直接借用论文中看到的模型架构是完全可以的。同时,了解如何根据实际问题调整模型并从零开始创建全新的模型也是至关重要的。在设计模型时,还应考虑其他因素,如 GPU 内存容量和预期的训练时间。我们将讨论以下内容:

  • 整体模型架构设计

  • 选择卷积操作方法

  • 选择下采样操作方法

整体模型架构设计

深度学习模型的设计过程主要有两种,它们适用于不同的场景,您应该熟悉这两种过程:

  • 直接设计整个网络,尤其适用于浅层网络。您可以轻松地在网络中添加/删除任何层。在这种方法下,您可以轻松发现网络中的任何瓶颈(例如,哪个层需要更多/更少的神经元),这在设计将在移动设备上运行的模型时尤为重要。

  • 设计一个小模块/单元(包含若干层或操作),并重复这些模块多次以形成完整的网络。这种方法在非常深的网络中非常流行,特别是在网络架构搜索NAS)中。这种方法在发现模型中的弱点时稍显困难,因为你所能做的就是调整模块,训练整个网络几个小时,然后看看你的调整是否提高了性能。

在本书的某些章节中,将使用 U-Net 形状的网络(例如,pix2pixm,我们将在第五章,图像到图像的翻译及其应用中介绍)和 ResNet 形状的网络(例如,SRGAN,我们将在第七章,使用 GAN 的图像恢复中介绍)。这两种架构都通过模块化的方式进行设计,并使用跳跃连接连接非相邻层。在神经网络中,数据流有两种不同的形式:

  • 简单网络:网络中的任何层最多只有一个输入方向和一个输出方向。

  • 分支网络:至少有一层连接到两个以上的其他层,例如 ResNet 和 DenseNet。

你可能已经注意到,在本书中,普通网络通常用于判别器,而分支架构常用于生成器。这是因为生成器网络通常比判别器更难训练,而分支(例如跳跃连接)在前向传递中将低级细节传递给更深层的网络,并帮助反向传递中的梯度流动。

当我们处理网络中的分支时,如何合并多个分支(以便张量能够传递到另一个大小一致的块/单元)也会对网络的性能产生重大影响。以下是推荐的方法:

  • 将所有张量连接成一个列表,然后创建另一个卷积层,将该列表映射到一个较小的张量。这样,所有输入分支的信息都被保留,卷积层会学习它们之间的关系。然而,当处理非常深的网络时需要小心这种方法,因为它会消耗更多的内存,并且更多的参数意味着它更容易过拟合。

  • 直接将所有输入张量求和。这种方法易于实现,但当输入分支过多时,可能会表现不佳。

  • 在对分支求和之前,给每个分支分配可训练的权重因子。在这里,合并的张量将是输入张量的加权和。这使得网络能够确定应该回应哪些输入,并且如果某些分支的训练权重因子接近 0,你可以将其移除。

一般来说,如果你正在处理复杂的数据,尝试使用我们在本书中学到的经典模型。如果经典模型效果不好,试着构建一个基本模块(例如残差模块),并用它构建一个深度网络。更深的网络会带来更多的惊喜,当然,也需要更长时间的训练。

选择卷积操作方法

我们可以选择多种卷积操作,不同的配置会导致不同的结果。这里,我们将总结常用的卷积操作,并讨论它们的优缺点:

  1. 经典卷积:这是 CNN 中最常见的卷积操作。与具有相同输入/输出大小的全连接层(nn.Linear)相比,卷积所需的参数更少,并且可以通过 im2col 快速计算(更多细节请参见第七章,图像恢复与 GANs)。你可以使用以下代码片段来创建一个 ReLu-Conv-BN 组(当然,可以根据需要调整三者的顺序):
class ReLUConvBN(nn.Module):
    def __init__(self, C_in, C_out, kernel_size, stride, padding, affine=True):
        super(ReLUConvBN, self).__init__()
        self.op = nn.Sequential(
            nn.ReLU(inplace=False),
            nn.Conv2d(C_in, C_out, kernel_size, stride=stride, padding=padding, bias=False),
            nn.BatchNorm2d(C_out, affine=affine)
        )

    def forward(self, x):
        return self.op(x)
  1. 分组卷积:这里,输入/输出神经元之间的连接被分成了多个组。你可以通过调用nn.Conv2d并将groups参数设置为大于 1 的整数来创建一个分组卷积。通常会跟随一个卷积层,卷积核大小为 1,以便将来自不同组的信息混合在一起。只要卷积核大小大于 1,GroupConv-1x1Conv 组合的参数总是比常规卷积少。

  2. 深度可分离卷积:这是一种分组卷积,其中组大小等于输入通道数,后面跟一个 1x1 的卷积。只要卷积核大小大于 1,它的参数总是比常规卷积少。深度可分离卷积在移动设备的微小网络和神经架构搜索(NAS)中非常流行(人们尝试在有限的硬件资源下达到最高的性能)。通常会用它来查看两个深度可分离卷积是否能一起出现并提高性能。你可以使用以下代码片段来创建一个两层深度可分离卷积操作:

class SepConv(nn.Module):
    def __init__(self, C_in, C_out, kernel_size, stride, padding, affine=True):
        super(SepConv, self).__init__()
        self.op = nn.Sequential(
            nn.ReLU(inplace=False),
            nn.Conv2d(C_in, C_in, kernel_size=kernel_size, stride=stride, padding=padding, groups=C_in, bias=False),
            nn.Conv2d(C_in, C_in, kernel_size=1, padding=0, bias=False),
            nn.BatchNorm2d(C_in, affine=affine),
            nn.ReLU(inplace=False),
            nn.Conv2d(C_in, C_in, kernel_size=kernel_size, stride=1, padding=padding, groups=C_in, bias=False),
            nn.Conv2d(C_in, C_out, kernel_size=1, padding=0, bias=False),
            nn.BatchNorm2d(C_out, affine=affine)
        )
    def forward(self, x):
        return self.op(x)
  1. 膨胀卷积:与常规卷积相比,膨胀卷积具有更大的感受野。例如,一个  常规卷积有一个  滑动窗口,而一个  膨胀卷积有一个  滑动窗口,其中输入像素是按每两个相邻步骤取一个样本。然而,不建议在同一个网络中将膨胀卷积与其他类型的卷积(例如深度可分离卷积)结合使用。因为膨胀卷积通常需要更小的学习步长进行训练,这将大大减慢你的训练过程。你可以使用以下代码片段来创建一个膨胀卷积操作:
class DilConv(nn.Module):
    def __init__(self, C_in, C_out, kernel_size, stride, padding, dilation, affine=True):
        super(DilConv, self).__init__()
        self.op = nn.Sequential(
            nn.ReLU(inplace=False),
            nn.Conv2d(C_in, C_in, kernel_size=kernel_size, stride=stride, padding=padding, dilation=dilation, groups=C_in, bias=False),
            nn.Conv2d(C_in, C_out, kernel_size=1, padding=0, bias=False),
            nn.BatchNorm2d(C_out, affine=affine)
            )

    def forward(self, x):
        return self.op(x)

通常,常规卷积已经足够好。如果你的内存容量极为有限,深度可分离卷积绝对是你最佳的选择。

选择下采样操作方法

在网络中,增加或减少张量(特征图)大小往往是不可避免的。减小张量大小的过程称为下采样,而增大张量大小的过程称为上采样。下采样通常比上采样更棘手,因为我们不希望在较小的张量中丢失太多有用的信息。

在神经网络中,尤其是在卷积神经网络(CNN)中,有几种方法可以执行下采样。你可以根据自己的需求选择最合适的方法:

  • 最大池化(例如,nn.MaxPool2d),即在滑动窗口中选择最大值。这在早期的浅层网络中非常流行,例如 LeNet-5。然而,最大值不一定是特征图中最重要的特征。例如,最小值会发生什么呢?显然,在  张量中的最小值()比最大值()提供了更多关于这个张量包含什么模式的信息。

  • 平均池化(例如,nn.AvgPool2dnn.AdaptiveAvgPool2d),即在滑动窗口中取平均值。它比最大池化更受欢迎。如果你想进行快速降采样,应该选择平均池化而不是最大池化。

  • 跨步卷积,即卷积的步幅大于 1。事实上,这就是本书中大多数模型用于降采样的方法,因为这种方法可以同时提取特征并减少张量大小。值得指出的是,这种方法可能会导致大量信息丢失,因为滑动窗口在计算时跳过了很多像素。特征图的尺寸减少通常伴随着通道数的增加。例如,一个迷你批量张量 (四个维度分别表示批量大小、通道数、特征图高度和特征图宽度)通常会降采样到 ,以便输出张量包含与输入张量相似的信息量。

  • 因式分解降维,即执行两个带有轻微偏移的跨步卷积。在这种方法中,第二个卷积覆盖了第一个卷积跳过的像素。因此,更多的信息得以保留。它包含更多的参数,因此训练时间较长。你可以使用以下代码片段来执行因式分解降维:

class FactorizedReduce(nn.Module):
    def __init__(self, C_in, C_out, affine=True):
        super(FactorizedReduce, self).__init__()
        assert C_out % 2 == 0
        self.relu = nn.ReLU(inplace=False)
        self.conv_1 = nn.Conv2d(C_in, C_out // 2, 1, stride=2, padding=0, bias=False)
        self.conv_2 = nn.Conv2d(C_in, C_out // 2, 1, stride=2, padding=0, bias=False)
        self.bn = nn.BatchNorm2d(C_out, affine=affine)

    def forward(self, x):
        x = self.relu(x)
        out = torch.cat([self.conv_1(x), self.conv_2(x[:,:,1:,1:])], dim=1)
        out = self.bn(out)
        return out

如果你有足够的 GPU 内存,可以在模型中使用因式分解降维。如果内存不足,使用跨步卷积会节省很多内存。

更多关于模型设计

欢迎查看 PyTorch 的官方文档 torch.nn,了解更多关于各种层和操作的信息:pytorch.org/docs/stable/nn.html

模型训练备忘单

设计训练策略与模型设计一样重要——甚至更重要。有时候,一个好的训练策略能够让一个设计不佳的模型大放异彩。在这里,我们将讨论以下几个话题:

  • 参数初始化

  • 调整损失函数

  • 选择优化方法

  • 调整学习率

  • 梯度裁剪、权重裁剪等

参数初始化

有时,从书籍或论文中学习优化方法并用代码实现时,最令人沮丧的事情之一就是机器学习系统的初始状态(参数的初始值)可能会对模型的最终表现产生重大影响。了解参数初始化非常重要,尤其是在处理深度网络时。好的参数初始化意味着你不必总是依赖批量归一化(Batch Normalization)来确保在训练过程中参数的一致性。引用 PyTorch 文档中的一句话,"一个 PyTorch 张量基本上和一个 numpy 数组是一样的:它不知道深度学习、计算图或梯度,只是一个用于任意数值计算的通用 n 维数组。"这就是为什么会有这么多方法的原因,而且未来可能还会有更多的方法。

有几种流行的参数初始化方法。由于一些方法比较直观,我们不会深入探讨。需要注意的是,均匀分布通常用于全连接层,而正态分布通常用于卷积层。现在我们来回顾一些这些方法:

  • 均匀分布 (nn.init.uniform_(tensor, a, b)): 它使用均匀分布初始化tensor

  • 正态分布 (nn.init.normal_(tensor, a, b)): 它使用正态分布初始化tensor

  • Xavier 均匀分布 (nn.init.xavier_uniform_(tensor)): 它使用均匀分布初始化tensor,其公式为:

  • Xavier 正态分布 (nn.init.xavier_normal_(tensor)): 它使用正态分布初始化tensor,其公式为:

  • He 均匀分布(即 Kaiming 均匀分布或 MSRA 均匀分布,nn.init.kaiming_uniform_(tensor)):它使用均匀分布初始化tensor,其公式为:

  • He 正态分布(即 Kaiming 正态分布或 MSRA 正态分布,nn.init.kaiming_normal_(tensor)):它使用正态分布初始化tensor,其公式为:

  • 截断正态分布:在这种方法中,所有大于(或小于)两倍标准差(或负两倍标准差)的值都会被丢弃并重新生成。

除了使用 torch.nn.init 来初始化参数,你还可以创建自己的自定义初始化器。例如,下面是一个可以用于卷积层的初始化器,我们可以使用 numpyscipy.stats 来实现:

import numpy as np
from scipy import stats

def initializer_conv(shape,
                     init='he',
                     dist='truncnorm',
                     dist_scale=1.0):
    w_width = shape[3]
    w_height = shape[2]
    size_in = shape[1]
    size_out = shape[0]

    limit = 0.
    if init == 'xavier':
        limit = math.sqrt(2\. / (w_width * w_height * (size_in + size_out))) * dist_scale
    elif init == 'he':
        limit = math.sqrt(2\. / (w_width * w_height * size_in)) * dist_scale
    else:
        raise Exception('Arg `init` not recognized.')
    if dist == 'norm':
        var = np.array(stats.norm(loc=0, scale=limit).rvs(shape)).astype(np.float32)
    elif dist == 'truncnorm':
        var = np.array(stats.truncnorm(a=-2, b=2, scale=limit).rvs(shape)).astype(np.float32)
    elif dist == 'uniform':
        var = np.array(stats.uniform(loc=-limit, scale=2*limit).rvs(shape)).astype(np.float32)
    else:
        raise Exception('Arg `dist` not recognized.')
    return var

class Conv2d(nn.Conv2d):
    def __init__(self, in_channels, out_channels, kernel_size,
                 stride=1, padding=0, dilation=1, groups=1, bias=True,
                 init='he', dist='truncnorm', dist_scale=1.0):
        super(Conv2d, self).__init__(
            in_channels, out_channels, kernel_size, stride,
            padding, dilation, groups, bias)
        self.weight = nn.Parameter(torch.Tensor(
            initializer_conv([out_channels, in_channels // groups, kernel_size, kernel_size],
            init=init, dist=dist, dist_scale=dist_scale)))

有时不同的初始化方法对模型最终性能的影响不大,只要参数的幅度保持在相似的水平。在这种情况下,我们建议你在每一个微小的改进都重要时尝试不同的初始化方法。

调整损失函数

损失函数描述了训练过程的目标。我们在不同的 GAN 模型中看到了多种形式的损失函数,具体取决于它们的不同目标。设计正确的损失函数对于模型训练的成功至关重要。通常,一个 GAN 模型包含两个损失函数:一个生成器损失函数和一个判别器损失函数。当然,如果模型中有超过两个网络,还会有更多的损失函数需要处理。每个损失函数都可以有一个或多个正则化项。以下是三种最常见的形式:

在第七章,使用 GAN 进行图像恢复,我们将详细讨论 GAN 中的不同损失函数形式。请查阅该章节以了解更多信息。

最常用的两种正则化项如下:

  • L1 损失,

  • L2 损失,

在 L1 损失和 L2 损失中,可以是许多东西,例如两张图像之间的距离或图像的梯度。L2 损失往往产生更密集的结果(大多数值接近 0),而 L1 损失则产生更稀疏的结果(容忍一些大于 0 的离群值)。

值得一提的是,L2 正则化(L2-惩罚)对参数的作用本质上与权重衰减相同。原因如下:

第一个方程中的第二项是 L2-惩罚,第二个方程中的第二项是权重衰减。对第一个方程两边求导得到第二个方程。因此,神经网络中的 L2-惩罚和权重衰减本质上是相同的。

损失函数也是你将算法设计付诸实践的地方。例如,如果你的数据集有额外的标签信息,可以将其添加到损失函数中。如果你希望结果尽可能接近某个目标,可以将它们的距离添加到正则化项中。如果你希望生成的图像平滑,可以将它们的梯度添加到正则化项中。

选择优化方法

在这里,我们只讨论基于梯度的优化方法,这些方法在 GAN 中最为常用。不同的梯度方法各有其优缺点。没有一种通用的优化方法可以解决所有问题。因此,在面对不同的实际问题时,我们应该明智地选择优化方法。现在,让我们来看看一些:

  1. SGD(当momentum=0nesterov=False时调用optim.SGD):它在浅层网络中运行得很快并且表现良好。然而,对于深层网络来说,它可能会非常慢,甚至可能无法收敛:

在这个方程中,是迭代步骤中的参数,是学习率,是目标函数的梯度,

  1. Momentum(当momentum参数大于 0 且nestrov=False时调用optim.SGD):这是最常用的优化方法之一。该方法将前一步的更新与当前步骤的梯度相结合,从而使其比 SGD 具有更平滑的轨迹。Momentum 的训练速度通常比 SGD 快,并且一般适用于浅层和深层网络:

在这个方程中, 被称为动量项,通常设置为介于 0.5~0.9 之间的浮动值。

  1. Nesterov(当momentum参数大于 0 且nestrov=True时调用optim.SGD);这是动量法的一个变体。它在结合动量向量和梯度向量时,计算目标函数在迭代步骤中的“预测”梯度。理论上,它比动量法具有更快的收敛速度。当你的模型在使用动量法时遇到收敛问题时,你一定要尝试一下 Nesterov:

  1. AdaGradoptim.Adagrad):该方法用较小的学习率更新那些更新频繁的参数,用较大的学习率更新那些更新不频繁的参数。2012 年 Google 的 DistBelief 就使用了 AdaGrad。然而,由于学习率不断减小,这对于深度模型的长期训练是不利的,因此如今 AdaGrad 的使用并不广泛。

在这个方程中, 是从迭代步骤 的梯度平方的总和,随着时间推移增加并减少学习率,而 是一个非常小的值。

  1. RMSprop (optim.RMSprop): 这种方法与 AdaGrad 类似,不同之处在于它取的是梯度平方的移动平均,而不是它们的总和。这个方法在各种深度学习模型中并不常见。在第七章,《使用 GAN 进行图像恢复》中,我们明确指出在 Wasserstein GAN 中应该使用 RMSprop:

在这个方程中, 是直到迭代步骤 为止的 的移动平均,而 是平滑项,通常设置为非常接近 1 的值;例如,0.99 或 0.999。

  1. Adam (optim.Adam): 这种方法在某种程度上通过两个动量项结合了 Momentum 和 RMSprop。它是深度模型中最流行且最有效的优化方法之一。如果之前的所有方法在你的模型中表现不佳,Adam 是你最好的选择,特别是当你的模型非常深且参数之间的关系非常复杂时(例如,你的模型中有多个分支结构):

在这个方程中,动量系数()通常设置为非常接近 1 的值,例如,。方程的第三行存在是因为我们不希望在训练的开始阶段动量项接近 0,特别是当它们在 时通常初始化为零。请注意,Adam 的学习率应明显小于其他方法(如 Momentum)。

总之,当你为新模型尝试训练策略时,应该先尝试使用 Momentum,因为它具有更少的可调超参数且训练速度更快。当你对模型的表现感到满意时,总是值得尝试使用 Adam 来进一步挖掘其潜力。

调整学习率

现在你已经选择了优化方法,你需要为梯度方法设置合适的学习率并开始训练。通常,参数的更新在训练初期变化较大,容易察觉。经过长时间训练后,参数之间的关系已经确定,这时需要通过较小的学习率来微调参数。我们不能仅依赖优化方法(如 RMSprop 或 Adam)自动减少学习率。定期主动降低学习率,在训练过程中更为高效。

你可以使用optim.lr_scheduler来设置一个scheduler,并在每个 epoch 后调用scheduler.step(),如下代码所示:

scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=50, gamma=0.1)
for epoch in range(epochs):
    ...
    scheduler.step()

你还可以创建自己的自定义 scheduler,如下所示:

class LRScheduleCosine(object):
    def __init__(self, optimizer, epoch=0, epoch_start=0, lr_max=0.05, lr_min=0.001, t_mul=10):
        self.optimizer = optimizer
        self.epoch = epoch
        self.lr_min = lr_min
        self.lr_max = lr_max
        self.t_start = epoch_start
        self.t_mul = t_mul
        self.lr = lr_max

    def step(self):
        self.epoch += 1
        self.lr = self.lr_min + 0.5*(self.lr_max-self.lr_min)*(1.+math.cos(math.pi*(self.epoch-self.t_start)/self.t_mul))
        if self.optimizer is not None:
            for param_group in self.optimizer.param_groups:
                param_group['lr'] = self.lr
        if self.epoch == self.t_start + self.t_mul:
            self.t_start += self.t_mul
            self.t_mul *= 2
        return self.lr

这是带有热重启的余弦调度的实现。要在训练中使用它,只需按以下方式调用:

scheduler = LRScheduleCosine(optimizer,
                                       lr_max=0.025,
                                       lr_min=0.001,
                                       t_mul=10)
for epoch in range(epochs):
    lr = scheduler.step()
    ...

学习率将在前 10 个 epoch 中从 0.025 降低到 0.001,重启为 0.025 并在接下来的 10 个 epoch 中降低至 0.001,然后在接下来的 40 个 epoch 中再次重启到 0.025 并降低至 0.001,以此类推。

你可以查看 PyTorch 的官方文档,了解更多类型的 scheduler:pytorch.org/docs/stable/optim.html#how-to-adjust-learning-rate

梯度裁剪、权重裁剪等

在本书的第一章,第一章《生成对抗网络基础》中,我们使用 NumPy 创建了一个简单的 GAN,利用梯度裁剪和权重裁剪生成正弦信号,确保训练收敛。我们来回顾一下这些技巧为何对你的模型有用:

  • 梯度裁剪:梯度基本上告诉我们如何更新参数。通常,较大的梯度会导致对参数的更大更新。如果恰好在我们的搜索位置周围损失曲面很陡,较大的梯度值可能意味着我们将在下一次迭代中跳得很远,需要从新区域重新寻找最优解。因此,裁剪梯度并设定其最大/最小值限制,可以确保我们不会在长时间训练后破坏之前的搜索结果。你可以使用nn.utils.clip_grad_norm_进行梯度裁剪。

  • 梯度消失:当梯度变化过小时,也可能会导致问题。通常,这是因为输入数据被压缩得过于紧凑,无法让系统正确学习。如果出现这种情况,可以考虑使用ReLU或 Leaky ReLU,我们在第一章《生成对抗网络基础》中介绍过这两种方法,生成对抗网络的基础

  • 权重裁剪:这不是一种广泛使用的技术,除了它在 Wasserstein GAN 中的应用(第七章,基于 GAN 的图像修复)。它是一种间接的梯度裁剪方法。因此,不必在同一个模型中同时使用这两种技术。在第一章,生成对抗网络基础 中的示例中,我们使用了这两者,以确保模型没有出错。

高效的 Python 编程

本书中你看到的大部分代码都是用 Python 编写的。几乎所有流行的深度学习工具(如 PyTorch、TensorFlow、Keras、MXNet 等)也都是用 Python 编写的。与其他面向对象编程OOP)语言如 C++ 和 Java 相比,Python 更易学且更易用。然而,使用 Python 并不意味着我们可以懒散地编写代码。我们永远不应满足于 它能工作。在深度学习中,高效的代码可以为我们节省数小时的训练时间。在本节中,我们将为你提供一些关于编写高效 Python 项目的建议和技巧。

明智地发明轮子

创新型开发者不热衷于重新发明轮子,也就是实现项目中每一个可以轻松从 GitHub 或第三方库获取的微小组件。深度学习依赖开源,世界上任何人都可以学习并做出酷炫的事情。我们鼓励你利用任何可用的工具来解决实际问题,只要它能节省你宝贵的时间。本书中的一些模型实现来自 GitHub 上其他人的项目。想象一下,如果我们要根据已经发布的论文去弄清楚所有实现细节,可能需要花费多长时间!

以下是一些网站,当你寻找特定工具或代码片段时可能会派上用场:

深度学习初学者的建议

以下是深度学习初学者应该遵循的一些建议:

  • 设定合理且坚实的目标和期限:给自己足够的时间来研究、学习和实验一个主题。从目标开始,然后创建一系列能够实现该目标的步骤。记录下你的进展。

  • 在网上查找与你正在进行的项目相关的信息:互联网通常是收集特定主题信息的最快方式。先从简单而直接的搜索文本开始,然后逐步细化你的搜索,以获取最佳资源。

  • 小步走比大跳跃更好:在你阅读某篇文章或章节时,将代码复制到你的集成开发环境(IDE)中并运行项目。理解输入、输出以及生成它们的代码后,才可以继续学习。

  • 尝试寻找预训练模型:一旦你掌握了基本信息并理解了模型过程,就使用预训练模型来节省时间和硬件资源。同样,把结果记录在日志中。

  • 将你搜索到的结果进行实验:在你进行研究和测试的过程中,可能会对该主题产生一些想法。把它们记录下来,并将你的想法与所学的内容进行对比测试。

  • 选择你能够承受的最好的硬件,而不至于超出预算:这可能是最重要的建议。一台配备优秀显卡和 GPU 的好电脑,拥有尽可能多的内存,将可能为你的工作节省数小时。

总结

在本章中,我们研究了模型架构的整体设计以及在选择最佳卷积操作时所需的步骤。

在下一章中,我们将介绍一个经典的高性能 GAN 模型——DCGAN,该模型用于生成二维图像。

第二章:典型的 GAN 图像合成模型

本节将介绍用于图像生成、翻译和修复的典型 GAN 模型的架构、训练策略和评估方法,并提供实际的工作代码。

本节包含以下章节:

  • 第四章,使用 PyTorch 构建你的第一个 GAN

  • 第五章,基于标签信息生成图像

  • 第六章,图像到图像翻译及其应用

  • 第七章,使用 GAN 进行图像修复

  • 第八章,训练你的 GAN 以突破不同模型

  • 第九章,从描述文本生成图像

  • 第十章,使用 GAN 进行序列合成

  • 第十一章,使用 GAN 重建 3D 模型

第五章:使用 PyTorch 构建您的第一个 GAN

在前几章中,我们讲解了使用对抗学习生成简单信号的概念,并学习了 PyTorch 1.3 的新特性和能力。现在是时候使用 PyTorch 来训练一个 GAN 模型,生成有趣的样本了。

在这一章中,我们将向您介绍一个经典且表现优异的 GAN 模型,称为 DCGAN,用于生成 2D 图像。您将学习以下内容:

  • DCGAN 的架构

  • DCGAN 的训练与评估

  • 使用 DCGAN 生成手写数字、人脸图像

  • 通过对潜在向量进行图像插值和算术计算,改变图像属性,与生成器网络一起玩乐

到本章结束时,您将掌握 GAN 模型生成图像数据的核心架构设计,并更好地理解潜在向量与生成样本之间的关系。

深度卷积生成对抗网络(DCGAN)简介

DCGAN深度卷积生成对抗网络)是早期表现良好且稳定的生成图像的对抗训练方法之一。让我们回顾一下第一章中的简单例子,生成对抗网络基础

在这里,即使我们只训练一个 GAN 来处理 1D 数据,我们也必须使用多种技术来确保训练的稳定性。在 GAN 的训练过程中,很多问题都可能出现。例如,如果生成器或判别器没有收敛,其中一个可能会过拟合。有时,生成器只能生成少数几种样本变体,这被称为模式崩溃。以下是模式崩溃的一个例子,我们想要训练一个 GAN 来生成一些中国的热门表情包,叫做暴走。我们可以看到,GAN 只能一次生成一两个表情包。其他机器学习算法中常见的梯度消失/爆炸和欠拟合等问题,在 GAN 训练中也很常见。因此,单纯将 1D 数据替换为 2D 图像并不能轻易保证训练成功:

GAN 训练中的模式崩溃(左:一些训练样本;中:第 492 次迭代的结果;右:第 500 次迭代的结果)

为了确保 GAN 在此类图像数据上的稳定训练,DCGAN 使用了三种技术:

  • 摒弃全连接层,仅使用卷积层

  • 使用步长卷积层进行下采样,而不是使用池化层

  • 使用 ReLU/leakyReLU 激活函数替代隐藏层之间的 Tanh

本节将介绍 DCGAN 的生成器和判别器架构,并学习如何使用它生成图像。我们将使用 MNIST (yann.lecun.com/exdb/mnist) 样本来说明 DCGAN 的架构,并在接下来的两节中使用它来训练模型。

生成器的架构

DCGAN 的生成器网络包含 4 个隐藏层(为简化起见,我们将输入层视为第 1 个隐藏层)和 1 个输出层。隐藏层中使用转置卷积层,后面跟随批量归一化层和 ReLU 激活函数。输出层也是一个转置卷积层,使用 Tanh 作为激活函数。生成器的架构如下图所示:

DCGAN 中的生成器架构

第 2、3、4 个隐藏层和输出层的步幅值为 2。第 1 层的填充值为 0,其余层的填充值为 1。随着图像(特征图)尺寸在更深的层中增加一倍,通道数减少一半。这是神经网络架构设计中的一种常见约定。所有转置卷积层的卷积核大小都设置为 4 x 4。输出通道可以是 1 或 3,具体取决于你是想生成灰度图像还是彩色图像。

转置卷积层可以被看作是普通卷积的逆过程。它曾一度被一些人称为反卷积层,这种叫法具有误导性,因为转置卷积并不是卷积的操作。从线性代数的角度来看,大多数卷积层是不可逆的,因为它们是病态的(具有极大的条件数),这使得它们的伪逆矩阵不适合表示逆过程。如果你有兴趣寻找卷积核的逆运算方法,可以在互联网上搜索数值反卷积方法。

判别器的架构

DCGAN 的判别器网络由 4 个隐藏层(同样,我们将输入层视为第 1 个隐藏层)和 1 个输出层组成。所有层中都使用卷积层,后面跟随批量归一化层,除了第一层没有批量归一化。隐藏层中使用 LeakyReLU 激活函数,输出层使用 Sigmoid 激活函数。判别器的架构如下所示:

DCGAN 中的判别器架构

输入通道可以是 1 或 3,具体取决于你处理的是灰度图像还是彩色图像。所有隐藏层的步幅值为 2,填充值为 1,因此它们的输出图像尺寸将是输入图像的一半。随着图像在更深层次的尺寸增大,通道的数量会翻倍。卷积层中的所有卷积核大小为 4 x 4。输出层的步幅值为 1,填充值为 0。它将 4 x 4 的特征图映射为单一值,以便 Sigmoid 函数能够将该值转换为预测置信度。

使用 PyTorch 创建 DCGAN

现在,让我们开始编写 PyTorch 代码来创建一个 DCGAN 模型。在这里,我们假设你正在使用 Ubuntu 18.04 的 Python 3.7 环境。如果不是,请参考 第二章,PyTorch 1.3 入门,了解如何创建 Anaconda 环境。

首先,我们创建一个名为 dcgan.py 的 Python 源文件,并导入我们需要的包:

import os
import sys

import numpy as np
import torch
import torch.nn as nn
import torch.nn.parallel
import torch.backends.cudnn as cudnn
import torch.optim as optim
import torch.utils.data
import torchvision.datasets as dset
import torchvision.transforms as transforms
import torchvision.utils as vutils

import utils

这里,NumPy 仅用于初始化随机种子。如果你没有安装 NumPy,只需将 np.random 替换为 random,并在 import os 后插入 import random。在代码的最后一行,我们导入了一个名为 utils 的模块,它是一个自定义的实用程序包,定义在 utils.py 文件中。utils.py 的完整源代码可以在本章节的代码仓库中找到。

在本书中,我们将把大部分与 PyTorch 无关的辅助函数(包括文件组织、学习率调整、日志记录、张量可视化等)放在 utils.py 文件中。因此,在未来的章节中我们还会遇到这个模块。随着章节的推进,别忘了更新这个文件。

然后,我们定义输出路径和超参数。请注意,这里我们将生成器和判别器中隐藏层的最小通道大小设置为 64,因为我们发现之前展示的 128 可能导致判别器的过拟合:

CUDA = True
DATA_PATH = '~/Data/mnist'
OUT_PATH = 'output'
LOG_FILE = os.path.join(OUT_PATH, 'log.txt')
BATCH_SIZE = 128
IMAGE_CHANNEL = 1
Z_DIM = 100
G_HIDDEN = 64
X_DIM = 64
D_HIDDEN = 64
EPOCH_NUM = 25
REAL_LABEL = 1
FAKE_LABEL = 0
lr = 2e-4
seed = 1

如果你没有 CUDA 支持的显卡,并且想在 CPU 上训练网络,你可以将 CUDA 设置为 FalseDATA_PATH 指向 MNIST 数据集的根目录。如果你还没有下载并正确预处理 MNIST,只需将其指向任意目录(例如 '.'),稍后我们可以下载数据。BATCH_SIZE 会对代码消耗的 GPU 内存量产生重大影响。如果你不确定哪个批量大小适合你的系统,可以从一个较小的值开始,训练模型 1 个 epoch,然后将批量大小加倍,直到出现错误。

对于 MNIST,设置 BATCH_SIZE 为 128 应该足够,而且 GPU 内存消耗不到 1GB。IMAGE_CHANNEL 描述图像样本的颜色通道数。由于 MNIST 中的所有图像都是单通道的,因此我们应该将其设置为 1。EPOCH_NUM 对神经网络的训练时间有很大的影响。如果你希望得到更好的结果,通常将 epoch 数量设置大一点,学习率设置小一点是个不错的策略。我们将 seed=1,这样你的结果应该和我们在本书中得到的结果完全一致。

接下来,在创建网络之前,我们需要做一些准备工作:

utils.clear_folder(OUT_PATH)
print("Logging to {}\n".format(LOG_FILE))
sys.stdout = utils.StdOut(LOG_FILE)
CUDA = CUDA and torch.cuda.is_available()
print("PyTorch version: {}".format(torch.__version__))
if CUDA:
    print("CUDA version: {}\n".format(torch.version.cuda))
if seed is None:
    seed = np.random.randint(1, 10000)
print("Random Seed: ", seed)
np.random.seed(seed)
torch.manual_seed(seed)
if CUDA:
    torch.cuda.manual_seed(seed)
cudnn.benchmark = True
device = torch.device("cuda:0" if CUDA else "cpu")

在这里,utils.clear_folder(OUT_PATH) 将清空输出文件夹,并在该文件夹不存在时创建一个。sys.stdout = utils.StdOut(LOG_FILE) 将把所有 print 的消息重定向到日志文件,并同时在控制台显示这些消息。如果你对实现感兴趣,可以参考 utils.py 文件。cudnn.benchmark = True 将告诉 cuDNN 为你的模型选择最优的算法集,如果输入数据的大小是固定的;否则,cuDNN 将在每次迭代时都寻找最佳算法。

如果你之前在使用 PyTorch 进行 CNN 训练时遇到过问题,你可能会注意到,有时候设置 cudnn.benchmark = True 会显著增加 GPU 内存消耗,特别是在模型架构在训练过程中发生变化且你在代码中同时进行训练和评估时。如果遇到奇怪的 OOM (内存溢出) 问题,请将其改为 False

生成器网络

现在,让我们使用 PyTorch 来定义生成器网络:

class Generator(nn.Module):
    def __init__(self):
        super(Generator, self).__init__()
        self.main = nn.Sequential(
            # 1st layer
            nn.ConvTranspose2d(Z_DIM, G_HIDDEN * 8, 4, 1, 0, bias=False),
            nn.BatchNorm2d(G_HIDDEN * 8),
            nn.ReLU(True),
            # 2nd layer
            nn.ConvTranspose2d(G_HIDDEN * 8, G_HIDDEN * 4, 4, 2, 1, bias=False),
            nn.BatchNorm2d(G_HIDDEN * 4),
            nn.ReLU(True),
            # 3rd layer
            nn.ConvTranspose2d(G_HIDDEN * 4, G_HIDDEN * 2, 4, 2, 1, bias=False),
            nn.BatchNorm2d(G_HIDDEN * 2),
            nn.ReLU(True),
            # 4th layer
            nn.ConvTranspose2d(G_HIDDEN * 2, G_HIDDEN, 4, 2, 1, bias=False),
            nn.BatchNorm2d(G_HIDDEN),
            nn.ReLU(True),
            # output layer
            nn.ConvTranspose2d(G_HIDDEN, IMAGE_CHANNEL, 4, 2, 1, bias=False),
            nn.Tanh()
        )

    def forward(self, input):
        return self.main(input)

请注意,输出层没有连接批归一化层。

让我们创建一个 helper 函数来初始化网络参数:

def weights_init(m):
    classname = m.__class__.__name__
    if classname.find('Conv') != -1:
        m.weight.data.normal_(0.0, 0.02)
    elif classname.find('BatchNorm') != -1:
        m.weight.data.normal_(1.0, 0.02)
        m.bias.data.fill_(0)

在生成器网络中,只有两种类型的层包含可训练的参数:转置卷积层和批归一化层。在这里,我们根据高斯分布(正态分布)初始化卷积核,均值为 0,标准差为 0.02。我们还需要初始化批归一化中的仿射参数(缩放因子)。

现在,我们可以按照以下方式创建一个 Generator 对象:

netG = Generator().to(device)
netG.apply(weights_init)
print(netG)

我们可以通过直接打印生成器网络来检查其中包含的模块。考虑到输出的长度,我们不会显示它的输出。

判别器网络

现在,让我们定义判别器网络:

class Discriminator(nn.Module):
    def __init__(self):
        super(Discriminator, self).__init__()
        self.main = nn.Sequential(
            # 1st layer
            nn.Conv2d(IMAGE_CHANNEL, D_HIDDEN, 4, 2, 1, bias=False),
            nn.LeakyReLU(0.2, inplace=True),
            # 2nd layer
            nn.Conv2d(D_HIDDEN, D_HIDDEN * 2, 4, 2, 1, bias=False),
            nn.BatchNorm2d(D_HIDDEN * 2),
            nn.LeakyReLU(0.2, inplace=True),
            # 3rd layer
            nn.Conv2d(D_HIDDEN * 2, D_HIDDEN * 4, 4, 2, 1, bias=False),
            nn.BatchNorm2d(D_HIDDEN * 4),
            nn.LeakyReLU(0.2, inplace=True),
            # 4th layer
            nn.Conv2d(D_HIDDEN * 4, D_HIDDEN * 8, 4, 2, 1, bias=False),
            nn.BatchNorm2d(D_HIDDEN * 8),
            nn.LeakyReLU(0.2, inplace=True),
            # output layer
            nn.Conv2d(D_HIDDEN * 8, 1, 4, 1, 0, bias=False),
            nn.Sigmoid()
        )

    def forward(self, input):
        return self.main(input).view(-1, 1).squeeze(1)

请注意,输入层没有连接批归一化层。这是因为,当将批归一化应用到所有层时,可能会导致样本震荡和模型不稳定,正如原始论文中所指出的那样。

同样,我们可以按以下方式创建一个 Discriminator 对象:

netD = Discriminator().to(device)
netD.apply(weights_init)
print(netD)

模型训练与评估

我们将使用 Adam 作为生成器和判别器网络的训练方法。如果你对梯度下降方法的细节感兴趣,请参考第三章,模型设计与训练最佳实践,以了解更多常见的训练方法。

让我们首先为判别器网络定义损失函数,并为两个网络定义optimizers

criterion = nn.BCELoss()

optimizerD = optim.Adam(netD.parameters(), lr=lr, betas=(0.5, 0.999))
optimizerG = optim.Adam(netG.parameters(), lr=lr, betas=(0.5, 0.999))

在这里,nn.BCELoss()表示二元交叉熵损失函数,我们在第一章,生成对抗网络基础中曾经使用过。

接下来,让我们将 MNIST 数据集加载到 GPU 内存中:

dataset = dset.MNIST(root=DATA_PATH, download=True,
                     transform=transforms.Compose([
                     transforms.Resize(X_DIM),
                     transforms.ToTensor(),
                     transforms.Normalize((0.5,), (0.5,))
                     ]))
assert dataset
dataloader = torch.utils.data.DataLoader(dataset, batch_size=BATCH_SIZE,
                                         shuffle=True, num_workers=4)

在处理小数据集时,你还可以在调用torch.utils.data.DataLoader()时添加pin_memory=True参数,这样可以确保数据存储在固定的 GPU 内存地址,从而加快训练时的数据加载速度。

训练迭代

训练过程与第一章中的简单示例基本相同,生成对抗网络基础

  1. 使用真实数据训练判别器,并将其识别为真实数据。

  2. 使用虚假数据训练判别器,并将其识别为虚假数据。

  3. 使用虚假数据训练生成器,并将其识别为真实数据。

前两个步骤让判别器学习如何区分真实数据和虚假数据。第三步教会生成器如何用生成的样本混淆判别器:

viz_noise = torch.randn(BATCH_SIZE, Z_DIM, 1, 1, device=device)
for epoch in range(EPOCH_NUM):
    for i, data in enumerate(dataloader):
        x_real = data[0].to(device)
        real_label = torch.full((x_real.size(0),), REAL_LABEL, device=device)
        fake_label = torch.full((x_real.size(0),), FAKE_LABEL, device=device)

        # Update D with real data
        netD.zero_grad()
        y_real = netD(x_real)
        loss_D_real = criterion(y_real, real_label)
        loss_D_real.backward()

        # Update D with fake data
        z_noise = torch.randn(x_real.size(0), Z_DIM, 1, 1, device=device)
        x_fake = netG(z_noise)
        y_fake = netD(x_fake.detach())
        loss_D_fake = criterion(y_fake, fake_label)
        loss_D_fake.backward()
        optimizerD.step()

        # Update G with fake data
        netG.zero_grad()
        y_fake_r = netD(x_fake)
        loss_G = criterion(y_fake_r, real_label)
        loss_G.backward()
        optimizerG.step()

        if i % 100 == 0:
            print('Epoch {} [{}/{}] loss_D_real: {:.4f} loss_D_fake: 
              {:.4f} loss_G: {:.4f}'.format(
                epoch, i, len(dataloader),
                loss_D_real.mean().item(),
                loss_D_fake.mean().item(),
                loss_G.mean().item()
            ))

在这里,我们实时创建real_labelfake_label张量,因为不能保证所有样本批次的大小相同(最后一个批次通常较小,具体取决于批量大小和训练样本的总数)。

可视化生成的样本

如果我们能够检查生成器的训练效果会更好。因此,我们需要在训练过程中导出生成的图像。请在if语句的末尾添加以下代码行:

if i % 100 == 0:
            ...
            vutils.save_image(x_real, os.path.join(OUT_PATH, 'real_samples.png'), normalize=True)
            with torch.no_grad():
                viz_sample = netG(viz_noise)
                vutils.save_image(viz_sample, os.path.join(OUT_PATH, 'fake_samples_{}.png'.format(epoch)), normalize=True)
    torch.save(netG.state_dict(), os.path.join(OUT_PATH, 'netG_{}.pth'.format(epoch)))
    torch.save(netD.state_dict(), os.path.join(OUT_PATH, 'netD_{}.pth'.format(epoch)))

现在,你的 DCGAN 准备好进行训练了。打开终端,activate Anaconda 环境并开始训练 DCGAN:

 $ conda activate torch
(torch)$ python dcgan.py

在 GTX 1080Ti 显卡上训练大约需要 13 分钟。如果你在训练未完成前就不喜欢生成的样本,你可以随时按Ctrl + C来取消训练。第 1 轮和第 25 轮后的生成图像如下所示。请注意,我们只展示生成图像的一半(即 64 个样本)。

我们可以看到,DCGAN 在生成手写数字方面表现不错:

DCGAN 在 MNIST 数据集上经过第 1 轮和第 25 轮后的生成图像

供你参考,以下是不同BATCH_SIZE值下 GPU 内存消耗的列表。请注意,无论批量大小如何,训练的总时间几乎不变,因为计算的总工作量基本相同:

批量大小 128 256 512 1024 2048
GPU 内存 939 MB 1283 MB 1969 MB 3305 MB 6011 MB

检查 GPU 使用信息

在这里,我们将讨论如何在 Windows 10 和 Ubuntu 18.04 中查看 GPU 使用情况以及其他硬件使用信息。

在 Windows 10 中,检查硬件使用情况(包括 GPU 使用情况)最简单的方法是使用任务管理器。你可以通过按Ctrl + Shift + Esc打开任务管理器,并切换到性能面板。现在,你可以查看所有硬件使用情况信息。

在 Ubuntu 18.04 中,你可以使用GNOME 系统监视器查看 CPU、RAM 和磁盘使用情况,它是系统自带的。你可以在应用菜单中搜索系统监视器,或者在终端中运行gnome-system-monitor来打开它。

或者,你可以安装一个 GNOME 扩展来在状态栏中显示使用图表。我们建议你使用system-monitor 扩展extensions.gnome.org/extension/120/system-monitor)来实现这个目的。要安装它,你首先需要安装几个先决条件:

$ sudo apt-get install gir1.2-gtop-2.0 gir1.2-networkmanager-1.0 gir1.2-clutter-1.0 gir1.2-clutter-gst-3.0 gir1.2-gtkclutter-1.0

然后,打开 Firefox 浏览器,访问这个网站,addons.mozilla.org/en-US/firefox/addon/gnome-shell-integration,安装浏览器扩展程序,以便轻松安装gnome.org提供的 GNOME 扩展。你还需要在终端中运行sudo apt-get install chrome-gnome-shell

接下来,打开网页,extensions.gnome.org/extension/120/system-monitor,使用 Firefox 浏览器;你将看到扩展标题右侧的开关按钮。点击它将开关切换到ON,然后你将被提示安装 system-monitor 扩展。

最后,按下Alt + F2,输入r,然后按Enter。这将重新启动 GNOME shell,从而激活 system-monitor 扩展。

要在 Ubuntu 中检查 GPU 使用情况,你可以在终端中运行这个脚本,实时显示 GPU 使用情况:

watch -n 0.5 nvidia-smi

你还可以在一个方便的目录中创建一个.sh文件,例如~/gpu.sh:将脚本复制到此文件中,然后运行chmod +x ~/.gpu.sh。然后,你可以在终端中简单地运行./gpu.sh,每当需要检查 GPU 使用情况时。

另外,Ubuntu 上还有许多其他工具可以使用,例如 NVTOP(github.com/Syllo/nvtop)。

迁移到更大的数据集

生成数字是有趣的。我们可以通过生成其他东西,比如人脸和卧室照片,获得更多的乐趣。为了生成像这样的复杂图像,我们需要比 MNIST 提供的 60,000 个样本更多的训练样本。在本节中,我们将下载两个更大的数据集(CelebA 和 LSUN),并在其上训练 DCGAN,以获得更复杂的生成样本。

从 CelebA 数据集中生成人脸

CelebFaces 属性(CelebAmmlab.ie.cuhk.edu.hk/projects/CelebA.html)数据集是一个大规模的面部属性数据集,包含超过 200,000 张名人图像,每张图像有 40 个属性标注。我们需要下载裁剪和对齐过的图像。由于我们在这里不需要任何属性标注,所以只需要下载名为img_align_celeba.zip的文件,文件大小不超过 2 GB。

如果你无法从官方链接下载 CelebA 数据集,可以尝试以下 Kaggle 提供的链接和 PyTorch 官方教程:www.kaggle.com/jessicali9530/celeba-datasetdrive.google.com/drive/folders/0B7EVK8r0v71pWEZsZE9oNnFzTm8。请注意,你只需要从 Google Drive 链接下载Img/img_align_celeba.zip文件。

将下载的图像提取到一个目录中,例如~/Data/CelebA。确保所有的图像都存储在这个根目录下的独立子目录中,以便图像存储在像~/Data/CelebA/img_align_celeba/000001.png这样的路径下。

如果你的机器上有足够空间的固态硬盘SSD),我们强烈建议将所有训练样本移至 SSD,特别是当你有一块强力的显卡时。因为在训练非常大的数据集时,如果数据集无法完全加载到 GPU 内存中,从物理硬盘读取数据的速度可能会成为训练性能的瓶颈。有时,SSD(50 MB/s)的读取速度相比传统硬盘(5 MB/s)可以大大缩短训练时间。

我们只需要在上一节中修改 3 个不同的代码部分,就可以在 CelebA 数据集上训练 DCGAN:

  1. 更改数据集根目录:
DATA_PATH = '/media/john/FastData/CelebA'    # Load data from SSD

如果你不确定在 Ubuntu 文件管理器中当前的绝对路径,可以按 Ctrl + L,此时完整路径会显示出来。

  1. 更改图像通道数:
IMAGE_CHANNEL = 3
  1. 重新定义dataset对象:
dataset = dset.ImageFolder(root=DATA_PATH,
                           transform=transforms.Compose([
                           transforms.Resize(X_DIM),
                           transforms.CenterCrop(X_DIM),
                           transforms.ToTensor(),
                           transforms.Normalize((0.5, 0.5, 0.5),  
                                               (0.5, 0.5, 0.5)),
                           ]))

现在,让我们在终端中运行python dcgan.py并等待一段时间。在 GTX 1080Ti 显卡上,完成 25 轮训练大约需要 88 分钟。生成的图像分别是第 1 轮和第 25 轮训练后的结果。这里我们仅展示 64 张生成的样本:

DCGAN 在 CelebA 数据集上经过第 1 轮和第 25 轮训练后生成的图像

下面是不同BATCH_SIZE值下的 GPU 内存使用情况:

批量大小 64 128 256 512 1024 2048
GPU 内存 773 MB 963 MB 1311 MB 2029 MB 3441 MB 6283 MB

从 LSUN 数据集生成卧室照片

LSUN(大规模场景理解,www.yf.io/p/lsun)是一个包含 10 个场景类别和 20 个物体类别的大型图像数据集。你可以从github.com/fyu/lsun获取下载工具包。我们将使用bedroom类别来训练我们的 DCGAN,它包含超过 300 万张卧室照片:

$ git clone https://github.com/fyu/lsun.git
$ cd lsun
$ python download.py -c bedroom

你还可以使用python data.py export bedroom_train_lmdb --out_dir bedroom_train_img将图像导出为单独的文件,这样你就可以轻松地将这些图像用于其他项目。但尽量不要直接通过文件管理器打开图像文件夹,因为这会占用大量内存和时间。

数据集保存在LMDBLightning Memory-Mapped Database Manager)数据库文件中,文件大小约为 54GB。确保数据库文件位于bedroom_train_lmdb目录下,以便 PyTorch 的数据加载器在指定根目录时能够识别它。

类似地,我们只需要更改代码中的 3 个部分,就能使用 LSUN 数据集来训练我们的模型:

  1. 更改数据集根目录:
DATA_PATH = '/media/john/FastData/lsun'    # Load data from SSD
  1. 更改图像通道数:
IMAGE_CHANNEL = 3
  1. 重新定义dataset对象:
dataset = dset.LSUN(root=DATA_PATH, classes=['bedroom_train'],
                    transform=transforms.Compose([
                    transforms.Resize(X_DIM),
                    transforms.CenterCrop(X_DIM),
                    transforms.ToTensor(),
                    transforms.Normalize((0.5, 0.5, 0.5), 
                                        (0.5, 0.5, 0.5)),
                    ]))

别忘了为 Python 安装lmdb库,这样我们才能读取数据库文件:

$ pip install lmdb

现在,保存源文件并在终端中运行python dcgan.py。由于 LSUN 数据集中有更多的样本,我们不需要训练 25 个 epoch。一些生成的图像在第 1 个 epoch 训练后就已经相当出色了。在 GTX 1080Ti 显卡上,训练 5 个 epoch 大约需要 5 小时。以下是第 1 个 epoch 和第 25 个 epoch 生成的图像。这里我们只展示 64 个生成样本。由于 LSUN 和 CelebA 的输入图像都是 3 通道,并且网络结构没有变化,我们不展示 LSUN 的 GPU 内存消耗,它几乎与 CelebA 相同:

DCGAN 生成的 LSUN 图像,分别是在第 1 和第 5 个 epoch 后生成的

再次强调,如果你计划在大数据集上训练 GAN,务必考虑使用强大的 GPU,并将数据集放在 SSD 上。这里,我们提供了两组性能对比。在第一种配置中,我们使用 NVIDIA GTX 960 显卡,并将训练集放在HDD硬盘驱动器)上。在第二种配置中,我们使用 NVIDIA GTX 1080Ti 显卡,并将训练集放在 SSD 上。我们可以看到强大平台的加速效果堪称改变人生:

数据集 CelebA LSUN
GTX 960 + HDD 2 小时/epoch 16.6 小时/epoch
GTX 1080Ti + SSD 3.5 分钟/epoch 53 分钟/epoch
加速 34X 19X

玩转生成器网络

现在我们的第一个图像生成器已经训练完成,你是否好奇它能做些什么,以及如何从随机噪声向量生成图像?在本节中,我们将通过生成器网络来进行一些有趣的操作。首先,我们将选择两个随机向量并计算它们之间的插值,看看会生成什么样的图像。其次,我们将选择一些典型的向量并对其进行算术运算,看看生成的样本中会出现什么变化。

首先,我们需要一个测试版本的 DCGAN 代码。

将原始的 dcgan.py 文件复制到 dcgan_test.py。接下来,我们需要对新文件进行一些修改。首先,我们需要替换掉这些仅包含 Generator 类的行:

netG = Generator().to(device)
netG.apply(weights_init)
print(netG)

我们将它们替换为以下几行(你可以删除它们,或者只需注释掉它们):

netG = Generator()
negG.load_state_dict(torch.load(os.path.join(OUT_PATH, 'netG_24.pth')))
netG.to(device)

接下来,我们需要移除(或注释掉)weights_initDiscriminatordatasetdataloadercriterionoptimizer 对象。

接下来,我们需要将整个训练迭代部分替换为以下内容:

if VIZ_MODE == 0:
    viz_tensor = torch.randn(BATCH_SIZE, Z_DIM, 1, 1, device=device)
elif VIZ_MODE == 1:
    load_vector = np.loadtxt('vec_20190317-223131.txt')
    xp = [0, 1]
    yp = np.vstack([load_vector[2], load_vector[9]]) # choose two exemplar vectors
    xvals = np.linspace(0, 1, num=BATCH_SIZE)
    sample = interp1d(xp, yp, axis=0)
    viz_tensor = torch.tensor(sample(xvals).reshape(BATCH_SIZE, Z_DIM, 1, 1), dtype=torch.float32, device=device)
elif VIZ_MODE == 2:
    load_vector = np.loadtxt('vec_20190317-223131.txt')
    z1 = (load_vector[0] + load_vector[6] + load_vector[8]) / 3.
    z2 = (load_vector[1] + load_vector[2] + load_vector[4]) / 3.
    z3 = (load_vector[3] + load_vector[4] + load_vector[6]) / 3.
    z_new = z1 - z2 + z3
    sample = np.zeros(shape=(BATCH_SIZE, Z_DIM))
    for i in range(BATCH_SIZE):
        sample[i] = z_new + 0.1 * np.random.normal(-1.0, 1.0, 100)
    viz_tensor = torch.tensor(sample.reshape(BATCH_SIZE, Z_DIM, 1, 1), dtype=torch.float32,  device=device)

我们快完成了。我们需要在最后添加以下代码:

with torch.no_grad():
    viz_sample = netG(viz_tensor)
    viz_vector = utils.to_np(viz_temsor).reshape(BATCH_SIZE, Z_DIM)
    cur_time = datetime.now().strftime("%Y%m%d-%H%M%S")
    np.savetxt('vec_{}.txt'.format(cur_time), viz_vector)
    vutils.save_image(viz_sample, 'img_{}.png'.format(cur_time), nrow=10, normalize=True

现在,回到代码文件的顶部,在 import 部分添加一行:

from datetime import datetime

最后,我们需要在变量定义中添加一行代码。在写着 CUDA = True 的那一行之后,添加以下内容:

VIZ_MODE = 0

VIZ_MODE 的值为 0 代表随机生成,1 代表插值,2 代表语义计算。这将在我们继续进行三组代码时使用。

我们需要将输入向量和生成的图像导出到文件。DCGAN 测试的完整代码可以在本章的代码库中找到,文件名为 dcgan_test.py。别忘了删除或注释掉 utils.clear_folder(OUT_PATH) 这一行,否则你所有的训练结果会被删除,这将是个坏消息。

图像插值

生成器网络将输入的随机向量(潜在向量)映射到生成的图像。如果我们对潜在向量进行线性插值,相应的输出图像也会遵循插值关系。我们以在 CelebA 上训练的模型为例:

首先,让我们随机选择两个生成干净图像的向量。这里为了简便,我们将 BATCH_SIZE=10。我们还将添加一个 if 条件语句的开始部分,以便轻松选择要运行的代码部分:

if VIZ_MODE == 0:
    viz_tensor = torch.randn(BATCH_SIZE, Z_DIM, 1, 1, device=device)

生成的图像可能如下所示。这些图像的潜在向量将导出到一个文件(例如,vec_20190317-223131.txt):

随机生成的图像

假设我们选择第 3 张和最后一张图像进行插值。现在,让我们用 SciPy 对它们的潜在向量进行线性插值(用以下几行代码替换之前以 viz_tensor = ... 开头的那一行)。确保将文件名改为刚刚在系统上生成的文件名:

elif VIZ_MODE == 1:    
    load_vector = np.loadtxt('vec_20190317-223131.txt')
    xp = [0, 1]
    yp = np.vstack([load_vector[2], load_vector[9]])
    xvals = np.linspace(0, 1, num=BATCH_SIZE)
    sample = interp1d(xp, yp, axis=0)
    viz_tensor = torch.tensor(sample(xvals).reshape(BATCH_SIZE, Z_DIM, 1, 1), dtype=torch.float32, device=device)

你还需要将 VIZ_MODE 标志从 0 改为 1,以便进行插值:

VIZ_MODE = 1

现在,运行你修改后的源代码。对应生成的图像如下:

图像插值

我们可以看到,左边的图像平滑地转变为右边的图像。因此,我们知道潜在向量的插值会导致生成图像的插值。

语义向量算术

线性插值是线性代数中的基本方法之一。我们可以通过对潜在向量进行算术计算,做更多的事情。

取前面步骤中随机生成的图像。我们注意到有些图像是微笑的女性(第 1、7、9 张图像),有些女性图像并没有微笑(第 2、3、5 张图像),而这些图像中的男性没有一个是在微笑。天哪,他们不是很严肃吗!我们怎么能在不重新生成一组新的随机向量的情况下,让一个男人微笑呢?

好吧,想象一下我们可以通过算术计算来解决这个问题:

[微笑的女性] - [女性] = [微笑]

[微笑] + [男性] = [微笑的男性]

我们能做到吗?让我们试试吧!

首先,再次设置VIS_MODE标志,这次设置为2进行语义计算:

VIZ_MODE = 2 

接下来,继续用以下代码执行if条件。再次使用之前创建的文件名:

elif VIZ_MODE == 2:    
    load_vector = np.loadtxt('vec_20190317-223131.txt')
    z1 = (load_vector[0] + load_vector[6] + load_vector[8]) / 3.
    z2 = (load_vector[1] + load_vector[2] + load_vector[4]) / 3.
    z3 = (load_vector[3] + load_vector[4] + load_vector[6]) / 3.
    z_new = z1 - z2 + z3
    sample = np.zeros(shape=(BATCH_SIZE, Z_DIM))
    for i in range(BATCH_SIZE):
        sample[i] = z_new + 0.1 * np.random.normal(-1.0, 1.0, 100)
    viz_tensor = torch.tensor(sample.reshape(BATCH_SIZE, Z_DIM, 1, 1), dtype=torch.float32, device=device)

在这里,通过执行z1-z2,我们得到一个微笑向量。而z3则给我们一个男性向量。将它们加在一起将得到以下结果。我们使用 3 个不同潜在向量的均值向量来获得更稳定的结果,并将小的随机值加入算术结果中,以引入轻微的随机性:

潜在向量的向量算术

向量算术计算过程可以描述如下:

向量算术

出于好奇,我们直接基于z1-z2生成图像,这给我们带来了前面截图右下角的样本。我们可以看出它是一个微笑的面孔,但面部其他部分显得相当不自然。看起来像是一个奇怪的人的脸。

现在,我们已经解锁了 GAN 在操作生成图像属性方面的潜力。然而,结果仍然不够自然和真实。在下一章中,我们将学习如何生成具有我们想要的确切属性的样本。

总结

在本章中,我们花费了大量时间学习深度卷积生成对抗网络(DCGAN)。我们处理了 MNIST 数据集,以及形式为 CelebA 和 LSUN 的大型数据集。我们还消耗了大量的计算资源。希望到现在为止,你对 DCGAN 已经有了较好的掌握。

接下来,我们将学习条件生成对抗网络CGAN),以及如何在训练过程中添加标签信息。开始吧!

参考文献及有用的阅读清单

Hui J. (2018 年 6 月 21 日). GAN — 为什么训练生成对抗网络这么困难!medium.com/@jonathan_hui/gan-why-it-is-so-hard-to-train-generative-advisory-networks-819a86b3750b获取。

第六章:基于标签信息生成图像

在上一章中,我们初步体验了 GAN(生成对抗网络)学习潜在向量与生成图像之间关系的潜力,并模糊地观察到潜在向量以某种方式操控图像的属性。在本章中,我们将正式利用开放数据集中常见的标签和属性信息,正确地建立潜在向量与图像属性之间的桥梁。

在本章中,您将学习如何使用条件 GANCGAN)基于给定标签生成图像,以及如何通过自动编码器实现对抗性学习,并让人类面部从年轻到衰老。接下来,您将学习如何高效地组织源代码,便于调整和扩展。

阅读完本章后,您将学习到使用监督和无监督方法,通过标签和属性信息来提高 GAN 生成图像的质量。本章还介绍了本书中使用的基本源代码结构,这对您的项目将非常有用。

本章将涵盖以下主题:

  • CGAN —— 标签是如何使用的?

  • 基于标签生成图像的 CGAN

  • 使用 Fashion-MNIST

  • InfoGAN —— 无监督的属性提取

  • 参考文献和有用的阅读书单

CGAN —— 标签是如何使用的?

在上一章中,我们了解到,潜在向量与生成图像之间的关系可以通过 GAN 的训练过程建立,并且潜在向量的某些操作会通过生成图像的变化反映出来。但我们无法控制哪些部分或哪种类型的潜在向量会生成我们想要的属性图像。为了解决这个问题,我们将使用 CGAN 在训练过程中添加标签信息,这样我们就可以决定模型生成什么样的图像。

CGAN 的概念由 Mehdi Mirza 和 Simon Osindero 在他们的论文《Conditional Generative Adversarial Nets》中提出。其核心思想是将标签信息融入生成器和判别器网络,以使标签向量改变潜在向量的分布,从而生成具有不同属性的图像。

与传统的 GAN 模型相比,CGAN 在目标函数上做了小的改动,使得通过将真实数据  和生成数据  分别替换为  和  后,可以包含额外的信息,其中  表示辅助信息,如标签和属性:

在这个公式中,借用了描述数据在条件下如何分布的条件概率形式。为了计算新的目标函数,我们需要生成器网络能够根据某些条件生成数据,而判别器网络则判断输入的图像是否符合给定的条件。因此,在本节中,我们将讨论如何设计生成器和判别器来实现这一目标。

本章中我们将创建两个不同的模型,为了编写可重用的代码,我们将把源代码放在不同的文件中,而不是像以前一样将所有代码放在一个文件里。

将标签与生成器结合

CGAN 的生成器网络架构如下所示。如原始论文所述,所有数据都是通过类似 MLP 的网络生成的。然而,与原始论文不同的是,我们使用了一个更深的结构,并采用了批量归一化和 LeakyReLU 等技术,以确保获得更好的结果:

CGAN 的生成器网络架构

标签值被转换为长度为 10 的向量,并与潜在向量z拼接。生成器网络中的所有数据都以向量的形式存储。输出向量的长度等于生成图像的宽度和高度的乘积,MNIST 数据集的输出为 。当然,我们可以将输出图像的大小更改为我们想要的其他值(稍后我们将在源代码中将图像大小设置为 64 x 64)。

让我们将代码组织方式与之前的章节不同,并创建一个cgan.py文件来定义模型。

首先,我们在源代码文件的开始部分导入 PyTorch 和 NumPy 模块:

import torch
import torch.nn as nn
import torch.nn.functional as F
import numpy as np

然后,我们定义Generator网络:

class Generator(nn.Module):
    def __init__(self, classes, channels, img_size, latent_dim):
        super(Generator, self).__init__()
        self.classes = classes
        self.channels = channels
        self.img_size = img_size
        self.latent_dim = latent_dim
        self.img_shape = (self.channels, self.img_size, self.img_size)
        self.label_embedding = nn.Embedding(self.classes, self.classes)

        self.model = nn.Sequential(
            *self._create_layer(self.latent_dim + self.classes, 128, False),
            *self._create_layer(128, 256),
            *self._create_layer(256, 512),
            *self._create_layer(512, 1024),
            nn.Linear(1024, int(np.prod(self.img_shape))),
            nn.Tanh()
        )

    def _create_layer(self, size_in, size_out, normalize=True):
        layers = [nn.Linear(size_in, size_out)]
        if normalize:
            layers.append(nn.BatchNorm1d(size_out))
        layers.append(nn.LeakyReLU(0.2, inplace=True))
        return layers

    def forward(self, noise, labels):
        z = torch.cat((self.label_embedding(labels), noise), -1)
        x = self.model(z)
        x = x.view(x.size(0), *self.img_shape)
        return x

生成器网络由 5 个线性层组成,其中 3 个连接到批量归一化层,前 4 个线性层使用LeakyReLU激活函数,最后一个使用Tanh激活函数。标签信息通过nn.Embedding模块处理,它充当查找表。假设我们手头有 10 个用于训练样本的标签。嵌入层将这 10 个不同的标签转换为 10 个预定义的嵌入向量,这些向量默认通过正态分布初始化。标签的嵌入向量然后与随机潜在向量拼接,作为第一层的输入向量。最后,我们需要将输出向量重塑为 2D 图像作为最终结果。

将标签集成到判别器中

CGAN 判别器网络的架构如下所示。同样,判别器的架构与原始论文中使用的架构不同。当然,你可以根据自己的需求调整网络结构,看看你的模型是否能生成更好的结果:

CGAN 的判别器网络架构

与生成器网络类似,标签值也是 CGAN 判别器网络的输入部分。输入图像(大小为 28 x 28)被转换为一个长度为 784 的向量,因此判别器网络的输入向量的总长度为 794。判别器网络中有 4 个隐藏层。与常见的用于图像分类的 CNN 模型不同,判别器网络输出的是一个单一的值,而不是一个长度等于类别数的向量。这是因为我们已经将标签信息包含在网络输入中,且我们只希望判别器网络根据给定的标签条件告诉我们,图像与真实图像的相似度有多高。

现在,让我们在 cgan.py 文件中定义判别器网络:

class Discriminator(nn.Module):
    def __init__(self, classes, channels, img_size, latent_dim):
        super(Discriminator, self).__init__()
        self.classes = classes
        self.channels = channels
        self.img_size = img_size
        self.latent_dim = latent_dim
        self.img_shape = (self.channels, self.img_size, self.img_size)
        self.label_embedding = nn.Embedding(self.classes, self.classes)
        self.adv_loss = torch.nn.BCELoss()

        self.model = nn.Sequential(
            *self._create_layer(self.classes + int(np.prod(self.img_shape)), 1024, False, True),
            *self._create_layer(1024, 512, True, True),
            *self._create_layer(512, 256, True, True),
            *self._create_layer(256, 128, False, False),
            *self._create_layer(128, 1, False, False),
            nn.Sigmoid()
        )

    def _create_layer(self, size_in, size_out, drop_out=True, act_func=True):
        layers = [nn.Linear(size_in, size_out)]
        if drop_out:
            layers.append(nn.Dropout(0.4))
        if act_func:
            layers.append(nn.LeakyReLU(0.2, inplace=True))
        return layers

    def forward(self, image, labels):
        x = torch.cat((image.view(image.size(0), -1), self.label_embedding(labels)), -1)
        return self.model(x)

    def loss(self, output, label):
        return self.adv_loss(output, label)

同样,标签通过另一个 nn.Embedding 模块传递,在与图像向量连接之前。判别器网络由 5 个线性层组成,其中 2 个层连接到 Dropout 层,以增强泛化能力。由于我们不能总是保证最后一层的输出值位于 [0, 1] 的范围内,因此我们需要一个 Sigmoid 激活函数来确保这一点。

Dropout 层的丢弃率为 0.4 意味着,在训练的每一次迭代中,每个神经元有 0.4 的概率不参与最终结果的计算。因此,在不同的训练步骤中,训练出的是不同的子模型,这使得整个模型相较于没有 Dropout 层的模型,更不容易过拟合训练数据。在评估过程中,Dropout 层通常会被停用。

哪一层使用 DropoutLeakyReLU 激活函数的选择是相当主观的。你可以尝试其他组合,并找出哪个配置产生了最好的结果。

使用 CGAN 从标签生成图像

在上一节中,我们定义了 CGAN 的生成器和判别器网络的架构。现在,让我们编写模型训练的代码。为了方便你重现结果,我们将使用 MNIST 作为训练集,看看 CGAN 在图像生成方面的表现。我们希望完成的目标是,在模型训练完成后,它能够根据我们指定的数字生成正确的数字图像,并且具有丰富的多样性。

一站式模型训练 API

首先,让我们创建一个新的 Model 类,它作为不同模型的封装,并提供一站式训练 API。创建一个名为 build_gan.py 的新文件,并导入必要的模块:

import os

import numpy as np
import torch
import torchvision.utils as vutils

from cgan import Generator as cganG
from cgan import Discriminator as cganD

然后,让我们创建Model类。在这个类中,我们将初始化GeneratorDiscriminator模块,并提供traineval方法,以便用户可以简单地在其他地方调用Model.train()(或Model.eval())来完成模型的训练(或评估):

class Model(object):
    def __init__(self,
                 name,
                 device,
                 data_loader,
                 classes,
                 channels,
                 img_size,
                 latent_dim):
        self.name = name
        self.device = device
        self.data_loader = data_loader
        self.classes = classes
        self.channels = channels
        self.img_size = img_size
        self.latent_dim = latent_dim
        if self.name == 'cgan':
            self.netG = cganG(self.classes, self.channels, 
              self.img_size, self.latent_dim)
        self.netG.to(self.device)
        if self.name == 'cgan':
            self.netD = cganD(self.classes, self.channels, 
              self.img_size, self.latent_dim)
        self.netD.to(self.device)
        self.optim_G = None
        self.optim_D = None

在这里,生成器网络netG和判别器网络netD是基于类数(classes)、图像通道(channels)、图像大小(img_size)和潜在向量的长度(latent_dim)进行初始化的。这些参数稍后会给出。目前,我们假设这些值已经是已知的。由于我们需要初始化该类中的所有张量和函数,我们需要定义模型运行的deviceself.device)。optim_Goptim_D对象是两个网络的优化器,它们会用以下方式进行初始化:

    def create_optim(self, lr, alpha=0.5, beta=0.999):
        self.optim_G = torch.optim.Adam(filter(lambda p: p.requires_grad,
                                        self.netG.parameters()),
                                        lr=lr,
                                        betas=(alpha, beta))
        self.optim_D = torch.optim.Adam(filter(lambda p: p.requires_grad,
                                        self.netD.parameters()),
                                        lr=lr,
                                        betas=(alpha, beta))

Adam优化器的第一个参数,filter(lambda p: p.requires_grad, self.netG.parameters()),用于获取所有requires_grad标记为TrueTensor。这在模型的一部分未训练时非常有用(例如,将一个训练好的模型迁移到新数据集后对最后一层进行微调),尽管在我们的情况下并不必要。

接下来,让我们定义一个名为train的方法用于模型训练。train的参数包括训练的轮数(epochs)、日志消息之间的迭代间隔(log_interval)、结果输出目录(out_dir)以及是否将训练消息打印到终端(verbose):

    def train(self,
              epochs,
              log_interval=100,
              out_dir='',
              verbose=True):
        self.netG.train()
        self.netD.train()
        viz_noise = torch.randn(self.data_loader.batch_size, self.latent_dim, device=self.device)
        viz_label = torch.LongTensor(np.array([num for _ in range(nrows) for num in range(8)])).to(self.device)
        for epoch in range(epochs):
            for batch_idx, (data, target) in enumerate(self.data_loader):
                data, target = data.to(self.device), target.to(self.device)
                batch_size = data.size(0)
                real_label = torch.full((batch_size, 1), 1., device=self.device)
                fake_label = torch.full((batch_size, 1), 0., device=self.device)

                # Train G
                self.netG.zero_grad()
                z_noise = torch.randn(batch_size, self.latent_dim, device=self.device)
                x_fake_labels = torch.randint(0, self.classes, (batch_size,), device=self.device)
                x_fake = self.netG(z_noise, x_fake_labels)
                y_fake_g = self.netD(x_fake, x_fake_labels)
                g_loss = self.netD.loss(y_fake_g, real_label)
                g_loss.backward()
                self.optim_G.step()

                # Train D
                self.netD.zero_grad()
                y_real = self.netD(data, target)
                d_real_loss = self.netD.loss(y_real, real_label)

                y_fake_d = self.netD(x_fake.detach(), x_fake_labels)
                d_fake_loss = self.netD.loss(y_fake_d, fake_label)
                d_loss = (d_real_loss + d_fake_loss) / 2
                d_loss.backward()
                self.optim_D.step()

train中,我们首先将网络切换到训练模式(例如,self.netG.train())。这主要影响Dropout和批量归一化层的行为。然后,我们定义一组固定的潜在向量(viz_noise)和标签(viz_label)。它们用于在训练过程中偶尔生成图像,以便我们能够跟踪模型的训练进度,否则我们可能只有在训练完成后才意识到训练出现了问题:

                if verbose and batch_idx % log_interval == 0 and batch_idx > 0:
                    print('Epoch {} [{}/{}] loss_D: {:.4f} loss_G: {:.4f}'.format(
                          epoch, batch_idx, len(self.data_loader),
                          d_loss.mean().item(),
                          g_loss.mean().item()))
                    vutils.save_image(data, os.path.join(out_dir, 'real_samples.png'), normalize=True)
                    with torch.no_grad():
                        viz_sample = self.netG(viz_noise, viz_label)
                        vutils.save_image(viz_sample, os.path.join(out_dir, 'fake_samples_{}.png'.format(epoch)), nrow=8, normalize=True)
            self.save_to(path=out_dir, name=self.name, verbose=False)

在这里,我们省略了一些代码部分(包括评估 API、模型导出和加载)。你可以从本章的代码库中获取完整的源代码。

参数解析和模型训练

现在,我们要做的唯一事情就是为项目创建并定义主入口。在这个文件中,我们需要定义我们之前假定为已知的参数。这些超参数在创建任何网络时都至关重要,我们将优雅地解析这些值。让我们创建一个名为main.py的新文件,并导入必要的模块:

import argparse
import os
import sys

import numpy as np
import torch
import torch.backends.cudnn as cudnn
import torch.utils.data
import torchvision.datasets as dset
import torchvision.transforms as transforms

import utils

from build_gan import Model

你有没有注意到,唯一与我们模型相关的 Python 模块是build_gan.Model?我们可以轻松创建另一个项目,并在没有重大修改的情况下复制这个文件中的大部分内容。

然后,让我们定义main函数:

FLAGS = None

def main():
    device = torch.device("cuda:0" if FLAGS.cuda else "cpu")

    if FLAGS.train:
        print('Loading data...\n')
        dataset = dset.MNIST(root=FLAGS.data_dir, download=True,
                             transform=transforms.Compose([
                             transforms.Resize(FLAGS.img_size),
                             transforms.ToTensor(),
                             transforms.Normalize((0.5,), (0.5,))
                             ]))
        assert dataset
        dataloader = torch.utils.data.DataLoader(dataset, batch_size=FLAGS.batch_size,
                                                 shuffle=True, num_workers=4, pin_memory=True)
        print('Creating model...\n')
        model = Model(FLAGS.model, device, dataloader, FLAGS.classes, FLAGS.channels, FLAGS.img_size, FLAGS.latent_dim)
        model.create_optim(FLAGS.lr)

        # Train
        model.train(FLAGS.epochs, FLAGS.log_interval, FLAGS.out_dir, True)

        model.save_to('')
    else:
        model = Model(FLAGS.model, device, None, FLAGS.classes, FLAGS.channels, FLAGS.img_size, FLAGS.latent_dim)
        model.load_from(FLAGS.out_dir)
        model.eval(mode=0, batch_size=FLAGS.batch_size)

由于我们已经在单独的文件中定义了网络和训练计划,因此模型的初始化和训练只需要 3 行代码:model = Model()model.create_optim()model.train()。这样,我们的代码易于阅读、修改和维护,而且我们可以轻松地在其他项目中使用大部分代码。

FLAGS对象存储了定义和训练模型所需的所有参数和超参数。为了使参数配置更加用户友好,我们将使用 Python 提供的argparse模块。

请注意,如果你想使用不同的数据集,可以像上一章一样修改dataset对象的定义。

源代码的main入口和参数定义如下:

if __name__ == '__main__':
    from utils import boolean_string
    parser = argparse.ArgumentParser(description='Hands-On GANs - Chapter 5')
    parser.add_argument('--model', type=str, default='cgan', help='one of `cgan` and `infogan`.')
    parser.add_argument('--cuda', type=boolean_string, default=True, help='enable CUDA.')
    parser.add_argument('--train', type=boolean_string, default=True, help='train mode or eval mode.')
    parser.add_argument('--data_dir', type=str, default='~/Data/mnist', help='Directory for dataset.')
    parser.add_argument('--out_dir', type=str, default='output', help='Directory for output.')
    parser.add_argument('--epochs', type=int, default=200, help='number of epochs')
    parser.add_argument('--batch_size', type=int, default=128, help='size of batches')
    parser.add_argument('--lr', type=float, default=0.0002, help='learning rate')
    parser.add_argument('--latent_dim', type=int, default=100, help='latent space dimension')
    parser.add_argument('--classes', type=int, default=10, help='number of classes')
    parser.add_argument('--img_size', type=int, default=64, help='size of images')
    parser.add_argument('--channels', type=int, default=1, help='number of image channels')
    parser.add_argument('--log_interval', type=int, default=100, help='interval between logging and image sampling')
    parser.add_argument('--seed', type=int, default=1, help='random seed')

    FLAGS = parser.parse_args()

通过parser.add_argument(ARG_NAME, ARG_TYPE, DEFAULT_VALUE, HELP_MSG)创建一个新参数,其中ARG_NAME是参数名称,ARG_TYPE是参数的值类型(例如,intfloatboolstr),DEFAULT_VALUE是没有给定值时的默认参数值,HELP_MSG是在终端中运行python main.py --help时打印的消息。可以通过python main.py --ARG_NAME ARG_VALUE来指定参数值,或者你可以在源代码中更改默认值并直接运行python main.py。在这里,我们的模型将训练 200 个 epoch,批次大小为 128,学习率设置为 0.0002,因为较小的学习率值适合Adam方法。潜在向量的长度为 100,生成图像的大小设置为 64。我们还将随机种子设置为 1,以便你可以得到与本书中相同的结果。

boolean_stringutils.py文件中定义,内容如下(更多信息请参考stackoverflow.com/a/44561739/3829845)。否则,在终端中传递--train False将不会影响脚本:

def boolean_string(s):
    if s not in {'False', 'True'}:
        raise ValueError('Not a valid boolean string')
    return s == 'True'

我们仍然需要对参数进行一些预处理:

    FLAGS.cuda = FLAGS.cuda and torch.cuda.is_available()

    if FLAGS.seed is not None:
        torch.manual_seed(FLAGS.seed)
        if FLAGS.cuda:
            torch.cuda.manual_seed(FLAGS.seed)
        np.random.seed(FLAGS.seed)

    cudnn.benchmark = True

    if FLAGS.train:
        utils.clear_folder(FLAGS.out_dir)

    log_file = os.path.join(FLAGS.out_dir, 'log.txt')
    print("Logging to {}\n".format(log_file))
    sys.stdout = utils.StdOut(log_file)

    print("PyTorch version: {}".format(torch.__version__))
    print("CUDA version: {}\n".format(torch.version.cuda))

    print(" " * 9 + "Args" + " " * 9 + "| " + "Type" + \
          " | " + "Value")
    print("-" * 50)
    for arg in vars(FLAGS):
        arg_str = str(arg)
        var_str = str(getattr(FLAGS, arg))
        type_str = str(type(getattr(FLAGS, arg)).__name__)
        print(" " + arg_str + " " * (20-len(arg_str)) + "|" + \
              " " + type_str + " " * (10-len(type_str)) + "|" + \
              " " + var_str)

    main()

在这里,我们首先确保 CUDA 确实可用于 PyTorch。然后,我们手动设置 NumPy、PyTorch 和 CUDA 后端的随机种子。每次重新训练模型时,我们需要清理输出目录,并将所有输出消息重定向到外部文件log.txt。最后,我们在运行main函数之前打印所有的参数,以便检查是否正确配置了模型。

现在,打开终端并运行以下脚本。记得将DATA_DIRECTORY更改为你机器上 MNIST 数据集的路径:

       $ conda activate torch
(torch)$ python main.py --model cgan --train True --data_dir DATA_DIRECTORY

输出信息可能如下所示(参数的顺序可能不同):

Logging to output/log.txt

PyTorch version: 1.0.1.post2
CUDA version: 10.0.130

         Args         |   Type    |   Value
--------------------------------------------------
  model               | str       | cgan
  cuda                | bool      | True
  train               | bool      | True
  data_dir            | str       | ~/Data/mnist
  out_dir             | str       | output
  epochs              | int       | 200
  batch_size          | int       | 128
  lr                  | float     | 0.0002
  latent_dim          | int       | 100
  classes             | int       | 10
  img_size            | int       | 64
  channels            | int       | 1
  log_interval        | int       | 100
  seed                | int       | 1
Loading data...

Creating model...

Epoch 0 [100/469] loss_D: 0.6747 loss_G: 0.6119
Epoch 0 [200/469] loss_D: 0.4745 loss_G: 0.8135
...

在 GTX 1080Ti 显卡上训练 200 个 epoch 大约需要 22 分钟,并消耗约 729 MB 的 GPU 内存。从 MNIST 数据集中生成的图像如下所示:

通过 CGAN 生成的 MNIST 图像(左:第 1 个 epoch;中:第 25 个 epoch;右:第 200 个 epoch)

我们可以看到,对于相应的标签,数字图像被正确生成,同时保持外观上的逼真多样性。由于我们将图像视为模型中的非常长的向量,因此很难在垂直和水平方向上生成平滑度,而且在训练了仅 25 个 epoch 后,生成的图像容易出现斑点噪声。然而,在训练 200 个 epoch 后,图像质量有了显著提高。

使用 Fashion-MNIST

所以你现在应该知道,MNIST 数据集由一堆手写数字组成。它是机器学习领域的事实标准,常用于验证流程。另一个小组决定创建一个可能是更好的替代数据集。这个项目被命名为Fashion-MNIST,旨在作为一个简单的替代品。您可以通过www.kaggle.com/zalando-research/fashionmnist/data#深入了解该项目。

Fashion-MNIST 包含一个包含 60,000 张图像和标签的训练集,以及一个包含 10,000 张图像和标签的测试集。所有图像都是灰度图,大小为 28x28 像素,共有 10 个类别的图像,分别是:T 恤/上衣、裤子、套头衫、裙子、大衣、凉鞋、衬衫、运动鞋、包和 ankle boot。你可以开始看到,这个替代数据集应该能让算法更加复杂。

为了展示数据集的使用,我们将使用刚才为标准 MNIST 数据集创建的程序,并做一些修改。

  1. 将 main.py 文件复制到fashion-main.py以保护原文件。现在,在fashion-main.py文件中找到以下代码部分:
dataset = dset.MNIST(root=FLAGS.data_dir, download=True,
                     transform=transforms.Compose([
                     transforms.Resize(FLAGS.img_size),
                     transforms.ToTensor(),
                     transforms.Normalize((0.5,), (0.5,))
                     ]))

这是main()函数中的第四行。

  1. 现在,您只需将dset.MNIST(更改为dset.FashionMNIST(,就像这样:
dataset = dset.FashionMNIST(root=FLAGS.data_dir, download=True,
                            transform=transforms.Compose([
                            transforms.Resize(FLAGS.img_size),
                            transforms.ToTensor(),
                            transforms.Normalize((0.5,), (0.5,))
                            ]))

幸运的是,torchvision 已经为 Fashion-MNIST 提供了一个内置类。稍后我们会指出其他一些类。

  1. 现在保存您的源文件。

  2. 现在,为了确保您的第一个示例中的数据集安全,重新命名上次使用的 Data 文件夹。新数据集将自动下载给您。另一件事是,您应该重新命名输出文件夹,以确保它的安全。

  3. 正如我们在上一个程序中所做的,我们将通过命令行输入来启动它:

(torch)$ python fashion_main.py --model cgan --train True --data_dir DATA_DIRECTORY

终端中的输出将与上一个程序非常相似,除了显示下载新数据集信息的几行:

Downloading http://fashion-mnist.s3-website.eu-central-1.amazonaws.com/train-images-idx3-ubyte.gz to ./Data/fashion/FashionMNIST/raw/train-images-idx3-ubyte.gz
26427392it [00:06, 4212673.42it/s] 
Extracting ./Data/fashion/FashionMNIST/raw/train-images-idx3-ubyte.gz to ./Data/fashion/FashionMNIST/raw
...

下面是你可以期待的输出图像示例:

左侧是实际的样本数据,中间是第 0 轮的结果,最后右侧是第 199 轮的结果。尽管结果并不完美,但你可以看到输出已经变得相当不错。

之前,我提到过我们将会看看 torchvision 支持的其他类。这里无法讨论所有内容,但如果你访问:pytorch.org/docs/stable/torchvision/datasets.html,你可以看到每个支持类及其 API 参数的详细列表。许多类可以直接与我们的代码一起使用,唯一需要修改的是代码中的数据集行,甚至可以让程序为你下载数据集。

InfoGAN – 无监督属性提取

在前面的章节中,我们学习了如何使用数据的标签等辅助信息来提高 GAN 生成图像的质量。然而,准备训练样本的准确标签并不总是可行的。有时候,准确描述极其复杂数据的标签甚至是非常困难的。在本节中,我们将介绍来自 GAN 家族的另一个优秀模型——InfoGAN,它能够在训练过程中以无监督的方式提取数据的属性。InfoGAN 由 Xi Chen、Yan Duan、Rein Houthooft 等人在他们的论文《InfoGAN: Interpretable Representation Learning by Information Maximizing Generative Adversarial Nets》中提出。它展示了 GAN 不仅能够学习生成逼真的样本,还能够学习对样本生成至关重要的语义特征。

与 CGAN 类似,InfoGAN 也用条件分布(将辅助信息作为条件)替换了数据的原始分布。主要的区别在于,InfoGAN 不需要将标签和属性信息输入到判别器网络中;而是使用另一个分类器 Q 来衡量辅助特征的学习情况。InfoGAN 的目标函数如下所示。你可能会注意到它在公式末尾添加了另一个目标,

在这个公式中,是生成的样本,是潜在向量,代表辅助信息。描述了的实际分布,而这通常很难找到。因此,我们使用后验分布,,来估计,这一过程通过神经网络分类器完成。

在前述公式中, 实际上是 互信息的近似值, ,用于表示辅助向量与生成样本之间的关系。互信息, ,描述了我们基于 Y 的知识能了解关于随机变量 X 的多少—— ,其中 ,而 条件熵。它也可以通过 Kullback-Leibler 散度来描述, ,该散度描述了我们使用边缘分布来近似 XY 的联合分布时的信息损失。你可以参考原版 InfoGAN 论文来了解详细的数学推导。目前,你只需要知道, 告诉我们生成的 是否如预期那样基于 生成。

InfoGAN 的网络定义

InfoGAN 生成器网络的架构如下所示。复现原论文中的结果相对复杂。因此,我们基于此 GitHub 仓库提供的模型架构, github.com/eriklindernoren/PyTorch-GAN/blob/master/implementations/infogan/infogan.py

InfoGAN 的生成器架构

InfoGAN 的生成器网络由 4 层隐藏层组成。第一层隐藏层将长度为 74 (62+10+2) 的输入向量转换为长度为 8,192 (12888),然后直接转换为具有 12888 维度的张量。特征图随后逐渐放大到 32*32 的图像。特征图的缩放通过 torch.nn.functional.interpolate 完成。我们需要定义一个派生的 Module 类来进行上采样,以便将其视为其他 torch.nn 层。

让我们创建一个名为 infogan.py 的新源文件,并导入与 cgan.py 中相同的 Python 模块,然后按如下方式定义 Upsample 类:

class Upsample(nn.Module):
    def __init__(self, scale_factor):
        super(Upsample, self).__init__()
        self.scale_factor = scale_factor

    def forward(self, x):
        return F.interpolate(x, scale_factor=self.scale_factor, mode='bilinear', align_corners=False)

我们使用 bilinear 方法来上采样图像,因为它相比其他选择最为合适。由于我们从 torch.nn.Module 派生这个类,并且仅在前向传播过程中使用 torch 中的函数进行计算,我们的自定义类在训练时不会出现反向传播梯度计算的问题。

在 PyTorch 1.0 中,调用 nn.Upsample 会给出一个弃用警告,实际上它现在是通过 torch.nn.functional.interpolate 来实现的。因此,我们自定义的 Upsample 层与 nn.Upsample 相同,但没有警告信息。

生成器网络定义如下:

class Generator(nn.Module):
    def __init__(self, classes, channels, img_size, latent_dim, code_dim):
        super(Generator, self).__init__()
        self.classes = classes
        self.channels = channels
        self.img_size = img_size
        self.img_init_size = self.img_size // 4
        self.latent_dim = latent_dim
        self.code_dim = code_dim
        self.img_init_shape = (128, self.img_init_size, self.img_init_size)
        self.img_shape = (self.channels, self.img_size, self.img_size)
        self.stem_linear = nn.Sequential(
            nn.Linear(latent_dim + classes + code_dim,
                      int(np.prod(self.img_init_shape)))
        )
        self.model = nn.Sequential(
            nn.BatchNorm2d(128),
            *self._create_deconv_layer(128, 128, upsample=True),
            *self._create_deconv_layer(128, 64, upsample=True),
            *self._create_deconv_layer(64, self.channels, upsample=False, normalize=False),
            nn.Tanh()
        )

    def _create_deconv_layer(self, size_in, size_out, upsample=True,  
      normalize=True):
        layers = []
        if upsample:
            layers.append(Upsample(scale_factor=2))
        layers.append(nn.Conv2d(size_in, size_out, 3, stride=1, 
          padding=1))
        if normalize:
            layers.append(nn.BatchNorm2d(size_out, 0.8))
            layers.append(nn.LeakyReLU(0.2, inplace=True))
        return layers

    def forward(self, noise, labels, code):
        z = torch.cat((noise, labels, code), -1)
        z_vec = self.stem_linear(z)
        z_img = z_vec.view(z_vec.shape[0], *self.img_init_shape)
        x = self.model(z_img)
        return x

在这个类中,我们使用了一个辅助函数_create_deconv_layer来创建卷积隐藏层。由于我们将使用自定义的Upsample层来增加特征图的大小,因此我们只需要使用nn.Conv2d,其输入大小等于输出大小,而不是像上一章中的 DCGAN 那样使用nn.ConvTranspose2d

在我们的 InfoGAN 配置中,torch.nn.functional.interpolatenn.Conv2d组合比使用步幅的nn.ConvTranspose2d效果更好。尽管如此,您仍然可以尝试不同的配置,看看是否能得到更好的结果。

InfoGAN 判别器网络的架构如下所示。再次强调,我们使用了与原始论文中不同的结构:

InfoGAN 的判别器架构

判别器网络由 4 个隐藏层组成。如前所述,InfoGAN 使用了一个额外的分类器网络来衡量辅助向量的有效性。事实上,这个额外的分类器与判别器共享大部分权重(前 4 个隐藏层)。因此,图像的质量度量由一个 1 x 1 的张量表示,这是 4 个隐藏层末尾线性层的结果。辅助信息的度量,包括类保真度和样式保真度,是通过两组不同的线性层得到的,其中12822特征图首先映射到 128 长度的向量,然后分别映射到长度为 10 和 2 的输出向量。

判别器在 PyTorch 代码中的定义如下:

class Discriminator(nn.Module):
 def __init__(self, classes, channels, img_size, latent_dim, code_dim):
 super(Discriminator, self).__init__()
 self.classes = classes
 self.channels = channels
 self.img_size = img_size
 self.latent_dim = latent_dim
 self.code_dim = code_dim
 self.img_shape = (self.channels, self.img_size, self.img_size)
 self.model = nn.Sequential(
 *self._create_conv_layer(self.channels, 16, True, False),
 *self._create_conv_layer(16, 32, True, True),
 *self._create_conv_layer(32, 64, True, True),
 *self._create_conv_layer(64, 128, True, True),
 )
 out_linear_dim = 128 * (self.img_size // 16) * (self.img_size // 16)
 self.adv_linear = nn.Linear(out_linear_dim, 1)
 self.class_linear = nn.Sequential(
 nn.Linear(out_linear_dim, 128),
 nn.BatchNorm1d(128),
 nn.LeakyReLU(0.2, inplace=True),
 nn.Linear(128, self.classes)
 )
 self.code_linear = nn.Sequential(
 nn.Linear(out_linear_dim, 128),
 nn.BatchNorm1d(128),
 nn.LeakyReLU(0.2, inplace=True),
 nn.Linear(128, self.code_dim)
 )
 self.adv_loss = torch.nn.MSELoss()
 self.class_loss = torch.nn.CrossEntropyLoss()
 self.style_loss = torch.nn.MSELoss()

 def _create_conv_layer(self, size_in, size_out, drop_out=True, normalize=True):
 layers = [nn.Conv2d(size_in, size_out, 3, 2, 1)]
 if drop_out:
 layers.append(nn.LeakyReLU(0.2, inplace=True))
 layers.append(nn.Dropout(0.4))
 if normalize:
 layers.append(nn.BatchNorm2d(size_out, 0.8))
 return layers

 def forward(self, image):
 y_img = self.model(image)
 y_vec = y_img.view(y_img.shape[0], -1)
 y = self.adv_linear(y_vec)
 label = F.softmax(self.class_linear(y_vec), dim=1)
 code = self.code_linear(y_vec)
 return y, label, code

在这里,我们将质量保真度(self.adv_loss)视为普通 GAN 模型中的损失,类保真度(self.class_loss)视为分类问题(因为标签值是硬编码的,通常是独热编码),样式保真度(self.style_loss)则视为期望最大化问题(因为我们希望样式向量服从某种随机分布)。因此,交叉熵(torch.nn.CrossEntropyLoss)和均方误差(torch.nn.MSELoss)损失函数分别用于它们。

我们想解释为什么均方误差用于期望最大化。我们假设样式向量服从均值为 0,标准差为 1 的正态分布。在计算熵时,取随机变量的概率的对数。正态分布的概率密度函数pdf)的对数推导为,因此,均方误差适用于此类目的。

InfoGAN 的训练与评估

我们需要对训练 API 做一些调整,以便可以利用类向量和样式向量进行属性提取和图像生成。

首先,我们在build_gan.py文件中添加几个导入的模块:

import itertools

from infogan import Generator as infoganG
from infogan import Discriminator as infoganD

PyTorch 提供的默认权重初始化容易导致饱和,因此我们需要一个自定义的weight初始化器:

def _weights_init(m):
    classname = m.__class__.__name__
    if classname.find('Conv') != -1:
        torch.nn.init.normal_(m.weight.data, 0.0, 0.02)
    elif classname.find('BatchNorm') != -1:
        torch.nn.init.normal_(m.weight.data, 1.0, 0.02)
        torch.nn.init.constant_(m.bias.data, 0.0)

让我们在Model类的定义中添加以下几行:

        self.style_dim = 2
        self.infogan = self.name == 'infogan'
        self.optim_info = None

我们还需要修改self.netGself.netD的定义:

        if self.name == 'cgan':
            self.netG = cganG(self.classes, self.channels, self.img_size, self.latent_dim)
        elif self.name == 'infogan':
            self.netG = infoganG(self.classes, self.channels, self.img_size, self.latent_dim, self.style_dim)
            self.netG.apply(_weights_init)
        self.netG.to(self.device)
        if self.name == 'cgan':
            self.netD = cganD(self.classes, self.channels, self.img_size, self.latent_dim)
        elif self.name == 'infogan':
            self.netD = infoganD(self.classes, self.channels, self.img_size, self.latent_dim, self.style_dim)
            self.netD.apply(_weights_init)
        self.netD.to(self.device)

然后,我们在create_optim方法的末尾添加一个用于互信息的优化器:

        if self.infogan:
            self.optim_info = torch.optim.Adam(itertools.chain(self.netG.parameters(), self.netD.parameters()),
                                               lr=lr, betas=(alpha, beta))

接下来,我们需要对train方法进行一些调整,其中我们首先训练生成器和判别器网络,并基于辅助信息再次更新这两个网络。在这里,我们省略了所有的if self.infogan语句,只展示了 InfoGAN 的训练过程。完整的源代码可以参考本章的代码仓库。

初始化固定的潜在向量以进行结果可视化:

        viz_noise = torch.randn(self.data_loader.batch_size, self.latent_dim, device=self.device)
        nrows = self.data_loader.batch_size // 8
        viz_label = torch.LongTensor(np.array([num for _ in range(nrows) for num in range(8)])).to(self.device)
        viz_onehot = self._to_onehot(viz_label, dim=self.classes)
        viz_style = torch.zeros((self.data_loader.batch_size, self.style_dim), device=self.device)

这里,self._to_onehot方法负责将标签值转换为 one-hot 编码:

    def _to_onehot(self, var, dim):
        res = torch.zeros((var.shape[0], dim), device=self.device)
        res[range(var.shape[0]), var] = 1.
        return res

InfoGAN 的一个训练迭代包括以下内容:

  • 使用伪数据训练生成器,并让判别器将它们视为真实数据

  • 使用真实和伪数据训练判别器,以提高其区分能力

  • 同时训练生成器和判别器,以便生成器可以基于给定的辅助信息生成高质量的样本,而判别器可以判断生成的样本是否符合给定辅助信息的分布:

        for epoch in range(epochs):
            for batch_idx, (data, target) in enumerate(self.data_loader):
                data, target = data.to(self.device), target.to(self.device)
                batch_size = data.size(0)
                real_label = torch.full((batch_size, 1), 1., 
                  device=self.device)
                fake_label = torch.full((batch_size, 1), 0.,  
                  device=self.device)

                # Train G
                self.netG.zero_grad()
                z_noise = torch.randn(batch_size, self.latent_dim,  
                  device=self.device)
                x_fake_labels = torch.randint(0, self.classes, 
                  (batch_size,), device=self.device)
                labels_onehot = self._to_onehot(x_fake_labels, 
                  dim=self.classes)
                z_style = torch.zeros((batch_size, self.style_dim), 
                  device=self.device).normal_()
                x_fake = self.netG(z_noise, labels_onehot, z_style)
                y_fake_g, _, _ = self.netD(x_fake)
                g_loss = self.netD.adv_loss(y_fake_g, real_label)
                g_loss.backward()
                self.optim_G.step()

                # Train D
                self.netD.zero_grad()
                y_real, _, _ = self.netD(data)
                d_real_loss = self.netD.adv_loss(y_real, real_label)
                y_fake_d, _, _ = self.netD(x_fake.detach())
                d_fake_loss = self.netD.adv_loss(y_fake_d, fake_label)
                d_loss = (d_real_loss + d_fake_loss) / 2
                d_loss.backward()
                self.optim_D.step()

                # Update mutual information
                self.optim_info.zero_grad()
                z_noise.normal_()
                x_fake_labels = torch.randint(0, self.classes,  
                  (batch_size,), device=self.device)
                labels_onehot = self._to_onehot(x_fake_labels, 
                  dim=self.classes)
                z_style.normal_()
                x_fake = self.netG(z_noise, labels_onehot, z_style)
                _, label_fake, style_fake = self.netD(x_fake)
                info_loss = self.netD.class_loss(label_fake, 
                  x_fake_labels) +\
                            self.netD.style_loss(style_fake, z_style)
                info_loss.backward()
                self.optim_info.step()

我们完全不需要修改main.py文件,直接在终端运行以下脚本即可:

(torch)$ python main.py --model infogan --latent_dim 62 --img_size 32 --batch_size 64 --data_dir DATA_DIRECTORY

完成 200 个 epoch 的训练大约需要 2 小时,并且在 GTX 1080Ti 显卡上消耗约 833MB 的 GPU 内存。训练过程中生成的结果如下所示:

由 CGAN 生成的 MNIST 图像(左:第 1 个 epoch;中:第 25 个 epoch;右:第 200 个 epoch)

训练完成后,运行以下脚本进行模型评估:

(torch)$ python main.py --model infogan --latent_dim 62 --img_size 32 --batch_size 64 --train False

调用model.eval()并设置mode=0mode=1将告诉我们风格向量中的两个值分别负责什么,如下所示:

第一个风格位(mode=0)控制数字的角度,第二个风格位(mode=1)控制笔画的宽度。

风格向量中的一个值负责数字的角度,另一个值负责笔画的宽度,就像原始的 InfoGAN 论文所述。想象一下,这项技术在复杂数据集和精心配置的训练下能够发挥什么作用。

我们可以用 CGAN 及类似方法做更多的事情。例如,标签不仅仅限于图像,图像中的每个像素也可以有自己的标签。在下一章中,我们将探讨 GAN 在像素级标签上的表现,做一些更有趣的事情,不仅限于手写数字和人脸。

参考文献和有用的阅读书单

  1. 米尔扎·M 和奥辛德罗·S。 (2014)。条件生成对抗网络。arXiv 预印本 arXiv:1411.1784。

  2. 会杰。 (2018 年 6 月 3 日)。GAN — CGAN 与 InfoGAN(使用标签提升 GAN 性能)。取自medium.com/@jonathan_hui/gan-cgan-infogan-using-labels-to-improve-gan-8ba4de5f9c3d

  3. 张泽、宋颖和齐宏。 (2017)。基于条件对抗自编码器的年龄进展/回归。CVPR。

  4. 陈欣、段宇、侯福特。 (2016)。InfoGAN:通过信息最大化生成对抗网络进行可解释的表示学习。arXiv 预印本 arXiv:1606.03657。

总结

在本章中,我们发现了条件生成对抗网络CGANs),它可以与 MNIST 和 Fashion-MNIST 数据集一起使用,我们还了解了使用 InfoGAN 模型,它同样可以与 MNIST 数据集一起使用。

在我们下一章中,我们将学习图像到图像的翻译技术,我相信你一定会觉得这非常令人兴奋,并且在今天的世界中非常相关。

第七章:图像到图像翻译及其应用

在本章中,我们将把基于标签的图像生成推向一个新水平:我们将使用像素级标注来执行图像到图像的翻译,并传输图像风格。

您将学习如何使用像素级标签信息来执行 pix2pix 的图像到图像翻译,并使用 pix2pixHD 来翻译高分辨率图像。随后,您将学习如何在未成对的图像集合之间执行风格转移与 CycleGAN。

到本章末,结合前一章的知识,您将掌握使用基于图像和像素级标签信息来提高生成图像质量或操作生成图像属性的核心方法论。您还将学会如何灵活设计模型架构以实现您的目标,包括生成更大的图像或在不同风格的图像之间传输纹理。

本章将涵盖以下主题:

  • 使用像素级标签来执行与 pix2pix 的图像翻译

  • Pix2pixHD – 高分辨率图像翻译

  • CycleGAN – 从未成对的集合进行图像到图像的翻译

使用像素级标签来执行与 pix2pix 的图像翻译

在上一章中,我们学习了如何使用标签和属性等辅助信息来提高 GAN 生成的图像质量。我们在上一章中使用的标签是基于图像的,这意味着每个图像只有一个或几个标签。标签可以分配给特定的像素,这些像素被称为像素级标签。像素级标签在深度学习领域中的作用越来越重要。例如,最著名的在线图像分类比赛之一,ImageNet 大规模视觉识别挑战ILSVRCwww.image-net.org/challenges/LSVRC),自 2017 年最后一次活动后不再举办,而像 COCO(cocodataset.org)这样的对象检测和分割挑战却越来越受到关注。

像素级标注的一个标志性应用是语义分割。语义分割(或图像/对象分割)是一个任务,其中图像中的每个像素必须属于一个对象。语义分割最有前途的应用是自动驾驶汽车。如果自动驾驶汽车上安装的摄像头捕获的每个像素都被正确分类,图像中的所有对象都将容易被识别,这使得车辆能够更轻松地分析当前环境并做出正确的决策,例如转弯或减速以避开其他车辆和行人。要了解更多关于语义分割的信息,请参考以下链接:devblogs.nvidia.com/image-segmentation-using-digits-5

将原始彩色图像转换为分割图(如下图所示)可以视为图像到图像的翻译问题,这是一个更广泛的领域,包括风格迁移、图像上色等。图像风格迁移是指将一种图像中的标志性纹理和颜色转移到另一张图像上,例如将你的照片与文森特·梵高的画作结合,创作一幅独特的艺术肖像。图像上色是一个任务,我们将一张单通道的灰度图像输入模型,让它预测每个像素的颜色信息,从而得到一张三通道的彩色图像。

GANs 也可以用于图像到图像的翻译。在这一节中,我们将使用经典的图像到图像翻译模型 pix2pix,将图像从一个领域转换到另一个领域。Pix2pix 由 Phillip Isola、Jun-Yan Zhu、Tinghui Zhou 等人在他们的论文《带条件对抗网络的图像到图像翻译》中提出。Pix2pix 的设计旨在学习成对图像集合之间的关系,例如将卫星拍摄的航拍图像转换为普通地图,或者将素描图像转换为彩色图像,反之亦然。

论文的作者友好地提供了他们工作的完整源代码,该代码在 PyTorch 1.3 上运行完美。源代码也很好地组织起来。因此,我们将直接使用他们的代码来训练和评估 pix2pix 模型,并学习如何以不同的方式组织我们的模型。

首先,打开一个终端并使用以下命令下载本节的代码。此代码也可以在本章代码库中的pix2pix目录下找到:

$ git clone https://github.com/junyanz/pytorch-CycleGAN-and-pix2pix.git

然后,安装必要的前提条件,以便在训练过程中能够可视化结果:

$ pip install dominate visdom

生成器架构

pix2pix 生成器网络的架构如下:

pix2pix 的生成器架构

在这里,我们假设输入和输出数据都是三通道的 256x256 图像。为了说明 pix2pix 的生成器结构,特征图用彩色块表示,卷积操作用灰色和蓝色箭头表示,其中灰色箭头表示用于减少特征图大小的卷积层,蓝色箭头表示用于加倍特征图大小的卷积层。身份映射(包括跳跃连接)用黑色箭头表示。

我们可以看到,这个网络的前半部分逐步将输入图像转换为 1x1 的特征图(并且通道更宽),后半部分将这些非常小的特征图转换为与输入图像相同大小的输出图像。它将输入数据压缩为较低的维度,然后再将其恢复为原始维度。因此,这种 U 形的网络结构通常被称为 U-Net。U-Net 中还有许多跳跃连接,它们连接镜像层,以帮助信息(包括前向传播中来自先前层的细节和后向传播中来自后续层的梯度)在网络中流动。如果没有这些跳跃连接,这个网络也可以称为编码器-解码器模型,意思是我们在编码器的末尾堆叠了一个解码器。

pix2pix 模型在models.pix2pix_model.Pix2PixModel类中定义,该类继承自一个抽象基类ABC),即models.base_model.BaseModel

Python 中的抽象基类是一个包含至少一个抽象方法(已声明但未实现)的类。它不能被实例化。你只能在提供所有抽象方法的实现后,使用它的子类创建对象。

生成器网络netG是由models.networks.define_G方法创建的。默认情况下,它使用'unet_256'作为netG参数值(该值在models/pix2pix_model.py的第 32 行指定,并覆盖了options/base_options.py中第 34 行初始化的值,即"resnet_9blocks")。因此,models.networks.UnetGenerator被用来创建 U-Net。为了展示 U-Net 是如何递归创建的,我们将参数替换为它们的实际值,如下代码所示:


import torch.nn as nn
class UnetGenerator(nn.Module):
    def __init__(self):
        super(UnetGenerator, self).__init__()
        unet_block = UnetSkipConnectionBlock(64 * 8, 64 * 8, submodule=None, innermost=True)
        for i in range(8 - 5):
            unet_block = UnetSkipConnectionBlock(64 * 8, 64 * 8, submodule=unet_block, use_dropout=True)
        unet_block = UnetSkipConnectionBlock(64 * 4, 64 * 8, submodule=unet_block)
        unet_block = UnetSkipConnectionBlock(64 * 2, 64 * 4, submodule=unet_block)
        unet_block = UnetSkipConnectionBlock(64, 64 * 2, submodule=unet_block)
        self.model = UnetSkipConnectionBlock(3, 64, input_nc=3, submodule=unet_block, outermost=True)

    def forward(self, input):
        return self.model(input)

在前面的代码片段的第 4 行,定义了最内层的块,它创建了 U-Net 中间部分的层。最内层的块定义如下。请注意,以下代码应视为伪代码,因为它只是用来展示不同块是如何设计的:

class UnetSkipConnectionBlock(nn.Module):
    # Innermost block */
    def __init__(self):
        down = [nn.LeakyReLU(0.2, inplace=True),
                nn.Conv2d(64 * 8, 64 * 8, kernel_size=4,
                          stride=2, padding=1, bias=False)]
        up = [nn.ReLU(inplace=True),
              nn.ConvTranspose2d(64 * 8, 64 * 8,
                                 kernel_size=4, stride=2,
                                 padding=1, bias=False),
              nn.BatchNorm2d(64 * 8)]
        model = down + up
        self.model = nn.Sequential(*model)

    def forward(self, x):
        return torch.cat([x, self.model(x)], 1)

down中的nn.Conv2d层将 2x2 的输入特征图转换为 1x1 的特征图(因为kernel_size=4 且padding=1),然后nn.ConvTranspose2d层将它们转置回 2x2 大小。

记得nn.Conv2dnn.ConvTranspose2d的输出大小计算公式吗?卷积的输出大小是,而转置卷积的输出大小是

在前向传播中,它将输出与跳跃连接(即输入 x 本身)沿深度通道拼接,这样就将通道数翻倍(并导致前面图中第一个 1,024 通道的特征图)。

在设计复杂网络时,已观察到将两个分支的特征图进行拼接比它们的求和效果更好,因为拼接保留了更多的信息。当然,拼接也会稍微增加内存的开销。

然后,其他层按递归方式构建,如下所示:

class UnetSkipConnectionBlock(nn.Module):
    # Other blocks */
    def __init__(self, out_channels, in_channels, submodule, use_dropout):
        down = [nn.LeakyReLU(0.2, inplace=True),
                nn.Conv2d(out_channels, in_channels, kernel_size=4,
                          stride=2, padding=1, bias=False),
                nn.BatchNorm2d(in_channels)]
        up = [nn.ReLU(inplace=True),
              nn.ConvTranspose2d(in_channels * 2, out_channels,
                                 kernel_size=4, stride=2,
                                 padding=1, bias=False),
              nn.BatchNorm2d(out_channels)]
        if use_dropout:
            model = down + [submodule] + up + [nn.Dropout(0.5)]
        else:
            model = down + [submodule] + up
        self.model = nn.Sequential(*model)

    def forward(self, x):
        return torch.cat([x, self.model(x)], 1)

尽管在models.networks.UnetGenerator中,unet_block对象作为submodule递归传递给新的unet_block,但由于张量实现的紧凑设计,实际模块会正确地在内存中创建和保存。

最后,第一层和最后一层(可以在最外层块中看到)定义如下:

class UnetSkipConnectionBlock(nn.Module):
    # Outermost block */
    def __init__(self):
        down = [nn.Conv2d(3, 64, kernel_size=4,
                          stride=2, padding=1, bias=False)]
        up = [nn.ReLU(inplace=True),
              nn.ConvTranspose2d(64 * 2, 3,
                                 kernel_size=4, stride=2,
                                 padding=1),
              nn.Tanh()]
        model = down + [submodule] + up
        self.model = nn.Sequential(*model)

    def forward(self, x):
        return self.model(x)

生成器网络中的所有卷积核都基于均值为 0、标准差为 0.02 的正态分布进行初始化。所有批量归一化层中的缩放因子则基于均值为 1、标准差为 0.02 的正态分布进行初始化。

判别器架构

pix2pix 的判别器网络架构如下:

pix2pix 的判别器架构

一对样本(每个集合中各取一个)沿深度通道进行拼接,这个 6 通道的图像被视为判别器网络的实际输入。判别器网络将 6 通道 256x256 的图像映射为 1 通道 30x30 的图像,用于计算判别器损失。

判别器网络netD是通过models.networks.define_G方法创建的。默认情况下,它将"basic"作为netD的参数值,这在options/base_options.py的第 33 行中定义。models.networks.NLayerDiscriminator模块(其n_layer=3)被初始化以作为判别器网络。为了简化代码,便于阅读,我们已经对代码进行了简化。你可以参考models/networks.py文件中的完整代码:

class NLayerDiscriminator(nn.Module):
    def __init__(self, n_layers=3):
        super(NLayerDiscriminator, self).__init__()
        sequence = [nn.Conv2d(3 + 3, 64, kernel_size=4, stride=2, padding=1),
                    nn.LeakyReLU(0.2, True)]
        channel_scale = 1
        channel_scale_prev = 1
        for n in range(1, n_layers):
            channel_scale_prev = channel_scale
            channel_scale = 2**n
            sequence += [
                nn.Conv2d(64 * channel_scale_prev, 64 * channel_scale, kernel_size=4, stride=2, padding=1, bias=False),
                nn.BatchNorm2d(64 * channel_scale),
                nn.LeakyReLU(0.2, True)
            ]
        channel_scale_prev = channel_scale
        sequence += [
            nn.Conv2d(64 * channel_scale_prev, 64 * 8, kernel_size=4, stride=1, padding=1, bias=False),
            nn.BatchNorm2d(64 * 8),
            nn.LeakyReLU(0.2, True)
        ]
        sequence += [nn.Conv2d(64 * 8, 1, kernel_size=4, stride=1, padding=1)]
        self.model = nn.Sequential(*sequence)

    def forward(self, input):
        return self.model(input)

这里,我们提供了一段简短的代码,以便在模型创建时打印所有特征图的大小,如下所示:

class SomeModel(nn.Module):
    def __init__(self):
        super(SomeModel, self).__init__()
        sequence = [layer1, layer2, ...]
        self.model = nn.Sequential(*sequence)

    def forward(self, input):
        return self.model(input)

你可以将return self.model(input)这一行替换为以下代码,以检查所有层中的特征图大小(包括归一化和激活函数层):

 def forward(self, input):
 x = input
 for i in range(len(self.model)):
 print(x.shape)
 x = self.modeli
 print(x.shape)
 return x

另外,你可以始终使用 TensorBoard 或其他工具,我们将在本书的最后一章介绍这些工具,以便你可以轻松查看模型的架构。

判别器网络生成一个 30x30 的特征图来表示损失。这样的架构被称为PatchGAN,意味着原始图像中的每个小图像块都会映射到最终损失图中的一个像素。PatchGAN 的一个大优点是,它可以处理任意大小的输入图像,只要标签已经转换为与损失图相同的大小。它还根据局部图像块的质量评估输入图像的质量,而不是根据其全局属性。这里,我们将展示如何计算图像块的大小(即 70)。

首先,让我们考虑一个单独的卷积层,卷积核大小为k,步幅为s。对于输出特征图中的每个像素,其值仅由输入图像中与卷积核大小相同的小块像素决定。

当卷积层超过两个时,输入补丁的大小可以通过以下公式计算:

因此,可以获得判别器网络中每一层输出特征图中单个像素对应的输入补丁大小:

  • 第五层(k=4,s=1):输入补丁的大小是 4(即卷积核的大小)

  • 第四层(k=4,s=1):输入补丁的大小是 4+1*(4-1)=7

  • 第三层(k=4,s=2):输入补丁的大小是 4+2*(7-1)=16

  • 第二层(k=4,s=2):输入补丁的大小是 4+2*(16-1)=34

  • 第一层(k=4,s=2):输入补丁的大小是 4+2*(34-1)=70

这意味着所有这些 70x70 重叠的图像块会通过卷积层转换为 30x30 损失图中的独立像素。任何超出这个 70x70 图像块的像素都不会对损失图中的相应像素产生影响。

pix2pix 的训练和评估

pix2pix 的训练与我们在前一章中介绍的条件 GAN 非常相似。在训练判别器网络时,一对真实数据和标签应映射为 1,而一对生成的数据和标签(该假数据是由生成器生成的)应映射为 0。当训练生成器网络时,梯度会通过判别器和生成器网络,当生成器网络中的参数被更新时。这些生成的数据和标签应由判别器网络映射为 1。主要的区别是,条件 GAN 中的标签是基于图像的,而 pix2pix 中的标签是基于像素的。这个过程在下面的图示中进行了说明:

图像级和像素级标签 GAN 的基本训练过程。A* 和 B* 表示真实样本。红框中的网络实际上会被更新。

请注意,在训练 pix2pix 时,为了使生成的样本尽可能与真实样本相似,在训练生成器网络时,损失函数中会额外添加一个项,具体如下:

这里,表示生成样本与来自配对集合的真实样本之间的 L1 损失。L1 损失的目的是保留图像中的低频信息,以获得更好的图像质量。

值得一提的是,仅使用 L1 范数或 L2 范数将会生成模糊或块状的图像。对此的简短解释可以在此找到:wiseodd.github.io/techblog/2017/02/09/why-l2-blurry。在传统的图像恢复方法中,常常将它们用作正则化项,其中恢复图像的梯度控制图像的清晰度。如果你对 L1 损失和 L2 损失在图像处理领域的作用感兴趣,可以查看 Tony F. Chan 和 C.K. Wong 于 1998 年发表的著名论文《Total variation blind deconvolution》。

现在,我们可以定义 pix2pix 的训练过程,如下所示(伪代码):

class Pix2PixModel(BaseModel):
    def __init__(self):
        BaseModel.__init__(self)
        self.netG = networks.define_G()
        self.netD = networks.define_D()

        self.criterionGAN = torch.nn.BCEWithLogitsLoss()
        self.criterionL1 = torch.nn.L1Loss()
        self.optimizer_G = torch.optim.Adam(self.netG.parameters(), lr=0.0002, betas=(0.5, 0.999))
        self.optimizer_D = torch.optim.Adam(self.netD.parameters(), lr=0.0002, betas=(0.5, 0.999))
        self.optimizers.append(self.optimizer_G)
        self.optimizers.append(self.optimizer_D)

    def forward(self):
        self.fake_B = self.netG(self.real_A)

    def backward_D(self):
        fake_AB = torch.cat((self.real_A, self.fake_B), 1)
        pred_fake = self.netD(fake_AB.detach())
        self.loss_D_fake = self.criterionGAN(pred_fake, False)

        real_AB = torch.cat((self.real_A, self.real_B), 1)
        pred_real = self.netD(real_AB)
        self.loss_D_real = self.criterionGAN(pred_real, True)

        self.loss_D = (self.loss_D_fake + self.loss_D_real) * 0.5
        self.loss_D.backward()

    def backward_G(self):
        fake_AB = torch.cat((self.real_A, self.fake_B), 1)
        pred_fake = self.netD(fake_AB)
        self.loss_G_GAN = self.criterionGAN(pred_fake, True)

        self.loss_G_L1 = self.criterionL1(self.fake_B, self.real_B)

        self.loss_G = self.loss_G_GAN + self.loss_G_L1 * 100.0
        self.loss_G.backward()

    def optimize_parameters(self):
        self.forward()
        # update D
        self.set_requires_grad(self.netD, True)
        self.optimizer_D.zero_grad()
        self.backward_D()
        self.optimizer_D.step()
        # update G
        self.set_requires_grad(self.netD, False)
        self.optimizer_G.zero_grad()
        self.backward_G()
        self.optimizer_G.step()

Pix2PixModel类的作用类似于前一章build_gan.py中的Model类,用于创建生成器和判别器网络,定义它们的优化器,并控制网络的训练过程。

现在,让我们下载一些图像并训练 pix2pix 模型来执行图像到图像的转换。

运行datasets/download_pix2pix_dataset.sh脚本以下载数据集文件,具体如下:

$ ./datasets/download_pix2pix_dataset.sh maps

或者,你可以访问efrosgans.eecs.berkeley.edu/pix2pix/datasets/手动下载数据集文件,并将其解压到你喜欢的任何位置(例如,外部硬盘/media/john/HouseOfData/image_transfer/maps)。地图数据集文件大约为 239 MB,包含在训练集、验证集和测试集中的 1,000 多张图像。

请注意,地图数据集中的集合 A 包含卫星照片,而集合 B 包含地图图像,这与前面小节中所展示的图示相反。

接下来,打开终端并运行以下脚本以开始训练。确保修改dataroot参数以指定你自己的位置。你也可以尝试其他数据集,并将directionBtoA更改为AtoB,以更改两个图像集合之间的转换方向:

$ python train.py --dataroot /media/john/HouseOfData/image_transfer/maps --name maps_pix2pix --model pix2pix --direction BtoA

在第一次训练时,你可能会遇到错误提示Could not connect to Visdom server。这是因为训练脚本调用了Visdom模块来动态更新生成结果,以便我们通过网页浏览器监控训练过程。你可以手动打开checkpoints/maps_pix2pix/web/index.html文件,用你喜欢的浏览器来查看生成的图像,随着模型的训练进行监控。请注意,关闭浏览器中的index.html页面可能会导致训练过程冻结。

完成 200 个训练周期大约需要 6.7 小时,并且在 GTX 1080Ti 显卡上大约需要 1,519 MB 的 GPU 内存。

结果也保存在checkpoints/maps_pix2pix/web/images目录中。通过此方法生成的图像如下所示:

由 pix2pix 生成的图像

正如我们所看到的,生成的卫星照片看起来相当可信。与真实的卫星照片相比,它们在公园小径两旁的树木组织方面做得相当好。

在本节中,我们成功地生成了 256x256 的图像。在下一节中,我们将学习如何使用 pix2pix 的升级版本:pix2pixHD 生成高分辨率图像。

Pix2pixHD - 高分辨率图像转换

Pix2pixHD 由 Ting-Chun Wang、Ming-Yu Liu 和 Jun-Yan Zhu 等人在他们的论文《高分辨率图像合成与语义操作与条件 GANs》中提出,是 pix2pix 模型的升级版。pix2pixHD 相比于 pix2pix 的最大改进在于,它支持 2,048x1,024 分辨率的图像到图像的转换,并且具有更高的质量。

模型架构

为了实现这一点,他们设计了一个两阶段的方案,逐步训练并优化网络,如下图所示。首先,一个分辨率为 1,024x512 的低分辨率图像由生成器网络生成,,称为全局生成器(红框)。其次,该图像由另一个生成器网络放大,,称为局部增强网络,使其大小接近 2,048x1,024(黑框)。也可以在最后加入另一个局部增强网络来生成 4,096x2,048 的图像。请注意,中的最后一个特征图也通过逐元素求和的方式插入到(在残差块之前),以便将更多的全局信息引入到更高分辨率的图像中:

pix2pixHD 中生成器模型的架构(图片摘自 T. C. Wang 等人 2018 年的论文)

pix2pixHD 中的判别器网络也是以多尺度的方式设计的。三个相同的判别器网络分别在不同的图像尺度(原始大小、1/2 大小和 1/4 大小)上工作,并且它们的损失值会加在一起。作者报告称,如果判别器没有多尺度设计,生成的图像中常常会出现重复的模式。此外,一个额外的项,称为特征匹配损失,被添加到最终的判别器损失中,如下公式所示:

这里, 测量生成图像与真实图像在判别器网络多个层次上特征图的 L1 损失。它迫使生成器在不同尺度上近似真实数据,从而生成更为逼真的图像。

有时候,几个具有相同标签的物体可能会被识别为同一物体,这会导致生成器难以正确区分这些物体。如果生成器能知道哪些像素属于哪个物体,而不是它们属于哪个类别,会有所帮助。因此,在 pix2pixHD 中,实例边界图(这是一个二值图,表示所有物体的边界)会按通道方式与语义标签图连接,然后再输入到生成器中。类似地,实例边界图也会与语义标签图和图像(生成的或真实的)连接,然后一起输入到判别器中。

此外,为了更方便地操作生成图像的属性,pix2pixHD 使用了一个额外的 编码器 来从真实图像中提取特征,并对特征进行实例级的平均池化(对每个物体的所有像素进行平均,然后将结果广播回这些像素)。这些特征也是生成器的输入之一。对每个类别中所有物体的特征进行 K-means 聚类,并在推理过程中为物体选择几种可用的纹理或颜色。

我们不会深入探讨 pix2pixHD 的具体架构设计,因为其源代码的主要结构与 pix2pix 类似。如果你感兴趣,可以查看源代码。

模型训练

pix2pixHD 的训练既耗时又耗内存。训练 2,048x1,024 图像大约需要 24 GB 的 GPU 内存。因此,我们将只在 1,024x512 分辨率下进行训练,以便将其适配到单张显卡上。

NVIDIA 已经将完整的 pix2pixHD 源代码开源,支持 PyTorch。我们需要做的就是下载源代码和数据集,来生成我们自己的高分辨率合成图像。现在就开始吧:

  1. 安装先决条件(dominate 和 apex)。我们之前已经安装了 dominate 库。Apex 是由 NVIDIA 开发的一个混合精度和分布式训练库。

  2. 使用 自动混合精度 (AMP) 在训练过程中通过将标准浮点值替换为较低位数的浮点数来减少 GPU 内存消耗(甚至是训练时间)。

  3. 在 Ubuntu 中打开终端,输入以下脚本来安装 apex

$ git clone https://github.com/NVIDIA/apex
$ cd apex
$ pip install -v --no-cache-dir --global-option="--cpp_ext" --global-option="--cuda_ext" .
  1. 下载 pix2pixHD 的源代码(本章的代码库中也可以找到):
$ git clone https://github.com/NVIDIA/pix2pixHD
  1. 使用 Cityscapes 数据集来训练 pix2pixHD 模型。它可以在 www.cityscapes-dataset.com 获取,您需要先注册才能获得下载链接。我们需要下载 gtFine_trainvaltest.zip(241 MB) 和 leftImg8bit_trainvaltest.zip(11 GB) 文件来进行此次实验。

  2. 下载完成后,我们需要重新组织图像,以便训练脚本可以正确地获取图像:

  • 将所有以 _gtFine_instanceIds.png 结尾的图像文件放入 gtFine/train/* 文件夹中,并将其放入 datasets/cityscapes/train_inst 目录中。

  • 将所有以 _gtFine_labelIds.png 结尾的图像文件放入 gtFine/train/* 文件夹中,并将其放入 datasets/cityscapes/train_label 目录中。

  • 将所有以 _leftImg8bit.png 结尾的图像文件放入 leftImg8bit/train/* 文件夹中,并将其放入 datasets/cityscapes/train_img 目录中。

  1. 可以忽略测试和验证集,因为我们只需要训练图像。每个训练文件夹中应该有 2,975 张图像。

  2. 运行 scripts/train_512p.sh 来启动训练过程,或者在终端中简单地输入以下内容:

$ python train.py --name label2city_512p

所有中间结果(采用的参数、生成的图像、日志信息和模型文件)都将保存在 checkpoints/label2city_512p 文件夹中。您可以随时在您喜爱的浏览器中检查 checkpoints/label2city_512p/web/index.html 文件,或直接查看 checkpoints/label2city_512p/web/images 文件夹中的图像,以监视训练过程。

在经过 35 个周期(约 20 小时)的训练后,这里是结果:

在经过 35 个周期的 pix2pixHD 训练后生成的图像

在这里,我们可以看到模型已经根据实例地图中的标签信息找到了放置车辆、树木、建筑物和行人的位置,尽管这些对象在外观上仍有很大改进空间。有趣的是观察到模型试图将道路线放置在正确的位置,并且从被捕捉图像的汽车前盖上的徽章具有几乎完美的反射(这是有道理的,因为徽章和前盖在每张图像中都出现)。

如果你能够等待足够长的时间(大约 110 小时),结果将会非常令人印象深刻:

由 pix2pixHD 生成的图像(从 https://github.com/NVIDIA/pix2pixHD 检索的图像)

在 1,024x512 分辨率上训练大约需要 8,077 MB 的 GPU 内存。当启用 AMP(使用 --fp16 训练)时,GPU 内存消耗从一开始的 7,379 MB 逐渐增加到几个周期后的 7,829 MB,这确实比以前低。然而,训练时间几乎减少了一半。因此,目前应该不使用 AMP,直到未来其性能得到改进。

CycleGAN – 来自无配对图像集的图像到图像翻译

你可能已经注意到,在训练 pix2pix 时,我们需要确定一个方向(AtoBBtoA),即图像要被翻译到的方向。这是否意味着,如果我们想要在图像集 A 和图像集 B 之间自由地进行翻译,我们需要分别训练两个模型呢?我们要说的是,使用 CycleGAN 就不需要!

CycleGAN 是由 Jun-Yan Zhu、Taesung Park、Phillip Isola 等人提出的,发表在他们的论文《使用循环一致性对抗网络进行无配对图像到图像的翻译》中。它是一个基于无配对图像集的双向生成模型。CycleGAN 的核心思想基于循环一致性假设,这意味着如果我们有两个生成模型 G 和 F,它们在两个图像集 X 和 Y 之间进行翻译,其中 Y=G(X),X=F(Y),我们可以自然地假设 F(G(X))应该与 X 非常相似,而 G(F(Y))应该与 Y 非常相似。这意味着我们可以同时训练两组生成模型,它们可以在两个图像集之间自由翻译。

CycleGAN 专门为无配对图像集设计,这意味着训练样本不一定像我们在前几节中看到的 pix2pix 和 pix2pixHD 那样严格配对(例如,从相同视角的语义分割图与街景,或者常规地图与同一地点的卫星照片)。这使得 CycleGAN 不仅仅是一个图像到图像的翻译工具。它解锁了从任何类型的图像到你自己图像的风格迁移的潜力,例如,将苹果变成橙子、马变成斑马、照片变成油画,反之亦然。在这里,我们将使用风景照片和文森特·梵高的油画作为示例,展示 CycleGAN 是如何设计和训练的。

请注意,在本节中,代码布局与上一章中的 CGAN 类似。完整的源代码可以在本章的代码库中找到。模型在cyclegan.py中定义,训练过程在build_gan.py中定义,主入口位于main.py。源代码基于github.com/eriklindernoren/PyTorch-GAN提供的实现。值得一提的是,我们的实现训练速度比该实现快 1.2 倍,并且 GPU 内存占用减少了 28%。此外,在本章的第一部分,你可以找到 pix2pix 的源代码,其中也提供了 CycleGAN 的实现。你可以选择任何一个实现,因为两者之间没有太大区别。

基于循环一致性的模型设计

使用两对生成器和判别器网络,每对负责一个翻译方向。为了理解为什么 CycleGAN 是这样设计的,我们需要了解循环一致性是如何构建的。

在下图中,生成器,将样本 A 映射为样本 B,其性能由判别器,来衡量。与此同时,另一个生成器,,被训练来将样本 B 映射回样本 A,其性能由判别器,来衡量。在这个过程中,生成样本,与对应的真实样本,之间的距离,告诉我们模型中是否存在循环一致性,如下图中的虚线框所示。之间的距离通过循环一致性损失来衡量,该损失形式为 L1 范数。

除了传统的对抗损失与 1 之间的距离),还加入了恒等损失(意味着应该与非常接近),以帮助保持图像的颜色风格:

CycleGAN 中的损失计算。A和 B表示真实样本。由红框表示的网络在训练生成器时会更新。

这两个生成器网络,和,完全相同。生成器网络的架构可以在下图中看到。256x256 的输入图像经过多个卷积层下采样为 64x64,然后经过九个连续的残差块处理,最后通过卷积再次上采样回 256x256:

CycleGAN 中的生成器架构

我们将以一个名为cyclegan.py的空文件开始,如之前所提到的。让我们从导入开始:

import torch
import torch.nn as nn
import torch.nn.functional as F
import numpy as np

接下来,我们将创建定义残差块的代码,如下所示:

class ResidualBlock(nn.Module):
    def __init__(self, channels):
        super(ResidualBlock, self).__init__()

        block = [nn.ReflectionPad2d(1),
                 nn.Conv2d(channels, channels, 3),
                 nn.InstanceNorm2d(channels),
                 nn.ReLU(inplace=True),
                 nn.ReflectionPad2d(1),
                 nn.Conv2d(channels, channels, 3),
                 nn.InstanceNorm2d(channels)]
         self.block = nn.Sequential(*block)

     def forward(self, x):
         return x + self.block(x)

现在,我们可以定义生成器网络,如下所示:

class Generator(nn.Module):
    def __init__(self, channels, num_blocks=9):
        super(Generator, self).__init__()
        self.channels = channels

        model = [nn.ReflectionPad2d(3)]
        model += self._create_layer(self.channels, 64, 7, stride=1, padding=0, transposed=False)
        # downsampling
        model += self._create_layer(64, 128, 3, stride=2, padding=1, transposed=False)
        model += self._create_layer(128, 256, 3, stride=2, padding=1, transposed=False)
        # residual blocks
        model += [ResidualBlock(256) for _ in range(num_blocks)]
        # upsampling
        model += self._create_layer(256, 128, 3, stride=2, padding=1, transposed=True)
        model += self._create_layer(128, 64, 3, stride=2, padding=1, transposed=True)
        # output
        model += [nn.ReflectionPad2d(3),
                  nn.Conv2d(64, self.channels, 7),
                  nn.Tanh()]

        self.model = nn.Sequential(*model)

    def _create_layer(self, size_in, size_out, kernel_size, stride=2, padding=1, transposed=False):
        layers = []
        if transposed:
            layers.append(nn.ConvTranspose2d(size_in, size_out, kernel_size, stride=stride, padding=padding, output_padding=1))
        else:
            layers.append(nn.Conv2d(size_in, size_out, kernel_size, stride=stride, padding=padding))
        layers.append(nn.InstanceNorm2d(size_out))
        layers.append(nn.ReLU(inplace=True))
        return layers

    def forward(self, x):
        return self.model(x)

如你所注意到的,在这里我们使用了torch.nn.InstanceNorm2d而不是torch.nn.BatchNorm2d。前者的归一化层更适合风格迁移。

同样,CycleGAN 中使用了两个相同的判别器网络,它们的关系可以在下图中看到:

CycleGAN 中两个判别器网络之间的关系。由红框表示的网络在训练时会更新。

判别器网络的架构几乎与 pix2pix 中的架构相同(该架构称为 PatchGAN),唯一不同的是输入图像的深度通道为 3,而不是 6,并且torch.nn.BatchNorm2d被替换成了torch.nn.InstanceNorm2d

判别器网络定义的代码如下:

class Discriminator(nn.Module):
    def __init__(self, channels):
        super(Discriminator, self).__init__()
        self.channels = channels

        self.model = nn.Sequential(
            *self._create_layer(self.channels, 64, 2, normalize=False),
            *self._create_layer(64, 128, 2),
            *self._create_layer(128, 256, 2),
            *self._create_layer(256, 512, 1),
            nn.Conv2d(512, 1, 4, stride=1, padding=1)
        )

    def _create_layer(self, size_in, size_out, stride, normalize=True):
        layers = [nn.Conv2d(size_in, size_out, 4, stride=stride, padding=1)]
        if normalize:
            layers.append(nn.InstanceNorm2d(size_out))
        layers.append(nn.LeakyReLU(0.2, inplace=True))
        return layers

    def forward(self, x):
        return self.model(x)

现在,让我们学习如何训练和评估模型。

模型训练与评估

现在,我们将创建build_gan.py文件。像往常一样,我们从导入开始:

import itertools
import os
import time

from datetime import datetime

import numpy as np
import torch
import torchvision.utils as vutils
import utils

from cyclegan import Generator as cycG
from cyclegan import Discriminator as cycD

我们需要一个函数来初始化权重:

def _weights_init(m):
    classname = m.__class__.__name__
    if classname.find('Conv') != -1:
        torch.nn.init.normal_(m.weight.data, 0.0, 0.02)
    elif classname.find('BatchNorm') != -1:
        torch.nn.init.normal_(m.weight.data, 1.0, 0.02)
        torch.nn.init.constant_(m.bias.data, 0.0)

现在,我们将创建Model类:

class Model(object):
    def __init__(self,
                 name,
                 device,
                 data_loader,
                 test_data_loader,
                 channels,
                 img_size,
                 num_blocks):
        self.name = name
        self.device = device
        self.data_loader = data_loader
        self.test_data_loader = test_data_loader
        self.channels = channels
        self.img_size = img_size
        self.num_blocks = num_blocks
        assert self.name == 'cyclegan'
        self.netG_AB = cycG(self.channels, self.num_blocks)
        self.netG_AB.apply(_weights_init)
        self.netG_AB.to(self.device)
        self.netG_BA = cycG(self.channels, self.num_blocks)
        self.netG_BA.apply(_weights_init)
        self.netG_BA.to(self.device)
        self.netD_A = cycD(self.channels)
        self.netD_A.apply(_weights_init)
        self.netD_A.to(self.device)
        self.netD_B = cycD(self.channels)
        self.netD_B.apply(_weights_init)
        self.netD_B.to(self.device)
        self.optim_G = None
        self.optim_D_A = None
        self.optim_D_B = None
        self.loss_adv = torch.nn.MSELoss()
        self.loss_cyc = torch.nn.L1Loss()
        self.loss_iden = torch.nn.L1Loss()

    @property
    def generator_AB(self):
        return self.netG_AB

    @property
    def generator_BA(self):
        return self.netG_BA

    @property
    def discriminator_A(self):
        return self.netD_A

    @property
    def discriminator_B(self):
        return self.netD_B

    def create_optim(self, lr, alpha=0.5, beta=0.999):
        self.optim_G = torch.optim.Adam(itertools.chain(self.netG_AB.parameters(), self.netG_BA.parameters()),
                                        lr=lr,
                                        betas=(alpha, beta))
        self.optim_D_A = torch.optim.Adam(self.netD_A.parameters(),
                                          lr=lr,
                                          betas=(alpha, beta))
        self.optim_D_B = torch.optim.Adam(self.netD_B.parameters(),
                                          lr=lr,
                                          betas=(alpha, beta))

之前展示了生成器和判别器网络的训练过程。在这里,我们将深入探讨build_gan.train()的实现。

首先,我们需要训练生成器网络:

def train(self,
          epochs,
          log_interval=100,
          out_dir='',
          verbose=True):
        self.netG_AB.train()
        self.netG_BA.train()
        self.netD_A.train()
        self.netD_B.train()
        lambda_cyc = 10
        lambda_iden = 5
        real_label = torch.ones((self.data_loader.batch_size, 1, self.img_size//2**4, self.img_size//2**4), device=self.device)
        fake_label = torch.zeros((self.data_loader.batch_size, 1, self.img_size//2**4, self.img_size//2**4), device=self.device)
        image_buffer_A = utils.ImageBuffer()
        image_buffer_B = utils.ImageBuffer()
        total_time = time.time()
        for epoch in range(epochs):
            batch_time = time.time()
            for batch_idx, data in enumerate(self.data_loader):
                real_A = data['trainA'].to(self.device)
                real_B = data['trainB'].to(self.device)

                # Train G
                self.optim_G.zero_grad()

                # adversarial loss
                fake_B = self.netG_AB(real_A)
                _loss_adv_AB = self.loss_adv(self.netD_B(fake_B),  
                  real_label)
                fake_A = self.netG_BA(real_B)
                _loss_adv_BA = self.loss_adv(self.netD_A(fake_A), 
                  real_label)
                adv_loss = (_loss_adv_AB + _loss_adv_BA) / 2

                # cycle loss
                recov_A = self.netG_BA(fake_B)
                _loss_cyc_A = self.loss_cyc(recov_A, real_A)
                recov_B = self.netG_AB(fake_A)
                _loss_cyc_B = self.loss_cyc(recov_B, real_B)
                cycle_loss = (_loss_cyc_A + _loss_cyc_B) / 2

                # identity loss
                _loss_iden_A = self.loss_iden(self.netG_BA(real_A), real_A)
                _loss_iden_B = self.loss_iden(self.netG_AB(real_B), real_B)
                iden_loss = (_loss_iden_A + _loss_iden_B) / 2

                g_loss = adv_loss + lambda_cyc * cycle_loss + 
                  lambda_iden * iden_loss
                g_loss.backward()
                self.optim_G.step()

然后,我们需要训练判别器网络:

                # Train D_A
                self.optim_D_A.zero_grad()

                _loss_real = self.loss_adv(self.netD_A(real_A), real_label)
                fake_A = image_buffer_A.update(fake_A)
                _loss_fake = self.loss_adv(self.netD_A(fake_A.detach()), 
                 fake_label)
                d_loss_A = (_loss_real + _loss_fake) / 2

                d_loss_A.backward()
                self.optim_D_A.step()

                # Train D_B
                self.optim_D_B.zero_grad()

                _loss_real = self.loss_adv(self.netD_B(real_B), real_label)
                fake_B = image_buffer_B.update(fake_B)
                _loss_fake = self.loss_adv(self.netD_B(fake_B.detach()), 
                  fake_label)
                d_loss_B = (_loss_real + _loss_fake) / 2

                d_loss_B.backward()
                self.optim_D_B.step()

                d_loss = (d_loss_A + d_loss_B) / 2

最后的变量d_loss仅用于日志记录,这里已省略。如果你想了解更多关于日志打印和图像导出的内容,可以参考本章的源代码文件:

                if verbose and batch_idx % log_interval == 0 and batch_idx > 0:
                    print('Epoch {} [{}/{}] loss_D: {:.4f} loss_G: {:.4f} time: {:.2f}'.format(
                          epoch, batch_idx, len(self.data_loader),
                          d_loss.mean().item(),
                          g_loss.mean().item(),
                          time.time() - batch_time))
                    with torch.no_grad():
                        imgs = next(iter(self.test_data_loader))
                        _real_A = imgs['testA'].to(self.device)
                        _fake_B = self.netG_AB(_real_A)
                        _real_B = imgs['testB'].to(self.device)
                        _fake_A = self.netG_BA(_real_B)
                        viz_sample = torch.cat(
                            (_real_A, _fake_B, _real_B, _fake_A), 0)
                        vutils.save_image(viz_sample,
                                          os.path.join(
                                              out_dir, 'samples_{}_{}.png'.format(epoch, batch_idx)),
                                          nrow=self.test_data_loader.batch_size,
                                          normalize=True)
                    batch_time = time.time()

            self.save_to(path=out_dir, name=self.name, verbose=False)
        if verbose:
            print('Total train time: {:.2f}'.format(time.time() - total_time))
   def eval(self,
             batch_size=None):
        self.netG_AB.eval()
        self.netG_BA.eval()
        self.netD_A.eval()
        self.netD_B.eval()
        if batch_size is None:
            batch_size = self.test_data_loader.batch_size

        with torch.no_grad():
            for batch_idx, data in enumerate(self.test_data_loader):
                _real_A = data['testA'].to(self.device)
                _fake_B = self.netG_AB(_real_A)
                _real_B = data['testB'].to(self.device)
                _fake_A = self.netG_BA(_real_B)
                viz_sample = torch.cat((_real_A, _fake_B, _real_B, _fake_A), 0)
                vutils.save_image(viz_sample,
                                  'img_{}.png'.format(batch_idx),
                                  nrow=batch_size,
                                  normalize=True)

    def save_to(self,
                path='',
                name=None,
                verbose=True):
        if name is None:
            name = self.name
        if verbose:
            print('\nSaving models to {}_G_AB.pt and such ...'.format(name))
        torch.save(self.netG_AB.state_dict(), os.path.join(
            path, '{}_G_AB.pt'.format(name)))
        torch.save(self.netG_BA.state_dict(), os.path.join(
            path, '{}_G_BA.pt'.format(name)))
        torch.save(self.netD_A.state_dict(), os.path.join(
            path, '{}_D_A.pt'.format(name)))
        torch.save(self.netD_B.state_dict(), os.path.join(
            path, '{}_D_B.pt'.format(name)))

    def load_from(self,
                  path='',
                  name=None,
                  verbose=True):
        if name is None:
            name = self.name
        if verbose:
            print('\nLoading models from {}_G_AB.pt and such ...'.format(name))
        ckpt_G_AB = torch.load(os.path.join(path, '{}_G_AB.pt'.format(name)))
        if isinstance(ckpt_G_AB, dict) and 'state_dict' in ckpt_G_AB:
            self.netG_AB.load_state_dict(ckpt_G_AB['state_dict'], strict=True)
        else:
            self.netG_AB.load_state_dict(ckpt_G_AB, strict=True)
        ckpt_G_BA = torch.load(os.path.join(path, '{}_G_BA.pt'.format(name)))
        if isinstance(ckpt_G_BA, dict) and 'state_dict' in ckpt_G_BA:
            self.netG_BA.load_state_dict(ckpt_G_BA['state_dict'], strict=True)
        else:
            self.netG_BA.load_state_dict(ckpt_G_BA, strict=True)
        ckpt_D_A = torch.load(os.path.join(path, '{}_D_A.pt'.format(name)))
        if isinstance(ckpt_D_A, dict) and 'state_dict' in ckpt_D_A:
            self.netD_A.load_state_dict(ckpt_D_A['state_dict'], strict=True)
        else:
            self.netD_A.load_state_dict(ckpt_D_A, strict=True)
        ckpt_D_B = torch.load(os.path.join(path, '{}_D_B.pt'.format(name)))
        if isinstance(ckpt_D_B, dict) and 'state_dict' in ckpt_D_B:
            self.netD_B.load_state_dict(ckpt_D_B['state_dict'], strict=True)
        else:            self.netD_B.load_state_dict(ckpt_D_B, strict=True)

在这里,正如论文中建议的那样,我们通过随机从生成的图像历史中选择一张图像来更新判别器,而不是实时从假样本中选择。生成图像的历史由ImageBuffer类维护,定义如下。请将上一章中的utils.py文件复制过来,并将ImageBuffer类添加到其中:

class ImageBuffer(object):
    def __init__(self, depth=50):
        self.depth = depth
        self.buffer = []

    def update(self, image):
        if len(self.buffer) == self.depth:
            i = random.randint(0, self.depth-1)
            self.buffer[i] = image
        else:
            self.buffer.append(image)
        if random.uniform(0,1) > 0.5:
            i = random.randint(0, len(self.buffer)-1)
            return self.buffer[i]
        else:
            return image

我们还需要编写一个自定义数据集读取器,从不同的文件夹中提取未配对的图像。将以下内容放入一个名为datasets.py的新文件中:

import glob
import random
import os

import torchvision

from torch.utils.data import Dataset
from PIL import Image

class ImageDataset(Dataset):
    def __init__(self, root_dir, transform=None, unaligned=False, mode='train'):
        self.transform = torchvision.transforms.Compose(transform)
        self.unaligned = unaligned
        self.train = (mode == 'train')

        self.files_A = sorted(glob.glob(os.path.join(root_dir, '%sA' % mode) + '/*.*'))
        self.files_B = sorted(glob.glob(os.path.join(root_dir, '%sB' % mode) + '/*.*'))

    def __getitem__(self, index):
        item_A = self.transform(Image.open(self.files_A[index % len(self.files_A)]))

        if self.unaligned:
            item_B = self.transform(Image.open(self.files_B[random.randint(0, len(self.files_B) - 1)]))
        else:
            item_B = self.transform(Image.open(self.files_B[index % len(self.files_B)]))

        if self.train:
            return {'trainA': item_A, 'trainB': item_B}
        else:
            return {'testA': item_A, 'testB': item_B}

    def __len__(self):
        return max(len(self.files_A), len(self.files_B))

画作和照片的形状并不总是方形的。因此,我们需要从原始图像中裁剪 256x256 的补丁。我们在main.py中预处理数据(数据增强)。这里我们只展示了一部分代码,你可以在main.py文件中找到其余部分:

def main():
    device = torch.device("cuda:0" if FLAGS.cuda else "cpu")

    if FLAGS.train:
        print('Loading data...\n')
        transform = [transforms.Resize(int(FLAGS.img_size*1.12), Image.BICUBIC),
                     transforms.RandomCrop((FLAGS.img_size, FLAGS.img_size)),
                     transforms.RandomHorizontalFlip(),
                     transforms.ToTensor(),
                     transforms.Normalize((0.5,0.5,0.5), (0.5,0.5,0.5))]
        dataloader = DataLoader(ImageDataset(os.path.join(FLAGS.data_dir, FLAGS.dataset),
                                             transform=transform, unaligned=True, mode='train'),
                                batch_size=FLAGS.batch_size, shuffle=True, num_workers=2)
        test_dataloader = DataLoader(ImageDataset(os.path.join(FLAGS.data_dir, FLAGS.dataset),
                                                  transform=transform, unaligned=True, mode='test'),
                                     batch_size=FLAGS.test_batch_size, shuffle=True, num_workers=2)

        print('Creating model...\n')
        model = Model(FLAGS.model, device, dataloader, test_dataloader, FLAGS.channels, FLAGS.img_size, FLAGS.num_blocks)
        model.create_optim(FLAGS.lr)

        # Train
        model.train(FLAGS.epochs, FLAGS.log_interval, FLAGS.out_dir, True)

不要忘记调整 CycleGAN 的参数解析。记住,你应该更改--data_dir的默认值,使其与自己的设置匹配,因此请确保在命令行中包含以下内容:

    parser.add_argument('--data_dir', type=str, default='/media/john/HouseOfData/image_transfer', help='Directory for dataset.')
    parser.add_argument('--dataset', type=str, default='vangogh2photo', help='Dataset name.')
    ...
    parser.add_argument('--num_blocks', type=int, default=9, help='number of residual blocks')

现在,是时候下载数据集并开始享受了!访问people.eecs.berkeley.edu/~taesung_park/CycleGAN/datasets手动下载数据集文件。或者,你可以使用位于 pix2pix 源代码中的datasets/download_cyclegan_dataset.sh脚本下载vangogh2photo.zip文件,该文件约为 292 MB,包含 400 张梵高画作和 7,038 张照片(其中 6,287 张用于训练,751 张用于测试)。下载完成后,将图像提取到一个文件夹中(例如,外部硬盘/media/john/HouseOfData/image_transfer)。

打开终端并键入以下脚本以开始训练:

$ python main.py --dataset vangogh2photo

训练 CycleGAN 20 个周期大约需要 10 小时,并且在 GTX 1080Ti 显卡上消耗大约 4,031 MB 的 GPU 内存。以下图像中可以看到一些结果。在这里,我们可以看到 CycleGAN 的风格迁移能力非常惊人。你还可以访问这个网站,了解更多关于 CycleGAN 的应用:junyanz.github.io/CycleGAN

CycleGAN 生成的图像。上面两行:从画作到照片;下面两行:从照片到画作。

摘要

我们已经在几个章节中熟悉了图像生成技术。尽管成功训练 GAN 生成惊艳图像总是充满挑战和成就感,但我们也应该认识到,GAN 还可以用来修复问题并恢复图像。

在下一章,我们将探讨 GAN 的生成能力,以解决图像修复中的一些挑战性问题。

进一步阅读

  1. Le J. (2018 年 5 月 3 日) 如何使用深度学习进行语义分割。取自 medium.com/nanonets/how-to-do-image-segmentation-using-deep-learning-c673cc5862ef

  2. Rainy J. (2018 年 2 月 12 日) 为视频稳定化神经风格转移。取自 medium.com/element-ai-research-lab/stabilizing-neural-style-transfer-for-video-62675e203e42

  3. Isola P, Zhu JY, Zhou T, Efros A. (2017) 基于条件对抗网络的图像到图像翻译。CVPR。

  4. Agustinus K. (2017 年 2 月 9 日) 为什么 L2 重建损失会导致模糊图像? 取自 wiseodd.github.io/techblog/2017/02/09/why-l2-blurry

  5. Chan T F, Wong C K. (1998) 总变分盲去卷积。IEEE 图像处理学报。7(3): 370-375。

  6. Wang T C, Liu M Y, Zhu J Y, 等. (2018) 基于条件 GAN 的高分辨率图像合成与语义操作。CVPR。

  7. Zhu J Y, Park T, Isola P, 等. (2017) 使用循环一致对抗网络的无配对图像到图像翻译。ICCV。

第八章:使用 GAN 进行图像修复

你是否曾经遇到过那种你非常喜欢的、质量差且模糊的图像(或表情包),即使是 Google 也无法帮助你找到更高分辨率的版本?除非你是那少数几位已经花费多年时间学习数学和编程的人,知道在目标方程中哪个分数阶正则化项可以通过哪种数值方法来求解,不然我们不妨尝试一下 GAN!

本章将帮助你使用 SRGAN 实现图像超分辨率,将低分辨率图像生成高分辨率图像,并使用数据预取器加速数据加载,提高训练过程中 GPU 的效率。你还将学习如何通过几种方法实现自己的卷积操作,包括直接方法、基于 FFT 的方法和 im2col 方法。稍后,我们将了解原始 GAN 损失函数的缺点,并通过使用 Wasserstein 损失(即 Wasserstein GAN)来改进它们。在本章结束时,你将学会如何训练 GAN 模型来执行图像修复,填补图像中的缺失部分。

本章将涵盖以下主题:

  • 使用 SRGAN 进行图像超分辨率

  • 生成式图像修复

使用 SRGAN 进行图像超分辨率

图像修复是一个广泛的领域,涉及到图像修复的三大主要过程:

  • 图像超分辨率:将图像扩展至更高的分辨率

  • 图像去模糊:将模糊的图像转化为清晰图像

  • 图像修复:填补图像中的空洞或去除水印

所有这些过程都涉及从现有像素中估计像素信息。修复像素一词实际上是指估计它们应该呈现的样子。例如,在图像超分辨率中:为了将图像大小扩展一倍,我们需要估算三个额外的像素,以便与当前像素一起形成一个 2 x 2 区域。图像修复已经被研究人员和组织研究了几十年,开发了许多深奥的数学方法,这使得非数学背景的人很难轻松上手。现在,令人感兴趣的是,GAN 正在逐渐受到关注。

在这一节中,我们将介绍 GAN 家族的另一位成员 SRGAN,用于将图像上采样至更高的分辨率。

SRGAN(超分辨率生成对抗网络)由 Christian Ledig、Lucas Theis、Ferenc Huszar 等人提出,发表在他们的论文 Photo-Realistic Single Image Super-Resolution Using a Generative Adversarial Network 中。它被认为是首个成功将图像分辨率提高四倍的方法。它的结构非常简单。与许多其他 GAN 一样,它由一个生成器网络和一个判别器网络组成。它们的架构将在以下部分展示。

创建生成器

让我们来看一下生成器网络的组成部分:

SRGAN 的生成器架构(2X)

在前面的图示中,我们以 2 倍(到 1,0241,024)放大一个 512512 的图像作为例子。输入图像的大小是相当随意的,因为生成器网络中每个组件的设计与特征图的大小无关。上采样块负责将图像大小扩大一倍。如果我们想放大四倍,只需要在现有的上采样块后再添加一个上采样块。使用三个上采样块,当然可以将图像大小扩大八倍。

在生成器网络中,高级特征通过五个残差块提取,这些特征与来自原始图像的较少处理的细节信息(通过跨越残差块的长跳跃连接)结合。结合后的特征图被扩展为  通道(其中  代表尺度因子,  代表残差块中的通道数),其大小为 。上采样块将此  Tensor(  代表批量大小)转换为 。这一过程通过子像素卷积实现,这一方法由 Wenzhe Shi、Jose Caballero、Ferenc Huszár 等人在其论文《使用高效子像素卷积神经网络进行实时单图像和视频超分辨率》中提出。

以下是子像素卷积的示例。在低分辨率特征图中的每个  通道中,每个通道只负责高分辨率输出中  块内的一个像素。这种方法的一个大优点是,相较于普通卷积层,它只执行  的卷积操作,从而使训练更容易且更快速。

在 PyTorch 中,子像素卷积中的上采样步骤可以通过 nn.PixelShuffle 层完成,实际上它是重新调整输入张量的形状。你可以在这里查看 C++的源代码,pytorch/aten/src/ATen/native/PixelShuffle.cpp,看看形状是如何调整的。

如何查看 PyTorch 操作的源代码?在使用 VS Code 时很容易。我们只需不断双击类名,并按 F12 直到我们到达 torch 模块下源代码树中的类定义。然后,我们查找该类中调用了哪些其他方法(通常可以在 self.forward 中找到),这将引导我们找到其 C++实现。

以下是实现nn.PixelShuffle的 C++源代码步骤:

  1. 双击名称PixelShuffle,然后按F12。这会把我们带到site-packages/torch/nn/modules/__init__.py文件中的这一行:*
from .pixelshuffle import PixelShuffle
  1. 双击并按F12在这一行内的PixelShuffle,会将我们带到site-packages/torch/nn/modules/pixelshuffle.py中的PixelShuffle类定义。在它的forward方法中,我们可以看到调用了F.pixel_shuffle

  2. 再次双击并按F12pixel_shuffle上。我们会到达site-packages/torch/nn/functional.py中的类似片段:

pixel_shuffle = _add_docstr(torch.pixel_shuffle, r"""
...
""")

这是代码中的 C++部分作为 Python 对象注册到 PyTorch 的地方。PyTorch 操作的 C++对应物有时也会从torch._C._nn模块调用。在 VS Code 中,将鼠标悬停在torch.pixel_shuffle上会显示pixel_shuffle(self: Tensor, upscale_factor: int) -> Tensor,具体取决于使用的扩展。不幸的是,按F12没有找到任何有用的内容。

  1. 要查找此pixel_shuffle函数的 C++实现,我们只需在 GitHub 上的 PyTorch 仓库中搜索pixel_shuffle关键字。如果你已将 PyTorch 源代码克隆到本地,可以在终端中输入以下命令来在*.cpp文件中搜索关键字:
$ grep -r --include \*.cpp pixel_shuffle .

因此,我们可以在pytorch/aten/src/ATen/native/PixelShuffle.cpp中找到函数定义Tensor pixel_shuffle(const Tensor& self, int64_t upscale_factor)

如果你对 PyTorch 是如何开发的,以及 C++和 Python 如何在 CPU 和 GPU 上协同工作,提供如此灵活且易于使用的接口感兴趣,可以查看 PyTorch 开发者之一 Edward Z. Yang 写的这篇长文:blog.ezyang.com/2019/05/pytorch-internals

现在,让我们来看看定义生成器网络的代码。我们实现的 SRGAN 大部分基于这个仓库:github.com/leftthomas/SRGAN。本章的代码仓库中也提供了 PyTorch 1.3 的完整工作源代码。我们将从创建一个新的 Python 文件开始,命名为srgan.py

  1. 定义残差块(当然,首先需要导入必要的模块)。
import math
import torch
import torch.nn.functional as F
from torch import nn
import torchvision.models as models

class ResidualBlock(nn.Module):
    def __init__(self, channels):
        super(ResidualBlock, self).__init__()
        self.conv1 = nn.Conv2d(channels, channels, kernel_size=3,  
          padding=1)
        self.bn1 = nn.BatchNorm2d(channels)
        self.prelu = nn.PReLU()
        self.conv2 = nn.Conv2d(channels, channels, kernel_size=3, 
          padding=1)
        self.bn2 = nn.BatchNorm2d(channels)

    def forward(self, x):
        residual = self.conv1(x)
        residual = self.bn1(residual)
        residual = self.prelu(residual)
        residual = self.conv2(residual)
        residual = self.bn2(residual)
        return x + residual

在这里,参数化 ReLUPReLU)被用作激活函数。PReLU 与 LeakyReLU 非常相似,不同之处在于负值的斜率因子是一个可学习的参数。

  1. 定义上采样块:
class UpsampleBLock(nn.Module):
    def __init__(self, in_channels, up_scale):
        super(UpsampleBLock, self).__init__()
        self.conv = nn.Conv2d(in_channels, in_channels * up_scale **  
          2, kernel_size=3, padding=1)
        self.pixel_shuffle = nn.PixelShuffle(up_scale)
        self.prelu = nn.PReLU()

    def forward(self, x):
        x = self.conv(x)
        x = self.pixel_shuffle(x)
        x = self.prelu(x)
        return x

在这里,我们使用一个nn.Conv2d层和一个nn.PixelShuffle层来执行子像素卷积,将低分辨率特征图重塑为高分辨率。这是 PyTorch 官方示例推荐的方法:github.com/pytorch/examples/blob/master/super_resolution/model.py

  1. 定义包含残差和上采样块的生成器网络:
class Generator(nn.Module):
    def __init__(self, scale_factor):
        upsample_block_num = int(math.log(scale_factor, 2))

        super(Generator, self).__init__()
        self.block1 = nn.Sequential(
            nn.Conv2d(3, 64, kernel_size=9, padding=4),
            nn.PReLU()
        )
        self.block2 = ResidualBlock(64)
        self.block3 = ResidualBlock(64)
        self.block4 = ResidualBlock(64)
        self.block5 = ResidualBlock(64)
        self.block6 = ResidualBlock(64)
        self.block7 = nn.Sequential(
            nn.Conv2d(64, 64, kernel_size=3, padding=1),
            nn.BatchNorm2d(64)
        )
        block8 = [UpsampleBLock(64, 2) for _ in 
          range(upsample_block_num)]
        block8.append(nn.Conv2d(64, 3, kernel_size=9, padding=4))
        self.block8 = nn.Sequential(*block8)

    def forward(self, x):
        block1 = self.block1(x)
        block2 = self.block2(block1)
        block3 = self.block3(block2)
        block4 = self.block4(block3)
        block5 = self.block5(block4)
        block6 = self.block6(block5)
        block7 = self.block7(block6)
        block8 = self.block8(block1 + block7)

        return (torch.tanh(block8) + 1) / 2

别忘了最后的长跳跃连接(self.block8(block1 + block7))。最后,生成器网络的输出通过 tanh 激活函数被缩放到[0,1]的范围,原始范围是[-1,1]。这是因为训练图像的像素值位于[0,1]范围内,我们需要将其调整为有利于判别网络区分真实和伪造图像的相同范围。

我们之前没有谈到在训练 GAN 时如何注意值范围的陷阱。在之前的章节中,我们几乎总是通过transforms.Normalize((0.5,), (0.5,))将输入图像缩放到[-1,1]范围,进行训练数据的预处理。由于torch.tanh的输出也是[-1,1],因此在将生成的样本输入到判别器网络或损失函数之前,不需要对其进行重新缩放。

创建判别器

判别网络的架构如下所示:

SRGAN 的判别器架构

SRGAN 的判别器采用类似 VGG 的结构,逐步减少特征图的尺寸,并扩展深度通道,希望每一层包含相似量的信息。与传统 VGG 网络不同,判别器使用池化层将最后一层 VGG 的特征图转换为 1 x 1。判别器网络的最终输出是一个单一值,表示输入图像是高分辨率还是低分辨率。

这里,我们给出了 SRGAN 判别器网络的定义代码:

class Discriminator(nn.Module):
    def __init__(self):
        super(Discriminator, self).__init__()
        self.net = nn.Sequential(
            nn.Conv2d(3, 64, kernel_size=3, padding=1),
            nn.LeakyReLU(0.2),

            nn.Conv2d(64, 64, kernel_size=3, stride=2, padding=1),
            nn.BatchNorm2d(64),
            nn.LeakyReLU(0.2),

            nn.Conv2d(64, 128, kernel_size=3, padding=1),
            nn.BatchNorm2d(128),
            nn.LeakyReLU(0.2),

            nn.Conv2d(128, 128, kernel_size=3, stride=2, padding=1),
            nn.BatchNorm2d(128),
            nn.LeakyReLU(0.2),

            nn.Conv2d(128, 256, kernel_size=3, padding=1),
            nn.BatchNorm2d(256),
            nn.LeakyReLU(0.2),

            nn.Conv2d(256, 256, kernel_size=3, stride=2, padding=1),
            nn.BatchNorm2d(256),
            nn.LeakyReLU(0.2),

            nn.Conv2d(256, 512, kernel_size=3, padding=1),
            nn.BatchNorm2d(512),
            nn.LeakyReLU(0.2),

            nn.Conv2d(512, 512, kernel_size=3, stride=2, padding=1),
            nn.BatchNorm2d(512),
            nn.LeakyReLU(0.2),

            nn.AdaptiveAvgPool2d(1),
            nn.Conv2d(512, 1024, kernel_size=1),
            nn.LeakyReLU(0.2),
            nn.Conv2d(1024, 1, kernel_size=1)
        )

    def forward(self, x):
        batch_size = x.size(0)
        return torch.sigmoid(self.net(x).view(batch_size))

定义训练损失

SRGAN 的损失由 4 部分组成。这里,我们用  表示低分辨率(LR)图像,  表示生成器给出的超分辨率(SR)图像,  表示真实的高分辨率(HR)图像:

  • 对抗损失 ,与之前的 GAN 模型类似

  • 像素级内容损失 ,即超分辨率(SR)图像与高分辨率(HR)图像之间的均方误差(MSE)损失

  • VGG 损失 ,即超分辨率(SR)图像和高分辨率(HR)图像最后特征图之间的均方误差(MSE)损失

  • 正则化损失 ,即像素梯度在水平方向和垂直方向上的平均 L2 范数之和

最终训练损失如下:

它被称为感知损失,意味着在判断超分辨率图像质量时,它会同时考虑像素级别的相似性和高层特征。

请注意,感知损失中的 L2 范数正则化项实际上会使图像变得模糊,因为它对像素梯度施加了强烈的约束。如果你对这一断言感到困惑,可以在脑海中想象一个正态分布,其中 x 轴表示像素梯度,y轴告诉我们像素梯度值出现在图像中的可能性。在正态分布中,,大部分元素都非常接近y轴,这意味着大多数像素的梯度非常小。这表明相邻像素之间的变化通常是平滑的。因此,我们不希望正则化项主导最终的损失。实际上,正则化项已从 SRGAN 论文的更新版本中删除,你也可以放心地将其去除。

这是感知loss函数的定义代码:

class GeneratorLoss(nn.Module):
    def __init__(self):
        super(GeneratorLoss, self).__init__()
        vgg = models.vgg16(pretrained=True)
        loss_network = nn.Sequential(*list(vgg.features)[:31]).eval()
        for param in loss_network.parameters():
            param.requires_grad = False
        self.loss_network = loss_network
        self.mse_loss = nn.MSELoss()
        self.l2_loss = L2Loss()

    def forward(self, out_labels, out_images, target_images):
        # adversarial Loss
        adversarial_loss = torch.mean(1 - out_labels)
        # vgg Loss
        vgg_loss = self.mse_loss(self.loss_network(out_images), 
          self.loss_network(target_images))
        # pixel-wise Loss
        pixel_loss = self.mse_loss(out_images, target_images)
        # regularization Loss
        reg_loss = self.l2_loss(out_images)
        return pixel_loss + 0.001 * adversarial_loss + 0.006 * vgg_loss 
         + 2e-8 * reg_loss

正则化项计算如下:

class L2Loss(nn.Module):
    def __init__(self, l2_loss_weight=1):
        super(L2Loss, self).__init__()
        self.l2_loss_weight = l2_loss_weight

    def forward(self, x):
        batch_size = x.size()[0]
        h_x = x.size()[2]
        w_x = x.size()[3]
        count_h = self.tensor_size(x[:, :, 1:, :])
        count_w = self.tensor_size(x[:, :, :, 1:])
        h_l2 = torch.pow((x[:, :, 1:, :] - x[:, :, :h_x - 1, :]), 2).sum()
        w_l2 = torch.pow((x[:, :, :, 1:] - x[:, :, :, :w_x - 1]), 2).sum()
        return self.l2_loss_weight * 2 * (h_l2 / count_h + w_l2 / count_w) 
         / batch_size

    @staticmethod
    def tensor_size(t):
        return t.size()[1] * t.size()[2] * t.size()[3]

现在,我们需要修改现有的train.py文件,以支持我们的新功能:

# from loss import GeneratorLoss
# from model import Generator, Discriminator
from srgan1 import GeneratorLoss, Discriminator, Generator

github.com/leftthomas/SRGAN提供的训练脚本在进行一些小修正后运行良好,修正方法是将每个.data[0]实例替换为.item()

训练 SRGAN 以生成高分辨率图像

当然,我们需要一些数据来进行操作。我们只需从README.md文件中的链接下载训练图像。你可以使用任何你喜欢的图像集合,因为 SRGAN 的训练只需要低分辨率图像(这些可以通过调整大小轻松获得)以及原始图像。

创建一个名为data的文件夹,并将训练图像放入名为DIV2K_train_HR的文件夹中,将验证图像放入DIV2K_valid_HR文件夹中。接下来,创建一个名为epochs的文件夹来保存周期数据。最后,创建一个名为training_results的文件夹。

要训练 SRGAN,在终端中执行以下命令:

$ pip install tqdm, pandas
$ python train.py

leftthomas提供的图像集合从 VOC2012 数据集采样,共包含 16,700 张图像。在 GTX 1080Ti 显卡上,批处理大小为 64 时,训练 100 个周期大约需要 6.6 小时。批处理大小为 88 时,GPU 内存使用量大约为 6433MB,批处理大小为 96 时,内存使用量为 7509MB。

然而,在训练 SRGAN 期间,GPU 使用率大多数时间都低于 10%(通过nvtop观察到),这表明数据的加载和预处理占用了过多时间。这个问题可以通过两种不同的解决方案来解决:

  • 将数据集放置在 SSD 上(最好通过 NVMe 接口)

  • 使用数据预取器在下一次迭代开始之前将数据预加载到 GPU 内存中

在这里,我们将讨论如何执行第二种解决方案。数据预取器的代码来自于 NVIDIA 的apex项目中的 ImageNet 示例:github.com/NVIDIA/apex/blob/master/examples/imagenet/main_amp.py。请按照以下步骤操作:

  1. 在源代码树中的某个位置定义数据预取器(例如,SRGAN 中的data_utils.py文件):
class data_prefetcher():
 def __init__(self, loader):
 self.loader = iter(loader)
 self.stream = torch.cuda.Stream()
 self.preload()

 def preload(self):
 try:
 self.next_input, self.next_target = next(self.loader)
 except StopIteration:
 self.next_input = None
 self.next_target = None
 return
 with torch.cuda.stream(self.stream):
 self.next_input = self.next_input.cuda(non_blocking=True)
 self.next_target = self.next_target.cuda(non_blocking=True)
 self.next_input = self.next_input.float()

 def next(self):
 torch.cuda.current_stream().wait_stream(self.stream)
 input = self.next_input
 target = self.next_target
 self.preload()
 return input, target
  1. 使用数据prefetcher在训练过程中加载样本:
for epoch in range(1, NUM_EPOCHS + 1):
    train_bar = tqdm(train_loader)
 prefetcher = data_prefetcher(train_bar)
 data, target = prefetcher.next()
    ...
    while data is not None:
        // train D
        ...
        // train G
        ...
        data, target = prefetcher.next()

这里的tqdm模块用于在训练过程中在终端打印进度条,并且可以当作其原始的可迭代对象。在 SRGAN 的训练中,数据prefetcher对 GPU 效率有着巨大的提升,如下所示:

使用预取器加载图像到 GPU 内存之前和之后的 GPU 使用情况

数据预取器可以调整为另一种数据形式,这些内容也包含在本章对应仓库的源代码中。

以下展示了一些超分辨率的结果。我们可以看到 SRGAN 在锐化低分辨率图像方面表现良好。但我们也能注意到,在处理大色块之间的锐利边缘时,它会有一定的局限性(例如,第一张图中的岩石和第三张图中的树木):

SRGAN 的超分辨率结果

生成图像修复

我们知道,如果训练得当,GAN 能够学习数据的潜在分布,并利用这些信息生成新的样本。GAN 的这一非凡能力使其非常适合应用于图像修复等任务,即用合理的像素填补图像中缺失的部分。

在本节中,我们将学习如何训练一个 GAN 模型来执行图像修复,基于 Jiahui Yu、Zhe Lin、Jimei Yang 等人在其论文《Generative Image Inpainting with Contextual Attention》中的工作。尽管他们的项目已有更新版本发布(jiahuiyu.com/deepfill2),但在撰写时,源代码尚未开源。因此,我们应该尝试根据其先前版本在 TensorFlow 上的源代码(github.com/JiahuiYu/generative_inpainting)在 PyTorch 中实现该模型。

在开始处理使用 GAN 进行图像修复之前,有几个基本概念需要理解,因为这些概念对理解该方法至关重要。

高效卷积 – 从 im2col 到 nn.Unfold

如果你曾经有足够的好奇心,尝试自己实现卷积神经网络(无论是使用 Python 还是 C/C++),你一定知道最痛苦的部分是梯度反向传播,而最耗时的部分是卷积操作(假设你实现的是像 LeNet 这样的简单 CNN)。

在代码中执行卷积的方法有很多种(除了直接使用深度学习工具,如 PyTorch):

  1. 按照定义直接计算卷积,这通常是最慢的方法。

  2. 使用快速傅里叶变换FFT),这对于卷积神经网络(CNN)来说并不理想,因为卷积核的大小通常相对于图像来说太小。

  3. 将卷积视为矩阵乘法(换句话说,一般矩阵乘法GeMM)使用im2col。这是许多软件和工具中最常用的方法,速度也快得多。

  4. 使用Winograd方法,这在某些情况下比 GeMM 更快。

在本节中,我们将仅讨论前三种方法。如果你想了解更多关于 Winograd 方法的内容,可以查看这个项目,github.com/andravin/wincnn,以及 Andrew Lavin 和 Scott Gray 的论文《卷积神经网络的快速算法》。在这里,我们将提供使用不同方法进行二维卷积的 Python 代码。

在继续之前,请确保通过在终端输入以下命令来安装必要的依赖项:

$ pip install numpy, scipy

现在,让我们按照这些步骤进行:

  1. 直接计算卷积。请注意,以下所有卷积实现的步幅大小为1,填充大小为0,这意味着输出大小为
import numpy as np

def conv2d_direct(x, w):
    w = np.flip(np.flip(w, 0), 1)
    rows = x.shape[0]
    cols = x.shape[1]
    kh = w.shape[0]
    kw = w.shape[1]
    rst = np.zeros((rows-kh+1, cols-kw+1))
    for i in range(rst.shape[0]):
        for j in range(rst.shape[1]):
            tmp = 0.
            for ki in range(kh):
                for kj in range(kw):
                    tmp += x[i+ki][j+kj] * w[ki][kj]
            rst[i][j] = tmp
    return rst

正如我们之前所说,按照定义直接计算卷积是极其慢的。以下是使用 5x5 卷积核对 512 x 512 图像进行卷积时的消耗时间:

x = np.random.randn(512, 512)
w = np.random.randn(5, 5)

from timeit import default_timer as timer
start = timer()
rst1 = conv2d_direct(x, w)
end = timer()
print('Elapsed time (direct): {}'.format(end - start))
# 3.868343267000455 seconds on an Intel Core i5-4590 CPU

我们还需要将结果与基准进行比较(例如,scipy.signal.convolve2d),以确保计算正确:

from scipy import signal

start = timer()
rst0 = signal.convolve2d(x, w, mode='valid')
end = timer()
print('Elapsed time (reference): {}'.format(end - start))
# 0.017827395000495017

error1 = np.max(np.abs(rst1 - rst0))
print('Error: {}'.format(error1))
# 1.0658141036401503e-14

现在我们知道我们的计算是正确的,问题在于如何更快地执行。

  1. 使用 FFT 计算卷积:

根据这个公式,我们可以通过执行两次傅里叶变换和一次逆傅里叶变换来获得卷积结果:

由于我们处理的是数字图像,我们需要执行离散傅里叶变换DFT),可以通过 NumPy 提供的快速傅里叶变换FFT)方法极其快速地计算:

def conv2d_fft(x, w):
    # return signal.fftconvolve(x, w, mode='valid')
    size = np.array(x.shape) + np.array(w.shape) - 1
    fsize = 2 ** np.ceil(np.log2(size)).astype(int)
    fslice = tuple([slice(kn-1, int(sz)-kn+1) for sz, kn in zip(size, w.shape)])
    x_fft = np.fft.fft2(x , fsize)
    w_fft = np.fft.fft2(w , fsize)
    rst = np.fft.ifft2(x_fft * w_fft)
    rst = rst[fslice].real
    return rst

这里是基于 FFT 的卷积的消耗时间和计算误差:

Elapsed time (FFT): 0.17074442000011913
Error: 1.0658141036401503e-14

我们可以看到,使用 FFT 进行卷积比直接方法要快得多,且几乎与scipy.signal.convolve2d消耗的时间相同。我们能否做得更快?

  1. 使用 im2col 计算卷积。

让我们停下来思考一下前两种方法。直接方法涉及 4 个for循环和大量随机访问矩阵元素。FFT 方法将卷积转化为矩阵乘法,但它需要进行 2 次 FFT 和 1 次逆 FFT。我们知道,低级计算工具如 BLAS 在矩阵乘法方面非常高效。那么,我们能否将原始卷积视为矩阵乘法呢?

以一个 3 x 3 的图像和 2 x 2 的卷积核为例(步长为 1,填充为 0):

图像与 2 x 2 卷积核之间的卷积

我们可以将输入图像拉伸成一个非常长的向量(1 x 9),并将卷积核转换成一个非常大的矩阵(9 x 4),这样我们的输出将具有预期的 1 x 4 大小。当然,我们还需要根据卷积中的计算过程安排大矩阵中的元素(例如,),如下所示:

通过稀疏矩阵乘法进行卷积

这样,我们需要计算一个非常长的向量和一个大型稀疏矩阵之间的矩阵乘法(其中很多元素为零)。直接进行矩阵乘法可能非常低效(无论是从时间还是内存的角度)。虽然我们可以通过一些数值算法加速稀疏矩阵乘法,但我们不会深入探讨这种方法,因为有一种更高效的方式将卷积转换为矩阵乘法。

将稀疏矩阵乘法与具有相同输入和输出维度(且权重矩阵大小相同)的全连接层(nn.Linear)进行比较,我们可以看到,卷积所需的参数远少于全连接层(因为权重矩阵中有许多零,并且很多元素是可重用的)。这使得 CNN 比 MLP 更容易训练且对过拟合更具鲁棒性,这也是近年来 CNN 更受欢迎的原因之一。

考虑到卷积核的大小通常远小于图像,我们将尝试将卷积核拉伸成一个向量,并重新排列输入图像中的元素以匹配卷积核向量的维度,如下所示:

通过 im2col 进行卷积

现在,我们可以看到,我们只需要进行具有较小维度的密集矩阵乘法。我们对输入图像进行的转换叫做im2col。im2col 的结果很容易理解:一行中的元素表示在给定位置执行卷积所需的输入图像元素(这被称为滑动窗口),而第 ^(th) 行对应于第 ^(th) 输出元素()。

这是 im2col 的 Python 实现:

def im2col(x, stride=1):
    # https://stackoverflow.com/a/30110497/3829845
    rows = x.shape[0]
    cols = x.shape[1]
    kh = w.shape[0]
    kw = w.shape[1]
    s0, s1 = x.strides
    nrows = rows-kh+1
    ncols = cols-kw+1
    shape = kh, kw, nrows, ncols
    slides = s0, s1, s0, s1
    L = kh*kw

    x_unfold = np.lib.stride_tricks.as_strided(x, shape=shape, strides=slides)
    return x_unfold.reshape(L, -1)[:,::stride]

def conv2d_gemm(x, w, stride=1):
    w = np.flip(np.flip(w, 0), 1)
    rows = x.shape[0]
    cols = x.shape[1]
    kh = w.shape[0]
    kw = w.shape[1]
    L = kh*kw

    x_unfold = im2col(x)
    y_unfold = np.matmul(x_unfold.transpose(), w.reshape((L, 1)))
    return y_unfold.reshape(rows-kh+1, cols-kw+1)

这里是经过的时间和计算误差:

Elapsed time (im2col): 0.014781345998926554
Error: 1.0658141036401503e-14

将卷积视为矩阵乘法在所有三种方法中获得了最快的计算速度。与直接方法相比,它在计算时间上实现了超过 260 倍的加速。im2col 的另一个优势是它与 CNN 完全兼容。在 CNN 中,卷积通常是在通道内执行的,这意味着我们需要计算一组独立卷积的和。例如,假设我们的输入特征图大小为 ,权重张量是 。对于每个 通道中的神经元,它是 乘以图像 和卷积核 之间的卷积操作的和。使用 im2col,给定位置的滑动窗口的卷积结果由两个向量的乘法表示(因为卷积本身是元素逐项相乘的和)。我们可以通过将所有 通道中的滑动窗口的所有元素填充到一个长向量中,从而应用这种模式,使得可以通过一次向量乘法获得一个 通道中的输出像素值。如果你想了解更多关于如何在 Python 中执行通道-wise 卷积的信息,可以查看这个 Stack Overflow 帖子:stackoverflow.com/q/30109068/3829845

将 4D 张量卷积转化为 3D 张量乘法是 nn.Unfold 派上用场的地方。以下是一个代码片段,展示了如何使用 PyTorch 显式地将卷积转换为矩阵乘法(基于官方文档:pytorch.org/docs/stable/nn.html?highlight=unfold#torch.nn.Unfold):

import torch

inp = torch.randn(1, 1, 512, 512)
w = torch.randn(1, 1, 5, 5)
start = timer()
inp_unf = torch.nn.functional.unfold(inp, (5, 5))
out_unf = inp_unf.transpose(1, 2).matmul(w.view(w.size(0), -1).t()).transpose(1, 2)
out = out_unf.view(1, 1, 508, 508)
# Or using
# out = torch.nn.functional.fold(out_unf, (508, 508), (1, 1))
end = timer()
print('Elapsed time (nn.Unfold): {}'.format(end - start))
error4 = (torch.nn.functional.conv2d(inp, w) - out).abs().max()
print('Error: {}'.format(error4))

输出消息如下:

Elapsed time (nn.Unfold): 0.021252065999760816
Error: 6.67572021484375e-06

很高兴看到我们的 Python im2col 实现甚至比 PyTorch 更快。我们希望这能鼓励你构建自己的深度学习工具箱!

WGAN – 理解 Wasserstein 距离

GAN 一直以来被认为难以训练,特别是如果你曾尝试从零开始构建一个 GAN。(当然,我们希望,在阅读本书后,训练 GAN 对你来说会变得更容易!)在过去的章节中,我们已经学习了许多不同的模型设计和训练技巧,这些技巧源自许多优秀研究者的经验。在这一部分,我们将讨论如何使用更好的距离度量来改进 GAN 的训练,即 Wasserstein GAN。

Wasserstein GANWGAN)由 Martin Arjovsky、Soumith Chintala 和 Léon Bottou 在他们的论文《Wasserstein GAN》中提出。Martin Arjovsky 和 Léon Bottou 也在早期的论文《Towards Principled Methods for Training Generative Adversarial Networks》中奠定了基础。为了充分理解这些论文,你需要具备概率论、测度论和泛函分析的基础数学知识。我们将尽力简化数学公式,帮助你理解 WGAN 的概念。

分析原始 GAN 损失函数的问题

让我们回顾一下 GAN 中常用的损失函数(这些已经出现在前几章中):

  • ,这是 GAN 损失的原始形式

前几章的实验结果已经表明这些损失函数在多个应用中表现良好。然而,让我们深入研究这些函数,看看当它们效果不好时可能出什么问题:

第 1 步: 第一个损失函数的问题:

假设生成器网络已经训练完成,我们需要找到一个最优的判别器网络 D。我们有以下公式:

在这个公式中, 代表真实数据的分布, 代表伪造(生成)数据的分布。 是计算 时的真实数据,计算 时的伪造数据。

我们承认这里的 表示法有点混乱。然而,如果我们考虑所有种类的数据存在于同一个数据空间中(例如,所有可能的 256 x 256 的三通道 8 位图像),并且其中一部分空间属于真实数据,另一部分属于生成数据。GAN 的训练本质上是让伪造部分与真实部分重叠,希望最终能与真实部分相同。

为了找到公式的最小值,我们令关于D的导数为零,得到以下结果:

因此,当 D 最优时,第一个损失函数变为如下形式:

这里,Jensen–Shannon 散度JS 散度),它是Kullback–Leibler 散度KL 散度)的对称版本:

Kullback-Leibler 散度通常用于描述两个分布之间的距离。它等于 交叉熵  与  之差减去 ,这也是为什么 KL 散度有时被称为 相对熵。请记住,KL 散度是非对称的,因为  与  会得到 ,但  与  会得到 。因此,KL 散度严格来说不是一个距离度量。然而,Jensen-Shannon 散度是对称的,可以作为一个距离度量使用。

如果你使用过 TensorBoard 来可视化神经网络学习到的嵌入空间,你可能会发现一种叫做 t-SNE 的有用技术,它可以非常清晰地在二维或三维图中展示高维数据(比 PCA 更清晰)。在 t-SNE 中,KL 散度的修正版被用来将高维数据映射到低维。你可以查看这个博客来了解更多关于 t-SNE 的信息:distill.pub/2016/misread-tsne。此外,这个 Google Techtalk 视频对于理解 KL 散度和 t-SNE 非常有帮助:www.youtube.com/watch?v=RJVL80Gg3lA

JS 散度的一个问题是,当  与  相距很远时(几乎没有或只有很少的重叠部分),其值保持为 ,无论  与  之间相距多远。在训练的开始阶段,假设  与  之间的距离极大是合理的(因为生成器是随机初始化的,且  可能在数据空间的任何位置)。当判别器最优时,几乎常数的损失值对导数提供的信息非常有限。因此,在 GAN 中使用第一种形式的损失时,一个训练良好的判别器将会阻止生成器自我改善(梯度消失)。

GAN 中的梯度消失问题有时可以通过在训练过程中向判别器的输入添加退火噪声来解决。但我们稍后会讨论一种更为有原则的方法。

步骤 2: 其他两个损失函数的问题:

以第三种损失为例,它可以写成如下形式:

在这个公式中,最后两个项与生成器无关。然而,前两个项的目标完全相反(最小化 KL 散度的同时最大化 JS 散度)。这导致训练非常不稳定。另一方面,使用 KL 散度可能会导致模式崩溃。未能生成真实的样本会受到严重惩罚()但仅生成少数几种真实样本则不会受到惩罚()。这使得生成器更容易生成样本的多样性较少。

Wasserstein 距离的优点

Wasserstein 距离(也叫地球搬运距离EMD)定义如下:

如果你发现前面的公式难以理解,不用担心。它本质上描述了从所有可能的联合分布中采样的两个变量之间的最小距离。用通俗的话说,它是将一堆土(以某种分布的形式)移动到形成另一堆土(另一种分布)所需的最小成本,如下图所示:

Wasserstein 距离:两个堆之间的最优运输(图片来自 vincentherrmann.github.io/blog/wasserstein

与 JS 散度相比,Wasserstein 距离能够正确描述真实数据与假数据之间的距离,即使它们相隔较远。因此,当判别器表现良好时,可以正确计算导数来更新生成器网络。

为了找到最合适的函数,f,我们可以简单地训练一个神经网络来估计它(幸运的是,我们已经在训练一个判别网络)。为了确保方程的第二行成立,一个重要的条件是所有函数 f 都是Lipschitz 连续的:

在神经网络中,Lipschitz 连续性很容易实现,通过将任何大于K的梯度值裁剪为K梯度裁剪),或者简单地将权重值裁剪为常数值(权重裁剪)。

记得在第一章中编写的简单 GAN 吗?我们应用了梯度裁剪和权重裁剪来确保训练的稳定性。如果有人问你为什么要裁剪(钳制)GAN 中的张量,你现在可以给出比梯度爆炸更好的答案。

最后,Wasserstein 损失写作如下:

然而,在训练非常深的神经网络时,也会出现一些梯度剪切的问题。首先,如果梯度/权重被频繁限制在[-c, c]之间,它们会在训练结束时倾向于固定在-c 或 c,而只有少数参数的值在这两个端点之间。其次,限制梯度到更大或更小的范围可能会导致“看不见”的梯度消失或爆炸。我们称之为“看不见”,因为即使梯度值非常大,它们最终仍会被限制在[-c, c]之间。但这将是完全浪费计算资源。因此,Ishaan Gulrajani、Faruk Ahmed、Martin Arjovsky 等人在他们的论文Improved Training of Wasserstein GANs中提出,向判别器损失中添加惩罚项——梯度惩罚

惩罚梯度是根据真实数据和假数据之间的一对随机插值来计算的。

简而言之,要使用 Wasserstein 损失,你需要做以下几步:

  • 去掉判别器网络最后一层的Sigmoid函数。

  • 计算损失时不要对结果应用log函数。

  • 使用梯度惩罚(或者直接剪切浅层神经网络中的权重)。

  • 使用 RMSprop 代替 Momentum 或 Adam 来训练你的网络。

训练图像修复的 GAN

现在,终于到了训练一个新的图像修复 GAN 模型的时刻。你可以从github.com/DAA233/generative-inpainting-pytorch获得原始 PyTorch 实现的代码。修改原始代码以实现你自己的模型将是一项挑战。由于你已经有了CelebA数据集,可以将其作为本节实验的训练数据集。

图像修复的模型设计

图像修复的 GAN 模型由两个生成器网络(粗生成器和精细生成器)和两个判别器网络(局部判别器和全局判别器)组成,如下所示:

图像修复的 GAN 模型:图像 x 表示输入图像;x[1]和 x[2]分别表示粗生成器和精细生成器生成的图像;x[r]表示原始完整图像;m 表示图像中缺失部分的掩膜。

生成器模型采用了两阶段的粗到细架构。粗生成器是一个 17 层的编码器-解码器 CNN,在中间使用扩张卷积来扩展感受野。假设输入图像(x)的大小为 3 x 256 x 256,那么粗生成器的输出(x[1])也是 3 x 256 x 256。

精细化生成器有两个分支。一个是 10 层 CNN,另一个被称为上下文注意力分支,负责在图像的另一部分找到适当的参考位置,以生成正确的像素来填充孔洞。初始输入图像x、粗略输出x[1]以及标记x中缺失像素的二进制掩模一起输入到精细化生成器,并通过 6 个卷积层映射到[128,64,64]的张量,然后进入上下文注意力分支。

这里展示了上下文注意力分支中的计算过程:

上下文注意力的计算:图像 b 为背景,f 为前景,m 为掩模。

由于内容长度的限制,我们不会深入讨论上下文注意力。重要步骤如前面图示所示。由于我们需要找到前景(待填充的像素)和背景(掩模孔外的剩余像素)之间最相关的部分,因此计算前景图像和背景图像中每一对图像块之间的逐像素相似度。逐一计算所有可能的对比显然效率低下。因此,使用nn.Unfold来创建前景和背景图像的滑动窗口版本(窗口大小为 3x3)(x[i]w[i])。为了减少 GPU 内存消耗,图像被调整为[128,32,32]的大小。因此,两幅图像中有3232=1,024个滑动窗口,x[i]w[i]*之间的卷积将告诉我们每对图像块中的像素相似度。具有最高相似度的位置信息对表明在重建前景块时注意力集中在哪个位置。

为了确保对轻微注意力偏移的鲁棒性,每个像素的注意力值沿水平和垂直轴进行了平均,这也是为什么y[i]与单位矩阵进行两次卷积的原因。注意力得分通过缩放的 softmax 函数计算:

最后,使用原始背景的展开形式作为卷积核,对y[i]执行转置卷积,以重建缺失的像素。

CNN 分支和 CA 分支的两个输出大小均为[128,64,64],这些输出被连接成一个[256,64,64]的宽张量。然后使用另外 7 个卷积层将重建的特征图逐渐映射到[3,256,256]的图像。

粗略和精细化生成器输出图像中的像素值被限制在[-1,1]范围内,以适应判别网络。

使用两个结构相似的判别器网络(局部判别器和全局判别器)来评估生成图像的质量。它们都有 4 层卷积层和 1 层全连接层。唯一的区别是局部判别器用于评估裁剪图像块(换句话说,就是原始图像中缺失的像素,大小为 3 x 128 x 128),而全局判别器用于评估整个图像(3 x 256 x 256)。

Wasserstein 损失的实现

在这里,我们让  和  (局部判别器的输出)分别表示裁剪图像  和 的保真度置信度。我们让  和 (全局判别器的输出)分别表示整个图像  和 的保真度置信度。然后,判别器的 Wasserstein 损失定义如下:

判别器的梯度惩罚项定义如下:

生成器的 Wasserstein 损失定义如下:

缺失像素的 L1 重建损失定义如下:

剩余像素的 L1 重建损失定义如下(显然,我们不希望改变这些像素):

最后,判别器损失如下:

生成器的损失定义如下:

使用批量大小为 24 的情况下,图像修复 GAN 的训练消耗约 10,097MB 的 GPU 内存,并且在生成一些不错的结果之前,需要大约 64 小时的训练(180k 次迭代)。以下是一些修复结果。

GAN 生成的图像修复结果

现在,我们已经学习了大部分需要用 GAN 生成图像的知识。

总结

本章我们获得了大量的实践和理论知识,包括学习图像去模糊和图像分辨率增强,从 FFA 算法到实现 Wasserstein 损失函数。

在下一章,我们将训练我们的 GAN 来突破其他模型。

有用的阅读列表和参考文献

第九章:训练你的 GAN 模型以突破不同的模型

人们逐渐倾向于使用深度学习方法来解决计算机视觉领域的问题。你有没有见过你的同学或同事向你炫耀他们最新的图像分类器?现在,借助 GANs,你可能真的有机会通过生成对抗性样本来打破他们之前的模型,向他们展示你能做些什么。

我们将深入探讨对抗性样本的基本原理,并学习如何使用FGSM快速梯度符号方法)攻击并迷惑 CNN 模型。我们还将学习如何通过迁移学习,在 Kaggle 的猫狗数据集上训练一个集成分类器,之后,我们将学习如何使用 accimage 库进一步加速图像加载,并训练一个 GAN 模型生成对抗性样本,从而愚弄图像分类器。

本章将涵盖以下主题:

  • 对抗性样本 - 攻击深度学习模型

  • 生成对抗性样本

对抗性样本 - 攻击深度学习模型

众所周知,使用具有大量参数的深度学习方法(有时超过千万个参数)时,人们往往难以理解它们到底学到了什么,除了它们在计算机视觉和自然语言处理领域表现异常出色这一事实。如果你身边的人感觉非常轻松地使用深度学习来解决每一个实际问题,并且毫不犹豫,那么我们即将在本章学习的内容将帮助他们意识到他们的模型可能面临的潜在风险。

什么是对抗性样本,它是如何创建的?

对抗性样本是一种样本(通常是基于真实数据修改的),它们很容易被机器学习系统错误分类(有时看起来对人眼是正常的)。对图像数据的修改可以是少量的噪声(openai.com/blog/adversarial-example-research)或一个小的图像补丁(Tom B. Brown 等人,2017 年)。有时,将它们打印在纸上并拍照,甚至能愚弄神经网络。从几乎所有角度来看,甚至可以通过 3D 打印一个物体来愚弄神经网络(Anish Athalye 等人,2018 年)。尽管你可以创建一些看似毫无意义的随机样本,仍然能导致神经网络出错,但更有趣的是研究那些看起来对人类来说是正常的,但却被神经网络误分类的对抗性样本。

请放心,我们并没有偏离主题,讨论对抗样本。在此之前,Ian Goodfellow(被称为 GAN 之父)曾花了相当多的时间研究对抗样本。对抗样本和 GAN 可能是兄弟关系!开个玩笑,GAN 擅长生成逼真且具有说服力的样本,也能生成误导其他分类器的样本。在本章中,我们首先将讲解如何构造对抗样本,并用它来攻击一个小型模型。接着,我们将展示如何使用 GAN 生成对抗样本以攻击大型模型。

使用 PyTorch 进行对抗攻击

有一个非常优秀的对抗攻击、对抗防御和基准测试工具箱,叫做 CleverHans,适用于 TensorFlow,地址是github.com/tensorflow/cleverhans。目前,开发者正在计划支持 PyTorch,详情请见github.com/tensorflow/cleverhans/blob/master/tutorials/future/torch/cifar10_tutorial.py。在本节中,我们需要在 PyTorch 中实现一个对抗样本。

以下代码片段基于 PyTorch 的官方教程:pytorch.org/tutorials/beginner/fgsm_tutorial.html。我们将稍作修改模型,并且对抗样本的创建将以批次形式进行。以一个名为advAttackGAN.py的空文件开始:

  1. 导入模块:
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
import matplotlib.pyplot as plt

from torchvision import datasets, transforms

print("PyTorch version: {}".format(torch.__version__))
print("CUDA version: {}\n".format(torch.version.cuda))
  1. 定义设备和扰动因子:
use_cuda = True
device = torch.device("cuda:0" if use_cuda and torch.cuda.is_available() else "cpu")

epsilons = [.05, .1, .15, .2, .25, .3]
  1. 定义 CNN 模型,这个模型被称为 LeNet-5 模型:
class Net(nn.Module):
    def __init__(self):
        super(Net, self).__init__()
        self.conv1 = nn.Conv2d(1, 20, kernel_size=5)
        self.conv2 = nn.Conv2d(20, 50, kernel_size=5)
        self.fc1 = nn.Linear(800, 500)
        self.fc2 = nn.Linear(500, 10)

    def forward(self, x):
        x = F.relu(F.max_pool2d(self.conv1(x), 2))
        x = F.relu(F.max_pool2d(self.conv2(x), 2))
        x = F.relu(self.fc1(x.view(-1, 800)))
        x = self.fc2(x)
        return x
  1. 定义训练和测试的数据加载器。在这里,我们将使用 MNIST 数据集:
batch_size = 64
train_data = datasets.MNIST('/home/john/Data/mnist', train=True, download=True,
                            transform=transforms.Compose([
                                transforms.ToTensor(),
                                # transforms.Normalize((0.1307,), (0.3081,)),
                                ]))
train_loader = torch.utils.data.DataLoader(train_data, batch_size=batch_size,
                                           shuffle=True, pin_memory=True)

test_data = datasets.MNIST('/home/john/Data/mnist', train=False, download=True,
                           transform=transforms.Compose([
                                transforms.ToTensor(),
                                # transforms.Normalize((0.1307,), (0.3081,)),
                                ]))
test_loader = torch.utils.data.DataLoader(test_data, batch_size=1000,
                                          shuffle=False, pin_memory=True)

注意,为了让定义的扰动因子能够适用于我们的模型,我们没有对数据进行标准化(去均值并除以标准差)。

  1. 创建modeloptimizerloss函数:
model = Net().to(device)
optimizer = optim.Adam(model.parameters(), lr=0.001, betas=(0.9, 0.999), weight_decay=3e-5)
criterion = nn.CrossEntropyLoss()
  1. 定义traintest函数:
def train(model, device, train_loader, optimizer):
    model.train()
    for batch_idx, (data, target) in enumerate(train_loader):
        data, target = data.to(device), target.to(device)
        optimizer.zero_grad()
        output = model(data)
        loss = criterion(output, target)
        loss.backward()
        optimizer.step()
        if batch_idx % 250 == 0:
            print('[{}/{}]\tLoss: {:.6f}'.format(
                batch_idx * batch_size, len(train_data), loss.item()))

def test(model, device, test_loader):
    model.eval()
    test_loss = 0
    correct = 0
    with torch.no_grad():
        for data, target in test_loader:
            data, target = data.to(device), target.to(device)
            output = model(data)
            test_loss += criterion(output, target).item()
            pred = output.max(1, keepdim=True)[1]
            correct += pred.eq(target.view_as(pred)).sum().item()
    test_loss /= len(test_loader)
    print('\nTest loss: {:.4f}, accuracy: {:.4f}%\n'.format(
        test_loss, 100\. * correct / len(test_data)))
  1. 让我们训练这个模型,看看这个小型模型能做什么:
model.train()
for epoch in range(5):
 print('Train Epoch: {}'.format(epoch))
 train(model, device, train_loader, optimizer)
 test(model, device, test_loader)

输出信息可能如下所示:

PyTorch version: 1.3.1
CUDA version: 10.0.130

Train Epoch: 0
[0/60000] Loss: 2.307504
[16000/60000] Loss: 0.148560
...
Test loss: 0.0229, accuracy: 99.3100%

我们可以看到,经过仅仅 5 个训练周期后,我们的小型 CNN 模型在测试集上的准确率达到了 99.31%。

  1. 现在,实施 FGSM 以创建一个来自读取样本及其导数的对抗样本:
def fgsm_attack(image, epsilon, data_grad):
    sign_data_grad = data_grad.sign()
    perturbed_image = image + epsilon*sign_data_grad
    perturbed_image = torch.clamp(perturbed_image, 0, 1)
    return perturbed_image
  1. 使用fgsm_attack对测试图像进行扰动,并观察会发生什么:
def adv_test(model, device, test_loader, epsilon):
    model.eval()
    correct = 0
    adv_examples = []
    #* grads of params are needed
    for data, target in test_loader:
        data, target = data.to(device), target.to(device)

        # Set requires_grad attribute of tensor. Important for Attack
        data.requires_grad = True
        output = model(data)
        init_pred = output.max(1, keepdim=True)[1]
        init_pred = init_pred.view_as(target)
        loss = criterion(output, target)
        model.zero_grad()
        loss.backward()

        perturbed_data = fgsm_attack(data, epsilon, data.grad.data)
        output = model(perturbed_data)
        final_pred = output.max(1, keepdim=True)[1]
        # final_pred has shape [1000, 1], target has shape [1000]. Must reshape final_pred
        final_pred = final_pred.view_as(target)
        correct += final_pred.eq(target).sum().item()
        if len(adv_examples) < 5 and not (final_pred == target).all():
            indices = torch.arange(5)
            for i in range(indices.shape[0]):
                adv_ex = perturbed_data[indices[i]].squeeze().detach().cpu().numpy()
                adv_examples.append((init_pred[indices[i]].item(), final_pred[indices[i]].item(), adv_ex))
                if (len(adv_examples) >= 5):
                    break
    final_acc = 100\. * correct / len(test_data)
    print("Epsilon: {}\tTest Accuracy = {}/{} = {:.4f}".format(
        epsilon, correct, len(test_data), final_acc))
    return final_acc, adv_examples

accuracies = []
examples = []

# Run test for each epsilon
for eps in epsilons:
    acc, ex = adv_test(model, device, test_loader, eps)
    accuracies.append(acc)
    examples.append(ex)

在这里,我们将前五个测试图像保存到adv_examples中,以展示扰动前后预测标签。你可以随时将indices = torch.arange(5)这一行替换成以下代码,以仅显示那些导致模型失败的对抗样本:

indices = torch.ne(final_pred.ne(target), init_pred.ne(target)).nonzero()

终端中的输出信息可能如下所示:

Epsilon: 0.05 Test Accuracy = 9603/10000 = 96.0300
Epsilon: 0.1 Test Accuracy = 8646/10000 = 86.4600
Epsilon: 0.15 Test Accuracy = 6744/10000 = 67.4400
Epsilon: 0.2 Test Accuracy = 4573/10000 = 45.7300
Epsilon: 0.25 Test Accuracy = 2899/10000 = 28.9900
Epsilon: 0.3 Test Accuracy = 1670/10000 = 16.7000

我们可以看到,随着epsilon的增大,模型误分类的样本数量增加。在最糟糕的情况下,模型的测试准确率降至 16.7%。

  1. 最后,使用matplotlib展示扰动后的图像:
cnt = 0
plt.figure(figsize=(8,10))
for i in range(len(epsilons)):
    for j in range(len(examples[i])):
        cnt += 1
        plt.subplot(len(epsilons),len(examples[0]),cnt)
        plt.xticks([], [])
        plt.yticks([], [])
        if j == 0:
            plt.ylabel("Eps: {}".format(epsilons[i]), fontsize=14)
        orig,adv,ex = examples[i][j]
        plt.title("{} -> {}".format(orig, adv))
        plt.imshow(ex, cmap="gray")
plt.tight_layout()
plt.show()

下面是前五个测试图像及其在不同因子值扰动前后的预测标签:

从 MNIST 创建的对抗样本

生成对抗样本

在前几章中,我们一直使用 GAN 生成各种类型的图像。现在,是时候尝试用 GAN 生成对抗样本并破解一些模型了!

为 Kaggle 的猫狗数据集准备一个集成分类器

为了使我们的演示更接近实际场景,我们将在 Kaggle 的猫狗数据集(www.kaggle.com/c/dogs-vs-cats)上训练一个合理的模型,然后用 GAN 生成的对抗样本来破解模型。该数据集包含 25,000 张训练图像和 12,500 张测试图像,图像内容为狗或猫。在本实验中,我们只使用 25,000 张训练图像。

为了方便,在下载数据集后,将猫和狗的图像分别放入不同的文件夹中,使文件结构如下所示:

/cats-dogs-kaggle
    /cat
        /cat.0.jpg
        /cat.1.jpg
        ...
    /dog
        /dog.0.jpg
        /dog.1.jpg
        ...

我们在这个数据集上训练的模型由几个由 PyTorch Hub 提供的预训练模型组成(github.com/pytorch/hub)。我们还需要对这些预训练模型进行迁移训练,以适应我们的数据集:

Kaggle 猫狗数据集的集成模型

现在,我们需要加载和预处理数据,创建一个集成分类器,并训练这个模型。下面是详细步骤:

  1. 创建一个名为cats_dogs.py的 Python 文件,并导入相关 Python 模块:
import argparse
import os
import random
import sys

import matplotlib.pyplot as plt
import numpy as np
import torch
import torch.nn as nn
import torch.backends.cudnn as cudnn
import torch.utils.data
import torchvision
import torchvision.datasets as dset
import torchvision.utils as vutils

import utils
from advGAN import AdvGAN_Attack
from data_utils import data_prefetcher, _transforms_catsdogs
from model_ensemble import transfer_init, ModelEnsemble

这里,advGANdata_utilsmodel_ensemble等自定义模块文件稍后将会创建。

  1. cats_dogs.py中定义主入口点,在其中解析参数值并定义图像解码后端。这里仅显示部分代码行,由于篇幅原因,完整的源代码可以在本章节的代码库中的cats_dogs文件夹中找到:
if __name__ == '__main__':
    from utils import boolean_string
    legal_models = ['resnet18', 'resnet34', 'mobilenet_v2', 'shufflenet_v2_x1_0',
                    'squeezenet1_1', 'densenet121', 'googlenet', 'resnext50_32x4d',
                    'vgg11']
    parser = argparse.ArgumentParser(description='Hands-On GANs - Chapter 8')
    parser.add_argument('--model', type=str, default='resnet18',
                        help='one of {}'.format(legal_models))
    parser.add_argument('--cuda', type=boolean_string,
                        default=True, help='enable CUDA.')
    parser.add_argument('--train_single', type=boolean_string,
                        default=True, help='train single model.')
    parser.add_argument('--train_ensemble', type=boolean_string,
                        default=True, help='train final model.')
    parser.add_argument('--data_split', type=float, default=0.8,
                        help='split ratio for train and val data')
    parser.add_argument('--data_dir', type=str,
                        default='./cats_dogs_kaggle', help='Directory for dataset.')
    parser.add_argument('--out_dir', type=str,
                        default='./output', help='Directory for output.')
    parser.add_argument('--epochs', type=int, default=60,
                        help='number of epochs')
    parser.add_argument('--batch_size', type=int,
                        default=128, help='size of batches')
    parser.add_argument('--lr', type=float, default=0.01, help='learning rate')
    parser.add_argument('--classes', type=int, default=2,
                        help='number of classes')
    parser.add_argument('--img_size', type=int,
                        default=224, help='size of images')
    parser.add_argument('--channels', type=int, default=3,
                        help='number of image channels')
    parser.add_argument('--log_interval', type=int, default=100,
                        help='interval between logging and image sampling')
    parser.add_argument('--seed', type=int, default=1, help='random seed')

    FLAGS = parser.parse_args()
    FLAGS.cuda = FLAGS.cuda and torch.cuda.is_available()

    if FLAGS.seed is not None:
        torch.manual_seed(FLAGS.seed)
        if FLAGS.cuda:
            torch.cuda.manual_seed(FLAGS.seed)
        np.random.seed(FLAGS.seed)

    cudnn.benchmark = True

    # if FLAGS.train:
    if FLAGS.train_single or FLAGS.train_ensemble:
        utils.clear_folder(FLAGS.out_dir)

    log_file = os.path.join(FLAGS.out_dir, 'log.txt')
    print("Logging to {}\n".format(log_file))
    sys.stdout = utils.StdOut(log_file)

    print("PyTorch version: {}".format(torch.__version__))
    print("CUDA version: {}\n".format(torch.version.cuda))

    print(" " * 9 + "Args" + " " * 9 + "| " + "Type" +
          " | " + "Value")
    print("-" * 50)
    for arg in vars(FLAGS):
        arg_str = str(arg)
        var_str = str(getattr(FLAGS, arg))
        type_str = str(type(getattr(FLAGS, arg)).__name__)
        print(" " + arg_str + " " * (20-len(arg_str)) + "|" +
              " " + type_str + " " * (10-len(type_str)) + "|" +
              " " + var_str)

    ...

    try:
        import accimage
        torchvision.set_image_backend('accimage')
        print('Image loader backend: accimage')
    except:
        print('Image loader backend: PIL')

    ...

    main()

在这里,我们使用accimage作为torchvision的图像解码后端。Accimage (github.com/pytorch/accimage)是一个为torchvision设计的图像解码和预处理库,它使用英特尔 IPP (software.intel.com/en-us/intel-ipp)来提高处理速度。

  1. 在主入口点上方,定义main函数,在其中我们首先加载并将训练图像拆分为训练集和验证集:
FLAGS = None

def main():
    device = torch.device("cuda:0" if FLAGS.cuda else "cpu")

    print('Loading data...\n')
    train_transform, _ = _transforms_catsdogs(FLAGS)
    train_data = dset.ImageFolder(root=FLAGS.data_dir, transform=train_transform)
    assert train_data

    num_train = len(train_data)
    indices = list(range(num_train))
    random.shuffle(indices)
    split = int(np.floor(FLAGS.data_split * num_train))

    train_loader = torch.utils.data.DataLoader(
        train_data, batch_size=FLAGS.batch_size,
        sampler=torch.utils.data.sampler.SubsetRandomSampler(indices[:split]),
        num_workers=2)

    valid_loader = torch.utils.data.DataLoader(
        train_data, batch_size=FLAGS.batch_size,
        sampler=torch.utils.data.sampler.SubsetRandomSampler(indices[split:num_train]),
        num_workers=2)

我们将 25,000 张训练图像拆分成两个集合,其中 80%的图像随机选择形成训练集,其余 20%形成验证集。在这里,_transforms_catsdogsdata_utils.py中定义:

import numpy as np
import torch
import torchvision.transforms as transforms

def _transforms_catsdogs(args):
    train_transform = transforms.Compose([
        transforms.Resize((args.img_size, args.img_size)),
        transforms.RandomHorizontalFlip(),
        transforms.ToTensor(),
    ])

    valid_transform = transforms.Compose([
        transforms.ToTensor()
        ])
    return train_transform, valid_transform

同样,我们没有对图像进行白化处理。然而,如果你有兴趣了解如何高效地计算数据集的均值和标准差,可以查看mean_std.py文件中的代码片段。

熟练使用 multiprocessing.Pool 来处理数据,这在 mean_std.py 中有演示。

  1. 从 PyTorch Hub 获取预训练模型文件并开始迁移学习:
    if FLAGS.train_single:
        print('Transfer training model {}...\n'.format(FLAGS.model))
        model = torch.hub.load('pytorch/vision', FLAGS.model, pretrained=True)
        for param in model.parameters():
            param.requires_grad = False

        model, param_to_train = transfer_init(model, FLAGS.model, FLAGS.classes)
        model.to(device)

        optimizer = torch.optim.SGD(
            param_to_train, FLAGS.lr,
            momentum=0.9, weight_decay=5e-4)
        scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=5, gamma=0.1)

        criterion = nn.CrossEntropyLoss()

        # Train
        best_acc = 0.0
        for epoch in range(25):
            model.train()
            scheduler.step()
            print('Epoch {}, lr: {}'.format(epoch, scheduler.get_lr()[0]))
            prefetcher = data_prefetcher(train_loader)
            data, target = prefetcher.next()
            batch_idx = 0
            while data is not None:
                optimizer.zero_grad()
                output = model(data)
                pred = output.max(1, keepdim=True)[1]
                loss = criterion(output, target)
                loss.backward()
                optimizer.step()
                correct = pred.eq(target.view_as(pred)).sum().item()
                if batch_idx % FLAGS.log_interval == 0:
                    print('[{}/{}]\tloss: {:.4f}\tbatch accuracy: {:.4f}%'.format(
                        batch_idx * FLAGS.batch_size, num_train,
                        loss.item(), 100 * correct / data.size(0)))
                data, target = prefetcher.next()
                batch_idx += 1
            # Eval
            ...

由于代码过长,评估代码已省略。这里,transfer_initmodel_ensemble.py 中定义,负责替换每个模型倒数第二层,以便我们可以根据需要训练任意数量的类别:

import os

import torch
import torch.nn as nn

def transfer_init(model, model_name, num_class):
    param_to_train = None
    if model_name in ['resnet18', 'resnet34', 'shufflenet_v2_x1_0', 'googlenet', 'resnext50_32x4d']:
        num_features = model.fc.in_features
        model.fc = nn.Linear(num_features, num_class)
        param_to_train = model.fc.parameters()
    elif model_name in ['mobilenet_v2']:
        num_features = model.classifier[1].in_features
        model.classifier[1] = nn.Linear(num_features, num_class)
        param_to_train = model.classifier[1].parameters()
    elif model_name in ['squeezenet1_1']:
        num_features = model.classifier[1].in_channels
        model.classifier[1] = nn.Conv2d(num_features, num_class, kernel_size=1)
        param_to_train = model.classifier[1].parameters()
    elif model_name in ['densenet121']:
        num_features = model.classifier.in_features
        model.classifier = nn.Linear(num_features, num_class)
        param_to_train = model.classifier.parameters()
    elif model_name in ['vgg11']:
        num_features = model.classifier[6].in_features
        model.classifier[6] = nn.Linear(num_features, num_class)
        param_to_train = model.classifier[6].parameters()
    return model, param_to_train

这是为什么我们可以通过简单地替换最后一层(通常是全连接层),将从一个领域(在 ImageNet 上训练)学到的知识迁移到另一个领域(猫狗分类)。CNN 中的所有卷积层负责从图像及中间特征图中提取特征。全连接层可以看作是重新组合最高层次的特征,形成原始数据的最终抽象。显然,在 ImageNet 上训练的好模型非常擅长提取特征。因此,以不同方式重新组合这些特征,很可能能够处理像猫狗分类这样较简单的数据集。

此外,data_prefetcher 用于加速训练过程。它在 data_utils.py 中定义:

class data_prefetcher():
    def __init__(self, loader):
        self.loader = iter(loader)
        self.stream = torch.cuda.Stream()
        self.preload()

    def preload(self):
        try:
            self.next_input, self.next_target = next(self.loader)
        except StopIteration:
            self.next_input = None
            self.next_target = None
            return
        with torch.cuda.stream(self.stream):
            self.next_input = self.next_input.cuda(non_blocking=True)
            self.next_target = self.next_target.cuda(non_blocking=True)
            self.next_input = self.next_input.float()

    def next(self):
        torch.cuda.current_stream().wait_stream(self.stream)
        input = self.next_input
        target = self.next_target
        self.preload()
        return input, target

这些单独模型的训练速度可以非常快。以下是经过 25 个 epoch 的迁移学习后的 GPU 内存消耗和验证准确率:

模型 内存 准确率
MobileNet V2 1665MB 98.14%
ResNet-18 1185MB 98.24%
DenseNet 1943MB 98.76%
GoogleNet 1447MB 98.06%
ResNeXt-50 1621MB 98.98%

ResNet-34、ShuffleNet V2、SqueezeNet 和 VGG-11 未被选择,原因是它们的性能较低或内存消耗过高(超过 2 GB)。

使用 torch.save(model.state_dict(), PATH) 将模型保存到硬盘时,只会导出参数值,你需要在加载到另一个脚本时明确地定义 model。然而,torch.save(model, PATH) 会保存所有内容,包括模型定义。

  1. model_ensemble.py 中组合集成分类器:
class ModelEnsemble(nn.Module):
    def __init__(self, model_names, num_class, model_path):
        super(ModelEnsemble, self).__init__()
        self.model_names = model_names
        self.num_class = num_class
        models = []
        for m in self.model_names:
            model = torch.load(os.path.join(model_path, '{}.pth'.format(m)))
            for param in model.parameters():
                param.requires_grad = False
            models.append(model)
        self.models = nn.Sequential(*models)
        self.vote_layer = nn.Linear(len(self.model_names)*self.num_class, self.num_class)

    def forward(self, input):
        raw_outputs = []
        for m in self.models:
            _out = m(input)
            raw_outputs.append(_out)
        raw_out = torch.cat(raw_outputs, dim=1)
        output = self.vote_layer(raw_out)
        return output

这里,将所有模型的预测结果组合在一起,在 vote_layer 中给出最终预测。

另外,你也可以直接将预训练模型中最后一层卷积层的特征图组合起来,训练一个单独的全连接层来预测图像标签。

  1. 返回到 cats_dogs.py 文件并开始训练集成分类器:
 elif FLAGS.train_ensemble:
 print('Loading model...\n')
 model_names = ['mobilenet_v2', 'resnet18', 'densenet121',
 'googlenet', 'resnext50_32x4d']
 model = ModelEnsemble(model_names, FLAGS.classes, FLAGS.model_dir)
 model.to(device)

 optimizer = torch.optim.SGD(
 model.vote_layer.parameters(), FLAGS.lr,
 momentum=0.9, weight_decay=5e-4)
 scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=1, gamma=0.1)

 criterion = nn.CrossEntropyLoss()

 # Train
 print('Training ensemble model...\n')
 for epoch in range(2):
 model.train()
 scheduler.step()
 print('Epoch {}, lr: {}'.format(epoch, scheduler.get_lr()[0]))
 prefetcher = data_prefetcher(train_loader)
 data, target = prefetcher.next()
 batch_idx = 0
 while data is not None:
 optimizer.zero_grad()
 output = model(data)
 pred = output.max(1, keepdim=True)[1]
 loss = criterion(output, target)
 loss.backward()
 optimizer.step()
 correct = pred.eq(target.view_as(pred)).sum().item()
 if batch_idx % FLAGS.log_interval == 0:
 print('[{}/{}]\tloss: {:.4f}\tbatch accuracy: {:.4f}%'.format(
 batch_idx * FLAGS.batch_size, num_train,
 loss.item(), 100 * correct / data.size(0)))
 data, target = prefetcher.next()
 batch_idx += 1
 # Eval
 ...

再次由于代码过长,评估代码已省略。集成分类器经过仅仅 2 个 epoch 的训练后,验证准确率达到了 99.32%。集成分类器的训练仅占用 2775 MB 的 GPU 内存,导出的模型文件大小不超过 200 MB。

使用 advGAN 打破分类器

我们将用于生成对抗样本的 GAN 模型主要借鉴自github.com/mathcbc/advGAN_pytorch。让我们创建两个文件,分别命名为advGAN.pymodels.py,并将以下代码放入这些文件中:

  1. advGAN.py:在这个文件中,你将看到以下内容:
import torch.nn as nn
import torch
import numpy as np
import models
import torch.nn.functional as F
import torchvision
import os

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)

class AdvGAN_Attack:
    def __init__(self,
                 device,
                 model,
                 model_num_labels,
                 image_nc,
                 box_min,
                 box_max,
                 model_path):
        output_nc = image_nc
        self.device = device
        self.model_num_labels = model_num_labels
        self.model = model
        self.input_nc = image_nc
        self.output_nc = output_nc
        self.box_min = box_min
        self.box_max = box_max
        self.model_path = model_path

        self.gen_input_nc = image_nc
        self.netG = models.Generator(self.gen_input_nc, image_nc).to(device)
        self.netDisc = models.Discriminator(image_nc).to(device)

        # initialize all weights
        self.netG.apply(weights_init)
        self.netDisc.apply(weights_init)

        # initialize optimizers
        self.optimizer_G = torch.optim.Adam(self.netG.parameters(),
                                            lr=0.001)
        self.optimizer_D = torch.optim.Adam(self.netDisc.parameters(),
                                            lr=0.001)

    def train_batch(self, x, labels):
        # optimize D
        for i in range(1):
            perturbation = self.netG(x)

            adv_images = torch.clamp(perturbation, -0.3, 0.3) + x
            adv_images = torch.clamp(adv_images, self.box_min, 
              self.box_max)

            self.optimizer_D.zero_grad()
            pred_real = self.netDisc(x)
            loss_D_real = F.mse_loss(pred_real, 
              torch.ones_like(pred_real, device=self.device))
            loss_D_real.backward()

            pred_fake = self.netDisc(adv_images.detach())
            loss_D_fake = F.mse_loss(pred_fake, 
              torch.zeros_like(pred_fake, device=self.device))
            loss_D_fake.backward()
            loss_D_GAN = loss_D_fake + loss_D_real
            self.optimizer_D.step()

        # optimize G
        for i in range(1):
            self.optimizer_G.zero_grad()

            pred_fake = self.netDisc(adv_images)
            loss_G_fake = F.mse_loss(pred_fake, 
              torch.ones_like(pred_fake, device=self.device))
            loss_G_fake.backward(retain_graph=True)

            C = 0.1
            loss_perturb = torch.mean(torch.norm(perturbation.view(perturbation.shape[0], -1), 2, dim=1))

            logits_model = self.model(adv_images)
            probs_model = F.softmax(logits_model, dim=1)
            onehot_labels = torch.eye(self.model_num_labels, device=self.device)[labels]

            real = torch.sum(onehot_labels * probs_model, dim=1)
            other, _ = torch.max((1 - onehot_labels) * probs_model - onehot_labels * 10000, dim=1)
            zeros = torch.zeros_like(other)
            loss_adv = torch.max(real - other, zeros)
            loss_adv = torch.sum(loss_adv)

            adv_lambda = 10
            pert_lambda = 1
            loss_G = adv_lambda * loss_adv + pert_lambda *  
             loss_perturb
            loss_G.backward()
            self.optimizer_G.step()

        return loss_D_GAN.item(), loss_G_fake.item(), 
         loss_perturb.item(), loss_adv.item()

    def train(self, train_dataloader, epochs):
        ...

    def adv_example(self, data):
        perturbation = self.netG(data)
        adv_images = torch.clamp(perturbation, -0.3, 0.3) + data
        adv_images = torch.clamp(adv_images, self.box_min,  
          self.box_max)
        return adv_images

由于篇幅原因,部分代码被省略。我们可以看到,这个 GAN 模型只负责生成对抗示例中的噪声部分,生成的噪声会被限制在[-0.3, 0.3]范围内,然后加到原始图像中。在训练过程中,使用 MSE 损失来衡量判别器损失,使用 L1 损失来计算生成器的对抗损失。生成的扰动噪声的 L2 范数也包含在生成器损失中。然而,GAN 的性能与我们试图突破的分类器(self.model)密切相关,这意味着每次引入新的分类器时,都需要重新训练 GAN 模型。

models.py中的代码在这里省略,但可以在本章的代码库中找到,因为你基本上可以根据自己的需求设计判别器和生成器。这里,我们使用一个 4 层的 CNN 作为判别器网络,以及一个 14 层类似 ResNet 的 CNN 作为生成器网络。

返回cats_dogs.py,我们需要训练 GAN 模型来学习如何突破集成分类器。

  1. 重新定义数据加载器,因为我们需要一个更小的批量大小来适应 11 GB 的 GPU 内存:
        print('Training GAN for adversarial attack...\n')
        train_loader = torch.utils.data.DataLoader(
            train_data, batch_size=16,

        sampler=torch.utils.data.sampler.SubsetRandomSampler
            (indices[:split]),
            num_workers=2)
  1. 开始训练 GAN 模型:
        model.eval()
        advGAN = AdvGAN_Attack(device, model, FLAGS.classes,
                               FLAGS.channels, 0, 1, FLAGS.model_dir)
        advGAN.train(train_loader, FLAGS.epochs)
  1. 使用 GAN 攻击集成分类器:
        print('Attacking ensemble model...\n')
        test_loss = 0
        test_correct = 0
        adv_examples = []
        with torch.no_grad():
            valid_prefetcher = data_prefetcher(valid_loader)
            data, target = valid_prefetcher.next()
            while data is not None:
                output = model(data)
                init_pred = output.max(1, keepdim=True)[1]
                init_pred = init_pred.view_as(target)

                perturbed_data = advGAN.adv_example(data)
                output = model(perturbed_data)
                test_loss += criterion(output, target).item()
                final_pred = output.max(1, keepdim=True)[1]
                final_pred = final_pred.view_as(target)
                test_correct += final_pred.eq(target).sum().item()
                if len(adv_examples) < 64 and not (final_pred == target).all():
                    indices = torch.ne(final_pred.ne(target), init_pred.ne(target)).nonzero()
                    for i in range(indices.shape[0]):
                        adv_ex = perturbed_data[indices[i]].squeeze().detach().cpu().numpy()
                        adv_examples.append((init_pred[indices[i]].item(), final_pred[indices[i]].item(), adv_ex))
                        if (len(adv_examples) >= 64):
                            break
                data, target = valid_prefetcher.next()
        test_loss /= len(valid_loader)
        print('Eval loss: {:.4f}, accuracy: {:.4f}'.format(
            test_loss, 100 * test_correct / (1-FLAGS.data_split) / num_train))

训练 GAN 模型 60 个 epoch 大约需要 6 个小时。攻击结果可能如下所示:

Attacking ensemble model...

Eval loss: 2.1465, accuracy: 10.3000

我们可以看到,验证准确率从 99.32%降到 10.3%,这是由于 GAN 对抗攻击的结果。

  1. 使用matplotlib显示一些被错误分类的图像:
        cnt = 0
        plt.figure(figsize=(8,10))
        for i in range(8):
            for j in range(8):
                cnt += 1
                plt.subplot(8, 8, cnt)
                plt.xticks([], [])
                plt.yticks([], [])
                orig, adv, ex = adv_examples[i*8+j]
                ex = np.transpose(ex, (1, 2, 0))
                plt.title("{} -> {}".format(orig, adv))
                plt.imshow(ex)
        plt.tight_layout()
        plt.show()

现在,代码中的一切都已经完成,最终我们可以真正运行程序了。我们需要多次运行程序,每次针对不同的模型。请在你的代码文件夹中创建一个名为 models 的空文件夹,用于保存模型。

我们将从命令行启动程序:

$ python cats_dogs.py --model resnet34 --train_single True
$ python cats_dogs.py --model mobilenet_v2 --train_single True --data_dir ./cats-dogs-kaggle
$ python cats_dogs.py --model shufflenet_v2_x1_0 --train_single True --data_dir ./cats-dogs-kaggle
$ python cats_dogs.py --model squeezenet1_1 --train_single True --data_dir ./cats-dogs-kaggle 
$ python cats_dogs.py --model densenet121 --train_single True --data_dir ./cats-dogs-kaggle
$ python cats_dogs.py --model googlenet --train_single True --data_dir ./cats-dogs-kaggle
$ python cats_dogs.py --model resnext50_32x4d --train_single True --data_dir ./cats-dogs-kaggle
$ python cats_dogs.py --model vgg11 --train_single True --data_dir ./cats-dogs-kaggle

一旦所有模型都运行完毕,我们就可以最终测试我们的集成代码:

$ python cats_dogs.py --train_single False --train_ensemble True

这里是一些由 GAN 生成的扰动图像,这些图像成功欺骗了我们的集成分类器:

GAN 生成的对抗样本

总结

本章内容涵盖了很多内容。你学到了快速梯度符号法(Fast Gradient Sign Methods)的基础知识,如何使用预训练模型训练分类器,如何处理迁移学习,等等。

在下一章,我们将展示如何将NLP自然语言处理)与 GAN 结合,并从描述文本中生成图像。

参考文献和进一步阅读列表

  1. Goodfellow I, Papernot N, Huang S, 等(2017 年 2 月 24 日)。通过对抗性样本攻击机器学习。来源:openai.com/blog/adversarial-example-research

  2. Brown T, Mané D, Roy A, 等(2017)。对抗性补丁。NIPS。

  3. Athalye A, Engstrom L, Ilyas A.(2018)。合成稳健的对抗性样本。ICML。

第十章:从描述文本生成图像

在前面的章节中,我们主要处理了图像合成和图像到图像的翻译任务。现在,是时候让我们从计算机视觉(CV)领域转向自然语言处理(NLP)领域,探索生成对抗网络(GANs)在其他应用中的潜力了。或许你已经见过一些卷积神经网络(CNN)模型被用于图像/视频描述生成。如果我们能反向操作,根据描述文本生成图像,那岂不是太棒了?

在本章中,你将了解词嵌入的基本知识及其在 NLP 领域中的应用。你还将学习如何设计一个文本到图像的 GAN 模型,从而根据一句描述文本生成图像。最后,你将了解如何堆叠两个或更多条件 GAN 模型,通过 StackGAN 和 StackGAN++进行更高分辨率的文本到图像合成。

本章将涵盖以下主题:

  • 使用 GAN 进行文本到图像的合成

  • 使用 StackGAN++生成逼真的照片级图像

使用 GAN 进行文本到图像的合成

从第四章,使用 PyTorch 构建你的第一个 GAN,到第八章,训练 GAN 突破不同模型,我们已经学习了几乎所有 GAN 在计算机视觉中的基本应用,特别是在图像合成方面。你可能会想,GAN 在其他领域(如文本或音频生成)中是如何使用的。在本章中,我们将逐步从 CV 领域转向 NLP 领域,通过结合这两个领域,尝试从描述文本中生成逼真的图像。这个过程叫做文本到图像合成(或文本到图像翻译)。

我们知道,几乎每个 GAN 模型都是通过在某种输入数据形式与输出数据之间建立明确的映射来生成合成数据。因此,为了根据对应的描述句子生成图像,我们需要了解如何用向量表示句子。

词嵌入简介

定义一种方法将句子中的单词转换为向量其实是相当简单的。我们可以简单地为所有可能的单词分配不同的值(例如,让 001 表示I,002 表示eat,003 表示apple),这样就可以通过向量唯一表示句子(例如,I eat apple将变为[001, 002, 003])。这基本上就是计算机中单词的表示方式。然而,语言比冷冰冰的数字要复杂和灵活得多。如果不了解单词的含义(例如,名词或动词,积极或消极),几乎不可能建立单词之间的关系并理解句子的含义。此外,由于硬编码的值之间的距离无法表示相应单词之间的相似性,因此很难基于硬编码的值找到一个单词的同义词。

用于将单词、短语或句子映射到向量的方法被称为词嵌入。其中最成功的词嵌入技术之一叫做word2vec。如果你想了解更多关于 word2vec 的信息,可以随时查阅 Xin Rong 的论文《word2vec 参数学习解释》,arxiv.org/pdf/1411.2738.pdf

嵌入这个术语意味着将数据映射到一个不同的空间,以便更容易分析。你可能在一些关于卷积神经网络(CNN)的旧论文或文章中见过这个术语,在这些论文中,经过训练的全连接层的输出向量被用来可视化模型是否已正确训练。

词嵌入主要用于解决 NLP 中的两类问题:

  • CBOW连续词袋模型),用于根据上下文中的若干个词预测单一目标词

  • Skip-Gram 模型,和 CBOW 模型相反,用于根据目标词预测上下文词

下图为我们提供了 CBOW 和 Skip-Gram 模型的概述:

两种类型的词嵌入。图像来源:Xin Rong,2014

NLP 中的另一个常见术语是语言建模。与词嵌入相比,语言模型预测的是句子的可能性,或者更具体地说,预测下一个词在句子中出现的可能性。由于语言建模考虑了单词的顺序,许多语言模型是基于词嵌入构建的,从而获得更好的结果。

简单来说,学习到的词嵌入是一个向量,它表示一个句子,使得机器学习算法更容易分析和理解原始句子的含义。请查看关于词嵌入的官方教程,了解如何在 PyTorch 中实现 CBOW 和 Skip-Gram 模型:pytorch.org/tutorials/beginner/nlp/word_embeddings_tutorial.html#sphx-glr-beginner-nlp-word-embeddings-tutorial-py

使用零-shot 迁移学习将文本转换为图像

在第八章,训练你的 GAN 以打破不同的模型中,我们了解了在图像分类任务中进行迁移学习所需的基本步骤。在更为现实的情况下,将这种学习到的知识迁移到另一个领域变得更加困难,因为可能会有许多新的数据形式是预训练模型之前没有见过的,尤其是当我们尝试根据描述文本生成图像(或者反过来,从给定图像生成描述文本)时。例如,如果模型仅在白猫的图像上进行训练,当我们要求它生成黑猫的图像时,它将无法知道该怎么做。这就是零-shot 迁移学习发挥作用的地方。

零-shot 学习

零样本学习是指机器学习过程中我们需要预测以前未见过的标签的新样本。通常通过向预训练过程提供额外信息来完成。例如,我们可以告诉模型,所谓的白猫具有两个属性:颜色即白色,以及猫的形状。这使得模型可以轻松知道,当我们要求它们时,用黑色替换白色将给我们黑猫

类似地,机器学习过程中,新样本仅一次标记每个类(或每个类很少标记几个样本)的过程称为单样本学习

为了在文本和图像之间建立零样本学习能力,我们将使用 Scott Reed、Zeynep Akata 和 Bernt Schiele 等人在其论文Learning Deep Representations of Fine-Grained Visual Descriptions中提出的词嵌入模型。他们的模型设计用于一个目的:基于单个查询句子从大型集合中找到最匹配的图像。

下面的图片是通过单个查询句子获得的图像搜索结果的示例:

在 CUB-200-2011 数据集上,通过单个查询句子获得的图像搜索结果示例

我们在这里不会深入讨论词嵌入方法的实现细节,而是使用作者提供的预训练的char-CNN-RNN结果。

GAN 架构与训练

本节中 GAN 模型的设计基于 Scott Reed、Zeynep Akata 和 Xinchen Yan 等人在其论文Generative Adversarial Text to Image Synthesis中提出的文本到图像模型。在这里,我们将描述和定义生成器和判别器网络的架构以及训练过程。

生成器网络有两个输入,包括一个潜在的噪声向量,,和描述句子的嵌入向量,,长度为 1,024,由一个全连接层映射到一个长度为 128 的向量。这个向量与噪声向量连接起来形成一个大小为[B, 228, 1, 1]的张量(其中 B 表示批量大小,现在被省略)。使用五个转置卷积层(核大小为 4,步长为 2,填充大小为 1),逐步扩展特征图的大小(同时减小通道宽度)到[3, 64, 64],这是通过Tanh激活函数生成的图像。隐藏层中使用批量归一化层和ReLU激活函数。

让我们创建一个名为gan.py的新文件来定义网络。以下是生成器网络的代码定义:

import torch
import torch.nn as nn

class Generator(nn.Module):
    def __init__(self, channels, latent_dim=100, embed_dim=1024, embed_out_dim=128):
        super(Generator, self).__init__()
        self.channels = channels
        self.latent_dim = latent_dim
        self.embed_dim = embed_dim
        self.embed_out_dim = embed_out_dim

        self.text_embedding = nn.Sequential(
            nn.Linear(self.embed_dim, self.embed_out_dim),
            nn.BatchNorm1d(self.embed_out_dim),
            nn.LeakyReLU(0.2, inplace=True)
        )

        model = []
        model += self._create_layer(self.latent_dim + self.embed_out_dim, 512, 4, stride=1, padding=0)
        model += self._create_layer(512, 256, 4, stride=2, padding=1)
        model += self._create_layer(256, 128, 4, stride=2, padding=1)
        model += self._create_layer(128, 64, 4, stride=2, padding=1)
        model += self._create_layer(64, self.channels, 4, stride=2, padding=1, output=True)

        self.model = nn.Sequential(*model)

    def _create_layer(self, size_in, size_out, kernel_size=4, stride=2, padding=1, output=False):
        layers = [nn.ConvTranspose2d(size_in, size_out, kernel_size, stride=stride, padding=padding, bias=False)]
        if output:
            layers.append(nn.Tanh())
        else:
            layers += [nn.BatchNorm2d(size_out),
                 nn.ReLU(True)]
        return layers

    def forward(self, noise, text):
        text = self.text_embedding(text)
        text = text.view(text.shape[0], text.shape[1], 1, 1)
        z = torch.cat([text, noise], 1)
        return self.model(z)

判别器网络也有两个输入,一个是生成的/真实图像,,另一个是嵌入向量,。输入图像,,是一个大小为[3, 64, 64]的张量,通过四个卷积层映射到[512, 4, 4]。判别器网络有两个输出,[512, 4, 4]特征图也是第二个输出张量。嵌入向量,,被映射为一个长度为 128 的向量,并扩展为大小为[128, 4, 4]的张量,随后与图像特征图进行拼接。最终,拼接后的张量(大小为[640, 4, 4])被输入到另一个卷积层,得到预测值。

判别器网络的代码定义如下:

class Embedding(nn.Module):
    def __init__(self, size_in, size_out):
        super(Embedding, self).__init__()
        self.text_embedding = nn.Sequential(
            nn.Linear(size_in, size_out),
            nn.BatchNorm1d(size_out),
            nn.LeakyReLU(0.2, inplace=True)
        )

    def forward(self, x, text):
        embed_out = self.text_embedding(text)
        embed_out_resize = embed_out.repeat(4, 4, 1, 1).permute(2, 3, 0, 1)
        out = torch.cat([x, embed_out_resize], 1)
        return out

class Discriminator(nn.Module):
    def __init__(self, channels, embed_dim=1024, embed_out_dim=128):
        super(Discriminator, self).__init__()
        self.channels = channels
        self.embed_dim = embed_dim
        self.embed_out_dim = embed_out_dim

        self.model = nn.Sequential(
            *self._create_layer(self.channels, 64, 4, 2, 1, 
              normalize=False),
            *self._create_layer(64, 128, 4, 2, 1),
            *self._create_layer(128, 256, 4, 2, 1),
            *self._create_layer(256, 512, 4, 2, 1)
        )
        self.text_embedding = Embedding(self.embed_dim, self.embed_out_dim)
        self.output = nn.Sequential(
            nn.Conv2d(512 + self.embed_out_dim, 1, 4, 1, 0, bias=False),
            nn.Sigmoid()
        )

    def _create_layer(self, size_in, size_out, kernel_size=4, stride=2,  
      padding=1, normalize=True):
        layers = [nn.Conv2d(size_in, size_out, kernel_size=kernel_size, 
          stride=stride, padding=padding)]
        if normalize:
            layers.append(nn.BatchNorm2d(size_out))
        layers.append(nn.LeakyReLU(0.2, inplace=True))
        return layers

    def forward(self, x, text):
        x_out = self.model(x)
        out = self.text_embedding(x_out, text)
        out = self.output(out)
        return out.squeeze(), x_out

两个网络的训练过程可以在下图中看到。我们可以看到,训练文本到图像的 GAN 与普通的 GAN 非常相似,唯一的不同是,判别器从真实和生成图像中得到的中间输出(第二个输出张量)用于计算 L1 损失,而真实/生成图像则用于计算 L2 损失:

一个文本到图像的生成对抗网络(GAN)的训练过程,其中 x 代表真实图像,x 代表生成的图像,t 代表文本嵌入向量,z 代表潜在噪声向量。来自判别器 D 的虚线箭头表示中间输出张量。

在介绍以下代码时,内容将以一种非线性的方式呈现。这是为了确保你能够理解每个环节涉及的过程。

让我们创建一个名为 build_gan.py 的新文件,并创建一个一站式的训练/评估 API,就像我们在之前的章节中做的那样。我们将只展示训练过程中的关键部分。你可以自行填充空白作为练习,或者参考代码仓库 text2image 文件夹中的完整源代码:

import os
import time

import torch
import torchvision.utils as vutils

from gan import Generator as netG
from gan import Discriminator as netD

def _weights_init(m):
    # init weights in conv and batchnorm layers
    ...

class Model(object):
    def __init__(self, name, device, data_loader, channels, l1_coef, l2_coef):
        # parse argument values
        ...
        self.netG = netG(self.channels)
        self.netG.apply(_weights_init)
        self.netG.to(self.device)
        self.netD = netD(self.channels)
        self.netD.apply(_weights_init)
        self.netD.to(self.device)
        self.loss_adv = torch.nn.BCELoss()
        self.loss_l1 = torch.nn.L1Loss()
        self.loss_l2 = torch.nn.MSELoss()
        self.l1_coef = l1_coef
        self.l2_coef = l2_coef

现在,让我们来进行训练过程的工作(该过程在Model.train()中定义):

    def train(self, epochs, log_interval=100, out_dir='', verbose=True):
        self.netG.train()
        self.netD.train()
        for epoch in range(epochs):
            for batch_idx, data in enumerate(self.data_loader):
                image = data['right_images'].to(self.device)
                embed = data['right_embed'].to(self.device)

                real_label = torch.ones((image.shape[0]), 
                 device=self.device)
                fake_label = torch.zeros((image.shape[0]), 
                 device=self.device)

                # Train D
                self.optim_D.zero_grad()

                out_real, _ = self.netD(image, embed)
                loss_d_real = self.loss_adv(out_real, real_label)

                noise = torch.randn((image.shape[0], 100, 1, 1), 
                 device=self.device)
                image_fake = self.netG(noise, embed)
                out_fake, _ = self.netD(image_fake, embed)
                loss_d_fake = self.loss_adv(out_fake, fake_label)

                d_loss = loss_d_real + loss_d_fake
                d_loss.backward()
                self.optim_D.step()

                # Train G
                self.optim_G.zero_grad()
                noise = torch.randn((image.shape[0], 100, 1, 1), 
                 device=self.device)
                image_fake = self.netG(noise, embed)
                out_fake, act_fake = self.netD(image_fake, embed)
                _, act_real = self.netD(image, embed)

                l1_loss = self.loss_l1(torch.mean(act_fake, 0), 
                 torch.mean(act_real, 0).detach())
                g_loss = self.loss_adv(out_fake, real_label) + \
                    self.l1_coef * l1_loss + \
                    self.l2_coef * self.loss_l2(image_fake, image)
                g_loss.backward()
                self.optim_G.step()

这里我们使用的是 Caltech-UCSD 鸟类数据集(CUB-200-2011),该数据集包含 11,788 张标注的鸟类图像。我们不会自己处理鸟类图像和训练词向量,而是直接使用作者提供的预训练嵌入(github.com/reedscot/icml2016)。在 GitHub 仓库(github.com/aelnouby/Text-to-Image-Synthesis)中,提供了一个包含图像文件、嵌入向量和原始描述文本的 HDF5 数据库文件。

让我们从提供的 Google Drive 链接中下载数据库文件(大小约为 5.7 GB)(drive.google.com/open?id=1mNhn6MYpBb-JwE86GC1kk0VJsYj-Pn5j),并将其放在一个文件夹中(例如,/media/john/DataAsgard/text2image/birds)。我们还需要下载自定义数据集类(github.com/aelnouby/Text-to-Image-Synthesis/blob/master/txt2image_dataset.py),因为将导出的 HDF5 数据库元素正确地转化为 PyTorch 张量有点复杂。这也意味着我们在运行脚本之前需要安装h5py库,使用命令pip install h5py进行安装。

最后,让我们创建一个main.py文件,并填写参数解析代码,正如我们已经做过多次一样,并从中调用Model.train()。再次强调,我们省略了main.py中的大部分代码。如果您需要任何帮助,可以参考本章的代码仓库中的完整源代码:

import argparse
import os
import sys

import numpy as np
import torch
import torch.backends.cudnn as cudnn
import utils

from torch.utils.data import DataLoader
from build_gan import Model
from txt2image_dataset import Text2ImageDataset

FLAGS = None

def main():
    ...
    device = torch.device("cuda:0" if FLAGS.cuda else "cpu")

    print('Loading data...\n')
    dataloader = DataLoader(Text2ImageDataset(os.path.join(FLAGS.data_dir, '{}.hdf5'.format(FLAGS.dataset)), split=0),
        batch_size=FLAGS.batch_size, shuffle=True, num_workers=8)

    print('Creating model...\n')
    model = Model(FLAGS.model, device, dataloader, FLAGS.channels, FLAGS.l1_coef, FLAGS.l2_coef)

    if FLAGS.train:
        model.create_optim(FLAGS.lr)

        print('Training...\n')
        model.train(FLAGS.epochs, FLAGS.log_interval, FLAGS.out_dir, True)

        model.save_to('')
    else:
        ...

完成 200 个 epoch 的训练大约需要 2 个半小时,使用 256 的 batch size 时,GPU 内存消耗大约为 1,753 MB。训练结束时,一些结果如下所示:

使用文本到图像的 GAN 在 CUB-200-2011 数据集上生成的图像

我们在本节中使用的方法是 3 年前提出的,因此生成的图像质量不如今天应该达到的水平。因此,我们将向您介绍 StackGAN 和 StackGAN++,以便您能够生成高分辨率的结果。

使用 StackGAN++生成逼真的照片级图像

从描述文本生成图像可以视为一个条件 GANCGAN)过程,其中描述句子的嵌入向量作为附加标签信息。幸运的是,我们已经知道如何使用 CGAN 模型生成令人信服的图像。现在,我们需要弄清楚如何使用 CGAN 生成大尺寸图像。

你还记得我们如何在第七章《使用 GAN 进行图像修复》中,利用两个生成器和两个判别器填补图像中的缺失部分(图像修复)吗?同样,我们也可以将两个 CGAN 堆叠在一起,从而获得高质量的图像。这正是 StackGAN 所做的。

使用 StackGAN 进行高分辨率文本到图像合成

StackGAN是由 Han Zhang、Tao Xu 和 Hongsheng Li 等人在他们的论文《StackGAN:基于堆叠生成对抗网络的文本到逼真图像合成》中提出的。

描述句子的嵌入向量,,经过条件增强步骤处理,生成一个条件向量,。在条件增强中,一对均值,,和标准差,,向量是从嵌入向量,,计算得出,用于生成基于高斯分布的条件向量,。这个过程使我们能够从有限的文本嵌入生成更多独特的条件向量,并确保所有的条件变量遵循相同的高斯分布。同时,被限制,使得它们不会偏离太远。这是通过在生成器的损失函数中加入 Kullback-Leibler 散度(KL 散度)项来实现的。

一个潜在向量,(它是从采样的),与条件向量,,结合,作为Stage-I 生成器的输入。第一个生成器网络生成一个低分辨率的图像,大小为 64 x 64。低分辨率图像被传递到Stage-I 判别器,后者同样将嵌入向量,,作为输入来预测低分辨率图像的真实性。Stage-I 生成器和判别器的损失函数如下:

在前面的方程中,中的是 Stage-I 生成器的输出,,其中,是条件向量,表示 KL 散度。

然后,低分辨率图像被输入到Stage-II 生成器。同样,嵌入向量,,也传递到第二个生成器,帮助生成大小为 256 x 256 的高分辨率图像。高分辨率图像的质量由Stage-II 判别器判断,后者同样接收作为输入。第二阶段的损失函数与第一阶段类似,如下所示:

在上述方程中, 在  中是 Stage-II 生成器的输出,,其中 是 Stage-I 生成器的输出, 是条件向量。

由 StackGAN 生成的一些图像将在接下来的章节中提供。如果你有兴趣尝试 StackGAN,论文的作者已经在这里开源了一个 PyTorch 版本:github.com/hanzhanggit/StackGAN-Pytorch

从 StackGAN 到 StackGAN++

StackGAN++(也叫 StackGAN v2)是 StackGAN 的改进版本,由 Han Zhang、Tao Xu 和 Hongsheng Li 等人在他们的论文《StackGAN++: Realistic Image Synthesis with Stacked Generative Adversarial Networks》中提出。与 StackGAN 相比,StackGAN++在设计上有三个主要的区别,具体如下:

  • 多尺度图像合成:它使用一种树形结构(如下图所示),其中每个分支代表一个独立的生成器网络,随着树的高度增加,生成图像的尺寸也会增大。每个分支生成的图像质量是由不同的判别器网络来估算的。

  • 采用无条件损失:除了使用标签信息(通过文本嵌入计算)来估算图像的保真度外,还向每个生成器和判别器的损失函数中添加了额外的损失项,这些损失项仅使用图像作为输入(如下式所示)。

判别器和生成器在  第 th 分支的损失函数定义如下:

在上述方程中,每个损失函数的第一行叫做条件损失,第二行叫做无条件损失。它们是通过JCU 判别器计算的,该判别器在之前的图中已有说明。

  • 颜色一致性约束:由于树形结构中可能有多个分支,因此确保不同分支生成的图像彼此相似是很重要的。因此,一个颜色一致性正则化项被添加到生成器的损失函数中(当然,还会有一个比例因子)。

颜色一致性正则化定义如下:

在上述公式中,代表批量大小,而代表由第个图像生成的第个生成器的均值和协方差。这确保由相邻分支生成的图像具有相似的颜色结构。

训练 StackGAN++生成更高质量的图像

StackGAN++的作者已经很好地开源了完整的源代码:github.com/hanzhanggit/StackGAN-v2。按照这些步骤在 CUB-200-2011 数据集上训练 StackGAN++。确保你已经在 Anaconda 中创建了一个Python 2.7环境,并安装了 PyTorch,因为在加载预训练文本嵌入时会从pickle中出现解码错误。你可以参考第二章,开始使用 PyTorch 1.3,来创建一个新的环境:

  1. 在你的终端中通过运行以下命令安装先决条件:
$ pip install pyyaml tensorboard-pytorch scipy python-dateutil easydict pandas torchfile

确保你的 Python 2.7 环境中没有安装tensorboard,因为 StackGAN++调用FileWriter将日志信息写入 TensorBoard,而FileWriter已在最新版本的 TensorBoard 中移除。如果你不想卸载 TensorBoard,可以通过运行pip install tensorboard==1.0.0a6来降级它。

  1. 下载 StackGAN++的源代码:
$ git clone https://github.com/hanzhanggit/StackGAN-v2 && cd StackGAN-v2
  1. www.vision.caltech.edu/visipedia/CUB-200-2011.html下载 CUB-200-2011 数据集,并将CUB_200_2011文件夹放置在data/birds目录下,以便图像位于类似于data/birds/CUB_200_2011/images/001.Black_footed_Albatross/Black_Footed_Albatross_0001_796111.jpg的路径中。需要下载的压缩文件大小约为 1.1 GB。

  2. drive.google.com/open?id=0B3y_msrWZaXLT1BZdVdycDY5TEE下载预训练的文本嵌入,并将其中的三个文件夹移动到data/birds。确保将text_c10文件夹重命名为text

  3. 导航到代码文件夹并开始训练过程:

$ cd code && python main.py --cfg cfg/birds_3stages.yml --gpu 0

你只需对 StackGAN++的源代码进行少量修改,以便其可以在 PyTorch 1.1 下运行;例如,你可以在trainer.py中将所有的.data[0]替换为.item()。还有几个弃用警告需要我们修复。你可以参考本书本章的stackgan-v2文件夹中的源代码库获取更多信息。

  1. (可选)测试你训练好的模型。在code/cfg/eval_birds.yml文件中指定模型文件:
 NET_G: '../output/birds_3stages_2019_07_16_23_57_11/Model/netG_220800.pth'

然后,在你的终端中运行以下脚本来开始评估过程:

$ python main.py --cfg cfg/eval_birds.yml --gpu 0

评估大约需要 7,819 MB 的 GPU 内存,并且需要 12 分钟才能完成。生成的图像将位于output/birds_3stages_2019_07_16_23_57_11/Model/iteration220800/single_samples/valid文件夹中。

在 GTX 1080Ti 显卡上完成 600 个训练周期大约需要 48 小时,并且大约消耗 10,155 MB 的 GPU 内存。以下是训练结束时生成的部分图像:

图像由 StackGAN++生成

尽管这个过程需要非常长的时间和大量的 GPU 内存,但你可以看到生成的结果非常出色。

总结

本章中,我们学习了如何根据描述文本生成低分辨率和高分辨率图像。

在下一章中,我们将重点介绍如何使用 GAN 直接生成序列数据,如文本和音频。

进一步阅读

  1. Rong X.(2014)。word2vec 参数学习解析。arXiv:1411.2738。

  2. Reed S, Akata Z, Schiele B 等(2016)。深度学习细粒度视觉描述的表示方法。CVPR。

  3. Reed S, Akata Z, Yan X 等(2016)。生成对抗文本到图像合成。ICML。

  4. Zhang H, Xu T, Li H 等(2017)。StackGAN:基于堆叠生成对抗网络的文本到照片级真实图像合成。ICCV。

  5. Zhang H, Xu T, Li H 等(2018)。StackGAN++:基于堆叠生成对抗网络的真实图像合成。IEEE 模式分析与机器智能杂志。

第十一章:使用 GAN 进行序列合成

在本章中,我们将研究能够直接生成序列数据(如文本和音频)的 GAN。与此同时,我们将回顾之前所学的图像生成模型,以便让你更快地熟悉 NLP 模型。

在本章中,你将了解 NLP 领域常用的技术,如 RNN 和 LSTM。你还将了解强化学习RL)的一些基本概念,以及它与监督学习(如基于 SGD 的 CNN)的区别。接下来,我们将学习如何从文本集合中构建自定义词汇表,以便训练自己的 NLP 模型,并学习如何训练 SeqGAN,使其能够生成简短的英语笑话。你还将学习如何使用 SEGAN 去除背景噪音并增强语音音频的质量。

本章将涵盖以下主题:

  • 通过 SeqGAN 进行文本生成 – 教授 GAN 如何讲笑话

  • 使用 SEGAN 进行语音质量增强

通过 SeqGAN 进行文本生成 – 教授 GAN 如何讲笑话

在上一章中,我们学习了如何通过 GAN 根据描述文本生成高质量图像。现在,我们将继续研究如何使用各种 GAN 模型进行序列数据合成,如文本和音频。

在文本生成方面,与图像生成的最大区别在于,文本数据是离散的,而图像像素值则更为连续,尽管数字图像和文本本质上都是离散的。一个像素通常有 256 个值,而像素的微小变化通常不会影响我们对图像的理解。然而,句子中的微小变化——即使是一个字母(例如,将we改成he)——也可能改变整个句子的意思。而且,我们通常对合成图像的容忍度要高于文本。例如,如果生成的狗的图像中有 90%的像素几乎完美无缺,我们通常能轻松识别出狗,因为我们的大脑足够聪明,能自动填补缺失的像素。然而,如果你阅读的新闻中每 10 个单词中就有一个不合逻辑,你肯定会觉得很难享受阅读。这就是为什么文本生成很困难,而且相较于图像合成,文本生成的进展较慢的原因。

SeqGAN 是首批成功尝试使用对抗学习生成文本的模型之一。它由 Lantao Yu、Weinan Zhang、Jun Wang 等人在他们的论文《SeqGAN: Sequence Generative Adversarial Nets with Policy Gradient》中提出。在这一节中,我们将带你了解 SeqGAN 的设计,如何为 NLP 任务创建自己的词汇表,以及如何训练 SeqGAN,使其能够生成简短的笑话。

SeqGAN 的设计 – GAN、LSTM 和 RL

与其他 GAN 模型类似,SeqGAN 基于对抗学习的思想构建。为了使其适应 NLP 任务,需要进行一些重大更改。例如,生成网络是用 LSTM 而不是 CNNs 构建的,类似于我们在前几章中看到的一些其他 GAN。此外,强化学习被用来优化离散目标,这与之前 GAN 模型中使用的 SGD 系列方法不同。

在这里,我们将简要介绍 LSTM 和 RL。但由于我们希望专注于模型的对抗学习部分,因此不会深入探讨这些话题。

快速介绍 RNN 和 LSTM

递归神经网络RNNs)被设计用来处理顺序数据,如文本和音频。它们与 CNNs 的最大区别在于,隐藏层中的权重(即某些函数)在多个输入上反复使用,并且输入的顺序会影响函数的最终结果。RNN 的典型设计可以在下图中看到:

图 10.1 递归神经网络的基本计算单元

正如我们所看到的,RNN 单元最显著的特点是隐藏状态 有一个指向自身的输出连接。这种自环是“递归”一词的来源。假设自环执行了三次,扩展版的计算单元如右图所示。计算过程如下:

因此,在适当训练后,这个 RNN 单元能够处理最大长度为 3 的顺序数据。

RNNs 广泛应用于语音识别、自然语言翻译、语言建模和图像标注。然而,RNN 仍然存在一个关键缺陷,我们需要通过 LSTM 来解决。

RNN 模型假设只有相邻的输入之间存在强连接(例如,,如上图所示),并且输入之间的远距离连接被忽略(例如,)。当我们试图将一个长句子翻译成具有完全不同语法规则的另一种语言时,就会变得麻烦,这时我们需要浏览句子的所有部分才能理解其含义。

LSTM长短期记忆)是由 Sepp Hochreiter 和 Jürgen Schmidhuber 于 1997 年提出的,用于保留顺序数据的长期记忆并解决 RNN 中的梯度爆炸和梯度消失问题。其计算过程在下图中展示:

图 10.2 LSTM 的计算过程

如我们所见,一个附加项 被引入,以帮助我们选择应该记住的长期信息。详细的计算过程如下:

  1. 被传递通过忘记门,用来决定应该忘记哪些信息:

  1. 相同的输入也通过输入门,以便我们在下一步计算更新后的

  1. 更新后的 通过输出门计算得出:

然后,新的 被用来计算下一对 。虽然 LSTM 单元的结构比普通的 RNN 单元复杂得多,但由于三大门(忘记门、输入门和输出门)的精妙设计,LSTM 可以在过去几年几乎所有的里程碑式 NLP 模型中看到。如果你想深入了解 LSTM 及其变体,可以查看 colah.github.io/posts/2015-08-Understanding-LSTMstowardsdatascience.com/illustrated-guide-to-lstms-and-gru-s-a-step-by-step-explanation-44e9eb85bf21

强化学习与监督学习

强化学习 是机器学习中的另一种优化方法。它通常用于模型试图解决的任务很难提供标准的正确答案,特别是当解决方案涉及 自由探索 并且任务的最终目标相比模型需要做出的具体决策更为 模糊 时。

例如,如果我们想教一个机器人走路,我们可以使用强化学习让机器人自己学会走路。我们不需要告诉机器人在什么时间如何移动哪个身体部位。我们只告诉它,最终目标是把自己带到前方 10 米的那个位置,然后让它随机地移动四肢。某个时刻,机器人的腿部某种组合的动作会让机器人前进一步,而机器人手臂的某种动作组合则确保它不会失去平衡。同样,强化学习也被用来教机器玩围棋(www.alphago-games.com)和视频游戏(openai.com/blog/openai-five)。

基于 SGD 的优化方法通常用于监督学习(它们曾用于前几章的模型,在这些模型中总是使用真实数据来衡量合成数据的质量),而在无监督学习中,优化策略则完全不同。

目前,策略梯度(Policy Gradients)和 Q 学习(Q-Learning)是强化学习(RL)中最常用的两种方法。我们简要解释一下它们:

  1. 策略梯度(Policy Gradient)是一种基于策略的方法。模型直接根据当前状态(输入)给出动作(输出)。它在评估策略(基于状态采取行动)和更新策略(更新状态和动作之间的映射)之间交替进行。它通常用于大的连续动作空间。

  2. Q 学习(Q-Learning)是一种基于价值的方法。它维护一个 Q 表,记录各种动作的奖励。它选择导致最大奖励值的动作,然后根据该动作带来的新环境更新 Q 表。与策略梯度方法相比,它的训练速度较快,通常用于简单任务和小的动作空间。

那么,当强化学习和监督学习(如 CNN 中的 SGD 方法)都可用时,我们该如何选择呢?一个简单的经验法则是搜索空间的连续性和目标函数的可微性。如果目标函数是可微的,并且搜索空间是连续的,那么最好使用 SGD 方法。如果搜索空间是离散的,或者目标函数是不可微的,我们需要坚持使用强化学习。然而,如果搜索空间不是特别大,并且你有多余的计算能力,那么进化搜索(Evolutionary Search,ES)方法也是一个不错的选择。当你的变量假定服从高斯分布时,你可以尝试 CMA-ES 方法(cma.gforge.inria.fr)。

如果你想深入了解策略梯度,这里有两篇额外的阅读材料:

SeqGAN 的架构

SeqGAN 的核心思想是让它解决原始 GAN 无法解决的问题,因为原始 GAN 擅长合成离散数据,而判别器网络无法处理具有不同长度的序列数据。为了解决第一个问题,使用了策略梯度方法来更新生成器网络。第二个问题则通过 蒙特卡洛树搜索MCTS)方法生成剩余数据来解决。

SeqGAN 中的强化学习策略设计如下。假设在时刻 ,生成的序列表示为 ,当前的动作  需要由生成器网络  给出,其中  为初始状态。基于  生成  是通过 LSTM(或其变体)完成的。生成器的目标是最大化累积奖励:

在这里,  为累积奖励,  为待优化的参数(即  中的参数),  被称为 动作价值函数。动作价值函数  给出了通过遵循策略  从初始状态  开始采取动作  时的奖励。

通常,我们期望使用判别器网络来给出奖励值。然而,判别器不能直接用于计算累积奖励,因为它只能评估完整的序列 。在时刻 ,我们只有 。那么,我们如何获得剩余的序列呢?

在 SeqGAN 中,剩余的序列  是通过 MCTS 方法生成的。MCTS 是一种基于树的搜索方法,广泛应用于象棋和扑克程序以及视频游戏 AI 算法中。所有可以执行的动作都由树中非常大的节点表示。要在蒙特卡洛树中完成一次完整的搜索,需经过以下四个步骤:

  1. 选择,即从根节点到叶节点选择一条路径。通常,现有节点的选择是基于上置信界限UCB)。得分较高的节点更有可能被选择,而那些之前没有被选择过很多次的节点更有可能被选中。这是探索与利用之间的平衡。

  2. 扩展,即在选定的叶节点上添加新的子节点。

  3. 模拟,即评估新添加的节点并获得最终结果(奖励)。

  4. 反向传播,即更新所选路径上所有节点的得分和计数统计。

实际上,只有第三步——模拟,才是用来生成剩余序列的,它通过多次执行模拟(生成剩余序列,使用 )来生成并获得平均奖励。

因此, 的定义如下:

生成器网络是一个 LSTM 网络,输入层是一个嵌入层,输出层是一个线性层。判别器网络由一个嵌入层、一个卷积层、一个最大池化层和一个 softmax 层组成。本文作者发布的代码是为 TensorFlow 编写的。幸运的是,在 GitHub 上可以找到 PyTorch 版本,链接为 github.com/suragnair/seqGAN。在这个版本中,有两个需要注意的区别:首先,蒙特卡洛模拟只执行一次;其次,判别器网络也是一个递归网络,并且在两个网络中使用了一种叫做 门控递归单元GRU)的 LSTM 变体。您可以自由地调整网络架构,尝试我们在本书前几章中学到的技巧和方法。我们修改后的代码也可以在本章的代码库中的 seqgan 文件夹下找到。

创建您自己的词汇表用于训练

阅读别人写的 GitHub 上的代码是很容易的。我们需要做的最重要的事情是将我们已知的模型应用到新的应用中,并创建我们自己的样本。在这里,我们将通过一些基本步骤来创建一个从大量文本中提取的词汇表,并用它来训练我们的 NLP 模型。

在 NLP 模型中,词汇集通常是一个表,将每个单词或符号映射到一个唯一的标记(通常是 int 值),这样任何句子都可以通过一个 int 向量来表示。

首先,让我们找一些数据来处理。为了开始,以下是 GitHub 上可用的 NLP 数据集列表:github.com/niderhoff/nlp-datasets。在这个列表中,你会找到一个包含英语笑话数据集(github.com/taivop/joke-dataset),该数据集包含超过 200,000 条从 Reddit(www.reddit.com/r/jokes)、Stupid Stuff(stupidstuff.org)和 Wocka(wocka.com)提取的笑话。笑话文本会分布在三个不同的文件中(reddit_jokes.jsonstupidstuff.jsonwocka.json)。请注意,我们对这些笑话的内容不负任何责任!

现在,让我们创建我们的词汇表。首先,在项目的代码文件夹中创建一个名为data的文件夹,并将之前提到的文件复制到其中。

现在,让我们创建一个小程序,以便我们能解析 JSON 文件并将其转换为 CSV 格式。我们称之为parse_jokes.py

import sys
import platform
import os
import json
import csv
import re

datapath = './data'
redditfile = 'reddit_jokes.json'
stupidfile = 'stupidstuff.json'
wockafile = 'wocka.json'
outfile = 'jokes.csv'
headers = ['row', 'Joke', 'Title', 'Body', 'ID',
           'Score', 'Category', 'Other', 'Source']

我相信导入部分的条目是显而易见的。常量的定义也应该相当清晰。headers变量只是我们在创建 CSV 文件时使用的列名列表。

我们希望将所有的笑话存储为纯文本格式。为此,首先去除所有非字母符号。这个操作是通过使用clean_str()来清理文本完成的,clean_str()使用了 Python 的str_translate参数,具体如下所示:

def clean_str(text):
    fileters = '"#$%&()*+-/;<=>@[\\]^_`{|}~\t\n\r\"'
    trans_map = str.maketrans(fileters, " " * len(fileters))
    text = text.translate(trans_map)
    re.sub(r'[^a-zA-Z,. ]+', '', text)
    return text

随意调整filters字符串,以便可以添加或删除任何特殊字符。下一个函数将读取我们三个 JSON 文件中的一个,并将其返回为 JSON 对象。我将其做得相当通用,因此它只需要知道要处理的文件名:

def get_data(fn):
    with open(fn, 'r') as f:
        extracted = json.load(f)
    return extracted

接下来,我们将创建三个函数,用来处理将这三个 JSON 对象转换为 CSV 文件。需要注意的是,三个 JSON 文件的结构都不同。因此,我们会让这三个处理函数大致相似,并在处理它们之间的差异时同时进行调整。每个函数都会接收由get_data函数创建的 JSON 对象以及一个名为startcount的整数值。这个值为 CSV 文件提供行号,并会在每一行中递增。然后,我们会将每一条数据转换为字典,并写入 CSV 文件。最后,我们会返回计数器,以便下一个函数知道行号应该是多少。这是处理 Reddit 文件的函数:

def handle_reddit(rawdata, startcount):
    global writer
    print(f'Reddit file has {len(rawdata)} items...')
    cntr = startcount
    with open(outfile, mode='a') as csv_file:
        writer = csv.DictWriter(csv_file, fieldnames=headers)
        for d in rawdata:
            title = clean_str(d['title'])
            body = clean_str(d['body'])
            id = d['id']
            score = d['score']
            category = ''
            other = ''
            dict = {}
            dict['row'] = cntr
            dict['Joke'] = title + ' ' + body
            dict['Title'] = title
            dict['Body'] = body
            dict['ID'] = id
            dict['Category'] = category
            dict['Score'] = score
            dict['Other'] = other
            dict['Source'] = 'Reddit'
            writer.writerow(dict)
            cntr += 1
            if cntr % 10000 == 0:
                print(cntr)
    return cntr

接下来,我们有另外两个函数:一个用于处理StupidStuff文件,另一个用于处理Wocka文件:

def handle_stupidstuff(rawdata, startcount):
    global writer
    print(f'StupidStuff file has {len(rawdata)} items...')
    with open(outfile, mode='a') as csv_file:
        writer = csv.DictWriter(csv_file, fieldnames=headers)
        cntr = startcount
        for d in rawdata:
            body = clean_str(d['body'])
            id = d['id']
            score = d['rating']
            category = d['category']
            other = ''
            dict = {}
            dict['row'] = cntr
            dict['Joke'] = body
            dict['Title'] = ''
            dict['Body'] = body
            dict['ID'] = id
            dict['Category'] = category
            dict['Score'] = score
            dict['Other'] = other
            dict['Source'] = 'StupidStuff'
            writer.writerow(dict)
            cntr += 1
            if cntr % 1000 == 0:
                print(cntr)
    return cntr

def handle_wocka(rawdata, startcount):
    global writer
    print(f'Wocka file has {len(rawdata)} items...')
    with open(outfile, mode='a') as csv_file:
        writer = csv.DictWriter(csv_file, fieldnames=headers)
        cntr = startcount
        for d in rawdata:
            other = clean_str(d['title'])
            title = ''
            body = clean_str(d['body'])
            id = d['id']
            category = d['category']
            score = ''
            other = ''
            dict = {}
            dict['row'] = cntr
            dict['Joke'] = body
            dict['Title'] = title
            dict['Body'] = body
            dict['ID'] = id
            dict['Category'] = category
            dict['Score'] = score
            dict['Other'] = other
            dict['Source'] = 'Wocka'
            writer.writerow(dict)
            cntr += 1
            if cntr % 1000 == 0:
                print(cntr)
    return cntr

倒数第二个函数将创建实际的 CSV 文件并写入表头:

def prep_CVS():
    global writer
    with open(outfile, mode='a') as csv_file:
        writer = csv.DictWriter(csv_file, fieldnames=headers)
        writer.writeheader()

最后,我们有程序的主函数和入口点。在这里,我们可以按任何顺序调用之前的函数:

def main():
    pv = platform.python_version()
    print(f"Running under Python {pv}")
    path1 = os.getcwd()
    print(path1)
    prep_CVS()
    print('Dealing with Reddit file')
    extracted = get_data(datapath + "/" + redditfile)
    count = handle_reddit(extracted, 0)
    print('Dealing with StupidStuff file')
    extracted = get_data(datapath + "/" + stupidfile)
    count = handle_stupidstuff(extracted, count)
    print('Dealing with Wocka file')
    extracted = get_data(datapath + "/" + wockafile)
    count = handle_wocka(extracted, count)
    print(f'Finished processing! Total items processed: {count}')

if __name__ == '__main__':
    main()

现在,我们要做的就是运行脚本:

$ python parse_jokes.py

完成后,笑话文本将存储在jokes.csv文件中。现在,我们需要使用 TorchText 来构建词汇表。TorchText(github.com/pytorch/text)是一个直接与 PyTorch 配合使用的 NLP 数据加载工具。

Windows 10 用户注意

在写这本书时,torchtext\utils.py似乎存在一个问题。如果你直接从 PyPi 安装torchtext包,你可能会在执行某些代码时遇到错误。

最好的解决方法是访问 GitHub 源代码仓库(github.com/pytorch/text),并下载源代码。然后,将代码解压到一个安全的文件夹中。在命令提示符中,导航到包含源代码的文件夹,并输入以下命令安装库:

pip install -e .

这将直接从源代码安装 torchtext。

对于其他操作系统,你可以使用以下命令安装:

$ pip install torchtext

请确保你已安装torchtext的最新版本(本书撰写时为 0.4.0);否则,我们稍后使用的代码可能无法正常工作。如果pip无法为你安装最新版本,你可以在pypi.org/project/torchtext/#files找到whl文件,并手动安装。

我们将使用torchtext提供的默认词汇工具来实现这一点。如果你希望为更复杂的 NLP 任务构建词汇表,也可以尝试使用spaCyspacy.io)。创建一个新文件并命名为mymain.py。首先在其中添加以下代码:

import torchtext as tt
import numpy as np
import torch
from datetime import datetime

VOCAB_SIZE = 5000
MAX_SEQ_LEN = 30
BATCH_SIZE = 32

src = tt.data.Field(tokenize=tt.data.utils.get_tokenizer("basic_english"),
                    fix_length=MAX_SEQ_LEN,
                    lower=True)

datafields = [('row', None),
              ('Joke', src),
              ('Title', None),
              ('Body', None),
              ('ID', None),
              ('Score', None),
              ('Category', None),
              ('Other', None),
              ('Source', None)]

datafields结构描述了我们刚刚创建的 CSV 文件。文件中的每一列都会被描述,而我们希望torchtext库关注的唯一列是'Joke'列,因此我们将其标记为'src',其他所有列标记为'None'

现在,我们将创建数据集对象并开始构建词汇表对象:

dataset = tt.data.TabularDataset(path='jokes.csv', format='csv',
                                 fields=[('id', None), 
                                         ('text', src)])

src.build_vocab(dataset, max_size=VOCAB_SIZE)

我们将使用torchtext库中的BucketIterator来遍历数据集中的数据,并创建相同长度的序列:

src_itr = tt.data.BucketIterator(dataset=dataset,
                                 batch_size=BATCH_SIZE,
                                 sort_key=lambda x: len(x.text),
                                 device=torch.device("cuda:0"))

现在我们已经构建了词汇表,接下来我们需要构建一个小型数据加载器,在训练过程中将批量数据输入到 SeqGAN 中:

class BatchLoader:
    def __init__(self, dl, x_field):
        self.dl, self.x_field = dl, x_field

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

    def __iter__(self):
        for batch in self.dl:
            x = getattr(batch, self.x_field)
            yield x.t()

train_loader = BatchLoader(src_itr, 'text')

我们还需要一个从标记到单词的映射,以便在训练过程完成后看到生成的文本:

vocab_max = 0
for i, batch in enumerate(train_loader):
    _max = torch.max(batch)
    if _max > vocab_max:
        vocab_max = _max

VOCAB_SIZE = vocab_max.item() + 1

inv_vocab = {v: k for k, v in src.vocab.stoi.items()}

这里,我们的词汇表存储在src.vocab中。src.vocab.stoi是一个 Python 的defaultdict,它将单词映射到int值。在前面的代码片段中的最后一行,字典被反转,并将从int值到单词的映射存储在inv_vocab中。

你可以通过以下代码测试词汇表:

sentence = ['a', 'man', 'walks', 'into', 'a', 'bar']
for w in sentence:
    v = src.vocab[w]
    print(v)
    print(inv_vocab[v])

如果你有兴趣,可以通过在前面代码后添加以下代码来查看inv_vocab的内容:

for i in inv_vocab:
    print(f'Counter: {i} inv_vocab: {inv_vocab[i]}')

但是,记住大约会打印出 5000 行,所以它将是一个很长的列表:

$ python mymain.py

现在,我们需要处理 SeqGAN 程序的其余部分。这包括生成器和判别器。正如我们在SeqGAN 架构部分提到的,这些模块可以在github.com/suragnair/seqGAN找到。下载源代码并将其解压到工作目录中的一个文件夹里。

要训练 SeqGAN,请在代码文件夹下运行以下脚本:

$ python main.py

生成器网络通过最大似然估计MLE)与真实数据进行预训练,训练 100 个周期,以便它在后续训练中更快。然后,判别器网络对真实数据和一些生成的数据进行 150 个周期的预训练,其中生成的数据每三个周期保持相同,以便判别器熟悉假数据。最后,两个网络以对抗方式共同训练 50 个周期,在这个过程中,判别器网络的训练次数是生成器网络的 15 倍。在一张 GTX 1080Ti 显卡上,预训练过程大约需要33 小时,最终训练的 17 个周期可能需要48 小时才能完成。GPU 内存消耗大约是 4,143 MB。

以下是一些由 SeqGAN 生成的笑话。不幸的是,由于模式崩溃(这意味着在一个批次的句子中,相同的随机单词会出现在任何地方),大多数句子都没有意义。

现在,让我们看一下。请注意,短于MAX_SEQ_LEN的句子会在末尾填充<pad>,并在此处省略:

  • "你有没有像番茄一样讲笑话?。他们会把蔬菜叫出来!"

  • "爱国者队没有被邀请去露营!因为我宁愿出生在帐篷里。"

  • "学员们。它是为圣诞口袋准备的火车"

  • "当你把袋鼠和犀牛混在一起会得到什么?。西班牙语"

以下句子是由模型生成的:

  • "我忍不住笑话。。。这都是。。。"

  • "我看不见一个新笑话。"

我们的模型还生成了一些过于不合适的笑话,无法发布,这也有趣地展示了它模仿人类幽默的尝试!

使用 SEGAN 进行语音质量增强

在第七章,使用 GAN 进行图像恢复中,我们探讨了 GAN 如何恢复图像中的一些像素。研究人员在自然语言处理领域发现了类似的应用,GAN 可以训练去除音频中的噪声,从而增强录音演讲的质量。在本节中,我们将学习如何使用 SEGAN 减少音频中的背景噪声,并使嘈杂音频中的人声更加清晰可听。

SEGAN 架构

语音增强 GAN (SEGAN) 是由 Santiago Pascual、Antonio Bonafonte 和 Joan Serrà 在他们的论文《SEGAN: 语音增强生成对抗网络》中提出的。它使用一维卷积成功去除语音音频中的噪声。你可以在这里查看与其他方法相比的噪声去除结果:veu.talp.cat/segan。此外,还有一个升级版,可以在veu.talp.cat/seganp找到。

图像是二维的,而声音是一维的。考虑到 GAN 在合成二维图像方面非常出色,因此显而易见的是,为了利用 GAN 在音频数据合成方面的优势,应考虑使用一维卷积层而非二维卷积层。这正是 SEGAN 的构建方式。

SEGAN 中的生成器网络采用带有跳跃连接的编码器-解码器架构,你可能已经对这种架构比较熟悉,因为我们之前遇到过使用类似架构的其他 GAN(如 pix2pixHD)。生成器网络的架构如下所示:

图 10.3 SEGAN 中生成器网络的架构

首先,音频样本被裁剪为固定长度 16,384,并通过五个一维卷积层进行处理,卷积核大小为 31,步幅大小为 4。压缩后的 1,024 x 16 向量(忽略批次通道)与潜在向量(大小为 1,024 x 16)连接在一起,以便通过另外五个反卷积层进行处理。镜像卷积层和反卷积层中形状相同的特征图通过跳跃连接连接在一起。这是因为噪声音频和干净音频的基本结构非常相似,跳跃连接可以帮助生成器更快地重构增强后的音频结构。最后,生成一个长度为 16,384 的去噪音频样本。

然而,SEGAN 的判别器网络是一个单一的编码器网络,因为我们从判别器中需要的只是输入音频的保真度得分。判别器网络的架构如下所示:

图 10.4 SEGAN 中判别器网络的架构

噪声音频和干净(真实数据或合成数据)音频被连接在一起,形成一个 2 x 16,384 的张量,然后通过五个卷积层和三个全连接层得到最终输出,指示干净音频是来自真实数据还是合成数据。在两个网络中,参数化 ReLU (PReLU) 被用作隐藏层的激活函数。

训练 SEGAN 提升语音质量

训练 SEGAN 与训练普通的图像合成 GAN 并没有太大区别。SEGAN 的训练过程如下:

图 10.5 SEGAN 的训练过程。每个阶段更新的网络用红色边框标出。这里,c*表示真实的清晰音频,n表示噪声音频,c表示合成的清晰音频。

首先,将来自训练数据的清晰音频和噪声音频输入到判别网络中以计算 MSE 损失。由生成器生成的合成音频以及噪声音频也会输入到判别网络。在这一阶段,判别网络将被训练得更好,以便区分真实音频和合成的清晰音频。然后,生成的音频被用来欺骗判别器(通过最小化 MSE 损失以接近 1),以使得我们的生成器网络在合成真实清晰音频方面表现得更好。同时,计算合成音频(c*)和真实音频之间的 L1 损失(乘以 100 的缩放因子),以强制这两者具有相似的基本结构。使用 RMSprop 作为优化方法,并将学习率设置为非常小的值(例如,)。

现在,让我们获取一些音频数据,看看 SEGAN 能做什么。这里有一个配对的清晰-噪声音频数据集:datashare.is.ed.ac.uk/handle/10283/1942。我们需要下载清晰和噪声的 48 kHz 语音训练集。clean数据集大小约为 822 MB,而noisy数据集大小约为 913 MB。两个数据集内共有 11,572 段语音,大多数是人类说的单句英语。noisy音频是由几个人同时说话造成的噪音污染。

SEGAN 的 PyTorch 源代码已由论文的作者提供:github.com/santi-pdp/segan_pytorch。按照以下步骤准备代码并开始训练 SEGAN:

  1. 运行以下脚本以获取代码并安装所需的依赖:
$ git clone https://github.com/santi-pdp/segan_pytorch.git
$ pip install soundfile scipy librosa h5py numba matplotlib pyfftw tensorboardX
  1. 还需要一个额外的工具ahoproc_toolsgithub.com/santi-pdp/ahoproc_tools)。我们需要下载ahoproc_tools的源代码,并将其中的ahoproc_tools复制到segan_pytorch的根文件夹中。或者,你也可以直接访问本章代码库中的完整源代码。你需要运行以下脚本以确保所有子模块已被下载:
$ git submodule update --init --recursive
  1. 从下载的.zip数据集文件中提取.wav文件,并将它们分别移动到data/clean_trainset_wavdata/noisy_trainset_wav文件夹中。

  2. 最后,运行以下脚本以开始训练过程:

$ python train.py --save_path ckpt_segan+ --batch_size 300 --clean_trainset data/clean_trainset_wav --noisy_trainset data/noisy_trainset_wav --cache_dir data/cache

首先,训练脚本将创建一个缓存文件夹(data/cache),并将音频文件的切片结果临时存储在其中(因为我们希望两个网络的输入长度为 16,384)。

在批量大小为 300 时,使用一块 GTX 1080Ti 显卡完成 100 个 epoch 的训练大约需要 10.7 小时,并消耗约 10,137 MB 的 GPU 内存。

训练过程完成后,运行以下脚本测试训练好的模型,并从放入 data/noisy_testset 文件夹中的任何音频文件中去除背景噪声:

$ python clean.py --g_pretrained_ckpt ckpt_segan+/weights_EOE_G-Generator-16301.ckpt --cfg_file ckpt_segan+/train.opts --synthesis_path enhanced_results --test_files data/noisy_testset --soundfile

总结

在这一章中,我们学习了如何使用 SeqGAN 生成纯文本,并使用 SEGAN 去除语音音频中的背景噪声。我们还尝试了如何从一组句子中构建自定义词汇表,以应对 NLP 任务。

在下一章中,我们将学习如何训练 GANs,以便直接生成 3D 模型。

进一步阅读

  1. Yu L, Zhang W, Wang J. (2017). SeqGAN:带策略梯度的序列生成对抗网络. AAAI。

  2. Hochreiter S 和 Schmidhuber J. (1997). 长短期记忆。神经计算. 9. 1735-80. 10.1162/neco.1997.9.8.1735。

  3. Olah C. (2015 年 8 月 27 日). 理解 LSTM 网络. 取自 colah.github.io/posts/2015-08-Understanding-LSTMs

  4. Nguyen M. (2018 年 9 月 25 日). LSTMs 和 GRUs 插图指南:一步一步的解释. 取自 towardsdatascience.com/illustrated-guide-to-lstms-and-gru-s-a-step-by-step-explanation-44e9eb85bf21

  5. Hui J. (2018 年 9 月 12 日). RL - 策略梯度解释. 取自 medium.com/@jonathan_hui/rl-policy-gradients-explained-9b13b688b146

  6. Weng L. (2018 年 4 月 8 日). 策略梯度算法. 取自 lilianweng.github.io/lil-log/2018/04/08/policy-gradient-algorithms.html

  7. Pascual S, Bonafonte A 和 Serrà J. (2017). SEGAN:语音增强生成对抗网络. INTERSPEECH。

第十二章:使用 GAN 重建 3D 模型

到目前为止,我们已经学习了如何使用 GAN 合成图像、文本和音频。现在,是时候探索 3D 世界并学习如何使用 GAN 创建令人信服的 3D 模型了。

在本章中,你将学习 3D 物体在计算机图形学CG)中的表示方法。我们还将深入了解 CG 的基本概念,包括相机和投影矩阵。在本章结束时,你将学会如何创建和训练 3D_GAN,以生成 3D 物体的点云,如椅子。

你将了解 3D 物体表示的基本知识和 3D 卷积的基本概念。然后,你将学习如何通过 3D 卷积构建 3D-GAN 模型,并训练它生成 3D 物体。你还将熟悉 PrGAN,这是一种基于物体的黑白 2D 视图生成 3D 物体的模型。

本章将涵盖以下主题:

  • 计算机图形学的基本概念

  • 设计用于 3D 数据合成的 GAN

计算机图形学的基本概念

在前面的章节中,我们学习了关于图像、文本和音频的各种 GAN 模型。通常,我们一直在处理 1D 和 2D 数据。在本章中,我们将通过研究 3D 领域,扩展我们对 GAN 世界的理解。在本章结束时,你将学会如何使用 GAN 创建自己的 3D 物体。

3D 物体的表示

在我们深入了解用于 3D 数据合成的 GAN 模型细节之前,必须理解 3D 物体在计算机中的表示方法。3D 物体、环境和动画的创建与渲染被称为计算机图形学CG),这是视频游戏和电影两个主要娱乐产业所依赖的领域。CG 中最重要的任务是如何高效地渲染出最逼真的图像。得益于 CG 领域从业者的辛勤工作,我们现在在视频游戏和电影中获得了更好的视觉效果。

3D 物体的属性

一个 3D 物体最基本的属性是其形状和颜色。我们在屏幕上看到的每个像素的颜色受多种因素的影响,例如其纹理本身的颜色、光源,甚至场景中的其他物体。这也受到光源和我们视角与像素自身表面相对方向的影响,这些方向由物体的形状、位置、朝向以及相机的位置决定。至于形状,3D 模型基本由点、线和面组成。下面的图像展示了如何创建一个 3D 跑车的形状和颜色:

在 Autodesk Maya 中创建一辆跑车,展示了线条如何形成表面,以及纹理如何在 3D 模型中提供颜色

表面,无论是平坦的还是曲面的,大多由三角形和四边形(通常称为多边形)组成。多边形网格(也称为线框)由一组 3D 点和连接这些点的一组段定义。通常情况下,多边形越多,3D 模型中的细节就越多。这可以从以下图像中看出:

在 3D 模型中,多边形数量越多,细节就越丰富。由 Autodesk Maya 捕获的图像。

有时,一组点(在某些应用程序中称为点云)就足以创建 3D 对象,因为有几种广泛使用的方法可以自动创建段以生成多边形网格(例如 Delaunay 三角剖分方法)。点云通常用于表示 3D 扫描仪收集的结果。点云是一组三维向量,表示每个点的空间坐标。在本章中,我们只关注使用 GAN 生成特定对象的点云。以下图像展示了几个椅子的点云示例:

椅子的点云

摄像机和投影

一旦定义了 3D 对象的形状和颜色,仍然有一个重要因素会影响它们在屏幕上的显示:摄像机。摄像机负责将 3D 点、线和表面映射到通常是我们的屏幕上的 2D 图像平面。如果摄像机配置不正确,我们可能根本看不到我们的对象。从 3D 世界映射到 2D 图像平面的过程称为投影

在 CG 领域中有两种常用的投影方法:正交投影和透视投影。现在让我们来了解它们:

在正交投影中,3D 空间中所有平行线在 2D 平面中仍然是平行的,只是长度和方向不同。更重要的是,同一对象的投影图像尺寸始终相同,无论它离摄像机有多远。然而,这并不是我们的眼睛和大多数摄像机捕捉 3D 世界图像的方式。因此,正交投影主要用于计算机辅助设计CAD)和其他工程应用中,需要正确呈现组件的实际大小。

  • 透视投影是一种将截头体中的所有物体(即去掉顶部的金字塔)映射到标准立方体的过程,如上图所示。在透视投影中,离摄像机近的物体看起来比远离摄像机的物体大。因此,3D 空间中的平行线在 2D 空间中不一定平行。这也是我们眼睛感知周围环境的方式。因此,这种投影方式能给我们更逼真的图像,常用于视频游戏和电影中的视觉效果渲染。

正射投影和透视投影在一些计算机图形软件中是一起使用的,例如 Autodesk Maya,如下图所示:

在 Autodesk Maya 的用户界面中,使用正射投影来显示顶部、侧面和正面视图(左上、左下和右下),而使用透视投影来预览 3D 模型(右上)。图片来自 https://knowledge.autodesk.com/support/maya/learn-explore/caas/simplecontent/content/maya-tutorials.html

本章我们将重点讲解透视投影。在计算机图形学中,经常使用齐次坐标,它可以方便地表示无限距离,并通过简单的矩阵乘法实现平移、缩放和旋转。对于一组齐次坐标 ,其对应的笛卡尔坐标系为 。从 3D 空间的截头体到  立方体的映射由投影矩阵定义:

在投影矩阵中,  是近裁剪平面,  是远裁剪平面。此外, 、 、  和  分别表示近裁剪平面的顶部、底部、左侧和右侧边界。投影矩阵与齐次坐标的相乘给我们提供了投影点应该落在的对应坐标。如果你对投影矩阵的推导感兴趣,可以参考以下文章:www.songho.ca/opengl/gl_projectionmatrix.html

设计用于 3D 数据合成的生成对抗网络(GAN)

3D-GAN 是由 Jiajun Wu、Chengkai Zhang、Tianfan Xue 等人在他们的论文Learning a Probabilistic Latent Space of Object Shapes via 3D Generative-Adversarial Modeling中提出的,旨在生成特定类型物体的 3D 点云。3D-GAN 的设计和训练过程与传统的 GAN 非常相似,不同之处在于 3D-GAN 的输入和输出张量是五维的,而不是四维的。

3D-GAN 中的生成器和判别器

3D-GAN 的生成器网络架构如下:

3D-GAN 中生成器网络的架构

生成器网络由五个反卷积层(nn.ConvTranspose3d)组成,其中前四个层后跟 Batch Normalization 层(nn.BatchNorm3d)和 ReLU 激活函数,最后一层后跟 Sigmoid 激活函数。在所有反卷积层中,卷积核大小、步长和填充大小分别设置为 4、2 和 1。在这里,输入的潜在向量可以逐步扩展到一个!立方体,这可以视为一个 1 通道的 3D“图像”。在这个 3D 图像中,“像素”值实际上表示某个点在这些!网格位置上是否存在的可能性。通常,我们会保留所有值高于 0.5 的点来形成最终的点云。

在我们的案例中,3D 图像中的“像素”实际上被称为体素

由于我们点云中的点位于!立方体的网格点上。每个体素有四个属性:x、y 和 z 坐标,以及该体素是否存在于(x, y, z)位置。与 2D 图像合成任务(如 MNIST)不同,在这些任务中像素的值可以在 0 到 1 之间(或者,如果你喜欢的话,也可以在 0 到 255 之间,例如数字边缘的像素),体素的存在是一个二元决策。因此,我们的点云张量实际上是稀疏的,包含许多零和少数的 1。

在本节中,我们将提供 3D-GAN 的完整源代码。代码文件已按照与前几章相同的方式进行组织。网络已在model_3dgan.py文件中定义(请确保模块名称不要以数字开头)。

以下代码是Generator的定义:

import torch
import torch.nn as nn
import torch.nn.functional as F
import numpy as np

class Generator(nn.Module):
    def __init__(self, latent_dim, cube_len, bias=False):
        super(Generator, self).__init__()
        self.latent_dim = latent_dim
        self.cube_len = cube_len

        self.model = nn.Sequential(
            *self._create_layer(self.latent_dim, self.cube_len*8, 4, stride=2, padding=1, bias=bias, transposed=True),
            *self._create_layer(self.cube_len*8, self.cube_len*4, 4, stride=2, padding=1, bias=bias, transposed=True),
            *self._create_layer(self.cube_len*4, self.cube_len*2, 4, stride=2, padding=1, bias=bias, transposed=True),
            *self._create_layer(self.cube_len*2, self.cube_len, 4, stride=2, padding=1, bias=bias, transposed=True),
            *self._create_layer(self.cube_len, 1, 4, stride=2, padding=1, bias=bias, transposed=True, last_layer=True)
        )

    def _create_layer(self, size_in, size_out, kernel_size=4, stride=2, padding=1, bias=False, transposed=True, last_layer=False):
        layers = []
        if transposed:
            layers.append(nn.ConvTranspose3d(size_in, size_out, kernel_size, stride=stride, padding=padding, bias=bias))
        else:
            layers.append(nn.Conv3d(size_in, size_out, kernel_size, stride=stride, padding=padding, bias=bias))
        if last_layer:
            layers.append(nn.Sigmoid())
        else:
            layers.append(nn.BatchNorm3d(size_out))
            layers.append(nn.ReLU(inplace=True))
        return layers

    def forward(self, x):
        x = x.view(-1, self.latent_dim, 1, 1, 1)
        return self.model(x)

3D-GAN 的判别器网络架构如下:

3D-GAN 中判别器网络的架构

判别网络由五个卷积层(nn.Conv3d)组成,其中前四个卷积层后跟一个批归一化层和一个 Leaky-ReLU(nn.LeakyReLU)激活函数,最后一层后跟一个 Sigmoid 激活函数。所有卷积层的卷积核大小、步幅和填充大小分别设置为 4、2 和 1。判别网络将 3D 点云的 立方体映射为一个单一的值,用于指定输入对象的可信度是否为真实。

想象一下,如果点云的维度设置为 会发生什么。你能创建带颜色的 3D 点云,比如火焰、烟雾或云彩吗?可以随意查找或甚至创建你自己的数据集来试试这个!

以下代码是 Discriminator 的定义(也可以在 model_3dgan.py 文件中找到):

class Discriminator(nn.Module):
    def __init__(self, cube_len, bias=False):
        super(Discriminator, self).__init__()
        self.cube_len = cube_len

        self.model = nn.Sequential(
            *self._create_layer(1, self.cube_len, 4, stride=2, padding=1, bias=bias, transposed=False),
            *self._create_layer(self.cube_len, self.cube_len*2, 4, stride=2, padding=1, bias=bias, transposed=False),
            *self._create_layer(self.cube_len*2, self.cube_len*4, 4, stride=2, padding=1, bias=bias, transposed=False),
            *self._create_layer(self.cube_len*4, self.cube_len*8, 4, stride=2, padding=1, bias=bias, transposed=False),
            *self._create_layer(self.cube_len*8, 1, 4, stride=2, padding=1, bias=bias, transposed=False, last_layer=True)
        )

    def _create_layer(self, size_in, size_out, kernel_size=4, stride=2, padding=1, bias=False, transposed=False, last_layer=False):
        layers = []
        if transposed:
            layers.append(nn.ConvTranspose3d(size_in, size_out, kernel_size, stride=stride, padding=padding, bias=bias))
        else:
            layers.append(nn.Conv3d(size_in, size_out, kernel_size, stride=stride, padding=padding, bias=bias))
        if last_layer:
            layers.append(nn.Sigmoid())
        else:
            layers.append(nn.BatchNorm3d(size_out))
            layers.append(nn.LeakyReLU(0.2, inplace=True))
        return layers

    def forward(self, x):
        x = x.view(-1, 1, self.cube_len, self.cube_len, self.cube_len)
        return self.model(x)

训练 3D-GAN

3D-GAN 的训练过程类似于传统 GAN 的训练过程。从以下图示可以看出:

3D-GAN 的训练过程。在这里,x* 表示真实数据,x 表示伪造数据,z 表示潜在向量。参数会被更新的网络用红色边界标出。

首先,训练判别网络来将真实的 3D 点云识别为真实数据,将由生成器网络生成的合成点云识别为伪造数据。判别网络使用 BCE 损失(nn.BCELoss)作为损失函数。然后,通过强迫判别器将合成的 3D 点云识别为真实数据来训练生成器网络,这样生成器就能学会在未来更好地欺骗判别器。训练生成器网络时也使用 BCE 损失。

以下是 3D-GAN 训练的部分源代码。创建一个 build_gan.py 文件并将以下代码粘贴到该文件中。部分训练技巧来自 github.com/rimchang/3DGAN-Pytorch,我们稍后会进行讨论:

import os
import time
from datetime import datetime
import torch
from torch.optim.lr_scheduler import MultiStepLR
import utils
from model_3dgan import Generator as G
from model_3dgan import Discriminator as D
import matplotlib
import matplotlib.pyplot as plt
import numpy as np
import pickle

class Model(object):
    def __init__(self, name, device, data_loader, latent_dim, cube_len):
        self.name = name
        self.device = device
        self.data_loader = data_loader
        self.latent_dim = latent_dim
        self.cube_len = cube_len
        assert self.name == '3dgan'
        self.netG = G(self.latent_dim, self.cube_len)
        self.netG.to(self.device)
        self.netD = D(self.cube_len)
        self.netD.to(self.device)
        self.optim_G = None
        self.optim_D = None
        self.scheduler_D = None
        self.criterion = torch.nn.BCELoss()

    def create_optim(self, g_lr, d_lr, alpha=0.5, beta=0.5):
        self.optim_G = torch.optim.Adam(self.netG.parameters(),
                                        lr=g_lr,
                                        betas=(alpha, beta))
        self.optim_D = torch.optim.Adam(self.netD.parameters(),
                                          lr=d_lr,
                                          betas=(alpha, beta))
        self.scheduler_D = MultiStepLR(self.optim_D, milestones=[500,  
         1000])

    def train(self, epochs, d_loss_thresh, log_interval=100, 
       export_interval=10, out_dir='', verbose=True):
        self.netG.train()
        self.netD.train()
        total_time = time.time()
        for epoch in range(epochs):
            batch_time = time.time()
            for batch_idx, data in enumerate(self.data_loader):
                data = data.to(self.device)

                batch_size = data.shape[0]
                real_label = torch.Tensor(batch_size).uniform_(0.7, 
                  1.2).to(self.device)
                fake_label = torch.Tensor(batch_size).uniform_(0, 
                  0.3).to(self.device)

                # Train D
                d_real = self.netD(data)
                d_real = d_real.squeeze()
                d_real_loss = self.criterion(d_real, real_label)

                latent = torch.Tensor(batch_size, 
                  self.latent_dim).normal_(0, 0.33).to(self.device)
                fake = self.netG(latent)
                d_fake = self.netD(fake.detach())
                d_fake = d_fake.squeeze()
                d_fake_loss = self.criterion(d_fake, fake_label)

                d_loss = d_real_loss + d_fake_loss

                d_real_acc = torch.ge(d_real.squeeze(), 0.5).float()
                d_fake_acc = torch.le(d_fake.squeeze(), 0.5).float()
                d_acc = torch.mean(torch.cat((d_real_acc, d_fake_acc),0))

                if d_acc <= d_loss_thresh:
                    self.netD.zero_grad()
                    d_loss.backward()
                    self.optim_D.step()

                # Train G
                latent = torch.Tensor(batch_size, 
                  self.latent_dim).normal_(0, 0.33).to(self.device)
                fake = self.netG(latent)
                d_fake = self.netD(fake)
                d_fake = d_fake.squeeze()
                g_loss = self.criterion(d_fake, real_label)

                self.netD.zero_grad()
                self.netG.zero_grad()
                g_loss.backward()
                self.optim_G.step()

            if epoch % export_interval == 0:
                samples = fake.cpu().data[:8].squeeze().numpy()
                utils.save_voxels(samples, out_dir, epoch)
            self.scheduler_D.step()

你可能注意到 real_labelfake_label 并不像通常那样设置为 1 和 0。相反,使用了随机初始化的标签(uniform_(0.7, 1.2)uniform_(0, 0.3))。这种技术与 软标签 非常相似,它使用更大网络的 softmax 输出作为标签(而不是“硬”标签 0 或 1),来训练一个较小但输入输出映射相同的网络(这被称为 知识蒸馏)。这个技巧随着时间的推移生成了一个更加平滑的损失函数,因为它假设标签是随机变量。你总是可以随机初始化 real_label,并让 fake_label 等于 1-real_label

我们已经知道期望的输出张量是稀疏的,并且完全训练判别器应该是非常简单的。实际上,判别器会在生成器训练充分之前就过拟合。因此,我们只在判别器的训练准确率不超过d_loss_thresh时训练判别器。注意,学习率衰减用于优化生成器。

在之前的代码中,我们可视化并导出了每隔export_interval个训练周期生成的点云。渲染点云的代码如下:

def save_voxels(voxels, path, idx):
    from mpl_toolkits.mplot3d import Axes3D
    voxels = voxels[:8].__ge__(0.5)
    fig = plt.figure(figsize=(32, 16))
    gs = gridspec.GridSpec(2, 4)
    gs.update(wspace=0.05, hspace=0.05)

    for i, sample in enumerate(voxels):
        x, y, z = sample.nonzero()
        ax = fig.add_subplot(gs[i], projection='3d')
        ax.scatter(x, y, z, zdir='z', c='red')
        ax.set_xticklabels([])
        ax.set_yticklabels([])
    plt.savefig(path + '/{}.png'.format(str(idx)), bbox_inches='tight')
    plt.close()

    with open(path + '/{}.pkl'.format(str(idx)), "wb") as f:
        pickle.dump(voxels, f, protocol=pickle.HIGHEST_PROTOCOL)

接下来的步骤是为 3D-GAN 准备训练数据集。你可以从3dshapenets.cs.princeton.edu/3DShapeNetsCode.zip下载 40 种不同类型物体的点云数据。下载并解压zip文件后,将volumetric_data文件夹移动到你喜欢的位置(例如,/media/john/DataAsgard/3d_models/volumetric_data),并选择一个类别进行模型训练。

用于加载训练点云文件的代码如下(创建一个datasets.py文件,并将以下代码粘贴到其中):

import os
import numpy as np
import scipy.ndimage as nd
import scipy.io as io
import torch
from torch.utils.data import Dataset

def getVoxelFromMat(path, cube_len=64):
    voxels = io.loadmat(path)['instance']
    voxels = np.pad(voxels, (1, 1), 'constant', constant_values=(0, 0))
    if cube_len != 32 and cube_len == 64:
        voxels = nd.zoom(voxels, (2, 2, 2), mode='constant', order=0)
    return voxels

class ShapeNetDataset(Dataset):
    def __init__(self, root, cube_len):
        self.root = root
        self.listdir = os.listdir(self.root)
        self.cube_len = cube_len

    def __getitem__(self, index):
        with open(os.path.join(self.root, self.listdir[index]), "rb") as f:
            volume = np.asarray(getVoxelFromMat(f, self.cube_len), dtype=np.float32)
        return torch.FloatTensor(volume)

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

最后,这里是main.py文件的代码,用于初始化和训练 3D-GAN:

import argparse
import os
import sys
import numpy as np
import torch
import torch.backends.cudnn as cudnn
import torch.utils.data as DataLoader
import torchvision.datasets as dset
import torchvision.transforms as transforms
import utils
from build_gan import Model
from datasets import ShapeNetDataset

FLAGS = None    

def main():
    device = torch.device("cuda:0" if FLAGS.cuda else "cpu")
    print('Loading data...\n')
    dataset = ShapeNetDataset(FLAGS.data_dir, FLAGS.cube_len)
    dataloader = torch.utils.data.DataLoader(dataset,
                                             FLAGS.batch_size,
                                             shuffle=True,
                                             num_workers=1,
                                             pin_memory=True)

    print('Creating model...\n')
    model = Model(FLAGS.model, device, dataloader, FLAGS.latent_dim, FLAGS.cube_len)
    model.create_optim(FLAGS.g_lr, FLAGS.d_lr)

    # Train
    model.train(FLAGS.epochs, FLAGS.d_loss_thresh, FLAGS.log_interval,
                FLAGS.export_interval, FLAGS.out_dir, True)

我们在第五章中使用了类似的代码来创建命令行解析器,基于标签信息生成图像。我们将在这里使用相同的思路,并添加一些选项:


if __name__ == '__main__':
    from utils import boolean_string
    parser = argparse.ArgumentParser(description='Hands-On GANs - Chapter 11')
    parser.add_argument('--model', type=str, default='3dGan',
                        help='enter `3dGan`.')
    parser.add_argument('--cube_len', type=int, default='32',
                        help='one of `cgan` and `infogan`.')
    parser.add_argument('--cuda', type=boolean_string,
                        default=True, help='enable CUDA.')
    parser.add_argument('--train', type=boolean_string,
                        default=True, help='train mode or eval mode.')
    parser.add_argument('--data_dir', type=str,
                        default='~/data', help='Directory for dataset.')
    parser.add_argument('--out_dir', type=str,
                        default='output', help='Directory for output.')
    parser.add_argument('--epochs', type=int, default=200,
                        help='number of epochs')
    parser.add_argument('--batch_size', type=int,
                        default=128, help='size of batches')
    parser.add_argument('--g_lr', type=float, default=0.0002,
                        help='G learning rate')
    parser.add_argument('--d_lr', type=float, default=0.0002,
                        help='D learning rate')
    parser.add_argument('--d_loss_thresh', type=float, default=0.7,
                        help='D loss threshold')
    parser.add_argument('--latent_dim', type=int,
                        default=100, help='latent space dimension')
    parser.add_argument('--export_interval', type=int,
                        default=10, help='export interval')
    parser.add_argument('--classes', type=int, default=10,
                        help='number of classes')
    parser.add_argument('--img_size', type=int,
                        default=64, help='size of images')
    parser.add_argument('--channels', type=int, default=1,
                        help='number of image channels')
    parser.add_argument('--log_interval', type=int, default=100,
                        help='interval between logging and image sampling')
    parser.add_argument('--seed', type=int, default=1, help='random seed')

    FLAGS = parser.parse_args()
    FLAGS.cuda = FLAGS.cuda and torch.cuda.is_available()

    if FLAGS.seed is not None:
        torch.manual_seed(FLAGS.seed)
        if FLAGS.cuda:
            torch.cuda.manual_seed(FLAGS.seed)
        np.random.seed(FLAGS.seed)

    cudnn.benchmark = True

    if FLAGS.train:
        utils.clear_folder(FLAGS.out_dir)

    log_file = os.path.join(FLAGS.out_dir, 'log.txt')
    print("Logging to {}\n".format(log_file))
    sys.stdout = utils.StdOut(log_file)

    print("PyTorch version: {}".format(torch.__version__))
    print("CUDA version: {}\n".format(torch.version.cuda))

    print(" " * 9 + "Args" + " " * 9 + "| " + "Type" +
          " | " + "Value")
    print("-" * 50)
    for arg in vars(FLAGS):
        arg_str = str(arg)
        var_str = str(getattr(FLAGS, arg))
        type_str = str(type(getattr(FLAGS, arg)).__name__)
        print(" " + arg_str + " " * (20-len(arg_str)) + "|" +
              " " + type_str + " " * (10-len(type_str)) + "|" +
              " " + var_str)
    main()

现在,我们可以使用以下命令行运行程序。务必提供正确的数据目录:

python main.py --model 3dgan --train True --epochs 1000 --data_dir Data_Directory

在这里,我们使用了椅子类别作为示例。完成 1,000 个训练周期大约需要 4 小时,并且在单个 NVIDIA GTX 1080Ti 显卡上大约消耗 1,023 MB 的 GPU 内存。注意,尽管我们的实现重度依赖于github.com/rimchang/3DGAN-Pytorch,但原始代码完成相同任务的时间为 14 小时,GPU 内存消耗为 1,499 MB。

以下是一些由 3D-GAN 生成的 3D 椅子模型。如我们所见,尽管存在一些异常值和体素位置错误,整体模型看起来还是相当逼真的。你还可以访问论文作者创建的 3D-GAN 官网,查看其中提供的生成椅子的互动展示:meetshah1995.github.io/gan/deep-learning/tensorflow/visdom/2017/04/01/3d-generative-adverserial-networks-for-volume-classification-and-generation.html

3D-GAN 生成的椅子模型

随意选择不同的对象类别,甚至尝试其他数据集。这里是一个点云数据集的列表:yulanguo.me/dataset.html。这里是过去几年关于 3D 点云的论文列表(截至撰写时):github.com/Yochengliu/awesome-point-cloud-analysis。希望你能用 GAN 和 3D 点云发现新的应用!

摘要

在本章中,我们学习了计算机图形学的基本概念,以及如何训练 3D-GAN 生成 3D 对象。

在下一章中,我们将回顾我们在各种 GAN 模型中使用过的所有有用技巧,并介绍更多实用的技术,这些技术将帮助你未来设计和训练 GAN 模型。

进一步阅读

  1. Ahn S H. (2019). OpenGL 投影矩阵。来源:www.songho.ca/opengl/gl_projectionmatrix.html

  2. Wu J, Zhang C, Xue T. (2016). 通过 3D 生成对抗建模学习对象形状的概率潜在空间。NIPS.

posted @ 2025-07-10 11:38  绝不原创的飞龙  阅读(105)  评论(0)    收藏  举报