Chuyển đến nội dung
5/30Chương 5 trên 30

Backpropagation từ đầu: Động cơ trước, rồi đến mạng

Viết autodiff engine 120 dòng bằng Python thuần, đối chiếu với PyTorch đến 16 chữ số thập phân và hiểu zero_grad bằng cách xóa nó.

Trên trang này

Sau bốn chương, vẫn còn một khoảng trống ở giữa khóa học.

Chương 3 đã cho chúng ta gradient descent: để cải thiện một tham số, hãy tìm độ dốc của hàm mất mát theo tham số đó rồi bước xuống dốc. Chương 4 đã cho chúng ta một hàm mất mát đáng để đi xuống. Nhưng trong cả hai chương, đạo hàm đều được tính bằng tay — một mô hình, một tham số, một dòng giải tích, và tất cả vừa trên một trang.

Giờ hãy xếp chồng hai lớp. Đầu ra của lớp thứ nhất đi vào lớp thứ hai, nên mọi trọng số trong lớp thứ nhất ảnh hưởng đến hàm mất mát thông qua mọi nơ-ron trong lớp thứ hai. Một mạng có hai lớp ẩn, mỗi lớp một trăm đơn vị, có khoảng hai mươi nghìn tham số, và mỗi tham số cần đạo hàm riêng của cùng một hàm mất mát. Làm việc đó bằng tay không phải là tẻ nhạt; nó là bất khả thi, và vẫn bất khả thi với mọi kiến trúc trong phần còn lại của khóa học này.

Lối ra không phải là một ký hiệu tốt hơn. Đó là nhận ra rằng đạo hàm của một hợp hàm có thể được tính một cách cơ học, bởi một chương trình, từ chính cấu trúc của phép tính — và nếu bạn làm theo đúng hướng, bạn có được toàn bộ hai mươi nghìn đạo hàm với chi phí xấp xỉ việc tính hàm mất mát một lần.

Cơ chế đó là reverse-mode automatic differentiation. Khi áp dụng cho mạng nơ-ron, nó được gọi là backpropagation, và đến cuối chương này bạn sẽ tự viết một bản khoảng 120 dòng Python không dùng thư viện, kiểm tra nó với PyTorch, rồi dùng nó để giải bài toán XOR đã đánh bại perceptron ở Chương 1.

Trước hết: vì sao bắt buộc phải có phi tuyến

Liên kết đến mục: Trước hết: vì sao bắt buộc phải có phi tuyến

Trước khi xây cỗ máy, cần chốt một câu hỏi, vì nếu câu trả lời đi theo hướng ngược lại thì sẽ chẳng có gì để xây.

Perceptron thất bại với XOR vì một đường thẳng không thể tách bốn điểm. Cách sửa hiển nhiên là xếp chồng: đưa đầu vào qua một lớp tuyến tính, rồi một lớp nữa. Cách đó có giúp gì không?

Không, và chứng minh chỉ mất hai dòng. Một lớp tuyến tính là h=W1x+b1\mathbf{h} = W_1\mathbf{x} + \mathbf{b}_1. Đưa nó vào một lớp khác, y=W2h+b2\mathbf{y} = W_2\mathbf{h} + \mathbf{b}_2, rồi thay vào:

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)

Hợp thành là Wx+bW\mathbf{x} + \mathbf{b} với W=W2W1W = W_2W_1b=W2b1+b2\mathbf{b} = W_2\mathbf{b}_1 + \mathbf{b}_2. Một chồng các lớp tuyến tính chỉ là một lớp tuyến tính duy nhất. Mười lớp, một nghìn lớp: vẫn là một đường thẳng, vẫn không làm được XOR.

Đáng để nhìn tận mắt điều đó xảy ra thay vì chỉ tin nó:

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

Không phải xấp xỉ bằng nhau. Giống hệt từng bit, vì đó là cùng một phép tính được sắp xếp lại.

Vì vậy độ sâu tự nó không mua được gì. Thứ có ích là đặt một hàm phi tuyến giữa các lớp — và đó là toàn bộ lý do các hàm kích hoạt tồn tại. Chúng không phải một nét trang trí sinh học hay một mẹo chuẩn hóa. Không có chúng, lớp thứ hai chỉ là đồ trang trí.

Quy tắc dây chuyền, trên giấy, với một nút dùng chung

