当前位置:   article > 正文

生成式深度学习

生成式深度学习


本章包括以下内容:

  • 使用 LSTM 生成文本
  • 实现 DeepDream
  • 实现神经风格迁移
  • 变分自编码器
  • 了解生成式对抗网络

我们的感知模式、语言和艺 术作品都具有统计结构。学习这种结构是深度学习算法所擅长的。机器学习模型能够对图像、 音乐和故事的统计潜在空间(latent space)进行学习,然后从这个空间中采样(sample),创造 出与模型在训练数据中所见到的艺术作品具有相似特征的新作品。当然,这种采样本身并不是 艺术创作行为。它只是一种数学运算,算法并没有关于人类生活、人类情感或我们人生经验的 基础知识;相反,它从一种与我们的经验完全不同的经验中进行学习。作为人类旁观者,只能靠我们的解释才能对模型生成的内容赋予意义。但在技艺高超的艺术家手中,算法生成可以变 得非常有意义,并且很美。潜在空间采样会变成一支画笔,能够提高艺术家的能力,增强我们 的创造力,并拓展我们的想象空间。此外,它也不需要专业技能和练习,从而让艺术创作变得 更加容易。它创造了一种纯粹表达的新媒介,将艺术与技巧相分离。

本章将从各个角度探索深度学习在增强艺术创作方面的可能性。我们将介绍序列数据生成 (可用于生成文本或音乐)、DeepDream 以及使用变分自编码器和生成式对抗网络进行图像生成。 我们会让计算机凭空创造出前所未见的内容,可能也会让你梦见科技与艺术交汇处的奇妙可能。 让我们开始吧。

1 使用 LSTM 生成文本

本节将会探讨如何将循环神经网络用于生成序列数据。我们将以文本生成为例,但同样的 技术也可以推广到任何类型的序列数据,你可以将其应用于音符序列来生成新音乐,也可以应 用于笔画数据的时间序列(比如,艺术家在 iPad 上绘画时记录的笔画数据)来一笔一笔地生成绘画,以此类推。

序列数据生成绝不仅限于艺术内容生成。它已经成功应用于语音合成和聊天机器人的对话 生成。Google 于 2016 年发布的 Smart Reply(智能回复)功能,能够对电子邮件或短信自动生 成一组快速回复,采用的也是相似的技术。

1.1 生成式循环网络简史

截至 2014 年年底,还没什么人见过 LSTM 这一缩写,即使在机器学习领域也不常见。用循环网络生成序列数据的成功应用在 2016 年才开始出现在主流领域。但是,这些技术都有着相当长的历史,最早的是 1997 年开发的 LSTM 算法。这一新算法早期用于逐字符地生成文本。

在 20 世纪末和 21 世纪初,Alex Graves 在使用循环网络生成序列数据方面做了重要的开创 性工作。特别是他在 2013 年的工作,利用笔触位置的时间序列将循环混合密度网络应用于生成 类似人类的手写笔迹,有人认为这是一个转折点。a 在那个特定时刻,神经网络的这个具体应 用中,能够做梦的机器这一概念适时地引起了我的兴趣,并且在我开始开发 Keras 时为我提供 了重要的灵感。Graves 在 2013 年上传到预印本服务器 arXiv 上的 LaTeX 文件中留下了一条类似 的注释性评论:“序列数据生成是计算机所做的最接近于做梦的事情。”几年之后,我们将这些 进展视作理所当然,但在当时看到 Graves 的演示,很难不为其中所包含的可能性感到惊叹并受到启发。

从那以后,循环神经网络已被成功应用于音乐生成、对话生成、图像生成、语音合成和分子设计。它甚至还被用于制作电影剧本,然后由真人演员来表演。

1.2 如何生成序列数据

用深度学习生成序列数据的通用方法,就是使用前面的标记作为输入,训练一个网络(通 常是循环神经网络或卷积神经网络)来预测序列中接下来的一个或多个标记。例如,给定输入 the cat is on the ma,训练网络来预测目标 t,即下一个字符。与前面处理文本数据时一样,标记 (token)通常是单词或字符,给定前面的标记,能够对下一个标记的概率进行建模的任何网络 都叫作语言模型(language model)。语言模型能够捕捉到语言的潜在空间(latent space),即语 言的统计结构。

一旦训练好了这样一个语言模型,就可以从中采样(sample,即生成新序列)。向模型中输 入一个初始文本字符串[即条件数据(conditioning data)],要求模型生成下一个字符或下一个 单词(甚至可以同时生成多个标记),然后将生成的输出添加到输入数据中,并多次重复这一过 程(见图 8-1)。这个循环可以生成任意长度的序列,这些序列反映了模型训练数据的结构,它 们与人类书写的句子几乎相同。在本节的示例中,我们将会用到一个 LSTM 层,向其输入从文 本语料中提取的 N 个字符组成的字符串,然后训练模型来生成第 N+1 个字符。模型的输出是对 所有可能的字符做 softmax,得到下一个字符的概率分布。这个 LSTM 叫作字符级的神经语言模型(character-level neural language model)。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-MG8aDjMA-1672460991380)(C:\Users\10555\AppData\Roaming\Typora\typora-user-images\image-20221228113020159.png)]

1.3 采样策略的重要性

生成文本时,如何选择下一个字符至关重要。一种简单的方法是贪婪采样(greedy sampling), 就是始终选择可能性最大的下一个字符。但这种方法会得到重复的、可预测的字符串,看起来 不像是连贯的语言。一种更有趣的方法是做出稍显意外的选择:在采样过程中引入随机性,即 从下一个字符的概率分布中进行采样。这叫作随机采样(stochastic sampling,stochasticity 在这 个领域中就是“随机”的意思)。在这种情况下,根据模型结果,如果下一个字符是 e 的概率为 0.3,那么你会有 30% 的概率选择它。注意,贪婪采样也可以被看作从一个概率分布中进行采样, 即某个字符的概率为 1,其他所有字符的概率都是 0。

从模型的 softmax 输出中进行概率采样是一种很巧妙的方法,它甚至可以在某些时候采样 到不常见的字符,从而生成看起来更加有趣的句子,而且有时会得到训练数据中没有的、听起 来像是真实存在的新单词,从而表现出创造性。但这种方法有一个问题,就是它在采样过程中无法控制随机性的大小

为什么需要有一定的随机性?考虑一个极端的例子——纯随机采样,即从均匀概率分布中 抽取下一个字符,其中每个字符的概率相同。这种方案具有最大的随机性,换句话说,这种概 率分布具有最大的熵。当然,它不会生成任何有趣的内容。再来看另一个极端——贪婪采样。 贪婪采样也不会生成任何有趣的内容,它没有任何随机性,即相应的概率分布具有最小的熵。 从“真实”概率分布(即模型 softmax 函数输出的分布)中进行采样,是这两个极端之间的一 个中间点。但是,还有许多其他中间点具有更大或更小的熵,你可能希望都研究一下。更小的 熵可以让生成的序列具有更加可预测的结构(因此可能看起来更真实),而更大的熵会得到更加 出人意料且更有创造性的序列。从生成式模型中进行采样时,在生成过程中探索不同的随机性 大小总是好的做法。我们人类是生成数据是否有趣的最终判断者,所以有趣是非常主观的,我 们无法提前知道最佳熵的位置。

为了在采样过程中控制随机性的大小,我们引入一个叫作 softmax 温度(softmax temperature) 的参数,用于表示采样概率分布的熵,即表示所选择的下一个字符会有多么出人意料或多么可 预测。给定一个 temperature 值,将按照下列方法对原始概率分布(即模型的 softmax 输出)进行重新加权,计算得到一个新的概率分布。

对于不同的 softmax 温度,对概率分布进行重新加权

import numpy as np

# original_distribution 是概率值组成的一维 Numpy 数组,这些概率值之和必须等于 1。temperature 是一个因子,用于定量描述输出分布的熵
def reweight_distribution(original_distribution, temperature=0.5):
     distribution = np.log(original_distribution) / temperature
     distribution = np.exp(distribution)
     return distribution / np.sum(distribution)
# 返回原始分布重新加权后的结果。distribution 的求和可能不再等于 1,因此需要将它除以求和,以得到新的分布
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

更高的温度得到的是熵更大的采样分布,会生成更加出人意料、更加无结构的数据, 而更低的温度对应更小的随机性,以及更加可预测的生成数据(见图 8-2)。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-AOrXOZkp-1672460991380)(C:\Users\10555\AppData\Roaming\Typora\typora-user-images\image-20221228113453684.png)]

1.4 实现字符级的 LSTM 文本生成

下面用 Keras 来实现这些想法。首先需要可用于学习语言模型的大量文本数据。我们可以 使用任意足够大的一个或多个文本文件——维基百科、《指环王》等。本例将使用尼采的一些作品,他是 19 世纪末期的德国哲学家,这些作品已经被翻译成英文。因此,我们要学习的语言模型将是针对于尼采的写作风格和主题的模型,而不是关于英语的通用模型。

  1. 准备数据

下载并解析初始文本文件

import keras
import numpy as np
import tensorflow

path = tensorflow.keras.utils.get_file(
    'nietzsche.txt',
    origin='https://s3.amazonaws.com/text-datasets/nietzsche.txt')
text = open(path).read().lower()
print('Corpus length:', len(text))
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

接下来,我们要提取长度为 maxlen 的序列(这些序列之间存在部分重叠),对它们进行 one-hot 编码,然后将其打包成形状为 (sequences, maxlen, unique_characters) 的三维 Numpy 数组。与此同时,我们还需要准备一个数组 y,其中包含对应的目标,即在每一个所提 取的序列之后出现的字符(已进行 one-hot 编码)。

将字符序列向量化

# 提取 60 个字符组成的序列
maxlen = 60

# 每 3 个字符采样一个新序列
step = 3

# 保存所提取的序列
sentences = []

