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

CHAPTER 05 / 21 · PART 2 · 訓練的機制:損失、最佳化與泛化

損失函數:從機率分布導出訓練目標

Loss functions

損失不是從清單裡挑來的:先假設輸出服從哪種分布,最大概似就會把損失的式子推出來。

§01學習重點

§02課程內容

一、換個看法:網路吐出來的不是答案,是一個分布的參數

你大概已經看過幾條損失函數(loss function)的式子,也大概記得一份對照表:預測實數用平方誤差、二選一用二元交叉熵、多選一用多類交叉熵。這份表很好用,卻留下一個錯誤的印象——好像這些式子是前人一條一條試出來、經驗上發現好用的配方,你只要背下哪個場合配哪一條就行。

本章要拆掉這個印象。實情是:你只要把「模型對自己的輸出有多不確定」講清楚,損失的式子就被決定了,你沒有挑的餘地。 上面那三條式子不是三個發明,是同一套推導在三種假設底下跑出來的三個結果。

先把損失函數的職務講清楚。第 1 章說過,訓練就是在一整族函數裡挑一個成員;而「挑」的前提是任兩個候選都能分出高下。可是一批訓練資料上有成千上萬筆落差,那是一堆數字,一堆數字彼此之間沒有大小可言。所以必須有人把這一堆壓成單獨一個數,壓完之後「這組參數比那組好」才成為一句有意義的話。損失函數就是負責這道壓縮的東西。

麻煩在於怎麼壓。舊看法是:網路算出來的 \(f[x, \boldsymbol\phi]\) 就是答案本身,損失去量它跟正確答案 \(y\) 差多遠。這個看法有一個沒被說出口的破綻——差多遠要用什麼尺量? 差的絕對值可以,差的平方可以,差的四次方也可以,每一種都能拿來訓練,也都真的訓練得出東西。這個看法本身給不出理由說該用哪一把尺。

新看法只改一句話:網路吐出來的不是答案,是一個機率分布的參數。

$$ Pr(y \mid x) \;=\; Pr(y \mid \theta), \qquad \theta \;=\; f[x, \boldsymbol\phi] $$

逐項拆解:\(x\) 是輸入,\(y\) 是那筆資料的正確答案;\(\boldsymbol\phi\) 是網路裡所有可調的參數;\(f[x, \boldsymbol\phi]\) 是網路的計算結果,但它現在的身分變了——它不再是「對 \(y\) 的猜測」,而是 \(\theta\),一個分布的參數。\(Pr(y \mid \theta)\) 讀作「在參數為 \(\theta\) 的那個分布底下,\(y\) 這個值有多可能出現」。豎線右邊的東西是給定的條件,左邊才是被賦予機率的對象。

換句話說,模型交出來的不是一個點,而是一整條曲線:對每一個可能的 \(y\) 值,它都說得出「這個值有多可能」。原本那個「答案」跑到哪裡去了?它退化成這條曲線上的一個特徵,通常是最高點。點預測是分布預測的一個副產品,不是它的替代品。

這裡要先把兩個容易混的詞分開。當 \(y\) 是連續的量,問「\(y\) 剛好等於某個值的機率是多少」沒有意義,答案永遠是零——連續量落在任何單一一個點上的機率都是零。有意義的問法是「\(y\) 落在某一小段區間裡的機率是多少」。機率密度(probability density) 就是為此存在的:它是「每單位長度分到多少機率」,把它沿著一段區間積分才得到那段區間的機率。所以密度值本身可以大於一,只要區間夠窄,積出來的機率照樣小於一。當 \(y\) 是離散的(例如只能是 0 或 1),就沒有這個困擾,每個值直接帶一個機率,那個東西叫機率質量(probability mass)。本章兩種都會用到,往下統一寫成 \(Pr(\cdot)\),需要區分時再點名。

挑分布的原則只有一條,而且是硬的:分布的定義域必須跟輸出的取值範圍對得上。 箭速可以是任何正實數,硬套一個只在 0 與 1 之間有定義的分布,模型連把真實資料寫進去都做不到。這條原則看起來平淡,但它就是整章的分岔器——輸出長什麼樣,決定分布長什麼樣,分布長什麼樣,決定損失長什麼樣。

比喻: 一張射箭用的靶紙上畫著一圈圈的環。環怎麼畫,決定了同一支箭值多少分:環畫得密,偏個兩公分就掉一環;環畫得疏,偏五公分還在同一環裡。你對輸出所做的分布假設,扮演的就是「環怎麼畫」這個角色——它決定了偏多少要扣多少分。同一組預測,換一張畫法不同的靶紙,優劣排序真的會翻過來。這個比喻有一處明確失準,而那正好是本章的重點:靶紙上的環是主辦單位隨意訂的,訂成什麼樣都不算錯;但損失不是——你一旦說定了輸出服從哪一種分布,損失的式子就被唯一決定,沒有任何可以自由發揮的空間。比喻裡的自由,在數學裡不存在。

二、從概似到損失:四個動作,每個都有理由

有了分布,就可以問一個很具體的問題:手上這批訓練資料,在模型目前給出的那些分布底下,有多可能發生?

對第 \(i\) 筆資料,這個「有多可能」就是 \(Pr(y_i \mid f[x_i, \boldsymbol\phi])\)。它有一個名字叫概似(likelihood)。概似跟機率算的是同一條式子,差別在你把誰當變數:把 \(\theta\) 釘住、讓 \(y\) 跑,那是機率,在整個定義域上積分(或加總)起來等於一;把 \(y\) 釘住(它就是資料,不會動)、讓參數跑,那就是概似,它對參數並不加總為一。同一個式子,兩種讀法,這是最容易卡住的地方之一。

還有一個更常見的誤會要先擋掉:概似不是「參數的機率」。\(Pr(y_i \mid \theta)\) 給機率的對象是 \(y_i\),不是 \(\theta\)。說「這組參數的概似比較高」,意思是「這組參數讓已經發生的資料顯得比較不意外」,不是「這組參數比較可能是對的」。

接著把整批資料合起來。這裡要用掉一個假設:各筆資料彼此獨立。獨立的意思是,知道了第三支箭的結果,並不會改變你對第七支箭的預期。這是假設,不是事實——同一位選手連續脫靶通常是有原因的,而那個原因會讓相鄰幾支箭彼此相關。我們照樣這樣假設,因為它讓下面這一步成立,而且在多數情況下夠好用。獨立事件同時發生的機率等於各自機率相乘,所以整批資料的概似是:

