第八章 生成式深度学习

人工智能模拟人类思维过程的可能性,并不局限于被动性任务(比如目标识别)和大多数反应性任务(比如驾驶汽车),它还包括创造性活动。很大一部分的艺术创作都是简单的模式识别与专业技能。这正是很多人认为没有吸引力、甚至可有可无的那部分过程。这也正是人工智能发挥作用的地方。我们的感知模式、语言和艺术作品都具有统计结构。学习这种结构是深度学习算法所擅长的。机器学习模型能够对图像、音乐和故事的统计潜在空间(latent space) 进行学习,然后从这个空间中采样(sample),创造出与模型在训练数据中所见到的艺术作品具有相似特征的新作品。当然,这种采样本身并不是艺术创作行为,只是一种数学运算。

8.1 使用 LSTM 生成文本

本节介绍了如何将循环神经网络用于生成序列数据。序列数据生成绝不仅限于艺术内容生成。它已经成功应用于语音合成和聊天机器人的对话生成。

8.1.1 生成式循环网络简史(略)

8.1.2 如何生成序列数据

用深度学习生成序列数据的思路是:使用前面的标记作为输入,训练一个网络来预测序列中接下来的一个或多个标记。具体来说,就是参考第六章的温度预测模型,以截取的文本作为输入,预测下一字符(或单词),训练模型。

给定前面的标记(token),能够对下一个标记的概率进行建模的任何网络都叫作语言模型(language model)。语言模型能够捕捉到语言的潜在空间(latent space),即语言的统计结构

一旦训练好了这样一个语言模型,就可以从中采样(sample,即生成新序列)。向模型中输入一个初始文本字符串[即条件数据(conditioning data)],要求模型生成下一个字符或下一个单词(甚至可以同时生成多个标记),然后将生成的输出添加到输入数据中。通过重复这一过程便可以实现文本的自动生成。
在这里插入图片描述

8.1.3 采样策略的重要性

生成文本时,如何选择下一个字符至关重要。一种简单的方法是 贪婪采样(greedy sampling),就是始终选择可能性最大的下一个字符。但这种方法会得到重复的、可预测的字符串,看起来不像是连贯的语言。另一种方法是 随机采样(stochastic sampling),即从下一个字符的概率分布中进行采样。

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

import numpy as np

def reweight_distribution(original_distribution, temperature=0.5): 
    """ 实现指数运算,通过开方来减小熵,次方来增大熵 """
    distribution = np.log(original_distribution) / temperature
    distribution = np.exp(distribution)
    return distribution / np.sum(distribution)   # 使加权后的概率值之和为1

在这里插入图片描述

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

一、准备数据

1、下载并解析初始文本文件

import keras
import numpy as np

path = 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))

2、将字符序列向量化

提取长度为 maxlen 的序列(这些序列之间存在部分重叠),对它们进行 one-hot 编码,然后将其打包成形状为 (sequences, maxlen, unique_characters) 的三维 Numpy 数组。

maxlen = 60 
step = 3 

sentences = [] 
next_chars = [] 

""" 制作输入文本列表和预测字符列表 """
for i in range(0, len(text) - maxlen, step):   # 每隔3个字符采样一次
    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))
char_indices = dict((char, chars.index(char)) for char in chars)   # 字符:索引的字典

print('Vectorization...')
x = np.zeros((len(sentences), maxlen, len(chars)), dtype=np.bool) 
y = np.zeros((len(sentences), len(chars)), dtype=np.bool)

""" one-hot 编码 """
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

二、构建网络

3、用于预测下一个字符的单层 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'))

4、模型编译配置

optimizer = keras.optimizers.RMSprop(lr=0.01)
model.compile(loss='categorical_crossentropy', optimizer=optimizer)

三、训练语言模型并从中采样

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

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)   # 随机采样,返回ndarray
    return np.argmax(probas)                      # 返回索引

np.random.multinomial 的参数和用法可以参考这里