# 保存目标(即下一个字符)
next_chars = []

for i in range(0, len(text) - maxlen, step):
    sentences.append(text[i: i + maxlen])
    next_chars.append(text[i + maxlen])
print('Number of sequences:', len(sentences))

# 语料中唯一字符组成的列表
chars = sorted(list(set(text)))
print('Unique characters:', len(chars))
# 一个字典,将唯一字符映射为它在列表 chars 中的索引
char_indices = dict((char, chars.index(char)) for char in chars)

# 将字符 one-hot 编码为二进制数组
print('Vectorization...')
x = np.zeros((len(sentences), maxlen, len(chars)), dtype=np.bool)
y = np.zeros((len(sentences), len(chars)), dtype=np.bool)
for i, sentence in enumerate(sentences):
    for t, char in enumerate(sentence):
        x[i, t, char_indices[char]] = 1
    y[i, char_indices[next_chars[i]]] = 1
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  1. 构建网络

这个网络是一个单层 LSTM,然后是一个 Dense 分类器和对所有可能字符的 softmax。但要 注意,循环神经网络并不是序列数据生成的唯一方法,最近已经证明一维卷积神经网络也可以 成功用于序列数据生成。

用于预测下一个字符的单层 LSTM 模型

from keras import layers

model = keras.models.Sequential()
model.add(layers.LSTM(128, input_shape=(maxlen, len(chars))))
model.add(layers.Dense(len(chars), activation='softmax'))
  • 1
  • 2
  • 3
  • 4
  • 5

目标是经过 one-hot 编码的,所以训练模型需要使用 categorical_crossentropy 作为 损失。

模型编译配置

optimizer = keras.optimizers.RMSprop(lr=0.01)
model.compile(loss='categorical_crossentropy', optimizer=optimizer)
  • 1
  • 2
  1. 训练语言模型并从中采样

给定一个训练好的模型和一个种子文本片段,我们可以通过重复以下操作来生成新的文本。

  • 给定目前已生成的文本,从模型中得到下一个字符的概率分布。
  • 根据某个温度对分布进行重新加权。
  • 根据重新加权后的分布对下一个字符进行随机采样。
  • 将新字符添加到文本末尾。下列代码将对模型得到的原始概率分布进行重新加权,并从中抽取一个字符索引[采样函数(sampling function)]。

给定模型预测,采样下一个字符的函数

def sample(preds, temperature=1.0):
     preds = np.asarray(preds).astype('float64')
     preds = np.log(preds) / temperature
     exp_preds = np.exp(preds)
     preds = exp_preds / np.sum(exp_preds)
     probas = np.random.multinomial(1, preds, 1)
     return np.argmax(probas)
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

最后,下面这个循环将反复训练并生成文本。在每轮过后都使用一系列不同的温度值来生成 文本。这样我们可以看到,随着模型收敛,生成的文本如何变化,以及温度对采样策略的影响。

文本生成循环

import random
import sys

# 将模型训练 60 轮
for epoch in range(1, 60):
    print('epoch', epoch)
    # 将模型在数据上拟合一次
    model.fit(x, y,
              batch_size=128,
              epochs=1)

    # 随机选择一个文本种子
    start_index = random.randint(0, len(text) - maxlen - 1)
    generated_text = text[start_index: start_index + maxlen]
    print('--- Generating with seed: "' + generated_text + '"')
	
    # 尝试一系列不同的采样温度
    for temperature in [0.2, 0.5, 1.0, 1.2]:
        print('------ temperature:', temperature)
        sys.stdout.write(generated_text)

        # 从种子文本开始,生成 400个字符
        for i in range(400):
            sampled = np.zeros((1, maxlen, len(chars)))		# 对目前生成的字符进行one-hot 编码
            for t, char in enumerate(generated_text):
                sampled[0, t, char_indices[char]] = 1.

            preds = model.predict(sampled, verbose=0)[0]	# 对下一个字符进行采样
            next_index = sample(preds, temperature)
            next_char = chars[next_index]

            generated_text += next_char
            generated_text = generated_text[1:]

            sys.stdout.write(next_char)
            sys.stdout.flush()
        print()
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37

这里我们使用的随机种子文本是 new faculty, and the jubilation reached its climax when kant。 第 20 轮时,temperature=0.2 的输出如下所示,此时模型还远没有完全收敛。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-qU4HKXCp-1672460991381)(C:\Users\10555\AppData\Roaming\Typora\typora-user-images\image-20221228114920089.png)]

第 60 轮时,模型已几乎完全收敛,文本看起来更加连贯。此时 temperature=0.2 的结果 如下所示。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Nn8CWGwX-1672460991381)(C:\Users\10555\AppData\Roaming\Typora\typora-user-images\image-20221228114934496.png)][外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-m6rXreD6-1672460991381)(C:\Users\10555\AppData\Roaming\Typora\typora-user-images\image-20221228114943475.png)]

可见,较小的温度值会得到极端重复和可预测的文本,但局部结构是非常真实的,特别是 所有单词都是真正的英文单词(单词就是字符的局部模式)。随着温度值越来越大,生成的文本 也变得更有趣、更出人意料,甚至更有创造性,它有时会创造出全新的单词,听起来有几分可信(比 如 eterned 和 troveration)。对于较大的温度值,局部模式开始分解,大部分单词看起来像是半随 机的字符串。毫无疑问,在这个特定的设置下,0.5 的温度值生成的文本最为有趣。一定要尝试 多种采样策略!在学到的结构与随机性之间,巧妙的平衡能够让生成的序列非常有趣。

注意,利用更多的数据训练一个更大的模型,并且训练时间更长,生成的样本会比上面的 结果看起来更连贯、更真实。但是,不要期待能够生成任何有意义的文本,除非是很偶然的情况。 你所做的只是从一个统计模型中对数据进行采样,这个模型是关于字符先后顺序的模型。语言 是一种信息沟通渠道,信息的内容与信息编码的统计结构是有区别的。为了展示这种区别,我 们来看一个思想实验:如果人类语言能够更好地压缩通信,就像计算机对大部分数字通信所做 的那样,那么会发生什么?语言仍然很有意义,但不会具有任何内在的统计结构,所以不可能像刚才那样学习一个语言模型。

1.5 小结

  • 我们可以生成离散的序列数据,其方法是:给定前面的标记,训练一个模型来预测接下来的一个或多个标记。
  • 对于文本来说,这种模型叫作语言模型。它可以是单词级的,也可以是字符级的。
  • 对下一个标记进行采样,需要在坚持模型的判断与引入随机性之间寻找平衡。
  • 处理这个问题的一种方法是使用 softmax 温度。一定要尝试多种不同的温度,以找到合适的那一个。

2 DeepDream

DeepDream 是一种艺术性的图像修改技术,它用到了卷积神经网络学到的表示。DeepDream 由 Google 于 2015 年夏天首次发布,使用 Caffe 深度学习库编写实现(当时比 TensorFlow 的首次公开发布要早几个月)。它很快在网上引起了轰动,这要归功于它所生成的迷幻图像(比如 图 8-3),图像中充满了算法生成的错觉式伪影、鸟羽毛和狗眼睛。这是 DeepDream 卷积神经网络在 ImageNet 上训练的副作用,因为 ImageNet 中狗和鸟的样本特别多。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8ioFPcsa-1672460991382)(C:\Users\10555\AppData\Roaming\Typora\typora-user-images\image-20221228115213603.png)]

DeepDream 算法与第 5 章介绍的卷积神经网络过滤器可视化技术几乎相同,都是反向运行一个卷积神经网络:对卷积神经网络的输入做梯度上升,以便将卷积神经网络靠顶部的某一层 的某个过滤器激活最大化。DeepDream 使用了相同的想法,但有以下这几个简单的区别。

  • 使用 DeepDream,我们尝试将所有层的激活最大化,而不是将某一层的激活最大化,因此需要同时将大量特征的可视化混合在一起。
  • 不是从空白的、略带有噪声的输入开始,而是从现有的图像开始,因此所产生的效果能够抓住已经存在的视觉模式,并以某种艺术性的方式将图像元素扭曲。
  • 输入图像是在不同的尺度上[叫作八度(octave)]进行处理的,这可以提高可视化的质量。 我们来生成一些 DeepDream 图像。

2.1 用 Keras 实现 DeepDream

我们将从一个在 ImageNet 上预训练的卷积神经网络开始。Keras 中有许多这样的卷积神经网络:VGG16、VGG19、Xception、ResNet50 等。我们可以用其中任何一个来实现 DeepDream, 但我们选择的卷积神经网络会影响可视化的效果,因为不同的卷积神经网络架构会学到不同的特征。最初发布的 DeepDream 中使用的卷积神经网络是一个 Inception 模型,在实践中,人们已经知道 Inception 能够生成漂亮的 DeepDream 图像,所以我们将使用 Keras 内置的 Inception V3 模型。

加载预训练的 Inception V3 模型

from keras.applications import inception_v3
from keras import backend as K

# 我们不需要训练模型,所以这个命令会禁用所有与训练有关的操作
K.set_learning_phase(0)

# 构建不包括全连接层的 Inception V3 网络。使用预训练的 ImageNet 权重来加载模型
model = inception_v3.InceptionV3(weights='imagenet',
                                 include_top=False)
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

接下来,我们要计算损失(loss),即在梯度上升过程中需要最大化的量。在第 5 章的过滤器可视化中,我们试图将某一层的某个过滤器的值最大化。这里,我们要将多个层的所有过滤器的激活同时最大化。具体来说,就是对一组靠近顶部的层激活的 L2 范数进行加权求和,然后将其最大化。选择哪些层(以及它们对最终损失的贡献)对生成的可视化结果具有很大影响, 所以我们希望让这些参数变得易于配置。更靠近底部的层生成的是几何图案,而更靠近顶部的层生成的则是从中能够看出某些 ImageNet 类别(比如鸟或狗)的图案。我们将随意选择 4 层的配置,但你以后一定要探索多个不同的配置。