$$ \prod_{i=1}^{I} Pr\bigl(y_i \mid f[x_i, \boldsymbol\phi]\bigr) $$

逐項拆解:\(\prod\) 是連乘符號,就像 \(\sum\) 是連加;\(I\) 是訓練資料的筆數;\(i\) 從 1 跑到 \(I\),把每一筆的概似乘在一起。訓練的目標寫成一句話就是:找一組 \(\boldsymbol\phi\) 讓這個乘積最大。

聽起來乾淨,但直接這樣算,程式會壞掉。每一項都是不超過一的正數(密度可以大於一,不過在實務上多半也是小數),幾百項乘起來就會小到浮點數表示不了。看實際的:

PYTHON
import numpy as np

rng = np.random.default_rng(0)

# 假設的教學數字:600 支箭各自的機率密度值
dens = rng.uniform(0.02, 0.5, 600)

prod = 1.0
for d in dens:
    prod *= d
print("直接連乘 600 個密度值:", prod)

log_total = float(np.sum(np.log(dens)))
print("先取對數再相加:", round(log_total, 3))
print("把它指數還原:", np.exp(log_total))

# 單調遞增的變換不會搬動極值的位置
theta = np.linspace(0.0, 5.0, 501)
lik = np.exp(-(theta - 2.30) ** 2 / 0.5) * 1e-120


def at(idx):
    return round(float(theta[idx]), 3)


print("概似最大處 theta =", at(lik.argmax()))
print("對數概似最大處 theta =", at(np.log(lik).argmax()))
nll = -np.log(lik)
print("負對數概似最小處 theta =", at(nll.argmin()))

實跑輸出:直接連乘的結果是 0.0,先取對數再相加是 -897.599,把它指數還原回去又變成 0.0。真正的答案大約是十的負三百九十次方,而雙精度浮點數能正常表示的最小值大約是十的負三百零八次方(再往下還有一小段精度逐步流失的區間,到十的負三百二十四次方左右就徹底歸零)。這個乘積遠在那之下,所以它被無聲地壓成零——不是報錯,是給你一個錯的數字。後面三行則顯示:概似的最大值、對數概似的最大值、負對數概似的最小值,都出現在同一個位置 2.3

這兩件事合起來,就是接下來三個動作的全部理由。

動作一:取對數。 對數把乘法變成加法(\(\log(ab) = \log a + \log b\)),幾百項相乘就變成幾百項相加,數值再也不會擠到浮點數的邊緣。但憑什麼可以動手改目標函數?憑對數是單調遞增的:\(a > b\) 就一定有 \(\log a > \log b\)。一個單調遞增的變換會把曲線拉高壓低、改變它的形狀,卻搬不動峰頂的位置——原來哪一組參數最大,取完對數還是它最大。上面程式最後三行就是在驗證這件事。請把兩件事分開記:損失的數值會變,極值的位置不變。

動作二:加負號。 最佳化工具幾乎一律寫成「往下走、找最小值」,第 6 章要講的梯度下降也是。所以我們把「最大化對數概似」翻成「最小化負對數概似」。加負號純粹是為了配合這個慣例,沒有任何數學上的必要性。

動作三:除以筆數。 把總和改成平均,得到本站統一使用的形式:

$$ \mathcal{L}[\boldsymbol\phi] \;=\; -\frac{1}{I}\sum_{i=1}^{I}\log Pr\bigl(y_i \mid f[x_i, \boldsymbol\phi]\bigr) $$

逐項拆解:\(\mathcal{L}\) 是損失,它是一個以參數為變數的函數;負號來自動作二;\(\frac{1}{I}\) 把總和攤成平均;\(\log Pr(\cdot)\) 是第 \(i\) 筆資料的對數概似。這條式子有一個正式名字叫負對數概似(negative log-likelihood)。有些教材寫成各筆的總和、不除以 \(I\);兩者只差一個 \(I\) 倍的正常數,最小值落在同一組參數上。本站一律用平均,因為這樣一來,不同批次大小、不同資料量算出來的損失數字才能直接互相比較。

到這裡,整套做法可以收成一張四步的工序單,之後每次遇到新的輸出型態,照著填就好:

  1. 挑一個分布,它的定義域要蓋得住輸出可能的取值範圍。
  2. 讓網路去算這個分布的參數,也就是 \(\theta = f[x, \boldsymbol\phi]\)。
  3. 把負對數概似寫出來,最小化它,得到 \(\hat{\boldsymbol\phi} = \arg\min_{\boldsymbol\phi} \mathcal{L}[\boldsymbol\phi]\)。
  4. 推論時,餵進新的 \(x\),拿到一條分布;若非要一個單一答案不可,就取這條分布的最高點。

第四步值得多說一句。訓練完的模型對每個輸入吐的是一整條分布,資訊比一個數字多得多——它同時告訴你「最可能是多少」與「有多不確定」。把它壓成一個點是你為了方便而做的取捨,不是模型只能給這麼多。第四小節會示範,把那份被丟掉的資訊撿回來能換到什麼。

三、代入一:實數輸出配常態分布,平方誤差自己長出來

現在把工序單跑第一遍。設想一個射箭場的教學情境:由選手的拉距預測箭速,兩者都是實數。第一步要挑分布,實數線上最常用的是常態分布(normal distribution),也叫高斯分布:

$$ Pr(y \mid \mu, \sigma^2) \;=\; \frac{1}{\sqrt{2\pi\sigma^{2}}}\exp\left[-\frac{(y-\mu)^{2}}{2\sigma^{2}}\right] $$

逐項拆解:\(\mu\)(讀作 mu)是平均值,決定這條鐘形曲線的峰頂落在哪裡;\(\sigma^{2}\)(sigma 的平方)是變異數,決定它有多胖——變異數大,曲線矮而寬,各種值都不算太意外;變異數小,曲線高而窄,稍微偏離峰頂機率就掉得很兇。前面那個帶根號的分數是歸一化因子,它的存在只為了讓整條曲線底下的面積剛好等於一。

