ข้ามไปยังเนื้อหา
5/30บทที่ 5 จาก 30

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 คือ h=W1x+b1\mathbf{h} = W_1\mathbf{x} + \mathbf{b}_1 ป้อนมันเข้าอีกชั้นหนึ่ง y=W2h+b2\mathbf{y} = W_2\mathbf{h} + \mathbf{b}_2 แล้วแทนค่า:

y=W2(W1x+b1)+b2=(W2W1)x+(W2b1+b2)\mathbf{y} = W_2(W_1\mathbf{x} + \mathbf{b}_1) + \mathbf{b}_2 = (W_2W_1)\mathbf{x} + (W_2\mathbf{b}_1 + \mathbf{b}_2)

composition คือ Wx+bW\mathbf{x} + \mathbf{b} โดยมี W=W2W1W = W_2W_1 และ b=W2b1+b2\mathbf{b} = W_2\mathbf{b}_1 + \mathbf{b}_2 การซ้อน linear layer หลายชั้นเท่ากับ linear layer ชั้นเดียว สิบชั้น พันชั้น ก็ยังเป็นเส้นเดียว และยังทำ XOR ไม่ได้

คุ้มค่าที่จะดูให้เห็นแทนที่จะเชื่อเฉย ๆ:

linear_is_linear.pyPYTHON
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]))
TEXT
-4.612963371048  -4.612963371048  0.00e+00

ไม่ได้แค่เท่ากันโดยประมาณ แต่ เหมือนกันทุกบิต เพราะมันคือ arithmetic เดิมที่จัดรูปใหม่

ดังนั้นความลึกโดยตัวมันเองไม่ได้ให้อะไร สิ่งที่ให้ผลคือการใส่ฟังก์ชัน nonlinear ไว้ระหว่างชั้น — และนี่คือเหตุผลทั้งหมดที่ activation function มีอยู่ มันไม่ใช่สีสันเชิงชีววิทยาหรือทริก normalization หากไม่มีมัน ชั้นที่สองก็เป็นแค่ของตกแต่ง

ตอนนี้มาถึงคณิตศาสตร์ และมันคือกฎเดียวที่คุณรู้อยู่แล้ว เพียงนำไปใช้ในที่ที่อาจยังไม่คุ้นนัก

chain rule ตัวแปรเดียวบอกว่า ถ้า LL ขึ้นกับ cc และ cc ขึ้นกับ xx แล้ว dLdx=dLdcdcdx\frac{dL}{dx} = \frac{dL}{dc} \cdot \frac{dc}{dx} derivative จะคูณกันไปตาม chain

ส่วนที่สำคัญตรงนี้คือเมื่อ variable หนึ่งป้อนไปยัง downstream path มากกว่าหนึ่งทาง ถ้า xx ส่งผลต่อ LL ผ่าน aa และยังผ่าน bb ด้วย contribution เหล่านั้นจะ บวกกัน:

dLdx=Laax+Lbbx\frac{dL}{dx} = \frac{\partial L}{\partial a}\frac{\partial a}{\partial x} + \frac{\partial L}{\partial b}\frac{\partial b}{\partial x}

คูณตาม path บวกข้าม path นั่นคือทั้งหมดของ backpropagation และรายละเอียดการ implement ทุกอย่างในบทนี้ — รวมถึง += ในโค้ด และการเรียก zero_grad() ที่ทำให้ทุกคนสะดุดตอนเขียน training loop แรก — เป็นผลโดยตรงจากคำที่สองนั้น

ลองใช้วงจรจริงที่มี operation ห้าตัว โดยมี x=0.5x = 0.5 และ y=1.4y = 1.4:

a=xy,b=x+y,c=ab,d=tanh(c),L=d+xa = xy, \quad b = x + y, \quad c = ab, \quad d = \tanh(c), \quad L = d + x

สังเกตว่า xx ปรากฏสามครั้ง: ใน aa, ใน bb และโดยตรงใน LL ทำ backward pass บนกระดาษ จากขวาไปซ้าย โดยเริ่มจาก dLdL=1\frac{dL}{dL} = 1:

L=d+xL = d + x ดังนั้น Ld=1\frac{\partial L}{\partial d} = 1 และ path ตรงมี contribution เป็น Lx=1\frac{\partial L}{\partial x} = 1 การบวกกระจาย gradient ขาเข้าไปยัง input ทั้งสองโดยไม่เปลี่ยนค่า