设置 DeepDream 配置

# 这个字典将层的名称映射为一个系数,这个系数定量
# 表示该层激活对你要最大化的损失的贡献大小。注意,
# 层的名称硬编码在内置的 Inception V3 应用中。可以
# 使用 model.summary() 列出所有层的名称
layer_contributions = {
    'mixed2': 0.2,
    'mixed3': 3.,
    'mixed4': 2.,
    'mixed5': 1.5,
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

接下来,我们来定义一个包含损失的张量,损失就是代码清单 8-9 中层激活的 L2 范数的加权求和。

定义需要最大化的损失

# 创建一个字典,将层的名称映射为层的实例
layer_dict = dict([(layer.name, layer) for layer in model.layers])

# 在定义损失时将层的贡献添加到这个标量变量中
loss = K.variable(0.)
for layer_name in layer_contributions:
    # Add the L2 norm of the features of a layer to the loss.
    coeff = layer_contributions[layer_name]
    activation = layer_dict[layer_name].output	# 获取层的输出

    # 将该层特征的L2范数添加到loss中。为了避免出现边界伪影,损失中仅包含非边界的像素
    scaling = K.prod(K.cast(K.shape(activation), 'float32'))
    loss += coeff * K.sum(K.square(activation[:, 2: -2, 2: -2, :])) / scaling
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

梯度上升过程

# 这个张量用于保存生成的图像,即梦境图像
dream = model.input

# 计算损失相对于梦境图像的梯度
grads = K.gradients(loss, dream)[0]

# 将梯度标准化(重要技巧)
grads /= K.maximum(K.mean(K.abs(grads)), 1e-7)

# 给定一张输出图像,设置一个 Keras 函数来获取损失值和梯度值
outputs = [loss, grads]
fetch_loss_and_grads = K.function([dream], outputs)

def eval_loss_and_grads(x):
    outs = fetch_loss_and_grads([x])
    loss_value = outs[0]
    grad_values = outs[1]
    return loss_value, grad_values

# 这个函数运行iterations次梯度上升
def gradient_ascent(x, iterations, step, max_loss=None):
    for i in range(iterations):
        loss_value, grad_values = eval_loss_and_grads(x)
        if max_loss is not None and loss_value > max_loss:
            break
        print('...Loss value at', i, ':', loss_value)
        x += step * grad_values
    return x
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28

最后就是实际的 DeepDream 算法。首先,我们来定义一个列表,里面包含的是处理图像的尺度(也叫八度)。每个连续的尺度都是前一个的 1.4 倍(放大 40%),即首先处理小图像,然 后逐渐增大图像尺寸(见图 8-4)。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-6bXAqHK2-1672460991382)(C:\Users\10555\AppData\Roaming\Typora\typora-user-images\image-20221228123002005.png)]

对于每个连续的尺度,从最小到最大,我们都需要在当前尺度运行梯度上升,以便将之前 定义的损失最大化。每次运行完梯度上升之后,将得到的图像放大 40%。

在每次连续的放大之后(图像会变得模糊或像素化),为避免丢失大量图像细节,我们可 以使用一个简单的技巧:每次放大之后,将丢失的细节重新注入到图像中。这种方法是可行的, 因为我们知道原始图像放大到这个尺寸应该是什么样子。给定一个较小的图像尺寸 S 和一个较 大的图像尺寸 L,你可以计算将原始图像大小调整为 L 与将原始图像大小调整为 S 之间的区别, 这个区别可以定量描述从 S 到 L 的细节损失。

在多个连续尺度上运行梯度上升

import numpy as np

# 改变这些超参数,可以得到新的效果
step = 0.01  # 梯度上升的步长
num_octave = 3  # 运行梯度上升的尺度个数
octave_scale = 1.4  # 两个尺度之间的大小比例
iterations = 20  # 在每个尺度上运行梯度上升的步数

# 如果损失增大到大于 10,我们要中断梯度上升过程,以避免得到丑陋的伪影
max_loss = 10.

# 将这个变量修改为你要使用的图像的路径
base_image_path = '/home/ubuntu/data/original_photo_deep_dream.jpg'

# 将基础图像加载成一个 Numpy 数组(这个函数在代码清单 8-13 中定义)
img = preprocess_image(base_image_path)

# 准备一个由形状元组组成的列表,它定义了运行梯度上升的不同尺度
original_shape = img.shape[1:3]
successive_shapes = [original_shape]
for i in range(1, num_octave):
    shape = tuple([int(dim / (octave_scale ** i)) for dim in original_shape])
    successive_shapes.append(shape)

# 将形状列表反转,变为升序
successive_shapes = successive_shapes[::-1]

# 将图像 Numpy 数组的大小缩放到最小尺寸
original_img = np.copy(img)
shrunk_original_img = resize_img(img, successive_shapes[0])

for shape in successive_shapes:
    print('Processing image shape', shape)
    img = resize_img(img, shape)	# 将梦境图像放大
    img = gradient_ascent(img,		# 运行梯度上升,改变梦境图像
                          iterations=iterations,
                          step=step,
                          max_loss=max_loss)
    upscaled_shrunk_original_img = resize_img(shrunk_original_img, shape)	# 将原始图像的较小版本放大,它会变得像素化
    same_size_original = resize_img(original_img, shape)		# 在这个尺寸上计算原始图像的高质量版本
    lost_detail = same_size_original - upscaled_shrunk_original_img	# 二者的差别就是在放大过程中丢失的细节

    img += lost_detail	# 将丢失的细节重新注入到梦境图像中
    shrunk_original_img = resize_img(original_img, shape)
    save_img(img, fname='dream_at_scale_' + str(shape) + '.png')

save_img(img, fname='final_dream.png')
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47

注意,上述代码使用了下面这些简单的 Numpy 辅助函数,其功能从名称中就可以看出来。 它们都需要安装 SciPy。

辅助函数

import scipy
from keras.preprocessing import image

def resize_img(img, size):
    img = np.copy(img)
    factors = (1,
               float(size[0]) / img.shape[1],
               float(size[1]) / img.shape[2],
               1)
    return scipy.ndimage.zoom(img, factors, order=1)


def save_img(img, fname):
    pil_img = deprocess_image(np.copy(img))
    scipy.misc.imsave(fname, pil_img)


def preprocess_image(image_path):	
    # 通用函数,用于打开图像、改变图像大小以及将图像格式转换为 Inception V3 模型能够处理的张量
    img = image.load_img(image_path)
    img = image.img_to_array(img)
    img = np.expand_dims(img, axis=0)
    img = inception_v3.preprocess_input(img)
    return img


def deprocess_image(x):
    # 通用函数,将一个张量转换为有效图像
    if K.image_data_format() == 'channels_first':
        x = x.reshape((3, x.shape[2], x.shape[3]))
        x = x.transpose((1, 2, 0))
    else:
        x = x.reshape((x.shape[1], x.shape[2], 3))	# 对 inception_v3.preprocess_input所做的预处理进行反向操作
    x /= 2.
    x += 0.5
    x *= 255.
    x = np.clip(x, 0, 255).astype('uint8')
    return x
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38

因为原始 Inception V3 网络训练识别尺寸为 299×299 的图像中的概念,而上述过程中将 图像尺寸减小很多,所以 DeepDream 实现在尺寸介于 300×300 和 400×400 之间的图像 上能够得到更好的结果。但不管怎样,你都可以在任何尺寸和任何比例的图像上运行同 样的代码。

最开始的照片是在旧金山湾和 Google 校园之间的小山上拍摄的,我们从这张照片得到的 DeepDream 图像如图 8-5 所示。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-evMLWS1r-1672460991382)(C:\Users\10555\AppData\Roaming\Typora\typora-user-images\image-20221228123553403.png)]

我们强烈建议你调节在损失中使用的层,从而探索能够得到什么样的结果。网络中更靠近 底部的层包含更局部、不太抽象的表示,得到的梦境图案看起来更像是几何形状。更靠近顶部 的层能够得到更容易识别的视觉图案,这些图案都是基于 ImageNet 中最常见的对象,比如狗 眼睛、鸟羽毛等。你可以随机生成 layer_contributions 字典中的参数,从而快速探索多种不同的层组合。对于一张自制美味糕点的图像,图 8-6 给出了利用不同的层配置所得到的一系列结果。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-WR3vUf4D-1672460991382)(C:\Users\10555\AppData\Roaming\Typora\typora-user-images\image-20221228123626158.png)]

2.2 小结

  • DeepDream 的过程是反向运行一个卷积神经网络,基于网络学到的表示来生成输入。

  • 得到的结果是很有趣的,有些类似于通过迷幻剂扰乱视觉皮层而诱发的视觉伪影。

  • 注意,这个过程并不局限于图像模型,甚至并不局限于卷积神经网络。它可以应用于语音、 音乐等更多内容。

3 神经风格迁移

除 DeepDream 之外,深度学习驱动图像修改的另一项重大进展是神经风格迁移(neural style transfer),它由 Leon Gatys 等人于 2015 年夏天提出。自首次提出以来,神经风格迁移算法已经做了许多改进,并衍生出许多变体,而且还成功转化成许多智能手机图片应用。为了简单起见,本节将重点介绍原始论文中描述的方法。 神经风格迁移是指将参考图像的风格应用于目标图像,同时保留目标图像的内容。图 8-7 给出了一个示例。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Om2u853y-1672460991383)(C:\Users\10555\AppData\Roaming\Typora\typora-user-images\image-20221228123807767.png)]

在当前语境下,风格(style)是指图像中不同空间尺度的纹理、颜色和视觉图案,内容 (content)是指图像的高级宏观结构。举个例子,在图 8-7 中(用到的参考图像是文森特 • 梵高 的《星夜》),蓝黄色圆形笔划被看作风格,而 Tübingen(图宾根)照片中的建筑则被看作内容。

