Backpropagation з нуля: спершу рушій, потім мережа
Напишіть 120-рядковий autodiff-рушій на чистому Python, звірте його з PyTorch до 16 знаків і зрозумійте zero_grad, видаливши його.
На цій сторінці
Чотири розділи позаду, і посеред курсу є прогалина.
Розділ 3 дав нам gradient descent: щоб поліпшити параметр, знайдіть нахил втрати відносно нього й зробіть крок униз. Розділ 4 дав нам втрату, якою варто спускатися. Але в обох випадках похідну обчислювали вручну — одна модель, один параметр, один рядок математичного аналізу, і все вміщалося на сторінці.
Тепер складіть два шари. Вихід першого подається в другий, тож кожна вага в першому впливає на втрату через кожен нейрон у другому. Мережа з двома прихованими шарами по сто вузлів у кожному має приблизно двадцять тисяч параметрів, і кожному потрібна власна часткова похідна тієї самої втрати. Робити це вручну не нудно; це неможливо, і це залишається неможливим для кожної архітектури в решті курсу.
Вихід — не краща нотація. Вихід — усвідомити, що похідну композиції можна обчислити механічно, програмою, зі структури самого обчислення — і що якщо робити це в правильному напрямку, ви отримаєте всі двадцять тисяч похідних приблизно за ціну одного обчислення втрати.
Цей механізм називається автоматичним диференціюванням у зворотному режимі. Застосований до нейронної мережі, він називається backpropagation, і до кінця цього розділу ви напишете його приблизно у 120 рядках Python без бібліотек, звірите з PyTorch і використаєте, щоб розвʼязати задачу XOR, яка зламала перцептрон у Розділі 1.
Спершу: чому нелінійність узагалі потрібна
Посилання на розділ: Спершу: чому нелінійність узагалі потрібнаПерш ніж будувати машину, треба закрити одне питання, бо якби відповідь була іншою, будувати було б нічого.
Перцептрон провалився на XOR, бо одна пряма не може розділити чотири точки. Очевидне виправлення — скласти шари: пропустити вхід через один лінійний шар, потім через інший. Це допоможе?
Ні, і доказ займає два рядки. Лінійний шар — це . Подайте його в інший, , і підставте:
Композиція — це з і . Стек лінійних шарів — це один лінійний шар. Десять таких, тисяча таких: усе ще одна пряма, усе ще нездатна розвʼязати 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Не приблизно рівні. Ідентичні біт у біт, бо це та сама арифметика, лише переставлена.
Отже, глибина сама по собі нічого не дає. Щось дає вставлення нелінійної функції між шарами — і саме тому існують функції активації. Це не біологічна прикраса й не трюк нормалізації. Без неї другий шар — декорація.
Ланцюгове правило, на папері, зі спільним вузлом
Посилання на розділ: Ланцюгове правило, на папері, зі спільним вузломТепер математика, і це одне правило, яке ви вже знаєте, застосоване в трохи незвичному місці.
Ланцюгове правило для однієї змінної каже: якщо залежить від , а залежить від , тоді . Похідні множаться уздовж ланцюга.
Тут важлива частина — що відбувається, коли змінна живить більше ніж один подальший шлях. Якщо впливає на через і також через , внески додаються:
Множте вздовж шляху, підсумовуйте між шляхами. Це і є весь backpropagation, і кожна деталь реалізації в решті розділу — включно з += у коді та викликом zero_grad(), на якому спотикається кожен, хто пише свій перший цикл навчання, — є прямим наслідком цього другого слова.
Візьмімо конкретну схему з пʼяти операцій, де і :
Зверніть увагу, що зʼявляється тричі: у , у і безпосередньо в . Виконайте зворотний прохід на папері, справа наліво, починаючи з :
Через додавання
Посилання на розділ: Через додавання, тож , а прямий шлях вносить . Додавання розподіляє вхідний градієнт без змін на обидва входи.
Через tanh
Посилання на розділ: Через tanhз , тож .
Через множення
Посилання на розділ: Через множення, тож і . Множення міняє місцями: градієнт кожного входу масштабується значенням іншого входу.
Зберіть три шляхи в x
Посилання на розділ: Зберіть три шляхи в xЧерез : . Через : . Безпосередньо: .
Запамʼятайте це число. За кілька сторінок програма видасть його, хоча їй нічого з цього не розповідали.
Будуємо рушій
Посилання на розділ: Будуємо рушійІнсайт, який робить це програмованим: кожен із цих кроків був локальним. Щоб проштовхнути градієнт через вузол множення, потрібні вхідний градієнт і два збережені вхідні значення — нічого про решту схеми. Кожна операція знає, як диференціювати саму себе.
Тож зробімо число, яке памʼятає, що його породило.
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, з яких було обчислено це значення, тобто ребра графа. А _backward — замикання, яке встановлює кожна операція: воно знає, як проштовхнути градієнт цього вузла на один крок назад до його входів.
Кожен оператор має ту саму форму: обчислити вихід, записати батьків, встановити локальне правило.
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 | послаблює — масштабує на , що не більше 1 і зазвичай менше |
Кожна з них використовує += і ніколи =. Це правило «підсумовувати між шляхами», закодоване. Вузол, який живить двох споживачів, викликається двічі, і два внески додаються самі.
Потім драйвер — єдина частина з глобальним знанням:
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-споживач уже зробив внесок. Помиліться з порядком, і ви проштовхнете назад напівготовий градієнт, що дасть неправильну відповідь без жодного повідомлення про помилку.
Чи збігається це з папером?
Посилання на розділ: Чи збігається це з папером?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. Те саме число, від програми, якій повідомили правило для +, правило для *, правило для tanh — і нічого про цю схему.
Дві незалежні перевірки, бо «збігається з тим, що я вивів» — слабкий тест, коли обидві частини робила та сама людина.
Чисельне диференціювання. Трохи зсуньте вхід і виміряйте. Центральна різниця оцінює похідну без жодного математичного аналізу:
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-рушій, написаний людьми, які роблять це професійно:
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 для 64-бітного float: два рушії виконують ідентичну арифметику. Тримайте чисельну перевірку під рукою — це інструмент для налагодження зворотного проходу нового шару, і саме завдяки йому неправильний градієнт узагалі можна знайти.
Насичення, виміряне
Посилання на розділ: Насичення, вимірянеТа сама схема, інші входи. Задайте і , що робить :
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Градієнт, який перетинає вузол , впав у 9 945 разів. Усе upstream від нього — у реальній мережі кожен шар перед ним — фактично не отримує нічого. Два шляхи через схему замовкли; сигнал усе ще несе лише пряме зʼєднання, яке обходить .
Це проблема зникаючого градієнта в одному вузлі. Складіть сорок шарів і перемножте сорок таких множників, і ранні шари повністю перестануть навчатися. До речі, це також аргумент на користь skip connections, який тут видно в мініатюрі: шлях, що обійшов нелінійність, — єдиний, який вижив.
Що насправді робить zero_grad і чому баг ховається
Посилання на розділ: Що насправді робить zero_grad і чому баг ховаєтьсяКожен _backward використовує +=. Це правильно — саме так шляхи підсумовуються. Але з цього випливає наслідок, на який натрапляють усі: градієнти накопичуються і між викликами backward() теж. Рушій не має уявлення, що ваш другий виклик — це новий крок навчання, а не ще один шлях у тому самому графі.
Тож цикл навчання має очищати їх:
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 rate | seed | зі скиданням | без скидання |
|---|---|---|---|
| 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 версія з багом перемагає в кожному рядку. Вона сходиться тоді, коли правильна версія застрягає.
Це не випадковість, і це варто зрозуміти, бо саме тому цей баг так важко зловити. Якщо ви ніколи не очищаєте градієнт, то на кроці параметр оновлюється сумою всіх градієнтів, обчислених дотепер. На втраті, яка продовжує вказувати приблизно в той самий бік, ця сума стабільно зростає, і ефект схожий на learning rate, що збільшується сам собою. При , коли правильний алгоритм повзе, неконтрольований розмір кроку виглядає саме як виправлення.
Потім подивіться на три нижні рядки. При той самий механізм розносить модель — loss 8.0 отримує модель, що схлопнулася до сталої , це половина від 16, які коштували б чотири максимально неправильні відповіді — — тоді як правильна версія тепер сходиться чисто.
Отже, чесне твердження не «завжди викликайте zero_grad, інакше модель не навчатиметься». Воно таке: без цього ви більше не запускаєте gradient descent. Ви запускаєте щось, чий розмір кроку дрейфує вгору зі швидкістю, яку ніхто не обирав, і це здаватиметься робочим — іноді кращим за справжній алгоритм — аж доки перестане. І тоді ви звинуватите learning rate, ініціалізацію або дані. Саме так виглядають найгірші баги в machine learning: вони не падають, вони перетворюють алгоритм на інший алгоритм, який іноді показує кращий результат.
Мережа і нарешті XOR
Посилання на розділ: Мережа і нарешті XORКоли рушій готовий, нейронна мережа — це зовсім небагато коду. Нейрон — це dot product, bias і activation; шар — список нейронів; мережа — список шарів.
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. Два входи, два приховані вузли, один вихід, девʼять параметрів:
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 це було доведено чотирма нерівностями, які вимагали, щоб було водночас додатним і відʼємним, — обчислено девʼятьма числами, знайденими автоматично.
Що зробив прихований шар
Посилання на розділ: Що зробив прихований шарПриємна частина не в тому, що це працює. А в тому, що можна побачити як, бо з двома прихованими вузлами проміжне представлення — це точка на площині, і її можна просто надрукувати.
Після навчання до втрати 0.001241 ось куди потрапляє кожен вхід після прихованого шару і що з ним робить вихідний нейрон:
| вхід | вихід прихованого шару | оцінка виходу | мітка |
|---|---|---|---|
Подивіться на перший і четвертий рядки. Входи і — діагонально протилежні кути квадрата, настільки далекі один від одного, наскільки взагалі можуть бути дві точки в цій задачі, — а прихований шар відображає їх у і . Майже в ту саму точку. Шар склав площину так, що два відхилені кути лягли один на одного, і коли вони опинилися в одному місці, одна пряма відділяє їх від двох інших.
І вихідний нейрон — саме ця пряма. Його вивчені параметри — , , тож його межа рішення така:
Це пряма лінія — перцептрон, той самий обʼєкт із Розділу 1, без змін. Тоді він не міг розвʼязати XOR і зараз не може. Змінилося те, що він більше не дивиться на вхід; він дивиться на простір, який перший шар побудував для нього, і в цьому просторі задача лінійно роздільна.
Саме це і є вивчене представлення, і варто бути точними, бо цю фразу в решті курсу й у решті галузі вживають вільно. Це не стиснення, не резюме й не embedding у якомусь містичному сенсі. Це зміна координат, вивчена, а не спроєктована, чия єдина робота — полегшити роботу наступного шару.
Теорема універсальної апроксимації і чого вона не каже
Посилання на розділ: Теорема універсальної апроксимації і чого вона не кажеТут є теорема, і її зазвичай цитують погано.
Cybenko у 1989 році та Hornik у 1991 році довели, що feedforward-мережа з одним прихованим шаром і відповідною функцією активації може апроксимувати будь-яку неперервну функцію на компактній множині з будь-якою бажаною точністю, якщо має достатньо прихованих вузлів.34 Це справжній і важливий результат: він каже, що архітектура не є обмеженням.
Тепер прочитайте, що він пропускає. Він не каже, скільки вузлів — межа може бути астрономічно великою. Він не каже, що ваги можна знайти; він стверджує існування, а gradient descent з випадкового старту — не оракул. І він нічого не каже про поведінку на даних, яких ви не бачили, а це друга половина Розділу 6.
Розрив між «існує» і «можна знайти» не академічний. Ось та сама задача XOR: по 50 випадкових ініціалізацій, 1000 кроків, змінювався лише розмір прихованого шару:
| приховані вузли | ініціалізації, що досягли 4/4 |
|---|---|
| 2 | 38 / 50 (76 %) |
| 3 | 49 / 50 (98 %) |
| 4 | 50 / 50 (100 %) |
| 8 | 47 / 50 (94 %) |
З мінімально життєздатною архітектурою один запуск із чотирьох так і не доходить — він осідає в конфігурацію, з якої не може спуститися, рівно той локальний мінімум, який Розділ 3 показав на одновимірній поверхні. Додайте один вузол — і відмови майже зникають, не тому що мережа стала виразнішою (двох вузлів уже достатньо — 38 запусків це доводять), а тому що додаткові виміри дають спуску більше напрямків для втечі.
А потім вісім вузлів працюють трохи гірше, ніж чотири. За фіксованого learning rate і бюджету кроків більша місткість не є монотонно кращою. Будь-хто, хто каже вам, що виправлення для застряглої мережі — це завжди більша мережа, екстраполює з середини цієї таблиці.
Це той самий урок, що й теорема збіжності в Розділі 1, і це буде той самий урок у Розділі 10 про scaling laws, у формі, яку дає той розділ: прогноз втрати — це не прогноз здатності, за яку ви платите, а відстань між ними — саме там живе інженерія.
Показати подробиці
Опційно: матрична форма і чому код вище її не використовує.
Усе тут було записано по одному скаляру за раз; це найясніший спосіб побачити механізм і найповільніший спосіб його виконувати. На практиці шар — це матричне множення, а зворотний прохід для такий:
Транспонування — не трюк для запамʼятовування; це те, як правило сумування між шляхами виглядає, коли шляхи індексовані елементами матриці. Загальний обʼєкт — якобіан, матриця всіх часткових похідних усіх виходів відносно всіх входів, а зворотний режим — це саме обчислення 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) — головна довідка для всієї галузі, включно з компромісом між прямим і зворотним режимами, обговореним вище.
Примітки
Посилання на розділ: Примітки-
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). Накопичення у зворотному режимі, за шістнадцять років до того, як воно дійшло до цієї галузі, і з зовсім іншою мотивацією. ↩
-
Rumelhart, D. E., Hinton, G. E. and Williams, R. J. Learning representations by back-propagating errors. Nature 323, pp. 533–536 (1986). Стаття, яка зробила метод відомим, і джерело прочитання прихованих вузлів як вивчених представлень, на якому цей розділ зосереджує вимірювання в секції Що зробив прихований шар. ↩
-
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: результат залежить від того, що активація неполіноміальна, а не від того, що вона sigmoidal. ↩