Backpropagation ตั้งแต่ศูนย์: สร้าง Engine ก่อน แล้วค่อยสร้าง Network
เขียน autodiff engine 120 บรรทัดด้วย Python ล้วน ตรวจเทียบกับ PyTorch ถึง 16 ตำแหน่ง และเข้าใจ zero_grad ด้วยการลบมัน
ในหน้านี้
ผ่านมาแล้วสี่บท แต่ยังมีช่องว่างอยู่กลางคอร์สนี้
บทที่ 3 ให้ gradient descent กับเรา: ถ้าจะปรับ parameter ให้ดีขึ้น จงหาความชันของ loss เทียบกับ parameter นั้น แล้วก้าวลงเขา บทที่ 4 ให้ loss ที่คุ้มค่าจะเดินลงไปหา แต่ในทั้งสองบท derivative ถูกคำนวณ ด้วยมือ — หนึ่งโมเดล หนึ่ง parameter หนึ่งบรรทัดของแคลคูลัส และทั้งหมดพอดีในหน้าเดียว
ตอนนี้ลองซ้อนสองชั้น output ของชั้นแรกป้อนเข้าไปยังชั้นที่สอง ดังนั้น weight ทุกตัวในชั้นแรกจึงส่งผลต่อ loss ผ่าน neuron ทุกตัวในชั้นที่สอง network ที่มี hidden layer สองชั้น ชั้นละร้อย unit มี parameter ประมาณสองหมื่นตัว และแต่ละตัวต้องมี partial derivative ของ loss เดียวกันเป็นของตัวเอง การทำแบบนั้นด้วยมือไม่ใช่แค่น่าเบื่อ แต่มันเป็นไปไม่ได้ และจะยังเป็นไปไม่ได้สำหรับ architecture ทุกแบบในส่วนที่เหลือของคอร์สนี้
ทางออกไม่ใช่สัญกรณ์ที่ดีกว่า แต่คือการตระหนักว่า derivative ของ composition สามารถคำนวณเชิงกลได้โดยโปรแกรม จากโครงสร้างของการคำนวณนั้นเอง — และถ้าคุณทำในทิศทางที่ถูกต้อง คุณจะได้ derivative ทั้งสองหมื่นตัวด้วยต้นทุนราว ๆ เท่ากับการคำนวณ loss หนึ่งครั้ง
กลไกนั้นคือ reverse-mode automatic differentiation เมื่อนำไปใช้กับ neural network จะเรียกว่า backpropagation และเมื่อจบบทนี้ คุณจะได้เขียนมันเองใน Python ประมาณ 120 บรรทัดโดยไม่ใช้ไลบรารี ตรวจเทียบกับ PyTorch และใช้มันแก้ปัญหา XOR ที่เคยฆ่า perceptron ในบทที่ 1
ก่อนอื่น: ทำไมต้องมี nonlinearity ตั้งแต่แรก
ลิงก์ไปยังส่วน: ก่อนอื่น: ทำไมต้องมี nonlinearity ตั้งแต่แรกก่อนสร้างเครื่องจักรนี้ มีคำถามหนึ่งที่ต้องเคลียร์ก่อน เพราะถ้าคำตอบไปอีกทางหนึ่ง เราก็ไม่มีอะไรต้องสร้าง
perceptron ล้มเหลวกับ XOR เพราะเส้นตรงหนึ่งเส้นแยกจุดทั้งสี่ไม่ได้ วิธีแก้ที่ดูชัดเจนคือการซ้อนชั้น: ส่ง input ผ่าน linear layer หนึ่งชั้น แล้วตามด้วยอีกชั้นหนึ่ง วิธีนี้ช่วยไหม?
ไม่ และพิสูจน์ได้ในสองบรรทัด linear layer คือ ป้อนมันเข้าอีกชั้นหนึ่ง แล้วแทนค่า:
composition คือ โดยมี และ การซ้อน linear layer หลายชั้นเท่ากับ linear layer ชั้นเดียว สิบชั้น พันชั้น ก็ยังเป็นเส้นเดียว และยังทำ XOR ไม่ได้
คุ้มค่าที่จะดูให้เห็นแทนที่จะเชื่อเฉย ๆ:
import numpy as np
rng = np.random.default_rng(0)
W1, b1 = rng.normal(size=(3, 2)), rng.normal(size=3)
W2, b2 = rng.normal(size=(1, 3)), rng.normal(size=1)
x = rng.normal(size=2)
two_layers = W2 @ (W1 @ x + b1) + b2
one_layer = (W2 @ W1) @ x + (W2 @ b1 + b2)
print(two_layers[0], one_layer[0], abs(two_layers[0] - one_layer[0]))-4.612963371048 -4.612963371048 0.00e+00ไม่ได้แค่เท่ากันโดยประมาณ แต่ เหมือนกันทุกบิต เพราะมันคือ arithmetic เดิมที่จัดรูปใหม่
ดังนั้นความลึกโดยตัวมันเองไม่ได้ให้อะไร สิ่งที่ให้ผลคือการใส่ฟังก์ชัน nonlinear ไว้ระหว่างชั้น — และนี่คือเหตุผลทั้งหมดที่ activation function มีอยู่ มันไม่ใช่สีสันเชิงชีววิทยาหรือทริก normalization หากไม่มีมัน ชั้นที่สองก็เป็นแค่ของตกแต่ง
chain rule บนกระดาษ พร้อม node ที่ถูกแชร์
ลิงก์ไปยังส่วน: chain rule บนกระดาษ พร้อม node ที่ถูกแชร์ตอนนี้มาถึงคณิตศาสตร์ และมันคือกฎเดียวที่คุณรู้อยู่แล้ว เพียงนำไปใช้ในที่ที่อาจยังไม่คุ้นนัก
chain rule ตัวแปรเดียวบอกว่า ถ้า ขึ้นกับ และ ขึ้นกับ แล้ว derivative จะคูณกันไปตาม chain
ส่วนที่สำคัญตรงนี้คือเมื่อ variable หนึ่งป้อนไปยัง downstream path มากกว่าหนึ่งทาง ถ้า ส่งผลต่อ ผ่าน และยังผ่าน ด้วย contribution เหล่านั้นจะ บวกกัน:
คูณตาม path บวกข้าม path นั่นคือทั้งหมดของ backpropagation และรายละเอียดการ implement ทุกอย่างในบทนี้ — รวมถึง += ในโค้ด และการเรียก zero_grad() ที่ทำให้ทุกคนสะดุดตอนเขียน training loop แรก — เป็นผลโดยตรงจากคำที่สองนั้น
ลองใช้วงจรจริงที่มี operation ห้าตัว โดยมี และ :
สังเกตว่า ปรากฏสามครั้ง: ใน , ใน และโดยตรงใน ทำ backward pass บนกระดาษ จากขวาไปซ้าย โดยเริ่มจาก :
ผ่านการบวก
ลิงก์ไปยังส่วน: ผ่านการบวกดังนั้น และ path ตรงมี contribution เป็น การบวกกระจาย gradient ขาเข้าไปยัง input ทั้งสองโดยไม่เปลี่ยนค่า
ผ่าน tanh
ลิงก์ไปยังส่วน: ผ่าน tanhโดยมี ดังนั้น
ผ่านการคูณ
ลิงก์ไปยังส่วน: ผ่านการคูณดังนั้น และ การคูณสลับกัน: gradient ของ input แต่ละตัวถูกสเกลด้วยค่าของ input อีกตัวหนึ่ง
รวมสาม path เข้าสู่ x
ลิงก์ไปยังส่วน: รวมสาม path เข้าสู่ xผ่าน : ผ่าน : โดยตรง:
จำเลขนี้ไว้ อีกไม่กี่หน้า โปรแกรมจะผลิตมันออกมาโดยไม่ได้ถูกบอกเรื่องใด ๆ เหล่านี้
สร้าง engine
ลิงก์ไปยังส่วน: สร้าง engineinsight ที่ทำให้เขียนเป็นโปรแกรมได้คือ ทุกขั้นตอนข้างต้นเป็น local ทั้งหมด หากต้องการดัน gradient ผ่าน node การคูณ คุณต้องมี gradient ขาเข้าและค่า input ที่เก็บไว้สองค่า — ไม่ต้องรู้อะไรเกี่ยวกับส่วนอื่นของวงจร operation แต่ละตัวรู้วิธีหา derivative ของตัวเอง
ดังนั้นให้สร้างตัวเลขที่จำได้ว่าอะไรสร้างมันขึ้นมา
class Value:
"""A number that remembers where it came from."""
def __init__(self, data, _children=(), _op=""):
self.data = data
self.grad = 0.0
self._backward = lambda: None
self._prev = set(_children)
self._op = _opสี่ฟิลด์ data คือค่า grad สะสม _prev คือเซตของ Value ที่ค่านี้ถูกคำนวณมาจาก — edge ของกราฟ และ _backward คือ closure ที่ operation แต่ละตัวติดตั้งไว้: มันรู้วิธีดัน gradient ของ node นี้ถอยหลังไปยัง input หนึ่งขั้น
operator ทุกตัวมีรูปแบบเดียวกัน: คำนวณ output, บันทึก parent, ติดตั้ง local rule
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 += out.grad
other.grad += out.grad
out._backward = _backward
return out
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 tanh(self):
t = math.tanh(self.data)
out = Value(t, (self,), "tanh")
def _backward():
self.grad += (1 - t * t) * out.grad
out._backward = _backward
return out
def relu(self):
out = Value(self.data if self.data > 0 else 0.0, (self,), "relu")
def _backward():
self.grad += (1.0 if out.data > 0 else 0.0) * out.grad
out._backward = _backward
return outอ่าน body ของ _backward ทั้งสี่ตัวเป็นตาราง แล้ว pattern การไหลจาก derivation บนกระดาษจะอยู่ตรงนั้นเลย:
| operation | ทำอะไรกับ gradient |
|---|---|
+ | กระจาย — gradient เดียวกันไปยังทุก input |
* | สลับ — input แต่ละตัวถูกสเกลด้วยค่าของอีกตัว |
relu | route — ส่งผ่านหรือบล็อกทั้งหมด |
tanh | ลดทอน — สเกลด้วย ซึ่งมากสุดคือ 1 และมักน้อยกว่านั้น |
ทุกตัวใช้ += และไม่เคยใช้ = นั่นคือกฎ “sum across paths” ที่ถูก encode ไว้ node ที่ป้อนไปยัง consumer สองตัวจะถูกเรียกสองครั้ง และ contribution ทั้งสองจะบวกกันเอง
ต่อมาคือ driver ซึ่งเป็นส่วนเดียวที่มีความรู้แบบ global:
def backward(self):
order, seen = [], set()
def build(v):
if v in seen:
return
seen.add(v)
for child in v._prev:
build(child)
order.append(v)
build(self)
self.grad = 1.0
for v in reversed(order):
v._backward() build สร้าง topological ordering ของกราฟ: ทุก node จะปรากฏหลัง input ทั้งหมดของมัน การเดิน list นี้ ย้อนกลับ รับประกันว่าเมื่อคุณเรียก _backward ของ node หนึ่ง gradient ของมันเองจะครบแล้ว — consumer ทุกตัว downstream ได้ contribute มาแล้ว หากเรียงลำดับผิด คุณจะดัน gradient ที่ยังทำไม่เสร็จถอยหลัง ซึ่งให้คำตอบผิดโดยไม่มี error message
มันตรงกับกระดาษไหม?
ลิงก์ไปยังส่วน: มันตรงกับกระดาษไหม?x = Value(0.5)
y = Value(1.4)
a = x * y
b = x + y
c = a * b
d = c.tanh()
L = d + x
L.backward()
print(x.grad, y.grad)forward: a=0.7000 b=1.9000 c=1.3300 d=0.8692 L=1.3692
backward: dL/dd=1.0000 dL/dc=0.2444 dL/da=0.4644 dL/db=0.1711
dL/dx=1.8212 dL/dy=0.40331.8212 เลขเดียวกัน จากโปรแกรมที่ถูกบอกแค่ rule สำหรับ +, rule สำหรับ *, rule สำหรับ tanh และไม่ได้รู้อะไรเกี่ยวกับวงจรนี้เลย
ตรวจสอบแบบอิสระสองทาง เพราะ “มันตรงกับที่ฉัน derive เอง” เป็น test ที่อ่อน เมื่อคนคนเดียวกันทำทั้งสองอย่าง
Numerical differentiation. ขยับ input นิดหนึ่งแล้ววัด centred difference ประมาณค่า derivative ได้โดยไม่ต้องใช้แคลคูลัสเลย:
dL/dx: analytic=1.821202805 numeric=1.821202805 |diff|=1.80e-10
dL/dy: analytic=0.403269235 numeric=0.403269235 |diff|=7.64e-12เทียบกับ PyTorch ซึ่งมี autodiff engine ระดับอุตสาหกรรมที่เขียนโดยคนที่ทำสิ่งนี้เป็นอาชีพ:
torch dL/dx=1.821202805316 ours=1.821202805316 |diff|=2.22e-16
torch dL/dy=0.403269234753 ours=0.403269234753 |diff|=1.11e-16ตรงกันที่ ซึ่งคือ machine epsilon สำหรับ float 64-bit: engine ทั้งสองกำลังทำ arithmetic เดียวกัน เก็บ numerical check นี้ไว้ในกระเป๋า — มันคือเครื่องมือสำหรับ debug backward pass ของ layer ใหม่ และเป็นเหตุผลที่ gradient ผิดยังค้นเจอได้
Saturation เมื่อวัดจริง
ลิงก์ไปยังส่วน: Saturation เมื่อวัดจริงวงจรเดิม input ต่างกัน ตั้ง และ ซึ่งทำให้ :
x=0.5, y=1.4: dL/dc = 0.244400 three paths into x: 0.6501 + 0.1711 + 1.0000 = 1.8212
x=2.0, y=-3.0: dL/dc = 0.000025 three paths into x: 0.0001 + -0.0001 + 1.0000 = 0.9999gradient ที่ข้าม node ลดลงด้วย factor 9,945 ทุกอย่างที่อยู่ upstream ของมัน — ใน network จริงคือทุก layer ก่อนหน้า — แทบไม่ได้รับอะไรเลย path สองทางผ่านวงจรเงียบลงไปแล้ว เหลือเพียงการเชื่อมต่อโดยตรงที่ข้าม เท่านั้นที่ยังมี signal
นั่นคือปัญหา vanishing gradient ใน node เดียว ซ้อน สี่สิบชั้น แล้วคูณ factor แบบนี้สี่สิบตัวเข้าด้วยกัน layer แรก ๆ ก็หยุดเรียนรู้โดยสิ้นเชิง และโดยบังเอิญ นี่ยังเป็น argument สำหรับ skip connection ที่เห็นได้ในแบบย่อส่วน: path ที่เลี่ยง nonlinearity คือทางเดียวที่รอด
zero_grad ทำอะไรจริง ๆ และทำไม bug ถึงซ่อนตัว
ลิงก์ไปยังส่วน: zero_grad ทำอะไรจริง ๆ และทำไม bug ถึงซ่อนตัวทุก _backward ใช้ += นั่นถูกต้อง — มันคือวิธีที่ path ต่าง ๆ sum กัน แต่มีผลตามมาที่ดักทุกคน: gradient สะสมข้ามการเรียก backward() ด้วย engine ไม่รู้ว่าการเรียกครั้งที่สองของคุณคือ training step ใหม่ ไม่ใช่ path อีกทางในกราฟเดียวกัน
ดังนั้น training loop ต้องล้างมัน:
for step in range(steps):
ys = [model(x) for x, _ in DATA]
loss = sum((yp - yt) ** 2 for yp, (_, yt) in zip(ys, DATA))
for p in model.parameters():
p.grad = 0.0
loss.backward()
for p in model.parameters():
p.data -= lr * p.gradนี่คือ optimizer.zero_grad() ใน PyTorch และคำแนะนำทั่วไปคือถ้าลืมมัน training จะพัง งั้นเราลองลบสองบรรทัดนั้นแล้วดูว่าพังแค่ไหน seed เดิม ทุกอย่างเดิม 200 step ของ XOR:
| learning rate | seed | with reset | without reset |
|---|---|---|---|
| 0.05 | 1337 | loss 3.255088, 3/4 | loss 0.000000, 4/4 |
| 0.05 | 7 | loss 2.144820, 2/4 | loss 0.000000, 4/4 |
| 0.05 | 42 | loss 2.126074, 2/4 | loss 0.000000, 4/4 |
| 0.1 | 1337 | loss 0.038597, 4/4 | loss 0.000000, 4/4 |
| 0.1 | 7 | loss 2.055048, 2/4 | loss 0.000000, 4/4 |
| 0.1 | 42 | loss 2.049876, 2/4 | loss 0.000073, 4/4 |
| 0.3 | 1337 | loss 4.512310, 2/4 | loss 8.000000, 2/4 |
| 0.3 | 7 | loss 0.015247, 4/4 | loss 4.000000, 3/4 |
| 0.3 | 42 | loss 0.005478, 4/4 | loss 4.000000, 3/4 |
ที่ learning rate เล็ก version ที่มี bug ชนะทุกแถว มัน converge ในขณะที่ version ที่ถูกต้องชะงัก
นี่ไม่ใช่ความบังเอิญ และควรทำความเข้าใจ เพราะมันอธิบายว่าทำไม bug นี้จับยากมาก ถ้าคุณไม่เคยล้าง gradient เมื่อถึง step parameter จะถูก update ด้วย ผลรวมของ gradient ทุกตัวที่คำนวณมาแล้ว บน loss ที่ยังชี้ไปทิศทางใกล้เคียงกัน ผลรวมนั้นจะโตขึ้นเรื่อย ๆ และผลลัพธ์คือ learning rate ที่เพิ่มขึ้นเอง ที่ ซึ่ง algorithm ที่ถูกต้องกำลังคลาน step size ที่ runaway จะดูเหมือนการแก้ปัญหาพอดี
จากนั้นดูสามแถวล่าง ที่ กลไกเดียวกันทำให้โมเดลแตกกระจาย — loss 8.0 คือคะแนนของโมเดลที่ collapse เป็นค่าคงที่ — ครึ่งหนึ่งของ 16 ที่คำตอบผิดสุดทั้งสี่ข้อจะต้องเสีย — — ในขณะที่ version ที่ถูกต้องตอนนี้ converge ได้สะอาด
ดังนั้นประโยคที่ซื่อสัตย์ไม่ใช่ “ต้องเรียก zero_grad เสมอ ไม่งั้นโมเดลจะ train ไม่ได้” แต่คือ: ถ้าไม่มีมัน คุณไม่ได้รัน gradient descent อีกต่อไป คุณกำลังรันบางอย่างที่ step size ลอยสูงขึ้นในอัตราที่ไม่มีใครเลือก และมันจะดูเหมือนทำงาน บางครั้งดีกว่าของจริงด้วยซ้ำ จนถึงจุดที่มันไม่ทำงาน — แล้วคุณจะโทษ learning rate, initialisation หรือ data นี่คือรูปทรงของ bug ที่เลวร้ายที่สุดใน machine learning: มันไม่ crash แต่มันเปลี่ยน algorithm ให้กลายเป็น algorithm อื่นที่บางครั้งได้คะแนนดีกว่า
network และ XOR ในที่สุด
ลิงก์ไปยังส่วน: network และ XOR ในที่สุดเมื่อ engine เสร็จแล้ว neural network แทบไม่ต้องใช้โค้ดเลย neuron คือ dot product, bias และ activation; layer คือ list ของ neuron; network คือ list ของ layer
class Neuron:
def __init__(self, nin):
self.w = [Value(random.uniform(-1, 1)) for _ in range(nin)]
self.b = Value(0.0)
def __call__(self, x):
act = sum((wi * xi for wi, xi in zip(self.w, x)), self.b)
return act.tanh()
def parameters(self):
return self.w + [self.b]
class Layer:
def __init__(self, nin, nout):
self.neurons = [Neuron(nin) for _ in range(nout)]
def __call__(self, x):
out = [n(x) for n in self.neurons]
return out[0] if len(out) == 1 else out
def parameters(self):
return [p for n in self.neurons for p in n.parameters()]
class MLP:
def __init__(self, nin, nouts):
sizes = [nin] + nouts
self.layers = [Layer(sizes[i], sizes[i + 1]) for i in range(len(nouts))]
def __call__(self, x):
for layer in self.layers:
x = layer(x)
return x
def parameters(self):
return [p for layer in self.layers for p in layer.parameters()]ไม่มี backward pass ในทั้งหมดนั้นเลย ไม่มีแม้แต่บรรทัดเดียว class Value รู้อยู่แล้วว่าจะหา derivative ของสิ่งใดก็ตามที่ class เหล่านี้สร้างขึ้นได้อย่างไร และนี่คือจุดประสงค์ของการเขียนมันก่อน: autodiff engine ไม่รู้ว่ามันกำลังถูกใช้กับ neural network
ตอนนี้คือปัญหาจากบทที่ 1 input สองตัว hidden unit สองตัว output หนึ่งตัว parameter เก้าตัว:
step 1: loss 4.156690
step 10: loss 4.005572
step 50: loss 3.996708
step 100: loss 3.510700
step 200: loss 0.038597
[0, 0] -> -0.9081 (target -1) ok
[0, 1] -> +0.8934 (target +1) ok
[1, 0] -> +0.8906 (target +1) ok
[1, 1] -> -0.9207 (target -1) okถูกทั้งสี่ข้อ ฟังก์ชันที่ perceptron ใด ๆ ก็ compute ไม่ได้ — พิสูจน์ในบทที่ 1 ด้วยอสมการสี่ข้อที่บังคับให้ ต้องเป็นทั้งบวกและลบ — ถูก compute ด้วยตัวเลขเก้าตัวที่ค้นพบโดยอัตโนมัติ
hidden layer ทำอะไร
ลิงก์ไปยังส่วน: hidden layer ทำอะไรส่วนที่น่าพอใจไม่ใช่แค่ว่ามันทำงานได้ แต่คือการมองเห็นว่า อย่างไร เพราะเมื่อมี hidden unit สองตัว intermediate representation คือจุดในระนาบ และคุณก็พิมพ์มันออกมาได้เลย
หลัง train จน loss เป็น 0.001241 นี่คือตำแหน่งที่ input แต่ละตัวไปตกหลัง hidden layer และสิ่งที่ output neuron ทำกับมัน:
| input | hidden layer output | output score | label |
|---|---|---|---|
ดูแถวแรกกับแถวที่สี่ input และ เป็นมุมตรงข้ามตามเส้นทแยงของสี่เหลี่ยม — ไกลกันที่สุดเท่าที่จุดสองจุดในปัญหานี้จะไกลได้ — และ hidden layer map พวกมันไปที่ และ แทบจะเป็นจุดเดียวกัน layer ได้พับระนาบให้มุมสองมุมที่ต้อง reject ไปตกทับกัน และเมื่อมันอยู่ที่เดียวกันแล้ว เส้นตรงหนึ่งเส้นก็แยกมันออกจากอีกสองจุดได้
และ output neuron ก็คือเส้นนั้นพอดี parameter ที่เรียนรู้ได้คือ , ดังนั้น decision boundary ของมันคือ
ซึ่งเป็นเส้นตรง — perceptron วัตถุเดียวกับบทที่ 1 ไม่เปลี่ยนแปลง มันแก้ XOR ไม่ได้ตอนนั้น และตอนนี้ก็ยังแก้ไม่ได้ สิ่งที่เปลี่ยนคือมันไม่ได้มอง input อีกต่อไป แต่มันกำลังมอง space ที่ layer แรกสร้างให้ ซึ่งปัญหาเป็น linearly separable ในนั้น
นั่นคือสิ่งที่ learned representation เป็น และควรพูดให้แม่น เพราะวลีนี้จะถูกใช้อย่างหลวม ๆ ในส่วนที่เหลือของคอร์ส และในทั้งวงการ มันไม่ใช่การบีบอัด สรุป หรือ embedding ในความหมายลึกลับใด ๆ มันคือการเปลี่ยนพิกัดที่เรียนรู้ขึ้นมาแทนที่จะออกแบบเอง โดยมีหน้าที่เดียวคือทำให้งานของ layer ถัดไปง่ายขึ้น
universal approximation theorem และสิ่งที่มันไม่ได้บอก
ลิงก์ไปยังส่วน: universal approximation theorem และสิ่งที่มันไม่ได้บอกตรงนี้มีทฤษฎีบทหนึ่ง และมันมักถูก quote แบบผิด ๆ
Cybenko ในปี 1989 และ Hornik ในปี 1991 พิสูจน์ว่า feedforward network ที่มี hidden layer เดียวและ activation function ที่เหมาะสม สามารถ approximate continuous function ใด ๆ บน compact set ได้แม่นยำเท่าที่คุณต้องการ หากมี hidden unit มากพอ34 นี่เป็นผลลัพธ์ที่แท้จริงและสำคัญ: มันบอกว่า architecture ไม่ใช่ข้อจำกัด
ตอนนี้อ่านสิ่งที่มันละไว้ มันไม่ได้บอกว่าต้องใช้กี่ unit — bound อาจใหญ่ระดับดาราศาสตร์ มันไม่ได้บอกว่าจะ หา weight ได้; มันยืนยันการมีอยู่ และ gradient descent จากจุดเริ่มสุ่มไม่ใช่ oracle และมันไม่พูดอะไรเกี่ยวกับพฤติกรรมบน data ที่คุณไม่เคยเห็น ซึ่งคือครึ่งหลังของบทที่ 6
ช่องว่างระหว่าง “มีอยู่” กับ “หาได้” ไม่ใช่เรื่องวิชาการ นี่คือปัญหา XOR เดิม สุ่ม initialisation 50 ครั้งต่อแบบ 1000 step เปลี่ยนแค่ขนาด hidden layer:
| hidden units | initialisations reaching 4/4 |
|---|---|
| 2 | 38 / 50 (76 %) |
| 3 | 49 / 50 (98 %) |
| 4 | 50 / 50 (100 %) |
| 8 | 47 / 50 (94 %) |
ด้วย architecture ขั้นต่ำที่พอใช้ได้ หนึ่งในสี่ run ไปไม่ถึงจุดนั้น — มัน settle ลงใน configuration ที่ลงต่อไม่ได้ ตรงกับ local minimum ที่บทที่ 3 เคยแสดงบนพื้นผิวหนึ่งมิติ เพิ่ม unit อีกหนึ่งตัวแล้ว failure แทบหายไป ไม่ใช่เพราะ network มี expressiveness มากขึ้น (สอง unit ก็พอแล้ว — 38 run พิสูจน์อยู่) แต่เพราะมิติที่เพิ่มขึ้นให้ทิศทางแก่ descent มากขึ้นสำหรับหลบออกมา
แล้วแปด unit กลับ แย่กว่า สี่เล็กน้อย ที่ learning rate และ step budget คงที่ capacity ที่มากขึ้นไม่ได้ดีกว่าแบบ monotonic ใครก็ตามที่บอกคุณว่าวิธีแก้ network ติดคือทำให้ network ใหญ่ขึ้นเสมอ กำลัง extrapolate จากตรงกลางของตารางนั้น
นี่คือบทเรียนเดียวกับ convergence theorem ในบทที่ 1 และจะเป็นบทเรียนเดียวกันในบทที่ 10 เรื่อง scaling law ในรูปแบบที่บทนั้นให้ไว้: การทำนาย loss ไม่ใช่การทำนาย capability ที่คุณจ่ายเงินซื้อ และระยะห่างระหว่างสองสิ่งนี้คือพื้นที่ที่ engineering เกิดขึ้น
แสดงรายละเอียด
ทางเลือกเสริม: รูปแบบ matrix และทำไมโค้ดด้านบนไม่ใช้มัน
ทุกอย่างในที่นี้ถูกเขียนทีละ scalar ซึ่งเป็นวิธีที่เห็นกลไกชัดที่สุดและรันช้าที่สุด ในทางปฏิบัติ layer คือ matrix multiply และ backward pass ของ คือ
transpose ไม่ใช่ทริกให้ท่องจำ แต่มันคือหน้าตาของกฎ sum-over-paths เมื่อ path ถูก index ด้วย entry ของ matrix วัตถุทั่วไปคือ Jacobian ซึ่งเป็น matrix ของ partial derivative ทั้งหมดของ output ทั้งหมดเทียบกับ input ทั้งหมด และ reverse mode ก็คือการคำนวณ vector-Jacobian product โดยไม่เคยสร้าง Jacobian ขึ้นมาเลย — ซึ่งสำคัญ เพราะสำหรับ layer ที่มี input 4096 และ output 4096 matrix นั้นมีสิบหกล้าน entry และไม่เคยคุ้มที่จะสร้าง
คุณไม่จำเป็นต้องรู้สิ่งนี้เพื่ออ่านบทถัดไป; เวอร์ชัน scalar ทำทุกอย่างที่เวอร์ชัน matrix ทำได้ เพียงช้ากว่า มันจะจำเป็นในบทที่ 9 ซึ่ง shape เริ่มไม่ชัดเจนอีกต่อไป
จากนี้ไปที่ไหน
ลิงก์ไปยังส่วน: จากนี้ไปที่ไหนตอนนี้คุณมี network ที่ train ได้แล้ว นั่นเป็นความสำเร็จที่เล็กกว่าความรู้สึก เพราะ network ที่คุณมี train บนตัวอย่างสี่ตัว และวัดผลบนสี่ตัวเดิม
รันโค้ดเดิมบน dataset จริง แล้วปัญหาชุดใหม่จะโผล่ขึ้นมา ซึ่งไม่มีข้อใดเกี่ยวกับ gradient loss ลดลงสักพักแล้วหยุด หรือมันลดลงบน training data แต่เพิ่มขึ้นบนอย่างอื่นทั้งหมด หรือมันไม่ขยับเลยตั้งแต่ step แรก และสาเหตุกลับเป็นช่วงค่าของ weight สุ่มตอนเริ่มต้น หรือ input ของ unit หนึ่ง drift ไปเป็นลบในทุกตัวอย่างตั้งแต่ epoch ที่สาม และมันก็ตายมาตั้งแต่นั้นแบบเงียบ ๆ พร้อมพา capacity ของโมเดลไปด้วยหนึ่ง chunk
สิ่งเหล่านี้ไม่ใช่ failure แปลกประหลาด มันคือสภาพปกติของ network ที่เพิ่งถูกเขียนขึ้น และไม่มีข้อใดประกาศตัวเอง gradient ถูกต้อง — คุณตรวจเทียบกับ PyTorch ถึงสิบหกตำแหน่งทศนิยมแล้ว — แต่โมเดลก็ยังไม่เรียนรู้
บทที่ 6 ว่าด้วยเรื่องนั้น: initialisation, normalisation, overfitting และ regularisation รวมถึงนิสัยเชิงวินิจฉัยในการถามว่า สิ่งไหน กำลังเกิดขึ้นก่อนจะเปลี่ยนอะไร นี่คือความต่างระหว่าง network ที่รันได้กับ network ที่ใช้งานได้
แหล่งที่มาและวิธีการ
ลิงก์ไปยังส่วน: แหล่งที่มาและวิธีการclass Value ในบทนี้สืบสายตรงมาจาก micrograd ของ Andrej Karpathy และวิดีโอของเขา The spelled-out intro to neural networks and backpropagation: building micrograd คือสามชั่วโมงที่คุ้มค่าที่สุดสำหรับเนื้อหานี้ หากคุณอยากฟังคำอธิบายอีกแบบจากคนอื่น โพสต์ปี 2016 ของเขา Yes you should understand backprop อธิบายเหตุผลว่าทำไมคุณควรเขียนมันเอง และเป็น reading ที่ถูกมอบหมายใน Stanford CS224n โน้ต CS231n เรื่อง backpropagation (cs231n.github.io/optimization-2) คือ treatment มาตรฐานของ pattern การไหลที่สรุปไว้ในตารางด้านบน สำหรับคณิตศาสตร์ในฐานะแคลคูลัสบนกราฟ แทนที่จะเป็น folklore ของ neural network บทที่ 5.6 ของ Mathematics for Machine Learning โดย Deisenroth, Faisal และ Ong อธิบายได้ชัดผิดปกติ; และ survey ของ Baydin, Pearlmutter, Radul และ Siskind เรื่อง Automatic Differentiation in Machine Learning: a Survey (arXiv:1502.05767) คือ reference ของสาขานี้โดยรวม รวมถึง trade-off ระหว่าง forward/reverse ที่กล่าวไว้ข้างต้น
รายการอ้างอิง
ลิงก์ไปยังส่วน: รายการอ้างอิง-
Linnainmaa, S. The representation of the cumulative rounding error of an algorithm as a Taylor expansion of the local rounding errors. วิทยานิพนธ์ปริญญาโท University of Helsinki (1970). reverse-mode accumulation สิบหกปีก่อนจะมาถึงสาขานี้ และด้วยแรงจูงใจที่ต่างกันโดยสิ้นเชิง ↩
-
Rumelhart, D. E., Hinton, G. E. and Williams, R. J. Learning representations by back-propagating errors. Nature 323, pp. 533–536 (1986). บทความที่ทำให้วิธีนี้เป็นที่รู้จัก และเป็นที่มาของการอ่าน hidden unit ในฐานะ learned representation ที่ส่วน hidden layer ทำอะไร ของบทนี้ใช้การวัดมาขยายความ ↩
-
Cybenko, G. Approximation by superpositions of a sigmoidal function. Mathematics of Control, Signals and Systems 2, pp. 303–314 (1989). ↩
-
Hornik, K. Approximation capabilities of multilayer feedforward networks. Neural Networks 4(2), pp. 251–257 (1991). ขยายผลของ Cybenko: ผลลัพธ์ขึ้นกับ activation ว่าเป็น non-polynomial ไม่ใช่ขึ้นกับว่ามันเป็น sigmoidal ↩