风格迁移这一想法与纹理生成的想法密切相关,在 2015 年开发出神经风格迁移之前,这一 想法就已经在图像处理领域有着悠久的历史。但事实证明,与之前经典的计算机视觉技术实现相比,基于深度学习的风格迁移实现得到的结果是无与伦比的,并且还在计算机视觉的创造性应用中引发了惊人的复兴。

实现风格迁移背后的关键概念与所有深度学习算法的核心思想是一样的:定义一个损失函数来指定想要实现的目标,然后将这个损失最小化。你知道想要实现的目标是什么,就是保存原始图像的内容,同时采用参考图像的风格。如果我们能够在数学上给出内容和风格的定义, 那么就有一个适当的损失函数(如下所示),我们将对其进行最小化。

loss = distance(style(reference_image) - style(generated_image)) +
 	   distance(content(original_image) - content(generated_image))
  • 1
  • 2

这里的 distance 是一个范数函数,比如 L2 范数;content 是一个函数,输入一张图像,并计算出其内容的表示;style 是一个函数,输入一张图像,并计算出其风格的表示。将 这个损失最小化,会使得 style(generated_image) 接近于 style(reference_image)、 content(generated_image) 接近于 content(generated_image),从而实现我们定义的风格迁移。

Gatys 等人发现了一个很重要的观察结果,就是深度卷积神经网络能够从数学上定义 style 和 content 两个函数。我们来看一下如何定义。

3.1 内容损失

如你所知,网络更靠底部的层激活包含关于图像的局部信息,而更靠近顶部的层则包含更加全局、更加抽象的信息。卷积神经网络不同层的激活用另一种方式提供了图像内容在不同空间尺度上的分解。因此,图像的内容是更加全局和抽象的,我们认为它能够被卷积神经网络更靠顶部的层的表示所捕捉到。

因此,内容损失的一个很好的候选者就是两个激活之间的 L2 范数,一个激活是预训练的卷 积神经网络更靠顶部的某层在目标图像上计算得到的激活,另一个激活是同一层在生成图像上计算得到的激活。这可以保证,在更靠顶部的层看来,生成图像与原始目标图像看起来很相似。 假设卷积神经网络更靠顶部的层看到的就是输入图像的内容,那么这种方法可以保存图像内容。

3.2 风格损失

内容损失只使用了一个更靠顶部的层,但 Gatys 等人定义的风格损失则使用了卷积神经网 络的多个层。我们想要捉到卷积神经网络在风格参考图像的所有空间尺度上提取的外观,而不 仅仅是在单一尺度上。对于风格损失,Gatys 等人使用了层激活的格拉姆矩阵(Gram matrix), 即某一层特征图的内积。这个内积可以被理解成表示该层特征之间相互关系的映射。这些特征 相互关系抓住了在特定空间尺度下模式的统计规律,从经验上来看,它对应于这个尺度上找到 的纹理的外观。

因此,风格损失的目的是在风格参考图像与生成图像之间,在不同的层激活内保存相似的内部相互关系。反过来,这保证了在风格参考图像与生成图像之间,不同空间尺度找到的纹理看起来都很相似。

简而言之,你可以使用预训练的卷积神经网络来定义一个具有以下特点的损失。

在目标内容图像和生成图像之间保持相似的较高层激活,从而能够保留内容。卷积神经网络应该能够“看到”目标图像和生成图像包含相同的内容。

在较低层和较高层的激活中保持类似的相互关系(correlation),从而能够保留风格。特征相互关系捕捉到的是纹理(texture),生成图像和风格参考图像在不同的空间尺度上应该具有相同的纹理。

接下来,我们来用 Keras 实现 2015 年的原始神经风格迁移算法。你将会看到,它与上一节介绍的 DeepDream 算法实现有许多相似之处。

3.3 用 Keras 实现神经风格迁移

神经风格迁移可以用任何预训练卷积神经网络来实现。我们这里将使用 Gatys 等人所使用 的 VGG19 网络。VGG19 是第 5 章介绍的 VGG16 网络的简单变体,增加了三个卷积层。

神经风格迁移的一般过程如下。

  1. 创建一个网络,它能够同时计算风格参考图像、目标图像和生成图像的 VGG19 层激活。
  2. 使用这三张图像上计算的层激活来定义之前所述的损失函数,为了实现风格迁移,需要将这个损失函数最小化。
  3. 设置梯度下降过程来将这个损失函数最小化。

我们首先来定义风格参考图像和目标图像的路径。为了确保处理后的图像具有相似的尺寸 (如果图像尺寸差异很大,会使得风格迁移变得更加困难),稍后需要将所有图像的高度调整为 400 像素。

定义初始变量

from keras.preprocessing.image import load_img, img_to_array

target_image_path = 'img/portrait.jpg'	# 想要变换的图像的路径
style_reference_image_path = 'img/transfer_style_reference.jpg'	# 风格图像的路径

# 生成图像的尺寸
width, height = load_img(target_image_path).size
img_height = 400
img_width = int(width * img_height / height)
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

我们需要一些辅助函数,用于对进出 VGG19 卷积神经网络的图像进行加载、预处理和后处理。

辅助函数

import numpy as np
from keras.applications import vgg19

def preprocess_image(image_path):
    img = load_img(image_path, target_size=(img_height, img_width))
    img = img_to_array(img)
    img = np.expand_dims(img, axis=0)
    img = vgg19.preprocess_input(img)
    return img

def deprocess_image(x):
    # vgg19.preprocess_input 的作用是减去 ImageNet 的平均像素值,使其中心为 0。这里相当于 vgg19.preprocess_input 的逆操作
    x[:, :, 0] += 103.939
    x[:, :, 1] += 116.779
    x[:, :, 2] += 123.68
    
    # 'BGR'->'RGB' 将图像由 BGR 格式转换为 RGB 格式。这也是vgg19.preprocess_input 逆操作的一部分
    x = x[:, :, ::-1]	
    x = np.clip(x, 0, 255).astype('uint8')
    return x
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20

下面构建 VGG19 网络。它接收三张图像的批量作为输入,三张图像分别是风格参考图像、 目标图像和一个用于保存生成图像的占位符。占位符是一个符号张量,它的值由外部 Numpy 张量提供。风格参考图像和目标图像都是不变的,因此使用 K.constant 来定义,但生成图像的占位符所包含的值会随着时间而改变。

加载预训练的 VGG19 网络,并将其应用于三张图像

from keras import backend as K

target_image = K.constant(preprocess_image(target_image_path))
style_reference_image = K.constant(preprocess_image(style_reference_image_path))

# This placeholder will contain our generated image
combination_image = K.placeholder((1, img_height, img_width, 3))

# We combine the 3 images into a single batch
input_tensor = K.concatenate([target_image,
                              style_reference_image,
                              combination_image], axis=0)

# We build the VGG19 network with our batch of 3 images as input.
# The model will be loaded with pre-trained ImageNet weights.
model = vgg19.VGG19(input_tensor=input_tensor,
                    weights='imagenet',
                    include_top=False)
print('Model loaded.')
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19

我们来定义内容损失,它要保证目标图像和生成图像在 VGG19 卷积神经网络的顶层具有相似的结果。

内容损失

def content_loss(base, combination):
    return K.sum(K.square(combination - base))
  • 1
  • 2

接下来是风格损失。它使用一个辅助函数来计算输入矩阵的格拉姆矩阵,即原始特征矩阵中相互关系的映射。

风格损失

def gram_matrix(x):
    features = K.batch_flatten(K.permute_dimensions(x, (2, 0, 1)))
    gram = K.dot(features, K.transpose(features))
    return gram

def style_loss(style, combination):
    S = gram_matrix(style)
    C = gram_matrix(combination)
    channels = 3
    size = img_height * img_width
    return K.sum(K.square(S - C)) / (4. * (channels ** 2) * (size ** 2))
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

除了这两个损失分量,我们还要添加第三个——总变差损失(total variation loss),它对生成 的组合图像的像素进行操作。它促使生成图像具有空间连续性,从而避免结果过度像素化。你可以将其理解为正则化损失。

总变差损失

def total_variation_loss(x):
    a = K.square(
        x[:, :img_height - 1, :img_width - 1, :] - x[:, 1:, :img_width - 1, :])
    b = K.square(
        x[:, :img_height - 1, :img_width - 1, :] - x[:, :img_height - 1, 1:, :])
    return K.sum(K.pow(a + b, 1.25))
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

我们需要最小化的损失是这三项损失的加权平均。为了计算内容损失,我们只使用一个靠顶部的层,即 block5_conv2 层;而对于风格损失,我们需要使用一系列层,既包括顶层也包括底层。最后还需要添加总变差损失。

根据所使用的风格参考图像和内容图像,很可能还需要调节 content_weight 系数(内容损失对总损失的贡献比例)。更大的 content_weight 表示目标内容更容易在生成图像中被识别出来。

定义需要最小化的最终损失

# 将层的名称映射为激活张量的字典
outputs_dict = dict([(layer.name, layer.output) for layer in model.layers])

# 用于内容损失的层
content_layer = 'block5_conv2'

# 用于风格损失的层
style_layers = ['block1_conv1',
                'block2_conv1',
                'block3_conv1',
                'block4_conv1',
                'block5_conv1']

# 损失分量的加权平均所使用的权重
total_variation_weight = 1e-4
style_weight = 1.
content_weight = 0.025

# 添加内容损失
loss = K.variable(0.)	# 在定义损失时将所有分量添加到这个标量变量中
layer_features = outputs_dict[content_layer]
target_image_features = layer_features[0, :, :, :]
combination_features = layer_features[2, :, :, :]
loss += content_weight * content_loss(target_image_features,
                                      combination_features)