6、文本生成循环

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 + '"')
    
    """ 查看不同 softmax 温度值下的生成文本 """
    for temperature in [0.2, 0.5, 1.0, 1.2]: 
        print('------ temperature:', temperature)
        sys.stdout.write(generated_text)   # 打印文本,print会在末尾添加换行符
        
        """ 生成400个字符 """
        for i in range(400): 
            
            """ 初始输入文本one-hot编码 """
            sampled = np.zeros((1, maxlen, len(chars))) 
            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)   # 打印生成的字符

较小的温度值会得到极端重复和可预测的文本,但局部结构是非常真实的,特别是所有单词都是真正的英文单词(单词就是字符的局部模式)。随着温度值越来越大,生成的文本也变得更有趣、更出人意料,甚至更有创造性。对于较大的温度值,局部模式开始分解,大部分单词看起来像是半随机的字符串。

应注意,语言是一种信息沟通渠道,信息的内容信息编码的统计结构是有区别的。不要期待能够生成任何有意义的文本,除非是很偶然的情况。

8.1.5 小结

1、构建 语言模型 的思路。
2、利用 softmax 温度 调节生成文本的随机性。
3、实现简单的文本生成。

8.2 DeepDream

DeepDream 是一种艺术性的图像修改技术,它由 Google 于 2015 年夏天首次发布,使用了 Caffe 深度学习库编写实现。

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

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

8.2.1 用 Keras 实现 DeepDream

1、加载预训练的 Inception V3 模型

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

K.set_learning_phase(0)   # 禁用所有与训练有关的操作

# 使用预训练的 ImageNet 权重,不包括全连接层
model = inception_v3.InceptionV3(weights='imagenet', 
                                 include_top=False)   

2、设置 DeepDream 配置

# 这个字典将层的名称映射为最终损失的权重
layer_contributions = {   
    'mixed2': 0.2,
    'mixed3': 3.,
    'mixed4': 2.,
    'mixed5': 1.5,
}

3、定义需要最大化的损失

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

loss = K.variable(0.)   # 在定义损失时将层的贡献添加到这个标量变量中
for layer_name in layer_contributions:
    coeff = layer_contributions[layer_name]       # 获取权重
    activation = layer_dict[layer_name].output    # 获取层激活
    
    scaling = K.prod(K.cast(K.shape(activation), 'float32'))   # 获取层输出的张量大小
    loss += coeff * K.sum(K.square(activation[:, 2: -2, 2: -2, :])) / scaling   # 将该层特征的L2范数添加到loss中。为了避免出现边界伪影,损失中仅包含非边界的像素点

4、梯度上升过程

dream = model.input   # 保存生成的图像

grads = K.gradients(loss, dream)[0]   # 保留3D梯度张量
grads /= K.maximum(K.mean(K.abs(grads)), 1e-7)   # 梯度标准化

""" 设置获取损失值和梯度值的函数 """
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

""" 定义执行梯度上升的迭代函数 """
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

执行 DeepDream 算法前需要定义一个列表,里面包含的是处理图像的尺度(也叫八度)。每个连续的尺度都是前一个的 1.4 倍(放大 40%)。每次运行完梯度上升之后,将图像放大 40%。此时,图像会变得模糊或像素化。为避免丢失大量图像细节,每次放大之后将丢失的细节重新注入到图像中。
在这里插入图片描述

5、辅助函数

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): 
    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)) 
    
    """ 标准化 """
    x /= 2.
    x += 0.5
    x *= 255.
    x = np.clip(x, 0, 255).astype('uint8')          # 使数据值落在0~255区间
    return x

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

import numpy as np

step = 0.01               # 步长
num_octave = 3            # 尺度个数
octave_scale = 1.4        # 相邻尺度的放大比例
iterations = 20           # 梯度上升的步数
max_loss = 10.            # 避免损失过大
base_image_path = '...'   # 输入图像的路径

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]     # 将列表变为升序

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')

网络中更靠近底部的层包含更局部、不太抽象的表示,得到的梦境图案看起来更像是几何形状。更靠近顶部的层能够得到更容易识别的视觉图案,这些图案都是基于 ImageNet 中最常见的对象,比如狗眼睛、鸟羽毛等。

8.2.2 小结

1、DeepDream 的过程是反向运行一个卷积神经网络,基于网络学到的表示来生成输入。
2、注意,这个过程并不局限于图像模型,甚至并不局限于卷积神经网络。它可以应用于语音、音乐等更多内容。

8.3 神经风格迁移

DeepDream 之外,深度学习驱动图像修改的另一项重大进展是神经风格迁移(neural style transfer),它由 Leon Gatys 等人于 2015 年夏天提出。

神经风格迁移 是指将参考图像的风格应用于目标图像,同时保留目标图像的内容。在当前语境下,风格(style) 是指图像中不同空间尺度的纹理、颜色和视觉图案,内容(content) 是指图像的高级宏观结构。
在这里插入图片描述

实现风格迁移背后的关键概念与所有深度学习算法的核心思想是一样的:定义一个损失函数来指定想要实现的目标,然后将这个损失最小化。例如:

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

其中,distance 是一个范数函数,比如 L2 范数;content 是一个函数,输入一张图像,并计算出其内容的表示;style 是一个函数,输入一张图像,并计算出其风格的表示。这个损失函数的核心在于使生成图像与参考图像之间的风格差异、生成图像与原始图像之间的内容差异两者最小化。

8.3.1 内容损失

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

因此,内容损失可以用两个激活之间的 L2 范数表示,一个激活是预训练的卷积神经网络更靠顶部的某层在目标图像上计算得到的激活,另一个激活是同一层在生成图像上计算得到的激活。

8.3.2 风格损失

与内容不同,风格的尺度是多样的。因此,需要捕捉卷积神经网络在风格参考图像的所有空间尺度上提取的外观,而不仅仅是在单一尺度上。

对于风格损失,Gatys 等人使用了层激活的格拉姆矩阵(Gram matrix),即某一层特征图的内积。这个内积可以被理解成表示该层特征之间相互关系的映射。这些特征相互关系抓住了在特定空间尺度下模式的统计规律,从经验上来看,它对应于这个尺度上找到的纹理的外观。

因此,风格损失的目的是在风格参考图像与生成图像之间,在不同的层激活内保存相似的内部相互关系

8.3.3 用 Keras 实现神经风格迁移

1、定义初始变量

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)

2、辅助函数

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)   # 转换为VGG19能处理的输入图像
    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 格式。这也是
    # vgg19.preprocess_input 逆操作的一部分
    x = x[:, :, ::-1]
    x = np.clip(x, 0, 255).astype('uint8')
    return x

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

以三张图像的批量作为 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))
combination_image = K.placeholder((1, img_height, img_width, 3))   # 占位符,保存生成图像

input_tensor = K.concatenate([target_image, 
                              style_reference_image,
                              combination_image], axis=0)   # 拼接为一个批量
 
model = vgg19.VGG19(input_tensor=input_tensor, 
                    weights='imagenet',
                    include_top=False)
print('Model loaded.')

4、内容损失

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

5、风格损失

""" 定义计算格拉姆矩阵的函数 """
def gram_matrix(x):
    # 调换坐标轴,将通道轴置于0轴,沿0轴展开为2D矩阵
    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))

在这里插入图片描述

6、总变差损失

除了上述两个损失分量外,还要添加第三个——总变差损失(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))

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

# 将层的名称映射为激活张量的字典
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, :, :, :]   # 3D
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)

8、设置梯度下降过程

接下来使用 L-BFGS 算法进行最优化。L-BFGS 算法内置于 SciPy 中,但 SciPy 实现有两个限制:
1、它需要将损失函数值和梯度值作为两个单独的函数传入。
2、它只能应用于展平的向量,而我们的数据是三维图像数组。

分别计算损失函数值和梯度值是很低效的,因为这么做会导致二者之间大量的冗余计算。这一过程需要的时间几乎是联合计算二者所需时间的 2 倍。为了避免这种情况,该书创建一个名为 EvaluatorPython 类,它可以同时计算损失值和梯度值,在第一次调用时会返回损失值,同时缓存梯度值用于下一次调用。