第二步,讓網路算參數。常態分布有兩個參數,但先只讓網路算平均值:\(\mu = f[x, \boldsymbol\phi]\),變異數 \(\sigma^{2}\) 暫時當成一個我們不知道、但固定不變的常數。第三步,把負對數概似寫出來。把常態密度代進上一節那條式子,中間有兩次化簡:

$$ \mathcal{L}[\boldsymbol\phi] \;=\; \frac{1}{I}\sum_{i=1}^{I}\left[\frac{1}{2}\log(2\pi\sigma^{2}) \;+\; \frac{\bigl(f[x_i,\boldsymbol\phi]-y_i\bigr)^{2}}{2\sigma^{2}}\right] $$

逐項拆解:中括號裡第一項來自那個歸一化因子取對數,它裡面完全沒有 \(\boldsymbol\phi\);第二項來自指數部分取對數,負負得正之後就是殘差平方除以兩倍變異數。

接著是兩個丟東西的動作,各有各的條件。第一項不含參數,所以不管 \(\boldsymbol\phi\) 怎麼變它都一樣——它只是把整條曲線整體上下平移,谷底位置不動,可以丟。第二項裡的 \(\frac{1}{2\sigma^{2}}\) 是一個正的常數倍,乘上正數只會把曲線整體拉高壓低,谷底位置一樣不動,也可以丟。丟完之後剩下:

$$ \mathcal{L}[\boldsymbol\phi] \;=\; \frac{1}{I}\sum_{i=1}^{I}\bigl(f[x_i,\boldsymbol\phi]-y_i\bigr)^{2} $$

這就是第 1 章寫過的最小平方損失。它不是誰發明的、也不是因為「平方比較好算」——它是「輸出服從常態分布」這個假設的推導結果。你如果從來沒明說過這個假設,那它就是被你默默用掉了。

丟常數這一步有一個常被忽略的但書:那兩項之所以能丟,前提是 \(\sigma^{2}\) 跟 \(\boldsymbol\phi\) 無關。等到第四小節讓網路自己去算變異數,它就跟 \(\boldsymbol\phi\) 有關了,這兩項馬上失去被丟掉的資格。

驗證一下「丟常數不搬動谷底」這件事。下面這段固定住截距、沿著斜率這一條軸掃描,同時算平方誤差與三種不同 \(\sigma\) 之下的負對數概似:

PYTHON
import numpy as np

rng = np.random.default_rng(0)

# 教學用例(數字為假設):拉距 x 公分 → 箭速 y 公尺/秒
x = np.linspace(66.0, 74.0, 40)
y = 12.0 + 0.85 * x + rng.normal(0, 1.2, x.size)


def predict(phi):
    return phi[0] + phi[1] * x


def mse(phi):                     # 平均平方誤差
    return float(np.mean((predict(phi) - y) ** 2))


def nll(phi, sd):                 # 常態假設下的負對數概似
    r = predict(phi) - y
    return float(np.mean(0.5 * np.log(2 * np.pi * sd ** 2)
                         + r ** 2 / (2 * sd ** 2)))


def report(tag, v):
    print(f"{tag:<11} 谷底 φ1={grid[v.argmin()]:.3f}"
          f"  谷底值={v.min():.4f}")


grid = np.linspace(0.60, 1.10, 501)
report("MSE", np.array([mse([12.0, g]) for g in grid]))
for s in (0.6, 1.2, 3.0):
    v = np.array([nll([12.0, g], s) for g in grid])
    report(f"NLL σ={s}", v)

實跑輸出:平方誤差的谷底落在 φ1=0.849、谷底值 0.8927;\(\sigma\) 取 0.61.23.0 三種時,負對數概似的谷底值分別是 1.64801.41122.0671——三個數字都不一樣,而且跟平方誤差也不一樣,但谷底位置四次全是 0.849。這正是「損失的數值會變、極值的位置不變」的實證。順帶一提,那個 0.849 跟造資料時用的 0.85 不完全相等,因為資料裡有雜訊;這個落差是第 8 章「衡量表現:泛化、雙下降與測試紀律」的主題。

第四步,推論。常態分布的最高點就在 \(\mu\),而 \(\mu\) 正是網路算出來的那個數。所以「取分布的最高點」在這裡剛好退化成「直接用網路的輸出」——舊看法之所以一直看起來沒問題,是因為在常態這個特例底下它恰好對。 換一種分布,這個巧合就沒有了。

最後補一個觀念上的重點。平方誤差對離群值特別敏感,這件事你可能聽過,但理由通常沒被說出來:常態分布的尾巴掉得非常快。離峰頂三個標準差的地方,密度只剩峰頂的百分之一出頭;到五個標準差,只剩二十六萬分之一。所以當一支箭的實際結果落在那麼遠的地方,模型會覺得「這在我的假設下幾乎不可能發生」,於是給出巨大的損失,訓練就會為了遷就這一支箭而把整條線拉過去。這不是平方誤差的缺陷,這是常態假設的忠實後果。想讓模型不要那麼容易被拉走,正確的做法不是去改損失式子,而是改假設——換一個尾巴比較厚的分布,新的損失自然會長出來。作業一會請你親手推一次。

比喻: 常態假設對應的是一張特殊畫法的靶紙:環的寬度處處相等,而扣掉的分數隨偏離距離的平方成長——偏兩公分扣四分,偏四公分就扣十六分。這張靶紙上的分數怎麼算,完全由「環等寬」這一個設定決定,你不能一邊說環等寬、一邊又說扣分要按距離的一次方算,那是自相矛盾。這個比喻在一處失準:射箭是一箭一分、當場結算,而損失是整批一起算出來的一個數。單獨一支箭的那一項數字,你拿在手上其實判斷不了模型好不好——它必須跟全部一起平均之後才有意義。

四、變異數的三種身分:模型能不能說出「我這次沒把握」

上一節把 \(\sigma^{2}\) 當成不知道的固定常數,然後把它丟掉了。這是三種可能處理裡最省事的一種,但不是唯一一種。變異數在模型裡可以有三種身分,每往下一級,模型多知道一件事。

第一種身分:不知道、但固定的常數。 就是上一節的做法。它從損失式子裡被消掉,所以你看到的平方誤差裡根本沒有 \(\sigma\) 的影子。這裡要拆掉一個很常見的誤解:式子裡沒有變異數,不代表模型沒有假設不確定性。 假設一直都在,只是它的值不影響最佳參數的位置,所以被省略了。省略跟不存在是兩件事。

