§01學習重點
- 說出判別器在這套做法裡的職務是「生成器的訊號來源」,而不是訓練完要交出去的那個產物
- 解釋一個沒有標籤的生成問題怎麼被改寫成一個有標籤的二元分類問題,並指出那些標籤是誰造出來的
- 寫出極小極大目標函數,逐項說出每個符號代表什麼,並說明兩個下標為什麼方向相反
- 說明第 6 章那張固定不動的損失地景在這裡為什麼不成立,並親手跑出一段永遠繞不完的參數軌跡
- 區分本章的「訊號消失」與第 7 章的梯度消失:同一個名字,兩個無關的成因
- 用「像不像」與「夠不夠全」兩個彼此獨立的要求解釋模式崩潰,並說出一個訓練中量得到的診斷指標
- 分清楚「判別器」與「判別模型」,並說明整套生成對抗網路為什麼仍然算生成模型
- 說出這一族模型付的代價:抽得出樣本,卻算不出任何一筆樣本的機率密度
§02課程內容
一、把「像不像」外包出去:損失也可以是學出來的
前面十四章的損失,骨架都一樣。你有一筆輸入、有一個正確答案、有模型算出來的預測,把預測與答案丟進一條事先寫死的式子,得到一個數。第 5 章還把這條式子的來歷交代得很清楚:你先假設輸出服從哪一種機率分布,那個假設就決定了式子長什麼樣。但無論怎麼推,那條式子從訓練第一步到最後一步都不會變——它是這場計算裡唯一不動的東西。
現在把答案拿掉。你手上只剩一批樣本,任務是造出新的、像它們的東西。這時候第一個卡住的地方不是「怎麼造」,而是「怎麼評分」:你要拿什麼式子衡量一筆造出來的東西像不像原來那批?
最直覺的做法是逐點相減——把生成的東西跟某一筆真實樣本對齊,逐個數字算差、平方、相加。這在低維還堪用,一上高維就整個垮掉,原因是逐點的差異小跟聽起來像,是兩件不同的事。設想有一段真實的錄音,你把它整體往後挪千分之三秒:逐點相減的差距會非常大,人耳卻完全分不出兩者的差別。反過來,在同一段錄音上均勻疊一層很小的雜訊:逐點差距很小,聽感立刻就壞了。任何一條寫死的距離式子,都是在替「像不像」給一個固定的定義;而在高維資料上,我們根本寫不出那個定義。
寫不出來,就換個辦法:不寫,改成學。
具體做法是再養一個網路,它的職務只有一件——對一筆送進來的東西回答「這是資料集裡本來就有的,還是被造出來的」。這個網路叫判別器(discriminator),我們把它寫成 \(d[\cdot, \boldsymbol\phi_d]\),輸出是一個介於 0 與 1 之間的數,可以讀成「這一筆是真的」的可能性。負責造東西的那個網路叫生成器(generator),寫成 \(g[\cdot, \boldsymbol\phi_g]\)。兩組參數各自獨立,下標 \(g\) 與 \(d\) 只是在說「這是哪一個網路的參數全集」。生成器吃的是第 1 章介紹過的潛在變數 \(\mathbf{z}\)——從一個我們自己挑的簡單分布裡隨機抽出來的低維向量——把它攤開成一筆跟資料同樣形狀的東西,記作 \(\mathbf{x}^\ast\),以便跟真實樣本 \(\mathbf{x}\) 區分。這一整套合起來叫生成對抗網路(generative adversarial network)。
這裡有一個很容易被跳過的關鍵:判別器的訓練標籤不是任何人標的,是這套流程自己造出來的。 從資料集裡撈一批,全部蓋上「1」;讓生成器吐一批,全部蓋上「0」。這兩個標籤不需要任何領域知識、不需要任何人力,只需要知道這一筆是從哪裡來的——而那件事我們當然知道,因為東西是我們自己抽的、自己造的。
於是一個「沒有標籤」的問題,被改寫成一個標籤要多少有多少的二元分類問題。這裡常有人問:不是非監督式學習嗎,怎麼會冒出交叉熵?答案就在上一段——標籤是被製造出來的,不是被觀測到的。整套流程對外只吃一批無標籤的樣本,內部卻替自己生出了一個監督式的子任務。
有了標籤,第 5 章那條二元交叉熵(binary cross-entropy)直接可以用。把一批 \(I\) 筆真品與一批 \(J\) 筆仿品湊成一個批次,判別器的損失是:
逐項拆解:\(\mathbf{x}_i\) 是第 \(i\) 筆真品,它的標籤是 1,所以只剩交叉熵的前半項 \(\log d\);\(\mathbf{x}^\ast_j\) 是第 \(j\) 筆仿品,標籤是 0,所以只剩後半項 \(\log(1-d)\)。兩個負號都在總和外面——這是第 5 章特別叮嚀過的位置,寫進括號裡整條式子會變成負的。兩個 \(\tfrac{1}{2}\) 是因為這個批次由真仿各半組成,取平均才不會讓其中一邊的筆數多寡影響損失的尺度(本課一律取平均,第 6 章已經釘死這個口徑)。判別器要做的事沒有任何新意:把 \(\mathcal{L}_d\) 往下降,就是把真品的判定值往 1 推、把仿品的判定值往 0 壓。
真正的轉折在生成器這一邊。生成器沒有自己的損失式子——它的損失就是判別器現在的看法:
逐項拆解:\(\mathbf{z}_j\) 是第 \(j\) 次抽到的潛在變數;\(g[\mathbf{z}_j, \boldsymbol\phi_g]\) 是生成器交出的那一筆仿品;外面那層 \(d[\cdot, \boldsymbol\phi_d]\) 是判別器對它的判定值。判定值越接近 1,\(1-d\) 越接近 0,對數就越負,這條損失就越小。所以把 \(\mathcal{L}_g\) 降到最低,字面上的意思就是「讓判別器盡量把我造的東西當成真的」。
請盯住這條式子裡的 \(\boldsymbol\phi_d\)。它在裡面,但它不是這一步要調的東西——這一步只動 \(\boldsymbol\phi_g\)。而 \(\boldsymbol\phi_d\) 會隨著判別器自己的訓練不斷改變,所以 \(\mathcal{L}_g\) 這條函數的形狀每一輪都不一樣。前面十四章那條寫死的式子,在這裡變成一個會動的東西。這是本章與前面所有內容最大的斷點,值得停三秒。
梯度怎麼算?第 7 章的鏈鎖法則直接搬過來用,只是這次鏈條要多穿過一個網路:
逐項拆解:\(\ell_j\) 是第 \(j\) 筆仿品貢獻的那一項損失;第一個因子問「判定值變一點,這一項損失變多少」;第二個因子問「這筆仿品的數值變一點,判定值變多少」——這一段完全發生在判別器內部;第三個因子才回到生成器,問「生成器的參數變一點,它吐出來的東西變多少」。三段乘起來,就是判別器的意見怎麼一路折算回生成器身上。
這條式子帶出訓練生成器那一步的一條紀律,實作時常有人搞錯:判別器的參數不更新,但它的梯度必須讓路通過。 你不能為了「不要動到判別器」就把它整個切斷——切斷了,中間那個因子 \(\partial d / \partial \mathbf{x}^\ast\) 就沒了,生成器收不到任何東西。正確的做法是照常反向計算穿過判別器,只是最後不把 \(\boldsymbol\phi_d\) 的更新套用上去。反過來,訓練判別器那一步則要把仿品當成固定不變的資料,把生成器那一端切斷,這個動作叫停止梯度(stop-gradient)。
import numpy as np
rng = np.random.default_rng(0)
# 想像評審這一輪聽了 8 段真品錄音與 8 段模仿,
# 對每一段給一個 0~1 的判定值(=「這是本人」的可能性)
real = np.clip(rng.normal(0.78, 0.10, 8), 0.01, 0.99)
fake = np.clip(rng.normal(0.35, 0.12, 8), 0.01, 0.99)
# 標籤沒有人給,是這套流程自己造出來的
y_real, y_fake = np.ones(8), np.zeros(8)
def bce(y, lam): # 第 5 章的二元交叉熵
lam = np.clip(lam, 1e-12, 1 - 1e-12)
return -np.mean(y * np.log(lam) + (1 - y) * np.log(1 - lam))
loss_d = 0.5 * bce(y_real, real) + 0.5 * bce(y_fake, fake)
loss_g = float(np.mean(np.log(1 - fake)))
print("真品判定值:", " ".join(f"{v:.3f}" for v in real))
print("仿品判定值:", " ".join(f"{v:.3f}" for v in fake))
print("判別器損失:", round(float(loss_d), 4))
print("生成器損失:", round(loss_g, 4))
# 對照點:評審完全分不出來,一律輸出 0.5
half = np.full(8, 0.5)
blind = 0.5 * bce(y_real, half) + 0.5 * bce(y_fake, half)
print("一律 0.5 時的判別器損失:", round(float(blind), 4),
"/ log 2 =", round(float(np.log(2)), 4))實跑輸出:真品的判定值是 0.793 0.767 0.844 0.790 0.726 0.816 0.910 0.875,仿品是 0.266 0.198 0.275 0.355 0.071 0.324 0.200 0.262,判別器損失 0.246,生成器損失 -0.2853。最後一行是這一章會反覆用到的對照點:判定值一律給 0.5 時,判別器損失剛好是 0.6931,也就是 \(\log 2\)——第 5 章算過同一個數。它代表「完全分不出來」的水準,是一個參考刻度,不是一個要去追的目標。
比喻: 一場盲聽的模仿秀。評審席上的人看不到台上是誰,只聽聲音,每聽完一段就回答一件事:這是本人,還是模仿。模仿者拿不到本人的錄音,一次也沒有;他唯一能得到的東西,是評審給的那句判定。他要靠那句判定反推自己哪裡不像,然後改。這個比喻在一處失準得很要緊:真實的模仿秀有一個獨立存在的「本人」可以事後查證,評審講錯了會被抓出來;生成對抗網路沒有這種外部裁判,評審自己也是學出來的,它的判準可以整個歪掉,而且沒有任何人會發現。
這張圖裡最重要的是那條被劃掉的線:生成器從頭到尾沒有直接接觸過任何一筆真實樣本,它看得到的世界只有判別器對它的判定。後面談模式崩潰時,你會發現許多怪現象都源自這個管道有多窄。
最後補一個容易搞混的地方,而且是中文譯名帶來的陷阱。「判別器」與「判別模型(discriminative model)」是兩個不同層次的東西。 判別器是這套架構裡的一個網路元件,職務是回答「真的還是造的」;判別模型則是一種建模方向,指的是直接對「給定輸入、答案是什麼」建模,不去描述資料本身怎麼分布。兩個詞在中文裡只差一個字,講的卻不是同一回事。所以整套生成對抗網路是生成模型(generative model),即使它肚子裡裝了一個判別器——判斷一個模型屬於哪一類,看的是它最後能不能造出新樣本,不是看它內部有沒有做過分類。
還有一件事現在就該講清楚:訓練結束以後,判別器就丟掉了。 要生成東西時只需要生成器,把 \(\mathbf{z}\) 抽出來餵進 \(g\) 即可。判別器是訓練期間的鷹架,不是成品的一部分——它的價值不在於分得多準,而在於它是生成器唯一的訊號來源。
二、極小極大:地景會動的時候,沒有谷底可以走
上一節留下一個尷尬的處境:判別器要把兩邊分開,生成器要讓它分不開。同一個量,一邊想壓小、一邊想推大。把這件事寫成一條式子,就是本章的核心目標函數:
逐項拆解:中間那一大塊就是上一節判別器損失取負號之後的樣子——判別器分得越開,這個量越大。\(\max_{\boldsymbol\phi_d}\) 說的是判別器調自己的參數把這個量推到最大;\(\min_{\boldsymbol\phi_g}\) 說的是生成器調自己的參數把同一個量壓到最小。兩個下標分別掛在兩組不同的參數上,這是全式最該看清楚的地方:它們動的不是同一組旋鈕,卻在拉同一個量的兩端。 這種形式叫極小極大(minimax),名字就是「取最小」與「取最大」疊在一起。
順帶說明一件事,免得你被符號誤導:兩個下標的先後在數學上是有意義的(先固定誰、再對誰取極值,答案未必相同),但對本課的目的,只要記住「一個在壓、一個在推,壓推的是同一個量」就夠了。把內外層的嚴格語義展開會牽進賽局論,那不是本課的先修;這個問題的理論解在什麼條件下存在,也一樣——章末的原書對照節標了該翻哪幾頁。
現在請把第 6 章那張圖調出來:橫軸是參數,縱軸是損失,訓練就是在那個高低起伏的地景上往下走。這張圖在本章不成立。 不是因為地景太複雜、谷太多,而是因為地景本身在動。生成器往下走一步,判別器隨即更新,於是生成器腳下那片地形整個換了一個形狀;它上一步辛苦找到的下坡方向,這一步可能變成上坡。反過來也一樣。兩邊都在對著一個會變的東西做梯度下降。
實務上的做法是交替更新:固定生成器,讓判別器走幾步;固定判別器,讓生成器走幾步;然後換手,一直輪下去。這個安排很自然,但它保證不了任何事——底下這段程式用一個小到不能再小的例子,把「這裡沒有谷底可以走」直接演給你看。
import numpy as np
# 最小的雙人博弈:目標值 v = a * b
# a 想把 v 壓小,所以往 -∂v/∂a 走;b 想把 v 推大,所以往 +∂v/∂b 走
def run(alpha, steps, mode):
a, b = 1.0, 1.0
path = [(a, b)]
for _ in range(steps):
if mode == "同時":
a, b = a - alpha * b, b + alpha * a # 兩邊都讀舊值
else:
a = a - alpha * b # 先動 a
b = b + alpha * a # 再用新的 a 動 b
path.append((a, b))
return np.array(path)
for mode in ("同時", "交替"):
for alpha in (0.15, 0.45):
p = run(alpha, 240, mode)
r = np.hypot(p[:, 0], p[:, 1])
v = p[:, 0] * p[:, 1]
flips = int(np.sum(np.sign(v[1:]) != np.sign(v[:-1])))
print(f"{mode}更新 α={alpha}|半徑 起 {r[0]:.3f}/第 60 步 {r[60]:.3f}"
f"/最小 {r.min():.3f}/最大 {r.max():.3f}/終 {r[-1]:.3f}"
f"|v 變號 {flips} 次")
# 交替更新跨過某個學習率就從「繞不完」變成「炸開」
for alpha in (1.6, 1.9, 2.1, 2.4):
r = float(np.hypot(*run(alpha, 240, "交替")[-1]))
print(f"交替更新 α={alpha}:第 240 步的半徑 {r:.4g}")實跑輸出分兩段看。上半段:同時更新在 \(\alpha=0.15\) 下,半徑走到第 60 步已經是 2.757、240 步後是 20.423,換成 \(\alpha=0.45\) 更誇張,終點半徑 5764016340.558——兩個玩家一起往外甩。交替更新則完全不同:\(\alpha=0.15\) 的半徑在 1.312 與 1.414 之間來回,\(\alpha=0.45\) 在 1.125 與 1.414 之間來回。它不發散,但它也永遠不會到達那個誰都不想再動的點——240 步跑完,兩個參數還在原地繞。同一段輸出裡,目標值 \(v\) 的正負號變了二十幾次到七十次;你要是拿它當進度指標,會以為訓練一直在震盪,而事實是:它確實一直在震盪,那就是這個問題的真面目。
下半段給了另一個提醒:交替更新的穩定性不是無條件的。\(\alpha=1.6\) 與 \(1.9\) 的終點半徑分別是 0.8567 與 0.2423,都還在原地附近;\(\alpha=2.1\) 直接跳到 3.194e+65,\(\alpha=2.4\) 是 4.045e+129。分界落在 2 附近。
從這裡可以推出一條實務上非常重要的結論:在這套訓練裡,損失數字不能當進度指標。 前面幾章你養成的習慣是看損失曲線往下掉,掉得夠久夠平就差不多了。這裡不行。判別器的損失下降,可能代表它變強了,也可能代表生成器退步了;生成器的損失下降,可能代表它進步了,也可能代表判別器暫時落後。兩條曲線是彼此的鏡子,任何一條單看都不帶資訊。這一點跟第 6 章談到的假收斂有一半的親戚關係——那裡是觀測指標騙了你,這裡也是,只是騙人的機制不同:第 6 章是曲線躺平而參數還在磨蹭,這裡是曲線本身就沒有「應該往哪走」這回事。
還有一個實作上的細節值得一提。上一節寫的生成器損失是「讓判別器判不出是仿品」,實務上也常改寫成「讓判別器把仿品判成真」——把 \(\log(1-d)\) 換成 \(-\log d\)。兩種寫法的最佳點相同,但同一個位置上的梯度大小不同,後者在訓練初期(判別器輕鬆獲勝、\(d\) 貼近 0 的時候)給的訊號大得多。請不要把它們說成「等價」,那會漏掉唯一有用的那個差別。
比喻: 評審席上的人每一輪都在變厲害。模仿者這一輪拿到「這段咬字太用力」的評語,照著改;下一輪同一段唱法卻被打回票,因為評審已經聽出了新的破綻。他不是在對著一把固定的尺調整,而是在追一把每輪都被重新刻過的尺。這個比喻的失準之處剛好也是它最有價值的地方:真實的評審是同一個人,判準大致穩定,模仿者累積下來的經驗多半還算數;判別器沒有這種穩定性,它上一輪關注的特徵這一輪可以完全換掉,所以「上一輪拿到高分」不保證這一輪還高。比喻裡的「評審」被默認為一把尺,而那正是這套訓練沒有的東西。
三、訊號會消失:太鈍與贏太輕鬆,兩邊都壞
上一節說判別器一直在變強。那麼問題來了:它是不是越強越好?
反了。 這大概是本章跟你前八章的直覺牴觸得最厲害的一句話。判別器準到一定程度以後,生成器就收不到有用的訊號了。原因藏在梯度那條鏈子裡:中間那個因子問的是「這筆仿品的數值變一點,判定值變多少」。當判別器已經能一聽就判、而且判得非常有把握時,仿品附近的判定值全都貼在 0,你把仿品挪一點點,判定值還是 0。因子接近零,整條鏈子乘出來就接近零。評審只說「不像」,卻沒有透露往哪個方向改比較好。
反過來,判別器太鈍也不行。一個還沒學到位的判別器,對什麼都回答 0.5 附近的數,那個判定值裡沒有任何鑑別力,生成器同樣得不到方向。
所以這裡存在一個必須維持的相對強弱:判別器要強到能給出有意義的區分,又不能強到把仿品判死。這句話聽起來很抽象,但它可以直接算給你看。
import numpy as np
# 一維的評分軸 u:真品的分數服從 N(0,1),仿品的分數服從 N(-s,1)。
# s 越大,代表兩堆東西在評審耳裡分得越開。
u = np.linspace(-24, 16, 40001)
du = u[1] - u[0]
def probe(s, k):
# 最佳判別器的判定值化簡後就是 σ(s·u + s²/2)。
# k 是鈍化係數:k=1 是完全學到位的評審,k=0 是一律回 0.5 的評審。
z = np.clip(k * (s * u + s * s / 2), -700, 700)
d = 1 / (1 + np.exp(-z))
p_fake = np.exp(-(u + s) ** 2 / 2) / np.sqrt(2 * np.pi)
conf = float(np.sum(d * p_fake) * du) # 仿品那一側的平均判定值
return conf, k * s * conf # 第二個數就是生成器拿到的梯度
print("A:評審完全學到位(k=1),只改變兩堆分得多開")
for s in (0.5, 1.0, 2.0, 3.0, 4.0, 6.0):
c, g = probe(s, 1.0)
print(f" s={s:.1f} 仿品平均判定值 {c:.6f} 梯度 {g:.3e}")
print("B:分離程度固定在 s=3,只改變評審學到位的程度")
for k in (0.0, 0.05, 0.2, 0.5, 1.0):
c, g = probe(3.0, k)
print(f" k={k:.2f} 仿品平均判定值 {c:.6f} 梯度 {g:.3e}")先說這段程式在算什麼。它不是在訓練,是在解析地算一個量,所以跑得很快、數字很乾淨,也不必擔心收斂。設定是:把真品與仿品各自壓成評分軸上的一個常態分布,兩者相隔 \(s\)。在這個設定下,讓 \(\mathcal{L}_d\) 最小的那個判別器有閉式解——對 \(d\) 求導再令其為零,會得到「這一點的真品密度佔兩邊密度總和的比例」,代進兩個常態分布化簡以後,判定值剛好是 \(\mathrm{sig}[su + s^2/2]\),也就是一條 logistic sigmoid 套在 \(u\) 的一次式上。第二個旋鈕 \(k\) 是我加上去的:把括號裡整條式子乘上 \(k\),\(k=1\) 是那個完全學到位的評審,\(k\) 越小判定線被壓得越平,\(k=0\) 就是一律回 0.5。最後一個數是生成器的損失對「把整堆仿品往真品方向挪一格」的導數大小——那正是它每一步能拿到的訊號。
實跑輸出的 A 段是這樣:\(s\) 從 0.5 到 6,梯度依序是 2.353e-01、3.980e-01、4.496e-01、2.959e-01、1.372e-01、1.245e-02。它先升後降,最高點落在 \(s\) 約等於 2 的地方。看仿品的平均判定值就明白為什麼會降:\(s=2\) 時是 0.224800,\(s=6\) 時只剩 0.002076。評審贏得越輕鬆,回程能傳的東西越少——從 \(s=2\) 到 \(s=6\),訊號掉了三十六倍。
B 段則是另一個方向的失敗:分離程度固定,只把評審鈍化。\(k\) 從 0 到 1,梯度依序是 0.000e+00、6.664e-02、1.817e-01、2.435e-01、2.959e-01。評審完全沒學到東西時,訊號真的是零。
現在必須把一件事切乾淨,否則你後面一定會混淆。 本章的「訊號消失」跟第 7 章的梯度消失只是名字撞在一起,成因完全無關。
第 7 章的梯度消失是深度造成的:反向計算時每經過一層就乘上一個增益,層數一多,這些增益連乘起來把梯度指數地縮小,所以最前面幾層幾乎不動。那是一個純粹結構性的問題,跟資料像不像、跟另一個網路強不強都沒有關係,處方是調初始化的尺度或改架構。
本章的訊號消失是判別器相對太強造成的:梯度不是被層數縮小的,而是根本沒有產生——判定曲線在仿品所在的那一帶太平,微調生成器不會改變判定結果,所以第一節那條鏈子裡的中間因子本身就接近零。它跟網路有幾層完全沒有關係;一個只有兩層的生成器照樣會遇到。處方也不同:要調的是兩個網路之間的相對強弱、或者換一個對「差多遠」比較敏感的量(下一節會談)。同一個詞,兩個病,兩張處方。
四、模式崩潰:騙得過,不代表唱得全
把生成器的損失再讀一次,然後問一個問題:這條式子裡有哪一項在要求「要涵蓋全部」?
沒有。一項都沒有。\(\mathcal{L}_g\) 從頭到尾只在說一件事——讓判別器對我交出來的東西給出高的判定值。它沒有任何地方提到「你要能造出各式各樣的東西」,也沒有任何地方懲罰「你老是造同一個」。
這就把兩個要求分開了,而學生常常把它們當成同一件事:造得像是一回事,造得全是另一回事。前者問「你交出來的東西合不合格」,後者問「資料裡有的花樣你是不是都造得出來」。一個模型完全可以在前者拿滿分、在後者拿零分。
這件事的溫和版本叫多樣性不足:生成器只覆蓋了資料分布的一小塊,但那一小塊覆蓋得非常好。極端版本叫模式崩潰(mode collapse):生成器把輸出塌成少數幾種,甚至塌成一種,連隨機輸入 \(\mathbf{z}\) 都被無視了——不管抽到哪一個 \(\mathbf{z}\),出來的東西都差不多。這是最直接的診斷訊號,因為 \(\mathbf{z}\) 是這套架構裡多樣性的唯一來源;生成器一旦學會忽略它,多樣性就歸零。
那判別器為什麼不懲罰?它理應能懲罰的——一批仿品全都長得一樣,這件事本身就是個破綻。問題出在判別器是逐筆判定的:它拿到一筆東西,回答這一筆像不像,它的職務裡沒有「看看這一批彼此像不像」。所以只要生成器找到的那一小塊確實在真品密度高的地方,逐筆看每一筆都合格,判別器就抓不到。
實際訓練中還會演化成一個更討厭的循環。判別器最終還是會發現「這一帶的東西最近特別多,可疑」,於是開始針對那一帶壓分;生成器一被壓,就整批跳到另一塊真品密度高的地方,在那裡又爽拿高分;判別器再追過去,生成器再跳回原來那塊。兩邊繞著幾個位置輪流跑,訓練曲線看起來一直有變化,實際上什麼都沒有累積起來。
底下這段程式把「像」與「全」的脫鉤量出來。
import numpy as np
rng = np.random.default_rng(0)
# 曲庫:6 首曲目,每一首在「唱腔特徵平面」上佔一個聚集區
ang = np.arange(6) / 6 * 2 * np.pi
centres = np.stack([3.0 * np.cos(ang), 3.0 * np.sin(ang)], axis=1)
real = centres[rng.integers(0, 6, 1200)] + rng.normal(0, 0.28, (1200, 2))
def like_real(pts): # 用真品樣本估計「聽起來像本人」的程度
d2 = ((pts[:, None, :] - real[None, :, :]) ** 2).sum(-1)
return np.exp(-d2 / (2 * 0.35 ** 2)).mean(1)
thr = np.quantile(like_real(real), 0.10) # 門檻訂在讓九成真品過關
only_one = centres[0] + rng.normal(0, 0.28, (600, 2)) # 場場只唱第一首
all_six = centres[rng.integers(0, 6, 600)] + rng.normal(0, 0.28, (600, 2))
for name, x in (("只唱一首", only_one), ("六首都唱", all_six)):
passed = float((like_real(x) > thr).mean())
dist = np.sqrt(((x[:, None, :] - centres[None, :, :]) ** 2).sum(-1))
covered = int((dist.min(axis=0) < 1.0).sum())
spread = float(x.std(axis=0).mean())
print(f"{name}:騙過率 {passed:.3f} 覆蓋曲目 {covered}/6 "
f"批次逐維標準差平均 {spread:.3f}")這裡的「判別器」被簡化成一條密度規則:一筆東西如果落在真品密集的地方就算過關,門檻訂在讓九成真品過得去。這比真正的判別器寬鬆,但方向一致——它就是在做逐筆判定。
實跑輸出:只唱一首的那個生成器,騙過率 0.845、覆蓋曲目 1/6、批次逐維標準差平均 0.283;六首都唱的那個,騙過率 0.900、覆蓋曲目 6/6、標準差平均 2.147。兩者的騙過率只差了不到六個百分點,覆蓋率卻差了六倍。 這就是模式崩潰能在這個損失底下活得好好的原因:它在被評分的那個維度上幾乎不吃虧。
輸出的最後一欄同時給了偵測的著力點。批次內部的多樣性統計量——最簡單的一種就是算一批樣本每個維度的標準差再取平均——在兩種情形下是 0.283 對 2.147,差了七倍多,比騙過率敏感得多。實務上把這個數字跟真實批次的同一個統計量並排監看,塌下去就知道出事了。這也是為什麼監看訓練狀況時,光看兩條損失曲線是不夠的:損失不會告訴你多樣性的事,因為損失裡根本沒有那一項。
比喻: 一個模仿者發現,只要場場都唱同一首拿手曲目,評審幾乎每次都判成本人。他不會有任何動機去練第二首——賽制沒有要求他練,評語裡也從來沒出現過「你的曲目太少」這句話,因為評審每次只聽一段、只回答那一段像不像。抽籤環節形同虛設:不管抽到哪一支籤,他都唱那一首。這個比喻在一處失準:真實的賽制通常會規定曲目不得重複,主辦方看得到整場的節目單;判別器看不到「整場」,它的視野永遠只有手上這一段,所以這個漏洞不是有人疏忽,而是這個損失的定義裡本來就沒有那一格。
五、換一個距離:改善穩定性的方向
前面那些麻煩有一個共同的病因,可以壓成一句話:當生成的那一堆東西與真實的那一堆幾乎沒有交疊時,用來衡量它們差多遠的那個量,對「靠近一點」沒有反應。 兩堆完全分開的時候,把它們拉近一半,那個量往往紋風不動;既然不動,梯度就是零,生成器就不知道該往哪走。第三節 A 段實跑出來的那條下滑曲線,量到的正是這個現象。
修法的方向也可以壓成一句話:換一個在兩堆還沒碰到彼此時、就已經對遠近有反應的量。 這條路線裡最有代表性的是 Wasserstein 距離(Wasserstein distance)(領域裡通用這個名字,沒有標準中譯)。它有一個直接的好處:即使兩堆完全沒有交疊,它也不會卡在同一個值上,所以還給得出方向。
代價也很明確:要用神經網路把這個量算出來,扮演評分角色的那個網路必須被限制住,不能讓它的輸出隨輸入變化得太劇烈。怎麼施加這個限制、限制到什麼程度,本身就是一整個題目。
到此為止。這條路線的完整寫法、限制手法的幾種實作,以及各式提升成品品質的工程技巧,章末的原書對照節都標了頁碼。本課只要你記住那一組因果:兩堆不重疊 → 衡量的量不敏感 → 沒有梯度 → 換一個對遠近敏感的量。
六、這一族模型的分野:抽得出樣本,算不出密度
最後把這一章放回它的位置。第 14 章(非監督式學習:沒有標籤時要學什麼)建立了這一族模型的分類與評價標準;第 1 章第四小節則已經預告過一個分岔:生成模型有「要不要把整個資料分布明明白白寫成一條式子」的選擇,而第 15 到 18 章分屬這個分岔的兩邊。
本章這套做法站在不寫的那一邊,而且不是因為懶,是因為它的架構裡根本沒有那條式子的位置。你有一個生成器,能把 \(\mathbf{z}\) 攤開成 \(\mathbf{x}^\ast\),所以要多少新樣本有多少;但你手上沒有任何東西可以拿來回答「這一筆樣本出現的機率密度(probability density)是多少」。
這個缺口不是理論上的潔癖,它擋掉了一整類實際用途。你問不了「這一筆有多罕見」,因此凡是要拿密度當判準的事都做不了——比方說異常偵測(anomaly detection),它的整個邏輯就是「密度低於某個門檻就標記出來」;比方說拿兩個模型比較誰把資料解釋得比較好,那也需要一個可以算出來的概似值。第 1 章第四小節提過的評價難題在這裡又加了一層:連最基本的那把尺都沒有。
接下來三章各自用不同的方式把這件事補回來。第 16 章(正規化流:可逆變換與精確概似)與第 17 章(變分自編碼器:用一個下界換來的生成模型)走的路不同,第 18 章(擴散模型:把雜訊一步步還原成資料)又是另一條。它們的機制留到各自的章節,這裡只要先記住一件事:你剛剛學到的「抽得出樣本、算不出密度」不是生成模型的通性,而是這一章這個做法的選擇。
最後補一句,讓你知道這個想法能走多遠。判別器給出的訊號其實不必綁在「生成」上——只要一個任務的好壞難以寫成式子、卻容易被一眼認出來,你就可以掛一個判別器上去,把它的判定當成附加的損失項,跟原本那條寫死的損失加在一起用。對抗訊號是一種造損失的手法,不是一種模型。 這個觀念比本章那個具體架構活得更久;它的各種變形,章末的原書對照節指了路。
§03原書對照
原書第 15 章的篇幅有一大半並不在核心機制上,而在兩件事:怎麼讓這套東西真的訓練得起來,以及這套對抗訊號還能拿去做什麼。這兩塊都比機制本身龐雜,值得進階讀者按頁碼各取所需。
損失函數的完整推導在 pp.278–279。原書從二元分類的交叉熵出發,替真實樣本與生成樣本各自指派一個標籤,說明為什麼其中一組參數要取最小、另一組要取最大,最後把單一目標拆成兩條可以分別做梯度下降的式子,並交代拆的時候丟掉了哪一項、為什麼丟得掉。同一段也把「兩個網路互相追逐」正式命名為極小極大博弈,並提到理論上的解對應到賽局論裡的一種均衡。想看完整代數步驟的人,這兩頁寫得最密。
具體架構在 pp.279–280。原書給了一個早期卷積式生成器的完整規格,從潛在向量的維度一路交代到最後用什麼函數把數值壓進影像的合法範圍,判別器的結構也一併給了。緊接著的同一頁列出了讓這個架構穩定訓練所必須做的幾項具體設定,並指出這種對設定的敏感在深度學習模型裡並不常見。p.281 放了三組不同資料上的生成結果,以及一組刻意做壞的對照。
理論分析在 pp.282–283。原書先把損失改寫成期望值與積分的形式,解出最佳判別器的閉式表達,再把它代回去,得到「這個損失在最佳判別器下等價於兩個分布之間的某種散度」的結論。這個散度可以拆成兩項,各自對應到不同的要求,而原書指出其中只有一項會反映到生成器的目標上。想知道覆蓋不足為什麼被歸因到這裡,那兩頁的推導是原始出處;那兩頁的頁邊還標了附錄裡兩節散度定義的位置。pp.283–284 接著討論兩個分布幾乎不重疊時梯度為什麼會趨近於零,並附上一組把生成器凍住、只繼續訓練判別器的實測曲線。
改良路線在 pp.284–286,是全章數學密度最高的三頁。原書先在離散分布上把另一種距離寫成一個搬運質量的最小化問題,交代它的線性規劃形式與對偶形式,再推廣到連續情形,最後說明要用神經網路實作這個距離,判別器必須被限制得不能太陡,並給了兩種常見的限制手法。
接下來三塊是應用與工程。pp.287–289 收了幾種提升成品品質的手法;pp.289–292 轉向讓使用者指定輸出屬性的幾種變形,其中一種還能自動找出哪些屬性值得被指定;pp.292–296 把對抗判別器當成一個附加的損失項,掛在本來就是監督式的影像轉換任務上,成對資料與非成對資料兩種情形都有;pp.296–298 介紹一個把風格與雜訊分開注入、並且分尺度控制的較新架構。
最後,pp.299–303 的註記與文獻回顧值得單獨一提。那幾頁整理了這個題材的主要分支、多樣性到底夠不夠的實證研究、防止輸出塌縮的各種做法,以及把已經生成的樣本反推回潛在變數的技術路線——最後這一項是編輯既有影像的前提。章末 p.303 有六道習題,其中兩題要求手算幾種分布之間的距離並比較它們的行為。原書第 15 章對應印刷頁 pp.276–303。
§04作業和解答
作業一:算出「完全分不出來」那條刻度
評審這一輪聽了 \(2N\) 段錄音,真品與仿品各半,而且他對每一段都給出同一個判定值 0.5。(a)用第一節那條判別器損失手算出這一輪的損失值,寫出計算過程。(b)說明為什麼這個數字是一個參考刻度而不是訓練目標。(c)如果評審改成對每一段都給 0.4,損失會變大還是變小?先用直覺猜,再算出來。
解答 SOLUTION
(a)真品那一半,每一項是 \(-\log 0.5\);仿品那一半,每一項是 \(-\log(1 - 0.5) = -\log 0.5\)。兩邊完全一樣,所以整批平均就是 \(-\log 0.5 = \log 2 \approx 0.6931\)。第一節的程式最後一行印的正是這個數。
(b)因為它代表的是「判別器完全沒有鑑別力」的狀態。訓練初期判別器的損失通常比它低(它多少分得出一點),隨著生成器變強會往上爬向這個數。但爬到 0.6931 不等於訓練成功——第三節已經證明過,判別器亂猜的另一個原因可能是它自己還沒學到位(那段程式裡 \(k=0\) 的情形),那時生成器一樣拿不到訊號。這個數字只告訴你「判別器現在分不出來」,不告訴你「為什麼分不出來」,所以它是刻度不是目標。
(c)直覺:判定值一律 0.4 對真品是錯得更離譜(真品該接近 1),對仿品則是猜得更準(仿品該接近 0),一好一壞,不明顯。實際算:真品那一半每項是 \(-\log 0.4 = 0.9163\),仿品那一半每項是 \(-\log 0.6 = 0.5108\),平均 \(0.7136\),比 0.6931 大。這不是巧合——固定一個常數判定值時,損失在 0.5 處取到最小,往兩邊偏都會變大。(本題數值以 numpy 重算核對過。)
作業二:繞圈與炸開的分界
拿第二節那段雙人博弈的程式,只改學習率 \(\alpha\),其餘不動。(a)在 \(\alpha \in \{0.5, 1.0, 1.5, 1.9, 2.1, 3.0\}\) 各跑 240 步,記錄終點半徑,找出「繞得住」與「炸開」的分界。(b)交替更新的一步可以寫成一個 2×2 矩陣作用在 \((a, b)\) 上,請把那個矩陣寫出來,並用它解釋你在(a)觀察到的分界。(c)這個分界跟第 6 章的學習率穩定上限是同一回事嗎?
解答 SOLUTION
(a)實跑結果:\(\alpha = 0.5\)、\(1.0\)、\(1.5\)、\(1.9\) 的終點半徑分別是 1.134、1.414、0.6144、0.2423,都還在原點附近打轉;\(\alpha = 2.1\) 是 3.194e+65,\(\alpha = 3.0\) 是 3.29e+200。分界在 \(\alpha = 2\)。
(b)交替更新先算 \(a' = a - \alpha b\),再用新的 \(a'\) 算 \(b' = b + \alpha a' = \alpha a + (1 - \alpha^2) b\)。寫成矩陣:
這個矩陣的行列式是 \(1\cdot(1-\alpha^2) - (-\alpha)(\alpha) = 1\),跡是 \(2 - \alpha^2\)。當跡的絕對值小於 2(也就是 \(\alpha < 2\))時,兩個特徵值是一對共軛複數,而它們的乘積等於行列式 1,所以各自的長度剛好是 1——每一步都是一個旋轉,半徑不增不減,於是永遠繞不完。當 \(\alpha > 2\) 時跡的絕對值超過 2,特徵值變成兩個實數,其中一個的絕對值大於 1,於是每步放大,指數炸開。分界正是 \(\alpha = 2\)。
順帶一提:若你剛好在 \(\alpha = 2\) 跑一次,會看到半徑一動也不動地停在 1.414。那是起點 \((1,1)\) 落在一個特殊方向上的巧合,換成 \((1, 0)\) 起跑,240 步後半徑會長到 678 左右。臨界點的行為跟起點有關,不要拿單一起點的觀察去推廣。
(c)不是同一回事,只是長得像。第 6 章的穩定上限來自一個固定二次損失的曲率:步子跨過某個長度就會越過谷底彈到更高的地方。這裡沒有谷底可以越過——即使 \(\alpha\) 小到不會炸開,參數也不收斂,它只是繞得比較小圈。第 6 章的小學習率能換到收斂,這裡的小學習率只能換到不發散。
作業三:讓騙過率與覆蓋率脫鉤
沿用第四節的曲庫設定(6 個聚集區)。(a)設計一個生成器,讓它的騙過率高於 0.8、而覆蓋曲目數不超過 2,並說出你怎麼構造它。(b)用該節那三個指標檢查你的設計。(c)這個結果在模仿秀的賽制裡對應到什麼?主辦方要怎麼改賽制才抓得到?
解答 SOLUTION
(a)構造方式很直接:把生成的樣本全部放在其中一兩個聚集區的中心附近,抖動幅度取得跟真品差不多(0.28 左右)。因為判別器是逐筆判定的,只要每一筆都落在真品密集的位置,它就過關;至於這些樣本彼此有多像,逐筆判定的規則裡沒有那一格。
(b)第四節的程式已經跑過極端版本:只唱一首時騙過率 0.845、覆蓋 1/6、批次逐維標準差平均 0.283,滿足題目要求(對照組六首都唱是 0.900、6/6、2.147)。要做覆蓋 2 的版本,把 centres[0] 換成 centres[rng.integers(0, 2, 600)] 即可,實跑得到騙過率 0.878、覆蓋 2/6、標準差 1.055——騙過率照樣夾在兩個對照組中間,因為每一筆的位置一樣落在密集區。
(c)對應到「一個只練了一兩首、但練得極像的模仿者,在單段盲聽的賽制下拿得到高分」。主辦方要抓到他,不能繼續只問「這一段像不像」,必須改問一個跨越整場的問題——例如規定同一位選手的曲目不得重複、或者在評分表上加一欄「本場曲目涵蓋度」。翻譯回機制:判別器的輸入必須從單筆改成一整批,才有機會把「這一批彼此太像」當成扣分的理由。這正是各種防止塌縮的手法共同的出發點。
作業四:三段訓練紀錄的診斷
底下三段紀錄各跑了 200 輪,記的是判別器損失、生成器損失,以及每批生成樣本的逐維標準差平均(真實資料的同一個統計量約為 2.1,僅供對照;以下數字為本題自造的假想紀錄,不是實跑輸出)。請判斷各是哪一種失敗,並說出下一步該調什麼。
- 紀錄甲:判別器損失從 0.69 一路降到 0.02 就不動了;生成器損失從 0.7 爬到 3.9 之後幾乎持平;批次標準差維持在 2.0 附近。
- 紀錄乙:判別器損失在 0.68 到 0.70 之間微幅來回,從頭到尾沒有離開過;生成器損失同樣在 0.69 附近抖;批次標準差維持在 2.1。
- 紀錄丙:兩條損失都在大幅震盪、看不出趨勢;批次標準差從 2.0 一路掉到 0.3,之後偶爾跳回 1.2 又掉下去。
解答 SOLUTION
甲是判別器贏太輕鬆。 判別器損失掉到 0.02 表示它幾乎每一筆都判對;生成器損失爬到 3.9 而後持平,是因為仿品的判定值已經貼近 0,\(\log(1-d)\) 進到平坦區,梯度塌了。批次標準差正常,所以不是崩潰。這對應第三節 A 段 \(s\) 很大的那一端。下一步:減少判別器的更新次數或調低它的學習率、把生成器的損失換成梯度較大的那個寫法,或往第五節那個方向換一個對距離敏感的量。
乙是判別器學不到位。 兩條損失都黏在 \(\log 2 \approx 0.693\) 附近不動,代表判別器對什麼都回答 0.5 上下。注意這跟「訓練成功」在數字上長得一模一樣,差別在於:成功的話應該是先分得開、再慢慢被追平;這裡是從頭到尾就沒分開過。這對應第三節 B 段 \(k\) 很小的情形。下一步:把判別器練得更久或加大它的容量——這是少數該讓判別器變強的場合。
丙是模式崩潰,而且正在跳來跳去。 判斷的依據不是損失(它們在這裡什麼都沒說),而是批次標準差從 2.0 掉到 0.3——只剩真實資料的七分之一。偶爾跳回 1.2 再掉下去,正是第四節說的那個循環:判別器追過來,生成器整批換到另一個位置。下一步:導入能讓判別器看到「一整批」而不只是「一筆」的機制,並且把批次多樣性統計量正式納入監看,不要只盯損失曲線。
§05參考資料
- NumPy 官方使用手冊 — 本章四段程式只用到 numpy,索引與廣播的語法查這裡最快
- NumPy random.Generator 說明 — 想改本章程式的隨機設定時,先確認
default_rng的抽樣順序怎麼影響重現性 - Wikipedia:Generative adversarial network — 想快速掃過這條路線後來長出哪些變形時的索引頁,附大量原始論文連結
- Wikipedia:Minimax — 本章刻意沒展開的賽局論背景,這裡有最短的入口
- Wikipedia:Wasserstein metric — 第五節只給了名字與動機,這條目補上它的正式定義與幾種等價寫法
- Wikipedia:Mode collapse — 第四節那個失敗模式的術語頁,可對照英文文獻的用詞
- Distill:Deconvolution and Checkerboard Artifacts — 生成器架構本身如何在成品上留下規律痕跡,是本章沒談的工程面向
- KaTeX 支援函式表 — 本站數學式的渲染範圍,想自己抄公式做筆記時可查
- Understanding Deep Learning(MIT Press) — 本課課綱主題所本的原書出版頁(ISBN 9780262048644,2023-12 出版)
- udlbook 官方網站(作者釋出的 PDF、投影片與習題) — 原書作者維護的免費資源站(udlbook.com 會轉址到此)