在第二部分,我们将深入六类生成式模型,包括他们背后的工作机理,以及实际的样例来展示如何构建各类模型。
在第三章中,我们一起来看看本书的第一个生成式深度学习模型,即 变分自编码器。这一技术不仅可以让我们生成真实感的人脸图像,也可以修改已有图像 — 例如,增加微笑 / 改变某人的发色。
在第四章中,我们将探索近年来最成功的生成式建模技术之一: 生成式对抗网络。我们将看到GAN训练、调优的方式,以及它如何持续推动生成式建模的性能发展。
在第五章中,我们将深入自回归模型的几个例子,包括 LSTMs 和 PixelCNN。这类模型将生成式过程当做一个序列预测问题 — 这是今天经典文本生成模型的基础,也可以用于图像生成。
在第六章中,我们将介绍 归一流模型,包括RealNVP。这类模型基于变量公式的变化,可以将简单分布,例如高斯分布,转变为更复杂的分布。
在第七章中,我们介绍基于能量的模型家族。这类模型训练一个数值能量函数来对给定输入的合理性进行打分。我们探索了一种训练能力模型的技术: contrastive divergence, 以及一种采样新分布的技术: Langevin dynamics。
最后,在第八章中,我们探索扩散模型家族。这类技术基于这样一个想法:持续向一幅图像添加噪声,并训练模型去除这些噪声,这将让我们具有将纯噪声转换为真实样本的能力。
在第二部分的最后,你将构建上述六种生成式模型家族的实际样例,并能从理论角度解释为什么每种网络可以生效。
章节目标
- 了解自编码器架构设计为什么会让它完美适合生成式建模任务。
- 使用Keras 从零开始构建并训练一个自编码器。
- 使用自编码器来生成新的图像,并理解方法的局限性。
- 了解变分自编码器的架构,以及它如何解决标准自编码器存在的问题。
- 使用Keras从零开始构建一个变分自编码器。
- 使用变分自编码器生成新的图像。
- 使用变分自编码器,利用隐空间算术来进行生产图像的操控。
2013年,Diederik P. Kingma 和 Max Welling发表了一篇论文,打下了一种名为 变分自编码器(variational autoencoder) 的神经网络的基础。这是当前生成式建模领域最基础、最知名的一种架构,也是我们开始生成式建模之旅最好的起点。
本章中,我们首先看看标准的自编码器,然后看看如何将这种框架延展到变分自编码器。沿着这条路,我们将从细节上理解这两类模型如何工作。在本章的末尾,你将对如何构建并操控自编码器模型有完整的理解。特别的,你也将学会如何基于自己的数据库,从零开始构建一个变分自编码器并生成自己的图像。
我们以一个简单的故事开始,这将帮助我们解释自编码器所尝试解决的基础问题。
Brian, 缝制,衣柜 |
---|
想象一下,在你面前的地板上,放了一堆衣服 — 裤子,上衣,鞋子,外套,各种不同的类型。你的造型师,Brian,因为帮你寻找你要求的东西耗时很长,而越来越沮丧,因此,他打算采用更聪明的计划。他告诉你,在一个足够高,足够宽的衣柜里放置后自己的衣服(下图3-1)。当你要求一个特定的东西时,你只需要简单告诉Brian它的位置,他就会用缝纫机从零开始缝制那件衣服。很显然,你需要把相似的东西放置的更近,以帮助Brian在仅知道其位置的情况下准确重建。经过几周的实践,你和Brian都理解了彼此对于衣柜布局的理解。现在,你可以告诉Brian任何你想要衣物的位置,他都可以帮你精准的从零开始缝制。这引发了一个思考—如果你给Brian一个空的衣柜位置,将会发生生命?你将惊奇的发现,Bian能够生成全新的衣物,在衣柜中并不存在!这个过程并不完美,但是现在你对于生成新衣服有了一些理解,在无限衣柜中挑 |
上述过程的一个框图如下图3-2所示。你扮演encoder的角色,将衣物的具体项移动到衣柜的某个位置。这个过程称为编码。Brian扮演解码器的角色,拿到一个衣柜的位置,并且尝试去重新创作对应衣物项。这个过程称为解码。
衣柜中每个位置用两个数用两个数表示 (也即,一个2D向量)。例如,图3-2中的裤子被编码为 [6.3, -0.9]。这个向量被称作 一个嵌入 (an embedding), 因为编码器尝试向其中嵌入尽可能多的信息,因此解码器才有可能进行精准重建。
简单说来,自编码器就是一个神经网络,它被训练来完成编码和解码特定事物的任务,从而使得该过程的输出尽可能与原始输入接近。它可以用作生成式模型,因为我们可以解码二维空间中的任何点(特别的,那些不是原始事物嵌入的点),以此来产生新的衣物。
现在,我们一起来看看,我们该如何利用Keras来构建一个自编码器,并把它用于真实数据库。
运行本示例代码 |
---|
本示例代码可以在Jupyter Notebook中运行,其路径为“notebooks/03_vae/01_autoencoder/autoencoder.ipynb” |
在本例中,我们将使用Fashion-MNIST数据集 — 一堆衣物灰度图片的合集,每张图片28x28像素。一些图片的例子如图3-3所示。
数据集也在TensorFlow中预打包好,可以按如下示例3-1方式下载。
# 示例3-1 Fashion-MNIST数据集下载
from tensorflow.keras import datasets
(x_train, y_train), (x_test, y_test) = datasets.fashion_mnist.load_data()
这是开箱即用的 28x28 灰度图片 (像素值介于0到255之间)。我们需要进行预处理以确保像素值被放缩到0和1之间。同时,我们也会将图片补齐到32x32,以使得tensor在网络中传递起来形状更易于处理,如下示例3-2所示。
# 示例3-2 数据的预处理
def preprocess(imgs):
imgs = imgs.astype("float32") / 255.0
imgs = np.pad(imgs, ((0,0), (2,2), (2,2)), constant_values = 0.0)
imgs = np.expand_dims(imgs,-1)
return imgs
x_train = preprocess(x_train)
x_test = preprocess(x_test)
接下里,我们需要理解自编码器的整体结构,以便于我们可以用TensorFlow和Keras将之编码出来。
一个自编码器是一个由两部分组成的神经网络:
网络架构如下图3-4所示。输入图像被编码为一个隐嵌入向量 z,z 稍后被解码回到原始像素空间。
自编码器被训练用以重构图像。这一点第一眼看起来可能很奇怪 — 为什么我们要重构一波我们已拥有的数据?但是,我们将很快看到,真正有趣的是 嵌入空间 (也称作 隐空间) ,从该空间进行采样可以让我们产生新的图像。
让我们先来定义什么是 嵌入。嵌入是原始图像到低维隐空间的一个压缩。背后的思想在于: 通过在隐空间选取任何一个点,我们通过将该点传给解码器能够生成新的图像,因为解码器本身已经学会如何把隐空间的一个点转换为有意义的图像。
在我们的实验中,我们会将图像嵌入到一个2维隐空间。这将有利于我们可视化隐空间,因为我们可以很容易画出2D空间中的点。实际上,自编码器的隐空间通常都有两个以上维度,以在更多自由度上捕获更多的图像细微差别。
使用自编码器作为去噪模型 |
---|
自编码器可以用来净化噪声图像,因为编码器学习到: 在隐空间中捕捉随机噪声的位置对于重建原始图像并无帮助。对于类似的任务,2D隐空间可能过小,不足以将输入中相关信息都编码进去。但是,我们也将看到,如果我们利用自编码器作为生成模型,增长隐空间的维度也将很快带来问题。 |
现在,让我们一起看下如何构建编码器和解码器。
在自编码器中,编码器的职责是接收输入图像,并把它映射到隐空间中的一个嵌入向量。我们将构建的编码器架构如表3-1所示。
层(类型) | 输出形状 | 参数数量 |
---|---|---|
InputLayer | (None, 32, 32, 1) | 0 |
Conv2D | (None, 16, 16, 32) | 320 |
Conv2D | (None, 8, 8, 64) | 18496 |
Conv2D | (None, 4, 4, 128) | 73856 |
Flatten | (None, 2048) | 0 |
Dense | (None, 2) | 4098 |
总参数量 | 96770 |
---|---|
可训练参数量 | 96770 |
非训练参数量 | 0 |
为了实现这一点,我们首先创建了一个图像输入层,并把它传给3个依次连接的Conv2D层,每一个卷积层捕获更高层的特征。我们使用stride = 2 来在每一层中进行尺寸上的减半,同时增加通道数,最后的卷积层被拉平,并且连接到一个大小为2的Dense层上,这代表的是我们的二维隐空间。示例3-3展示了我们怎么在Keras中构建这个自编码器。
# 示例3-3 编码器
# 定义编码器的输入层(图像)
encoder_input = layers.Input(shape = (32,32,1), name = "encoder_input")
# 堆叠Conv2D层
x = layers.Conv2D(32, (3,3), strides = 2, activation = 'relu', padding = "same")(encoder_input)
x = layers.Conv2D(64, (3,3), strides = 2, activation = 'relu', padding = "same")(x)
x = layers.Conv2D(128, (3,3), strides = 2, activation = 'relu', padding = "same")(x)
shape_before_flattening = K.int_shape(x)[1:]
# 拉直向量,并与2D嵌入向量层向量
x = layers.Flatten()(x)
encoder_output = layers.Dense(2, name = "encoder_output")(x)
# 构建编码器的Keras模型————模型接收一幅输入图像,将之编码到2D嵌入向量
encoder = models.Model(encoder_input,encoder_output)
小贴士 |
---|
我强烈建议你通过实验调整卷积层的数目,来理解架构如何影响总体的模型参数,模型性能以及模型运行时间。 |
解码器是编码器的镜像 — 不是使用卷积层,我们使用卷积转置(convolutional transpose)层,如下表3-2所示。
层(类型) | 输出形状 | 参数数量 |
---|---|---|
InputLayer | (None, 2) | 0 |
Dense | (None, 2048) | 6144 |
Reshape | (None, 4, 4, 128) | 0 |
Conv2DTranspose | (None, 8, 8, 128) | 147584 |
Conv2DTranspose | (None, 16, 16, 64) | 73792 |
Conv2DTranspose | (None, 32, 32, 32) | 18464 |
Conv2D | (None, 32, 32, 1) | 289 |
总参数量 | 246273 |
---|---|
可训练参数量 | 246273 |
非训练参数量 | 0 |
卷积转置层 |
---|
标准的卷积层通过设定strides=2允许我们将一个张量在两个维度(高和宽)上减半。卷积转置层使用与卷积层同样的原则(在图像上滑动滤波器),但是在strides = 2的设定上略有不同:它会在两个维度上将输入张量翻倍。在卷积转置层中,strides 参数决定了内部像素间补0,如图3-5所示。这里,一个3x3x1滤波器(灰度)被传递给一小块3x3x1图像(蓝色),strides=2,我们最终能得到大小为6x6x1的输出张量(绿色) |
在Keras中,Conv2DTranspose层允许我们实现张量上的卷积转置操作。通过这些层的叠加,我们可以使用strides = 2逐渐扩展每层的尺寸,直到我们最终回到原始图像尺寸32x32。
样例3-4 展示了我们在Keras中如何构建decoder:
# 示例3-3 解码器
# 定义解码器的输入(嵌入)
decoder_input = layers.Input(shape=(2,), name = "decoder_input")
# 把输入和一个Dense层连起来
x = layers.Dense(np.prod(shape_before_flattening))(decoder_input)
# 通过张量Reshape操作使得张量可作为第一个Conv2DTranspose层的输入
x = layers.Reshape(shape_before_flattening)(x)
x = layers.Conv2DTranspose(128,(3,3),strides=2,activation='relu', padding="same")(x)
x = layers.Conv2DTranspose(64,(3,3),strides=2,activation='relu', padding="same")(x)
x = layers.Conv2DTranspose(32,(3,3),strides=2,activation='relu', padding="same")(x)
decoder_output = layers.Conv2D(1,(3,3),strides=1,activation='sigmoid', padding="same", name="decoder_output")(x)
# Keras 模型所定义的解码器: 以隐空间嵌入为输入,并将它解码到原始图像domain
decoder = models.Model(decoder_input, decoder_output)
为了同时训练编码器和解码器,我们需要定义一个模型,它能表示图像在编码器和解码器之间流动的过程。幸运的是,Keras使得这个变得格外容易,如示例3-5所示。注意我们指定自编码器输出的方式: 编码器输出传递给解码器之后的结果。
# 示例3-5 完整的自编码器
# 定义完整自编码器的Keras模型 --- 接收单个图像作为输入,并将它传递给编码器,通过解码器产生原始图像的重建
autoencoder = Model(encoder_input, decoder(encoder_output))
现在,我们已经定义了模型,我们只需要使用一个损失函数编和优化器编译它,如下示例3-6所示。经常使用的损失函数是 均方根误差(root mean squared error, RMSE) 或者 原始图像及重建图像像素间的 二元互熵(binary cross-entropy)。
# 示例3-6 编译自编码器
autoencoder.compile(optimizer = “adam”, loss = "binary_crossentropy")
选择损失函数 |
---|
优化RMSE意味着生成的输出需要再平均像素值附近对称分布,因为过估计和欠估计是同样惩罚的。另一方面,二元互熵是非对称的 — 它对极端误差比中心误差惩罚更严重。例如,如果真实像素值比较高(例如,0.7),那么对生成0.8的像素其惩罚比生成0.6的像素更严重。如果真实的像素值比较低(例如,0.3),那么对生成0.2的像素其惩罚比生成0.4的像素更严重。因此,从效果上来看,二元互熵损失会比RMSE损失产生略模糊的图像(因为它喜欢把输出往0.5附近拉),但有时候这个特性是我们想要的,因为RMSE可能导致过分像素化的边缘。这里并没有对错之分 – 你只需要在试验之后选择在你的case下最有效的损失函数即可。 |
我们现在可以通过将输入图像同时作为输入和输出来训练自编码器了,如下例子3-7所示。
# 训练自编码器
autoencoder.fit(x_train, x_train, epochs = 5, batch_size=100, shuffle=True, validation_data = (x_test, x_test),)
现在,你的自编码器已经训练好了,我们要核实的第一件事是它是否能准确的对输入图像进行重建。
我们通过以下方式测试其图像重建能力: 将测试集中的图像塞入自编码器,比较自编码器输出和原始图像的差距。这部分代码如下样例3-8所示。
# 使用自编码器重建图像
example_images = x_test[:5000]
predictions = autoencoder.predict(example_images)
在图3-6中我们可以看到原始图像的一些实例(上行),编码之后的2D嵌入向量,以及解码之后对应的重构图像(下行)。
注意,这里的重建并非完美 — 仍然有许多原始图像中的细节不能被解码过程捕捉,例如 logos。这是因为,把每幅图像压缩到仅仅2个数字,我们自然会损失一些信息。
让我们来研究一下编码器在隐空间中是如何表示图像的。
我们可以通过如下方式可视化图像嵌入隐空间的过程: 将测试集传入编码器,并画出对应的嵌入结果,如下样例3-9所示。
# 可视化编码器输出之嵌入图像
embeddings = encoder.predict(example_images)
plt.figure(figsize=(8,8))
plt.scatter(embeddings[:,0], embeddings[:,1], c = "black", alpha=0.5, s=3)
plt.show()
结果如图3-2中散点图所示 — 每个黑色的点表示一幅图像在隐空间中的嵌入。
为了更好的理解隐空间是如何划分结构的,我们可以利用Fashion-MNIST数据集的标签,标签描述了每张图像的类别。这里一共有10类,如下表3-3所示。
ID | 衣物标签 |
---|---|
0 | T-shirt/top |
1 | Trouser |
2 | Pullover |
3 | Dress |
4 | Coat |
5 | Sandal |
6 | Shirt |
7 | Sneaker |
8 | Bag |
9 | Ankle boot |
我们可以把嵌入空间中的每个点根据对应图像的标签着色,从而得到如下图3-7。
现在,结构变得非常清晰了!即便衣物的标签在整个训练过程中从未给模型看过,但是自编码器自然地把看起来相似的衣服品类聚集到隐空间的同样区域。例如,右下角的深蓝色点云是trousers的不同图像,而中间区域的红色点云是短靴。
现在,我们可以通过在隐空间进行采样并进一步使用解码器将之反转到像素空间来生成一些新的图像,如下样例3-10所示。
# 使用解码器生成新的图像
mins, maxs = np.min(embeddings, axis = 0), np.max(embeddings, axis = 0)
sample = np.random.uniform(mins, maxs, size=(18,2))
reconstructions = decoder.predict(sample)
一些生成图像的样例在图3-8所示,它们在隐空间的嵌入同步放在旁边。
每个蓝色点映射到右边图上的某个图,嵌入向量在下面。注意其中一些生成图像比其他更有真实感,为什么会这样?
为了回答这些,我们先针对隐空间的全部点分布做一些观察,回顾一下图3-7:
这些观察事实上使得从隐空间采样变得异常挑战。如果我们将隐空间叠加上网格化解码图上,如图3-9所示,我们就会理解为什么解码器并不总能生成足够好的图像。
首先,我们可以看到,如果我们从一个限定空间进行间隔采样,我们更可能采样到一些点,其解码结果看起来像个包(ID 8)的概率比看起来像个短靴(ID 9)的概率更大, 因为隐空间中包对应的区域(橙色) 要大于短靴对应的区域 (红色)。
其次,我们该如何从隐空间中去选择一个随机点,这点并不显然,因为这些点的分布是未定义的。技术上,我们选择2D空间的任何点都是可以的!甚至这些点并不一定是以(0,0)为中心。这使得从隐空间采样本身就是有问题的。
最后,我们能够在隐空间中看到很多空洞,其并不能与任何编码的原始图像对应。例如,在区域的边缘有大片的白色区域 — 自编码器没有任何理由保证在这里的点也能被解码为看起来可被识别为衣物的图片,因为在训练集中,很少有图片被编码到这里。
即使是中间的点也不一定能够被解码为好的图像。这是因为,自编码器并没有任何约束来保障空间是连续的。例如,尽管(-1,-1)可被解码为令人满意的拖鞋图片,并没有现成的机制来保证点(-1.1,-1.1)也会产生令人满意的拖鞋图片。
在二维空间中,这个问题还不明显。自编码器只需要处理比较小的维度,所以自然的它需要把衣物类挤压到一起,使得不同的衣物类别之间的空间相对较小。然而,如果我们开始使用更多的隐空间维度来生成更负责的图片(如人脸),这个问题将会变得更为明显。如果我们让自编码器自由掌控它如何利用隐空间编码图像,那么相似点群之间将有巨量的间隔,并且没有任何激励让这些间隔空间能够生成好的图像。
为了解决上述三个问题,我们需要把我们的自编码器转化为一个变分自编码器(variational autoencoder)。
为了解释清楚,我们再回顾一下之前的无限衣柜,并做一些改变。
无限衣柜的回顾 |
---|
现在假设,我们不再把衣服中的每个样本放置到衣柜中的单一点,而是将它放置到一个区域,在该区域我们很可能能找到对应衣物。选择这样一个更宽松的物品放置机制,能够帮你解决衣柜中的局部不连续性问题。同时,为了确保你不至于变得对新放置系统漠不关心,你和Brian打成一致,你将尽量把每件衣物的区域中心放到衣柜的中间,同时,衣物到中心的偏差尽量不超过1米。你偏离这个规则越多,雇佣Brian作为你造型师的成本越高。完成了以上两个简单改变几个月之后,你反过来会喜欢这个新的布局,也包括Brian帮你生成的新样本。好多了!生成的样本具有更好的多样性,同时也没有低质量衣服产生。看起来,这两项改变让一切都变得不同! |
现在,让我们尝试理解为了把我们的自编码器变成变分自编码器,我们需要做些什么使得它成为更复杂的生成模型。
我们需要改变的两个部分是编码器和损失函数。
在自编码器中,每个图像直接被映射为隐空间中的一个点。在变分自编码器中,每个图形被映射为隐空间中环绕一点的多元正态分布,如图3-10所示。
多元正态分布 |
---|
一个正态分布(或者高斯分布) N ( μ , σ ) N(\mu, \sigma) N(μ,σ)是一个钟形曲线形状的概率分布,由两个变量定义: 均值( μ \mu μ) 以及方差 σ 2 \sigma ^ 2 σ2。标准差 ( σ \sigma σ)是方差的平方根。一维正态分布的概率密度函数公式如下: |
f ( x ∣ μ , σ 2 ) = 1 2 π σ 2 e − ( x − μ ) 2 2 σ 2 f(x | \mu, \sigma ^2) = \frac{1}{\sqrt{2 \pi \sigma ^2 }}e^{-\frac{(x-\mu)^2}{2\sigma ^2}} f(x∣μ,σ2)=2πσ21e−2σ2(x−μ)2 |
图3-11 给出了多个一维正态分布,其中均值和方差略有差异。红色曲线是所谓的标准整体分布(或单位整体分布) N ( 0 , 1 ) N(0,1) N(0,1)—均值为0,方差为1的整体分布。我们可以使用下面的方程从均值为 μ \mu μ, 标准差为 σ \sigma σ的正态分布中采样一个点z: |
z = μ + σ ϵ z = \mu + \sigma \epsilon z=μ+σϵ ,其中 ϵ \epsilon ϵ是从标准分布中采样得到。 |
正态分布的概念可以延展到高于一维 — 多元正态分布(多元高斯分布)的概率密度函数 N ( μ , Σ ) N(\mu, \Sigma) N(μ,Σ), 维度为k,均值向量 μ \mu μ, 对称协方差矩阵 Σ \Sigma Σ定义下: |
f ( x 1 , ⋯ , x k ) = e x p ( − 1 2 ( x − μ ) T Σ − 1 ( x − μ ) ) ( 2 ∗ π ) k ∣ Σ ∣ f(x_1, \cdots, x_k) = \frac{exp(-\frac{1}{2}(\bf{x}-\mu)^T\Sigma ^{-1} (\bf{x}-\mu))}{\sqrt{(2*\pi)^k|\Sigma|}} f(x1,⋯,xk)=(2∗π)k∣Σ∣exp(−21(x−μ)TΣ−1(x−μ)) |
在本书中,我们通常使用各向同性多元正态分布,其中协方差矩阵是对角的。这表示分布在各个维度上是独立的(也即,我们可以采样一个向量,各个维度上都是独立均值和方差的正态分布)。这是我们将在变分自编码器中使用的多元正态分布。 |
一个多元标准整体分布 N ( 0 , I ) N(0, \bf{I}) N(0,I)是一个零均值向量和单位协方差矩阵的多元分布。 |
编码器只需要将每个输入映射到一个均值向量和一个方差向量,并无需担心维度间的协方差。
方差值一般都是正的,因为我们实际上选择去映射到对数方差,因为这个可以取到 ( − ∞ , + ∞ ) (-\infin, +\infin) (−∞,+∞)之间的任何值。通过这种方式我们可以用一个神经网络作为编码器来实现从输入图像到均值和对数方差向量的映射。
总而言之,编码器接收每张输入图像,并把它编码为两个向量,这两个向量一起定义了一个隐空间中的多元正态分布。
z_mean
分布的均值点。
z_log_var
每个维度的对数方差。
我们可以使用下面的公式从分布中采样一个点 z:
z = z _ m e a n + z _ s i g m a ∗ e p s i l o n z = z\_mean + z\_sigma * epsilon z=z_mean+z_sigma∗epsilon
其中
z _ s i g m a = e x p ( z _ l o g _ v a r ∗ 0.5 ) z\_sigma = exp(z\_log\_var * 0.5) z_sigma=exp(z_log_var∗0.5)
e p s i l o n ∼ N ( 0 , I ) epsilon \sim N(0,\bf{I}) epsilon∼N(0,I)
小贴士 |
---|
z _ s i g m a ( σ ) z\_sigma (\sigma) z_sigma(σ) 和 z _ l o g _ v a r ( l o g ( σ 2 ) ) z\_log\_var (log (\sigma ^ 2)) z_log_var(log(σ2)) 的关系可以从以下推断: |
σ = e x p ( l o g ( σ ) ) = e x p ( 2 l o g ( σ ) / 2 ) = e x p ( l o g ( σ 2 ) / 2 ) \sigma = exp(log(\sigma)) = exp(2log(\sigma)/2) = exp(log(\sigma ^2)/2) σ=exp(log(σ))=exp(2log(σ)/2)=exp(log(σ2)/2) |
变分自编码器的解码器与普通自编码器的解码器完全一样,因此总体架构图如下图3-12所示。
为什么编码器的小改变能够起作用?
之前,我们看到对于隐空间的连续性,我们没有做任何要求 — 即使点(-2,2)通过解码能够得到很好的拖鞋图片,对于点(-2.1, -2.1)我们都没有设置任何要求要求它解码后跟前者看起来相似。现在,因为我们是从z_mean附近的一个区域采样了一个随机点,解码器必须保证邻域附近所有的点解码后的图像都必须看起来相似,使得重建误差保持比较小。这是一个很好的特性,它能够保证即使我们在隐空间中采样到一个点,解码器从未看过,但它仍然可以解码出好的图像。
现在,让我们一起看看,我们如何用Keras构建新版本encoder。
运行本示例代码 |
---|
本示例代码可以在Jupyter Notebook的如下路径找到: “notebooks/03_vae/02_vae_fashion/vae_fashion.ipynb”,这个代码修改自 Francois Chollet杰出的VAE教程(Keras官网可获取) 。 |
首先,我们需要创造一个Sampling层,该层可以让我们从 z_mean 及 z_log_var 定义的分布中采样,如下样例 33-11所示。
# Sampling 层
# 我们创建一个新层,其是Keras Layer基类的子类
class Sampling(layers.Layer):
def call(self, inputs):
z_mean, z_log_var = inputs
batch = tf.shape(z_mean)[0]
dim = tf.shape(z_mean)[1]
epsilon = K.random_normal(shape=(batch, dim))
# 我们使用重参数化技巧来构建一个样本,其来自参数为 z_mean和z_log_var的正态分布
return z_mean + tf.exp(0.5*z_log_var) * epsilon
Layer类的子类 |
---|
在Keras中,我们可以创建抽象类Layer的子类,并定义 call 方法,该方法定义了该层如何转换一个张量。例如,在变分自编码器中,我们可以构建一个Sampling层,该层可以处理参数为 z_mena 和 z_log_var 的正态分布的一个采样z。子类的定义是非常有用的,尤其是当你想针对张量应用一种转换,而该转换在已有的Keras类目中并不存在时。 |
重参数化技巧 |
---|
本书中,我们没有直接从参数为 z_mean 和 z_log_var 的正态分布采样,而是从标准整体分布中采样了 epsilon ,并手工把该样本调整到正确的均值和方差。这种做法被称为 重参数化技巧,这很重要,因为这意味着梯度可以从这层轻易反传。通过把这层所有的随机性都包含在变量 epsilon中,这层输出相对于输入的偏微分可以证明是确定的 (也即,独立于随机数 epsilon),这对于该层反传的可能是重要的。 |
编码器之完整代码,包括新定义的Sampling层,如下样例 3-12所示。
# 编码器
encoder_input = layers.Input(shape = (32,32,1), name = "encoder_input")
# 堆叠Conv2D层
x = layers.Conv2D(32, (3,3), strides = 2, activation = 'relu', padding = "same")(encoder_input)
x = layers.Conv2D(64, (3,3), strides = 2, activation = 'relu', padding = "same")(x)
x = layers.Conv2D(128, (3,3), strides = 2, activation = 'relu', padding = "same")(x)
shape_before_flattening = K.int_shape(x)[1:]
# 拉直向量,并与2D嵌入向量层向量
x = layers.Flatten()(x)
# 这里不是把Flatten层直接跟2D隐层相连,我们把它连接到层 z_mean 和 z_log_var.
z_mean = layers.Dense(2, name = "z_mean")(x)
z_log_var = layers.Dense(2, name = "z_mean")(x)
# Sampling 层从隐空间中按z_mean和z_log_var定义的正态分布采样一个点
z = Sampling()([z_mean, z_log_var])
# 构建编码器的Keras模型————模型接收一幅输入图像,输出z_mean, z_log_var,以及从上述两个参数定义的正态分布的一个采样点
encoder = models.Model(encoder_input,[z_mean, z_log_var, z], name="encoder")
关于上述编码器的一个总结如下表3-4所示。
层(类型) | 输出形状 | 参数数量 |
---|---|---|
InputLayer | (None, 32, 32, 1) | 0 |
Conv2D | (None, 16, 16, 32) | 320 |
Conv2D | (None, 8, 8, 64) | 18496 |
Conv2D | (None, 4, 4, 128) | 73856 |
Flatten | (None, 2048) | 0 |
Dense (z_mean) | (None, 2) | 4098 |
Dense (z_log_var) | (None, 2) | 4098 |
Sampling (z) | (None, 2) | 0 |
总参数量 | 100868 |
---|---|
可训练参数量 | 100868 |
非训练参数量 | 0 |
原始自编码器另一个需要改变的部分是损失函数。
之前,我们的损失函数包含了一个原始图像和重建图像之重建误差。重建误差在变分自编码器中也存在,现在,我们需要一个新的部分: Kullback-Leibler散度(KL散度)项。
KL散度是一种度量两个分布差异的方法。在VAE中,我们希望度量我们的正态分布(参数为 z_mean 和 z_log
_var) 与标准正态分布差别多大。在这个特定的case里,KL散度具有如下的闭环形式:
kl_loss = -0.5 * sum(1 + z_log_var - z_mean ^ 2 - exp(z_log_var))
或者用数学公式的形式:
D K L [ N ( μ , σ ) ∣ ∣ N ( 0 , 1 ) ] = − 1 2 ∑ ( 1 + l o g ( σ 2 ) − μ 2 − σ 2 ) D_{KL}[N(\mu, \sigma) || N(0,1)] = -\frac{1}{2}\sum(1+log(\sigma ^2)-\mu ^2 - \sigma ^2) DKL[N(μ,σ)∣∣N(0,1)]=−21∑(1+log(σ2)−μ2−σ2)
上述求和是在隐空间的所有维度上计算,当在各个维度上z_mean = 0, z_log_var = 0时,kl_loss 最小化为0。当这两项开始脱离0时,kl_loss增加。
总而言之,在神经网络将观察编码到显著偏离于标准正态分布的z_mean和z_log_var时,KL散度项会对网络进行惩罚。
为什么增加这个损失函数会有帮助?
首先,我们现在有了一个定义良好的分布(标准正态分布),从中我们可以从隐空间进行点采样。其次,因为这项尝试将所有的编码分布强制为标准正态分布,那么不同点cluster之间存在大的gap可能性就比较低。相反,编码器会尝试高效、对称的利用原点附近的空间。
在原始的VAE论文中,损失函数是重建误差和KL散度损失项的简单加和。一个变种( β \beta β-VAE)则包括了一个权重,用以平衡KL散度和重建误差。如果我们过分重视重建损失,KL损失就无法起到想要的正则化效应,我们将同样碰到在普通自编码器中碰到的问题。如果散度项被过分强调,KL散度损失会占主导,重建图像质量比较低。这个权重项是一个训练VAE时需要精调的参数。
样例3-13展示了我们如何构建整个VAE模型,作为Keras抽象类 Model的子类。这允许我们利用一个典型的 train_step 方法来完成损失函数中KL散度的计算。
# VAE的训练
class VAE(models.Model):
def __init__(self, encoder, decoder, **kwargs):
super(VAE, self).__init__(**kwargs)
self.encoder = encoder
self.decoder = decoder
self.total_loss_tracker = metrics.Mean(name="total_loss")
self.reconstruction_loss_tracker = metrics.Mean(name="reconstruction_loss")
self.kl_loss_tracker = metrics.Mean(name="kl_loss")
@property
def metrics(self):
return [self.total_loss_tracker,
self.reconstruction_loss_tracker,
self.kl_loss_tracker,
]
# 这个函数描述了我们希望VAE对于特定的输入图像返回什么
def call(self, inputs):
z_mean, z_log_var, z = encoder(inputs)
reconstruction = decoder(z)
return z_mean, z_log_var, reconstruction
# 这个函数描述了VAE的一个训练步骤,包括损失函数的计算
def train_step(self, data):
with tf.GradientTape() as tape:
z_mean, z_log_var, reconstruction = self(data)
# 这里我们用了beta值为500
reconstruction_loss = tf.reduce_mean(
500*losses.binary_crossentropy(data, reconstruction, axis=(1,2,3))
)
kl_loss = tf.reduce_mean(
tf.reduce_sum(-0.5*(1 + z_log_var - tf.square(z_mean) - tf.exp(z_log_var)), axis = 1)
)
# 重建误差和KL散度之和
total_loss = reconstruction_loss + kl_loss
grads = tape.gradient(total_loss, self.trainable_weights)
self.optimizer.apply_gradients(zip(grads,self.trainable_weights))
self.total_loss_tracker.update_state(total_loss)
self.reconstruction_loss_tracker.update_state(reconstruction_loss)
self.kl_loss_tracker.update_state(kl_loss)
return {m.name: m.result{} for m in self.metrics}
vae = VAE(encoder, decoder)
vae.compile(optimizer="adam")
vae.fit(train,epochs=5,batch_size=100)
梯度Tape |
---|
TensorFlow的梯度Tape是这样一种机制: 它允许模型的前向过程中计算算子的梯度。为了使用它,你需要把你想要进行微分操作的得代码封装在tf.GradientTape()中。一旦你记录了这一操作,你可以通过调用tape.gradients()来计算损失函数相对于某些变量的梯度。梯度可以用来更新变量。这一机制在计算典型函数的梯度时非常有用,并可以创造典型的训练loops,如我们将在第四章中看到的。 |
现在,我们已经训练了自己的VAE,我们可以利用编码器来编码测试集中的图像。并在隐空间中画出 z_mean。我们也从标准的正态分布中从隐空间中产生一些点,并用解码器讲这些点反投到像素空间,以此来看看VAE表现如何。
图3-13给出了新的隐空间结构,以及一些取样点和解码图像。我们可以迅速看到一些隐空间组织方式的改变。
首先,KL散度损失项保障了 z_mean 和 z_log_var 值不会偏离标准正态分布过远。其次,因为隐空间现在更连续(编码器现在是统计的,而非确定的),生成不好的图也不像之前那么多了。
最后,通过将隐空间中的点用衣物类型着色(图3-14)。我们可以看到,这里没有任何偏好对待。右侧的图展示了我们将空间转换到p-values— 从中我们看到每种色彩都是相对均匀表示的。同时,我们谨记样本标签事实上并没有在训练中被使用。VAE事实上自己学到了不同形式的衣物,以此来最小化重建损失。
截止目前,我们在自编码器和变分自编码器上所有的工作都局限于二维隐空间。这帮助我们在页面上可视化了VAE的内在工作机理,并帮助我们理解了为何自编码器架构上的小改变可以帮助它转变成可用于生成式建模的更强有力神经网络。
让我们现在把自己的注意力转向一个更复杂的数据集,一起看看当我们进一步增加隐空间维度时,变分自编码器将变成什么样。
运行本示例代码 |
---|
本示例代码可以在Jupyter Notebook中运行,其路径为“notebooks/03_vae/03_faces/vae_faces.ipynb” |
我们将使用CelebFaces Attributes (CelebA) 数据集来训练我们的下一个变分自编码器。这是一个超过20万彩色名人人脸图像的集合,每一张图像都有不同的标签(例如,戴帽子,微笑 等)。一些样本的示例如下图3-15所示。
当然了,我们不需要任何标签以训练VAE,但是这些标签在后面我们研究多维隐空间如何捕获特征时又比较重要。一旦我们的VAE训练完成,我们可以从隐空间采样,以生成名人脸的新样本。
CeleA数据库同样在Kaggle上可以获得,因此你可以通过运行Kaggle数据集之下载器脚本下载该数据,如下样例3-14所示。这将在本地/data目录下存储图像及伴随meta数据。
bash scripts/download_kaggle_data.sh jessicali9530 celeba-dataset
我们可以使用Keras函数 image_dataset_from_directory 来创建一个新的 TensorFlow数据集,指向图像存储的目录,如下样例3-15所示。这可以让我们只有在需要时(如训练时),读取一批图像到内存,因此我们可以处理大规模数据集,且不必担心怎样把整个数据集放到内存中。下例也通过像素插值把图像放缩到 64x64。
train_data = utils.image_dataset_from_directory(
"/app/data/celeba-dataset/img_align_celeba/img_align_celeba",
labels=None,
color_mode="rgb",
image_size=(64,64),
batch_size=128,
shuffle=True,
seed=42,
interpolation='bilinear',
)
原始数据被放缩到[0,255]范围内来表示像素强度,我们进一步如样例3-16所示将之重放缩到[0,1].
def preprocess(img):
img = tf.cast(img,"float32") / 255.0
return img
train = train_data.map(lambda x: preprocess(x))
人脸模型之网络架构与Fashion-MNIST网络架构相似,只有如下小幅度差异:
编码器和解码器的完整架构分别如表3-5和3-6所示。
层(类型) | 输出形状 | 参数数量 |
---|---|---|
InputLayer | (None, 32, 32, 3) | 0 |
Conv2D | (None, 16, 16, 128) | 3584 |
BatchNomalization | (None, 16, 16, 128) | 512 |
LeakyReLU | (None, 16, 16, 128) | 0 |
Conv2D | (None, 8, 8, 128) | 147584 |
BatchNomalization | (None, 8, 8, 128) | 512 |
LeakyReLU | (None, 8, 8, 128) | 0 |
Conv2D | (None, 4, 4, 128) | 147584 |
BatchNomalization | (None, 4, 4, 128) | 512 |
LeakyReLU | (None, 4, 4, 128) | 0 |
Conv2D | (None, 2, 2, 128) | 147584 |
BatchNomalization | (None, 2, 2, 128) | 512 |
LeakyReLU | (None, 2, 2, 128) | 0 |
Flatten | (None, 512) | 0 |
Dense (z_mean) | (None, 200) | 102600 |
Dense (z_log_var) | (None, 200) | 102600 |
Sampling (z) | (None, 200) | 0 |
总参数量 | 653584 |
---|---|
可训练参数量 | 652560 |
非训练参数量 | 1024 |
层(类型) | 输出形状 | 参数数量 |
---|---|---|
InputLayer | (None, 200) | 0 |
Dense | (None, 512) | 102912 |
BatchNomalization | (None, 512) | 2048 |
LeakyReLU | (None, 512) | 0 |
Reshape | (None, 2, 2, 128) | 0 |
Conv2DTranspose | (None, 4, 4, 128) | 147584 |
BatchNomalization | (None, 4, 4, 128) | 512 |
LeakyReLU | (None, 4, 4, 128) | 0 |
Conv2DTranspose | (None, 8, 8, 128) | 147584 |
BatchNomalization | (None, 8, 8, 128) | 512 |
LeakyReLU | (None, 8, 8, 128) | 0 |
Conv2DTranspose | (None, 16, 16, 128) | 147584 |
BatchNomalization | (None, 16, 16, 128) | 512 |
LeakyReLU | (None, 16, 16, 128) | 0 |
Conv2DTranspose | (None, 32, 32, 128) | 147584 |
BatchNomalization | (None, 32, 32, 128) | 512 |
LeakyReLU | (None, 32, 32, 128) | 0 |
Conv2DTranspose | (None, 32, 32, 3) | 3459 |
总参数量 | 700803 |
---|---|
可训练参数量 | 698755 |
非训练参数量 | 2048 |
经过大约五个epochs的训练,我们的VAE将能够产生名人脸的新样本。
首先,让我们观察一下重建脸样本。图3-16的顶行给出了原始图像,底行给出了原始图像经过编码和解码之后的重建图像。
我们可以看到,VAE成功捕获了每张脸的关键特征,头部角度,发型,表情等等。虽然仍然丢失了一些细节,但我们需要记住的是变分自编码器的目标并不是实现完美的重建,我们的终极目标是从隐空间采样,以生成新的脸。
为了让它成为可能,我们必须确保隐空间点的分布大致象一个多元标准正态分布。如果我们能够看到一些维度显著偏离标准正态分布,我们可能需要降低重建误差,因为KL散度项并没有发挥足够的效果。
隐空间前50维如图3-17所示。可以看到所有的分布都没有明显偏离标准正态分布,因此我们可以继续尝试生成一些脸。
我们可以用样例3-17的代码来生成新的人脸。输出如图3-18所示。
grid_width, grid_height = (10,3)
# 从200维标准多元正态分布中采样30个点
z_sample = np.random.normal(size=(grid_width,grid_height,200))
# 解码采样点
reconstructions = decoder.predict(z_sample)
fig = plt.figure(figsize=(18,5))
fig.subplots_adjust(hspace=0.4, wspace=0.4)
# 绘制图像
for i in range(grid_width*grid_height):
ax = fig.add_subplot(grid_height, grid_width, i+1)
ax.axis("off")
ax.imshow(reconstructions[i,:,:])
令人惊讶的是,VAE可以接收我们从标准整体分布中采样的这组点,并将每个点转换成某人的可信人脸。这是我们第一次真正看到生成式模型的力量。
接下来,让我们开始使用隐空间对生成图像进行一些有趣的操作吧。
将图像映射到一个低维隐空间的一大好处在于: 我们可以在隐空间上进行向量的算术操作,该操作在解码回到原始图像空间上时有一个视觉近似。
例如,假定我们想要某人的一幅看起来比较悲伤的图像,并给他一个微笑。为了做到这一点,我们需要首先在隐空间找到一个向量,该向量指向的方向是微笑增加的方向。在原始图像的隐空间编码中加上这个向量可以给我们一个新的点,当我们解码时,我们能够得到原始图像的一个更微笑的版本。
那么,我们该如何找到一个微笑向量呢?CelebA数据库中每张图都有属性的标签,其中之一是 Smiling。如果我们在隐空间中求取所有标签为 Smiling 属性的平均编码位置,并将其减去所有标签非 Smiling 属性的平均编码位置,我们就能得到一个指向 Smiling方向的向量,这正是我们想要的。
概念上,我们在隐空间进行如下操作,其中 alpha 是一个因子,决定我们增加或者减少多大程度的特征向量:
z_new = z + alpha * feature_vector
让我们在动作上看看这个。图3-19给出了隐空间中编码的一些图像。我们给它们加上或者减去一定量的某个向量()例如,Smiling,Black_Hair, Eyeglasses, Young, Male, Blond_Hair) ,以此获取不同版本的图像,其中只有相关特征被改变。
很神奇的是,即使我们在隐空间中将一个点移动了一大截距离,图像的核心并未改变,除了我们想要操作的那一维特征。这展现了变分自编码器获取和调整高层次图像特征的能力。
我们可以用一个相似的思想来在两张脸之间进行变形。想象隐空间中两个点,A和B,代表两幅图像。如果你盯着点A,然后沿着直线从A走到B,把直线上所有的点都进行解码,你将得到从起点脸到目标脸的渐进式转变。
数学上,我们在遍历一条直线,这一过程可以用下面的公式描述:
z_new = z_A * (1 - alpha) + z_B * alpha
这里,alpha 是一个介于 0 到 1之间的数字,它决定了我们沿着这条直线出发,距离A点走了多远。
图3-20展示了整个过程的动态。我们取了两张图,将它们编码到隐空间,然后把它们之间直线上以一定间隔采样的点解码出来。
值得注意的是转换的平滑性 — 尽管存在多个不同特征同时改变 (例如,去除眼镜,头发颜色,性别等),VAE 也能够流畅的进行改变,这表明VAE的隐空间确实是连续空间,并能够被遍历和探索以生成一系列不同的人脸图像。
在本章中,我们已经看到自编码器如何作为生成式建模工具箱中的一个有力工具。我们首先探索了普通的自编码器,其可以将高维图像映射到低维隐空间,以从信息量不足的单个像素中抽取高层次特征。然而,我们很快发现使用普通自编码器作为生成式模型的一些缺陷 — 例如,从学习到的隐空间中进行采样是病态的。
变分自编码器通过在模型中引入随机性,并限制隐空间中点分布解决了这些问题。我们看到,仅仅只用了一些细微的调整,我们就把自编码器转换成了变分自编码器,并给予了它作为真正生成式模型的能力。
最后,我们把新技术应用于人脸生成问题,并看到我们可以如何通过从标准正态分布取点并进行简单解码来生成新的人脸。进一步的,通过在隐空间进行向量算术运算,我们可以实现一些令人吃惊的效果,比如人脸变换和特征操作。
在下一章中,我们将探索一类不同的模型,这类模型现在仍然是生成式建模的一大热门选择: 生成对抗网络。