第二種身分:一個要學的參數。 把 \(\sigma^{2}\) 也列進要調的東西裡,跟 \(\boldsymbol\phi\) 一起最小化損失。這時候上一節那兩個「不含參數所以可以丟」的項立刻失效——第一項 \(\frac{1}{2}\log(2\pi\sigma^{2})\) 現在含參數了。式子變得比平方誤差複雜一些,換來的是模型會報出一個整體的不確定性寬度。

第三種身分:隨輸入而變的第二個網路輸出。 讓網路對每個 \(x\) 同時吐兩個數:一個當平均值,一個拿去變成變異數。這種「不同輸入配不同寬度」的模型叫異質變異(heteroscedastic) 模型;相對地,整批資料共用一個寬度的叫同質變異(homoscedastic) 模型。

第三種身分有一個技術障礙:網路的輸出是任意實數,可正可負,而變異數必須為正——它出現在對數裡,也出現在分母裡,一旦是零或負數,整條式子當場失去意義。所以中間必須夾一層變換,把一個沒有範圍限制的實數搬進「必須為正」的約束裡。本課採用的做法是取平方:讓網路吐出的第二個數是 \(g\),把 \(g^{2}\) 當作變異數。平方保證非負,而且是一個平滑、處處可微的函數,第 7 章要講的梯度傳得過去。(實務上也常見另一種做法:讓網路輸出的是 \(\log \sigma^{2}\),再取指數還原。兩種做法要解決的是同一個障礙,只是繞路方式不同。)

用實際的數字看看第三種身分能換到什麼。設想一個射箭場的另一個量測:射距越遠,落點的散布越寬,但平均而言並不偏向任何一邊。

PYTHON
import numpy as np

rng = np.random.default_rng(0)


def draw(n):
    """教學用例(數字為假設):射距 x 公尺 →
    落點水平偏移 y 公分。平均偏移 0,散布寬度隨射距變寬。"""
    x = rng.uniform(18.0, 70.0, n)
    return x, rng.normal(0.0, 0.30 * x, n)


x_tr, y_tr = draw(4000)
x_te, y_te = draw(4000)


def mean_nll(y, sd):         # 平均值固定為 0 的負對數概似
    return float(np.mean(0.5 * np.log(2 * np.pi * sd ** 2)
                         + y ** 2 / (2 * sd ** 2)))


# 模型 A:同質變異,整座靶場共用一個 sigma
sd_a = float(np.std(y_tr))
print(f"A 同質  σ={sd_a:5.2f}"
      f"  訓練 {mean_nll(y_tr, sd_a):.4f}"
      f"  測試 {mean_nll(y_te, sd_a):.4f}")

# 模型 B:異質變異,第二個輸出 g 取平方後當變異數
best = (np.inf, 0.0, 0.0)
for c0 in np.linspace(-3.0, 3.0, 61):
    for c1 in np.linspace(0.0, 0.6, 121):
        g = c0 + c1 * x_tr            # 可正可負的原始輸出
        sd = np.sqrt(g ** 2 + 1e-9)   # 取平方 → 必為正
        v = mean_nll(y_tr, sd)
        if v < best[0]:
            best = (v, c0, c1)

v, c0, c1 = best
sd_te = np.sqrt((c0 + c1 * x_te) ** 2 + 1e-9)
print(f"B 異質  g={c0:.1f}+{c1:.3f}x  訓練 {v:.4f}"
      f"  測試 {mean_nll(y_te, sd_te):.4f}")

for d in (20.0, 65.0):
    print(f"射距 {d:.0f} 公尺:A 說 ±{sd_a:.1f},"
          f"B 說 ±{abs(c0 + c1 * d):.1f},"
          f"實際 ±{0.30 * d:.1f}")

實跑輸出:同質模型只能報一個 σ=13.83,測試集上的平均負對數概似是 4.0569;異質模型學到 g=0.4+0.290x,測試集上是 3.9335。損失只降了一點點,但看最後兩行就知道差別在哪裡——射距 20 公尺時,同質模型說 ±13.8 公分,異質模型說 ±6.2,實際是 ±6.0;射距 65 公尺時,同質模型還是說 ±13.8,異質模型說 ±19.2,實際是 ±19.5兩個模型對平均值的預測完全一樣(都是零),差別純粹在它們對自己有多有把握這件事上。

這就是第二小節最後那句話的兌現:把分布壓成一個點,丟掉的正是這份資訊。什麼時候值得撿回來?當「猜錯的代價隨情況變化」的時候。一個會說「這次我不太確定」的模型,可以把那些沒把握的個案轉給人處理;一個只會給點預測的模型,你連該轉哪些都不知道。

比喻: 異質變異相當於允許靶紙的環寬隨當下的風況調整:風大的時候環放寬一點,偏了同樣的距離扣得少一些;風平的時候環收緊,同樣的偏差要扣更多。這樣記分才公平,因為風大時的偏差本來就不全是選手的責任。這個比喻在一處失準,而且失準得很要緊:靶紙怎麼畫,對箭在空中怎麼飛沒有任何影響——換一張靶紙,箭的落點一支都不會變。損失卻不是這樣,換一個損失,訓練出來的參數會真的不同,模型的行為會真的改變。靶紙只是在事後記分,損失卻在反過來塑造它所評量的那個東西。

五、代入二與三:同一條工序單,跑在二元與多類上

工序單的價值在於它可以重複使用。這一節連跑兩遍,你會發現真正換掉的只有第一步。

二元輸出。 設想要判斷一支箭中不中黃心,答案只能是 0 或 1。取值範圍是兩個點,實數線上的常態分布完全用不上。這個定義域對應的是伯努利分布(Bernoulli distribution),它只有一個參數 \(\lambda\),代表出現 1 的機率:

$$ Pr(y \mid \lambda) \;=\; \lambda^{\,y}\,(1-\lambda)^{\,1-y}, \qquad y \in \{0, 1\} $$

逐項拆解:這條式子把兩種情形合成一條。\(y = 1\) 時指數是 1 和 0,整式變成 \(\lambda\);\(y = 0\) 時指數是 0 和 1,整式變成 \(1-\lambda\)。用指數當開關是一個純粹為了寫得下去的技巧,取對數之後它會變成係數,非常好用。

