從一篇ICLR'2017被拒論文談起:行走在GAN的Latent Space


同步自我的知乎專欄文章:https://zhuanlan.zhihu.com/p/32135185

從Slerp說起
ICLR'2017的投稿里,有一篇很有意思但被拒掉的投稿《Sampling Generative Networks》 by Tom White。文章比較松散地講了一些在latent space挺有用的采樣和可視化技巧,其中一個重要的點是指出在GAN的latent space中,比起常用的線性插值,沿着兩個采樣點之間的“弧”進行插值是更合理的辦法。實現的方法就是圖形學里的Slerp(spherical linear interpolation)在高維空間中的延伸:
\text {Slerp} (p_0,p_1;t)=\frac{sin((1-t)\Omega)}{sin(\Omega)} p_0+\frac{sin(t\Omega)}{sin(\Omega)} p_1\text {Slerp} (p_0,p_1;t)=\frac{sin((1-t)\Omega)}{sin(\Omega)} p_0+\frac{sin(t\Omega)}{sin(\Omega)} p_1
形象理解並不難,以wiki上的圖為例:
要求的是圖中P點,在Wiki圖的基礎上我加了A、B和O三個點,O就是原點。所以P其實就是\vec {OP}=\vec {OA} + \vec {OB}\vec {OP}=\vec {OA} + \vec {OB} 。先考慮求 \vec {OA}\vec {OA} ,第一步引入和 \vec {OP_0}\vec {OP_0} 垂直的 \vec {O\perp P_0}\vec {O\perp P_0} ,也就是圖中藍色的箭頭。那么 \left| OA \right|\left| OA \right|\left| OP_1 \right|\left| OP_1 \right| 的比值就等於他們分別投影到藍色向量上的部分的比值,也就是藍色箭頭兩側的橙色線段比紅色線段。這個比值正是 \frac{\sin \theta}{\sin \Omega}\frac{\sin \theta}{\sin \Omega} ,於是 \vec {OA}\vec {OA} 就是 \frac{sin(\theta)}{sin(\Omega)} p_1\frac{sin(\theta)}{sin(\Omega)} p_1 。很顯然,同樣的方法也可以用來求 \vec {OB}=\frac{sin(\Omega-\theta)}{sin(\Omega)} p_0\vec {OB}=\frac{sin(\Omega-\theta)}{sin(\Omega)} p_0 ,然后代入 \vec {OP}=\vec {OA}+\vec {OB}\vec {OP}=\vec {OA}+\vec {OB} ,並令 \theta=t\Omega\theta=t\Omega ,就得到了Slerp的公式。注意雖然推導的時候用的雖然是 P_0P_0P_1P_1 在同一個(超)球面上,但是實際用的時候不同長度的 P_0P_0P_1P_1 之間利用Slerp也是可以很自然的插值的,得到的向量長度介於二者之間且單調(非線性)增減。ICLR的Review中也討論到了這個問題。
使用Slerp比起純線性插值的好處在哪里呢?作者原文這樣解釋:
"Frequently linear interpolation is used, which is easily understood and implemented. But this is often inappropriate as the latent spaces of most generative models are high dimensional (> 50 dimensions) with a Gaussian or uniform prior. In such a space, linear interpolation traverses locations that are extremely unlikely given the prior. As a concrete example, consider a 100 dimensional space with the Gaussian prior µ=0, σ=1. Here all random vectors will generally a length very close to 10 (standard deviation < 1). However, linearly interpolating between any two will usually result in a "tent-pole" effect as the magnitude of the vector decreases from roughly 10 to 7 at the midpoint, which is over 4 standard deviations away from the expected length."
就是說在高維(>50)的空間里做線性插值,會路過一些不太可能路過的位置,就好像數據都分布在帳篷布上,但是線性插值走的是帳篷桿。
要更具體理解這個現象,還要從GAN中常用的prior distribution說起。在GAN中,最常用的是uniform和Gaussian(感覺現在Gaussian居多)。不管是哪種prior,對於一個n維樣本 \left( x_1,x_2,\dots,x_n \right)\left( x_1,x_2,\dots,x_n \right) ,到中心的歐式距離為:
d=\sqrt{x_1^2+x_2^2+\dots+x_n^2}d=\sqrt{x_1^2+x_2^2+\dots+x_n^2}
而通常GAN的采樣空間維度還算高,這個時候我們把d的平方看作是一連串n個獨立同分布的隨機變量 x_1^2,x_2^2,\dots,x_n^2x_1^2,x_2^2,\dots,x_n^2 的和,則由中心極限定理可知d的平方近似服從正態分布(實際上是Chi-square分布):
N\left( n\mu, n\sigma^2 \right)N\left( n\mu, n\sigma^2 \right)
考慮很常見的100維標准正態分布作為prior的情況,平方之后就是k=1的Chi-square分布,均值為1,方差為2。所以每個樣本到原點的距離的平方近似服從N(100, 200),標准差~14.14,如果認為 \delta\delta =14.14/100已經足夠小,使 \sqrt{1+\delta}\approx1+\frac 1 2\delta\sqrt{1+\delta}\approx1+\frac 1 2\delta ,則d也可以近似看作是一個高斯分布(實際上是Chi分布),均值為10,標准差為0.707,就是作者在原文中說的情況。
uniform prior的情況也類似,不過更加復雜,因為均勻分布並非各向同性。在高維空間中,一個超立方體形狀如果腦補一下就是一個球周邊長了很多尖刺,每個尖刺就是象限中的一個極端值。具體推導我不會,不過寫個程序很容易模擬。采10萬個樣本得到的結果是100維,每個維度[-1, 1]的均勻分布的prior,樣本到中心的距離平均值約為5.77,標准差約0.258。所以無論是哪種情況,高維空間里的樣本都有一個特點:遠離中心,且集中分布在均值附近。所以線性插值就會像在帳篷桿上插值一樣,路過真實樣本出現概率極低的區域。
原文還提到了在100維Gaussian prior的情況下,線性插值取到的點到中心的距離會從10到7,這怎么理解呢?我的數學水平腦補不了這件事,定性來看隨機采兩個樣本,這兩個樣本趨於垂直的傾向會很高,因為要趨於同向或者反向需要每一維的距離都足夠近或足夠遠,這個概率會很低。定量的話可以寫個程序模擬:
import numpy
from matplotlib import pyplot