grads = K.gradients(loss, combination_image)[0]   # 损失相对于生成图像的梯度,去批量轴,3D

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

"""
这个类将 fetch_loss_and_grads 包装起来,让你可以利用两个单独的方法调用
来获取损失和梯度
"""
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()

9、风格迁移循环

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

result_prefix = 'my_result'
iterations = 20

x = preprocess_image(target_image_path) 

# 将图像展平,因为 scipy.optimize.fmin_l_bfgs_b 只能处理展平的向量
x = x.flatten() 
for i in range(iterations):
    print('Start of iteration', i)
    start_time = time.time()  # 计录开始时间
    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)
    print('Image saved as', fname)
    
    end_time = time.time()   # 记录结束时间
    print('Iteration %d completed in %ds' % (i, end_time - start_time))

这个风格迁移算法的运行速度很慢,但实现的变换足够简单。要想实现快速风格迁移可以花费大量的计算时间对一张固定的风格参考图像生成许多输入 - 输出训练样例,然后利用这些样本训练一个简单的卷积神经网络来学习这个特定风格的变换。

8.3.4 小结

1、风格迁移是指创建一张新图像,保留目标图像的内容的同时还抓住了参考图像的风格。
2、内容(content) 是指图像的高级宏观结构。它可以被卷积神经网络更靠顶部的层激活所捕捉到。
3、风格(style) 是指图像中不同空间尺度的纹理、颜色和视觉图案。它可以被卷积神经网络不同层激活的内部相互关系所捕捉到。

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

在本节和下一节中,将会介绍两种图像生成相关的技术,分别是变分自编码器(VAE,variational autoencoder)生成式对抗网络(GAN,generative adversarial network)

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

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

在这里插入图片描述

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

8.4.2 图像编辑的概念向量

在第 6 章介绍词嵌入时,该书就暗示了概念向量(concept vector) 的想法:给定一个表示的潜在空间或一个嵌入空间,空间中的特定方向可能表示原始数据中有趣的变化轴(第六章是以凶猛程度和猫科到犬科两轴为例)。一旦找到了这样的向量,就可以将图像投射到潜在空间中,用一种有意义的方式来移动其表示,然后再将其解码到图像空间,就可以得到一系列连续变化的图像。

在这里插入图片描述

8.4.3 变分自编码器

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

经典的图像自编码器接收一张图像,通过一个编码器模块将其映射到潜在向量空间,然后再通过一个解码器模块将其解码为与原始图像具有相同尺寸的输出。这种经典的自编码器不会得到特别有用或具有良好结构的潜在空间,也没有对数据做多少压缩。

与之相比,VAE 向自编码器添加了一点统计魔法,迫使其学习连续的、高度结构化的潜在空间。VAE 不是将输入图像压缩成潜在空间中的固定编码,而是将图像转换为统计分布的参数,即平均值和方差。然后,VAE 使用平均值和方差这两个参数来从分布中随机采样一个元素,并将这个元素解码到原始输入。这个过程的随机性提高了其稳健性,并迫使潜在空间的任何位置都对应有意义的表示,即潜在空间采样的每个点都能解码为有效的输出。

在这里插入图片描述

具体过程是:
1、编码器模块将输入样本 input_img 转换为表示潜在空间中的两个参数 z_meanz_log_variance
2、 从潜在空间中随机采样一个点 z:z = z_mean + exp(z_log_variance) * epsilon,其中 epsilon 是取值很小的随机张量。
3、解码器模块将潜在空间的这个点映射回原始输入图像。

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

z_mean, z_log_variance = encoder(input_img)   # 将输入图像编码为平均值和方差
z = z_mean + exp(z_log_variance) * epsilon    # 在潜在空间中采集随机点
reconstructed_img = decoder(z)                # 将z解码为图像
model = Model(input_img, reconstructed_img)   # 模型实例化

1、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)

2、潜在空间采样的函数

在 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])

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

decoder_input = layers.Input(K.int_shape(z)[1:]) 

x = layers.Dense(np.prod(shape_before_flattening[1:]), 
                 activation='relu')(decoder_input)