第二步,網路要算 \(\lambda\)。可是網路吐出來的是任意實數,而 \(\lambda\) 必須落在 0 與 1 之間,所以中間又要夾一層變換。標準選擇是 logistic sigmoid

$$ \lambda \;=\; \mathrm{sig}[z] \;=\; \frac{1}{1+\exp[-z]}, \qquad z = f[x, \boldsymbol\phi] $$

逐項拆解:\(z\) 是網路的原始輸出,習慣上叫分數或 logit,可正可負;\(z\) 很大時 \(\exp[-z]\) 趨近 0,整式趨近 1;\(z\) 很負時 \(\exp[-z]\) 很大,整式趨近 0;\(z = 0\) 時剛好是 0.5。這裡要記住一個結構:網路的原始輸出跟分布的參數之間永遠隔著一層變換,兩者不是同一個東西,也不在同一個尺度上。

第三步,代進負對數概似。把伯努利的式子取對數,指數落下來變成係數:

$$ \mathcal{L}[\boldsymbol\phi] \;=\; -\frac{1}{I}\sum_{i=1}^{I}\Bigl[\,y_i\log\lambda_i \;+\; (1-y_i)\log(1-\lambda_i)\,\Bigr] $$

這條式子叫二元交叉熵(binary cross-entropy)。負號的位置常被寫錯,值得停一下:整條式子只有一個負號,在總和的外面。 中括號裡的每一項本身都是負的,因為機率不超過一、對數不超過零;外面那個負號把它翻正,損失才會是一個非負的數。學生常見的錯誤是把負號寫進括號裡的每一項,那樣算出來會是負的,愈訓練愈小、小到沒有下界。

第四步,推論。伯努利分布只有兩個可能值,取機率大的那個,也就是 \(\lambda > 0.5\) 就答 1。注意這個 0.5 是「取最高點」的直接後果,不是一個可以隨便調的旋鈕——當然你可以為了業務理由調它,但那已經超出最大概似的範圍了。

PYTHON
import numpy as np


def sigmoid(z):
    return 1.0 / (1.0 + np.exp(-z))


def softmax(v):
    e = np.exp(v - v.max())
    return e / e.sum()


# 一支箭的原始分數(假設值):兩條路徑給出同一個機率
z = 0.8
print("sigmoid 壓縮後:", round(float(sigmoid(z)), 6))
print("兩類 softmax :",
      np.round(softmax(np.array([z, 0.0])), 6))
print("兩個分數各加 5:",
      np.round(softmax(np.array([z + 5.0, 5.0])), 6))

# 一輪 8 支箭:y=1 表示中黃心。分數為假設值
y = np.array([1, 1, 0, 1, 0, 0, 1, 0])
s = np.array([2.1, 0.4, -1.6, -0.3, 0.9, -2.4, 1.7, -0.7])
lam = sigmoid(s)
bce = -np.mean(y * np.log(lam) + (1 - y) * np.log(1 - lam))
print("每支箭的中心機率:", np.round(lam, 3))
print("二元交叉熵(平均):", round(float(bce), 4))
print("整輪都猜 0.5:", round(float(-np.log(0.5)), 4))

# 把第 4 支箭改成「模型很有把握地猜錯」
s_bad = s.copy()
s_bad[3] = -6.0
lam_b = sigmoid(s_bad)
bce_b = -np.mean(y * np.log(lam_b)
                 + (1 - y) * np.log(1 - lam_b))
print(f"那一支改成機率 {lam_b[3]:.4f} 之後:",
      round(float(bce_b), 4))

實跑輸出:這一輪八支箭的二元交叉熵是 0.4457,而「全部都猜 0.5」的損失是 0.6931(也就是 \(\log 2\)),所以模型確實學到了東西。最後一行值得注意:只把其中一支箭的預測從 0.426 改成 0.0025——模型很有把握地猜錯了一次——整輪的損失就從 0.4457 跳到 1.0892。對數在機率趨近零時掉向負無窮,所以自信地答錯的代價是沒有上限的。這個性質不是設計出來的,它是取對數那一步的附帶結果。

多類輸出。 換成 \(K\) 選一。設想要把一支箭的放箭失誤歸進四種型態之一。定義域是 \(\{1, \dots, K\}\),對應的是類別分布(categorical distribution),它有 \(K\) 個參數,每個類別一個機率,且必須全部非負、加起來等於一。這種「非負且總和為一」的參數空間有個名字叫單體(simplex)

網路吐出的是 \(K\) 個沒有範圍限制的分數,要搬進單體裡,用的是第 1 章已經給過式子的 softmax:先取指數讓每個數變正,再除以總和讓它們加起來等於一。這裡要澄清一個常見誤解:指數在這裡的職務是保證為正,不是為了放大差距。 它確實順帶讓高分的優勢被放大,但那是副作用;任何一個把實數映到正數、而且單調遞增的函數都能擔任這個角色。選指數的真正理由是它跟對數是一對——取負對數概似的時候,指數會被對數消掉,損失式子因此變得極其簡單。

代進工序單第三步,得到多類交叉熵:對第 \(i\) 筆資料,取它正確類別 \(y_i\) 對應的那個機率,取對數,加負號,平均。負號同樣只有一個,在總和的外面。

這裡藏著一個容易忽略的簡化。正確答案通常寫成 one-hot 向量——一個長度 \(K\) 的向量,正確類別那格是 1,其餘是 0。把它跟 \(\log\) 機率向量逐項相乘再加總,那 \(K-1\) 個 0 會把對應的項全部歸零,只剩正確類別那一項活著。所以你在框架裡看到的「逐項相乘後加總」和你手算時的「直接查正確類別那一格」是同一件事。

PYTHON
import numpy as np


def softmax(v):
    e = np.exp(v - v.max())
    return e / e.sum()


# 四種放箭失誤型態,分數為假設的教學值
kinds = ["撒放太早", "握弓手扭轉", "瞄點偏移", "無明顯失誤"]
s = np.array([1.4, -0.2, 0.6, 2.0])   # 網路吐出的四個分數
p = softmax(s)
print("分布參數:", np.round(p, 4),
      " 總和:", round(float(p.sum()), 6))