d=tanh(c)d = \tanh(c) โดยมี c=ab=0.7×1.9=1.33c = ab = 0.7 \times 1.9 = 1.33 ดังนั้น dLdc=1tanh2(1.33)=0.2444\frac{dL}{dc} = 1 - \tanh^2(1.33) = 0.2444

c=abc = ab ดังนั้น dLda=dLdcb=0.2444×1.9=0.4644\frac{dL}{da} = \frac{dL}{dc} \cdot b = 0.2444 \times 1.9 = 0.4644 และ dLdb=dLdca=0.2444×0.7=0.1711\frac{dL}{db} = \frac{dL}{dc} \cdot a = 0.2444 \times 0.7 = 0.1711 การคูณสลับกัน: gradient ของ input แต่ละตัวถูกสเกลด้วยค่าของ input อีกตัวหนึ่ง

ผ่าน aa: dLday=0.4644×1.4=0.6501\frac{dL}{da} \cdot y = 0.4644 \times 1.4 = 0.6501 ผ่าน bb: dLdb1=0.1711\frac{dL}{db} \cdot 1 = 0.1711 โดยตรง: 11

dLdx=0.6501+0.1711+1.0000=1.8212\frac{dL}{dx} = 0.6501 + 0.1711 + 1.0000 = 1.8212

จำเลขนี้ไว้ อีกไม่กี่หน้า โปรแกรมจะผลิตมันออกมาโดยไม่ได้ถูกบอกเรื่องใด ๆ เหล่านี้

insight ที่ทำให้เขียนเป็นโปรแกรมได้คือ ทุกขั้นตอนข้างต้นเป็น local ทั้งหมด หากต้องการดัน gradient ผ่าน node การคูณ คุณต้องมี gradient ขาเข้าและค่า input ที่เก็บไว้สองค่า — ไม่ต้องรู้อะไรเกี่ยวกับส่วนอื่นของวงจร operation แต่ละตัวรู้วิธีหา derivative ของตัวเอง

ดังนั้นให้สร้างตัวเลขที่จำได้ว่าอะไรสร้างมันขึ้นมา

value.pyPYTHON
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 สะสม Lself\frac{\partial L}{\partial \text{self}} _prev คือเซตของ Value ที่ค่านี้ถูกคำนวณมาจาก — edge ของกราฟ และ _backward คือ closure ที่ operation แต่ละตัวติดตั้งไว้: มันรู้วิธีดัน gradient ของ node นี้ถอยหลังไปยัง input หนึ่งขั้น

operator ทุกตัวมีรูปแบบเดียวกัน: คำนวณ output, บันทึก parent, ติดตั้ง local rule

value.py (continued)PYTHON
    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 แต่ละตัวถูกสเกลด้วยค่าของอีกตัว
reluroute — ส่งผ่านหรือบล็อกทั้งหมด
tanhลดทอน — สเกลด้วย 1t21 - t^2 ซึ่งมากสุดคือ 1 และมักน้อยกว่านั้น

ทุกตัวใช้ += และไม่เคยใช้ = นั่นคือกฎ “sum across paths” ที่ถูก encode ไว้ node ที่ป้อนไปยัง consumer สองตัวจะถูกเรียกสองครั้ง และ contribution ทั้งสองจะบวกกันเอง

ต่อมาคือ driver ซึ่งเป็นส่วนเดียวที่มีความรู้แบบ global:

value.py (continued)PYTHON
    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

check.pyPYTHON
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)
TEXT
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.4033

1.8212 เลขเดียวกัน จากโปรแกรมที่ถูกบอกแค่ rule สำหรับ +, rule สำหรับ *, rule สำหรับ tanh และไม่ได้รู้อะไรเกี่ยวกับวงจรนี้เลย

ตรวจสอบแบบอิสระสองทาง เพราะ “มันตรงกับที่ฉัน derive เอง” เป็น test ที่อ่อน เมื่อคนคนเดียวกันทำทั้งสองอย่าง

Numerical differentiation. ขยับ input นิดหนึ่งแล้ววัด centred difference L(x+h)L(xh)2h\frac{L(x+h) - L(x-h)}{2h} ประมาณค่า derivative ได้โดยไม่ต้องใช้แคลคูลัสเลย:

TEXT
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 ระดับอุตสาหกรรมที่เขียนโดยคนที่ทำสิ่งนี้เป็นอาชีพ:

TEXT
torch dL/dx=1.821202805316   ours=1.821202805316   |diff|=2.22e-16
torch dL/dy=0.403269234753   ours=0.403269234753   |diff|=1.11e-16

ตรงกันที่ 2×10162 \times 10^{-16} ซึ่งคือ machine epsilon สำหรับ float 64-bit: engine ทั้งสองกำลังทำ arithmetic เดียวกัน เก็บ numerical check นี้ไว้ในกระเป๋า — มันคือเครื่องมือสำหรับ debug backward pass ของ layer ใหม่ และเป็นเหตุผลที่ gradient ผิดยังค้นเจอได้

วงจรเดิม input ต่างกัน ตั้ง x=2x = 2 และ y=3y = -3 ซึ่งทำให้ c=6c = 6:

TEXT
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.9999

gradient ที่ข้าม node tanh\tanh ลดลงด้วย factor 9,945 ทุกอย่างที่อยู่ upstream ของมัน — ใน network จริงคือทุก layer ก่อนหน้า — แทบไม่ได้รับอะไรเลย path สองทางผ่านวงจรเงียบลงไปแล้ว เหลือเพียงการเชื่อมต่อโดยตรงที่ข้าม tanh\tanh เท่านั้นที่ยังมี signal

นั่นคือปัญหา vanishing gradient ใน node เดียว ซ้อน tanh\tanh สี่สิบชั้น แล้วคูณ factor แบบนี้สี่สิบตัวเข้าด้วยกัน layer แรก ๆ ก็หยุดเรียนรู้โดยสิ้นเชิง และโดยบังเอิญ นี่ยังเป็น argument สำหรับ skip connection ที่เห็นได้ในแบบย่อส่วน: path ที่เลี่ยง nonlinearity คือทางเดียวที่รอด

zero_grad ทำอะไรจริง ๆ และทำไม bug ถึงซ่อนตัว

ลิงก์ไปยังส่วน: zero_grad ทำอะไรจริง ๆ และทำไม bug ถึงซ่อนตัว

ทุก _backward ใช้ += นั่นถูกต้อง — มันคือวิธีที่ path ต่าง ๆ sum กัน แต่มีผลตามมาที่ดักทุกคน: gradient สะสมข้ามการเรียก backward() ด้วย engine ไม่รู้ว่าการเรียกครั้งที่สองของคุณคือ training step ใหม่ ไม่ใช่ path อีกทางในกราฟเดียวกัน

ดังนั้น training loop ต้องล้างมัน:

train.pyPYTHON
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 rateseedwith resetwithout reset
0.051337loss 3.255088, 3/4loss 0.000000, 4/4
0.057loss 2.144820, 2/4loss 0.000000, 4/4
0.0542loss 2.126074, 2/4loss 0.000000, 4/4
0.11337loss 0.038597, 4/4loss 0.000000, 4/4
0.17loss 2.055048, 2/4loss 0.000000, 4/4
0.142loss 2.049876, 2/4loss 0.000073, 4/4
0.31337loss 4.512310, 2/4loss 8.000000, 2/4
0.37loss 0.015247, 4/4loss 4.000000, 3/4
0.342loss 0.005478, 4/4loss 4.000000, 3/4

ที่ learning rate เล็ก version ที่มี bug ชนะทุกแถว มัน converge ในขณะที่ version ที่ถูกต้องชะงัก

นี่ไม่ใช่ความบังเอิญ และควรทำความเข้าใจ เพราะมันอธิบายว่าทำไม bug นี้จับยากมาก ถ้าคุณไม่เคยล้าง gradient เมื่อถึง step kk parameter จะถูก update ด้วย ผลรวมของ gradient ทุกตัวที่คำนวณมาแล้ว บน loss ที่ยังชี้ไปทิศทางใกล้เคียงกัน ผลรวมนั้นจะโตขึ้นเรื่อย ๆ และผลลัพธ์คือ learning rate ที่เพิ่มขึ้นเอง ที่ η=0.05\eta = 0.05 ซึ่ง algorithm ที่ถูกต้องกำลังคลาน step size ที่ runaway จะดูเหมือนการแก้ปัญหาพอดี

จากนั้นดูสามแถวล่าง ที่ η=0.3\eta = 0.3 กลไกเดียวกันทำให้โมเดลแตกกระจาย — loss 8.0 คือคะแนนของโมเดลที่ collapse เป็นค่าคงที่ ±1\pm 1 — ครึ่งหนึ่งของ 16 ที่คำตอบผิดสุดทั้งสี่ข้อจะต้องเสีย — — ในขณะที่ version ที่ถูกต้องตอนนี้ converge ได้สะอาด

