模組 08 · 第 3 課

手寫反向傳播

寫一個幾十行的類,讓每個數都記住自己是怎麼算出來的,就能從損失出發,用鏈式法則把梯度一路傳回每個參數。用數值求導核對它,再用它訓練一個小網路學會異或。

  • 約 60 分鐘
  • 難度:進階
  • 實測:2026-09-14 純 Python,固定隨機種子

程式碼和執行結果保留原樣(簡體中文),與實際執行時完全一致。

上一課的梯度,是對著一個具體的公式手推出來的。可神經網路是一層套一層的複雜函式,參數成百上千,手推每一個的導數是不可能的。

這一課寫一個小工具,讓計算機自動算出所有參數的梯度。它的名字叫反向傳播(backpropagation)。PyTorch 最核心的功能就是它,這一課用純 Python 寫一個只有幾十行的版本,寫完你就知道 loss.backward() 這一行背後在做什麼。

鏈式法則

先看一個簡單的例子。假設 y = 3xz = y²。問:x 變一點點,z 變多少?

分兩步想:x 變一點點,y 變 3 倍那麼多(yx 的導數是 3);y 變一點點,z2y 倍那麼多(zy 的導數是 2y)。所以 x 變一點點,z3 × 2y 倍。

z 对 x 的导数 = (z 对 y 的导数) × (y 对 x 的导数)

這就是鏈式法則:一串運算的導數,等於每一步的導數乘起來。每一步的導數只和這一步本身有關,叫區域性導數。

神經網路就是一長串運算:乘、加、再經過一個非線性函式,一層一層往下,最後算出損失。只要知道每一步的區域性導數,就能用鏈式法則,從損失出發一步一步往回乘,得到損失對每個參數的導數。

讓每個數都記賬

辦法是:每做一次運算,就把"結果對每個輸入的區域性導數"記下來。寫一個類 Num,它除了儲存數值,還儲存一個列表:[(输入的数, 局部导数), ...]

class Num:
    """一个会记账的数:记下自己的值、梯度,以及"我对每个上游数的局部导数"。"""

    def __init__(self, value, parents=()):
        self.value = value
        self.grad = 0.0
        self.parents = parents  # [(上游的 Num, 局部导数), ...]

    def __add__(self, other):
        other = other if isinstance(other, Num) else Num(other)
        # a + b 对 a 的导数是 1,对 b 的导数也是 1
        return Num(self.value + other.value, [(self, 1.0), (other, 1.0)])

    def __mul__(self, other):
        other = other if isinstance(other, Num) else Num(other)
        # a × b 对 a 的导数是 b,对 b 的导数是 a
        return Num(self.value * other.value, [(self, other.value), (other, self.value)])

    def __pow__(self, n):
        # x 的 n 次方,导数是 n × x 的 (n-1) 次方
        return Num(self.value ** n, [(self, n * self.value ** (n - 1))])

    def tanh(self):
        t = math.tanh(self.value)
        # tanh 的导数是 1 - tanh²
        return Num(t, [(self, 1 - t * t)])

每種運算都只需要知道自己的區域性導數:

  • 加法 a + ba 變一點,結果就變一點,所以對 a、對 b 的區域性導數都是 1。
  • 乘法 a × ba 變一點,結果變 b 倍,所以對 a 的區域性導數是 b,對 b 的是 a
  • 乘方 xⁿ:區域性導數是 n × xⁿ⁻¹,高中數學學過。
  • tanh:一個把任意數壓到 -1 到 1 之間的函式,神經網路裡常用,它的導數是 1 - tanh²

用 Python 的運算子過載(__add____mul__ 這些),a * b + c 這樣的普通寫法就會自動產生 Num 物件,並悄悄記下了整個計算過程。

(完整的程式碼裡還有減法和讓普通數字也能參與運算的幾行,見 code/08-neural-nets/backprop.py。)

反向傳播

