手寫反向傳播
寫一個幾十行的類,讓每個數都記住自己是怎麼算出來的,就能從損失出發,用鏈式法則把梯度一路傳回每個參數。用數值求導核對它,再用它訓練一個小網路學會異或。
- 約 60 分鐘
- 難度:進階
- 實測:2026-09-14 純 Python,固定隨機種子
程式碼和執行結果保留原樣(簡體中文),與實際執行時完全一致。
上一課的梯度,是對著一個具體的公式手推出來的。可神經網路是一層套一層的複雜函式,參數成百上千,手推每一個的導數是不可能的。
這一課寫一個小工具,讓計算機自動算出所有參數的梯度。它的名字叫反向傳播(backpropagation)。PyTorch 最核心的功能就是它,這一課用純 Python 寫一個只有幾十行的版本,寫完你就知道 loss.backward() 這一行背後在做什麼。
鏈式法則
先看一個簡單的例子。假設 y = 3x,z = y²。問:x 變一點點,z 變多少?
分兩步想:x 變一點點,y 變 3 倍那麼多(y 對 x 的導數是 3);y 變一點點,z 變 2y 倍那麼多(z 對 y 的導數是 2y)。所以 x 變一點點,z 變 3 × 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 + b:a變一點,結果就變一點,所以對a、對b的區域性導數都是 1。 - 乘法
a × b:a變一點,結果變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
分兩步:
- 排好順序。
visit保證一個數總是排在所有算出它的數之後。反過來遍歷,就是從損失往回走,並且處理到某個數時,所有依賴它的數都已經處理完了,它的梯度已經累加齊了。 - 傳遞梯度。損失對自己的導數是 1。往回走的每一步,都用鏈式法則:上游的梯度加上"下游的梯度 × 區域性導數"。
為什麼是"加上"(+=),而不是直接賦值?因為一個數可能被用了好幾次。比如 y = x * x,x 同時是乘法的兩個輸入,兩條路徑傳回來的梯度要加在一起。
驗證:和數值求導比一比
寫完先測試。用一個小算式 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 = 4,f = 4² = 16;f 對 (a×b+c) 的導數是 2 × 4 = 8,所以 df/dc = 8,df/da = 8 × b = -24,df/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,它做的是完全相同的事,只是一次處理一整批數字(張量),並且用最佳化過的底層程式碼計算,快了成千上萬倍。
練習
- 給
Num加一個relu方法:輸入大於 0 時原樣輸出,否則輸出 0。它的區域性導數是什麼?寫好後用數值求導核對。 - 把訓練迴圈裡清零梯度的那兩行刪掉,重新執行,看看會發生什麼。
- 把隱藏層的神經元從 4 個改成 1 個、2 個,還能學會異或嗎?再把隨機種子換幾個試試。
自測
1. 鏈式法則說的是什麼?它和反向傳播是什麼關係?
鏈式法則:一串運算的導數,等於每一步區域性導數的乘積。反向傳播就是系統地運用鏈式法則:從損失出發,沿著計算過程往回走,每經過一步就乘上這一步的區域性導數,最終得到損失對每個參數的導數。
2. 反向傳播時,為什麼用 += 累加梯度,而不是直接賦值?
一個數可能在計算中被用了多次,比如 y = x × x 裡 x 出現了兩次。每一次使用都會有一條路徑把梯度傳回來,這些梯度要加在一起才是完整的導數。
3. 為什麼一條直線學不會異或,加了隱藏層和 tanh 的網路就能學會?
異或的四個點沒法用一條直線分開。多層直線疊在一起,結果仍然是一條直線,所以關鍵在於非線性的啟用函式 tanh:它讓網路能組合出彎曲的邊界,把這四個點分開。
提問與討論
這一課沒看懂的地方,在這裡問。看到別人的問題,也歡迎你來回答。
提問 +3 點,回答別人 +6 點。內容經審核後公開。
正在載入討論…