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

CHAPTER 02 / 21 · PART 1 · 地基:從監督式學習到深度網路

監督式學習:把問題寫成一個函數族

Supervised learning

把有答案的任務拆成模型族、參數、損失與挑選四件事,並交代訓練完怎麼驗收。

§01學習重點

§02課程內容

一、先劃清界線:哪些是人定的,哪些交給資料

監督式學習(supervised learning)的前提只有一句話:你手上的每一筆資料都是成對的,一邊是輸入,另一邊是一個已經知道的正確輸出。沒有這種配對,後面整套機制都動不起來。

但「成對」還不夠。要讓資料進得了模型,還得先滿足兩個約定。第一,輸入必須是固定長度的一串數字,輸出也是;每一筆資料的長度都要一樣,不能這一筆五個數、下一筆七個數。第二,這串數字裡哪一格放什麼,必須從頭到尾同一套規矩。第三格今天放的是水平距離、明天放的是高程,模型就完全學不起來——它沒有辦法知道你換了規矩,它只會看到第三格的數字忽然變得很奇怪。這種每一筆都是固定欄位、欄位意義也固定的資料,一般叫結構化資料(structured data)。

這裡有一件事值得先講清楚,因為它決定了你在整個流程裡的角色:上面那套約定是人訂的,訓練不會幫你訂,也不會幫你檢查。 把真實世界的東西轉成一串數字這個動作叫編碼;要放進去哪幾項、每一項用什麼單位、順序怎麼排,全部是你在動手之前就要拍板的決定。訓練能改的只有式子裡的數字,改不動式子的形狀,更改不動你當初決定要量什麼。

先把這一章要一路用到的例子架起來。假設你要複丈一塊土地的北側界線。地面上並沒有一條畫好的線給你描,你能做的只有量。標準做法是先在地上拉一條筆直的基線,選一根樁當起算點,然後沿著基線走,每隔一段距離停下來,從基線垂直量到界線上,把量到的距離記下來。這樣一根一根釘下去、量出來的位置,就是樁點。

於是輸入輸出都定下來了:輸入 \(x\) 是這根樁點沿基線離起算點多遠(單位公尺,往東為正、往西為負),輸出 \(y\) 是從基線垂直量到界線的距離(也是公尺)。兩邊都只有一個數,這是能想到最小的規格,但該有的東西一樣不少——有配對、有固定長度、有固定的單位約定。

現在輪到第二個由人拍板的決定:模型的形狀。

一個模型不是一個函數,是一整族函數。你先寫下一條形式固定的數學式子,式子裡留幾個空格;空格填不同的數字,就得到不同的函數。以我們的界線為例,最簡單的一族是所有的一次函數:

$$ f[x, \boldsymbol\phi] \;=\; \phi_0 + \phi_1 x $$

逐項拆解:\(x\) 是輸入,也就是那根樁點的橫距;\(f[\cdot]\) 是模型,方括號是本課沿用的記號慣例,用來跟一般的乘法括號區分開;\(\boldsymbol\phi\) 是參數,這裡它有兩個分量 \(\phi_0\) 與 \(\phi_1\)。\(\phi_0\) 是這條線在起算點正前方的縱距,也就是 \(x = 0\) 時算出來的值;\(\phi_1\) 是這條線每往東走一公尺、縱距增加多少,也就是它相對基線傾斜的程度。

這條式子本身沒有指定任何一條線,它指定的是所有可能的線。填 \(\boldsymbol\phi = (2.00,\, 0.00)\) 得到一條和基線平行的線,填 \((2.40,\, 0.28)\) 得到一條往東爬升的線——它們是同一族裡的兩個成員。所以參數扮演的角色是族內的座標:給我兩個數,我就能指出你說的是族裡的哪一個。

反過來也成立,而且反過來看往往更有用。族裡任何一條線,都對應到參數平面上唯一的一個點。於是「挑一條線」這個聽起來很幾何的動作,就變成了「在一個二維平面上選一個位置」這個很算術的動作。這個雙向的對照是本章接下來所有內容的地基:訓練不是在紙上畫線,是在參數平面上移動。

底下這段程式把三個成員各自算出來,看它們在同一批位置上給出什麼答案:

PYTHON
import numpy as np

# 模型族:所有一次函數 f[x, phi] = phi0 + phi1 * x
# phi0=界線在起算樁正前方的縱距,phi1=相對基線的傾斜
def f(x, phi):
    return phi[0] + phi[1] * x


members = {"A": (2.00, 0.00),
           "B": (2.40, 0.28),
           "C": (3.20, 0.45)}
probe = np.array([-6.0, 0.0, 6.0])   # 三個要放樣的橫距

for name, phi in members.items():
    vals = f(probe, np.array(phi))
    shown = " ".join(f"{v:.2f}" for v in vals)
    print(f"成員 {name}  phi=({phi[0]:.2f}, {phi[1]:.2f})"
          f"  縱距 = {shown}")

# 同一個橫距、不同成員 → 不同輸出:換參數=換族成員
cc = np.array(members["C"])
aa = np.array(members["A"])
print("在 x=6 處 A 與 C 的縱距差:",
      round(float(f(6.0, cc) - f(6.0, aa)), 2), "公尺")

實跑輸出:成員 A 在三個位置的縱距都是 2.00;成員 B 是 0.72 2.40 4.08;成員 C 是 0.50 3.20 5.90。在 x=6 這個位置上,A 與 C 差了 3.9 公尺。式子一個字都沒改,差別全部來自那兩個數字。