def dist_o2l(p1, p2):
    # distance from origin to the line defined by (p1, p2)
    p12 = p2 - p1
    u12 = p12 / numpy.linalg.norm(p12)
    l_pp = numpy.dot(-p1, u12)
    pp = l_pp*u12 + p1
    return numpy.linalg.norm(pp)

dim = 100
N = 100000

rvs = []
dists2l = []
for i in range(N):
    u = numpy.random.randn(dim)
    v = numpy.random.randn(dim)
    rvs.extend([u, v])
    dists2l.append(dist_o2l(u, v))

dists = [numpy.linalg.norm(x) for x in rvs]

print('Distances to samples, mean: {}, std: {}'.format(numpy.mean(dists), numpy.std(dists)))
print('Distances to lines, mean: {}, std: {}'.format(numpy.mean(dists2l), numpy.std(dists2l)))

fig, (ax0, ax1) = pyplot.subplots(ncols=2, figsize=(11, 5))
ax0.hist(dists, 100, normed=1, color='g')
ax1.hist(dists2l, 100, normed=1, color='b')
pyplot.show()

結果如下:

左邊是在latent space里隨機采樣的樣本到中心距離的分布,右邊是原點在隨機采樣的兩個樣本所在直線上的投影點到中心距離的分布,也就是線性插值中到中心最近點的距離的分布。可以看到隨機采樣並進行線性插值的辦法還真的是容易路過樣本幾乎不可能出現的區域(距原點距離5~7.5)。可是《Sampling Generative Networks》被拒的comment里有一句:"neither the reviewers nor I were convinced that spherical interpolation makes more sense than linear interpolation"。就這一點來說,感覺Tom White有些冤枉,雖然確實不是什么眼前一亮的大改進,但是有理有據。那為什么reviewer們沒覺得比線性插值好多少呢?原因可能就是:
基於ReLU網絡的線性
CNN在12年的時候一鳴驚人,應該說ReLU一系的激活函數扮演了一個至關重要的角色:讓深層網絡可訓練。后續的無論是LeakyReLU、ELU還是Swish等等,大於0的部分都是非常線性的。所以雖然非線性變換(激活函數)是神經網絡作為universal approximator的基礎,但基於ReLU系的神經網絡其實是線性程度很高的。對於常見判別式網絡,Ian Goodfellow認為這種線性再加上Distributed Representation的超強表達能力是使得網絡容易被對抗樣本攻擊的基礎(詳見這篇),並據此發明了Fast Gradient Sign方法快速生成對抗樣本。
那么基於ReLU的CNN的線性有多強呢?先來看生成式網絡,以DCGAN為例,示意圖如下
從結構上來看,DCGAN比常見的判別式網絡更加線性,因為連max pooling都沒了,不那么線性的部分就只有最后輸出圖片的Tanh。尤其是從latent space到第一組feature map這一步,常見的實現方法是把100維的噪聲看成是100個channel,1x1的feature map,然后直接用沒有bias的transposed convolution上采樣,是一個純線性變換!定性來看,如果整個后續的網絡部分線性程度也足夠高,則在latent space的任意樣本,同時對所有維度進行縮放的話,得到的圖像應該差不多就是同一幅圖不同的對比度。
訓練一個GAN的生成器就可以驗證這個結論,感謝何之源在文章GAN學習指南:從原理入門到制作生成Demo中提供了一份對GAN而言高質量且好下載的動漫頭像數據。基於這個數據和PyTorch的官方DCGAN例子就可以很輕松的訓練出一個模型。基於訓練出的模型隨機采樣並分別進行Slerp和線性插值,結果如下:
1、3、5行是線性插值的結果,2、4、6是Slerp結果,仔細看的話會發現線性插值結果的中間部分和Slerp相比,顏色淡了那么一點點,除此以外差別微乎其微。也難怪Reviewer會覺得Tom White的結論不令人信服,如果沒有對比,線性插值的結果看起來很好。並且由於高維空間中樣本遠離中心的特性,所以線性插值的均勻性和Slerp也差不多。不過有了上面的分析,再回過頭來看DCGAN原文中的線性插值結果,好像中間部分看起來顏色還真是有點淡……
直接對比Slerp和線性插值並不是很有說服力,我們可以做一個更暴力的實驗,讓樣本沿着一個隨機方向從原點出發一直到距離原點20的位置,結果如下:
還是挺一目了然的,隨着latent sample漸漸遠離原點,圖像的變化基本上是對比度越來越高,直至飽和。起碼就人眼來說,這是很線性的。那么實際上呢?如果去掉Tanh層,隨機取一些樣本和輸出圖像隨機位置的值,畫出隨着latent sample距中心距離變化的趨勢,大概是下面這樣:
可以看到,只有在距離中心很遠的時候,線性才比較明顯,這和Goodfellow論文中的圖性質一致。在樣本最集中的10附近,線性程度一般般,甚至有些輸出都不是單調的。
那么更進一步,如果latent sample只產生在超球面上呢?或是到原點距離均勻分布呢?不妨試一試,結果如下:
1) 在到原點距離為10的超球面上產生latent sample
肉眼看上去還是很線性,輸出曲線的結果看上去和直接Gaussian采樣差別也不大。
2) latent sample到原點距離從0到10均勻分布
從曲線來看和前兩種情況明顯不一樣了,生成圖像質量也下降了一些。但是從產生的圖片來看線性仍然較強,至少到中心距離<10的部分,圖片“身份”無區分度的結論還是基本成立。
這是個很有趣的現象,無論是Gaussian采樣,還是1)和2)的情況,對人眼來說,這種很粗略程度的線性已經足夠讓沿着某個方向上的latent sample產生從“身份”角度看上去無差別的樣本了。不管怎么樣,基於這個現象,得到一個粗略的推論:GAN的Latent Space只有沿着超球面的變化才是有區分力的
在Great Circle上行走
經過一番分析,得到了一個好像也沒什么用的結論。再回來看最初的問題,線性插值會路過低概率區域(雖然並沒有什么影響),Slerp比線性插值也沒什么視覺上的本質提高,那么有沒有什么更優雅地行走在latent space的方法呢?我覺得是:Great Circle。比起Slerp,Greate Circle通常要經過多3倍的距離,雖然這和Slerp其實也沒什么本質區別,但是感覺上要更屌,而且沿着great circle走起點和終點是同一個點,這感覺更屌。
產生great circle路徑比Slerp要簡單得多:1)根據所使用分布產生一個超球面半徑r(按前面討論,Gaussian的話就是chi分布,或者Gaussian近似);2)產生一個隨機向量u和一個與u垂直的隨機向量v,然后把u和v所在平面作為great circle所在平面;3)u和v等效於一個坐標系的兩軸,所以great circle上任一點就用在u和v上的投影表示就可以,最后在乘上r就得到了行走在great circle上的采樣。代碼如下:
from __future__ import print_function
import argparse
import os
import numpy
from scipy.stats import chi
import torch.utils.data
from torch.autograd import Variable
from networks import NetG
from PIL import Image

