TensorFlow從1到2(十一)變分自動編碼器和圖片自動生成

基本概念

「變分自動編碼器」(Variational Autoencoders,縮寫:VAE)的概念來自Diederik P Kingma和Max Welling的論文《Auto-Encoding Variational Bayes》。如今有了很普遍的應用,應用範圍已經遠遠超出了當時論文的設想。不過看起來彷佛,國內尚未見到什麼相關產品出現。python

做爲普及型的文章,介紹「變分自動編碼器」,要先從編碼提及。
簡單說,編碼就是數字化,前面第六篇咱們已經介紹了一些常見的編碼方法。好比對於一句話:「It is easy to be wise after the event.」。使用序列編碼的方式,咱們能夠設定1表明it,2表明is,3表明easy,以此類推,就好像咱們機器翻譯程序中第一步編碼作的那樣。圖片也是同樣,好比咱們可讓1表明貓貓的照片,2表明狗狗的照片。
有編碼對應就有解碼,解碼是編碼的反過程,好比見到1就還原成「it」,或者還原成一幅貓貓的照片。
這樣的編碼如此簡單,看上去其實根本不須要有什麼「自動編碼器」存在。git

但這些編碼是沒有「靈魂」的,所謂沒有靈魂,就是除非你保留了完整的對照表和原始數據,不然你看到1沒辦法知道1表明是it,也沒辦法知道1表明貓貓的照片。甚至即使你知道1表明貓貓,但貓貓圖片那麼多,到底是哪張貓貓的照片,你仍是不知道。
這個只有智慧生物才具備的「靈魂」,把編碼的難度提升了無數倍。
但這是合理的,就好比你見到一隻貓貓,你心中會想「這是一隻貓」;有人給你說「我剛纔見到了一隻橘貓」,你腦海中會出現一隻賣萌的加菲,也許順便還在想「十隻橘貓九隻胖」。這個動做來自於你思惟中的長期積累造成的概念化和聯想,也實質上至關於編碼過程。你心中的「自動編碼器」無時不在高效的運轉,只不過咱們已經習覺得常,這個「自動編碼器」就是人的智慧。這個「自動編碼器」的終極目標就是可能「無中生有」。算法

上一節神經網絡翻譯,咱們知道這個編碼結果實際就是神經網絡對於一句話的理解。對於天然語言如此,對於圖一樣如此。
深度學習技術的發展爲自動編碼器賦予了「靈魂」,自動編碼器迅速的出現了不少。咱們早就熟悉的分類算法就屬於典型的自動編碼器,即使他們一開始表現的並不像在幹這個。按照某種規則,把具備相同性質的數據,分配到某一類,產生相同的編碼------這就是分類算法乾的。不像自動編碼器的緣由主要是在學習的過程當中,咱們實際都使用了標註以後的訓練集,這個標註自己就是人爲分類的過程,這個過程稱不上自動。但也有不少分類算法是不須要標註數據的,好比K-means聚類算法。

一個基於深度學習模型的編碼器能夠輕鬆的通過訓練,把一幅圖片轉換爲一組數據。再經過訓練好的模型(你能夠理解爲存儲有信息的模型),完整把編碼數據還原到圖片。NMT機器翻譯,也算的上實現了這個過程。
因此在圖片應用中的自動編碼器,最終的效果更相似於壓縮器或者存儲器,把一幅圖片的數據量下降。隨後解碼器把這個過程逆轉,從一組小的數據量還原爲完整的圖片。編程

變分自動編碼器

傳統的自動編碼器之因此更相似於壓縮器或者存儲器。在於所生成的數據(編碼結果、壓縮結果)基本是肯定的,而解碼後還原的結果,也基本是肯定的。這個肯定性一般是一種優勢,但也每每限制了想像力。數組

