Перейти до вмісту
5/30Розділ 5 з 30

Backpropagation з нуля: спершу рушій, потім мережа

Напишіть 120-рядковий autodiff-рушій на чистому Python, звірте його з PyTorch до 16 знаків і зрозумійте zero_grad, видаливши його.

На цій сторінці

Чотири розділи позаду, і посеред курсу є прогалина.

Розділ 3 дав нам gradient descent: щоб поліпшити параметр, знайдіть нахил втрати відносно нього й зробіть крок униз. Розділ 4 дав нам втрату, якою варто спускатися. Але в обох випадках похідну обчислювали вручну — одна модель, один параметр, один рядок математичного аналізу, і все вміщалося на сторінці.

Тепер складіть два шари. Вихід першого подається в другий, тож кожна вага в першому впливає на втрату через кожен нейрон у другому. Мережа з двома прихованими шарами по сто вузлів у кожному має приблизно двадцять тисяч параметрів, і кожному потрібна власна часткова похідна тієї самої втрати. Робити це вручну не нудно; це неможливо, і це залишається неможливим для кожної архітектури в решті курсу.

Вихід — не краща нотація. Вихід — усвідомити, що похідну композиції можна обчислити механічно, програмою, зі структури самого обчислення — і що якщо робити це в правильному напрямку, ви отримаєте всі двадцять тисяч похідних приблизно за ціну одного обчислення втрати.

Цей механізм називається автоматичним диференціюванням у зворотному режимі. Застосований до нейронної мережі, він називається backpropagation, і до кінця цього розділу ви напишете його приблизно у 120 рядках Python без бібліотек, звірите з PyTorch і використаєте, щоб розвʼязати задачу XOR, яка зламала перцептрон у Розділі 1.

Спершу: чому нелінійність узагалі потрібна

Посилання на розділ: Спершу: чому нелінійність узагалі потрібна

Перш ніж будувати машину, треба закрити одне питання, бо якби відповідь була іншою, будувати було б нічого.

Перцептрон провалився на XOR, бо одна пряма не може розділити чотири точки. Очевидне виправлення — скласти шари: пропустити вхід через один лінійний шар, потім через інший. Це допоможе?

Ні, і доказ займає два рядки. Лінійний шар — це 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)

Композиція — це Wx+bW\mathbf{x} + \mathbf{b} з W=W2W1W = W_2W_1 і b=W2b1+b2\mathbf{b} = W_2\mathbf{b}_1 + \mathbf{b}_2. Стек лінійних шарів — це один лінійний шар. Десять таких, тисяча таких: усе ще одна пряма, усе ще нездатна розвʼязати 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

Не приблизно рівні. Ідентичні біт у біт, бо це та сама арифметика, лише переставлена.

Отже, глибина сама по собі нічого не дає. Щось дає вставлення нелінійної функції між шарами — і саме тому існують функції активації. Це не біологічна прикраса й не трюк нормалізації. Без неї другий шар — декорація.

Ланцюгове правило, на папері, зі спільним вузлом

Посилання на розділ: Ланцюгове правило, на папері, зі спільним вузлом

Тепер математика, і це одне правило, яке ви вже знаєте, застосоване в трохи незвичному місці.

Ланцюгове правило для однієї змінної каже: якщо LL залежить від cc, а cc залежить від xx, тоді dLdx=dLdcdcdx\frac{dL}{dx} = \frac{dL}{dc} \cdot \frac{dc}{dx}. Похідні множаться уздовж ланцюга.

Тут важлива частина — що відбувається, коли змінна живить більше ніж один подальший шлях. Якщо xx впливає на LL через aa і також через bb, внески додаються:

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}

Множте вздовж шляху, підсумовуйте між шляхами. Це і є весь backpropagation, і кожна деталь реалізації в решті розділу — включно з += у коді та викликом zero_grad(), на якому спотикається кожен, хто пише свій перший цикл навчання, — є прямим наслідком цього другого слова.

Візьмімо конкретну схему з пʼяти операцій, де 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. Виконайте зворотний прохід на папері, справа наліво, починаючи з dLdL=1\frac{dL}{dL} = 1:

L=d+xL = d + x, тож Ld=1\frac{\partial L}{\partial d} = 1, а прямий шлях вносить Lx=1\frac{\partial L}{\partial x} = 1. Додавання розподіляє вхідний градієнт без змін на обидва входи.

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. Множення міняє місцями: градієнт кожного входу масштабується значенням іншого входу.

Через 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

Запамʼятайте це число. За кілька сторінок програма видасть його, хоча їй нічого з цього не розповідали.