# 添加每个目标层的风格损失分量
for layer_name in style_layers:	
    layer_features = outputs_dict[layer_name]
    style_reference_features = layer_features[1, :, :, :]
    combination_features = layer_features[2, :, :, :]
    sl = style_loss(style_reference_features, combination_features)
    loss += (style_weight / len(style_layers)) * sl
    
# 添加总变差损失
loss += total_variation_weight * total_variation_loss(combination_image)
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36

最后需要设置梯度下降过程。在 Gatys 等人最初的论文中,使用 L-BFGS 算法进行最优化, 所以我们这里也将使用这种方法。这是本例与 8.2 节 DeepDream 例子的主要区别。L-BFGS 算 法内置于 SciPy 中,但 SciPy 实现有两个小小的限制。

  • 它需要将损失函数值和梯度值作为两个单独的函数传入。
  • 它只能应用于展平的向量,而我们的数据是三维图像数组。 分别计算损失函数值和梯度值是很低效的,因为这么做会导致二者之间大量的冗余计算。 这一过程需要的时间几乎是联合计算二者所需时间的 2 倍。

为了避免这种情况,我们将创建一 个名为 Evaluator 的 Python 类,它可以同时计算损失值和梯度值,在第一次调用时会返回损失值,同时缓存梯度值用于下一次调用。

设置梯度下降过程

# 获取损失相对于生成图像的梯度
grads = K.gradients(loss, combination_image)[0]

# 用于获取当前损失值和当前梯度值的函数
fetch_loss_and_grads = K.function([combination_image], [loss, grads])

# 这个类将 fetch_loss_and_grads 包装起来,让你可以利用两个单独的方法调用来获取损失和梯度,这是我们要使用的 SciPy 优化器所要求的
class Evaluator(object):	
    
    def __init__(self):
        self.loss_value = None
        self.grads_values = None

    def loss(self, x):
        assert self.loss_value is None
        x = x.reshape((1, img_height, img_width, 3))
        outs = fetch_loss_and_grads([x])
        loss_value = outs[0]
        grad_values = outs[1].flatten().astype('float64')
        self.loss_value = loss_value
        self.grad_values = grad_values
        return self.loss_value

    def grads(self, x):
        assert self.loss_value is not None
        grad_values = np.copy(self.grad_values)
        self.loss_value = None
        self.grad_values = None
        return grad_values

evaluator = Evaluator()
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31

最后,可以使用 SciPy 的 L-BFGS 算法来运行梯度上升过程,在算法每一次迭代时都保存当前的生成图像(这里一次迭代表示 20 个梯度上升步骤)。

风格迁移循环

from scipy.optimize import fmin_l_bfgs_b
from scipy.misc import imsave
import time

result_prefix = 'style_transfer_result'
iterations = 20

x = preprocess_image(target_image_path)		# 这是初始状态:目标图像
x = x.flatten()		# 将图像展平,因为 scipy.optimize.fmin_l_bfgs_b 只能处理展平的向量
for i in range(iterations):
    print('Start of iteration', i)
    start_time = time.time()
    # 对生成图像的像素运行L-BFGS 最优化,以将神经风格损失最小化。注意,必须将计算损失的函数和计算梯度的函数作为两个单独的参数传入
    x, min_val, info = fmin_l_bfgs_b(evaluator.loss, x,
                                     fprime=evaluator.grads, maxfun=20)
    print('Current loss value:', min_val)
    # 保存当前的生成图像
    img = x.copy().reshape((img_height, img_width, 3))
    img = deprocess_image(img)
    fname = result_prefix + '_at_iteration_%d.png' % i
    imsave(fname, img)
    end_time = time.time()
    print('Image saved as', fname)
    print('Iteration %d completed in %ds' % (i, end_time - start_time))
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24

得到的结果如图 8-8 所示。请记住,这种技术所实现的仅仅是一种形式的改变图像纹理, 或者叫纹理迁移。如果风格参考图像具有明显的纹理结构且高度自相似,并且内容目标不需要 高层次细节就能够被识别,那么这种方法的效果最好。它通常无法实现比较抽象的迁移,比如 将一幅肖像的风格迁移到另一幅中。这种算法更接近于经典的信号处理,而不是更接近于人工智能,因此不要指望它能实现魔法般的效果。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XL70obCK-1672460991383)(C:\Users\10555\AppData\Roaming\Typora\typora-user-images\image-20221228143856691.png)]

此外还请注意,这个风格迁移算法的运行速度很慢。但这种方法实现的变换足够简单,只 要有适量的训练数据,一个小型的快速前馈卷积神经网络就可以学会这种变换。因此,实现快 速风格迁移的方法是,首先利用这里介绍的方法,花费大量的计算时间对一张固定的风格参考 图像生成许多输入 - 输出训练样例,然后训练一个简单的卷积神经网络来学习这个特定风格的 变换。一旦完成之后,对一张图像进行风格迁移是非常快的,只是这个小型卷积神经网络的一 次前向传递而已。

3.4 小结

风格迁移是指创建一张新图像,保留目标图像的内容的同时还抓住了参考图像的风格。

内容可以被卷积神经网络更靠顶部的层激活所捕捉到。

风格可以被卷积神经网络不同层激活的内部相互关系所捕捉到。

因此,深度学习可以将风格迁移表述为一个最优化过程,并用到了一个用预训练卷积神 经网络所定义的损失。

从这个基本想法出发,可以有许多变体和改进。

4 用变分自编码器生成图像

从图像的潜在空间中采样,并创建全新图像或编辑现有图像,这是目前最流行也是最成 功的创造性人工智能应用。在本节和下一节中,我们将会介绍一些与图像生成有关的高级概 念,还会介绍该领域中两种主要技术的实现细节,这两种技术分别是变分自编码器(VAE, variational autoencoder)生成式对抗网络(GAN,generative adversarial network)。我们这里介绍的技术不仅适用于图像,使用 GAN 和 VAE 还可以探索声音、音乐甚至文本的潜在空间,但 在实践中,最有趣的结果都是利用图像获得的,这也是我们这里介绍的重点。

4.1 从图像的潜在空间中采样

图像生成的关键思想就是找到一个低维的表示潜在空间(latent space,也是一个向量空间), 其中任意点都可以被映射为一张逼真的图像。能够实现这种映射的模块,即以潜在点作为输入并输出一张图像(像素网格),叫作生成器generator,对于 GAN 而言)或解码器decoder, 对于 VAE 而言)。一旦找到了这样的潜在空间,就可以从中有意地或随机地对点进行采样,并 将其映射到图像空间,从而生成前所未见的图像(见图 8-9)。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-wyytd8gG-1672460991383)(C:\Users\10555\AppData\Roaming\Typora\typora-user-images\image-20221228144242407.png)]

想要学习图像表示的这种潜在空间,GAN 和 VAE 是两种不同的策略,每种策略都有各自的特点。VAE 非常适合用于学习具有良好结构的潜在空间,其中特定方向表示数据中有意义的变化轴(见图 8-10)。GAN 生成的图像可能非常逼真,但它的潜在空间可能没有良好结构,也没有足够的连续性。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3EYzXHbF-1672460991384)(C:\Users\10555\AppData\Roaming\Typora\typora-user-images\image-20221228144500042.png)]

4.2 图像编辑的概念向量

第 6 章介绍词嵌入时,我们已经暗示了概念向量(concept vector)的想法:给定一个表示的潜在空间或一个嵌入空间,空间中的特定方向可能表示原始数据中有趣的变化轴。比如在人 脸图像的潜在空间中,可能存在一个微笑向量(smile vector)s,它满足:如果潜在点 z 是某张 人脸的嵌入表示,那么潜在点 z+s 就是同一张人脸面带微笑的嵌入表示。一旦找到了这样的向量, 就可以用这种方法来编辑图像:将图像投射到潜在空间中,用一种有意义的方式来移动其表示, 然后再将其解码到图像空间。在图像空间中任意独立的变化维度都有概念向量,对于人脸而言, 你可能会发现向人脸添加墨镜的向量、去掉墨镜的向量。将男性面孔变成女性面孔的向量等。 图 8-11 是一个微笑向量的例子,它是由新西兰维多利亚大学设计学院的 Tom White 发现的概念 向量,使用的是在名人人脸数据集(CelebA 数据集)上训练的 VAE。

​ [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-OCBu8RnB-1672460991384)(C:\Users\10555\AppData\Roaming\Typora\typora-user-images\image-20221228145641733.png)]

4.3 变分自编码器

自编码器由 Kingma 和 Welling 于 2013 年 12 月 a 与 Rezende、Mohamed 和 Wierstra 于 2014 年 1 月 b 同时发现,它是一种生成式模型,特别适用于利用概念向量进行图像编辑的任务。它是 一种现代化的自编码器,将深度学习的想法与贝叶斯推断结合在一起。自编码器是一种网络类型, 其目的是将输入编码到低维潜在空间,然后再解码回来。

经典的图像自编码器接收一张图像,通过一个编码器模块将其映射到潜在向量空间,然后再通过一个解码器模块将其解码为与原始图像具有相同尺寸的输出(见图 8-12)。然后,使用与输入图像相同的图像作为目标数据来训练这个自编码器,也就是说,自编码器学习对原始输入 进行重新构建。通过对代码(编码器的输出)施加各种限制,我们可以让自编码器学到比较有趣的数据潜在表示。最常见的情况是将代码限制为低维的并且是稀疏的(即大部分元素为 0), 在这种情况下,编码器的作用是将输入数据压缩为更少二进制位的信息。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-zj9zCXmA-1672460991384)(C:\Users\10555\AppData\Roaming\Typora\typora-user-images\image-20221228150007464.png)]

在实践中,这种经典的自编码器不会得到特别有用或具有良好结构的潜在空间。它们也没有对数据做多少压缩。因此,它们已经基本上过时了。但是,VAE 向自编码器添加了一点统计魔法,迫使其学习连续的、高度结构化的潜在空间。这使得 VAE 已成为图像生成的强大工具。