變分自動編碼器最初的目的應當也是同樣的,算是一種編解碼器的實現。最大的特色是首先作了一個預設,就是編碼的結果不是某個確認的值,而是一個範圍。算法認爲編碼的結果,根據分類的不一樣,目標值應當平均分佈在一個範圍內。這樣設計是很是合理的,平均分佈在一個範圍,才能保證編碼空間的利用率最大化而且相近類之間又有良好的區分度。
如何表示一個範圍呢?論文中使用了平均值和方差。也就是表示,多幅圖片的編碼結果值,平均分佈在平均值兩側的方差範圍內。也能夠說符合高斯分佈或者正態分佈。在本例的程序中(本例中的代碼來自TensorFlow官方文檔),使用了平均值和對數方差,從數學性能上,對數方差數值會更穩定。基本原理是相同的。
這樣一個改變,使得編碼結果有了不少有趣的新特徵。好比對於編碼結果的值進行微調,而後再解碼還原以後,生成的圖片可能會產生了一些使人興奮的變化。
從資料介紹的狀況看(參考資料),好比對一組人臉照片進行編碼,調整編碼的某個數值項,結果的人臉膚色可能發生變化;調整另一個數值,人臉的朝向可能發生了變化。
模型就此彷佛得到了使人興奮的創造能力,而本來這應當是藝術家、人類的領域範圍。
另一個例子中,經過對一組序列的視頻圖片的學習,隨後刪去其中的一部分,好比一輛駛過的汽車。而後使用VAE從新生成刪除的畫面,能夠完美再現畫面的背景,汽車彷佛從未出如今那裏。這樣的功能,咱們在某大公司的圖像、視頻處理軟件中已經見到了商業化的實現(Neural Inpainting)。
網絡

程序要點

本示例程序中使用的訓練圖片,就是手寫數字的樣本庫,這是咱們最容易獲取到的樣本集。
咱們但願通過大量的訓練以後,VAE模型可以自動的生成能夠亂真的手寫字符圖片。

(MNIST手寫數字樣本圖片)
程序一開始,先載入MNIST樣本庫。根據模型卷積層的須要,將樣本整形爲樣本數量x寬x高x色深的形式。最後把樣本規範化爲背景色爲0、前景筆畫爲1的張量數據。app

程序訓練的結果,是使用隨機生成的編碼向量,還原爲手寫的數字圖片。由於編碼是隨機生成的,因此不一樣的編碼,生成的圖片不可能徹底吻合原有的樣本集,而這種合理的差別,更相似人本身每次手寫的字體------大致上是一致的,但有不少細微的區別。看起來就好像計算機有了人的智慧,在學習了不少手寫數字的樣本後,本身也能手寫數字。

(VAE通過100次訓練迭代後,生成的手寫數字樣本圖片)
下面就是隨機生成4格x4格共16個樣本編碼向量,每一個向量長度是50個浮點數:dom

...
latent_dim = 50
num_examples_to_generate = 16
    ...
random_vector_for_generation = tf.random.normal(
    shape=[num_examples_to_generate, latent_dim])

這一組編碼在整個程序中是保持不變的,這樣每次生成的圖片是相同的一組數字,從而,能觀察到從最初生成的一組白噪聲,一點點清晰,到第100次迭代的時候較爲能夠辨別的手寫數字。機器學習

程序的編碼模型(推理模型)和解碼模型(生成模型)雖然略微複雜,但在Keras.Sequential的幫助下看上去也沒有什麼。真正複雜的是程序的代價函數和代價值的計算。
由於模型的代價值是真實圖片同生成圖片之間的對比,乘上每批次100幅樣本圖片,是一個比較大的數據量,再考慮編碼所使用的範圍方式,VAE使用了一個新的計算方法。這部分公式請參考本文開頭連接的論文。在程序中,把公式使用代碼實現是下面兩個函數:ide

# 計算代價值
def log_normal_pdf(sample, mean, logvar, raxis=1):
    log2pi = tf.math.log(2. * np.pi)
    return tf.reduce_sum(
        -.5 * ((sample - mean) ** 2. * tf.exp(-logvar) + logvar + log2pi),
        axis=raxis)
# 代價函數
def compute_loss(model, x):
    # 編碼一個批次(100)的圖片
    mean, logvar = model.encode(x)
    # 隨機生成100個均勻分佈的編碼向量
    z = model.reparameterize(mean, logvar)
    # 使用編碼向量生成圖片
    x_logit = model.decode(z)

    # 下面是代價之計算,結構很複雜,但來源是生成圖片和樣本圖片的對比
    cross_ent = tf.nn.sigmoid_cross_entropy_with_logits(logits=x_logit, labels=x)
    logpx_z = -tf.reduce_sum(cross_ent, axis=[1, 2, 3])
    logpz = log_normal_pdf(z, 0., 0.)
    logqz_x = log_normal_pdf(z, mean, logvar)
    return -tf.reduce_mean(logpx_z + logpz - logqz_x)

編碼向量的長度值是一開始就肯定的,本例中是50。這個長度根據須要能夠調整,表明了編碼所佔用的存儲空間。編碼若是比較長,能包含的圖片細節就多,還原的圖片容易作到更吻合原圖。編碼若是短,準確的編碼自己通常不會有大的問題,但編碼稍有變化,結果的圖片變化可能就很大。這至關於等級比例的變化,很容易理解。 每次編碼完成後,獲得的是平均值和對數方差。是表示範圍的量,在本例中,這個範圍表明了100副圖片的編碼。而解碼的時候,解碼器確定須要指定具體某幅圖片的編碼向量值,而不能是一個範圍。程序使用下面的函數在指定範圍內生成100個編碼向量的數組:

