從一個點到一團雲:讓潛在空間具備機率性
上一篇我們建了一個普通的 自編碼器:編碼器把一張影像壓縮成一小串數字,稱為 潛在編碼,解碼器再從這串編碼把影像重建回來。它學會了漂亮地壓縮與重建——但當我們想拿它來生成時就撞牆了。訓練出來的編碼像散落的孤島一樣分佈在潛在空間裡,島與島之間是大片空白。在那些空白處隨手挑一個點交給解碼器,得到的只是一團糊。整個空間佈滿了洞,而一個佈滿洞的空間根本沒辦法拿來取樣。
變分自編碼器(VAE)用一個看似簡單的改動解決了這件事。它不再把影像編碼成單一一個點,而是編碼成一團模糊的小雲——也就是一個機率分佈。想像你要把一張照片釘在地圖上。舊的自編碼器是把一根尖針精準插在某一個點。VAE 則是在一小塊區域塗上一團柔和模糊的色斑,說:『這張照片大致住在這裡。』這份模糊不是隨便,而正是整個關鍵所在。
示意圖:輸入影像流入編碼器,輸出 mu 與 sigma;從產生的高斯雲中取樣出潛在編碼 z;解碼器再重建影像。
為什麼模糊能填補空洞?因為當每張照片佔據的是一小塊區域而不是單一一點時,相鄰的雲就會重疊。在兩團雲重疊的地方,中間的點同時稍微屬於兩張影像——於是解碼器被迫讓那些中間點解碼出合理的東西(兩張影像的混合)。對整個資料集都這麼做,這些雲就會編織成一片連續的布料,沒有死掉的空隙。現在你可能落腳的每一個點都代表某種意義,而這正是我們敢去取樣之前所需要的性質。
於是編碼器的工作變了。它不再吐出單一一個編碼 z,而是預測一團雲的配方:兩個向量——一個平均值 μ,說明雲的中心落在哪裡;一個變異數 σ²,說明雲攤得多開(多模糊)。我們把這個分佈寫成下面的式子。
編碼器在給定輸入影像後,定義出一個關於潛在編碼的高斯分佈。
由左到右讀。符號 q(z | x) 的意思是『在給定影像 x 的條件下,潛在編碼 z 的分佈』——那條直線讀作『給定』。𝒩 代表高斯分佈(經典的鐘形曲線)。μ(mu)是雲的中心:一個由編碼器預測的向量,潛在空間每一維各一個數字。σ²(sigma 平方)是變異數:每一維上鐘形攤得多寬,同樣由編碼器預測。關鍵的轉變在於——編碼器不再輸出 z 本身,而是輸出整團可能 z 的配方(μ, σ)。舉例來說,如果潛在空間只有 2 維,編碼器預測 μ = (1.0, −0.5)、σ = (0.2, 0.3),那麼這張照片的雲就以點 (1.0, −0.5) 為中心,而且沿第二軸比第一軸稍微模糊一點。
整齊的潛在空間:高斯先驗
把每張影像編碼成一團雲,填補了局部的空隙,但這還不能保證整批雲是有組織的。想像編碼器若放任不管,可能把某些雲停在座標 (50, 70) 那麼遠的地方,另一些擠在原點附近,還有些散落在遙遠的角落。每一團雲單獨看也許都沒問題,但整堆加起來就是一片散亂、歪斜的混亂——我們仍然不知道該往哪裡擲飛鏢才能得到一張有效的影像。我們需要控制全部 潛在編碼 合起來的整體形狀。
把目標講白:我們希望全部影像合起來的整堆潛在編碼,集體看起來像一條簡單的以原點為中心的標準鐘形曲線——一顆整齊的圓球狀機率團,沒有跑很遠的離群值,也沒有空隙。想像把那些模糊的雲全部輕輕地趕攏,讓它們在中心周圍緊密地堆成一顆整齊的球。這個目標形狀就叫做先驗(prior):我們希望編碼服從、事先就決定好的分佈。
為什麼非要堅持這個特定的簡單形狀?因為這關係到生成時會發生什麼。訓練完成後,我們會把編碼器整個丟掉,直接抽一個隨機編碼再解碼。如果編碼本該住在某個奇形怪狀、坑坑窪窪、我們不認識的分佈裡,我們根本不知道怎麼從裡面抽。但如果它們本該長得像一條平凡的標準鐘形曲線,抽一個新編碼就再簡單不過——那是任何程式庫都能給你的最基本的隨機數。我們現在刻意選一個容易的目標,正是為了讓之後的取樣毫不費力。
先驗:我們為潛在編碼選定的目標分佈——一個標準多維高斯分佈。
解讀符號:p(z) 是先驗——我們為潛在編碼挑選的目標分佈。𝒩(0, I) 是一個標準多維高斯分佈。其中 0 表示這團雲以原點為中心(每一維的平均值都是零)。I 是單位共變異數矩陣,它一次傳達兩件事:每一維的分散程度(變異數)恰好是 1,而且各維彼此獨立——沒有哪一軸依賴另一軸。用白話說,整條式子的意思是:『我們希望編碼看起來像從標準鐘形曲線抽出的普通隨機雜訊。』具體地說,在 2 維潛在空間裡,p(z) = 𝒩(0, I) 是一顆以 (0, 0) 為中心的圓形雲團,大約 99% 的機率落在半徑約 3 的範圍內——正是這種圓形、可預測的形狀,讓之後的取樣變得如此容易。
KL 散度:偏離先驗要繳的稅
要把編碼器的雲推向先驗,我們首先需要一種方法來度量它們偏離了多遠。那把量尺就是 KL 散度(Kullback–Leibler 散度)。先建立直覺:KL 是兩個機率分佈之間的一種距離。當兩個分佈完全吻合時它恰好為零,兩者越不一致它就越大。它有點不尋常,因為它是非對稱的——從 A 到 B 的『距離』不一定等於從 B 到 A——所以它是一個有方向的不匹配分數,而不是一把真正對稱的尺。但就我們的目的而言,你可以安心地把它想成『編碼器的雲跟目標先驗差多少?』
這個比喻能讓你一下子懂:把 KL 想成一筆稅。每一個訓練步,編碼器都可以隨意把每張影像的雲擺在它喜歡的位置、做成它喜歡的大小——但它必須繳一筆罰款,金額與那團雲偏離標準常態分佈的程度成正比。把雲停到 (50, 70) 那麼遠?重稅。把雲做得細如刀片或脹得超寬而不是分散度為 1?課稅。唯一能繳零稅的方法,就是讓雲恰好等於原點上的標準鐘形曲線。這筆稅正是把所有雲趕進上一節所要的那顆整齊、無空隙球體的那股力量。
兩個高斯分佈之間的 KL 散度有一個漂亮的封閉形式——不需要積分。
這看起來很密,但每一塊都有它的道理。求和 Σ 從 j = 1 跑到 d,也就是潛在空間各維,所以我們對每一維各算一筆稅再加總。括號內,μⱼ² 懲罰中心偏離原點的雲——偏得越遠,平方越大,稅越重。σⱼ² 懲罰變得太寬的雲(變異數越大代價越高)。而 −ln σⱼ² 則往反方向拉:當 σⱼ² 縮向零時,−ln σⱼ² 會飆向無窮大,所以它懲罰塌縮得太窄的雲(一團毫無模糊的雲又變回一根尖針——正是我們想逃離的東西)。−1 與 ½ 是調好的常數,讓整個式子在 μ = 0、σ = 1 時恰好觸底為零。
我們代個數字來感受一下。取一個已經完美的維度:μ = 0、σ = 1,所以 σ² = 1、ln σ² = ln 1 = 0。括號變成 0 + 1 − 0 − 1 = 0。零稅——雲乖乖聽話,什麼都不用繳。現在把它推離中心到 μ = 2、σ 仍為 1:括號是 4 + 1 − 0 − 1 = 4,乘上 ½ 得到 2 的稅。編碼器會把這個 2 感受成一道梯度,把 μ 往 0 拉回去。這筆罰款只有在雲恰好等於標準常態分佈時才消失——服從先驗,稅就不見了。
證據下界:一個目標、兩股力量
現在我們有兩個要求——忠實重建、緊貼先驗——而我們需要把它們合成單一一個數字,在訓練時去最大化。那個數字就是 證據下界,幾乎都簡稱 ELBO。我們把這個嚇人的名字溫和地拆開。『證據』(evidence)只是『我們的模型認為真實訓練影像有多可能出現』的花俏說法;我們很想讓它越高越好,但直接計算在數學上很棘手。『下界』(lower bound)意思是 ELBO 是一個永遠落在那個真實可能性下方的量。所以若我們把 ELBO 往上推,就會連帶把真實可能性一起拉上去——我們推的是一塊地板,而它會把天花板頂高。最大化 ELBO,就等於最大化模型能合理地生成真實影像的能力。
VAE 唯一的訓練目標:重建的獎勵,減去整齊度的稅。
我們把每個符號解碼。第一項 𝔼_{q(z|x)}[ log p(x | z) ] 讀作『對從編碼器的雲 q(z|x) 抽出的編碼 z 取平均後的 log p(x | z)』。𝔼[...] 是期望值——就是加權平均。括號內 p(x | z) 是解碼器的重建品質:當餵入編碼 z 時,解碼器賦予重建出原始影像 x 的機率。當重建影像與 x 高度吻合時它就高,所以整項是對忠實重建的獎勵。第二項正是上一節那筆 KL 散度 稅,度量編碼器的雲 q(z|x) 偏離先驗 p(z) 多遠。前面的減號表示我們把那筆稅減掉——偏離要受罰。講白:最大化 ELBO = 把影像重建好,並且讓雲緊貼先驗。 這一行就是 VAE 的全部目標。
把第一項接回第一篇。在那裡我們用平方誤差重建損失來訓練普通自編碼器——把原圖與重建圖之間的像素差平方後加總,再把它推向零。事實證明,『高斯解碼器的 log p(x | z)』在差一個常數的意義下,正好就是那個平方誤差的負值。所以最大化 𝔼[log p(x | z)],跟你早已熟悉的最小化逐像素平方誤差是同一回事,只不過現在是對從雲中取樣出的編碼取平均。沒有什麼玄妙——就是你已經認識的同一個重建目標,披上了機率的外衣。
把這兩項想成一場拔河。重建項想把每張影像的雲拉開、各自拉到專屬的鮮明位置,好讓它能與別人區分、被精準重建。KL 項則想把所有雲拉攏到原點,聚成一顆整齊的球。重建拉太用力,你就回到舊的佈滿洞的孤島;KL 拉太用力,每團雲就塌成一團糊。好的生成住在兩者之間的平衡裡。實務上我們常在 KL 項前加一個權重旋鈕 β 來調控這個取捨:把 β 調大,潛在空間變得更乾淨、更解耦,但重建會變模糊;把 β 調小,影像更銳利,但空間會更亂。調好這一個旋鈕,正是訓練 VAE 的核心技藝之一。
重參數化技巧:讓隨機性可被訓練
到目前為止,紙上談兵都很優雅,但流程的正中央藏著一個嚴重的問題,值得我們把腳步放到最慢。神經網路靠反向傳播訓練:我們計算當每個參數微微擺動時損失如何變化(梯度),再推動參數去降低損失。梯度要能流動,從輸入到損失的每一步都必須是平滑、可微分的函數。但看看 VAE 的中段:編碼器產生一團雲 (μ, σ),然後我們得從那團雲取樣出一個編碼 z——我們抽出一個隨機點。而你根本沒辦法對一次隨機抽樣做微分。擲一次硬幣沒有導數;不存在一個平滑的旋鈕說『若我把 μ 微推一點,隨機結果就會移動這麼多』。
打個比方。想像一台吃角子老虎機,有一根拉桿(你可學習的 μ 與 σ),機器內部封著一組骰子,會被擲出來決定彩金(z 的隨機取樣)。你想學會怎麼調整拉桿來提高期望獎金。但隨機性發生在封閉的箱子裡、在你的拉桿下游,所以你沒辦法從拉桿、穿過骰子、一路追到結果,畫出一條乾淨的因果線。梯度卡在骰子那裡。只要隨機這一步夾在你的參數與損失之間,訓練就被堵死。
解法就是 重參數化技巧,一旦看懂就會發現它優美地簡單:把隨機性挪到旁邊去。 我們不再直接從雲 𝒩(μ, σ²) 抽 z,而是從一個固定、不含任何可學參數的標準常態分佈抽出普通、通用的雜訊 ε,然後用 μ、σ 和這份雜訊以確定性的方式組出 z。隨機性還是發生了——但它現在是從側邊以外部輸入的形式進來,不在參數與損失之間的那條路徑上。
用編碼器的輸出加上固定的外部雜訊,以確定性的方式組出潛在編碼。
逐個符號看:μ 與 σ 來自編碼器,承載著網路的梯度——它們是可學習的旋鈕。ε(epsilon)是新鮮的標準常態雜訊,每一步都從 𝒩(0, I) 重新抽出;它不承載梯度,因為它只是來自外部的隨機輸入,裡面沒有任何可學的東西。符號 ⊙ 表示逐元素相乘:把 σ 的每個分量乘上 ε 對應的分量。於是我們拿這份雜訊,用 σ 把它拉伸,再用 μ 平移,這就是我們的編碼。回報有兩重。第一,在統計上這跟直接從 𝒩(μ, σ²) 抽 z 完全相同——把標準雜訊乘上 σ、加上 μ,正是把標準鐘形曲線變成平均值 μ、分散度 σ 的那條曲線的做法。第二,也是關鍵,z 現在是 μ 與 σ 的一個普通可微函數:導數 ∂z/∂μ = 1 與 ∂z/∂σ = ε 都存在。梯度可以直接穿過 z 流回編碼器。吃角子老虎的骰子現在住在箱子外面了,所以我們終於能學那根拉桿。
import torch
def reparameterize(mu, logvar):
# The encoder predicts log-variance for numerical stability,
# so recover the standard deviation sigma = exp(0.5 * logvar).
sigma = torch.exp(0.5 * logvar)
# Draw fresh standard-normal noise epsilon ~ N(0, I).
# randn_like has NO learnable parameters -> it carries no gradient.
epsilon = torch.randn_like(sigma)
# Build z deterministically: z = mu + sigma (elementwise *) epsilon.
# Gradients now flow through mu and sigma, but not through epsilon.
z = mu + sigma * epsilon
return z
# Why predict logvar instead of sigma directly?
# logvar can be any real number, while sigma must stay positive;
# exp(0.5 * logvar) is always > 0, so the network never has to
# fight a positivity constraint during training.取樣全新影像與模糊問題
現在來到我們一路鋪陳的回報。一旦 VAE 訓練完成,生成一張全新影像簡單到幾乎有點不好意思。我們再也不需要編碼器了——它唯一的工作就是在訓練期間整理潛在空間。要創造一張新影像,我們只要直接從標準常態先驗 𝒩(0, I) 抽一個 潛在編碼 z,餵給解碼器,就會跑出一張模型從沒見過的新圖。而且因為 KL 稅逼著潛在空間變得整齊、無空隙,那個隨機的 z 不論落在哪裡,都會落在有意義的地帶——解碼器知道該拿它怎麼辦。這正是普通自編碼器辦不到的事。
生成示意圖:從標準高斯分佈取樣的隨機潛在編碼直接餵入解碼器,產生一張新的合成影像,編碼器以灰色顯示、未被使用。
import torch
# Generation: the encoder is gone. We sample straight from the prior.
num_samples = 16
latent_dim = 128
# Draw codes from N(0, I) -- the exact distribution we trained toward.
z = torch.randn(num_samples, latent_dim)
# Decode each code into a brand-new image.
new_images = decoder(z) # shape: (16, channels, height, width)
# --- Latent interpolation: walk smoothly between two images ---
z_a = torch.randn(1, latent_dim) # code for an imagined face A
z_b = torch.randn(1, latent_dim) # code for an imagined face B
for t in torch.linspace(0, 1, steps=10):
z_mix = (1 - t) * z_a + t * z_b # straight line through latent space
frame = decoder(z_mix) # face A morphs smoothly into face B接下來這一刻會讓人愛上 VAE:潛在插值。挑兩個編碼 z_a 與 z_b,沿著一條直線從其中一個走到另一個,沿途每一步都解碼。因為潛在空間現在處處連續且有意義,解碼出的影像會平滑地變形——一張臉慢慢長出笑容、臉頰變圓,化成另一張臉,而每一個中間幀本身都是一張合理的臉。空隙裡沒有突兀的跳變、也沒有垃圾幀,因為根本沒有空隙。這份平滑,就是機率式潛在空間奏效的可見證據。
現在來談誠實的限制,因為精通也意味著認識它的弱點。VAE 的輸出往往看起來柔和而模糊——討喜又連貫,卻缺乏銳利的邊緣與細緻的紋理。原因直接追溯到重建損失。記得它是逐像素的平方誤差:它獎勵讓每個像素的值接近目標。但對任何一個給定編碼,存在許多同樣合理的銳利影像(一縷頭髮可能稍微偏左或偏右,一道邊緣可能挪一個像素)。既然平方誤差會懲罰任何單一像素出錯,解碼器就保守地預測所有這些合理選項的平均——而眾多銳利可能性的平均,是一張平滑、糊掉的圖。平均會把邊緣抹糊。這份模糊不是程式碼裡的臭蟲,而是『要對齊每個像素』這個目標本身就內建的。