ดังนั้นประโยคที่ซื่อสัตย์ไม่ใช่ “ต้องเรียก zero_grad เสมอ ไม่งั้นโมเดลจะ train ไม่ได้” แต่คือ: ถ้าไม่มีมัน คุณไม่ได้รัน gradient descent อีกต่อไป คุณกำลังรันบางอย่างที่ step size ลอยสูงขึ้นในอัตราที่ไม่มีใครเลือก และมันจะดูเหมือนทำงาน บางครั้งดีกว่าของจริงด้วยซ้ำ จนถึงจุดที่มันไม่ทำงาน — แล้วคุณจะโทษ learning rate, initialisation หรือ data นี่คือรูปทรงของ bug ที่เลวร้ายที่สุดใน machine learning: มันไม่ crash แต่มันเปลี่ยน algorithm ให้กลายเป็น algorithm อื่นที่บางครั้งได้คะแนนดีกว่า

เมื่อ engine เสร็จแล้ว neural network แทบไม่ต้องใช้โค้ดเลย neuron คือ dot product, bias และ activation; layer คือ list ของ neuron; network คือ list ของ layer

nn.pyPYTHON
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 เก้าตัว:

TEXT
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 ด้วยอสมการสี่ข้อที่บังคับให้ bb ต้องเป็นทั้งบวกและลบ — ถูก compute ด้วยตัวเลขเก้าตัวที่ค้นพบโดยอัตโนมัติ

ส่วนที่น่าพอใจไม่ใช่แค่ว่ามันทำงานได้ แต่คือการมองเห็นว่า อย่างไร เพราะเมื่อมี hidden unit สองตัว intermediate representation คือจุดในระนาบ และคุณก็พิมพ์มันออกมาได้เลย

หลัง train จน loss เป็น 0.001241 นี่คือตำแหน่งที่ input แต่ละตัวไปตกหลัง hidden layer และสิ่งที่ output neuron ทำกับมัน:

inputhidden layer outputoutput scorelabel
(0,0)(0, 0)(+0.8206, 0.8474)(+0.8206,\ -0.8474)2.4045-2.40451-1
(0,1)(0, 1)(+0.9985, +0.8564)(+0.9985,\ +0.8564)+2.3049+2.3049+1+1
(1,0)(1, 0)(0.8401, 0.9990)(-0.8401,\ -0.9990)+2.3006+2.3006+1+1
(1,1)(1, 1)(+0.8368, 0.8550)(+0.8368,\ -0.8550)2.4786-2.47861-1

ดูแถวแรกกับแถวที่สี่ input (0,0)(0,0) และ (1,1)(1,1) เป็นมุมตรงข้ามตามเส้นทแยงของสี่เหลี่ยม — ไกลกันที่สุดเท่าที่จุดสองจุดในปัญหานี้จะไกลได้ — และ hidden layer map พวกมันไปที่ (0.82,0.85)(0.82, -0.85) และ (0.84,0.86)(0.84, -0.86) แทบจะเป็นจุดเดียวกัน layer ได้พับระนาบให้มุมสองมุมที่ต้อง reject ไปตกทับกัน และเมื่อมันอยู่ที่เดียวกันแล้ว เส้นตรงหนึ่งเส้นก็แยกมันออกจากอีกสองจุดได้

และ output neuron ก็คือเส้นนั้นพอดี parameter ที่เรียนรู้ได้คือ w=(3.1153, +3.0893)\mathbf{w} = (-3.1153,\ +3.0893), b=+2.7697b = +2.7697 ดังนั้น decision boundary ของมันคือ

3.1153h1+3.0893h2+2.7697=0-3.1153\,h_1 + 3.0893\,h_2 + 2.7697 = 0

ซึ่งเป็นเส้นตรง — 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 unitsinitialisations reaching 4/4
238 / 50 (76 %)
349 / 50 (98 %)
450 / 50 (100 %)
847 / 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 ของ y=Wx\mathbf{y} = W\mathbf{x} คือ

