代碼倉庫:https://github.com/karpathy/nn-zero-to-hero
Andrej Karpathy 是著名深度學習課程 Stanford CS 231n 的作者與主講師,也是 OpenAI 創始人之一,"micrograd" 是他創建的一個小型、教育性質的項目。這個項目實現了一個非常簡化的自動微分和梯度下降庫,學習者只需要擁有最基本的微積分知識即可,用於教學和演示目的。
Micrograd 介紹#
Micrograd 基本上是一個autograd(自動梯度) 引擎,它的真正作用是實現反向傳播。
反向傳播是一種算法,可以有效地計算神經網絡中某種損失函數相對於權重的梯度。這讓我們能夠迭代地調整神經網絡的權重,以最小化損失函數,從而提高網絡的準確性。因此,反向傳播將成為任何現代深度神經網絡庫 (如 PyTorch、JAX) 的數學核心。
micrograd 支持的操作種類:
from micrograd.engine import Value
a = Value(-4.0)
b = Value(2.0)
c = a + b # C的字節節點是A和B,因為C維護A和B值對象的指針
d = a * b + b**3
c += c + 1
c += 1 + c + (-a)
d += d * 2 + (b + a).relu()
d += 3 * d + (b - a).relu()
e = c - d
f = e**2
g = f / 2.0
g += 10.0 / f # 前向傳遞的輸出
print(f'{g.data:.4f}') # prints 24.7041, the outcome of this forward pass
g.backward() # 在節點G處初始化反向傳播,從微積分中遞歸地引用鏈式法則,可以評估G相對於所有內部節點的導數
print(f'{a.grad:.4f}') # prints 138.8338, i.e. the numerical value of dg/da
print(f'{b.grad:.4f}') # prints 645.5773, i.e. the numerical value of dg/db
這會告訴我們,如果對 A 和 B 進行微小的正向調整,G 會如何響應。
可以看出,實現這些操作的重點在於Value
類的實現方式,下面將詳細介紹實現思路。
神經網絡原理#
基本來說,神經網絡只是數學表達式,他們將輸入數據作為輸入,將神經網絡的權重作為輸入,輸出的就是神經網絡的預測結果或損失函數。
這裡的引擎是在單個標量的級別上工作,分解到這些原子操作和所有的加號、乘號,這是有些過度的。你永遠不會再生產環境中執行這些操作,這裡做的只是為了教學目的,因為它不需要處理現代深度神經網絡庫中使用的 n 維張量 (nd tensor)。這樣做只是為了讓你理解並重構反向傳播和鏈式法則,以及神經網絡訓練的理解。如果要訓練更大的神經網絡,則必須使用這些張量,但數學方式並沒有改變。
導數的理解#
import numpy as np
import matplotlib.pyplot as plt
import math
%matplotlib inline
# 定義一個標量函數 f(x),接受一個標量 x 並返回一個標量 y
def f(x):
return 3*x**2 - 4*x + 5
f(3.0)
20.0
# 創建一組標量值以輸入 f(x)
xs = np.arange(-5, 5, 0.25)
'''
xs = array([-5. , -4.75, -4.5 , -4.25, -4. , -3.75, -3.5 , -3.25, -3. ,
-2.75, -2.5 , -2.25, -2. , -1.75, -1.5 , -1.25, -1. , -0.75,
-0.5 , -0.25, 0. , 0.25, 0.5 , 0.75, 1. , 1.25, 1.5 ,
1.75, 2. , 2.25, 2.5 , 2.75, 3. , 3.25, 3.5 , 3.75,
4. , 4.25, 4.5 , 4.75])
'''
# 將 f(x) 應用於 xs 中的每個值
ys = f(xs)
'''
ys = array([100. , 91.6875, 83.75 , 76.1875, 69. , 62.1875,
55.75 , 49.6875, 44. , 38.6875, 33.75 , 29.1875,
25. , 21.1875, 17.75 , 14.6875, 12. , 9.6875,
7.75 , 6.1875, 5. , 4.1875, 3.75 , 3.6875,
4. , 4.6875, 5.75 , 7.1875, 9. , 11.1875,
13.75 , 16.6875, 20. , 23.6875, 27.75 , 32.1875,
37. , 42.1875, 47.75 , 53.6875])
'''
# 繪製結果
plt.plot(xs, ys)
圖上每個 x 的導數是多少?
OFC,f'(x) = 6x - 4,但我們不會這樣做,因為在神經網絡中沒有人會真正寫出表達式。
這將是一個龐大的表達式,所以我們不會採取這種符號化的方法。
導數的定義#
相反,讓我們來看一下導數的定義,確保我們真正理解它。
# 我們基本上可以通過取一個非常小的 h 來數值評估導數
h = 0.00000001
x = 3.0
(f(x + h) - f(x)) / h # 函數正向響應
14.00000009255109
這是函數在正方向上的響應。
After normalization by the run, So we have the rise over run to get the numerical approximation of the slope (上升 / 橫向移動得到數值近似的斜率)
而在某個位置上,如果我們朝著正方向推進,函數沒有響應,幾乎保持不變,這就是為什麼slope=0
導數能告訴我們什麼#
看一個更複雜的例子:
a = 2.0
b = -3.0
c = 10
d = a * b + c
print(d)
這個函數的輸出變量 d 是三個標量輸入的函數,我們需要再次審視關於 d 相對於 a、b 和 c 的導數。
h = 0.0001
# inputs
a = 2.0
b = -3.0
c = 10
d1 = a * b + c
a += h
d2 = a * b + c
print('da',d1)
print('d2',d2)
print('slope', (d2 - d1) / h)
這裡要感悟在推進 a 後,d2 的變化是什麼樣的
我們發現 a 是正數,b 是負數,a 變大則會減少加到 d 上的內容,所以我們可以預料到函數值 d2 會變小。
da 4.0
d2 3.999699999999999
slope -3.000000000010772
從數學上也可以證明,
b += h
da 4.0
d2 4.0002
slope 2.0000000000042206
函數提升的量與我們添加到 c 上的量是相同的,所以斜率是 1
現在我們已經對導數是如何告訴你有關這個函數的信息有了一些直觀的感覺,我們可以轉向神經網絡了。
框架實現#
自定義類的數據結構#
前面也提到了,神經網絡是非常龐大的數學表達式,所以我們需要一些數據結構來維護這些表達式。
class Value:
def __init__(self, data):
self.data = data
def __repr__(self):
return f"Value({self.data})"
def __add__(self, other):
out = Value(self.data + other.data)
return out
def __mul__(self, other):
out = Value(self.data * other.data)
return out
a = Value(2.0)
b = Value(-3.0)
c = Value(10.0)
d = a * b + c # (a.__add__(b)).__mul__(c)
repr
的作用是為我們提供一種打印輸出的方法
現在我們缺少的是這個表達式的聯繫組織,我們需要知道並保留有關哪些值產生了哪些其它值的指針。
def __init__(self, data, _children=(), _op=''):
self.data = data
self._prev = set(_children)
self._op = _op
def __repr__(self):
return f"Value({self.data})"
def __add__(self, other):
out = Value(self.data + other.data, (self, other), '+')
return out
def __mul__(self, other):
out = Value(self.data * other.data, (self, other), '*')
return out
這樣可以知道每個值的子項,並且追到了是哪些運算操作生成了這個值
現在我們擁有了完整的數學表達式,需要尋找一個更好的可視化方法來表現會變得很大的表達式。(不是重點,可以跳過)
from graphviz import Digraph
def trace(root):
# builds a set of all node and edges in the graph
nodes, edges = set(), set()
def build(v):
if v not in nodes:
nodes.add(v)
for child in v._prev:
edges.add((child, v))
build(child)
build(root)
return nodes, edges
def draw_dot(root):
dot = Digraph(format='svg', graph_attr={'rankdir': 'LR'}) # LR = left to right
nodes, edges = trace(root)
for n in nodes:
uid = str(id(n))
# for many value in graph, create a rectangular ('record') node for it
dot.node(name = uid, label = "{ %s | data %.4f}" % (n.label, n.data), shape = 'record')
if n._op:
# if this value is a result of some operation, create an op node for it
dot.node(name = uid + n._op, label = n._op)
# and connect this node for it
dot.edge(uid + n._op, uid)
for n1, n2 in edges:
# connect n1 to the op node of n2
dot.edge(str(id(n1)), str(id(n2)) + n2._op)
return dot
draw_dot(d)
需要在 Value 類中添加
label
成員,並修改定義如下:
a = Value(2.0, label='a')
b = Value(-3.0, label='b')
c = Value(10.0, label='c')
e = a * b; e.label = 'e'
d = e + c; d.label = 'd'
d
現在讓表達式更深一層:
f = Value(-2.0, label='f')
L = d * f; L.label = 'L'
L
draw_dot(L)
可視化前向傳遞
運行反向傳播#
我們將從 L 開始,反向計算沿著所有中間值的梯度。在神經網絡環境中,你會對神經網絡的權重相對於損失函數 L 的導數非常感興趣。
在 Value 類匯總創建一個變量來維護關於該值的 L 的導數:
self.grad = 0
在初始化時,我們假設每個值都不會影響輸出。因為導數為 0 意味著這個變量不會改變損失函數。
現在我們從 L 開始填寫梯度,那麼 L 關於 L 的導數是多少?換言之,如果我把 L 變為一個微小的量 h,L 會變化多少?
不難想出,L 會隨著 h 的變化而成比例變化,因此導數為 1:
def lol():
h = 0.0001
a = Value(2.0, label='a')
b = Value(-3.0, label='b')
c = Value(10.0, label='c')
e = a * b; e.label = 'e'
d = e + c; d.label = 'd'
f = Value(-2.0, label='f')
L = d * f; L.label = 'L'
L1 = L.data
a = Value(2.0, label='a')
b = Value(-3.0, label='b')
c = Value(10.0, label='c')
e = a * b; e.label = 'e'
d = e + c; d.label = 'd'
f = Value(-2.0, label='f')
L = d * f; L.label = 'L'
L2 = L.data + h
print((L2 - L1) / h)
lol() # 0.9999999999976694
L.grad = 1.0
# L = d * f
# dL/dd = ? -> f
f.grad = 4.0 # d.data
d.grad = -2.0 # f.data
我們通過lol()
函數所做的有點像是內鏈梯度檢查,也就是我們在進行反向傳播並獲取相對於所有中間結果的導數時所進行的操作。數值梯度僅僅是使用小步長來估計它。
現在我們開始進入反向傳播的要點,我們知道 L 對 D 很敏感,但是 L 如何受到 C 的影響呢?
'''
dd / dc = ? -> 1.0 得到局部導數
dd / de = ? -> 1.0
d = c + e
(f(x+h)) - f(x) / h
((c + h + e) - (c + e)) / h
h / h = 1.0
'''
計算local derivate
c、e、d 中間的+
節點完全不知道全圖的其餘部分,它只知道自己做了一個 add 操作並 created d,以及 c、e 對 d 的局部影響,不過我們實際想要的是 dL/dc。
我們怎麼將這些信息整合在一起以得到 dL/dc 呢?答案是微積分中的Chain Rule 鏈式法則
如果變量 z 依賴於變量 y,而變量 y 又依賴於變量 x(即 y 和 z 是因變量),那麼 z 也通過中間變量 y 來依賴於 x。在這種情況下,鏈式法則可以表示為:
鏈式法則基本上告訴你如何正確地將這些導數鏈接在一起
正如喬治・F・西蒙斯所說:“如果一輛汽車的速度是自行車的兩倍,而自行車的速度是步行者的四倍,那麼汽車比人快 8 倍。”
這個例子很明白的解釋了這個法則的核心,也就是Multiply
加法節點#
distributor of gradient
在神經網絡中,每個節點的操作都會對梯度的傳播方式產生影響。對於加法節點,假設有兩個輸入 和 ,它們相加得到 。在反向傳播過程中,$c$ 相對於 和 的梯度分別是 1。
具體來說:
- 如果 的梯度是 ,其中 是損失函數,那麼根據鏈式法則,我們可以得到 和 的梯度:
- 由於 ,因此 和 。
- 因此, 和 都等於 。
所以,在加法節點,梯度會被 “原樣” 傳遞給所有輸入節點。這意味著加法節點不會改變梯度的大小,而是將梯度均勻分配給所有輸入。這是因為加法操作對於每個輸入來說是線性的,並且每個輸入對輸出的貢獻是獨立的。
乘法節點#
若想求a.grad
,可由:
所以反向傳播核心思想可以概括為:遞歸乘以局部導數
recursively multiply on the local derivatives
微調輸入#
微調輸入,嘗試讓 L 值增加。
對 a 點操作的話,意味著我們只需要朝著梯度方向前進。
我們需要對葉子節點進行調整,因為通常情況下,我們對它們有控制權。
我們期待 L 會升高:
a.data += 0.01 * a.grad
b.data += 0.01 * b.grad
c.data += 0.01 * c.grad
f.data += 0.01 * f.grad
e = a * b
d = e + c
L = d * f
print(L.data)
-7.286496
使用一個實際的例子:
# inputs x1,x2
x1 = Value(2.0, label='x1')
x2 = Value(0.0, label='x2')
# weights w1,w2, 每個輸入的突觸強度
w1 = Value(-3.0, label='w1')
w2 = Value(1.0, label='w2')
# bias b 偏置
b = Value(6.8813735870195432, label='b')
# x1*w1 + x2*w2 + b
x1w1 = x1 * w1; x1w1.label = 'x1w1'
x2w2 = x2 * w2; x2w2.label = 'x2w2'
x1w1x2w2 = x1w1 + x2w2; x1w1x2w2.label = 'x1w1x2w2'
n = x1w1x2w2 + b; n.label = 'n' # 細胞體的原始激活值
# 現在用一個激活函數來處理,這裡使用tanh,在先前的類中實現。
def tanh(self):
n = self.data
t = (math.exp(2*n) - 1)/(math.exp(2*n) + 1)
out = Value(t, (self,), 'tanh')
return out
o = n.tanh(); o.label = 'o'
draw_dot(o)
o.grad
=1.0- 計算
n.grad
o.grad = 1.0
n.grad = 0.5
x1w1x2w2.grad = 0.5
b.grad = 0.5
x1w1.grad = 0.5
x2w2.grad = 0.5
x1.grad = x1w1.grad * w1.data
w1.grad = x1w1.grad * x1.data
x2.grad = x2w2.grad * w2.data
w2.grad = x2w2.grad * x2.data
導數總是告訴我們它對最終輸出的影響,所以如果我改變 w2,輸出並不會改變,因為 x2 是 0。如果我們想讓這個神經元的輸出增加,w2 對現在這個神經元並不重要,但是 x1 這個權重應該增加。
自動化#
手動進行反向傳播是荒謬的,現在來看如何更自動地實現反向傳播。
我們將通過存儲一個特殊的self._backward
,which is a 執行上文中提到的小鏈式規則的 function,在每個接收輸入並產生輸出的的小節點上,我們將存儲 “如何將輸出的梯度鏈接到輸入的梯度中”
按照拓撲順序在所有節點上調用._backward
方法:
o.grad = 1.0
topo = []
visited = set()
def build_topo(v):
if v not in visited:
visited.add(v)
for child in v._prev:
build_topo(child)
topo.append(v)
build_topo(o)
for node in reversed(topo):
node._backward()
topo:
[Value(6.881373587019543), Value(0.0), Value(1.0), Value(0.0), Value(-3.0), Value(2.0), Value(-6.0), Value(-6.0), Value(0.8813735870195432), Value(0.7071067811865476)]
放到Value
類後,只需要調用o.backward()
即可完成一個神經元的 BP。
這時Value
類的構造函數如下:
class Value:
'''
data為實際數值;
_children元組用於維護子節點,用於溯源;
_op指定操作類型
label用於可視化標籤
'''
def __init__(self, data, _children=(), _op='', label=''):
self.data = data
self.grad = 0.0
self._backward = lambda: None
self._prev = set(_children)
self._op = _op
self.label = label
問題所在#
目前的算法有一個嚴重問題,但當前的例子中看不出來,原因是示例中的每個節點恰好只使用了一次,若調用多次,當前存儲的梯度會發生覆蓋。
導數顯然是 2 而不是 1
解決方案在於鏈式法則的多元情況及其推廣,基本思想就是 “累積這些梯度”:
self.grad += 1.0 * out.grad
other.grad += 1.0 * out.grad
修復了問題
操作級別#
整個框架的執行操作級別完全取決於你自己:
只要能夠實現每一個操作的前向傳遞和反向傳遞,那麼這個操作無論有多複合也不重要了。也就是說,如果你能實現局部梯度,那麼這些函數的設計完全由你決定:
def tanh(self):
n = self.data
t = (math.exp(2*n) - 1)/(math.exp(2*n) + 1)
out = Value(t, (self,), 'tanh')
def _backward():
self.grad += (1 - t**2) * out.grad
out._backward = _backward
return out
使用 tanh
# 需要實現所有原子操作的BP
def __repr__(self):
return f"Value({self.data})"
def __add__(self, other):
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data + other.data, (self, other), '+')
def _backward():
self.grad += 1.0 * out.grad
other.grad += 1.0 * out.grad
out._backward = _backward
return out
def __sub__(self, other):
return self + (-other)
def __radd__(self, other): # r即reverse,用於實現定義操作服的反向運算,可以讓自定義類的對象更好地與 Python 的運算符和內置類型交互。
return self.__add__(other)
def __mul__(self, other):
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data * other.data, (self, other), '*')
def _backward():
self.grad += other.data * out.grad
other.grad += self.data * out.grad
out._backward = _backward
return out
def __pow__(self, other):
assert isinstance(other, (int, float)), "only supporting int/float powers for now"
out = Value(self.data**other, (self,), f'**{other}')
def _backward():
self.grad += other * self.data ** (other - 1) * out.grad
out._backward = _backward
return out
def __rmul__(self, other):
return self.__mul__(other)
def __truediv__(self, other):
return self * other**-1
使用
在 Torch 的對應實現方式#
神經元類實現#
^c9c15a
import random
class Neuron:
def __init__(self, nin):
self.w = [Value(random.uniform(-1,1)) for _ in range(nin)]
self.b = Value(random.uniform(-1,1))
def __call__(self, x):
# w * x + b
act = sum((wi * xi for wi, xi in zip(self.w,x)), self.b)
out = act.tanh()
return out
x = [2.0, 3.0]
n = Neuron(2)
n(x)
實現 MLP#
^da5567
可以看到每一層中有多個神經元,他們之間沒有互相連接,但是都與輸入連接。
所以所謂的 Layer 就是一組獨立評估的神經元。
先實現一層 Layer:
按照圖示實現 MLP:
3 個輸入,兩個 4 個神經元的 Layer,1 個輸出
自己定一個一個簡單的數據集,我們希望神經網絡能對xs
中的四個樣本進行二分類,得到ys
中的結果:
顯然結果不能達到目標。
那我們該如何建立神經網絡,能夠調整權重以更高地預測所需的目標呢?
深度學習中實現這一點的技巧是計算一個單一的數字,這個數字能以某種方式衡量神經網絡的整體性能,那就是損失(the loss)。
在經典機器學習算法中,這個數字就相當於廣義線性模型中使用的均方誤差MSE,作用都是衡量模型的表現。
先觀察每組的 loss,可以看出 MSE 作為 loss 的話,只有
y輸出=y真實值
的時候才會 0 損失。預測越偏離,損失會越來越大。
由此得到了最終的損失:也就是所有 loss 的總和:
存儲權重和偏置:
兩種寫法效果一樣
對 MLP 類也類似地實現:
def parameters(self):
return [p for layer in self.layers for p in layer.parameters()]
這裡就可以根據梯度信息來改變權重了。但這裡我們可以看出,如果增加w[0].data
,因為它此時的梯度為負,所以實際上會減少它,損失反而會上升。
所以當前的梯度下降中缺少了一個-
,即 “沿著梯度的負方向” 即可最小化損失。
for p in n.parameters():
p.data += - 0.01 * p.grad*
可以看到,損失確實下降了,我們現在的預測效果更好了一些:
現在需要的就是迭代這一過程,剛才計算損失函數就是所謂的 “Forward Pass”,然後再次反向傳播、更新數據,這樣會得到一個更低的損失。
上述就是神經網絡中的 “梯度下降” 流程:前向傳遞、反向傳播、更新數據,神經網絡在這個過程中逐漸改善自己的預測效果。
如果嘗試改變步長,那麼可能會越出合適的界限。因為我們基本不了解當前的損失函數,我們唯一的認知只有這些參數與損失函數之間的局部依賴關係。如果步子跨大了,就可能進入完全不同的損失函數部分,導致梯度爆炸等情況。
在現在的情況下,循環數次後損失已經接近 0 了,可以看到此時ypred
中的結果已經很好了(這裡的學習率或者步長設置為 0.1)
再次執行,發現發生了梯度爆炸(loss 又變成了 5 以上),預測效果極差:
但下一次迭代,效果又異常的好:
可見,學習率的設置和調整是門多麼微妙的藝術。太低需要很長時間才能收斂,太高又會導致不穩定,因此在普通梯度下降 (gradient descent) 中,步長的設置確實是門學問。
神經網絡中的常見問題#
前面的過程其實有一個巨大的問題,僅僅是因為我們分析的問題過於簡單,所以才沒有發現漏洞在哪兒。
因為前面定義的梯度是累加的,所以我們需要在每次反向傳播前進行zero grad操作,否則相當於跨越的步長越來越大,很難得到想要的結果。
將迭代過程中加入梯度清零,對比可以發現現在的收斂速度較慢,並不能像之前一樣幾步就得到很低的 loss,這才是正常的情況:
這個 bug 有時候可能並並不會影響太多,但在面對複雜問題時,會導致我們無法很好地優化損失。
列出的常見錯誤,剛才遇到的就是其中的第 3 條:
總結:神經網絡是什麼?#
神經網絡是數學表達式,多層幹之際的情況下它是相當簡單的式子,以數據、神經網絡的權重和參數作為輸入;前向傳遞數學表達式,損失函數緊跟其後,用來衡量預測的準確性,然後反向傳播損失,按照梯度信息進行操作以最小化損失。
我們只需要一團神經元就能讓它做任何事情,當前例子中的神經網絡只有 41 個參數,但是你可以用數十億個神經元搭建更加複雜的神經網絡。以 GPT 模型為例,我們有來自互聯網的大量文本,嘗試讓神經網絡拿一些單詞來預測序列中的下一個單詞,這就是它要學習的問題。這樣龐大的神經網絡會有很多有意思的屬性,並且擁有數百億的參數,不過它基本上也是按照相同的原理運作,除了更新方式稍許不同,損失函數也是換用交叉熵損失來預測下一個 token。
現在的 micrograd 庫與示例中有所不同,比如當前使用的是ReLU
作為激活函數,這裡使用tanh
是因為它更平滑也更複雜一些,更加強調局部梯度和使用這些導數。
為了匹配 PyTorch 中的nn.module
類 API,因此項目中所有的神經元類都繼承自一個父類Module
,它們都具有梯度清零的作用:
class Module:
def zero_grad(self):
for p in self.parameters():
p.grad = 0.0
def parameters(self):
return []
class Neuron(Module):
...
學習 micrograd 的實現有助於理解 PyTorch 的基本設計思想,可以很容易理解怎麼註冊一個函數類型加入到 PyTorch 進行自動化梯度計算。官方文檔見鏈接。