順帶澄清一個很容易卡住的地方。上面這一族叫線性模型,但「線性」指的不是畫出來一定是直線。它指的是模型對參數是線性的:把 \(\phi_0\) 和 \(\phi_1\) 各乘兩倍,輸出就剛好變成兩倍。這個區別在第五小節會立刻派上用場——到時候我們會把 \(x^2\)、\(x^3\) 這些項加進式子裡,畫出來明明是曲線,但它對參數仍然是線性的,所以還是同一套解法。反過來說,就算式子裡只出現 \(x\) 的一次方,只要參數是以 \(\phi_0 \phi_1\) 這種相乘的方式出現,它就不是線性模型了。

比喻: 一塊地的界線在測量之前並不存在於任何一張紙上,但你已經先決定了要用「一條直線」去描述它——這個決定在你踏進現場之前就下了。剩下的工作只是把那條直線的起算縱距與傾斜這兩個數字定出來。所有可能的直線構成一族,你要交出去的成果圖是其中的一份。這個比喻在一個地方明確失準:地界有一個客觀存在的位置,量錯了可以重新複丈把它抓出來;而機器學習裡那個「真正的函數」通常根本不存在,或者存在但你永遠拿不到,你能拿到的只有一批帶著誤差的量測值。這個差別會在第五小節變成一個實際的麻煩:既然沒有真值可以對,你要怎麼知道自己配得好不好。

二、兩個動作:推論與訓練

有了族與參數,就可以把整件事拆成兩個動作。它們用的是同一條式子,方向卻完全不同。

第一個動作是推論(inference)。 參數是固定的,你把一個輸入代進去,把輸出算出來。以界線為例:成果圖已經定案了,測量員拿著它到現場,要在基線上某個位置把界線的位置標到地面上——他把橫距代進式子,算出縱距,然後在那裡釘一根樁。這個動作在地籍作業裡叫放樣。它不改變任何參數,只是把已經決定好的東西用出來。

第二個動作是訓練(training),也叫擬合(fitting)或學習(learning)。這一次反過來:輸入與輸出你都有,要找的是參數。你手上有一批實際量到的資料——第 \(i\) 根樁點的橫距 \(x_i\) 與量到的縱距 \(y_i\),一共 \(I\) 組。你要從族裡挑出一個成員,讓它盡量對得上這 \(I\) 組數字。

這兩個動作在時間上的分工很不對稱,值得記住:訓練通常只做一次(或偶爾重做),推論則會做上千萬次。 這個不對稱決定了整個工程實務的形狀——訓練慢一點可以忍,推論慢一秒就會被使用者感覺到。

順便清掉一個很常見的混淆:\(I\) 是樣本數,講的是「你量了幾根樁」;輸入的維度講的是「每一根樁記了幾個數字」。這兩個數字完全獨立。你可以量一萬根樁、每根只記一個數(\(I\) 很大、維度是 1),也可以只量十根樁、每根記兩百項(\(I\) 很小、維度是 200),而後者往往比前者難學得多。看到「資料很多」這種說法時,先問清楚多的是哪一邊。

比喻: 放樣就是推論。你手上那份成果圖已經定案,去現場只是把它上面的線標回地面;標一百個位置也不會讓那條線改變半分。這個比喻在一件事上失準:地面放樣做錯了有外部帳本可以抓——地籍圖冊、原始的樁位紀錄、相鄰宗地的成果,任何一個都能把錯誤照出來。模型推論沒有這種外部帳本,一個算出來的數字對不對,只能靠另外一批帶答案的資料去驗。這正是第五小節要處理的問題。

三、把「哪個成員比較好」壓成一個數

要挑,就得先能比。族裡有無限多個成員,你需要一個可以計算的準則,把「這個成員好不好」變成一個數字。

第一步是量落差。對第 \(i\) 根樁點,模型算出來的縱距是 \(f[x_i, \boldsymbol\phi]\),你實際量到的是 \(y_i\),兩者相減就是這根樁的殘差(residual):

$$ r_i \;=\; f[x_i, \boldsymbol\phi] - y_i $$

殘差有正有負:線畫在樁點上方是正,下方是負。但這個方向對「配得好不好」毫無意義——差 30 公分就是差 30 公分,往上往下一樣糟。所以在把所有樁的殘差匯總之前,得先想辦法把方向消掉。

取平方是最常用的做法,而它其實同時做了兩件事,只是第二件比較不明顯:

  1. 消掉方向。 平方之後不分正負,上下對稱地罰。
  2. 對大偏差加重罰則。 殘差從 0.1 變成 0.3,只大了三倍,平方之後卻從 0.01 變成 0.09,大了九倍。也就是說,最小平方會強烈偏好「所有樁都差一點」的線,而不是「大部分樁完美、有一根差很多」的線。

第二點是個真正的取捨,不是附帶效果。如果你的資料裡有一根樁是打樁時記錯的,平方會讓整條線被那一根拉走。所以要不要用平方,其實取決於你怎麼看待自己的量測誤差。這句話透露了一件事:平方不是因為「數學比較好算」才被選中的,它背後有一個關於誤差分布的假設。這個假設長什麼樣、換一種假設會導出什麼樣的損失,是第 5 章的正題;本章只要記住平方是一個選擇,不是唯一解。

把所有樁的平方殘差匯總起來,就得到最小平方損失(least-squares loss):

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

逐項拆解:\(I\) 是樁點總數;\(\sum_{i=1}^{I}\) 表示把第 1 根到第 \(I\) 根全部加起來;括號裡是第 \(i\) 根的殘差;平方之後求和;最後乘上 \(\frac{1}{I}\) 取平均。\(\mathcal{L}\) 是這條式子的名字,讀作 loss。