parser = argparse.ArgumentParser()
parser.add_argument('--nz', type=int, default=100, help='size of the latent z vector')
parser.add_argument('--niter', type=int, default=10, help='how many paths')
parser.add_argument('--n_steps', type=int, default=23, help='steps to walk')
parser.add_argument('--ngf', type=int, default=64)
parser.add_argument('--ngpu', type=int, default=1, help='number of GPUs to use')
parser.add_argument('--netG', default='netG_epoch_49.pth', help="trained params for G")

opt = parser.parse_args()
output_dir = 'gcircle-walk'
os.system('mkdir -p {}'.format(output_dir))
print(opt)

ngpu = int(opt.ngpu)
nz = int(opt.nz)
ngf = int(opt.ngf)
nc = 3

netG = NetG(ngf, nz, nc, ngpu)
netG.load_state_dict(torch.load(opt.netG, map_location=lambda storage, loc: storage))
netG.eval()
print(netG)

for j in range(opt.niter):
    # step 1
    r = chi.rvs(df=100)

    # step 2
    u = numpy.random.normal(0, 1, nz)
    w = numpy.random.normal(0, 1, nz)
    u /= numpy.linalg.norm(u)
    w /= numpy.linalg.norm(w)

    v = w - numpy.dot(u, w) * u
    v /= numpy.linalg.norm(v)

    ndimgs = []
    for i in range(opt.n_steps):
        t = float(i) / float(opt.n_steps)
        # step 3
        z = numpy.cos(t * 2 * numpy.pi) * u + numpy.sin(t * 2 * numpy.pi) * v
        z *= r

        noise_t = z.reshape((1, nz, 1, 1))
        noise_t = torch.FloatTensor(noise_t)
        noisev = Variable(noise_t)
        fake = netG(noisev)
        timg = fake[0]
        timg = timg.data

        timg.add_(1).div_(2)
        ndimg = timg.mul(255).clamp(0, 255).byte().permute(1, 2, 0).numpy()
        ndimgs.append(ndimg)

    print('exporting {} ...'.format(j))
    ndimg = numpy.hstack(ndimgs)

    im = Image.fromarray(ndimg)
    filename = os.sep.join([output_dir, 'gc-{:0>6d}.png'.format(j)])
    im.save(filename)

結果如下:

雖然感覺沒什么用,不過萬一有人想試試,代碼在此:great-circle-interp

 


免責聲明!

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



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