Інсайт, який робить це програмованим: кожен із цих кроків був локальним. Щоб проштовхнути градієнт через вузол множення, потрібні вхідний градієнт і два збережені вхідні значення — нічого про решту схеми. Кожна операція знає, як диференціювати саму себе.

Тож зробімо число, яке памʼятає, що його породило.

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, з яких було обчислено це значення, тобто ребра графа. А _backward — замикання, яке встановлює кожна операція: воно знає, як проштовхнути градієнт цього вузла на один крок назад до його входів.

Кожен оператор має ту саму форму: обчислити вихід, записати батьків, встановити локальне правило.

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

Прочитайте чотири тіла _backward як таблицю, і патерни потоку з паперового виведення будуть прямо перед вами:

операціящо вона робить із градієнтом
+розподіляє — той самий градієнт на кожен вхід
*міняє місцями — кожен вхід масштабується значенням іншого
reluмаршрутизує — пропускає його або повністю блокує
tanhпослаблює — масштабує на 1t21 - t^2, що не більше 1 і зазвичай менше

Кожна з них використовує += і ніколи =. Це правило «підсумовувати між шляхами», закодоване. Вузол, який живить двох споживачів, викликається двічі, і два внески додаються самі.

Потім драйвер — єдина частина з глобальним знанням:

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 створює топологічне впорядкування графа: кожен вузол зʼявляється після всіх своїх входів. Прохід цим списком у зворотному напрямку гарантує, що коли ви викликаєте _backward вузла, його власний градієнт уже повний — кожен downstream-споживач уже зробив внесок. Помиліться з порядком, і ви проштовхнете назад напівготовий градієнт, що дасть неправильну відповідь без жодного повідомлення про помилку.

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. Те саме число, від програми, якій повідомили правило для +, правило для *, правило для tanh — і нічого про цю схему.

Дві незалежні перевірки, бо «збігається з тим, що я вивів» — слабкий тест, коли обидві частини робила та сама людина.

Чисельне диференціювання. Трохи зсуньте вхід і виміряйте. Центральна різниця L(x+h)L(xh)2h\frac{L(x+h) - L(x-h)}{2h} оцінює похідну без жодного математичного аналізу:

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-рушій, написаний людьми, які роблять це професійно:

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 для 64-бітного float: два рушії виконують ідентичну арифметику. Тримайте чисельну перевірку під рукою — це інструмент для налагодження зворотного проходу нового шару, і саме завдяки йому неправильний градієнт узагалі можна знайти.

Та сама схема, інші входи. Задайте 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

Градієнт, який перетинає вузол tanh\tanh, впав у 9 945 разів. Усе upstream від нього — у реальній мережі кожен шар перед ним — фактично не отримує нічого. Два шляхи через схему замовкли; сигнал усе ще несе лише пряме зʼєднання, яке обходить tanh\tanh.

Це проблема зникаючого градієнта в одному вузлі. Складіть сорок шарів tanh\tanh і перемножте сорок таких множників, і ранні шари повністю перестануть навчатися. До речі, це також аргумент на користь skip connections, який тут видно в мініатюрі: шлях, що обійшов нелінійність, — єдиний, який вижив.

Що насправді робить zero_grad і чому баг ховається

Посилання на розділ: Що насправді робить zero_grad і чому баг ховається

Кожен _backward використовує +=. Це правильно — саме так шляхи підсумовуються. Але з цього випливає наслідок, на який натрапляють усі: градієнти накопичуються і між викликами backward() теж. Рушій не має уявлення, що ваш другий виклик — це новий крок навчання, а не ще один шлях у тому самому графі.

Тож цикл навчання має очищати їх:

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

У PyTorch це optimizer.zero_grad(), і звична порада каже, що якщо забути його, навчання зламається. Тож видалімо ці два рядки й подивімося, наскільки воно зламане. Ті самі seeds, усе те саме, 200 кроків XOR:

learning rateseedзі скиданнямбез скидання
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 версія з багом перемагає в кожному рядку. Вона сходиться тоді, коли правильна версія застрягає.

Це не випадковість, і це варто зрозуміти, бо саме тому цей баг так важко зловити. Якщо ви ніколи не очищаєте градієнт, то на кроці kk параметр оновлюється сумою всіх градієнтів, обчислених дотепер. На втраті, яка продовжує вказувати приблизно в той самий бік, ця сума стабільно зростає, і ефект схожий на learning rate, що збільшується сам собою. При η=0.05\eta = 0.05, коли правильний алгоритм повзе, неконтрольований розмір кроку виглядає саме як виправлення.

Потім подивіться на три нижні рядки. При η=0.3\eta = 0.3 той самий механізм розносить модель — loss 8.0 отримує модель, що схлопнулася до сталої ±1\pm 1, це половина від 16, які коштували б чотири максимально неправильні відповіді — — тоді як правильна версія тепер сходиться чисто.