那個 \(\frac{1}{I}\) 值得單獨說一句。不放它、直接用總和,也是完全正當的寫法,原書就是這樣寫的;兩種寫法只差一個固定的正倍數,讓損失最小的那組參數完全相同。差別在於數字本身的可比性:如果你今天量 14 根樁、明天量 40 根,用總和的話明天的損失一定比較大,但那只反映樁變多了,不代表配得比較差。取平均之後,損失的意思變成「平均每根樁差多少的平方」,跨批次可以直接比。本站全部採用平均口徑,第 6 章談批次、第 8 章比較訓練與測試誤差時都要靠這個。

現在來看最關鍵、也最容易搞錯的一點:\(\mathcal{L}\) 是誰的函數?

看那條式子。\(x_i\) 和 \(y_i\) 都是你量回來的固定數字,不會變;\(I\) 也不會變。整條式子裡唯一還能動的東西是 \(\boldsymbol\phi\)。所以損失是參數的函數,不是輸入的函數。這句話聽起來像廢話,但它是本章最常被讀錯的地方——很多人下意識覺得損失跟著資料跑,於是在腦子裡把損失圖畫成了資料的散布圖。不是的。損失圖的座標軸是參數。

還有一組詞要分清楚,不然讀別的教材會打架。嚴格說,損失函數(loss function)是三者裡最窄的一個,指的是跟單一資料點對應的那一項——本站把第 \(i\) 根樁的那一項記作 \(\ell_i = \bigl(f[x_i, \boldsymbol\phi] - y_i\bigr)^2\)。成本函數(cost function)通常指的是把整批資料的那些項匯總起來、實際拿去最小化的那一個,也就是上面那條 \(\mathcal{L}\)。不過被最小化的那個量不一定只由資料項堆成:想壓住參數不要長太大的時候,你會另外加一項只看參數本身、跟哪一根樁都沒關係的懲罰進去,它照樣算在成本函數裡。這件事第 9 章會專門處理。目標函數(objective function)最寬鬆,指任何一個你要最佳化的量,連「越大越好」的量也算。三個詞的範圍由窄到寬一層包一層,實務上前兩個常被混用(本課前面把 \(\mathcal{L}\) 直接叫「損失」,就是這種通行的寬鬆用法),看到時以上下文為準。

用程式把三個候選成員的損失算出來:

PYTHON
import numpy as np

rng = np.random.default_rng(0)


def line(t):                       # 真實界線(測量員看不到)
    return 2.40 + 0.28 * t + 0.022 * t**2 - 0.0018 * t**3


# 14 根樁點:x=沿基線的橫距,y=量到的縱距(皆為公尺)
x = np.linspace(-6.0, 6.0, 14)
y = line(x) + rng.normal(0, 0.12, x.shape)


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


def loss_mean(phi):                # 平均口徑(本站統一採用)
    return float(np.mean((f(x, phi) - y) ** 2))


def loss_sum(phi):                 # 求和口徑
    return float(np.sum((f(x, phi) - y) ** 2))


for name, phi in (("A", (2.00, 0.00)),
                  ("B", (2.40, 0.28)),
                  ("C", (3.20, 0.45))):
    p = np.array(phi)
    r = f(x, p) - y
    print(f"成員 {name}  平均={loss_mean(p):.4f}"
          f"  求和={loss_sum(p):.4f}"
          f"  最大殘差={np.max(np.abs(r)):.3f}"
          f"  正殘差 {int((r > 0).sum())} 根")

pb = np.array((2.40, 0.28))
print("求和 ÷ 平均 =", round(loss_sum(pb) / loss_mean(pb), 6))

實跑輸出:成員 A 的平均口徑損失是 1.2229、求和口徑是 17.1200,最大殘差 2.457 公尺,14 根裡有 5 根的殘差為正;成員 B 是 0.19732.7619,最大殘差 1.196,正殘差 2 根;成員 C 是 1.053314.7464,最大殘差 1.611,正殘差 10 根。最後一行印出 14.0,正是樁點數——這就是那個固定倍數。

注意兩件事。第一,三個成員的排名在兩種口徑下完全一樣,這是「差一個正倍數不影響誰最小」的直接證據。第二,成員 A 與成員 C 的損失差不多(1.22291.0533),但它們錯的方式相反:A 的線太平,多數樁點落在線的一邊;C 的線太陡,正殘差有 10 根。同一個損失數字底下可以藏著很不一樣的毛病,這是單一數字必然要付的代價。

最後給挑選這個動作一個記號:

$$ \hat{\boldsymbol\phi} \;=\; \operatorname*{arg\,min}_{\boldsymbol\phi} \; \mathcal{L}[\boldsymbol\phi] $$

逐項拆解:\(\operatorname*{arg\,min}\) 讀作 argmin,意思是「讓後面那個量最小的引數」。請特別注意它取出來的是位置,不是最小值本身——如果你要的是那個最小的損失數字,記號是 \(\min_{\boldsymbol\phi} \mathcal{L}[\boldsymbol\phi]\),兩者不是同一個東西。\(\boldsymbol\phi\) 底下寫在 argmin 下方,表示我們是對參數取極小、不是對別的東西取。頭上的尖帽子 \(\hat{\boldsymbol\phi}\) 表示「被挑中的那一組」,跟任意一組 \(\boldsymbol\phi\) 區分開。

四、損失曲面:走下坡走的到底是哪個空間

現在把損失畫出來看。我們的模型只有兩個參數,所以參數平面是二維的;每一個點對應一個損失值,於是損失是這個平面上的一張曲面——這就是損失曲面(loss surface)。