計算完成後,每個 Num 都知道自己是從哪幾個數算出來的,整個計算過程形成了一張圖。反向傳播就是從最終的結果(損失)出發,沿著這張圖往回走:

    def backward(self):
        """从这个数(通常是损失)出发,把梯度传给所有上游的数。"""
        order, seen = [], set()

        def visit(node):  # 先访问完所有上游,再把自己放进列表:得到一个"从上游到下游"的顺序
            if id(node) not in seen:
                seen.add(id(node))
                for parent, _ in node.parents:
                    visit(parent)
                order.append(node)

        visit(self)
        self.grad = 1.0  # 损失对自己的导数是 1
        for node in reversed(order):  # 从下游往上游,链式法则:上游梯度 += 下游梯度 × 局部导数
            for parent, local in node.parents:
                parent.grad += node.grad * local

分兩步:

  1. 排好順序visit 保證一個數總是排在所有算出它的數之後。反過來遍歷,就是從損失往回走,並且處理到某個數時,所有依賴它的數都已經處理完了,它的梯度已經累加齊了。
  2. 傳遞梯度。損失對自己的導數是 1。往回走的每一步,都用鏈式法則:上游的梯度加上"下游的梯度 × 區域性導數"。

為什麼是"加上"(+=),而不是直接賦值?因為一個數可能被用了好幾次。比如 y = x * xx 同時是乘法的兩個輸入,兩條路徑傳回來的梯度要加在一起。

驗證:和數值求導比一比

寫完先測試。用一個小算式 f = (a × b + c)²a=2, b=-3, c=10

a, b, c = Num(2.0), Num(-3.0), Num(10.0)
f = (a * b + c) ** 2
f.backward()
print(f"  f = {f.value}")
print(f"  自动算出的梯度:df/da={a.grad}, df/db={b.grad}, df/dc={c.grad}")

再用上一課的數值辦法(這次用更準確的"左右各挪一點")核對:

def numeric(fn, x, h=1e-6):
    return (fn(x + h) - fn(x - h)) / (2 * h)
== 1. f = (a × b + c)²,a=2, b=-3, c=10
  f = 16.0
  自动算出的梯度:df/da=-24.0, df/db=16.0, df/dc=8.0
  数值求导核对: df/da≈-24.0000, df/db≈16.0000, df/dc≈8.0000

完全一致。手算一下也能驗證:a × b + c = 4f = 4² = 16f(a×b+c) 的導數是 2 × 4 = 8,所以 df/dc = 8df/da = 8 × b = -24df/db = 8 × a = 16

用它搭一個神經網路

有了自動求導,就可以搭神經網路了。

一個神經元做的事情很簡單:把每個輸入乘以一個權重,加起來,再加一個偏置,最後經過 tanh

class Neuron:
    def __init__(self, n_inputs):
        self.w = [Num(random.uniform(-1, 1)) for _ in range(n_inputs)]
        self.b = Num(0.0)

    def __call__(self, xs):
        total = self.b
        for w, x in zip(self.w, xs):
            total = total + w * x
        return total.tanh()

去掉最後的 tanh,它就是第 1 課的那條直線,只是輸入從 1 個變成了多個。tanh 這種非線性函式(也叫啟用函式)少不了:沒有它,很多層直線疊在一起,結果還是一條直線,什麼複雜的規律都學不了。

一排神經元組成一層,兩層疊起來就是一個小網路:2 個輸入 → 4 個隱藏的神經元 → 1 個輸出。數一下參數:隱藏層 4 個神經元,每個有 2 個權重加 1 個偏置,共 12 個;輸出層 1 個神經元,4 個權重加 1 個偏置,共 5 個。一共 17 個參數。

學會異或

異或(XOR):兩個輸入相同時輸出 -1,不同時輸出 1(因為 tanh 的輸出在 -1 到 1 之間,用 -1 和 1 代替通常說的 0 和 1)。

它是一個經典的例子,因為一條直線做不到:在平面上畫出這四個點,你沒法用一條直線把 (0,1)(1,0)(0,0)(1,1) 分開。必須要有隱藏層和非線性函式。