Liên kết đến mục: Quy tắc dây chuyền, trên giấy, với một nút dùng chung

Giờ đến phần toán học, và đó là một quy tắc bạn đã biết được áp dụng ở một nơi hơi lạ.

Quy tắc dây chuyền một biến nói rằng nếu LL phụ thuộc vào cccc phụ thuộc vào xx, thì dLdx=dLdcdcdx\frac{dL}{dx} = \frac{dL}{dc} \cdot \frac{dc}{dx}. Các đạo hàm nhân với nhau dọc theo một chuỗi.

Phần quan trọng ở đây là điều xảy ra khi một biến cấp dữ liệu cho nhiều hơn một đường downstream. Nếu xx ảnh hưởng đến LL thông qua aa và cũng thông qua bb, các phần đóng góp sẽ cộng lại:

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}

Nhân dọc theo một đường, cộng qua các đường. Đó là toàn bộ backpropagation, và mọi chi tiết triển khai trong phần còn lại của chương này — bao gồm += trong code và lệnh gọi zero_grad() làm vấp ngã mọi người khi viết training loop đầu tiên — đều là hệ quả trực tiếp của từ thứ hai đó.

Lấy một mạch cụ thể gồm năm phép toán, với x=0.5x = 0.5y=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

Lưu ý rằng xx xuất hiện ba lần: trong aa, trong bb, và trực tiếp trong LL. Hãy làm backward pass trên giấy, từ phải sang trái, bắt đầu từ dLdL=1\frac{dL}{dL} = 1:

L=d+xL = d + x, nên Ld=1\frac{\partial L}{\partial d} = 1 và đường trực tiếp đóng góp Lx=1\frac{\partial L}{\partial x} = 1. Phép cộng phân phối gradient đi vào không đổi đến cả hai đầu vào.

d=tanh(c)d = \tanh(c) với c=ab=0.7×1.9=1.33c = ab = 0.7 \times 1.9 = 1.33, nên dLdc=1tanh2(1.33)=0.2444\frac{dL}{dc} = 1 - \tanh^2(1.33) = 0.2444.

c=abc = ab, nên dLda=dLdcb=0.2444×1.9=0.4644\frac{dL}{da} = \frac{dL}{dc} \cdot b = 0.2444 \times 1.9 = 0.4644dLdb=dLdca=0.2444×0.7=0.1711\frac{dL}{db} = \frac{dL}{dc} \cdot a = 0.2444 \times 0.7 = 0.1711. Phép nhân hoán đổi: gradient của mỗi đầu vào được nhân theo tỉ lệ bởi đầu vào kia.

Qua aa: dLday=0.4644×1.4=0.6501\frac{dL}{da} \cdot y = 0.4644 \times 1.4 = 0.6501. Qua bb: dLdb1=0.1711\frac{dL}{db} \cdot 1 = 0.1711. Trực tiếp: 11.

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

Hãy giữ con số đó. Vài trang nữa, một chương trình sẽ tạo ra nó mà không được cho biết bất kỳ điều nào ở trên.

Trực giác khiến việc này lập trình được là: mỗi bước trong số đó đều cục bộ. Để đẩy gradient qua nút phép nhân, bạn cần gradient đi vào và hai giá trị đầu vào đã lưu — không cần biết gì về phần còn lại của mạch. Mỗi phép toán biết cách tự lấy đạo hàm của chính nó.

Vì vậy hãy tạo một con số nhớ được điều gì đã tạo ra nó.

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

Bốn trường. data là giá trị. grad tích lũy Lself\frac{\partial L}{\partial \text{self}}. _prev là tập các Value mà giá trị này được tính từ đó — các cạnh của đồ thị. Và _backward là một closure mà mỗi phép toán cài vào: nó biết cách đẩy gradient của nút này lùi một bước về các đầu vào.

Mọi toán tử đều theo cùng một hình dạng: tính đầu ra, ghi lại cha mẹ, cài quy tắc cục bộ.

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

Đọc bốn thân _backward như một bảng, bạn sẽ thấy các mẫu dòng chảy từ phần suy diễn trên giấy nằm ngay đó:

phép toánnó làm gì với gradient
+phân phối — cùng một gradient đến mọi đầu vào
*hoán đổi — mỗi đầu vào được nhân theo tỉ lệ bởi giá trị của đầu vào kia
reluđịnh tuyến — cho đi qua hoặc chặn hoàn toàn
tanhlàm suy giảm — nhân theo tỉ lệ bởi 1t21 - t^2, giá trị tối đa là 1 và thường nhỏ hơn

Từng dòng đều dùng += và không bao giờ dùng =. Đó là quy tắc “cộng qua các đường”, được mã hóa. Một nút cấp dữ liệu cho hai consumer sẽ được gọi hai lần, và hai phần đóng góp tự cộng lại.

Rồi đến driver, phần duy nhất có hiểu biết toàn cục:

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 tạo ra một thứ tự tô-pô của đồ thị: mọi nút xuất hiện sau toàn bộ đầu vào của nó. Đi qua danh sách đó theo chiều ngược lại đảm bảo rằng khi bạn gọi _backward của một nút, gradient của chính nút đó đã hoàn chỉnh — mọi consumer downstream của nó đã đóng góp xong. Sai thứ tự là bạn đẩy một gradient mới hoàn thành một nửa ngược về sau, tạo ra đáp án sai mà không có thông báo lỗi.

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. Cùng một con số, từ một chương trình chỉ được cho biết quy tắc của +, quy tắc của *, quy tắc của tanh, và không biết gì về mạch này.

Hai kiểm tra độc lập, vì “nó khớp với thứ tôi tự suy ra” là một bài kiểm tra yếu khi cùng một người làm cả hai.

Lấy đạo hàm số. Nhích đầu vào và đo. Sai phân trung tâm L(x+h)L(xh)2h\frac{L(x+h) - L(x-h)}{2h} ước lượng đạo hàm mà không cần giải tích nào cả:

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

So với PyTorch, có một autodiff engine công nghiệp do những người làm việc này để kiếm sống viết ra:

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

Khớp ở mức 2×10162 \times 10^{-16}, tức machine epsilon của số thực 64-bit: hai engine đang thực hiện cùng một phép tính số học. Hãy giữ phép kiểm tra số trong túi — đó là công cụ để gỡ lỗi backward pass của một lớp mới, và là lý do một gradient sai có thể được tìm thấy.

Cùng một mạch, đầu vào khác. Đặt x=2x = 2y=3y = -3, khiến 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 đi qua nút tanh\tanh giảm đi 9.945 lần. Mọi thứ upstream của nó — trong một mạng thật, mọi lớp trước nó — gần như không nhận được gì. Hai đường qua mạch đã im lặng; chỉ kết nối trực tiếp bỏ qua tanh\tanh vẫn còn mang tín hiệu.

Đó là vấn đề vanishing gradient, trong một nút. Xếp bốn mươi lớp tanh\tanh và nhân bốn mươi hệ số như vậy với nhau, các lớp đầu sẽ ngừng học hoàn toàn. Nhân tiện, đây cũng là một lập luận ủng hộ skip connections mà bạn có thể thấy ở dạng thu nhỏ: đường đi vòng qua phi tuyến là đường duy nhất sống sót.

zero_grad thực sự làm gì, và vì sao lỗi này ẩn mình

Liên kết đến mục: zero_grad thực sự làm gì, và vì sao lỗi này ẩn mình

Mọi _backward đều dùng +=. Điều đó đúng — đó là cách các đường cộng lại. Nhưng nó có một hệ quả bắt tất cả mọi người: gradient cũng tích lũy qua các lần gọi backward(). Engine không hề biết lần gọi thứ hai của bạn là một bước huấn luyện mới chứ không phải một đường khác trong cùng đồ thị.

Vì vậy training loop phải xóa chúng:

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

Đây là optimizer.zero_grad() trong PyTorch, và lời khuyên thường gặp là quên nó sẽ làm hỏng huấn luyện. Vậy hãy xóa hai dòng đó và xem nó hỏng đến mức nào. Cùng seed, cùng mọi thứ, 200 bước XOR:

learning rateseedcó resetkhông 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

Ở các learning rate nhỏ, phiên bản có lỗi thắng từng hàng một. Nó hội tụ khi phiên bản đúng bị kẹt.