畫三維曲面不好讀,實務上更常用的是把它從正上方壓平,畫成等高線圖(contour plot):同一圈上的每個點損失相同,越往內圈損失越低。這跟地形圖的等高線是同一套讀法,只是這裡的「地形」不是地面,兩個座標軸都是參數

再強調一次上一小節的重點,因為圖看錯的代價很高:這張圖上完全沒有任何一根樁點。樁點資料早就被吃進損失的計算裡了,它們決定了曲面長什麼形狀,但它們自己不出現在圖上。

於是訓練變成一個很具體的動作:在這張等高線圖上,從某處出發,走到最低的那一圈。 標準的走法有四個動作:

  1. 隨便挑一個起點。 沒有更好的辦法時就用亂數,一組參數就是圖上的一個位置。
  2. 量當地的斜率。 對每個參數各算一次偏導數,得到「往這個參數的方向動一點點,損失會變多還是變少、變多快」。兩個偏導數合起來就是一個向量,它指向損失上升最快的方向。
  3. 往反方向走一步。 上升最快的反方向就是下降最快的方向,沿著它移動一小段。
  4. 回到第 2 步,重複。

停止條件是斜率變平:當那個向量的長度接近零,代表你四面八方都走不下去了,就停。

這四個動作合起來叫梯度下降(gradient descent)。這裡刻意留了一個洞沒補:每一步該走多長? 走太小要走一輩子,走太大會直接跨過谷底、甚至越走越高。這個步長怎麼選是第 6 章整章的題目,本章一律用一個固定的小數字,不深究。

PYTHON
import numpy as np

rng = np.random.default_rng(0)


def line(t):
    return 2.40 + 0.28 * t + 0.022 * t**2 - 0.0018 * t**3


x = np.linspace(-6.0, 6.0, 14)
y = line(x) + rng.normal(0, 0.12, x.shape)


def loss(phi):
    return float(np.mean((phi[0] + phi[1] * x - y) ** 2))


def grad(phi):                     # 損失對兩個參數的偏導數
    r = phi[0] + phi[1] * x - y
    return np.array([2 * np.mean(r), 2 * np.mean(r * x)])


phi = np.array([1.20, 0.60])       # 隨機起點
step = 0.05                        # 步長:怎麼選是第 6 章的事
for k in range(1, 201):
    g = grad(phi)
    phi = phi - step * g
    if k in (1, 5, 20, 60, 200):
        print(f"第 {k:3d} 步  phi=({phi[0]:.4f}, {phi[1]:.4f})"
              f"  L={loss(phi):.5f}"
              f"  斜率長度={np.linalg.norm(g):.5f}")

# 閉式解:此處橫距已以起算樁為原點,平均為 0
b = float(np.mean(x * y) / np.mean(x**2))
a = float(np.mean(y))
ph = np.array([a, b])
print(f"閉式解 phi=({a:.4f}, {b:.4f})  L={loss(ph):.5f}")

實跑輸出:第 1 步走到 (1.3485, 0.0769),損失 2.14853,斜率長度 10.87509;第 5 步 (1.8082, 0.2190),損失 0.83892;第 20 步 (2.5045, 0.2222),損失 0.10238;第 60 步 (2.6824, 0.2222),損失 0.06978,斜率長度已經掉到 0.00593;第 200 步停在 (2.6851, 0.2222),斜率長度印出來是 0.00000。最後一行的閉式解是 (2.6851, 0.2222),損失 0.06978——跟走下坡走到的位置一模一樣。

從這串數字裡挖三件事出來。

第一,最小的損失不是零。 0.06978 是這一族能達到的下限,再怎麼調參數都降不下去。原因很簡單:樁點根本沒有排成一條直線。碰到這種情況,「再多訓練一下」是沒有用的,你要換的是族——那是第五小節的主題。

第二,兩個參數收斂的速度差很多。 第 1 步就把 \(\phi_1\) 從 0.60 修到 0.0769、第 5 步已經接近終值 0.2222 了;但 \(\phi_0\) 從 1.35 爬到 2.68 花了六十步。看等高線圖就懂了:這些圈是又扁又長的橢圓,沿短軸方向損失變化很劇烈、一步就到位,沿長軸方向損失變化很緩、只能慢慢磨。作業二會請你把這個「扁」量成一個具體的倍數。這種「有些方向很陡、有些方向很緩」的地形有個名字,叫條件不良(ill-conditioned);它直接決定訓練要跑多久,也是第 6 章要引入動量的原因。

第三,也是本節最該記住的一點:這個問題其實根本不需要走下坡。 線性迴歸有閉式解(closed-form solution)——把損失對兩個參數各偏微分一次、令兩條式子都等於零、解聯立,就能一步直接寫出答案。程式最後兩行做的就是這件事,結果分毫不差。

那為什麼還要教走下坡?兩個理由,缺一不可。一是可推廣。 閉式解之所以存在,是因為這一族的損失剛好是參數的二次式,偏微分之後是線性方程組,解得開。把模型換成一個有幾十層的神經網路,偏微分之後是一坨完全解不開的東西,閉式解不存在——不是難算,是根本沒有。而走下坡不需要解方程組,它只需要「當地的斜率」,而斜率永遠算得出來。二是規模。 就算某個大模型碰巧有閉式解,解聯立方程組的成本會隨參數個數暴增;參數上億時,這條路在計算上也是死的。所以本課從第 6 章起,全部押在迭代法上。