VAE 不是将输入图像压缩成潜在空间中的固定编码,而是将图像转换为统计分布的参数, 即平均值和方差。本质上来说,这意味着我们假设输入图像是由统计过程生成的,在编码和解码过程中应该考虑这一过程的随机性。然后,VAE 使用平均值和方差这两个参数来从分布中随机采样一个元素,并将这个元素解码到原始输入(见图 8-13)。这个过程的随机性提高了其稳健性,并迫使潜在空间的任何位置都对应有意义的表示,即潜在空间采样的每个点都能解码为有效的输出。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-SN8EqFJi-1672460991385)(C:\Users\10555\AppData\Roaming\Typora\typora-user-images\image-20221228150250524.png)]

从技术角度来说,VAE 的工作原理如下。

  1. 一个编码器模块将输入样本 input_img 转换为表示潜在空间中的两个参数 z_mean 和 z_log_variance。
  2. 我们假定潜在正态分布能够生成输入图像,并从这个分布中随机采样一个点 z:z = z_mean + exp(z_log_variance) * epsilon,其中 epsilon 是取值很小的随机 张量。
  3. 一个解码器模块将潜在空间的这个点映射回原始输入图像。

因为 epsilon 是随机的,所以这个过程可以确保,与 input_img 编码的潜在位置(即 z-mean)靠近的每个点都能被解码为与 input_img 类似的图像,从而迫使潜在空间能够连续地有意义。潜在空间中任意两个相邻的点都会被解码为高度相似的图像。连续性以及潜在空间 的低维度,将迫使潜在空间中的每个方向都表示数据中一个有意义的变化轴,这使得潜在空间具有非常良好的结构,因此非常适合通过概念向量来进行操作。

VAE 的参数通过两个损失函数来进行训练:一个是重构损失(reconstruction loss),它迫使解码后的样本匹配初始输入;另一个是正则化损失(regularization loss),它有助于学习具有良好结构的潜在空间,并可以降低在训练数据上的过拟合。我们来快速浏览一下 Keras 实现的 VAE。其大致代码如下所示。

z_mean, z_log_variance = encoder(input_img)	# 将输入编码为平均值和方差两个参数
z = z_mean + exp(z_log_variance) * epsilon	# 使用小随机数 epsilon 来抽取一个潜在点
reconstructed_img = decoder(z)				# 将 z 解码为一张图像
model = Model(input_img, reconstructed_img)	# 将自编码器模型实例化,它将一张输入图像映射为它的重构
  • 1
  • 2
  • 3
  • 4

然后,你可以使用重构损失和正则化损失来训练模型。下列代码给出了我们将使用的编码器网络,它将图像映射为潜在空间中概率分布的参数。 它是一个简单的卷积神经网络,将输入图像 x 映射为两个向量 z_mean 和 z_log_var。

VAE 编码器网络

import keras
from keras import layers
from keras import backend as K
from keras.models import Model
import numpy as np

img_shape = (28, 28, 1)
batch_size = 16
latent_dim = 2  # 潜在空间的维度:一个二维平面

input_img = keras.Input(shape=img_shape)

x = layers.Conv2D(32, 3,
                  padding='same', activation='relu')(input_img)
x = layers.Conv2D(64, 3,
                  padding='same', activation='relu',
                  strides=(2, 2))(x)
x = layers.Conv2D(64, 3,
                  padding='same', activation='relu')(x)
x = layers.Conv2D(64, 3,
                  padding='same', activation='relu')(x)
shape_before_flattening = K.int_shape(x)

x = layers.Flatten()(x)
x = layers.Dense(32, activation='relu')(x)

# 输入图像最终被编码为这两个参数
z_mean = layers.Dense(latent_dim)(x)
z_log_var = layers.Dense(latent_dim)(x)
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29

接下来的代码将使用 z_mean 和 z_log_var 来生成一个潜在空间点 z,z_mean 和 z_log_ var 是统计分布的参数,我们假设这个分布能够生成 input_img。这里,我们将一些随意的代 码(这些代码构建于 Keras 后端之上)包装到 Lambda 层中。在 Keras 中,任何对象都应该是一 个层,所以如果代码不是内置层的一部分,我们应该将其包装到一个 Lambda 层(或自定义层)中。

潜在空间采样的函数

def sampling(args):
    z_mean, z_log_var = args
    epsilon = K.random_normal(shape=(K.shape(z_mean)[0], latent_dim),
                              mean=0., stddev=1.)
    return z_mean + K.exp(z_log_var) * epsilon

z = layers.Lambda(sampling)([z_mean, z_log_var])
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

下列代码给出了解码器的实现。我们将向量 z 的尺寸调整为图像大小,然后使用几个卷积层来得到最终的图像输出,它和原始图像 input_img 具有相同的大小。

VAE 解码器网络,将潜在空间点映射为图像

# 需要将 z 输入到这里
decoder_input = layers.Input(K.int_shape(z)[1:])

# 对输入进行上采样
x = layers.Dense(np.prod(shape_before_flattening[1:]),
                 activation='relu')(decoder_input)

# 将 z 转换为特征图,使其形状与编码器模型最后一个 Flatten 层之前的特征图的形状相同
x = layers.Reshape(shape_before_flattening[1:])(x)

# 使用一个 Conv2DTranspose 层和一个 Conv2D 层,将 z 解码为与原始输入图像具有相同尺寸的特征图
x = layers.Conv2DTranspose(32, 3,
                           padding='same', activation='relu',
                           strides=(2, 2))(x)
x = layers.Conv2D(1, 3,
                  padding='same', activation='sigmoid')(x)
# We end up with a feature map of the same size as the original input.

# 将解码器模型实例化,它将 decoder_input转换为解码后的图像
decoder = Model(decoder_input, x)

# 将这个实例应用于 z,以得到解码后的 z
z_decoded = decoder(z)
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23

我们一般认为采样函数的形式为 loss(input, target),VAE 的双重损失不符合这种形 式。因此,损失的设置方法为:编写一个自定义层,并在其内部使用内置的 add_loss 层方法 来创建一个你想要的损失。

用于计算 VAE 损失的自定义层

class CustomVariationalLayer(keras.layers.Layer):

    def vae_loss(self, x, z_decoded):
        x = K.flatten(x)
        z_decoded = K.flatten(z_decoded)
        xent_loss = keras.metrics.binary_crossentropy(x, z_decoded)
        kl_loss = -5e-4 * K.mean(
            1 + z_log_var - K.square(z_mean) - K.exp(z_log_var), axis=-1)
        return K.mean(xent_loss + kl_loss)

    def call(self, inputs):		# 通过编写一个 call 方法来实现自定义层
        x = inputs[0]
        z_decoded = inputs[1]
        loss = self.vae_loss(x, z_decoded)
        self.add_loss(loss, inputs=inputs)
        # We don't use this output.
        return x	# 我们不使用这个输出,但层必须要有返回值

# 对输入和解码后的输出调用自定义层,以得到最终的模型输出
y = CustomVariationalLayer()([input_img, z_decoded])
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20

最后,将模型实例化并开始训练。因为损失包含在自定义层中,所以在编译时无须指定外部损失(即 loss=None),这意味着在训练过程中不需要传入目标数据。(如你所见,我们在调 用 fit 时只向模型传入了 x_train。)

训练 VAE

from keras.datasets import mnist

vae = Model(input_img, y)
vae.compile(optimizer='rmsprop', loss=None)
vae.summary()

# Train the VAE on MNIST digits
(x_train, _), (x_test, y_test) = mnist.load_data()

x_train = x_train.astype('float32') / 255.
x_train = x_train.reshape(x_train.shape + (1,))
x_test = x_test.astype('float32') / 255.
x_test = x_test.reshape(x_test.shape + (1,))

vae.fit(x=x_train, y=None,
        shuffle=True,
        epochs=10,
        batch_size=batch_size,
        validation_data=(x_test, None))
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19

一旦训练好了这样的模型(本例中是在 MNIST 上训练),我们就可以使用 decoder 网络将任意潜在空间向量转换为图像。

从二维潜在空间中采样一组点的网格,并将其解码为图像

import matplotlib.pyplot as plt
from scipy.stats import norm

# 我们将显示 15×15 的数字网格(共 255 个数字)
n = 15  
digit_size = 28
figure = np.zeros((digit_size * n, digit_size * n))
# 使用 SciPy 的 ppf 函数对线性分隔的坐标进行变换,以生成潜在变量 z 的值(因为潜在空间的先验分布是高斯分布)
grid_x = norm.ppf(np.linspace(0.05, 0.95, n))
grid_y = norm.ppf(np.linspace(0.05, 0.95, n))

for i, yi in enumerate(grid_x):
    for j, xi in enumerate(grid_y):
        z_sample = np.array([[xi, yi]])
        z_sample = np.tile(z_sample, batch_size).reshape(batch_size, 2)	# 将 z 多次重复,以构建一个完整的批量
        x_decoded = decoder.predict(z_sample, batch_size=batch_size)	# 将批量解码为数字图像
        digit = x_decoded[0].reshape(digit_size, digit_size)			# 将批量第一个数字的形状从 28×28×1 转变为 28×28
        figure[i * digit_size: (i + 1) * digit_size,
               j * digit_size: (j + 1) * digit_size] = digit

plt.figure(figsize=(10, 10))
plt.imshow(figure, cmap='Greys_r')
plt.show()
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23

采样数字的网格(见图 8-14)展示了不同数字类别的完全连续分布:当你沿着潜在空间的一条路径观察时,你会观察到一个数字逐渐变形为另一个数字。这个空间的特定方向具有一定 的意义,比如,有一个方向表示“逐渐变为 4”、有一个方向表示“逐渐变为 1”等。

下一节我们将会详细介绍生成人造图像的另一个重要工具,即生成式对抗网络(GAN)

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-AXSIvRnO-1672460991385)(C:\Users\10555\AppData\Roaming\Typora\typora-user-images\image-20221228154914561.png)]

