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


基本概念

“變分自動編碼器”(Variational Autoencoders,縮寫:VAE)的概念來自Diederik P Kingma和Max Welling的論文《Auto-Encoding Variational Bayes》。現在有了很廣泛的應用,應用范圍已經遠遠超出了當時論文的設想。不過看起來似乎,國內還沒有見到什么相關產品出現。

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

但這些編碼是沒有“靈魂”的,所謂沒有靈魂,就是除非你保留了完整的對照表和原始數據,否則你看到1沒辦法知道1代表是it,也沒辦法知道1代表貓貓的照片。甚至即便你知道1代表貓貓,但貓貓圖片那么多,究竟是哪張貓貓的照片,你還是不知道。
這個只有智慧生物才具有的“靈魂”,把編碼的難度提高了無數倍。
但這是合理的,就比如你見到一只貓貓,你心中會想“這是一只貓”;有人給你說“我剛才見到了一只橘貓”,你腦海中會出現一只賣萌的加菲,也許順便還在想“十只橘貓九只胖”。這個動作來自於你思維中的長期積累形成的概念化和聯想,也實質上相當於編碼過程。你心中的“自動編碼器”無時不在高效的運轉,只不過我們已經習以為常,這個“自動編碼器”就是人的智慧。這個“自動編碼器”的終極目標就是可能“無中生有”。

上一節神經網絡翻譯,我們知道這個編碼結果實際就是神經網絡對於一句話的理解。對於自然語言如此,對於圖同樣如此。
深度學習技術的發展為自動編碼器賦予了“靈魂”,自動編碼器迅速的出現了很多。我們早就熟悉的分類算法就屬於典型的自動編碼器,即便他們一開始表現的並不像在干這個。按照某種規則,把具有相同性質的數據,分配到某一類,產生相同的編碼------這就是分類算法干的。不像自動編碼器的原因主要是在學習的過程中,我們實際都使用了標注之后的訓練集,這個標注本身就是人為分類的過程,這個過程稱不上自動。但也有很多分類算法是不需要標注數據的,比如K-means聚類算法。

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

變分自動編碼器

傳統的自動編碼器之所以更類似於壓縮器或者存儲器。在於所生成的數據(編碼結果、壓縮結果)基本是確定的,而解碼后還原的結果,也基本是確定的。這個確定性通常是一種優點,但也往往限制了想像力。

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

程序要點

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

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

程序訓練的結果,是使用隨機生成的編碼向量,還原為手寫的數字圖片。因為編碼是隨機生成的,所以不同的編碼,生成的圖片不可能完全吻合原有的樣本集,而這種合理的差異,更類似人自己每次手寫的字體------大體上是一致的,但有很多細微的區別。看起來就好像計算機有了人的智慧,在學習了很多手寫數字的樣本后,自己也能手寫數字。

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

	...
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使用了一個新的計算方法。這部分公式請參考本文開頭鏈接的論文。在程序中,把公式使用代碼實現是下面兩個函數:

# 計算代價值
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具備了人類才有的創造力,雖然創造的結果不一定都令人滿意,但畢竟可以“無中生有”啊。

(待續...)


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM