ARK · 理解深度學習CHAPTER 16 / 21

CHAPTER 16 / 21 · PART 4 · 生成模型:從資料裡長出資料

正規化流:可逆變換與精確概似

Normalizing flows

用一個可逆網路把簡單分布揉成資料分布,密度靠雅可比行列式精確換算回來。

§01學習重點

§02課程內容

一、把「這一筆有多罕見」買回來

上一章收尾時把話講得很直:那套用博弈生成資料的做法抽得出樣本,卻算不出任何一筆資料的機率密度,而且這不是生成模型的通性,是那個架構自己的選擇。它還點名了一個因此被擋掉的用途——異常偵測,整套邏輯就是「密度低於門檻就標記」,手上沒有密度值,門檻無從談起。

這一章把缺口補回來。正規化流(normalizing flow) 是一族生成模型,同時具備兩種能力:造得出新樣本,也對任何一筆給定的資料報得出精確的機率密度。回想第 1 章第四小節埋下的分岔——要不要把資料分布明明白白寫成一條式子;寫出來,就能回答「這一筆有多罕見」。本章是「寫出來」這一邊最徹底的代表:報出來的密度是精確值,不是近似,也不是下界(第 17 章的變分自編碼器走的是「用一個下界換來的生成模型」那條路,章題已經把取捨寫在臉上;兩邊的代價,讀完下一章你可以自己比)。

總思路一句話講完:從一個簡單到可以寫出密度式子的基底分布(base distribution)出發,用一個網路把它變形成資料的分布。 基底照慣例取標準常態,第 1 章已經定義過潛在變數 \(\mathbf{z}\) 與取樣(照分布隨機抽值),這裡直接沿用。兩個方向各做一件事:

第二條流程逼出本章的第一個關鍵字:網路必須可逆(invertible)。評密度得反著走,走不回去就查不到帳。可逆在這裡不是偏好,是必需。

先把記號釘好,順便看清這一族的架構長相。第 1 章把潛在變數模型寫成 \(x = g[z, \boldsymbol\phi]\);那個 \(g\) 在本章就是 \(f\) 自己。改用 \(f\) 有兩個理由:\(g\) 已經釘給第 15 章的生成器,而全站的 \(f[\cdot, \boldsymbol\phi]\) 從第 1 章起就指「模型本身」——這一章的網路恰好就是模型的全部,沒有第二個部件。這句話值得放大看:第 15 章要兩個網路對抗,生成器與判別器各持一組參數 \(\boldsymbol\phi_g\)、\(\boldsymbol\phi_d\);第 17 章的變分自編碼器也是兩個網路,一個編碼、一個解碼。流模型只有一個網路。\(f^{-1}\) 不是第二個網路,是同一個函數反著走,參數同一組 \(\boldsymbol\phi\),正反兩用。這是流模型跟前後兩章最大的架構分野。

名字也交代掉。「正規化」說的是反方向那條路:把雜亂的資料分布一層一層「流」回標準常態,等於把它正規化成基準分布。它跟第 11 章的批次正規化無關——那是把某一層活化的統計量拉回標準尺度的網路零件,這裡是把整個機率分布變形回基準分布的全局動作,同字不同物。「流」也不是資料流或工作流,它說的是分布在層與層之間連續變形的動態意象:一團東西被一道一道工序慢慢揉過去。

能力不會白拿。代價是每一層都得可逆,而且有一筆帳要算得動——那筆帳叫雅可比行列式,是接下來兩節的主角。整章後半講的全是同一件事:架構被這兩條鎖鏈綁住之後,怎麼在鐐銬裡跳出足夠的表達力。

二、密度不跟著點走:一維的換算律

全章的數學心臟是一條換算律,先在一維把它徹底打通。第 5 章講過:連續量的機率密度是「每單位長度分到多少機率」,密度本身可以大於一,沿著區間積分才是機率。這句話是本節的地基。

設一個一維、單調遞增的函數 \(x = f[z]\)。單調的意思是 \(z\) 越大 \(x\) 越大,於是每個 \(x\) 恰好對應一個 \(z\)——這是可逆的最低配備。現在讓 \(z\) 帶著一個分布 \(Pr(z)\) 過這個函數,問:出來的 \(x\) 服從什麼分布?

第一步是守恆律。「\(z\) 落在區間 \([a, b]\)」與「\(x\) 落在區間 \([f[a], f[b]]\)」是同一個事件的兩種說法——單調可逆保證兩邊互相蘊含——所以兩邊的機率相等。落在輸入小區間裡的那坨機率質量,映射後一克不少地全數落在對應的輸出小區間裡。質量守恆,密度重分配。

第二步看重分配怎麼分。函數在某處把區間拉寬,同樣的質量攤在更寬的範圍上,密度就變低;在某處壓窄,同樣的質量擠進更窄的範圍,密度就變高。拉寬多少有精確的量:\(z\) 動一小格 \(\mathrm{d}z\),\(x\) 就動 \(f'[z]\,\mathrm{d}z\)——導數就是該點的局部伸縮倍率

兩步合起來就是一維的變數變換(change of variables) 公式:

$$ Pr(x) \;=\; \frac{Pr(z)}{\left|\,f'[z]\,\right|}, \qquad z = f^{-1}[x] $$

逐項拆解:左邊 \(Pr(x)\) 是變形後的分布在資料點 \(x\) 的密度;右邊分子 \(Pr(z)\) 是基底分布在對應點 \(z\) 的密度;分母 \(\left|f'[z]\right|\) 是該點的伸縮倍率。而那個 \(z\) 不是自由變數——它是由 \(x\) 用逆函數反推出來的。這是逆映射在推導裡第一次進場:想查任何一筆 \(x\) 的帳,得先走回去找到它的來處。

為什麼是除以倍率、不是乘?這是全章最容易搞反的一步,值得放慢。小區間裡的質量約是 \(Pr(z)\,\mathrm{d}z\),映射後攤在寬度 \(f'[z]\,\mathrm{d}z\) 的區段上,每單位長度分到的就是 \(Pr(z)\,\mathrm{d}z \div f'[z]\,\mathrm{d}z = Pr(z)/f'[z]\)。拉伸倍率越大,攤得越開,密度就被除得越小。 分母上的絕對值則是替遞減函數留的:遞減函數的導數是負的,但「攤開的寬度」沒有方向可言,寬就是寬。

比喻: 拿一條染了色的橡皮泥,色料沿著長度均勻抹開。把中段拉長成兩倍:那一段的色料總量一克都沒變,卻攤在兩倍長的範圍上,單位長度分到的色料剩一半,顏色淡了一半;把某段壓短,顏色就變濃。機率質量就是色料——函數把 \(z\) 軸上的一段揉成 \(x\) 軸上的一段,質量原封不動跟過去,密度卻按伸縮倍率重新換算:拉幾倍,除幾倍。這個比喻有一處要當心:橡皮泥本身的物理質地並沒有變稀,變的是「每公分分到的色料」這個帳面量;對應回數學,被換算的是機率密度這個抽象量,不是泥的性質。

還有一條完整性檢查:換算後的密度沿 \(x\) 積分仍然是一。質量只是被搬了位置,總量不增不減——把積分變數從 \(x\) 換回 \(z\) 就能驗證,這正是「變數變換」這個名字的出處。

公式不是拿來背的,是拿來被樣本檢驗的。下面自造一個單調函數 \(f[z] = z + 0.4z^3\)(導數 \(1 + 1.2z^2\) 恆為正,處處嚴格遞增),把一百萬個標準常態樣本推過去,在幾個點上比三個數:公式算的密度、樣本直方圖估的密度,以及一個錯誤直覺——「\(z_0\) 處的密度值被原封搬到 \(x_0 = f[z_0]\) 處」:

PYTHON
import numpy as np

rng = np.random.default_rng(0)


def f(z):                       # 自造單調函數:導數 1 + 1.2 z^2 恆為正
    return z + 0.4 * z ** 3


def fp(z):                      # f 的導數=各點的局部伸縮倍率
    return 1.0 + 1.2 * z ** 2


def base_pdf(z):                # 基底分布:標準常態的密度
    return np.exp(-z ** 2 / 2) / np.sqrt(2 * np.pi)


# 把一百萬個基底樣本推過 f,得到資料空間的樣本
z = rng.normal(0, 1, 1_000_000)
x = f(z)

# 在三個點上比較:變數變換公式 vs 樣本直方圖 vs 「密度跟著點搬家」的錯誤直覺
for z0 in [0.0, 0.6, 1.5]:
    x0 = f(z0)
    formula = base_pdf(z0) / fp(z0)     # 公式:基底密度除以伸縮倍率
    naive = base_pdf(z0)                # 錯誤直覺:把 z0 的密度原封搬到 x0
    eps = 0.05
    hist = np.mean(np.abs(x - x0) < eps) / (2 * eps)   # 樣本說了算
    print(f"z0={z0:.1f}  x0={x0:.3f}  伸縮倍率={fp(z0):.3f}  "
          f"公式={formula:.4f}  直方圖={hist:.4f}  搬家直覺={naive:.4f}")

實跑輸出三行:z0=0.0 x0=0.000 伸縮倍率=1.000 公式=0.3989 直方圖=0.4004 搬家直覺=0.3989z0=0.6 x0=0.686 伸縮倍率=1.432 公式=0.2327 直方圖=0.2331 搬家直覺=0.3332z0=1.5 x0=2.850 伸縮倍率=3.700 公式=0.0350 直方圖=0.0355 搬家直覺=0.1295

把第三行讀完整。在 \(z_0 = 1.5\) 這個點,伸縮倍率已經放大到 3.700,公式說變形後的密度只剩 0.0350,一百萬個樣本的直方圖說 0.0355——公式贏。而「密度跟著點搬家」的直覺給出 0.1295,錯了將近四倍。密度值不跟著點走;跟著點走的是質量,密度要按體積的伸縮重新換算。 學生常在這裡問:為什麼機率不變、密度卻變了?答案就是守恆律那一條——機率是質量,密度是質量除以佔的寬度,寬度被函數改了。

三、升維:雅可比行列式是體積的匯率

真實資料是高維向量,換算律要升維。一維裡「導數」扮演伸縮倍率,多維的對應物是什麼?

答案分兩層。第一層:多維映射 \(\mathbf{x} = f[\mathbf{z}, \boldsymbol\phi]\) 有 \(D\) 個輸出分量、\(D\) 個輸入分量,把「第 \(i\) 個輸出對第 \(j\) 個輸入」的偏導數全部排成一張 \(D \times D\) 的表,就是雅可比矩陣(Jacobian matrix)

$$ \mathbf{J} \;=\; \begin{bmatrix} \dfrac{\partial x_1}{\partial z_1} & \cdots & \dfrac{\partial x_1}{\partial z_D}\\[2pt] \vdots & \ddots & \vdots\\[2pt] \dfrac{\partial x_D}{\partial z_1} & \cdots & \dfrac{\partial x_D}{\partial z_D} \end{bmatrix} $$

逐項拆解:第 \(i\) 列第 \(j\) 欄的格子 \(\partial x_i / \partial z_j\) 回答「第 \(j\) 個輸入動一小格、其他輸入釘住不動時,第 \(i\) 個輸出跟著動幾格」——偏導數的讀法第 7 章用過,這裡只是把它們排滿一張表。這張表描述的是 \(f\) 在某一個點附近的局部行為,所以本章寫到 \(\mathbf{J}\) 時一律標明在哪一點算的,記作 \(\mathbf{J}[\mathbf{z}]\)。

第二層是行列式(determinant)。你在線性代數課多半把它當一套算出來的數:對角相乘、交叉相減。本章需要的是它的幾何身分,值得停下來建好:一個線性映射把單位方塊送到一個平行四邊形(高維叫平行多面體),行列式的絕對值就是那個圖形的體積——線性映射的體積伸縮倍率。 舉個假設的例子:線性映射把 \((1,0)\) 送到 \((2,0)\)、把 \((0,1)\) 送到 \((0.6,1.5)\),寫成矩陣就是第一列 \((2, 0.6)\)、第二列 \((0, 1.5)\)。單位正方形(面積 1)被送成一個平行四邊形,面積 \(2 \times 1.5 - 0.6 \times 0 = 3\):任何一塊區域經過這個映射,面積都變成三倍。行列式算出來的 3,就是這個倍率。一維時 \(\mathbf{J}\) 是 \(1 \times 1\) 的表,行列式就是導數本身——所以下面的多維公式跟上一節是同一條律,不是新東西。

還有一個常見誤解要當場拆掉:行列式不是整個函數共用一個數,是每個點各有一個。 非線性映射沒有全域統一的伸縮倍率;它在每個點附近可以近似成一個線性映射——這個動作叫局部線性化(local linearization),就是用該點的切線(高維是切平面)代替函數本身——而那個線性近似的矩陣正是該點的 \(\mathbf{J}[\mathbf{z}]\)。上一節的 \(f[z] = z + 0.4z^3\) 就是活例子:在 \(z = 0.6\) 倍率是 1.432,在 \(z = 1.5\) 已經是 3.700,同一個函數,逐點不同。

兩層合起來,多維變數變換公式就是一維式的直接推廣——導數的絕對值換成行列式的絕對值:

$$ Pr(\mathbf{x}) \;=\; \frac{Pr(\mathbf{z})}{\bigl|\det \mathbf{J}[\mathbf{z}]\bigr|}, \qquad \mathbf{z} = f^{-1}[\mathbf{x}, \boldsymbol\phi] $$

逐項拆解:\(\det\) 讀作行列式;\(\bigl|\det \mathbf{J}[\mathbf{z}]\bigr|\) 是 \(f\) 在點 \(\mathbf{z}\) 附近的體積伸縮倍率;分子是基底分布在 \(\mathbf{z}\) 的密度;跟一維一樣,\(\mathbf{z}\) 由 \(\mathbf{x}\) 反推而得。攤開越大除越多、壓縮越緊密度越高,邏輯原封不動。

這條公式順手帶出一個必然的約束:潛在空間的維度必須等於資料空間的維度 \(D\)。 一對一的可逆映射不能把高維壓進低維——兩個不同的 \(\mathbf{z}\) 一旦被壓到同一個 \(\mathbf{x}\),逆映射就無定義;反過來升維也一樣,會留下一堆沒有來處的 \(\mathbf{x}\)。這裡跟第 1 章正面相撞,落差要明說:第 1 章把潛在變數描述成「一個維度很低的向量」,用少數幾個真旋鈕解釋高維資料;本章的架構卻被可逆性綁死在同維。這不是筆誤,是流模型真實付出的代價——它放棄了低維潛在空間,「自由度比維度低」的直覺得靠別的手段找回來,第五節末的多尺度流就是一種找補。

四、訓練目標與效率帳單

有了換算律,訓練目標幾乎是現成的。第 5 章鋪好的三步直接搬來:訓練資料假設獨立同分布,整批概似是逐筆連乘;取對數把連乘變連加;加負號把最大化翻成最小化。把上一節的公式代進去,每一筆資料的損失拆成兩項:

$$ \ell_i \;=\; -\log Pr(\mathbf{z}_i) \;+\; \log\bigl|\det \mathbf{J}[\mathbf{z}_i]\bigr|, \qquad \mathbf{z}_i = f^{-1}[\mathbf{x}_i, \boldsymbol\phi], \qquad \mathcal{L}[\boldsymbol\phi] = \frac{1}{I}\sum_{i=1}^{I}\ell_i $$

逐項拆解:\(\ell_i\) 是第 \(i\) 筆的損失,\(\mathcal{L}\) 是整批平均(全站口徑,第 6 章起沿用);\(\mathbf{z}_i\) 是第 \(i\) 筆資料反推回基底空間的落點;第一項是「落點處基底密度」取負對數,分母翻上來所以第二項變成的對數行列式。基底取標準常態時第一項就是 \(\tfrac{D}{2}\log(2\pi) + \tfrac{1}{2}\|\mathbf{z}_i\|^2\),前半是常數、後半把落點往原點拉。

兩項各自在把模型往哪裡推,用白話講一遍。第一項要求:每筆資料反推回去的落點,都得落在基底分布的高機率區——別把資料推到基底的荒郊野外去。第二項要求行列式小,也就是模型在資料所在的地方要「收攏」:伸縮倍率小於一,換算時基底密度被放大,蓋在資料頭上的密度才高。而可逆性在這裡扮演警察——一對一映射的體積帳是守恆的,這裡收攏,別處就必須攤開,不存在「處處都收攏」的作弊。 兩項合起來,訓練做的事就是把基底那團質量搬運到資料密集的地方:資料多的地方堆厚、資料少的地方抽薄,總量不變。

比喻: 像把一張厚薄均勻的橡皮泥皮,捏塑成一件有起伏的作品:要在幾個隆起處堆厚,就只能從別處抽薄,因為整團泥的量是固定的,捏的過程一克也不會多出來。第一項損失管「資料對應的那些位置要堆對地方」,第二項管「堆的手法要真的把厚度做出來」。但這個比喻有一處根本的失準:現實裡的揉塑多半可逆——真的橡皮泥揉過頭會混色、黏合,退不回去。流模型的可逆是被刻意設計出來的性質,每一道「工序」都被限制成做得回去、且留有記錄可查。下一節每個零件付的正是這筆設計代價。

實際的模型不會是單一一層變換,而是多個可逆層的複合

$$ \mathbf{x} \;=\; f_K\Bigl[\,f_{K-1}\bigl[\cdots f_1[\mathbf{z}, \boldsymbol\phi_1]\cdots\bigr],\; \boldsymbol\phi_K\Bigr] $$

逐項拆解:\(K\) 是層數,第 \(k\) 層寫作 \(f_k[\cdot, \boldsymbol\phi_k]\),層與層之間的向量沿用第 4 章的 \(\mathbf{h}_k\);全體參數 \(\boldsymbol\phi\) 就是各層參數的集合。三件事讓深度版立刻好用起來。其一,每層各自可逆,整條就可逆;整條的逆是各層的逆反著順序套回去——多道揉塑工序連著做,要退就得倒著一道一道退,最後做的那道最先退。其二,整條流的雅可比等於各層雅可比依序連乘——第 7 章的鏈鎖法則已經把「由後往前、逐站折算」講透,把純量因子換成矩陣,就是這裡。其三,行列式有一條好用的代數性質,用到當場給:乘積的行列式等於行列式的乘積。三者接起來、再取對數:

$$ \log\bigl|\det \mathbf{J}\bigr| \;=\; \sum_{k=1}^{K} \log\bigl|\det \mathbf{J}_k\bigr| $$

工程上真正在算的量就是右邊這串逐層加總。逆方向的行列式是正方向的倒數,取對數後只差一個正負號,兩個方向共用同一筆帳。於是「整條流的行列式好不好算」被切成「每一層的行列式好不好算」——設計問題徹底逐層化了。

現在把效率帳單攤開。每一筆訓練資料、每一步梯度更新,都要算一次逆映射與一次對數行列式。 而一般 \(D \times D\) 矩陣的行列式,維度一大,計算量暴漲到不可能每步都付。這張帳單就是後半所有設計約束的來源。一個可逆層要同時過四道關卡:

  1. 一對一——兩個輸入映到同一個輸出,逆就無定義,這是最低門檻;
  2. 逆要算得快——「存在逆」與「逆好算」是兩回事:有些函數確實有逆,但每求一次逆都得解一場方程、迭代半天,訓練照樣用不起;
  3. 行列式要算得快——理由就是上面那張帳單;
  4. 疊起來要有表達力——單層可以簡單,但整組必須能把標準常態揉成任意的目標分布。

這四道關卡互相擠壓出的狹小設計空間,就是下一節每個零件各自站的位置。

五、零件解剖:從一個做對的設計讀懂整族取捨

這一族的零件很多,逐個排隊介紹容易看成型錄。換個講法:先把做對了的主角放上檯面拆給你看,再看把它的部件單獨拿出來會殘廢成什麼樣、往極端推會變成什麼、取捨反著做又是哪一路——整族的地圖就自己浮出來了。

主角是耦合流(coupling flow)。把輸入向量切成兩半,\(\mathbf{h}_a\) 與 \(\mathbf{h}_b\):

$$ \mathbf{h}'_a = \mathbf{h}_a, \qquad \mathbf{h}'_b = \mathbf{h}_b \odot \exp[\mathbf{s}] + \mathbf{m}, \qquad (\mathbf{s}, \mathbf{m}) = c[\mathbf{h}_a] $$

逐項拆解:前一半 \(\mathbf{h}_a\) 原樣通過,一根汗毛都不動;後一半 \(\mathbf{h}_b\) 逐維做一個仿射變換——\(\odot\) 是逐元素相乘,\(\exp[\mathbf{s}]\) 逐維取指數保證尺度為正(尺度恆正、變換就嚴格單調),\(\mathbf{m}\) 是逐維平移。巧思全在最後一段:尺度與平移這組參數,是由前一半算出來的——\(c[\cdot]\) 是一個小網路,吃 \(\mathbf{h}_a\)、吐 \((\mathbf{s}, \mathbf{m})\)。這一下,兩半維度真的交談了。

逆向為什麼行得通?看輸出:\(\mathbf{h}_a\) 原樣就躺在輸出裡。拿它把 \(c\) 重新跑一遍,\((\mathbf{s}, \mathbf{m})\) 就原樣重現,接著逐維反解:

$$ \mathbf{h}_b = (\mathbf{h}'_b - \mathbf{m}) \odot \exp[-\mathbf{s}] $$

這裡藏著全節最容易蒙混過去的一點,單獨拎出來講:產參數的小網路 \(c\) 完全不需要可逆。 它從頭到尾沒有被反著跑過——逆向時它照樣正著跑一次,只是輸入取自輸出裡那半原樣的 \(\mathbf{h}_a\)。可逆的重擔全部落在逐維的仿射變換上,而那個變換因為尺度恆正,逆有一行閉式。這正是耦合流對第四節第 2 關的漂亮回答:把「需要表達力的部分」與「需要可逆的部分」拆開,讓不可逆的小網路去扛表達力。

行列式呢?分塊看雅可比的形狀:\(\mathbf{h}'_a\) 對 \(\mathbf{h}_a\) 是單位矩陣、對 \(\mathbf{h}_b\) 是零;\(\mathbf{h}'_b\) 對 \(\mathbf{h}_b\) 是對角矩陣(第 \(d\) 維輸出只碰第 \(d\) 維輸入);\(\mathbf{h}'_b\) 對 \(\mathbf{h}_a\) 那一塊隨 \(c\) 要多亂有多亂——但它落在下三角的位置,不影響行列式。三角矩陣的行列式等於對角線連乘(又一條用到才給的性質),所以整層的對數行列式就是 \(\sum_d s_d\),把 \(c\) 吐出來的尺度加一加就完事。第 3 關也過了。

代價當然有:一層只動一半。補法是層與層之間洗牌——用排列矩陣(permutation matrix) \(\boldsymbol\Pi\)(有些教材寫成 \(\mathbf{P}\))重排維度,讓每個維度輪流當「被變換的那一半」。重排座標不改變任何體積,\(\bigl|\det \boldsymbol\Pi\bigr| = 1\),密度帳零負擔——這也是一個很好的一致性自查點:洗牌不該動密度,公式果然說它不動。影像情境還有按通道分半、用小卷積混通道的變體,屬工程細節,本章不展開,頁碼見章末原書對照節。

下面把整套講法變成幾行可以跑的代碼:手寫一個二維仿射耦合層,正向再逆向量重建誤差,同場比對「解析行列式」與「逐格有限差分算出的數值行列式」:

PYTHON
import numpy as np

rng = np.random.default_rng(0)

# 產參數的小網路 c:吃直通的那一半 h_a,吐出尺度 s 與平移 m。
# 注意它本身完全不需要可逆——逆向時它照樣正著跑一次。
W1 = rng.normal(0, 1.0, (4, 1))
b1 = rng.normal(0, 0.5, 4)
W2 = rng.normal(0, 1.0, (2, 4))
b2 = rng.normal(0, 0.5, 2)


def c(ha):
    hidden = np.maximum(0.0, W1 @ ha + b1)   # 一層 ReLU 的小網路
    out = W2 @ hidden + b2
    return out[0], out[1]                    # s(對數尺度)、m(平移)


def forward(h):                              # 正向:h_a 原樣,h_b 仿射變換
    s, m = c(h[0:1])
    return np.array([h[0], h[1] * np.exp(s) + m])


def inverse(x):                              # 逆向:拿直通半把參數重算一遍
    s, m = c(x[0:1])
    return np.array([x[0], (x[1] - m) * np.exp(-s)])


# 一批自造輸入:正向再逆向,量最大重建誤差
H = rng.normal(0, 1, (200, 2))
err = max(np.max(np.abs(inverse(forward(h)) - h)) for h in H)
print("最大重建誤差:", err)

# 行列式:解析(三角矩陣=對角線連乘)vs 有限差分逐格算出整個雅可比
h0 = np.array([0.7, -1.2])
s0, _ = c(h0[0:1])
analytic = np.exp(s0)                        # 對角線= 1 × e^s
eps = 1e-6
J = np.zeros((2, 2))
for j in range(2):
    d = np.zeros(2)
    d[j] = eps
    J[:, j] = (forward(h0 + d) - forward(h0 - d)) / (2 * eps)
print("解析行列式:", round(float(analytic), 8))
print("數值行列式:", round(float(np.linalg.det(J)), 8))

實跑輸出:最大重建誤差是 8.881784197001252e-16——兩百筆資料來回一趟,誤差壓在雙精度浮點的解析度上,可逆是真的;解析行列式與數值行列式同為 0.35096143,八位小數一字不差——對角線連乘那筆便宜帳,跟老老實實逐格差分算出來的整個雅可比,給的是同一個答案。一段代碼同時證明了「可逆」與「行列式好算」。

比喻: 耦合層像一種兩隻手的揉塑手法:一手把半團橡皮泥按住不動,另一手看著按住那半團的形狀決定怎麼揉另一半。回頭要拆解時完全不慌——按住的那半團原封未動地留在成品裡,看它一眼,剛才的手法就能一模一樣地推算出來,另一半照著反向捏回去就是。這個比喻失準在尺度:橡皮泥是三維實物,「按住半團」按的是空間裡的一塊;流模型在幾百維的空間工作,按住的是一半的座標分量,不是空間上的半邊——資料的每個維度都攤在兩隻手底下,只是輪流被按住。

主角拆完,把它的部件單獨拿出來看,各自會殘廢成什麼樣。

拿掉 \(c\),參數不再看另一半,剩下的是逐元素流(elementwise flow):每個維度各自過一個單調的純量非線性函數,處處嚴格遞增就能反查,雅可比是對角矩陣(第 \(d\) 個輸出只看第 \(d\) 個輸入),行列式是對角線連乘——好算到極致,四道關卡的前三道全數滿分。殘廢處在第四道:維度之間老死不相往來,兩個維度進來獨立、出去還是獨立,任何相關性都學不出來。

反過來只留線性混合,是線性流(linear flow) \(\boldsymbol\Omega\mathbf{h} + \mathbf{b}\)(權重 \(\boldsymbol\Omega\)、偏置 \(\mathbf{b}\),沿用全站記法):可逆與否就是矩陣可逆與否,行列式線性代數直接給。但一般矩陣的求逆與行列式在高維都貴——正是第四節帳單付不起的那種貴;換上對角、三角這類特殊結構就好算,代價是變換不自由,光譜上的細節本章不展開。它還有一條更致命的天花板:線性映射保常態性——常態進、常態出,疊一百層線性流仍然是一個常態分布,多峰、彎曲一概變不出來。教學定位講明白:它是「好算」的示範與別人的積木,不是可用的完整答案。

把兩個殘廢擺在一起,互補關係一目瞭然:線性流會混維度、但變不出非線性;逐元素流有非線性、但不混維度——兩邊的短板恰好是對方的長板。耦合流就是把它們縫起來的方案:用「一半算參數、另一半被變換」讓維度真的交談,用逐維單調變換保住可逆與對角帳,層間再拿 \(\boldsymbol\Pi\) 洗牌,讓每個維度都輪到。

接著往極端推。分半分到極致——每個維度自成一塊——就是自迴歸流(autoregressive flow):第 \(d\) 維輸出的變換參數,由前面所有維的輸入算出。正向可以平行:靠遮罩讓第 \(d\) 個位置的參數網路只看得到前面(跟第 12 章遮罩自注意力同一個思路,softmax 前把不准看的位置壓掉那招的親戚,一句帶過)。逆向卻必須逐維串行——反解第 \(d\) 維之前,前面每一維都得先反解完,因為參數網路要的是它們的輸入值,正向時現成、逆向時得一維一維挖回來。於是兩個方向的效率天生不對稱,而且哪個方向快是設計時就要選邊的事:把快的方向留給評概似,訓練快、抽樣慢;反著配置,抽樣快、訓練慢——兩種選法都存在,文獻裡各有名字。理論上這族夠靈活時什麼分布都逼得出來,是四道關卡裡第四道的滿分選手。順帶排一個雷:這族文獻替「逐維的可逆變換函數」取的名字,跟第 12 章那個注意力架構撞個正著——同名不同物,讀論文時別被字面牽走,本課一律叫它變換函數。

取捨反著做,是殘差流(residual flow) 這一路,概覽即可。出發點是把第 11 章那族「把增量加回輸入」的殘差寫法改造成可逆:一種做法把向量分半、兩段交錯相加,逆向用減法照原路退回。這一路的痛點跟耦合流剛好互補——逆好求,行列式反而難算。附帶一條值得記的紅利:層可逆,正向的中間活化就不必存,反向傳播時當場重算回來即可,訓練記憶體大省——而且這個好處不依賴行列式好不好算,單靠可逆就成立。另一支走收縮映射,把每層限制成不斷縮短距離的映射,可用反覆迭代求逆、用隨機估計近似行列式,數學份量重,本課只指路,頁碼見章末原書對照節。

最後一段補上第三節欠的債。同維約束逼著潛在向量跟資料一樣大,但自然資料的自由度遠低於名義維度(第 1 章的自由度落差),讓全部維度陪跑全程很浪費。多尺度流(multiscale flow) 的解法是分段下車:把潛在向量切成幾段,淺層只帶一段,越深逐段併入;反方向走就是逐段「提前下車」、先對基底分布結帳。動機與輪廓到此為止,工程細節見原書對照節的頁碼。

六、拿到密度之後:應用與一個真的能訓練的最小流

繞了一整章,回到起點的承諾:拿到精確密度,到底能做什麼?

第一件事就是第 14 章定義過的密度估計——交出一個數,說這一筆在這批資料裡有多典型——以及它直接支撐的異常偵測:密度低於門檻就標記,上一章明說被擋掉的那個用途,現在做得動了。第二件事是模型比較:兩個生成模型誰把資料解釋得比較好,比一比測試集上的平均負對數概似就有答案,這需要的正是可以算出來的概似值。第三件事順帶一提:需要精確機率的場合不只這兩個,資料壓縮的碼長設計也要它,本課不展開。影像合成這族模型也做得動,品質曾有過站上第一線的紀錄;具體模型與規格本課一律不點名,原書對照節有路標。

有一個提醒必須就地講清楚,因為它反直覺:密度高的點,未必是「典型」的樣本。 以高維標準常態為例,單點密度最高的位置是原點,但你抽一百萬個樣本也幾乎抽不到原點附近的點——因為原點旁邊那一小塊的體積太小,密度再高,乘上體積得到的質量還是微不足道;絕大多數樣本落在離原點有一段距離的殼層上。所以拿密度做異常偵測時,邏輯是「密度低於門檻就標記」,而不是「密度最高處最正常」;前者抓的是分布幾乎沒有質量的地帶,站得住腳。

最後把全章的承諾兌現成一段能跑的訓練:一個一維、參數只有七個的最小流,損失就是第四節那條精確的負對數概似——不是代理目標,不是下界,就是概似本身。場景種回工作室:假設一間手作教室的橡皮泥有軟、硬兩種配方,進貨時混在同一批,對每塊測一個軟硬度讀數,攤開來是雙峰分布。我們用正規化方向建模:學一個單調映射 \(g\) 把讀數揉回標準常態——這裡的 \(g\) 是自造的不等寬分段仿射函數,六段斜率各取指數保證恆正,嚴格遞增所以可逆。從正規化方向寫密度,公式跟第二節同一條,只是方向反過來、改成乘上 \(g\) 的導數(逆方向的行列式是倒數,對數下差一個正負號,第四節講過的那筆帳):

PYTHON
import numpy as np

rng = np.random.default_rng(0)

# 假設的量測:一間工作室兩種橡皮泥配方的軟硬度讀數,混在同一批(雙峰)
data = np.concatenate([rng.normal(-1.7, 0.45, 260),
                       rng.normal(1.9, 0.6, 240)])

# 正規化方向的單調流 g:不等寬分段仿射,六段斜率取 exp 保證恆為正
knots = np.array([-3.0, -1.2, 0.1, 1.1, 2.9])


def g(x, th):
    s = np.exp(th[:6])                       # 六段斜率
    out = th[6] + s[0] * np.minimum(x - knots[0], 0.0)
    for j in range(4):
        out = out + s[j + 1] * np.clip(x - knots[j], 0.0,
                                       knots[j + 1] - knots[j])
    return out + s[5] * np.maximum(x - knots[4], 0.0)


def log_slope(x, th):                        # log g'(x):x 落在哪段就取哪段
    return th[:6][np.searchsorted(knots, x)]


def nll(th):                                 # 平均負對數概似(第 5 章口徑)
    z = g(data, th)
    return float(np.mean(0.5 * np.log(2 * np.pi) + z ** 2 / 2
                         - log_slope(data, th)))


th = np.zeros(7)
th[6] = -3.0                                 # 讓初始 g 恰為恆等映射
print("訓練前平均 NLL:", round(nll(th), 4))

for step in range(600):                      # 數值梯度的最大概似訓練
    grad = np.zeros(7)
    for j in range(7):
        d = np.zeros(7)
        d[j] = 1e-4
        grad[j] = (nll(th + d) - nll(th - d)) / 2e-4
    th -= 0.1 * grad
print("訓練後平均 NLL:", round(nll(th), 4))


def density(x):                              # 精確密度:基底密度 × 伸縮
    z = g(x, th)
    return np.exp(-z ** 2 / 2) / np.sqrt(2 * np.pi) * np.exp(log_slope(x, th))


for pt in [-1.7, 0.1, 1.9, 4.5]:
    print(f"x={pt:+.1f} 的精確密度:{float(density(np.array([pt]))[0]):.4f}")

實跑輸出:訓練前平均 NLL 是 2.6431,六百步之後降到 1.5672——梯度下降直接踩在精確概似上,一路踩得動。四個測試點的密度:兩個峰所在的 x=-1.7x=+1.9 分別是 0.38610.2461;兩峰之間的谷 x=+0.1 只有 0.0565;而 x=+4.5 這筆讀數的密度是 0.0001——照「密度低於門檻就標記」的邏輯,這塊泥直接送檢,它多半不是這兩種配方裡的東西。這就是異常偵測在流模型上的完整迴路:訓練、反推、查密度、標記,每一步都有精確的數字可依。

兩句誠實話收好這個示範。其一,六段常數斜率意味著學出來的密度在段界處是階梯狀的跳變,模型很粗糙——但「精確概似」的意思是算出來的密度就是模型自己的密度,一絲不差,而不是「模型等於真相」;粗糙的模型也可以有精確的概似,兩件事別混在一起。其二,這個 \(g\) 是資料到基底的方向,要生成新樣本就反著走——分段仿射的逆每段是一條一次方程,有閉式,兩個方向都便宜。這是一維的奢侈;維度一高,你就得回到第五節的零件堆裡挑一個,付它該付的那份代價。

全章壓成三句。可逆把「算得出精確概似」買了回來,這是流模型與上一章的分野;密度的換算跟著體積走,帳本是雅可比行列式,一維時就是導數;「逆要好求」與「行列式要好算」兩道約束互相擠壓,刻出了整族零件的形狀——耦合流是縫合兩個殘廢部件的主角,自迴歸流是推到極致的滿分表達力,殘差流把取捨反著做。下一章換一條路:變分自編碼器,用一個下界換來的生成模型——讀完你可以自己並排比較,精確與下界,兩邊各付了什麼。

§03原書對照

原書第 16 章從前一族模型留下的缺口說起:對抗式訓練抽得出樣本,卻不對資料定義一個可以查詢的機率分布(p.304)。整章大致分成理論與零件兩大塊,各佔一半篇幅。

理論塊先用一維情形把「密度怎麼跟著函數變形」講完。pp.304–306 配了三張圖:一張畫基底分布經函數映成新分布的全流程,一張用等寬區間拆解質量守恆與密度重分配的幾何,一張畫反向映射;一維版的變數變換公式與對應的最大概似訓練式在 pp.306–307 有完整推導。pp.307–309 把同一套論證推到多維:雅可比行列式在此登場,層層複合之下行列式的連乘分解、取對數之後的訓練目標,都寫成了完整的式子。基底分布取標準常態的慣例、「把資料密度逐層流回常態」這個名稱由來,在 p.308 隨著一張深度網路層複合的圖一併交代。想逐式核對推導的人,這六頁是全章最值得精讀的部分;p.309 另收了一份對可逆層設計條件的整理。

零件塊(pp.309–317)逐族盤點可逆層。線性流部分(p.310)比較了各種矩陣結構在求逆與行列式計算上的成本,給出一種用矩陣分解直接參數化的工程解法,並在同一頁證明線性映射保常態性——等於堵死只靠線性流的路。逐元素流部分(pp.310–311)附了一個以等寬區間分段線性函數構造可逆非線性的具體做法與配圖。耦合流(pp.311–312)與自迴歸流(pp.313–314)各有一張把正向與反向計算並排對照的圖;層間洗牌的作法與影像情境按通道分半、以小卷積混通道的變體在 p.312 交代,自迴歸流的通用逼近能力宣稱在 p.313,兩族在平行化上的不對稱則在 p.314 有逐式的展示,同頁還有用一個訓練快的流教出一個抽樣快的流的蒸餾安排。殘差流則分兩路(pp.314–317):一路好求逆但行列式難算,順帶交代了可逆層替訓練省記憶體的用法;另一路靠收縮映射與不動點迭代求逆,其行列式對數的冪級數展開與隨機化跡估計的完整推導在 p.317,數學份量重,適合選讀。

pp.317–318 用一小節與一張圖交代多尺度流的分段引入安排。應用節(pp.319–321)談三件事:密度估計與異常偵測(含「機率高未必典型」的警告)、一個把原書這章的零件組裝起來的影像合成模型(含它處理離散像素、偏好靠近中心抽樣與人臉內插展示的細節),以及用反向 KL 散度讓一個流去逼近另一個算得出卻難抽樣的分布的師生式用法。p.319 另有一則註腳,把幾族生成模型能不能給出概似值——精確、只給下界、或根本不給——並排比較,是快速定位這一族在整個版圖裡位置的好入口。章末註記(pp.322–325)梳理了整族方法的文獻譜系:起源論文、各家可逆層的先後與命名、把可逆結構延伸到其他網路型態的工作、連續時間的微分方程觀點,乃至哪些流族在理論上逼得出任意分布的通用性整理(p.325),都有指路;pp.325–326 附有十一道習題,前兩題的一維變換手算與變數變換公式直接對應,值得動筆做一遍。原書第 16 章對應印刷頁 pp.304–326。

§04作業和解答

作業一:手推一維密度換算

給定基底分布為區間 \([-1, 2]\) 上的均勻分布(密度恆為 \(1/3\)),變換函數 \(f[z] = z + e^z\)(導數 \(1 + e^z\) 恆正,嚴格遞增)。(a)求 \(z_0 = 0\) 對應的資料點 \(x_0\),以及變形後分布在 \(x_0\) 處的密度。(b)改從逆方向驗算:用「逆函數在 \(x_0\) 的導數」重新算一次同一個密度,確認兩個方向給同一個答案。(c)變形後的密度在對應 \(z = -1\) 的那一端高、還是對應 \(z = 2\) 的那一端高?先用伸縮倍率推理,再算出兩端的數值。

解答 SOLUTION

(a)\(x_0 = f[0] = 0 + e^0 = 1\)。該點伸縮倍率 \(f'[0] = 1 + e^0 = 2\),所以密度為 \(\tfrac{1/3}{2} = \tfrac{1}{6} \approx 0.1667\):質量被拉寬成兩倍,密度除以二。

(b)逆函數的導數是正向導數的倒數:\((f^{-1})'[x_0] = 1/f'[0] = 0.5\)。逆方向的換算式改成:\(\tfrac{1}{3} \times 0.5 = \tfrac{1}{6}\),與(a)一致。這正是「逆方向的伸縮是正方向的倒數」在一維的樣子。

(c)\(f'[z] = 1 + e^z\) 隨 \(z\) 遞增,所以 \(z = -1\) 端拉伸最少、密度最高。數值:\(z = -1\) 端密度 \(\tfrac{1/3}{1 + e^{-1}} \approx 0.2437\);\(z = 2\) 端密度 \(\tfrac{1/3}{1 + e^{2}} \approx 0.0397\)。(本題三個數值皆以 numpy 重算核對;順帶用數值積分驗證變形後密度沿 \(x\) 的積分為 1.0——質量守恆。)

作業二:三角線性流的密度換算

設一個二維線性流 \(\mathbf{x} = \boldsymbol\Omega\mathbf{z} + \mathbf{b}\),其中 \(\boldsymbol\Omega\) 第一列為 \((1.5, 0)\)、第二列為 \((0.8, 2.0)\),\(\mathbf{b} = (0.4, -0.3)\),基底為二維標準常態。(a)算出 \(\mathbf{z}_0 = (0.5, -1.0)\) 映到的 \(\mathbf{x}_0\)、\(\boldsymbol\Omega\) 的行列式,以及變形後分布在 \(\mathbf{x}_0\) 的密度。(b)為什麼實務上不直接用「一般矩陣」當可逆層?(c)如果把 \(\boldsymbol\Omega\) 換成排列矩陣 \(\boldsymbol\Pi\),密度換算會發生什麼事?

解答 SOLUTION

(a)\(\mathbf{x}_0 = (1.5 \times 0.5 + 0.4,\; 0.8 \times 0.5 + 2.0 \times (-1.0) - 0.3) = (1.15, -1.9)\)。下三角矩陣的行列式是對角線連乘:\(1.5 \times 2.0 = 3.0\)。基底密度 \(Pr(\mathbf{z}_0) = \tfrac{1}{2\pi}e^{-(0.5^2 + 1^2)/2} \approx 0.08519\),除以體積倍率 3.0 得 \(Pr(\mathbf{x}_0) \approx 0.028397\)。(數值以 numpy 重算核對。)

(b)帳單問題。訓練時每一筆資料、每一步更新都要算一次逆與一次行列式;一般矩陣的這兩筆帳都隨維度暴漲,高維時付不起。三角或對角這類特殊結構把行列式壓成「對角線連乘」、把求逆壓成逐列回代,帳才付得起——但結構越特殊,變換越不自由,這正是第五節線性流段落講的取捨。此外線性流保常態性,單靠它永遠變不出多峰分布,所以它只能當積木。

(c)什麼都不會發生——這正是重點。排列矩陣只重排座標、不改變任何體積,\(\bigl|\det \boldsymbol\Pi\bigr| = 1\),密度換算式中的修正因子是一。所以層間洗牌可以放心加,表達力的輪替是白撿的,密度帳零負擔。

作業三:耦合層手算逆向

一個二維仿射耦合層:\(x_a = h_a\)、\(x_b = h_b \cdot e^{s} + m\),其中 \((s, m) = c[h_a]\),且小網路具體為 \(s = 0.3\,h_a - 0.06\)、\(m = 0.5\,h_a + 0.1\)。已知輸出 \((x_a, x_b) = (1.2, 2.0)\),反解輸入 \((h_a, h_b)\),並寫出這一點的對數行列式。

解答 SOLUTION

第一步不用解:\(h_a = x_a = 1.2\),直通的那一半原樣躺在輸出裡。第二步拿它把參數重算一遍(小網路正著跑,不需要任何逆):\(s = 0.3 \times 1.2 - 0.06 = 0.3\),\(m = 0.5 \times 1.2 + 0.1 = 0.7\)。第三步反解:\(h_b = (2.0 - 0.7) \cdot e^{-0.3} \approx 0.96306\)。驗算正向:\(0.96306 \times e^{0.3} + 0.7 = 2.0\),吻合。對數行列式:雅可比是三角矩陣,對角線為 \((1, e^{s})\),所以 \(\log\bigl|\det \mathbf{J}\bigr| = s = 0.3\)。(數值以 numpy 重算核對。)

作業四:兩個概念關卡

(a)解釋為什麼流模型的潛在空間維度必須等於資料維度——「壓縮」與「升維」各壞在哪裡。(b)舉一個「可逆、但逆不好算」的具體例子,並說明耦合流是怎麼避開這個坑的。

解答 SOLUTION

(a)設潛在維度小於資料維度:\(f\) 的像頂多是資料空間裡一片低維的曲面,真實資料只要稍微偏離那片曲面,就沒有任何 \(\mathbf{z}\) 對應到它,密度無從定義;反過來潛在維度較大時,不同的 \(\mathbf{z}\) 會擠到同一筆 \(\mathbf{x}\) 上,\(f^{-1}\) 給不出唯一答案,換算式裡的 \(\mathbf{z} = f^{-1}[\mathbf{x}]\) 就寫不成。雙向都走得通的一對一映射,只能發生在同維度之間——這是可逆性直接開出的價碼。

(b)本章第二節的 \(f[z] = z + 0.4z^3\) 就是現成例子:導數恆正、嚴格遞增,逆存在;但給你一個 \(x\) 要反求 \(z\),得解一條三次方程——一維還有公式硬撐,換成高維、把多層單調變換疊起來之後,逆一般沒有閉式,只能一步步迭代逼近,訓練時每筆資料都得付這筆迭代費。耦合流的避法是釜底抽薪:把表達力外包給不必求逆的小網路 \(c\),真正要反解的只剩「尺度恆正的逐維仿射」,逆是一行閉式。「存在逆」與「逆好算」的鴻溝,就是靠這一手跨過去的。

§05參考資料