Đó không phải là may mắn ngẫu nhiên, và rất đáng hiểu, vì nó giải thích vì sao lỗi này khó bắt đến vậy. Nếu bạn không bao giờ xóa gradient, thì ở bước kk tham số được cập nhật bằng tổng của mọi gradient đã tính từ trước đến nay. Trên một hàm mất mát liên tục chỉ về gần cùng một hướng, tổng đó tăng đều, và hiệu ứng là learning rate tự tăng lên. Ở η=0.05\eta = 0.05, nơi thuật toán đúng đang bò, step size runaway trông đúng như một bản sửa lỗi.

Rồi nhìn ba hàng cuối. Ở η=0.3\eta = 0.3, cùng cơ chế đó làm mô hình vỡ tung — loss 8.0 là điểm số của một mô hình sụp về hằng số ±1\pm 1 — bằng một nửa của 16 mà bốn câu trả lời sai tối đa sẽ gây ra — — trong khi phiên bản đúng lúc này hội tụ sạch sẽ.

Vì vậy phát biểu trung thực không phải là “luôn gọi zero_grad nếu không mô hình của bạn sẽ không huấn luyện”. Mà là: không có nó, bạn không còn chạy gradient descent nữa. Bạn đang chạy một thứ có step size trôi dần lên với tốc độ không ai chọn, và nó sẽ có vẻ hoạt động, đôi khi còn tốt hơn thứ thật, cho đến khi không còn như vậy — khi đó bạn sẽ đổ lỗi cho learning rate, khởi tạo, hoặc dữ liệu. Đây là hình dạng của những lỗi tệ nhất trong machine learning: chúng không crash, chúng biến thuật toán thành một thuật toán khác đôi khi đạt điểm tốt hơn.

Khi engine đã xong, một mạng nơ-ron hầu như không cần nhiều code. Một nơ-ron là một dot product, một bias và một activation; một lớp là danh sách các nơ-ron; một mạng là danh sách các lớp.

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()]

Không có backward pass nào trong tất cả những thứ đó. Không một dòng. Lớp Value đã biết cách lấy đạo hàm của bất cứ thứ gì các lớp này tình cờ xây ra, và đó là mục đích của việc viết nó trước: một autodiff engine không biết nó đang được dùng cho mạng nơ-ron.

Giờ đến bài toán từ Chương 1. Hai đầu vào, hai đơn vị ẩn, một đầu ra, chín tham số:

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

Bốn trên bốn. Hàm mà không perceptron nào tính được — đã được chứng minh ở Chương 1 bằng bốn bất đẳng thức đòi bb vừa dương vừa âm — được tính bởi chín con số tìm được tự động.

Phần thỏa mãn không phải là nó hoạt động. Mà là có thể nhìn thấy cách nó hoạt động, vì với hai đơn vị ẩn, biểu diễn trung gian là một điểm trong mặt phẳng và bạn có thể chỉ việc in ra.

Sau khi huấn luyện đến loss 0.001241, đây là nơi mỗi đầu vào hạ cánh sau lớp ẩn, và điều nơ-ron đầu ra làm với nó:

đầu vàođầu ra lớp ẩnđiểm đầu ranhãn
(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

Nhìn vào hàng đầu và hàng thứ tư. Các đầu vào (0,0)(0,0)(1,1)(1,1) là hai góc đối diện theo đường chéo của hình vuông — xa nhau nhất có thể trong bài toán này — và lớp ẩn ánh xạ chúng tới (0.82,0.85)(0.82, -0.85)(0.84,0.86)(0.84, -0.86). Gần như cùng một điểm. Lớp này đã gấp mặt phẳng để hai góc bị loại đặt chồng lên nhau, và khi chúng ở cùng một chỗ, một đường thẳng có thể tách chúng khỏi hai điểm còn lại.

Và nơ-ron đầu ra chính xác là đường thẳng đó. Các tham số đã học của nó là w=(3.1153, +3.0893)\mathbf{w} = (-3.1153,\ +3.0893), b=+2.7697b = +2.7697, nên ranh giới quyết định của nó là

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

đó là một đường thẳng — một perceptron, cùng đối tượng từ Chương 1, không thay đổi. Khi đó nó không giải được XOR và bây giờ cũng không. Điều thay đổi là nó không còn nhìn vào đầu vào nữa; nó đang nhìn vào một không gian mà lớp đầu tiên xây cho nó, trong đó bài toán có thể tách tuyến tính.

Đó là một biểu diễn đã học, và đáng để nói chính xác vì cụm từ này sẽ được dùng khá lỏng trong phần còn lại của khóa học, và trong phần còn lại của lĩnh vực. Nó không phải là nén, tóm tắt, hay embedding theo nghĩa huyền bí nào. Nó là một phép đổi tọa độ, được học thay vì được thiết kế, với nhiệm vụ duy nhất là làm cho công việc của lớp tiếp theo trở nên dễ dàng.

Định lý xấp xỉ phổ quát, và điều nó không nói

Liên kết đến mục: Định lý xấp xỉ phổ quát, và điều nó không nói

Có một định lý ở đây, và nó thường bị trích dẫn rất tệ.

Cybenko năm 1989 và Hornik năm 1991 đã chứng minh rằng một mạng feedforward với một lớp ẩn duy nhất và một hàm kích hoạt phù hợp có thể xấp xỉ bất kỳ hàm liên tục nào trên một tập compact, tới bất kỳ độ chính xác nào bạn muốn, miễn là có đủ đơn vị ẩn.34 Đây là một kết quả thật sự quan trọng: nó nói kiến trúc không phải là giới hạn.

Giờ hãy đọc những gì nó bỏ qua. Nó không nói cần bao nhiêu đơn vị — cận có thể lớn đến mức thiên văn. Nó không nói các trọng số có thể được tìm thấy; nó khẳng định sự tồn tại, và gradient descent từ một khởi đầu ngẫu nhiên không phải là oracle. Và nó không nói gì về hành vi trên dữ liệu bạn chưa thấy, vốn là nửa sau của Chương 6.

Khoảng cách giữa “tồn tại” và “có thể tìm được” không phải chuyện hàn lâm. Đây là cùng bài toán XOR, 50 lần khởi tạo ngẫu nhiên mỗi cấu hình, 1000 bước, chỉ thay đổi kích thước lớp ẩn:

đơn vị ẩnsố lần khởi tạo đạt 4/4
238 / 50 (76 %)
349 / 50 (98 %)
450 / 50 (100 %)
847 / 50 (94 %)

Với kiến trúc tối thiểu có thể dùng được, cứ bốn lần chạy thì một lần không bao giờ tới đích — nó ổn định trong một cấu hình không thể đi xuống thoát ra, chính là cực tiểu cục bộ mà Chương 3 đã cho thấy trên một bề mặt một chiều. Thêm một đơn vị và các thất bại gần như biến mất, không phải vì mạng trở nên biểu đạt hơn (hai đơn vị đã đủ — 38 lần chạy chứng minh điều đó) mà vì các chiều bổ sung cho phép quá trình đi xuống có nhiều hướng hơn để thoát.

Và rồi tám đơn vị lại tệ hơn bốn một chút. Với learning rate và ngân sách bước cố định, nhiều capacity hơn không tốt hơn một cách đơn điệu. Bất kỳ ai nói với bạn rằng cách sửa một mạng bị kẹt luôn là mạng lớn hơn thì đang ngoại suy từ phần giữa của bảng đó.

Đây cũng là bài học của định lý hội tụ trong Chương 1, và sẽ là cùng bài học trong Chương 10 về scaling laws, dưới dạng mà chương đó đưa ra: dự đoán loss không phải là dự đoán capability bạn đang trả tiền để có, và khoảng cách giữa hai thứ đó là nơi công việc engineering diễn ra.

Hiện chi tiết

Tùy chọn: dạng ma trận, và vì sao code phía trên không dùng nó.

Mọi thứ ở đây được viết từng scalar một, đó là cách rõ nhất để thấy cơ chế và cũng là cách chậm nhất để chạy. Trong thực tế, một lớp là một phép nhân ma trận, và backward pass của 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}}

Các phép chuyển vị không phải là một mẹo để nhớ; chúng là hình dạng của quy tắc cộng qua các đường khi các đường được đánh chỉ số bởi các phần tử ma trận. Đối tượng tổng quát là Jacobian, ma trận của mọi đạo hàm riêng của mọi đầu ra theo mọi đầu vào, và reverse mode chính xác là phép tính một vector-Jacobian product mà không bao giờ dựng Jacobian — điều này quan trọng, vì với một lớp có 4096 đầu vào và 4096 đầu ra, ma trận đó có mười sáu triệu phần tử và không bao giờ đáng để xây.