Отже, чесне твердження не «завжди викликайте zero_grad, інакше модель не навчатиметься». Воно таке: без цього ви більше не запускаєте gradient descent. Ви запускаєте щось, чий розмір кроку дрейфує вгору зі швидкістю, яку ніхто не обирав, і це здаватиметься робочим — іноді кращим за справжній алгоритм — аж доки перестане. І тоді ви звинуватите learning rate, ініціалізацію або дані. Саме так виглядають найгірші баги в machine learning: вони не падають, вони перетворюють алгоритм на інший алгоритм, який іноді показує кращий результат.

Коли рушій готовий, нейронна мережа — це зовсім небагато коду. Нейрон — це dot product, bias і activation; шар — список нейронів; мережа — список шарів.

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

У всьому цьому немає зворотного проходу. Жодного рядка. Клас Value уже знає, як диференціювати все, що ці класи випадково побудують, і саме в цьому сенс того, що ми спершу написали його: autodiff-рушій не знає, що його використовують для нейронної мережі.

Тепер задача з Розділу 1. Два входи, два приховані вузли, один вихід, девʼять параметрів:

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

Чотири з чотирьох. Функцію, яку не може обчислити жоден перцептрон — у Розділі 1 це було доведено чотирма нерівностями, які вимагали, щоб bb було водночас додатним і відʼємним, — обчислено девʼятьма числами, знайденими автоматично.

Приємна частина не в тому, що це працює. А в тому, що можна побачити як, бо з двома прихованими вузлами проміжне представлення — це точка на площині, і її можна просто надрукувати.

Після навчання до втрати 0.001241 ось куди потрапляє кожен вхід після прихованого шару і що з ним робить вихідний нейрон:

вхідвихід прихованого шаруоцінка виходумітка
(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

Подивіться на перший і четвертий рядки. Входи (0,0)(0,0) і (1,1)(1,1) — діагонально протилежні кути квадрата, настільки далекі один від одного, наскільки взагалі можуть бути дві точки в цій задачі, — а прихований шар відображає їх у (0.82,0.85)(0.82, -0.85) і (0.84,0.86)(0.84, -0.86). Майже в ту саму точку. Шар склав площину так, що два відхилені кути лягли один на одного, і коли вони опинилися в одному місці, одна пряма відділяє їх від двох інших.

І вихідний нейрон — саме ця пряма. Його вивчені параметри — w=(3.1153, +3.0893)\mathbf{w} = (-3.1153,\ +3.0893), b=+2.7697b = +2.7697, тож його межа рішення така:

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

Це пряма лінія — перцептрон, той самий обʼєкт із Розділу 1, без змін. Тоді він не міг розвʼязати XOR і зараз не може. Змінилося те, що він більше не дивиться на вхід; він дивиться на простір, який перший шар побудував для нього, і в цьому просторі задача лінійно роздільна.

Саме це і є вивчене представлення, і варто бути точними, бо цю фразу в решті курсу й у решті галузі вживають вільно. Це не стиснення, не резюме й не embedding у якомусь містичному сенсі. Це зміна координат, вивчена, а не спроєктована, чия єдина робота — полегшити роботу наступного шару.

Теорема універсальної апроксимації і чого вона не каже

Посилання на розділ: Теорема універсальної апроксимації і чого вона не каже

Тут є теорема, і її зазвичай цитують погано.

Cybenko у 1989 році та Hornik у 1991 році довели, що feedforward-мережа з одним прихованим шаром і відповідною функцією активації може апроксимувати будь-яку неперервну функцію на компактній множині з будь-якою бажаною точністю, якщо має достатньо прихованих вузлів.34 Це справжній і важливий результат: він каже, що архітектура не є обмеженням.

Тепер прочитайте, що він пропускає. Він не каже, скільки вузлів — межа може бути астрономічно великою. Він не каже, що ваги можна знайти; він стверджує існування, а gradient descent з випадкового старту — не оракул. І він нічого не каже про поведінку на даних, яких ви не бачили, а це друга половина Розділу 6.

Розрив між «існує» і «можна знайти» не академічний. Ось та сама задача XOR: по 50 випадкових ініціалізацій, 1000 кроків, змінювався лише розмір прихованого шару:

приховані вузлиініціалізації, що досягли 4/4
238 / 50 (76 %)
349 / 50 (98 %)
450 / 50 (100 %)
847 / 50 (94 %)

З мінімально життєздатною архітектурою один запуск із чотирьох так і не доходить — він осідає в конфігурацію, з якої не може спуститися, рівно той локальний мінімум, який Розділ 3 показав на одновимірній поверхні. Додайте один вузол — і відмови майже зникають, не тому що мережа стала виразнішою (двох вузлів уже достатньо — 38 запусків це доводять), а тому що додаткові виміри дають спуску більше напрямків для втечі.

А потім вісім вузлів працюють трохи гірше, ніж чотири. За фіксованого learning rate і бюджету кроків більша місткість не є монотонно кращою. Будь-хто, хто каже вам, що виправлення для застряглої мережі — це завжди більша мережа, екстраполює з середини цієї таблиці.

Це той самий урок, що й теорема збіжності в Розділі 1, і це буде той самий урок у Розділі 10 про scaling laws, у формі, яку дає той розділ: прогноз втрати — це не прогноз здатності, за яку ви платите, а відстань між ними — саме там живе інженерія.

Показати подробиці

Опційно: матрична форма і чому код вище її не використовує.

Усе тут було записано по одному скаляру за раз; це найясніший спосіб побачити механізм і найповільніший спосіб його виконувати. На практиці шар — це матричне множення, а зворотний прохід для 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}}