4.4 小结

  • 用深度学习进行图像生成,就是通过对潜在空间进行学习来实现的,这个潜在空间能够捕捉到关于图像数据集的统计信息。通过对潜在空间中的点进行采样和解码,我们可以生成前所未见的图像。这种方法有两种重要工具:变分自编码器(VAE)和生成式对抗网络(GAN)。
  • VAE 得到的是高度结构化的、连续的潜在表示。因此,它在潜在空间中进行各种图像编 辑的效果很好,比如换脸、将皱眉脸换成微笑脸等。它制作基于潜在空间的动画效果也很好,比如沿着潜在空间的一个横截面移动,从而以连续的方式显示从一张起始图像缓慢变化为不同图像的效果。
  • GAN 可以生成逼真的单幅图像,但得到的潜在空间可能没有良好的结构,也没有很好的连续性。

对于图像,我见过的大多数成功的实际应用都是依赖于 VAE 的,但 GAN 在学术研究领域非常流行,至少在 2016—2017 年左右是这样。下一节将会介绍 GAN 的工作原理以及实现。

如果你想进一步研究图像生成,我建议你使用大规模名人人脸属性(CelebA)数据集。 它是一个可以免费下载的图像数据集,里面包含超过 20 万张名人肖像,特别适合用概念向量进行实验,其结果肯定能打败 MNIST。

5 生成式对抗网络简介

生成式对抗网络(GAN,generative adversarial network)由 Goodfellow 等人于 2014 年提出 ,它可以替代 VAE 来学习图像的潜在空间。它能够迫使生成图像与真实图像在统计上几乎无法区分,从而生成相当逼真的合成图像。

对 GAN 的一种直观理解是,想象一名伪造者试图伪造一副毕加索的画作。一开始,伪造者 非常不擅长这项任务。他将自己的一些赝品与毕加索真迹混在一起,并将其展示给一位艺术商人。 艺术商人对每幅画进行真实性评估,并向伪造者给出反馈,告诉他是什么让毕加索作品看起来像一幅毕加索作品。伪造者回到自己的工作室,并准备一些新的赝品。随着时间的推移,伪造者变得越来越擅长模仿毕加索的风格,艺术商人也变得越来越擅长找出赝品。最后,他们手上拥有了一些优秀的毕加索赝品。

这就是 GAN 的工作原理:一个伪造者网络和一个专家网络,二者训练的目的都是为了打败彼此。因此,GAN 由以下两部分组成。

生成器网络(generator network):它以一个随机向量(潜在空间中的一个随机点)作为输入,并将其解码为一张合成图像。

判别器网络(discriminator network):以一张图像(真实的或合成的均可)作为输入,并预测该图像是来自训练集还是由生成器网络创建。

训练生成器网络的目的是使其能够欺骗判别器网络,因此随着训练的进行,它能够逐渐生成越来越逼真的图像,即看起来与真实图像无法区分的人造图像,以至于判别器网络无法区分二 者(见图 8-15)。与此同时,判别器也在不断适应生成器逐渐提高的能力,为生成图像的真实性 设置了很高的标准。一旦训练结束,生成器就能够将其输入空间中的任何点转换为一张可信图像 (见图 8-16)。与 VAE 不同,这个潜在空间无法保证具有有意义的结构,而且它还是不连续的。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-H0YorbiW-1672460991385)(C:\Users\10555\AppData\Roaming\Typora\typora-user-images\image-20221228160402960.png)]

值得注意的是,GAN 这个系统与本书中其他任何训练方法都不同,它的优化最小值是不固 定的。通常来说,梯度下降是沿着静态的损失地形滚下山坡。但对于 GAN 而言,每下山一步, 都会对整个地形造成一点改变。它是一个动态的系统,其最优化过程寻找的不是一个最小值, 而是两股力量之间的平衡。因此,GAN 的训练极其困难,想要让 GAN 正常运行,需要对模型 架构和训练参数进行大量的仔细调整。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-acBSLwaM-1672460991386)(C:\Users\10555\AppData\Roaming\Typora\typora-user-images\image-20221228160437667.png)]

5.1 GAN 的简要实现流程

本节将会介绍如何用 Keras 来实现形式最简单的 GAN。GAN 属于高级应用,所以本书 不会深入介绍其技术细节。我们具体实现的是一个深度卷积生成式对抗网络(DCGAN,deep convolutional GAN),即生成器和判别器都是深度卷积神经网络的 GAN。特别地,它在生成器中 使用 Conv2DTranspose 层进行图像上采样。

我们将在 CIFAR10 数据集的图像上训练 GAN,这个数据集包含 50 000 张 32×32 的 RGB图像,这些图像属于 10 个类别(每个类别 5000 张图像)。为了简化,我们只使用属于“frog”(青蛙)类别的图像。

GAN 的简要实现流程如下所示。

  1. generator网络将形状为(latent_dim,)的向量映射到形状为(32, 32, 3)的图像。
  2. discriminator 网络将形状为 (32, 32, 3) 的图像映射到一个二进制分数,用于评估图像为真的概率。
  3. gan 网络将 generator 网络和 discriminator 网络连接在一起:gan(x) = discriminator (generator(x))。生成器将潜在空间向量解码为图像,判别器对这些图像的真实性进行评估,因此这个 gan 网络是将这些潜在向量映射到判别器的评估结果。
  4. 我们使用带有“真”/“假”标签的真假图像样本来训练判别器,就和训练普通的图像分类模型一样。
  5. 为了训练生成器,我们要使用 gan 模型的损失相对于生成器权重的梯度。这意味着,在每一步都要移动生成器的权重,其移动方向是让判别器更有可能将生成器解码的图像划分为“真”。换句话说,我们训练生成器来欺骗判别器。

5.2 大量技巧

训练 GAN 和调节 GAN 实现的过程非常困难。你应该记住一些公认的技巧。与深度学习中 的大部分内容一样,这些技巧更像是炼金术而不是科学,它们是启发式的指南,并没有理论上 的支持。这些技巧得到了一定程度的来自对现象的直观理解的支持,经验告诉我们,它们的效 果都很好,但不一定适用于所有情况。

下面是本节实现 GAN 生成器和判别器时用到的一些技巧。这里并没有列出与 GAN 相关的 全部技巧,更多技巧可查阅关于 GAN 的文献。

  • 我们使用 tanh 作为生成器最后一层的激活,而不用 sigmoid,后者在其他类型的模型中更加常见。
  • 我们使用正态分布(高斯分布)对潜在空间中的点进行采样,而不用均匀分布。
  • 随机性能够提高稳健性。训练GAN得到的是一个动态平衡,所以GAN可能以各种方式“卡住”。在训练过程中引入随机性有助于防止出现这种情况。我们通过两种方式引入随机性: 一种是在判别器中使用 dropout,另一种是向判别器的标签添加随机噪声。
  • 稀疏的梯度会妨碍 GAN 的训练。在深度学习中,稀疏性通常是我们需要的属性,但在 GAN 中并非如此。有两件事情可能导致梯度稀疏:最大池化运算和 ReLU 激活。我们推荐使用步进卷积代替最大池化来进行下采样,还推荐使用 LeakyReLU 层来代替 ReLU 激 活。LeakyReLU 和 ReLU 类似,但它允许较小的负数激活值,从而放宽了稀疏性限制。
  • 在生成的图像中,经常会见到棋盘状伪影,这是由生成器中像素空间的不均匀覆盖导致的 (见图 8-17)。为了解决这个问题,每当在生成器和判别器中都使用步进的 Conv2DTranpose 或 Conv2D 时,使用的内核大小要能够被步幅大小整除。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vxcIHVcV-1672460991386)(C:\Users\10555\AppData\Roaming\Typora\typora-user-images\image-20221228161026282.png)]

5.3 生成器

首先,我们来开发 generator 模型,它将一个向量(来自潜在空间,训练过程中对其随机 采样)转换为一张候选图像。GAN 常见的诸多问题之一,就是生成器“卡在”看似噪声的生成 图像上。一种可行的解决方案是在判别器和生成器中都使用 dropout。

GAN 生成器网络

import keras
from keras import layers
import numpy as np

latent_dim = 32
height = 32
width = 32
channels = 3

generator_input = keras.Input(shape=(latent_dim,))

# 将输入转换为大小为 16×16 的 128 个通道的特征图
x = layers.Dense(128 * 16 * 16)(generator_input)
x = layers.LeakyReLU()(x)
x = layers.Reshape((16, 16, 128))(x)

# Then, add a convolution layer
x = layers.Conv2D(256, 5, padding='same')(x)
x = layers.LeakyReLU()(x)

# 上采样为 32×32
x = layers.Conv2DTranspose(256, 4, strides=2, padding='same')(x)
x = layers.LeakyReLU()(x)

# Few more conv layers
x = layers.Conv2D(256, 5, padding='same')(x)
x = layers.LeakyReLU()(x)
x = layers.Conv2D(256, 5, padding='same')(x)
x = layers.LeakyReLU()(x)

# 生成一个大小为 32×32 的单通道特征图(即 CIFAR10 图像的形状)
x = layers.Conv2D(channels, 7, activation='tanh', padding='same')(x)
generator = keras.models.Model(generator_input, x)	# 将生成器模型实例化,它将形状为 (latent_dim,)的输入映射到形状为 (32, 32, 3) 的图像
generator.summary()
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34

5.4 判别器

接下来,我们来开发 discriminator 模型,它接收一张候选图像(真实的或合成的)作为输入,并将其划分到这两个类别之一:“生成图像”或“来自训练集的真实图像”。

GAN 判别器网络