Bạn không cần bất kỳ điều nào trong phần này để theo dõi các chương tiếp theo; phiên bản scalar làm mọi thứ phiên bản ma trận làm, chỉ chậm hơn. Nó trở nên cần thiết ở Chương 9, nơi các shape không còn hiển nhiên.

Giờ bạn đã có một mạng có thể huấn luyện. Thành tựu đó nhỏ hơn cảm giác của nó, vì mạng của bạn huấn luyện trên bốn ví dụ và cũng được đo trên chính bốn ví dụ đó.

Chạy cùng code trên một tập dữ liệu thật, một loạt vấn đề mới sẽ xuất hiện, không vấn đề nào nói về gradient. Loss giảm một lúc rồi dừng. Hoặc nó giảm trên dữ liệu huấn luyện và tăng trên mọi thứ khác. Hoặc nó không nhúc nhích ngay từ bước đầu tiên, và nguyên nhân hóa ra là phạm vi của các trọng số ngẫu nhiên ban đầu. Hoặc đầu vào của một đơn vị trôi sang âm trên mọi ví dụ ở epoch thứ ba và nó đã chết kể từ đó, âm thầm, mang theo một mảnh capacity của mô hình.

Đây không phải các lỗi kỳ lạ; chúng là trạng thái bình thường của một mạng vừa được viết ra, và không lỗi nào tự thông báo. Gradient đúng — bạn đã kiểm tra nó với PyTorch tới mười sáu chữ số thập phân — và mô hình vẫn không học.

Chương 6 nói về điều đó: khởi tạo, chuẩn hóa, overfitting và regularization, cùng thói quen chẩn đoán là hỏi điều nào đang xảy ra trước khi thay đổi bất cứ thứ gì. Đó là khác biệt giữa một mạng chạy được và một mạng hoạt động được.


Lớp Value trong chương này xuất phát trực tiếp từ micrograd của Andrej Karpathy, và video của anh The spelled-out intro to neural networks and backpropagation: building micrograd là ba giờ đáng giá nhất bạn có thể dành cho tài liệu này nếu muốn nghe một người khác giải thích theo cách thứ hai. Bài viết năm 2016 của anh Yes you should understand backprop lập luận cho việc tự viết một bản, và là bài đọc được giao trong Stanford CS224n. Ghi chú CS231n về backpropagation (cs231n.github.io/optimization-2) là cách trình bày kinh điển về các mẫu dòng chảy được lập bảng phía trên. Với phần toán học như giải tích trên đồ thị thay vì giai thoại mạng nơ-ron, chương 5.6 của Mathematics for Machine Learning của Deisenroth, Faisal và Ong rõ ràng một cách hiếm có; còn khảo sát Automatic Differentiation in Machine Learning: a Survey của Baydin, Pearlmutter, Radul và Siskind (arXiv:1502.05767) là tài liệu tham chiếu cho toàn bộ lĩnh vực, bao gồm trade-off forward/reverse đã thảo luận ở trên.

  1. Linnainmaa, S. The representation of the cumulative rounding error of an algorithm as a Taylor expansion of the local rounding errors. Luận văn thạc sĩ, University of Helsinki (1970). Reverse-mode accumulation, mười sáu năm trước khi nó đến lĩnh vực này và dưới một động cơ hoàn toàn khác.

  2. Rumelhart, D. E., Hinton, G. E. and Williams, R. J. Learning representations by back-propagating errors. Nature 323, tr. 533–536 (1986). Bài báo đã khiến phương pháp này được biết đến, và là nguồn của cách đọc các đơn vị ẩn như các biểu diễn đã học mà mục Lớp ẩn đã làm gì của chương này dành phần đo đạc để khảo sát.

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

  4. Hornik, K. Approximation capabilities of multilayer feedforward networks. Neural Networks 4(2), tr. 251–257 (1991). Tổng quát hóa Cybenko: kết quả phụ thuộc vào việc activation không phải đa thức, chứ không phụ thuộc vào việc nó có dạng sigmoidal.

Sẵn sàng để LIA chọn giúp bạn chưa?

Xây dựng cùng mọi mô hình AI ở một nơi — bắt đầu miễn phí ngay hôm nay.