訓練的迴圈和上一課的梯度下降一模一樣,只是算梯度那一步換成了 loss.backward()

data = [([0, 0], -1), ([0, 1], 1), ([1, 0], 1), ([1, 1], -1)]
lr = 0.1
for epoch in range(1, 301):
    loss = Num(0.0)
    for xs, y in data:
        pred = output(hidden(xs))[0]
        loss = loss + (pred - y) ** 2
    for p in params:
        p.grad = 0.0  # 每一轮都要清零,否则梯度会一直累加
    loss.backward()
    for p in params:
        p.value -= lr * p.grad

注意每一輪開始前要把梯度清零。因為 backward 用的是 +=,不清零的話,這一輪的梯度會加在上一輪的梯度上。這是一個非常經典的錯誤,用 PyTorch 時也一樣(下一課的 optimizer.zero_grad())。

== 2. 网络:2 个输入 → 4 个隐藏神经元 → 1 个输出,共 17 个参数
  第   1 轮  损失 4.1022
  第  10 轮  损失 3.7902
  第  50 轮  损失 0.1093
  第 100 轮  损失 0.0346
  第 200 轮  损失 0.0134
  第 300 轮  损失 0.0081
  训练后的预测:
    输入 [0, 0] → -0.965(目标 -1)
    输入 [0, 1] → +0.952(目标 +1)
    输入 [1, 0] → +0.953(目标 +1)
    输入 [1, 1] → -0.951(目标 -1)

一開始損失是 4.1,四個預測幾乎都是錯的。前 10 輪進展很慢,然後突然開始下降,50 輪就降到了 0.1。300 輪後,四個預測都非常接近目標。

一條直線學不會的規律,17 個參數的小網路學會了。整個過程沒有任何人告訴它"異或是什麼",它只是一次次地算損失、算梯度、往梯度的反方向調整參數。

我們寫了什麼

回頭看,這一課的幾十行程式碼已經包含了深度學習框架最核心的東西:

  • 自動求導:每個運算記下區域性導數,反向傳播用鏈式法則把梯度傳回去。
  • 神經元和層:加權求和,加偏置,過啟用函式。
  • 訓練迴圈:前向計算損失,清零梯度,反向傳播,更新參數。

它當然非常慢:每個數都是一個 Python 物件,一個大一點的網路會有幾百萬個這樣的物件。下一課換成 PyTorch,它做的是完全相同的事,只是一次處理一整批數字(張量),並且用最佳化過的底層程式碼計算,快了成千上萬倍。

練習

  1. Num 加一個 relu 方法:輸入大於 0 時原樣輸出,否則輸出 0。它的區域性導數是什麼?寫好後用數值求導核對。
  2. 把訓練迴圈裡清零梯度的那兩行刪掉,重新執行,看看會發生什麼。
  3. 把隱藏層的神經元從 4 個改成 1 個、2 個,還能學會異或嗎?再把隨機種子換幾個試試。

自測

1. 鏈式法則說的是什麼?它和反向傳播是什麼關係?

鏈式法則:一串運算的導數,等於每一步區域性導數的乘積。反向傳播就是系統地運用鏈式法則:從損失出發,沿著計算過程往回走,每經過一步就乘上這一步的區域性導數,最終得到損失對每個參數的導數。

2. 反向傳播時,為什麼用 += 累加梯度,而不是直接賦值?

一個數可能在計算中被用了多次,比如 y = x × x 裡 x 出現了兩次。每一次使用都會有一條路徑把梯度傳回來,這些梯度要加在一起才是完整的導數。

3. 為什麼一條直線學不會異或,加了隱藏層和 tanh 的網路就能學會?

異或的四個點沒法用一條直線分開。多層直線疊在一起,結果仍然是一條直線,所以關鍵在於非線性的啟用函式 tanh:它讓網路能組合出彎曲的邊界,把這四個點分開。

提問與討論

這一課沒看懂的地方,在這裡問。看到別人的問題,也歡迎你來回答。

提問 +3 點,回答別人 +6 點。內容經審核後公開。

正在載入討論…