LW=Lyx,Lx=WLy\frac{\partial L}{\partial W} = \frac{\partial L}{\partial \mathbf{y}}\mathbf{x}^\top, \qquad \frac{\partial L}{\partial \mathbf{x}} = W^\top\frac{\partial L}{\partial \mathbf{y}}

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 ที่กล่าวไว้ข้างต้น

  1. 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 สิบหกปีก่อนจะมาถึงสาขานี้ และด้วยแรงจูงใจที่ต่างกันโดยสิ้นเชิง

  2. 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 ทำอะไร ของบทนี้ใช้การวัดมาขยายความ

  3. Cybenko, G. Approximation by superpositions of a sigmoidal function. Mathematics of Control, Signals and Systems 2, pp. 303–314 (1989).

  4. Hornik, K. Approximation capabilities of multilayer feedforward networks. Neural Networks 4(2), pp. 251–257 (1991). ขยายผลของ Cybenko: ผลลัพธ์ขึ้นกับ activation ว่าเป็น non-polynomial ไม่ใช่ขึ้นกับว่ามันเป็น sigmoidal


สร้างโดย

David Vicente Campos

ผู้ก่อตั้ง NeuraLIA Labs และผู้ร่วมก่อตั้ง MyRealFood

ผมเป็นวิศวกรคอมพิวเตอร์ที่จบจากมหาวิทยาลัยเลออน ผมร่วมก่อตั้ง MyRealFood ที่ที่ผมในฐานะ CTO ได้สร้างแอปซึ่งผู้คนหลายล้านคนใช้เพื่อกินให้ดีขึ้น และผมก่อตั้ง NeuraLIA Labs ที่ที่ผมสร้างผลิตภัณฑ์ AI ที่นี่ผมเขียนถึงสิ่งที่ผมต้องทำความเข้าใจระหว่างทาง ในแบบที่ผมเคยหวังว่าจะมีใครสักคนอธิบายให้ผมฟัง

เพิ่มเติมเกี่ยวกับผู้เขียน

เผยแพร่โดย NeuraLIA Labs

รับโพสต์ใหม่ในกล่องจดหมาย

ข่าว AI คู่มือ และอัปเดตผลิตภัณฑ์ — อีเมลสั้น ๆ เมื่อเรามีสิ่งที่คุ้มเวลาของคุณ

ชอบแบบข้อความมากกว่าไหม รับเนื้อหาเดียวกันได้ที่นี่:คอมมูนิตี้ WhatsApp (เปิดในแท็บใหม่)ช่อง Telegram (เปิดในแท็บใหม่)

ดัชนีคอร์ส

Abstract software decision engine with branching paths, probability nodes, and glowing gates.
jevอ่าน 5 นาที

โมเดล AI Jev สร้างมาเพื่อการตัดสินใจ ไม่ใช่การเขียนความเรียง

Jev ของ TypeSafe AI กำลังได้รับความสนใจ เพราะมองความฉลาดของซอฟต์แวร์เป็นปัญหาความน่าจะเป็น: เลือกกิ่งที่ถูกต้อง แนบความมั่นใจ และหลีกเลี่ยงการจ่ายเงินให้ LLM เขียนข้อความเมื่อโค้ดต้องการการตัดสินใจ

Abstract legal research workspace with documents, search nodes and governance controls.
openaiอ่าน 4 นาที

Astra for Law ของ OpenAI คือระบบ AI ด้านกฎหมาย ไม่ใช่โมเดลใหม่

การเปิดตัวด้านกฎหมายของ OpenAI ไม่ได้เน้นโมเดลฐานรากใหม่เท่ากับระบบที่ล้อมรอบโมเดลนั้น: การค้นคืนเฉพาะโดเมน เครื่องมือที่เชื่อถือได้ สิทธิ์ เบนช์มาร์ก และเส้นทางการตรวจทาน

Abstract agent runtime sorting documents, memory blocks and pointer nodes inside a bounded context frame.
context-engineeringอ่าน 4 นาที

วิศวกรรมบริบทสำหรับเอเจนต์ AI ที่ทำงานระยะยาว

เอเจนต์ที่ทำงานต่อเนื่องไม่ได้ล้มเหลวเพียงเพราะหน้าต่างบริบทเล็กเกินไป แต่ล้มเหลวเมื่อไฟล์ ผลลัพธ์จากเครื่องมือ และประวัติที่ค้างเก่าบดบังงานที่เอเจนต์ควรทำให้เสร็จ

พร้อมให้ LIA เลือกโมเดลให้แล้วหรือยัง?

สร้างงานด้วยโมเดล AI ทุกตัวในที่เดียว เริ่มฟรีวันนี้