還有一句話得補在這裡,免得留下錯誤印象:損失一直在降,不等於你已經到了全域最低點。 我們這個例子之所以停下來就是答案,是因為線性迴歸的損失曲面只有一個谷底,這是它特別善良的性質。深度網路的損失曲面不長這樣,走下坡停住的地方是不是全域最低、甚至「是不是全域最低」這件事重不重要,都要等到第 20 章才談得清楚。

五、訓練完之後:泛化,以及兩種相反的失敗

上一節我們把損失壓到了這一族的下限。現在問一個更難的問題:這樣就算好了嗎?

回到現場。你用 14 根樁點配出了一條線,然後把它交出去。三個月後有人拿著這份成果圖到現場放樣,在你當初沒有量過的位置釘樁,結果差了半公尺。你的損失明明很小,怎麼會這樣?

因為損失量的是「這條線跟那 14 根樁配得多好」,而不是「這條線跟整條界線配得多好」。模型在沒見過的位置上表現得如何,這個能力叫泛化(generalization)。它跟訓練損失是兩回事,而且訓練損失小完全不保證泛化好。

要量它,只有一個辦法:留一批完全沒有參與調參數的資料。 在我們的例子裡就是另外去釘一批檢核樁,量完之後鎖起來,訓練的時候一眼都不看,等模型定案了才拿出來對。這一批叫測試資料。

泛化會失敗,來源有兩個,而且性質完全不同。

第一個來源是資料。 你的 14 根樁點如果全部集中在界線的東半段,那西半段長什麼樣你根本沒有資訊,模型當然配不對;這不是模型的錯,是資料沒有代表性。或者你的量測誤差偏大,那麼模型會把誤差也一起學進去。這個來源要靠多量、量得更有代表性來解決,跟模型選什麼沒關係。

第二個來源是模型族本身。 一族函數能表示多少種不同的形狀,這個能力叫表達力(expressivity)。表達力是有上下限的,而上下兩端各有一種失敗方式。

表達力不夠,叫低度擬合(underfitting)。 界線實際上是彎的,你卻只准自己用一條直線,那麼不管樁點量得多準、量得多密,都配不上去。這正是上一節那個 0.06978:它不是沒訓練好,它是這一族的天花板。

表達力太夠、資料又不夠多,叫過度擬合(overfitting)。 族大到可以穿過每一根樁點,模型就會連量測誤差一起照單全收。它學到的不是界線的形狀,是「這一批樁點恰好被量成什麼樣子」——而那些誤差在下一批樁點上不會重演。

這兩種失敗的成因方向剛好相反,症狀卻一模一樣:在沒見過的位置上表現很差。 所以光看檢核誤差的數字,你分不出是哪一種。要分辨,必須把兩條曲線並排:訓練資料上的損失,跟檢核資料上的損失,一起隨著族的大小畫出來。

底下這段程式把族逐步放大——從一次多項式一路加到十三次——看兩條曲線怎麼分家:

PYTHON
import numpy as np

rng = np.random.default_rng(0)


def line(t):                       # 真實界線(測量員看不到)
    return 2.40 + 0.28 * t + 0.022 * t**2 - 0.0018 * t**3


x = np.linspace(-6.0, 6.0, 14)     # 14 根界址樁:拿來調參數
y = line(x) + rng.normal(0, 0.12, x.shape)
xc = np.linspace(-5.7, 5.7, 30)    # 30 根檢核樁:完全不參與
yc = line(xc) + rng.normal(0, 0.12, xc.shape)


def fit(deg):                      # 階數越高,模型族越大
    # 除以 6 只是把橫距縮到 ±1,避免高次方溢位
    A = np.vander(x / 6.0, deg + 1)
    c, *_ = np.linalg.lstsq(A, y, rcond=None)
    return c


def mse(c, t, v):
    pred = np.vander(t / 6.0, len(c)) @ c
    return float(np.mean((pred - v) ** 2))


print("階數   樁點損失    檢核損失")
for deg in (1, 2, 3, 5, 9, 13):
    c = fit(deg)
    tr, ck = mse(c, x, y), mse(c, xc, yc)
    print(f"{deg:3d}   {tr:9.5f}   {ck:9.5f}")

實跑輸出(左為樁點損失、右為檢核損失):階數 1 是 0.069780.07552;階數 2 是 0.010500.01932;階數 3 是 0.007840.01621;階數 5 是 0.006670.02045;階數 9 是 0.000100.03681;階數 13 是 0.000000.16002

這組數字有幾個地方值得停下來看。

樁點損失是單調下降的,一路降到 0.00000。這很合理:十三次多項式有十四個參數,剛好足夠穿過十四根樁點,一根不差。但檢核損失在階數 3 觸底(0.01621),之後掉頭往上,到階數 13 時已經是最低點的十倍。族變大只保證前者變好,對後者毫無保證。 這就是為什麼「訓練損失很低」永遠不能單獨當成一個好消息。

左端與右端各是一種失敗。階數 1 兩邊都差(0.069780.07552),這是低度擬合的典型長相——模型連自己看過的樁點都配不好,談不上泛化。階數 13 是另一個極端:樁點配到完美,檢核樁錯得最離譜。至於階數 3,兩個數字都小、而且彼此接近,這是我們要的。

還有一個細節值得誠實地指出來:階數 3 的檢核損失(0.01621)比它的樁點損失(0.00784)大了一倍,但階數 1 的兩個數字幾乎一樣。這不代表哪裡算錯了。低度擬合的時候,誤差主要來自「模型形狀根本不對」,這個毛病在哪一批樁點上都一樣嚴重,所以兩個數字自然貼在一起;一旦模型有能力去記住個別樁點,兩邊才會開始拉開。兩條曲線的間距本身就是一個訊號,第 8 章會把它變成一套可用的診斷方法。