y = 2                                 # 正確答案:瞄點偏移
onehot = np.eye(len(kinds))[y]
print("one-hot 逐項相乘:",
      np.round(onehot * np.log(p), 4) + 0.0)
print("多類交叉熵:", round(float(-np.log(p[y])), 4))
p2 = softmax(s + 100.0)
print("分數整條 +100:", round(float(-np.log(p2[y])), 4))

# 多輸出:同一支箭同時判「失誤型態」與「中不中黃心」
z = -0.9                    # 中黃心的原始分數(假設值)
lam = 1.0 / (1.0 + np.exp(-z))
hit = 0                     # 這一箭沒中黃心
l_kind = -np.log(p[y])
l_hit = -(hit * np.log(lam) + (1 - hit) * np.log(1 - lam))
print(f"型態項 {l_kind:.4f} + 中心項 {l_hit:.4f}"
      f" = {l_kind + l_hit:.4f}")

實跑輸出:四個分數 [1.4, -0.2, 0.6, 2.0] 經 softmax 變成 [0.2879 0.0581 0.1294 0.5246],總和 1.0;one-hot 逐項相乘後只剩 -2.0451 那一格,多類交叉熵就是 2.0451。倒數第二行顯示,把四個分數同時加上 100,損失一模一樣還是 2.0451——softmax 只看分數之間的,整體平移不影響結果。

這個性質順手解釋了 sigmoid 與 softmax 的關係。兩類的 softmax 只由兩個分數的差決定,等於有一個多餘的平移自由度;把其中一個分數固定成 0,剩下那個直接餵進 sigmoid,得到的機率完全相同。二元那段程式的前三行輸出就驗證了這件事:sigmoid(0.8)softmax([0.8, 0.0]) 的第一個分量都是 0.689974,把兩個分數同時加 5 之後還是 0.689974sigmoid 不是另一個發明,它是兩類 softmax 把多餘自由度拿掉之後的樣子。

最後提醒一個實務上會咬人的細節:類別編號是名目的,1、2、3、4 只是四個名字,不是四個數量。把它們直接丟進平方誤差,等於憑空宣稱「第 1 型跟第 4 型的差距是第 1 型跟第 2 型的三倍」,那是無中生有的資訊。反過來,如果任務本身確實有序——例如靶紙上的環數從 1 到 10——用類別分布又會把「9 環比 3 環更接近 10 環」這件事整個丟掉。有序輸出該配什麼分布是一個真實的取捨,本課不展開,作業三會請你把這個矛盾說清楚。

六、多個輸出、分布選單,以及「交叉熵」這個名字

到目前為止每個例子都只有一個輸出。實際任務常常一次要交出好幾個數,處理方式是再用一次獨立假設:假設在給定輸入的條件下,各個輸出維度彼此獨立。 有了它,整筆資料的概似就是各維概似的乘積;取負對數之後,乘積變成加總——各維的損失直接相加就是整體的損失。 上一段程式的結尾示範了這件事:同一支箭的「失誤型態」與「中不中黃心」兩項損失分別是 2.04510.3412,相加得 2.3863

這件事有兩個推論值得記住。第一,你在別人的程式碼裡看到好幾個損失被加在一起,那不是隨手拼湊,背後是一個明確的獨立假設。第二,各維可以配不同的分布——一個維度是實數就配常態、一個是二選一就配伯努利,混著用完全合法,因為工序單是逐維各跑一次的。

既然定義域決定分布、分布決定損失,那麼把常見的定義域列一遍,就等於列出了一份損失清單:沒有範圍限制的實數配常態分布,或配尾巴更厚的分布(那會給你一條對離群值比較不敏感的損失);有下界的正實數(例如時間、距離)配指數分布或伽瑪分布;限制在某個有界區間裡的實數(例如比例)配貝他分布;二選一配伯努利分布;\(K\) 選一配類別分布;非負整數的計數配 Poisson 分布;角度或方向這種頭尾相接的量有專用的環狀分布;連排序這種輸出都有對應的機率模型。你不需要背這張表,你需要的是那條產生它的規則——先問輸出住在哪裡,再去找住在同一個地方的分布。

最後把名字對齊。你在文獻與框架裡看到的「交叉熵(cross-entropy)」,跟本章一路推下來的「負對數概似」是同一個東西,不是兩條可以互相替換的損失。它們只是來路不同。

負對數概似的來路你已經走過了。交叉熵的來路是這樣:把訓練資料本身也看成一個分布——每一筆資料上放一份等重的機率,這個分布叫經驗分布(empirical distribution)。現在手上有兩個分布:資料的經驗分布,與模型給出的分布。要衡量兩者差多遠,機率論的標準工具是 KL 散度(Kullback-Leibler divergence)。把 KL 散度攤開,會得到兩項:一項只跟經驗分布有關,另一項同時牽涉兩個分布。第一項完全不含 \(\boldsymbol\phi\),所以按照第三小節那個丟常數的道理,它對最小值的位置毫無影響,可以丟。剩下的第二項就叫交叉熵——「交叉」指的正是它同時牽涉兩個分布——而它化簡出來,逐字就是負對數概似。

所以兩條來路,同一個終點。這也解釋了為什麼命名上會出現這種局面:迴歸任務的損失叫「平方誤差」,分類任務的叫「交叉熵」,聽起來像兩個世界的東西,其實兩者都是負對數概似,只是代進去的分布不同而已。名字的分歧是歷史造成的,不是數學造成的。

還有一件事,本章結束前必須澄清,否則很容易被誤讀。模型輸出的東西形式上是一個機率——非負、加總為一——但這不保證它在統計上是準的。一個模型可能對它說「九成把握」的那一批案例,實際只答對七成。這個落差叫校準(calibration)誤差,它跟準確率是兩件獨立的事:一個模型可以又準又沒校準好,也可以不太準卻校準得不錯。最大概似會鼓勵模型往校準的方向走,但訓練資料有限、模型容量又大的時候(第 9 章「正則化:讓模型不要死記硬背」處理的正是這個處境),輸出的機率常常過度自信。「這是機率」是形式上的保證,「這個機率可信」需要另外驗證。