x = layers.Reshape(shape_before_flattening[1:])(x) 
x = layers.Conv2DTranspose(32, 3, padding='same', activation='relu', strides=(2, 2))(x)
x = layers.Conv2D(1, 3, padding='same', activation='sigmoid')(x)

decoder = Model(decoder_input, x) 

z_decoded = decoder(z)

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

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

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): 
            x = inputs[0]
            z_decoded = inputs[1]
            loss = self.vae_loss(x, z_decoded)
            self.add_loss(loss, inputs=inputs)
            return x 

y = CustomVariationalLayer()([input_img, z_decoded])

5、训练 VAE

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

from keras.datasets import mnist

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

(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))

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

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

n = 15            # 15×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) 
        x_decoded = decoder.predict(z_sample, batch_size=batch_size)    # 将批量解码为数字图像
        digit = x_decoded[0].reshape(digit_size, digit_size) 
        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()

8.4.4 小结

1、用深度学习进行图像生成,就是通过对潜在空间进行学习来实现的,这个潜在空间能够捕捉到关于图像数据集的统计信息
2、VAE 得到的是高度结构化的、连续的潜在表示。
3、GAN 可以生成逼真的单幅图像,但得到的潜在空间可能没有良好的结构,也没有很好的连续性。

8.5 生成式对抗网络简介

生成式对抗网络(GAN,generative adversarial network) 由 Goodfellow 等人于 2014 年提出,它可以替代 VAE 来学习图像的潜在空间。与 VAE 不同,这个潜在空间无法保证具有有意义的结构,而且它还是不连续的。但它能够迫使生成图像与真实图像在统计上几乎无法区分,从而生成相当逼真的合成图像。

这就是 GAN 的工作原理是:设计一个伪造者网络和一个专家网络,通过训练二者,使伪造网络生成更加真实的图像,专家网络的鉴别分类能力更强。

1、生成器网络(generator network):它以一个随机向量(潜在空间中的一个随机点)作为输入,并将其解码为一张合成图像。
2、判别器网络(discriminator network)对手(adversary):以一张图像(真实的或合成的均可)作为输入,并预测该图像是来自训练集还是由生成器网络创建。

在这里插入图片描述

值得注意的是,GAN 这个系统与该书中其他任何训练方法都不同,它的优化最小值是不固定的。通俗来讲,其他模型的训练目的是使模型能更好的将输入映射为输出。在这个过程中,模型的输入数据和目标输出(或标签数据)是固定的,所以可以将梯度下降看做是沿着静态的损失地形滚下山坡。但对于 GAN 而言,每下山一步,都会对整个地形造成一点改变。这是因为,在 GAN 中需要同步训练生成器和判别器,而生成器需要根据判别器的判断结果进行训练,判别器需要识别越来越逼真的图像。这是一个动态的系统,其最优化过程寻找的不是一个最小值,而是两股力量之间的平衡。因此,GAN 的训练极其困难,想要让 GAN 正常运行,需要对模型架构和训练参数进行大量的仔细调整。

8.5.1 GAN 的简要实现流程

由于 GAN 属于高级应用,所以本节只介绍、实现了形式最简单的 GAN。这是一个深度卷积生成式对抗网络(DCGAN,deep convolutional GAN),即生成器和判别器都是深度卷积神经网络的 GAN。特别地,它在生成器中使用 Conv2DTranspose 层进行图像上采样

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 模型的损失相对于生成器权重的梯度。这意味着,在每一步都要移动生成器的权重,其移动方向是让判别器更有可能将生成器解码的图像划分为“真”。换句话说,我们训练生成器来欺骗判别器。

GAN 的具体的流程会在后续的代码中变得更加清晰。

8.5.2 大量技巧

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

在这里插入图片描述

由于步幅大小和内核大小不匹配而导致的棋盘状伪影,进而导致像素空间不均匀的覆盖;这是 GAN 的诸多陷阱之一

8.5.3 生成器

GAN 常见的诸多问题之一,就是生成器 “卡在” 看似噪声的生成图像上。一种可行的解决方案是在判别器和生成器中都使用 dropout