最後是一條紀律。你可能已經想到:既然可以掃一遍階數、挑檢核損失最低的那個,那不就解決了嗎?可以,但要付出代價——檢核資料一旦被你拿來做選擇,它就不再是「沒見過」的了。 你等於用它挑了一個對它特別友善的階數,於是它報出來的那個 0.01621 會比模型真正上線後的誤差樂觀。這件事有多嚴重、標準做法是什麼,是第 8 章的正題;作業三會請你先親手量一次這個偏差。

比喻: 檢核樁的價值全部來自「它沒有參與調整」這件事。你如果一邊看著檢核樁的結果、一邊回頭改成果圖,改到檢核樁也對得漂亮為止,那它就已經變成第二批訓練樁了,不再有檢核的功能。這個比喻在一處失準:地面上的檢核樁失效了還能補救——再花一天外業,換個位置重新釘一批新的就好。機器學習沒有這種等價的補救辦法:資料通常是既有的、有限的,用掉就是用掉了,你沒辦法向現實再多要一批同樣獨立的樣本。所以測試資料要當成消耗品來管理,而不是當成隨時可查的工具。

六、方向倒過來寫:判別模型與生成模型

最後看一個換方向的寫法,它會改變不少事情。

我們一路寫的都是這個方向:

$$ y \;=\; f[x, \boldsymbol\phi] $$

也就是把「你量得到的東西」擺在右邊當輸入,把「你想知道的答案」擺在左邊。這種寫法叫判別模型(discriminative model)。

另一個方向是把等號兩邊調換,改成寫「答案會產生什麼量測」:

$$ x \;=\; g[y, \boldsymbol\theta] $$

這叫生成模型(generative model)。\(g[\cdot]\) 是另一條式子,\(\boldsymbol\theta\)(讀作 theta)是它的參數。注意這裡的 \(y\) 從輸出變成了輸入。有些中文文獻把這兩個名字寫成生成式模型與判別式模型,指的是同一件事,本課一律用前面那種寫法。第 1 章提過那種「可以造出新樣本」的生成模型也站在這個方向上——同樣是在描述資料怎麼被產生出來,只是那裡等號右邊擺的不是答案,而是一組更抽象的潛在變數。

換方向不是為了好看,它常常更貼近事情真正發生的順序。以界線為例:世界上先有那條界線,測量員才因為它而量到那些縱距——因果是從答案流向量測的,不是反過來。生成模型照著這個順序走,判別模型則是逆著因果寫。

代價出在推論。生成模型直接算的是「給定答案會量到什麼」,可是你真正要的是反過來:手上有量測,要回推答案。所以推論必須反解 \(g\),而反解有兩種難法。一種是解不出封閉形式,只能用數值方法一次次逼近,每做一次推論都要跑一輪最佳化。另一種更麻煩:答案不唯一想像你的量測只落在界線的東段,那麼西段要怎麼走,有無限多種可能都能產生一模一樣的量測——這時候反解不是難,是問題本身沒有唯一解。

生成模型的好處也在同一個地方。因為你是在描述「事情怎麼發生」,所以任何你已經知道的規律都可以直接寫進式子的形狀裡。像是「相鄰兩塊地的界線必須是同一條」「一塊地的界線一定首尾相接」這類約束,寫進 \(g\) 裡是自然的;而判別模型那一側沒有這個位置可以放,你只能希望它從資料裡自己看出來。

那為什麼現在的主流仍然是判別模型?因為兩邊拿來交換的東西不對等。生成模型付出的是推論成本與反解風險,換回的是「把先驗知識寫進去」的機會;判別模型放棄了寫進先驗知識,換來的是直接針對你真正要的那個方向去最佳化,而且模型族可以任意大、資料可以任意多。當資料的量足以把「量測到答案」這條路本身壓出來時,先驗知識能提供的優勢就相對變小了。所以判別模型主導不是因為它比較強,而是因為在目前的資料規模下,它換到的那一側比較划算。

要提醒的是,這裡沒有誰比誰更強的結論,只有方向不同、代價不同。第 14 章之後那一整個部份談的全是生成模型的做法,因為到了「沒有標籤、要學資料本身長什麼樣」的問題上,判別模型那個方向根本無從寫起。同一條式子,方向選錯了不是效率問題,是連題目都對不上。

§03原書對照

原書第 2 章只有八頁,是全書最短的章節之一。它幾乎不鋪陳,直接把整套監督式學習的框架壓在一個一維線性迴歸的例子上跑完一輪。本課把同一批概念攤得更開,因此原書有幾處緊湊的安排值得進階讀者翻回去看。

第一處是開場那一頁的密度。原書 p.17 把三件事接連擺齊:模型只是一條形式固定的數學式子、把輸入代進去算出輸出這個動作叫推論、式子裡的參數決定這一族關係裡的哪一個成員。三個定義擠在同一段裡,第一次讀很容易整段滑過去,值得放慢速度重讀一次。

第二處是記號的嚴謹度。原書 p.18 在頁邊直接把讀者指向附錄 A 的 argmin 條目,並用一個腳註承認:損失其實也依賴訓練資料,完整的寫法應該把資料一起寫進去,只是那樣太累贅所以省略了。想把符號讀到嚴謹的人,那個腳註值得專程翻一次。

第三處是三組配圖的分工。原書 pp.19–21 用連續三組圖把「族、成員、損失」串成一條線:先畫同一條式子在不同參數下的幾條直線,再把一批訓練點配上三條不同的線並各自標出損失數值,最後把同一個損失同時畫成三維曲面與俯視的等高線熱圖。同一件事給兩種視覺,是那幾頁的用心所在,特別是把同一個損失同時畫成曲面與熱圖的那一組。