Транспонування — не трюк для запамʼятовування; це те, як правило сумування між шляхами виглядає, коли шляхи індексовані елементами матриці. Загальний обʼєкт — якобіан, матриця всіх часткових похідних усіх виходів відносно всіх входів, а зворотний режим — це саме обчислення vector-Jacobian product без явного формування якобіана. Це важливо, бо для шару з 4096 входами й 4096 виходами така матриця має шістнадцять мільйонів елементів, і її ніколи не варто будувати.

Вам нічого з цього не потрібно, щоб стежити за наступними розділами; скалярна версія робить усе, що робить матрична, тільки повільніше. Це стає необхідним у Розділі 9, де форми перестають бути очевидними.

Тепер у вас є мережа, яка навчається. Це менше досягнення, ніж здається, бо ваша мережа навчається на чотирьох прикладах і вимірюється на тих самих чотирьох.

Запустіть той самий код на справжньому наборі даних, і зʼявиться новий набір проблем, жодна з яких не про градієнти. Втрата якийсь час зменшується, а потім зупиняється. Або вона зменшується на тренувальних даних і зростає на всьому іншому. Або не рухається взагалі з першого кроку, і причиною виявляється діапазон початкових випадкових ваг. Або вхід одного вузла став відʼємним на кожному прикладі в третю епоху, і відтоді він мертвий — мовчки, забравши із собою шматок місткості моделі.

Це не екзотичні відмови; це нормальний стан мережі, яку щойно написали, і жодна з них не оголошує про себе. Градієнт правильний — ви звірили його з PyTorch до шістнадцяти десяткових знаків — а модель усе одно не навчається.

Розділ 6 саме про це: ініціалізація, нормалізація, overfitting і regularisation, а також діагностична звичка питати, що саме з цього відбувається, перш ніж щось змінювати. Це різниця між мережею, яка запускається, і мережею, яка працює.


Клас Value у цьому розділі напряму походить від micrograd Andrej Karpathy, а його відео The spelled-out intro to neural networks and backpropagation: building micrograd — найкращі три години, які ви можете витратити на цей матеріал, якщо хочете почути пояснення вдруге від когось іншого. Його пост 2016 року Yes you should understand backprop доводить, чому варто написати такий рушій самостійно, і входить до обовʼязкового читання в Stanford CS224n. Нотатки CS231n про backpropagation (cs231n.github.io/optimization-2) — канонічний виклад патернів потоку, зведених у таблицю вище. Для математики як математичного аналізу на графі, а не як фольклору нейронних мереж, розділ 5.6 книги Mathematics for Machine Learning Deisenroth, Faisal і Ong незвично ясний; а огляд Baydin, Pearlmutter, Radul і Siskind Automatic Differentiation in Machine Learning: a Survey (arXiv:1502.05767) — головна довідка для всієї галузі, включно з компромісом між прямим і зворотним режимами, обговореним вище.

  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). Накопичення у зворотному режимі, за шістнадцять років до того, як воно дійшло до цієї галузі, і з зовсім іншою мотивацією.

  2. Rumelhart, D. E., Hinton, G. E. and Williams, R. J. Learning representations by back-propagating errors. Nature 323, pp. 533–536 (1986). Стаття, яка зробила метод відомим, і джерело прочитання прихованих вузлів як вивчених представлень, на якому цей розділ зосереджує вимірювання в секції Що зробив прихований шар.

  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: результат залежить від того, що активація неполіноміальна, а не від того, що вона sigmoidal.

Готові довірити вибір моделі LIA?

Створюйте з усіма моделями ШІ в одному місці — почніть безкоштовно вже сьогодні.