回到本章開頭那句話。損失函數不是一份要背的配方表,它是你對輸出所做的假設的投影。你選了常態分布,就會得到平方誤差;你選了伯努利分布,就會得到二元交叉熵;你什麼都沒選、直接抄了一條損失下來——那你其實還是選了,只是沒有意識到自己選了什麼。往後每次看到一條陌生的損失,先別問「它為什麼長這樣」,改問:它背後那個分布假設是什麼? 這一問通常會把整條式子解釋乾淨。

§03原書對照

原書第 5 章把「損失函數是怎麼被推出來的」寫成一條完整的推導鏈,鏈子的每一環都配了圖,推導細節與延伸分支也都鋪得很開;下面按印刷頁碼指出值得回頭翻的位置。

第一處是視角轉換的鋪陳。p.57 用一張四格圖把同一件事套在四種輸出型態上——實數、離散類別、非負整數的計數、角度方向——每一格都畫出模型面對同一個輸入時吐出的分布長什麼樣。「換一個分布就換一個損失」這句話,那張圖是一次看完的最短路徑。緊接著 p.58 有一個腳註值得專門讀一遍:同一條式子看成誰的函數,一個是加總為一的分布,另一個是不必加總為一的概似。這個分別,原書兩句話就交代完。

第二處是取對數那一步的視覺論證。p.59 用三格圖說明單調遞增的變換為什麼搬不動極值的位置:斜率的正負在變換前後保持一致,峰頂因此留在原地。想把這件事看得見,就翻那一頁。原書把整套做法收成四個步驟的配方寫在 p.60,同一頁還交代了推論時怎麼從一個完整的分布退回一個點估計——這一頁最適合當成整章的索引卡。

第三處是平方誤差的完整消去過程。pp.61–62 從常態分布的密度函數出發,用連續四行式子化簡到平方和,明白標出哪一項因為不含參數而被丟掉、哪一項因為只是正的常數倍而被丟掉。要親眼看見每一項是怎麼被消掉的,那兩頁最省事。p.63 另有一張佔滿整頁的四格圖,把「殘差平方很大」與「資料在預測分布下機率很低」這兩種說法,並排畫在同一組資料上。

第四處是變異數的三種處理。pp.64–65 依序給出把變異數當成未知常數、當成待學參數、以及讓它隨輸入而變的三種做法,並在圖上用灰帶標出兩種模型的不確定性寬度差別;連「變異數必須為正」這個技術障礙、以及原書用平方來繞過它的做法,都在那裡交代了。想把三種做法的取捨對著圖比一遍的人,那兩頁最省事。

第五處是兩種分類損失的圖解。pp.65–68 依序放上伯努利分布、logistic sigmoid、二元分類模型的三段變換圖,以及類別分布與 softmax 的對照圖。想確認「網路的原始輸出」「壓過去之後的分布參數」「損失」三者在圖上各自是哪一條線,那幾張圖比任何文字都直接。

第六處是整章的延伸分支。p.70 用一張表列出十一種輸出型態各自該配哪一種分布,涵蓋重尾分布、混合高斯、指數與伽瑪、貝他、von Mises 與 Poisson,以及排序任務用的 Plackett-Luce。pp.71–72 改從 KL 散度出發重推一次同一個損失,證明交叉熵與負對數概似只是同一件事的兩條來路;想看逐行的式子,就在那兩頁。pp.73–74 的註記補上穩健迴歸、分位數迴歸、類別不平衡下的 focal loss,以及不走機率路線的 hinge loss 與指數損失,每一條都附了原始論文出處;pp.74–76 的十道習題則涵蓋方向、計數與多輸出三類。原書第 5 章對應印刷頁 pp.56–76。

§04作業和解答

作業一:換一個分布,親手把新的損失推出來

Laplace 分布(Laplace distribution)的密度是 \(Pr(y \mid \mu, s) = \frac{1}{2s}\exp\left[-\frac{|y-\mu|}{s}\right]\),其中 \(\mu\) 是峰頂位置、\(s\) 是一個正的尺度參數。(a)照第二小節的工序單,把它的平均負對數概似寫出來,並化簡成一條損失;每丟掉一項都要說出丟掉的理由。(b)解釋為什麼這條損失對離群值的反應比平方誤差溫和。(c)給定一輪六支箭的落點偏移(公分)\(\{1.2,\, -0.8,\, 0.5,\, -1.5,\, 0.9,\, 12.0\}\),其中最後一支明顯是離群值。若模型只有一個參數 \(\mu\)(也就是對所有箭給同一個預測),分別求出平方誤差與(a)那條損失各自的最佳 \(\mu\);再把最後那支箭拿掉重算一次,比較兩者被拉走了多少。

解答 SOLUTION

(a)取負對數:\(-\log Pr(y \mid \mu, s) = \log(2s) + \frac{|y-\mu|}{s}\)。代進平均負對數概似,並讓網路去算峰頂位置(\(\mu = f[x,\boldsymbol\phi]\)):

\(\mathcal{L}[\boldsymbol\phi] = \frac{1}{I}\sum_{i=1}^{I}\left[\log(2s) + \frac{\bigl|f[x_i,\boldsymbol\phi]-y_i\bigr|}{s}\right]\)

第一項 \(\log(2s)\) 完全不含 \(\boldsymbol\phi\),只把整條曲線上下平移,谷底位置不動,可以丟。第二項的 \(\frac{1}{s}\) 是一個正的常數倍,只把曲線拉高壓低,谷底位置一樣不動,也可以丟。剩下 \(\mathcal{L}[\boldsymbol\phi] = \frac{1}{I}\sum_i \bigl|f[x_i,\boldsymbol\phi]-y_i\bigr|\),也就是平均絕對誤差。注意這兩次丟棄的理由跟第三小節推平方誤差時一模一樣——工序單沒變,只有第一步換了分布。

(b)兩個層次。第一,尾巴的厚度:常態的密度按 \(\exp[-(\cdot)^2]\) 衰減,Laplace 按 \(\exp[-|\cdot|]\) 衰減,後者慢得多。所以同一個遠離峰頂的觀測,在常態假設下被認為「幾乎不可能發生」、於是產生巨大的損失,在 Laplace 假設下卻只是「比較少見」。第二,成長速率:平方誤差隨偏差的平方成長,絕對誤差只線性成長;換句話說,絕對值函數的斜率恆為 1 或 −1,一個離群點對參數的拉力有上界,不會因為它偏得更遠就拉得更用力。