第四處藏在腳註裡。原書 p.22 談訓練只用了一小段正文,真正要緊的話寫在該頁的腳註:線性迴歸其實解得出閉式解,之所以還是示範走下坡的做法,是為了推廣到沒有閉式解、而且參數多到無法逐一試遍的模型。這句話解釋了本章為什麼要繞遠路。

第五處是測試那一小節。原書同樣在 p.22 用不到半頁把泛化、低度擬合與過度擬合一次交代完,並把泛化的成敗拆成兩個來源:訓練資料夠不夠有代表性與完整性,以及模型的表達力夠不夠。那半頁其實是第 8 章的縮影;同一頁的章末摘要還交代了第 3 到 9 章各自要接手這套框架的哪一塊,是一份很短的路線圖。

第六處是章末的註記區。原書 p.23 專門區分了損失函數、成本函數與目標函數三個詞的嚴謹用法,並指向第 9 章說明成本函數為什麼可以包含與單筆資料無關的項。不同教科書用語打架的時候,這一段可以拿來當仲裁。

第七處是判別式與生成式的對照。原書 pp.23–24 用一則跨頁的註記討論「把式子的方向倒過來寫」會發生什麼事:推論得靠反解,而反解可能很難;好處則是能把資料如何產生的既有知識寫進模型。作者最後給了一個判斷,說明現代機器學習為什麼仍由判別式取徑主導。

最後是習題。原書 p.24 有三題,依序要你算出損失對兩個參數的偏導數、令導數為零推出閉式解、再把線性迴歸改寫成生成式並比較兩種寫法在同一批資料上的預測是否相同。第三題標了星號;依原書 p.16 交代的體例,星號代表這一題的解答放在作者的網站上,不是難度標記。想動筆的讀者可以直接做那三題。原書第 2 章對應印刷頁 pp.17–24。

§04作業和解答

作業一:把一個放樣任務寫成規格,並估出兩種成本

假設你接到一件複丈委託:沿一條基線每 1.5 公尺選一個位置釘樁,一共 41 根。每根樁記錄三個數字當作輸入——橫距、地面高程、以及該處基線的方位偏角;要預測的是一個數字:從基線量到界線的縱距。模型族取 \(f[\mathbf{x},\boldsymbol\phi] = \phi_0 + \phi_1 x_1 + \phi_2 x_2 + \phi_3 x_3\)。

(a)樣本數 \(I\) 與輸入維度各是多少?請說明這兩個數字為什麼不能混為一談。 (b)這一族有幾個參數? (c)如果用格點窮舉,每個參數各試 20 個值,總共要算幾次損失?每次損失又要跑幾筆資料? (d)換成一個有 30 個參數的模型,同樣每個參數 20 個格點,格點總數是多少量級?這個數字告訴你什麼?

解答 SOLUTION

(a)樣本數 \(I = 41\),輸入維度 \(= 3\)。前者是「你量了幾根樁」,後者是「每根樁記了幾個數字」。它們可以各自獨立變動:多釘 100 根樁只會讓 \(I\) 變大,不影響維度;每根樁多記一項量測只會讓維度變大,不影響 \(I\)。混為一談的後果很實際——資料不夠時要補的是樣本,特徵不足時要補的是維度,兩者的外業成本完全不同。

(b)4 個:\(\phi_0\) 一個截距項,加上三個輸入各配一個係數。

(c)\(20^4 = 160{,}000\) 次損失計算。每次損失都要把 41 筆資料各跑一遍,所以模型評估的總次數是 \(160{,}000 \times 41 = 6{,}560{,}000\) 次。這個量級筆記型電腦一秒內就能算完,所以「參數只有兩三個時窮舉是可行的」這句話是真的。

(d)\(20^{30} \approx 1.07 \times 10^{39}\)。(用 Python 的 20**30 可直接驗證,得 1073741824000000000000000000000000000000。)這個數字大到沒有物理意義——就算每秒能算一兆次,也要跑遠超過宇宙年齡的時間。而 30 個參數在深度學習裡是極小的模型。這說明格點窮舉不是「比較慢的正確做法」,它在真實規模下根本不是一個做法;能用的只有「從一點出發、沿著斜率走」這一類方法。

作業二:證明線性迴歸的損失只有一個谷底,並算出等高線的形狀

模型族為 \(f[x,\boldsymbol\phi] = \phi_0 + \phi_1 x\),損失採本章第三小節的平均平方損失。

(a)把 \(\mathcal{L}[\boldsymbol\phi]\) 展開成 \(\phi_0\) 與 \(\phi_1\) 的多項式,指出它的最高次數,並說明為什麼這保證了損失曲面不會有第二個谷底。 (b)當輸入已經以平均為零的方式擺放(本章的樁點正是如此,起算點取在基線中央)時,證明損失可以寫成 \(\mathcal{L} = (\phi_0 - \hat\phi_0)^2 + \overline{x^2}\,(\phi_1 - \hat\phi_1)^2 + \mathcal{L}_{\min}\),其中 \(\overline{x^2}\) 是 \(x_i^2\) 的平均。 (c)由(b)推出等高線是橢圓,並算出本章那 14 根樁點所對應的橢圓長短軸比。

解答 SOLUTION

(a)把括號展開後對 \(i\) 求平均:

$$ \mathcal{L} = \phi_0^2 + 2\phi_0\phi_1\overline{x} + \phi_1^2\overline{x^2} - 2\phi_0\overline{y} - 2\phi_1\overline{xy} + \overline{y^2} $$