# 在向量空間內均勻分佈生成100個隨機編碼
    def reparameterize(self, mean, logvar):
        eps = tf.random.normal(shape=mean.shape)
        return eps * tf.exp(logvar * .5) + mean

再次提醒這裏使用的是對數方差,因此跟論文中的公式有區別。
此外注意這裏每次生成的100個隨機編碼,同訓練集定義的每一個批次100個樣本的數量,是必須吻合的。這樣生成的圖片纔是相同的數量,從而同相同數量的樣本集對比計算代價值。
程序在訓練的每次迭代中都生成一張相同編碼值、相同模型、不一樣階段(不一樣模型權重)得出的解碼樣本圖片,保存爲文件:

# 產生一幅圖片,輸出的時候文件名加上迭代次數
def generate_and_save_images(model, epoch, test_input):
    # 生成16幅樣本圖片
    predictions = model.sample(test_input)
    # 4格*4格圖片
    fig = plt.figure(figsize=(4, 4))

    # for i in range(predictions.shape[0]):
    # 用樣本中的前16幅生成一張4x4排布的彙總圖片
    for i in range(4*4):
        plt.subplot(4, 4, i+1)
        plt.imshow(predictions[i, :, :, 0], cmap='gray')
        plt.axis('off')

    # 把生成的圖片保存爲圖片文件
    plt.savefig('image_at_epoch_{:04d}.png'.format(epoch))
    # 也可直接顯示在屏幕上,但訓練過程比較慢,你不必定想等着看
    # plt.show()
    # 若是圖片只是用於保存而非顯示,則不會有用戶手動「關閉」圖片窗口
    # plt對象也就沒法關閉,因此須要顯示的關閉釋放內存,特別是本例中圖片數量很是多
    plt.close()

最後一共生成100張圖片,若是生成一張gif動圖,那看起來會對訓練過程的認識格外深入:

生成動圖的程序代碼以下,能夠單獨造成一個程序執行:

#!/usr/bin/env python3

from __future__ import absolute_import, division, print_function, unicode_literals

# 執行前請先安裝imageio庫
# pip3 install imageio

import os
import time
import numpy as np
import glob
import matplotlib.pyplot as plt
import PIL
import imageio

# 遍歷全部png圖片,生成一張gif動圖
anim_file = 'cvae-100-all.gif'
with imageio.get_writer(anim_file, mode='I') as writer:
    filenames = glob.glob('image*.png')
    filenames = sorted(filenames)
    last = -1
    for i, filename in enumerate(filenames):
        frame = 2*(i**0.5)
        if round(frame) > round(last):
            last = frame
        else:
            continue
        image = imageio.imread(filename)
        writer.append_data(image)
    # 最後一張圖片是最終效果圖,多存一張讓顯示時間長一點
    image = imageio.imread(filename)
    writer.append_data(image)

完整VAE代碼

最後是完整的VAE代碼,請參考註釋閱讀:

#!/usr/bin/env python3

# 引入所需庫
from __future__ import absolute_import, division, print_function, unicode_literals

import tensorflow as tf

import os
import time
import numpy as np
import glob
import matplotlib.pyplot as plt

# 讀取手寫字體樣本集
(train_images, _), (test_images, _) = tf.keras.datasets.mnist.load_data()

# 重整爲:樣本數x寬x高x色深 的格式
train_images = train_images.reshape(train_images.shape[0], 28, 28, 1).astype('float32')
test_images = test_images.reshape(test_images.shape[0], 28, 28, 1).astype('float32')

# 規範化數據到0-1浮點
train_images /= 255.
test_images /= 255.

# 將數據二值化,背景是0,筆畫是1
train_images[train_images >= .5] = 1.
train_images[train_images < .5] = 0.
test_images[test_images >= .5] = 1.
test_images[test_images < .5] = 0.

TRAIN_BUF = 60000
BATCH_SIZE = 100

TEST_BUF = 10000

# 這裏須要注意一下批次數量是100
train_dataset = tf.data.Dataset.from_tensor_slices(train_images).shuffle(TRAIN_BUF).batch(BATCH_SIZE)
test_dataset = tf.data.Dataset.from_tensor_slices(test_images).shuffle(TEST_BUF).batch(BATCH_SIZE)

class CVAE(tf.keras.Model):
    def __init__(self, latent_dim):
        super(CVAE, self).__init__()
        self.latent_dim = latent_dim
        # 推理模型,至關於Encoder,用於把手寫數字圖片,編碼到向量
        # 這裏獲得的不直接是向量自己,而是向量的均值和對數方差
        # 緣由看文中的解釋
        self.inference_net = tf.keras.Sequential(
            [
                tf.keras.layers.InputLayer(input_shape=(28, 28, 1)),
                tf.keras.layers.Conv2D(
                    filters=32, kernel_size=3, strides=(2, 2), activation='relu'),
                tf.keras.layers.Conv2D(
                    filters=64, kernel_size=3, strides=(2, 2), activation='relu'),
                tf.keras.layers.Flatten(),
                # 均值和對數方差的長度都是latent_dim,因此這裏是兩個
                tf.keras.layers.Dense(latent_dim + latent_dim),
            ]
        )

        # 生成模型,至關於Decoder,使用編碼生成對應的手寫數字圖片
        self.generative_net = tf.keras.Sequential(
            [
                tf.keras.layers.InputLayer(input_shape=(latent_dim,)),
                tf.keras.layers.Dense(units=7*7*32, activation=tf.nn.relu),
                tf.keras.layers.Reshape(target_shape=(7, 7, 32)),
                tf.keras.layers.Conv2DTranspose(
                    filters=64,
                    kernel_size=3,
                    strides=(2, 2),
                    padding="SAME",
                    activation='relu'),
                tf.keras.layers.Conv2DTranspose(
                    filters=32,
                    kernel_size=3,
                    strides=(2, 2),
                    padding="SAME",
                    activation='relu'),
                # No activation
                tf.keras.layers.Conv2DTranspose(
                    filters=1, kernel_size=3, strides=(1, 1), padding="SAME"),
            ]
        )
    # 獲取一百幅樣本圖片
    def sample(self, eps=None):
        if eps is None:
            eps = tf.random.normal(shape=(100, self.latent_dim))
        return self.decode(eps, apply_sigmoid=True)

    # 編碼器
    def encode(self, x):
        mean, logvar = tf.split(self.inference_net(x), num_or_size_splits=2, axis=1)
        # 每一步都保存一份平均值和對數方差,以便未來你可能想生成一組符合平均分佈的編碼
        self.mean = mean
        self.logvar = logvar
        return mean, logvar

    # 在向量空間內均勻分佈生成100個隨機編碼
    def reparameterize(self, mean, logvar):
        eps = tf.random.normal(shape=mean.shape)
        # tf.exp  is e^(logvar*0.5)
        return eps * tf.exp(logvar * .5) + mean

    # 解碼器
    def decode(self, z, apply_sigmoid=False):
        logits = self.generative_net(z)
        if apply_sigmoid:
            probs = tf.sigmoid(logits)
            return probs

        return logits

optimizer = tf.keras.optimizers.Adam(1e-4)

# 代價值的計算比較複雜,是公式的編程實現
def log_normal_pdf(sample, mean, logvar, raxis=1):
    log2pi = tf.math.log(2. * np.pi)
    return tf.reduce_sum(
        -.5 * ((sample - mean) ** 2. * tf.exp(-logvar) + logvar + log2pi),
        axis=raxis)
# 代價函數
def compute_loss(model, x):
    # 編碼一個批次(100)的圖片
    mean, logvar = model.encode(x)
    # 隨機生成100個均勻分佈的編碼向量
    z = model.reparameterize(mean, logvar)
    # 使用編碼向量生成圖片
    x_logit = model.decode(z)

    # 下面是代價之計算,結構很複雜,但來源是生成圖片和樣本圖片的對比
    cross_ent = tf.nn.sigmoid_cross_entropy_with_logits(logits=x_logit, labels=x)
    logpx_z = -tf.reduce_sum(cross_ent, axis=[1, 2, 3])
    logpz = log_normal_pdf(z, 0., 0.)
    logqz_x = log_normal_pdf(z, mean, logvar)
    return -tf.reduce_mean(logpx_z + logpz - logqz_x)

# 進行一次訓練和梯度迭代
def compute_gradients(model, x):
    with tf.GradientTape() as tape:
        loss = compute_loss(model, x)
    return tape.gradient(loss, model.trainable_variables), loss

# 根據梯度降低計算的結果,調整模型的權重值
def apply_gradients(optimizer, gradients, variables):
    optimizer.apply_gradients(zip(gradients, variables))

# 訓練迭代100次
epochs = 100
# 編碼向量的維度
latent_dim = 50
# 用於生成圖片的樣本數,4格x4格共16幅
num_examples_to_generate = 16