discriminator_input = layers.Input(shape=(height, width, channels))
x = layers.Conv2D(128, 3)(discriminator_input)
x = layers.LeakyReLU()(x)
x = layers.Conv2D(128, 4, strides=2)(x)
x = layers.LeakyReLU()(x)
x = layers.Conv2D(128, 4, strides=2)(x)
x = layers.LeakyReLU()(x)
x = layers.Conv2D(128, 4, strides=2)(x)
x = layers.LeakyReLU()(x)
x = layers.Flatten()(x)

# 一个 dropout 层:这是很重要的技巧
x = layers.Dropout(0.4)(x)

# 分类层
x = layers.Dense(1, activation='sigmoid')(x)

discriminator = keras.models.Model(discriminator_input, x)
discriminator.summary()

# 将判别器模型实例化,它将形状为 (32, 32, 3)的输入转换为一个二进制分类决策(真 / 假)
discriminator_optimizer = keras.optimizers.RMSprop(lr=0.0008, 
                                                   clipvalue=1.0, 	# 在优化器中使用梯度裁剪(限制梯度值的范围)
                                                   decay=1e-8)		# 为了稳定训练过程,使用学习率衰减

discriminator.compile(optimizer=discriminator_optimizer, loss='binary_crossentropy')
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26

5.5 对抗网络

最后,我们要设置 GAN,将生成器和判别器连接在一起。训练时,这个模型将让生成器向 某个方向移动,从而提高它欺骗判别器的能力。这个模型将潜在空间的点转换为一个分类决策(即 “真”或“假”),它训练的标签都是“真实图像”。因此,训练 gan 将会更新 generator 的权重, 使得 discriminator 在观察假图像时更有可能预测为“真”。请注意,有一点很重要,就是在 训练过程中需要将判别器设置为冻结(即不可训练),这样在训练 gan 时它的权重才不会更新。 如果在此过程中可以对判别器的权重进行更新,那么我们就是在训练判别器始终预测“真”,但这并不是我们想要的!

# 将判别器权重设置为不可训练(仅应用于 gan 模型)
discriminator.trainable = False

gan_input = keras.Input(shape=(latent_dim,))
gan_output = discriminator(generator(gan_input))
gan = keras.models.Model(gan_input, gan_output)

gan_optimizer = keras.optimizers.RMSprop(lr=0.0004, clipvalue=1.0, decay=1e-8)
gan.compile(optimizer=gan_optimizer, loss='binary_crossentropy')
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

5.6 如何训练 DCGAN

现在开始训练。再次强调一下,训练循环的大致流程如下所示。每轮都进行以下操作。

  1. 从潜在空间中抽取随机的点(随机噪声)。
  2. 利用这个随机噪声用 generator 生成图像。
  3. 将生成图像与真实图像混合。
  4. 使用这些混合后的图像以及相应的标签(真实图像为“真”,生成图像为“假”)来训练 discriminator,如图 8-18 所示。
  5. 在潜在空间中随机抽取新的点。
  6. 使用这些随机向量以及全部是“真实图像”的标签来训练 gan。这会更新生成器的权重 (只更新生成器的权重,因为判别器在 gan 中被冻结),其更新方向是使得判别器能够将生成图像预测为“真实图像”。这个过程是训练生成器去欺骗判别器。

我们来实现这一流程。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2dozdUfq-1672460991386)(C:\Users\10555\AppData\Roaming\Typora\typora-user-images\image-20221228164645584.png)]

实现 GAN 的训练

import os
from keras.preprocessing import image

# 加载 CIFAR10 数据
(x_train, y_train), (_, _) = keras.datasets.cifar10.load_data()

# 选择青蛙图像(类别编号为 6)
x_train = x_train[y_train.flatten() == 6]

# 数据标准化
x_train = x_train.reshape(
    (x_train.shape[0],) + (height, width, channels)).astype('float32') / 255.

iterations = 10000
batch_size = 20
save_dir = '/home/ubuntu/gan_images/'	# 指定保存生成图像的目录

# Start training loop
start = 0
for step in range(iterations):
    # 在潜在空间中采样随机点
    random_latent_vectors = np.random.normal(size=(batch_size, latent_dim))

    # 将这些点解码为虚假图像
    generated_images = generator.predict(random_latent_vectors)

    # 将这些虚假图像与真实图像合在一起
    stop = start + batch_size
    real_images = x_train[start: stop]
    combined_images = np.concatenate([generated_images, real_images])

    # 合并标签,区分真实和虚假的图像
    labels = np.concatenate([np.ones((batch_size, 1)),
                             np.zeros((batch_size, 1))])
    
    # 向标签中添加随机噪声,这是一个很重要的技巧
    labels += 0.05 * np.random.random(labels.shape)

    # 训练判别器
    d_loss = discriminator.train_on_batch(combined_images, labels)

    # 在潜在空间中采样随机点
    random_latent_vectors = np.random.normal(size=(batch_size, latent_dim))

    # 合并标签,全部是“真实图像”(这是在撒谎)
    misleading_targets = np.zeros((batch_size, 1))

    # 通过 gan 模型来训练生成器(此时冻结判别器权重)
    a_loss = gan.train_on_batch(random_latent_vectors, misleading_targets)
    
    start += batch_size
    if start > len(x_train) - batch_size:
      start = 0

    # 每 100 步保存并绘图
    if step % 100 == 0:
        # 保存模型权重
        gan.save_weights('gan.h5')

        # 将指标打印出来
        print('discriminator loss at step %s: %s' % (step, d_loss))
        print('adversarial loss at step %s: %s' % (step, a_loss))

        # 保存一张生成图像
        img = image.array_to_img(generated_images[0] * 255., scale=False)
        img.save(os.path.join(save_dir, 'generated_frog' + str(step) + '.png'))

        # 保存一张真实图像,用于对比
        img = image.array_to_img(real_images[0] * 255., scale=False)
        img.save(os.path.join(save_dir, 'real_frog' + str(step) + '.png'))
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70

训练时你可能会看到,对抗损失开始大幅增加,而判别损失则趋向于零,即判别器最终支配 了生成器。如果出现了这种情况,你可以尝试减小判别器的学习率,并增大判别器的 dropout 比率。

5.7 小结

  • GAN 由一个生成器网络和一个判别器网络组成。判别器的训练目的是能够区分生成器的输出与来自训练集的真实图像,生成器的训练目的是欺骗判别器。值得注意的是,生成器从未直接见过训练集中的图像,它所知道的关于数据的信息都来自于判别器。
  • GAN 很难训练,因为训练 GAN 是一个动态过程,而不是具有固定损失的简单梯度下降过程。想要正确地训练 GAN,需要使用一些启发式技巧,还需要大量的调节。
  • GAN 可能会生成非常逼真的图像。但与 VAE 不同,GAN 学习的潜在空间没有整齐的连续结构,因此可能不适用于某些实际应用,比如通过潜在空间概念向量进行图像编辑。

6 本章总结

借助深度学习的创造性应用,深度网络不仅能够对现有内容进行标注,还能够自己生成新内容。本章我们学到的内容如下。

  • 如何生成序列数据,每次生成一个时间步。这可以应用于文本生成,也可应用于逐个音符的音乐生成或其他任何类型的时间序列数据。

  • DeepDream 的工作原理:通过输入空间中的梯度上升将卷积神经网络的层激活最大化。

  • 如何实现风格迁移,即将内容图像和风格图像组合在一起,并产生有趣的效果。

  • 什么是对抗式生成网络(GAN),什么是变分自编码器(VAE),它们如何用于创造新图像,以及如何使用潜在空间概念向量进行图像编辑。

    gan.save_weights('gan.h5')
    
      # 将指标打印出来
      print('discriminator loss at step %s: %s' % (step, d_loss))
      print('adversarial loss at step %s: %s' % (step, a_loss))
    
      # 保存一张生成图像
      img = image.array_to_img(generated_images[0] * 255., scale=False)
      img.save(os.path.join(save_dir, 'generated_frog' + str(step) + '.png'))
    
      # 保存一张真实图像,用于对比
      img = image.array_to_img(real_images[0] * 255., scale=False)
      img.save(os.path.join(save_dir, 'real_frog' + str(step) + '.png'))
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

训练时你可能会看到,对抗损失开始大幅增加,而判别损失则趋向于零,即判别器最终支配 了生成器。如果出现了这种情况,你可以尝试减小判别器的学习率,并增大判别器的 dropout 比率。

### 5.7 小结

- GAN 由一个生成器网络和一个判别器网络组成。判别器的训练目的是能够区分生成器的输出与来自训练集的真实图像,生成器的训练目的是欺骗判别器。值得注意的是,生成器从未直接见过训练集中的图像,它所知道的关于数据的信息都来自于判别器。
- GAN 很难训练,因为训练 GAN 是一个动态过程,而不是具有固定损失的简单梯度下降过程。想要正确地训练 GAN,需要使用一些启发式技巧,还需要大量的调节。
- GAN 可能会生成非常逼真的图像。但与 VAE 不同,GAN 学习的潜在空间没有整齐的连续结构,因此可能不适用于某些实际应用,比如通过潜在空间概念向量进行图像编辑。

## 6 本章总结

借助深度学习的创造性应用,深度网络不仅能够对现有内容进行标注,还能够自己生成新内容。本章我们学到的内容如下。

- 如何生成序列数据,每次生成一个时间步。这可以应用于文本生成,也可应用于逐个音符的音乐生成或其他任何类型的时间序列数据。
- DeepDream 的工作原理:通过输入空间中的梯度上升将卷积神经网络的层激活最大化。
- 如何实现风格迁移,即将内容图像和风格图像组合在一起,并产生有趣的效果。
- 什么是对抗式生成网络(GAN),什么是变分自编码器(VAE),它们如何用于创造新图像,以及如何使用潜在空间概念向量进行图像编辑。

这几项技术仅涉及了这一快速发展领域的基础知识,还有许多内容等待你去探索。仅生成式深度学习这一领域的内容就可以写一整本书。
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/繁依Fanyi0/article/detail/311944?site
推荐阅读
相关标签
  

闽ICP备14008679号