1、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)

x = layers.Conv2D(256, 5, padding='same')(x)
x = layers.LeakyReLU()(x)

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

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 图像的形状)
# 是否可以理解为单张RGB图片?
x = layers.Conv2D(channels, 7, activation='tanh', padding='same')(x) 
generator = keras.models.Model(generator_input, x)   # 模型实例化
generator.summary()

8.5.4 判别器

2、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)

x = layers.Dropout(0.4)(x)                                   # 使用 Dropout 层

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

discriminator = keras.models.Model(discriminator_input, x)   # 模型实例化
discriminator.summary()

discriminator_optimizer = keras.optimizers.RMSprop(
    lr=0.0008,
    clipvalue=1.0,                                           # 在优化器中使用梯度裁剪(限制梯度值的范围)
    decay=1e-8)                                              # 为了稳定训练过程,使用学习率衰减

discriminator.compile(optimizer=discriminator_optimizer,
                      loss='binary_crossentropy')

8.5.5 对抗网络

3、对抗网络

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

# GAN 网络的输入似乎没有和 generator 网络的输出关联在一起,没有看明白
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')

8.5.6 如何训练 DCGAN

训练循环的大致流程如下:
1、从潜在空间中抽取随机的点(随机噪声)。
2、generator 网络利用这个随机噪声生成图像。
3、将生成图像与真实图像混合。
4、使用这些混合后的图像以及相应的标签(真实图像为“真”,生成图像为“假”)来训练 discriminator 网络
5、在潜在空间中随机抽取新的点。
6、使用这些随机向量以及全部是 “真实图像” 的标签来训练 GAN。只更新生成器的权重,其更新方向是使得判别器能够将生成图像预测为 “真实图像”。这个过程是训练生成器去欺骗判别器。

4、实现 GAN 的训练

import os
from keras.preprocessing import image

""" 加载 CIFAR10 数据"""
(x_train, y_train), (_, _) = keras.datasets.cifar10.load_data() 
x_train = x_train[y_train.flatten() == 6]                 # 只使用青蛙图像(类别编号为6)
x_train = x_train.reshape(
    (x_train.shape[0],) +
    (height, width, channels)).astype('float32') / 255.   # 数据标准化

iterations = 10000
batch_size = 20
save_dir = 'your_dir'   # 生成图像的保存路径

start = 0
for step in range(iterations):
    """ 训练 discriminator 网络 """
    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) 
    
    """ 训练 generator 网络"""
    random_latent_vectors = np.random.normal(size=(batch_size, latent_dim)) 
    misleading_targets = np.zeros((batch_size, 1))   # 全部标记为 “真实图像”(使生成图像更容易被判别器认定为真实图像)
    a_loss = gan.train_on_batch(random_latent_vectors, misleading_targets) 
    
    start += batch_size  # 将star标志置于batch末尾,下一批量的开始(stop的位置)

    # 如果剩下的图像不够一个批量,则重置star,从头开始取图像
    if start > len(x_train) - batch_size:
        start = 0
    
    """ 每100步保存一次模型权重、一张生成图片、一张真实图片 """
    if step % 100 == 0: 
        gan.save_weights('gan.h5')   # 保存模型权重
        
        print('discriminator loss:', d_loss) 
        print('adversarial loss:', 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'))

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

8.5.7 小结

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

小结

1、如何生成序列数据,每次生成一个时间步。这可以应用于文本生成,也可应用于逐个音符的音乐生成或其他任何类型的时间序列数据
2、DeepDream 的工作原理:通过输入空间中的梯度上升将卷积神经网络的层激活最大化(在这个过程中放大图像,并注入原始图像的细节)。
3、风格迁移:学习参考图像的风格(局部和整体特征,即激活通道间的相互关系),保留原始图像中的内容(全局特征和抽象概念,即最顶层的激活)。
4、变分自编码器(VAE)抗式生成网络(GAN) 实现方式和异同点。

Logo

CSDN联合极客时间,共同打造面向开发者的精品内容学习社区,助力成长!

更多推荐