# 隨機生成16個編碼向量,在整個程序過程當中保持不變,從而能夠看到
# 每次迭代,所生成的圖片的效果在逐次都在優化。相同的編碼會生成相同的目標數字圖片
random_vector_for_generation = tf.random.normal(
    shape=[num_examples_to_generate, latent_dim])
# 模型實例化
model = CVAE(latent_dim)

# 產生一幅圖片,輸出的時候文件名加上迭代次數
def generate_and_save_images(model, epoch, test_input):
    # 生成16幅樣本圖片
    predictions = model.sample(test_input)
    # 4格*4格圖片
    fig = plt.figure(figsize=(4, 4))

    # for i in range(predictions.shape[0]):
    # 用樣本中的前16幅生成一張4x4排布的彙總圖片
    for i in range(4*4):
        plt.subplot(4, 4, i+1)
        plt.imshow(predictions[i, :, :, 0], cmap='gray')
        plt.axis('off')

    # 把生成的圖片保存爲圖片文件
    plt.savefig('image_at_epoch_{:04d}.png'.format(epoch))
    # 也可直接顯示在屏幕上,但訓練過程比較慢,你不必定想等着看
    # plt.show()
    # 若是圖片只是用於保存而非顯示,則不會有用戶手動「關閉」圖片窗口
    # plt對象也就沒法關閉,因此須要顯示的關閉釋放內存,特別是本例中圖片數量很是多
    plt.close()

# 先生成第一幅、未經訓練狀況下的樣本圖片,全部的手寫字符都還在隨機噪點狀態
generate_and_save_images(model, 0, random_vector_for_generation)

# 訓練循環
for epoch in range(1, epochs + 1):
    start_time = time.time()
    for train_x in train_dataset:
        # 訓練一個批次
        gradients, loss = compute_gradients(model, train_x)
        apply_gradients(optimizer, gradients, model.trainable_variables)
    end_time = time.time()

    # 在每一個迭代循環生成一張圖片和顯示一次模型信息
    # 能夠修改成屢次循環顯示一次和生成一張圖片
    if epoch % 1 == 0:
        loss = tf.keras.metrics.Mean()
        for test_x in test_dataset:
            loss(compute_loss(model, test_x))
        elbo = -loss.result()
        # 顯示迭代次數、損失值、和本次迭代循環耗時
        print("============================")
        print(
            'Epoch: {}, Test set ELBO: {}, '
            'time elapse for current epoch {}'.format(
                epoch,
                elbo,
                end_time - start_time))
        # 生成一張圖片保存起來
        generate_and_save_images(
            model, epoch, random_vector_for_generation)

最終訓練迭代100次後生成的手寫數字樣本圖,雖然已經頗有辨識度。但同人寫的數字仍然區別很大,緣由是,人手寫時候偏差形成的變形,人類已經看習慣了,幾乎不太影響辨別。而機器造成的偏差,從人類的眼光中看起來,很怪異,甚至影響識別。這並不能說機器生成的手寫字體就不對,至少在機器學習模型看起來,這樣的字體已經能夠識別了。
咱們程序一直使用同一組隨機數生成的向量來生成手寫字符圖片,因此生成的數字一直是同一組。若是程序中再次執行隨機生成,獲得另一組隨機數,那解碼生成的手寫圖片,也一樣會換爲另一組:

固然做爲隨機數,自己的隨意性,所解碼還原的圖片辨識度,也基本是一樣的等級。按照100次的迭代訓練來看,也就是比兒童塗鴉略好。
咱們開始說過了,VAE的編碼目標是平均分配在一個編碼空間內的,符合高斯分佈。那麼咱們生成的隨機數編碼符合這個要求嗎?做爲50個浮點數長度的向量,這種可能性幾乎沒有。若是但願獲得一個符合正太分佈的隨機編碼向量,須要使用函數reparameterize中提供的方法。好比咱們使用這個方法,生成一組編碼,再還原爲圖片看一看:

是否是發現解碼還原的圖片辨識度高了不少?緣由很簡單,符合VAE編碼規則的編碼,所生成的圖片,自己就是和訓練樣本圖片最接近、代價值最低的圖片。這在人的眼光中看起來好看,實際上,同普通的編碼器也就沒什麼區別了。由於這算不上模型「創造」出來的圖片,只是「存儲」的圖片而已。
因此,VAE之因此受歡迎,就是在於VAE具有了人類纔有的創造力,雖然創造的結果不必定都使人滿意,但畢竟能夠「無中生有」啊。

(待續...)

相關文章
相關標籤/搜索