其中上橫線一律表示對 14 根樁點取平均。最高次數是 2,而且二次項 \(\phi_0^2 + 2\phi_0\phi_1\overline{x} + \phi_1^2\overline{x^2}\) 恰好等於 \(\overline{(\phi_0 + \phi_1 x)^2}\),是平方的平均,所以永遠不為負;而且只要樁點的橫距不是全部相同,那麼除了 \(\phi_0 = \phi_1 = 0\) 這一點以外,它都嚴格為正。二次項嚴格為正的二次函數只有一個極小點,沒有第二個谷底,也沒有可以卡住的平坦區。這正是本章第四小節說線性迴歸的損失曲面「特別善良」的意思。

(b)當 \(\overline{x} = 0\) 時,交叉項 \(2\phi_0\phi_1\overline{x}\) 消失,式子拆成兩個互不干擾的部分:

$$ \mathcal{L} = \bigl(\phi_0^2 - 2\phi_0\overline{y}\bigr) + \bigl(\phi_1^2\overline{x^2} - 2\phi_1\overline{xy}\bigr) + \overline{y^2} $$

各自配方即得 \(\mathcal{L} = (\phi_0 - \overline{y})^2 + \overline{x^2}\bigl(\phi_1 - \overline{xy}/\overline{x^2}\bigr)^2 + \mathcal{L}_{\min}\)。對照可知閉式解就是 \(\hat\phi_0 = \overline{y}\)、\(\hat\phi_1 = \overline{xy}/\overline{x^2}\),這正是第四小節程式最後兩行寫的東西。

(c)令 \(\mathcal{L} = \mathcal{L}_{\min} + c\),得 \((\phi_0-\hat\phi_0)^2 + \overline{x^2}(\phi_1-\hat\phi_1)^2 = c\),這是一個中心在閉式解、兩軸與座標軸平行的橢圓。它在 \(\phi_0\) 方向的半軸長是 \(\sqrt{c}\),在 \(\phi_1\) 方向是 \(\sqrt{c/\overline{x^2}}\),所以長短軸比等於 \(\sqrt{\overline{x^2}}\),與 \(c\) 無關——每一圈都一樣扁。

本章的 14 根樁點橫距是 \(-6\) 到 \(6\) 的等距分布,實跑得 \(\overline{x} = 0\)(浮點誤差量級 \(10^{-16}\))、\(\overline{x^2} = 13.846154\),所以比值是 \(\sqrt{13.846154} = 3.7210\):橢圓在 \(\phi_0\) 方向比在 \(\phi_1\) 方向寬 3.72 倍。取 \(c = 0.5\) 實際驗算,沿 \(\phi_0\) 走 \(\sqrt{0.5} = 0.7071\)、或沿 \(\phi_1\) 走 \(\sqrt{0.5/13.846154} = 0.1900\),兩種走法算出來的損失都是 0.56978,正好等於 \(\mathcal{L}_{\min} + 0.5 = 0.06978 + 0.5\)。這個 3.72 倍就是第四小節「\(\phi_1\) 五步到位、\(\phi_0\) 六十步才到」的直接原因。

作業三:量出「用檢核樁挑模型」會樂觀多少

本章第五小節掃了六個階數,挑出檢核損失最低的那一個。這一題請你量出這個做法的代價。

(a)先說清楚問題出在哪:為什麼那個被挑中的檢核損失,不能直接拿來當「這個模型上線後的預期誤差」? (b)把第五小節的程式改成掃 1 到 13 全部十三個階數,並額外造第三批樁點 xt = np.linspace(-5.85, 5.85, 30)(雜訊設定與前兩批相同,接在檢核樁之後抽)。用檢核樁挑出最好的階數,然後比較它在檢核樁與第三批樁點上的損失。 (c)這個偏差是往哪個方向的?如果候選階數從 13 個增加到 130 個,你預期偏差會變大還是變小?

解答 SOLUTION

(a)因為檢核樁已經參與了「挑階數」這個決定。掃描時每一個階數的檢核損失都帶著自己的隨機起伏,而你取的是這十三個數字裡的最小值——最小值天生就偏向「那些剛好被這批檢核樁優待」的候選。換句話說,你挑中的不只是「配得好的階數」,也包含了「運氣好的階數」。一旦拿它去面對真正沒見過的資料,那份運氣不會跟著過去。

(b)實跑結果:檢核損失在階數 3 最低,值為 0.01621。同一個階數 3 的模型在第三批樁點上的損失是 0.02006,比檢核樁報出來的高了約 24%。完整的十三個階數在三批資料上的損失如下(依序為樁點/檢核/第三批):階數 1 是 0.069780.075520.09088;階數 2 是 0.010500.019320.02504;階數 3 是 0.007840.016210.02006;階數 4 是 0.006760.019760.02431;階數 7 是 0.005680.024920.02550;階數 13 是 0.000000.160020.14216。十三個階數裡有十個,第三批的損失比檢核樁報出來的高——包括被挑中的階數 3 在內。

(c)偏差是樂觀方向的:檢核樁報出來的數字比真實表現好。候選變多會讓偏差變大,因為取最小值這個動作是在更多次的隨機起伏裡挑最幸運的一個,候選越多、最幸運的那個就越幸運。這件事的一般化說法、以及標準的三分法(訓練/驗證/測試)該怎麼安排,是第 8 章的正題。這裡先記住一條可以立刻用的紀律:只要一批資料被拿來做過任何選擇,它報出來的數字就已經樂觀了。

§05參考資料