(c)平方誤差的最佳 \(\mu\) 是資料的平均值,絕對誤差的最佳 \(\mu\) 是中位數。含離群值的六筆:平均值 \(=12.3/6=2.05\);六筆是偶數,中位數不唯一,最小值出現在中間兩個數 0.5 與 0.9 之間的任何位置(該區間上平均絕對誤差恆為 2.65)。拿掉離群值的五筆:平均值 \(=0.3/5=0.06\),中位數 \(=0.5\)。所以那一支離群箭把平方誤差的最佳點從 0.06 推到 2.05,移動了將近 2;卻只把絕對誤差的最佳點從 0.5 推到 0.5–0.9 這個區間,最壞情況移動 0.4。差距約五倍。(本題數值以 numpy 在 −2 到 13 的格點上重算核對過,格點結果與上述閉式答案一致。)

作業二:異質變異的正值約束

第四小節讓網路吐出第二個輸出 \(g\),取平方後當變異數。(a)如果直接令 \(\sigma^{2} = g\),會在哪兩個地方壞掉?(b)取平方確實解決了正值問題,但它帶來兩個副作用,請各說一個。(c)假設某一筆資料的殘差固定為 \(y - \mu = 3.0\),計算 \(g\) 取 0.02、1.0、3.0、6.0 時的單筆負對數概似 \(\frac{1}{2}\log(2\pi g^{2}) + \frac{3.0^{2}}{2g^{2}}\),並說出哪一個 \(g\) 讓它最小、為什麼是那個。

解答 SOLUTION

(a)兩個地方。第一,\(\sigma^{2}\) 出現在 \(\log(2\pi\sigma^{2})\) 裡,負數的對數沒有定義,網路只要吐出負值,損失就變成非數值,訓練當場中斷。第二,\(\sigma^{2}\) 出現在分母 \(\frac{(y-\mu)^2}{2\sigma^{2}}\),取零會除以零,取負值則會讓這一項變成負的——於是最佳化會發現「把 \(g\) 推向負無窮就能讓損失無限下降」,訓練朝一個沒有意義的方向跑掉。

(b)副作用一:\(g\) 與 \(-g\) 平方後完全相同,所以這個參數化不可分辨——兩組不同的網路輸出給出一模一樣的損失,最佳解不唯一。副作用二:\(g\) 經過零附近時變異數趨近零,而變異數在分母裡,損失會衝向無窮大;實作上必須像本章程式那樣加一個很小的下限(程式裡的 1e-9)擋住它。

(c)依序代入:\(g=0.02\) 時是 11247.0069;\(g=1.0\) 時是 5.4189;\(g=3.0\) 時是 2.5176;\(g=6.0\) 時是 2.8357。最小的是 \(g=3.0\),也就是 \(|g|\) 剛好等於殘差的絕對值。道理是這樣:損失裡兩項在互相拉扯——\(\frac{1}{2}\log(2\pi g^{2})\) 要把 \(g\) 推小(分布越窄、峰頂越高),\(\frac{9}{2g^{2}}\) 要把 \(g\) 推大(免得 3.0 這個偏差顯得太離譜),平衡點就落在 \(|g| = |y-\mu|\)。這也解釋了為什麼異質變異模型會學到「散布大的地方報大變異數」。(本題數值以 numpy 重算核對過,並用十萬點格點確認最小值落在 3.0。)

作業三:多類輸出的兩個陷阱

(a)第五小節那四種失誤型態如果編號成 1、2、3、4,直接拿平方誤差去訓練,模型憑空得到了哪兩項它不該有的資訊?如果任務改成預測靶紙上的環數(1 到 10),你的答案要怎麼修正?(b)把第五小節那組分數 \(\{1.4,\, -0.2,\, 0.6,\, 2.0\}\) 整條乘上一個正常數 \(k\),正確答案仍是第 3 個類別(索引 2)。計算 \(k\) 取 0.5、1.0、2.0、5.0 時的 softmax 機率與多類交叉熵,並說明機率最大的類別有沒有跟著變。(c)由(b)的結果說一句話:損失變了但推論結果沒變,這代表什麼?

解答 SOLUTION

(a)兩項。第一是順序:平方誤差假設 3 落在 2 與 4 之間,可是四種失誤型態彼此沒有先後可言,這個順序是編號時憑空加上去的。第二是間距:平方誤差假設第 1 型與第 4 型的差距是第 1 型與第 2 型的三倍,這同樣沒有根據。另外還有一個實作上的怪象——模型會輸出 2.5 這種值,而它不對應任何一個型態。改成環數的話,第一項不成立了:環數確實有序,9 環真的比 3 環更接近 10 環,這時候用類別分布反而會把順序資訊整個丟掉。第二項則要看情況:如果那張靶紙的環是等寬畫的,「10 環與 9 環」跟「2 環與 1 環」在半徑上確實差一樣多,把環數當實數做迴歸的等距假設就站得住;但換一張環寬不等的靶紙,這個假設立刻失效。重點不是哪一種做法對,而是你有沒有意識到自己正在假設什麼——這正是整章的主題。有序輸出有專門的機率模型可用,本課不展開。

(b)\(k=0.5\):機率 [0.2882 0.1295 0.1932 0.3891],損失 1.6440。\(k=1.0\):[0.2879 0.0581 0.1294 0.5246],損失 2.0451。\(k=2.0\):[0.2192 0.0089 0.0442 0.7277],損失 3.1179。\(k=5.0\):[0.0474 0.0000 0.0009 0.9517],損失 7.0495。機率最大的一律是索引 3 那一類,四次都沒有變——因為乘上一個正常數不會改變分數的大小順序。

(c)這代表分布的銳利程度與推論的答案是兩件可以分開的事。\(k\) 越大分布越尖,模型顯得越有把握;答案卻始終一樣。所以「模型答對了」跟「模型給的機率是可信的」是兩個獨立的問題——這一組分數答錯了(正確答案是索引 2,模型卻押索引 3),\(k\) 越大,它就把錯誤押得越重,損失也就漲得越兇。訓練時最大概似會同時壓這兩件事,但推論時如果只看 argmax,你根本看不出機率有沒有校準。(本題數值以 numpy 重算核對過。)

§05參考資料