Від нахилу до зворотного поширення: створюємо невеликий рушій автоматичного диференціювання
У нашій попередній статті про те, як нейронні мережі навчаються, ми пройшли через базовий навчальний цикл: модель робить прогноз, ми вимірюємо, наскільки вона неправильна, використовуючи функцію втрат, ми налаштовуємо ваги, а потім повторюємо. Щоб правильно відрегулювати кожну вагу, нам потрібно знати, чи зменшить її втрату збільшення чи зменшення. Один простий спосіб це з’ясувати – злегка підняти або опустити вагу, перерахувати втрату та побачити, чи покращилася вона. Проблема в тому, що реальні моделі можуть мати мільйони чи мільярди таких ваг. Тестування кожного окремо було б неймовірно повільним.
Ось де на допомогу приходять похідні. Вони дають нам швидкий шлях, не перевіряючи кожну можливу зміну. Для кожної ваги похідна говорить нам, наскільки втрата чутлива до невеликої зміни цієї ваги, припускаючи, що всі інші ваги залишаються незмінними. Якщо похідна додатна, ми знаємо, що нам слід зменшити цю вагу, щоб зменшити втрати. Якщо він негативний, ми повинні його збільшити.
Щоб побачити, як ми отримуємо ці похідні, ми почнемо з простого підходу під назвою кінцеві різниці, де ми оцінюємо нахил, порівнюючи втрати при двох сусідніх значеннях ваги. Потім ми побачимо, як похідні інструменти дозволяють нам знайти правильний напрямок для зменшення втрат без проб і помилок, і як ми можемо об’єднати їх разом через послідовність операцій. Нарешті, ми створимо невеликий механізм Python, який автоматично розраховує градієнти за допомогою техніки під назвою автоматичне диференціювання у зворотному режимі (або авторозрізнення), і ми використаємо його для навчання простої моделі.
Знайти напрямок шляхом тестування найближчих значень
Припустимо, наші втрати залежать від одного параметра :
При втрата . Нам слід збільшити або зменшити , щоб зменшити його?
Ми могли б спробувати обидва напрямки:
| Параметр | Втрата |
|---|---|
Зменшення на , від до , зменшило втрату від до . Збільшення на , від до , збільшило втрату з до . Ці оцінки припускають, що невелике зниження від зменшить втрати.
Важливо підкреслити, що напрямок, який зменшує втрати, залежить від поточного значення . Якби ми почали з , втрати також складали б , але збільшення до знизило б їх до . Зменшення до збільшить його до . Для цієї функції рух до нуля зменшує втрати: від додатного значення ми маємо зменшити , а від від’ємного – збільшити.
Ось чому ми описуємо напрямок як локальний: він говорить нам, чи зменшить втрати невелике збільшення чи зменшення поточного значення параметра. Це не означає, що зменшення завжди допомагає, або що ми можемо продовжувати зменшувати його нескінченно. Починаючи з , ми можемо зменшити збитки, рухаючись до нуля, але як тільки ми пройдемо нуль, продовжуючи в тому ж напрямку, збитки знову зростають. Нам потрібно враховувати як те, де ми зараз, так і те, як далеко ми рухаємося.
Давайте тепер використаємо ті самі значення, щоб оцінити, як швидко змінюються втрати, коли ми змінюємо параметр. Наше перше порівняння підказало нам, у якому напрямку зменшилися втрати. Цього разу ми розділимо зміну втрат на зміну параметра, щоб оцінити нахил. Використовуючи значення і :
Це співвідношення є середньою зміною втрати на одиницю зміни параметра протягом цього інтервалу. Це дає нам оцінку нахилу при , яка називається кінцево-різницевою оцінкою. Його позитивний знак передбачає зменшення , щоб зменшити втрати, тоді як його величина оцінює, наскільки втрати чутливі до параметра. Тут ми повторно використали значення втрат таблиці; обчислення коефіцієнта не вимагало додаткових оцінок збитків.
Записуючи зміну параметра як , це пряма різниця:
З мільйонами параметрів оцінка нахилу таким чином для кожного з них вимагала б мільйонів додаткових оцінок втрат. Щоб уникнути таких оцінок, нам потрібне правило, яке безпосередньо дає нам нахил.
Від кінцевих різниць до похідної
По-перше, розглянемо, що станеться з нашою оцінкою, коли ми зменшимо . У ми використовували і отримали . З ми отримуємо:
Оцінка наближається до . Похідна, написана або , є межею цього співвідношення, коли наближається до нуля:
Для ми можемо спростити це порівняння алгебраїчно. Для ненульового :
Коли наближається до нуля, відношення наближається до , що дає нам правило похідної:
У тепер ми можемо оцінити безпосередньо, без обчислення для виявлення нахилу.
Диференціювання дає нам правило для обчислення локальної швидкості зміни. Правило походить від аналізу того, як змінюється функція; коли ми його маємо, ми можемо застосувати його до поточного значення параметра.
Ми все ще розраховуємо втрати на кожному кроці тренування. Похідна дозволяє вибрати локальний напрямок оновлення без додаткової оцінки втрат для кожного збурення параметра. Після оновлення параметрів наступний крок обчислює нові втрати та градієнт.
Використання похідної для вибору напрямку
Похідна показує, наскільки втрати чутливі до невеликої зміни параметра. У значення означає:
Збільшення на збільшить втрати приблизно на . Зменшення його на зменшить втрати приблизно на . Ми можемо вибрати локальний напрямок за знаком похідної, не перевіряючи обидві можливості.
При похідною є , тому невелике збільшення зменшує втрати. При похідна дорівнює нулю; для цієї конкретної функції, це її мінімум. Нульова похідна не встановлює мінімум для кожної функції.
Візуалізація схилу
Похідна — це нахил дотичної до кривої втрат. Скінченно-різницевою оцінкою є нахил січної, що проходить через дві точки. Коли ці точки зближуються, нахил січної наближається до похідної.
Зменште до нижче та спостерігайте, як помаранчевий секанс наближається до фіолетового тангенса. При оцінка нахилу стає , близькою до похідної . Перемістіть від до , щоб побачити, як напрям покращення зміниться.
Positive slope: decrease a slightly to reduce the loss. The finite-difference error is h = 0.8.
Purpose: visualize the meaning of a derivative. The training experiments compute gradients with automatic differentiation.
Похідна описує локальні зміни. На зменшення аж до призведе до збільшення втрат від до , незважаючи на початок у запропонованому напрямку. Розмір оновлення також має значення.
Для цієї функції точна зміна втрат становить . Похідна фіксує частину першого порядку; він не передбачає точно довільних кінцевих змін. Пізніше ми знову використаємо кінцеві різниці, щоб незалежно перевірити наш механізм диференціювання.
Три способи обчислення або оцінки похідної
Константа зсунула криву втрат вгору, не змінивши її нахилу. Щоб порівняти методи диференціювання, використовуйте простішу функцію у . Його похідною є :
| метод | Що він використовує | Розрахунок на | Результат |
|---|---|---|---|
| Символічна диференціація | Правила, застосовані до виразу | Отримайте , потім замініть | |
| Кінцеві різниці | Виходи на сусідніх входах | Приблизно | |
| Автоматична диференціація | Виконувані операції та їх локальні похідні правила | Для x * x додайте внески |
Символічна диференціація створює похідний вираз, який ми можемо оцінити пізніше. Autodiff оцінює правила похідних, використовуючи значення, записані під час обчислення. Для цієї згладженої функції обидві обчислюють похідну у вибраній точці з урахуванням арифметики з плаваючою комою в коді. Кінцеві різниці оцінюють його шляхом порівняння результатів у різних точках.
Зворотне поширення нейронної мережі використовує третій підхід. Ми надаємо похідні правила для базових операцій, а система об’єднує їх за допомогою правила ланцюга. Він може обчислити градієнт при поточних значеннях параметрів без створення одного великого символічного виразу для всієї мережі.
Від локальних похідних до ланцюгового правила
Додавання і множення мають власні правила похідних. Спочатку розглянемо незалежні входи і . Як зміна впливає на вихід , залишаючи фіксованим?
| Операція | Похідна відносно | У | Скінченно-різницева перевірка |
|---|---|---|---|
Крім того, зміна змінює вихід на ту саму величину. Для множення зміна масштабується на . Ми використовуємо локальне правило похідної кожної операції безпосередньо; ще немає проміжних операцій. Символ означає, що ми змінюємо один вхід, утримуючи інші незалежні входи фіксованими.
З’єднайте операції
Тепер дозвольте одній операції переходити в іншу:
При пряме обчислення дає і . Ми могли б розширити весь вираз і відрізнити його:
Правило ланцюга дозволяє отримати ту саму похідну, дотримуючись проміжних співвідношень. Є два шляхи від до :
- Через додавання : локальна похідна від відносно дорівнює , а локальна похідна від множення щодо дорівнює . Їх продукт - .
- Безпосередньо в множенні, : якщо інший його операнд фіксований, локальна похідна .
Зміна впливає на обидва шляхи, тому ми додаємо їхні внески:
Утримання фіксованим описує лише локальне правило множення. У повному обчисленні також змінюється, коли змінюється ; перший шлях пояснює цей ефект.
Для порівняння, кінцева різниця оцінює повну функцію на найближчому вході:
Ми також можемо розрахувати чутливість до , використовуючи ті самі збережені значення. Є один шлях, , який дає . Жодна похідна не вимагала іншої оцінки на збуреному вході.
Правило ланцюга дозволяє обчислити, як зміна в одній змінній впливає на кінцевий результат, дотримуючись зв’язків між проміжними змінними. Якщо цей результат є втратою, той самий процес повідомляє нам, як кожен параметр впливає на втрату. Помножте локальні похідні вздовж шляху та додайте вклади з різних шляхів.
Зворотне розповсюдження організовує це обчислення у зворотному напрямку від кінцевого результату, повторно використовуючи проміжні значення та накопичені градієнти. Він все ще обчислює похідну для кожного параметра; це дозволяє уникнути повторення всього прямого обчислення для кожного збурення параметра.
Нелінійні зв’язки роблять поточне значення важливим
Лінійна залежність має постійний нахил: однакова зміна вхідних даних завжди призводить до однакових змін вихідних даних. У нелінійному відношенні локальний ефект може залежати від поточного входу. Обчислення дає нам спосіб обчислити зміну чутливості.
Розглянемо спрощений сферичний газовий контейнер з регульованим радіусом. Тримайте кількість газу та його температуру постійними, і припустімо, що тиск і об’єм задовольняють у вибраних нами одиницях. Це моделює розширення того самого газу; додавання газу під час надування справжньої повітряної кулі змінило б припущення.
Радіус визначає об’єм, а об’єм – тиск:
Якщо ми трохи збільшимо радіус, на скільки зміниться тиск? Обидва зв’язки відомі, але жоден не має постійної швидкості змін. Їх місцевими похідними є:
Перший говорить нам, скільки змінюється обсяг на одиницю радіуса; другий говорить нам, скільки змінюється тиск на одиницю об’єму. Ланцюгове правило об’єднує їх:
При об’єм становить приблизно , тиск – , а . Збільшення радіуса на , отже, передбачає зміну тиску приблизно на . Оцінка нового тиску дає фактичну зміну близько ; похідна дає локальне наближення.
Зберігайте на і перемістіть з на . Таке ж збільшення радіуса призводить до значно меншої зміни тиску при більшому радіусі. Потім збільште крок, щоб побачити, як передбачення дотичної стане менш точним.
The negative slope means a small radius increase reduces pressure. The tangent estimates that change at the current radius; larger steps can differ substantially from its prediction.
Model: fixed amount of gas at constant temperature, with PV = 100 in chosen units. The extra pressure evaluation checks the tangent prediction. The derivative comes from the two local rules.
Зв’язок із нейронними мережами – це зміна чутливості. Вплив ваги на остаточну втрату залежить від поточних значень уздовж шляхів обчислення. Нелінійні активації та функція втрати можуть змінити цю чутливість, тому ми знову оцінюємо локальні похідні правила після кожного оновлення. Навіть афінна модель передбачення, така як , може мати нелінійні втрати як функцію своїх ваг, коли ми зводимо її помилку передбачення в квадрат.
Авторізниця через одне множення
Поверніться до і напишіть це як програму. Тепер ми простежимо, як механізм записує та застосовує місцеві правила:
def f(x):
return x * xПравило множення застосовується до обох операндів. Якщо , його локальні похідні:
Для x * x обидва операнди посилаються на той самий вхід. У кожен операнд є , тому кожна локальна похідна також є . Зміна впливає на результат через шляхи обох операндів. Їхні внески додають:
Виберіть , потім . Слідкуйте за кожним операндом по черзі, щоб побачити, як накопичений градієнт досягає .
The trace uses the multiplication edges from the article’s scalar engine. Changing x starts a new trace at that value.
Віджет починає зворотний перехід із похідною виводу відносно самого себе: . Кожен шлях множить цей вхідний градієнт на його локальну похідну. У звичайному Python арифметика така:
x = 5.0
left, right = x, x
q = left * right # forward: 25.0
output_grad = 1.0 # dq/dq
x_grad = 0.0
x_grad += output_grad * right # left operand contributes 5.0
x_grad += output_grad * left # right operand contributes 5.0
print(x_grad) # 10.0Кожне значення, необхідне для цієї похідної, отримано з обчислення на . Ми використали правило множення та збережені операнди. Оцінка належить до скінченно-різницевого методу в таблиці; Трасування авторозрізнення вище завершується єдиною точкою оцінки.
Автоматизація відбувається завдяки запису цих шляхів операндів і застосуванню їхніх похідних правил для нас. Пізніше наш клас Value зробить те саме обчислення, коли ми напишемо q = x * x і викличемо q.backward().
Знання операцій і розуміння форми
Припустимо, у нас є тільки стіл:
| Спостережений вихід | |
|---|---|
| 1 | 1 |
| 2 | 4 |
| 3 | 9 |
| 10 | 100 |
Ці кінцеві спостереження не визначають однозначно функцію між переліченими точками або її точну похідну в перерахованій точці. Кілька функцій можуть узгоджуватися з кожним рядком і мати різні нахили. Одним із варіантів є обчислення кінцево-різницевої оцінки за доступними сусідніми зразками.
З програмою return x * x ми маємо більше інформації: ми знаємо операції, які генерують вихідні дані, тому ми можемо застосувати їхні похідні правила. Нейронна мережа також має відомі обчислення для своїх прогнозів і втрат. Навчальні приклади можуть надходити у вигляді таблиці, але модель і втрати є виконуваними математичними функціями.
Знання цих операцій також не дає нам автоматичного розуміння всієї кривої. Розглянемо:
Ми можемо оцінити значення та похідну в будь-якій вибраній точці за допомогою цих формул. Чи можете ви уявити кожен підйом, спад і поворотний момент, просто читаючи їх? Перемістіть нижче, а потім виберіть . Місцеве значення та нахил доступні до того, як буде видно всю ділянку.
The value and derivative are computed from known rules at the selected point. Revealing the curve additionally samples the function across the displayed interval.
Формула повністю визначає функцію. Розуміння його глобальної геометрії потребує додаткової оцінки чи аналізу. Діаграма дає нам більше цієї інформації за вибраний інтервал, тоді як похідна описує локальну чутливість в одній точці.
Для нейронної мережі втрати можуть залежати від мільйонів ваг і зміщень. Ми знаємо програму, яка обчислює при вибраному налаштуванні параметра. Його градієнт дає місцеву чутливість. Ці обчислення не визначають місцезнаходження кожного мінімуму в іншому місці цього високовимірного простору. Навчання використовує локальну інформацію для вибору оновлення, а потім обчислює новий градієнт за оновленими параметрами.
Прогноз дає нам щось оцінити
Тепер припустимо, що модель отримує вхідні дані і має передбачити цільовий . Дайте йому одну вагу і одне зміщення :
Капелюх у означає передбачення. На і передбачено , що нижче цілі.
Що слід звести до мінімуму у навчанні? Мінімізація самого прогнозу призведе до його зниження, навіть якщо нам потрібно, щоб він збільшився. Нам потрібна функція, яка вимірює розбіжність між прогнозом і метою.
Виберіть квадрат помилки:
Поточні втрати . Нуль означатиме, що передбачення відповідає цільовому показнику для цього прикладу.
Підставте обчислення моделі у втрати:
Це зв’язок, який нам потрібен. Тепер втрати є функцією параметрів, які ми можемо змінити. Для фіксованого прикладу навчання :
Одне лише число не говорить нам, у який бік рухатися. З і , і створюють втрати , але вони повинні рухатися в протилежних напрямках. Ми розрізняємо функцію, яка породила втрату, оцінену за поточними параметрами.
Те саме обчислення, із вагою як змінна
Тримайте тренувальний вхід , ціль і зміщення фіксованими. Втрата стає звичайною функцією однієї змінної:
Ми можемо диференціювати цей маленький вираз вручну та замінити поточну вагу, точно так само, як ми зробили для :
| Крок | Знайома функція | Втрата моделі з фіксованими навчальними даними |
|---|---|---|
| Напишіть функцію | ||
| Диференціювати | ||
| Вставте поточне значення | ||
| Отримайте похідну |
Буква, що позначає змінну, змінилася з на . Навчальний вхід і ціль є фіксованими числами всередині ; ми запитуємо, як втрата реагує на вагу. Така ж відмінність застосовується, коли ми використовуємо цілу партію: вхідні дані та цілі партії залишаються фіксованими, поки ми диференціюємо параметри.
Ця модель досить мала, щоб розкласти її в поліном. Для більшої мережі autodiff записує прямі операції з поточним налаштуванням параметра та оцінює їх локальні похідні правила назад. Він обчислює числові значення похідних параметрів без попередньої побудови однієї розширеної символічної формули для градієнта мережі.
Яку похідну взяти?
Для ваги ми хочемо:
Прочитайте це так: як швидко зміниться втрата, якщо ми змінимо вагу, утримуючи інші незалежні величини незмінними? Символ використовується, оскільки залежить від кількох змінних. Для цього розрахунку вхід, ціль і зміщення залишаються фіксованими.
Модель і втрата утворюють композицію. Спочатку обчисліть прогноз, потім відніміть ціль, а потім підведіть помилку в квадрат:
Кожна операція має локальну похідну:
| Операція | Які зміни? | Місцева похідна |
|---|---|---|
| Змініть , утримуйте фіксованим | ||
| Змініть , утримуйте фіксованим | ||
| Змінити |
Правило ланцюга множить ці чутливості на шляху:
За нашими початковими значеннями і :
Тому збільшення ваги на має змінити втрату приблизно на . Точна нова втрата - , зміна . Згода покращується, коли крок стає меншим.
Для зміщення змінюється останній фактор: . Отже:
Похідні за всіма параметрами, які можна навчити, разом утворюють градієнт. Ось цей вектор , у порядку .
Вибір втрати змінює сигнал навчання
Модель повідомляє нам, як її прогноз реагує на вагу. Втрата говорить нам, наскільки ця зміна прогнозу має значення для мети.
Для того самого прогнозу порівняйте дві втрати:
| Втрата | Похідна щодо передбачення | Похідна по вазі |
|---|---|---|
| Квадрат помилки: | ||
| Абсолютна помилка: | , далеко від нуля | , далеко від нуля |
У нашій початковій точці градієнт передбачення становить для квадратичної помилки та для абсолютної помилки. Множення на ту саму похідну моделі, , дає вагові градієнти і .
Перемикайтеся між і нижче. Перемістіть , спостерігаючи як за градієнтом передбачення, так і за градієнтом ваги. Вхідні дані залишаються фіксованими, тому змінюється лише перший фактор.
x = 2, y = 5, and b = 0 stay fixed. This experiment updates only w.
Квадрат помилки створює більший градієнт передбачення, коли величина помилки більша. Абсолютна похибка має постійну величину градієнта по відношенню до прогнозу, коли похибка відмінна від нуля. Його похідна не визначена в нулі; віджет вибирає там нульовий субградієнт.
Жодна втрата не містить правил диференціації. Ці правила походять від числення. Вибір втрати визначає функцію, на яку діють ці правила, і, отже, початковий сигнал, що поширюється через модель.
Похідне стає оновленням
Градієнтний спуск віднімає масштабований градієнт:
Швидкість навчання встановлює розмір кроку. З квадратичною помилкою, і :
Утримуючи упередження на нульовому рівні, новий прогноз буде , а нова втрата – . Використовуйте і в експерименті з втратами, щоб відтворити цей розрахунок.
Тут ми оновили лише вагу. Якщо ми також оновимо зміщення з того самого градієнта, він стане , даючи прогноз і нульові втрати для цього окремого прикладу. Це різні оновлення, тому вони дають різні результати.
Від’ємна похідна передбачає збільшення параметра; додатна похідна передбачає її зменшення. Це місцева заява. Великий крок може пройти мінімум і збільшити збиток. Зворотне поширення обчислює градієнт; оптимізатор і швидкість його навчання визначають, що з ним робити.
Зворотне поширення - це правило ланцюга через граф
Ми можемо записати обчислення у вигляді графіка. Кожен блок містить число, і кожна операція пов’язує свій результат зі значеннями, які використовуються для його отримання.
Почніть із крайнього правого поля. Похідна втрати відносно себе дорівнює одиниці:
Потім працюйте назад. Коробка отримує градієнт, який повідомляє нам, як кінцевий збиток реагує на значення цієї коробки. Щоб передати внесок до одного з його входів, помножте на локальну похідну операції:
Виберіть , потім , доки не досягнете і . Кожен відображений градієнт є похідною від фінальної втрати, навіть якщо квадрат представляє проміжний результат.
Scroll the graph sideways to follow all the operations.
| Value | Forward value | Gradient |
|---|---|---|
| w | 1 | — |
| x | 2 | — |
| p = w × x | 2 | — |
| b | 0 | — |
| ŷ = p + b | 2 | — |
| y (target) | 5 | — |
| e = ŷ − y | -3 | — |
| L = e² | 9 | — |
На графіку також наведено похідні щодо вхідних даних і цілі. Це не робить їх придатними для навчання. Ми вибираємо, які значення оновлюватиме оптимізатор: тут лише та . У глибшій мережі «вхід» шару може бути виходом попереднього шару, тому його градієнт стає маршрутом назад до попередніх параметрів.
Важлива відмінність виникає, коли вартість має кілька шляхів до втрати. Внески з цих шляхів додати. Наприклад:
У ця похідна є . Зворотний перехід має зберігати всі три внески, включаючи обидва використання у множенні. Ось чому наш двигун використовуватиме += під час поширення градієнтів.
Побудуйте число, яке запам’ятовує його обчислення
Звичайний float Python зберігає значення. Наш Value також зберігатиме:
grad: похідна вибраного кінцевого результату відносно цього значення.edges: послідовність пар(parent, local_derivative), що записує, як було обчислено це значення.
Додавання і множення стають операціями побудови графіків. Для обидві крайові похідні є . Для це і .
Ось перша частина двигуна, включаючи цілі ступені та активацію, яку ми використаємо пізніше:
import math
class Value:
def __init__(self, data, edges=()):
self.data = float(data)
self.grad = 0.0
self.edges = tuple(edges) # (parent, local derivative) pairs
@staticmethod
def wrap(value):
return value if isinstance(value, Value) else Value(value)
def __add__(self, other):
other = Value.wrap(other)
return Value(self.data + other.data, ((self, 1.0), (other, 1.0)))
def __mul__(self, other):
other = Value.wrap(other)
return Value(self.data * other.data,
((self, other.data), (other, self.data)))
def __pow__(self, exponent):
if not isinstance(exponent, int) or exponent < 1:
raise ValueError("This engine supports positive integer powers only")
return Value(self.data ** exponent,
((self, exponent * self.data ** (exponent - 1)),))
def tanh(self):
output = math.tanh(self.data)
return Value(output, ((self, 1.0 - output * output),))Коли ми множимо два об’єкти Value, __mul__ негайно обчислює результат і зберігає два ребра з їх локальними похідними. Ці похідні значення обчислюються з використанням поточних даних операндів. Ще нічого не вивчено; ми записуємо форвардне обчислення.
Ми безпосередньо реалізуємо степені, тому (prediction - target) ** 2 записує операцію квадрата. Механізм навмисно підтримує лише додатні цілі ступені. Це охоплює наші приклади без введення доменів, таких як дробові ступені від’ємних чисел.
Гіперболічний тангенс, tanh, є гладкою функцією активації з похідною . Це дозволяє мережі виражати нелінійні зв’язки.
Зручні оператори Python роблять движок приємним у використанні. Зворотні оператори дозволяють такі вирази, як 2 * w і sum(...):
def __neg__(self):
return self * -1.0
def __sub__(self, other):
return self + -Value.wrap(other)
def __rsub__(self, other):
return Value.wrap(other) + -self
__radd__ = __add__
__rmul__ = __mul__Усі ці методи належать до Value. повний файл Python містить двигун, моделі та приклади, які можна запускати. Ви також можете завантажити tinygrad.py.
Перегляньте графік у правильному порядку
Щоб обчислити градієнти, нам потрібно обробити кожен результат перед його батьками, після того, як надійдуть усі внески цього результату.
По-перше, обхід у глибину відвідує батьківські вузли перед додаванням самого вузла. Це створює топологічний порядок. Набір seen гарантує, що спільний вузол буде додано лише один раз. Змінюючи порядок, кінцевий результат стає першим.
Додайте цей метод до Value:
def backward(self):
order, seen = [], set()
def visit(node):
if node in seen:
return
seen.add(node)
for parent, _ in node.edges:
visit(parent)
order.append(node)
visit(self)
for node in order:
node.grad = 0.0
self.grad = 1.0
for node in reversed(order):
for parent, local_derivative in node.edges:
parent.grad += node.grad * local_derivativeЄ три окремі кроки: очистити старі градієнти на доступному графіку, додати градієнт остаточного виходу до одного та повернутися назад, щоб накопичити внески.
Набір seen дедуплікує вузли, тоді як edges зберігає кожне входження операнда. У a * a два ребра вказують на одного батька. Обидва виконують своє оновлення +=. Заміна edges на набір батьків без збереження відповідних внесків втратить цю відмінність.
Наш backward() навмисно очищає доступні градієнти під час кожного виклику. Це робить повторні виклики на тому самому незмінному графіку відтворюваним. Він відрізняється від API, які накопичують листові градієнти в зворотних викликах і вимагають окремого кроку обнулення. Протягом одного зворотного проходу внески все ще накопичуються з усіх шляхів графа.
Ця реалізація зберігає числові локальні похідні під час прямого проходу. Змініть дані параметрів після назад, а потім побудуйте новий графік для наступної ітерації. Повторний запуск старого графіка після зміни даних призведе до використання застарілих локальних похідних.
Завдяки рекурсивному обходу графа цей навчальний механізм є коротким. Довгі ланцюжки обчислень, такі як втрати, підсумовані за тисячами прикладів, можуть перевищувати обмеження рекурсії Python; ітераційний обхід уникає цього обмеження.
Перевірте двигун на арифметику
Збережіть повний файл як tinygrad.py. В іншому файлі Python у тому ж каталозі:
from tinygrad import Value
w, b = Value(1), Value(0)
prediction = w * 2 + b
loss = (prediction - 5) ** 2
loss.backward()
print(loss.data) # 9.0
print(w.grad) # -12.0
print(b.grad) # -6.0
a = Value(3)
q = a * a + a
q.backward()
print(a.grad) # 7.0: all three paths contributeДвигун так і не отримав спеціальної формули для градієнта ваги цієї моделі. Він записував множення, додавання, віднімання та квадрат, а потім комбінував їх місцеві похідні.
Використовуйте кінцеві різниці, щоб перевірити результат
Це друга мета, оголошена на початку: незалежна перевірка. Двигун уже отримав w.grad із записаних операцій на . Щоб перевірити його виконання, оцініть втрати окремо з обох сторін цієї ваги:
def numeric_loss(weight):
return (weight * 2 - 5) ** 2
h = 1e-5
finite_difference = (numeric_loss(1 + h) - numeric_loss(1 - h)) / (2 * h)
assert abs(w.grad - finite_difference) < 1e-6Ця центральна відмінність є чисельною перевіркою, а не алгоритмом диференціювання двигуна. Перевірка кожного параметра таким чином вимагає двох оцінок для кожного параметра. Натомість автоматичне диференціювання у зворотному режимі повторно використовує окремий записаний графік для обчислення всіх градієнтів параметрів для скалярного результату, з роботою, пропорційною розміру графіка.
Надзвичайно малі значення зрештою призводять до скасування числа з плаваючою комою, тому менші значення не завжди є кращими для цієї числової перевірки. Навчальні цикли нижче отримують градієнти, викликаючи backward() на поточному графіку.
Вчіться на кількох прикладах
Один навчальний приклад не може визначити унікальну лінію: багато пар задовольняють . Додайте більше прикладів:
| Введіть | Ціль |
|---|---|
| 0 | 1 |
| 1 | 3 |
| 2 | 5 |
| 3 | 7 |
Ці точки лежать на , тому ми можемо перевірити, чи знаходить навчання і .
Визначте втрату як середнє чотирьох квадратів помилок:
Спільна вага відображається в усіх чотирьох прогнозах. Під час зворотного руху кожен шлях сприяє тому самому w.grad. Множення на масштабує всі внески відповідно до середнього збитку. Підсумовування замість усереднення помножить обидва градієнти на чотири.
Використовуйте , щоб перевірити одне оновлення, а потім . Спробуйте дізнатися швидкість зі стану скидання, щоб побачити зростання втрат, навіть якщо градієнти розраховані правильно.
Each click builds a fresh graph, averages four squared errors, runs backward(), and updates both w and b. The line plot rescales if the parameters grow; the loss plot uses log₁₀, with a floor of 10⁻¹².
Віджет використовує аналог JavaScript скалярного двигуна вище. Його градієнти обчислюються через новий графік на кожному кроці. Ось той самий навчальний цикл у Python:
def train_line(steps=1000, learning_rate=0.05):
data = [(0, 1), (1, 3), (2, 5), (3, 7)]
w, b = Value(-1), Value(0)
for _ in range(steps):
loss = sum((w * x + b - y) ** 2 for x, y in data) * (1 / len(data))
loss.backward() # also clears this graph's old gradients
for parameter in (w, b):
parameter.data -= learning_rate * parameter.grad
final_loss = sum((w.data * x + b.data - y) ** 2 for x, y in data) / len(data)
return w.data, b.data, final_lossОбидва градієнти походять від того самого прямого проходу до зміни будь-якого параметра. Наступна ітерація повторно обчислює прогнози, втрати та похідні за оновленими параметрами. Повернений збиток оцінюється після останнього оновлення, тому він описує повернуті ваги.
Використовуйте той самий механізм для нейронної мережі
Поки що модель була спорідненою. Невелика нейронна мережа не потребує нових механізмів диференціювання: її прямий перехід — це більша композиція операцій, які ми вже підтримуємо.
Один нейрон: зважена сума, потім активація
Нейрон спочатку об’єднує свої вхідні дані в одне число, а потім застосовує функцію активації:
Тут — це один вхідний сигнал, — його вивчена вага, — це вивчене зміщення, а — функція активації. Вихід є одним скаляром; ми зберігаємо для цілі. У цьому розділі індексує вхідні дані для одного нейрона, а не навчальні приклади в попередньому розділі.
Скалярний добуток множить відповідні записи та підсумовує їх. Додавання зміщення робить афінним перетворенням від до . Застосування нелінійної активації, такої як ReLU, sigmoid або tanh, дозволяє шарам створювати нелінійні функції. Наша попередня модель — це випадок з одним входом і ідентифікаційною активацією .
Для конкретного прикладу візьмемо три входи:
Спочатку обчисліть зважену суму плюс зсув:
З ReLU результат буде . Наш механізм використовує tanh, тому його нелінійний нейрон замість цього повертає:
Вхідні дані, ваги та зміщення визначають ; активація визначає, як це значення передається вперед.
Дотримуйтеся правила ланцюга через нейрон
Додайте втрату до виходу цього нейрона. Вага впливає на цю втрату через зважену суму та активацію:
Локальна похідна зваженої суми відносно дорівнює . Активація сприяє . Помноживши їх на чутливість до втрат на виході, ви отримаєте:
Для упередженості , отже:
Для tanh, . Якщо цей єдиний нейрон передбачає ціль і ми використовуємо квадрат помилки, , тоді:
Поверніться до прикладу з трьома входами та виберіть ціль . При нахил активації становить приблизно . Для першої ваги:
Таким чином, невелике збільшення зменшує локальні втрати. Порівняно з нашою попередньою афінною моделлю, ланцюг має один додатковий фактор: нахил активації. З активацією ідентичності цей фактор становить , що відновлює попередню формулу.
Для прихованого нейрона надходить із пізніх шарів. Щоб продовжити назад, цей нейрон вносить у градієнт вхідного . Якщо вхід живить кілька нейронів, їхні внески додаються, як це було в наших попередніх прикладах графіків.
Від одного нейрона до шару
Шар із нейронами має один ваговий вектор і одне зміщення на нейрон. Складіть вектори ваги як рядки , а зміщення – у :
Тут збирає вихідні дані рівня, а діє на кожен запис окремо. MLP передає ці результати на наступний рівень.
Існує також геометрична інтерпретація. Для ненульових векторів : скалярний добуток вимірює вирівнювання, масштабоване за обома величинами. Кожен нейрон реагує у своєму власному напрямку ваги, причому зміщення зміщує його вхідну інформацію для активації. Це корисна відправна точка для інтерпретації відповіді нейрона, хоча сам по собі напрямок ваги не визначає, яку функцію представляє нейрон.
Наведений нижче код реалізує ті самі два етапи: total є , а total.tanh() застосовує активацію. Ми зберігаємо кінцевий шар лінійним для цього прикладу регресії:
import random
class Neuron:
def __init__(self, n_inputs, rng, nonlinear=True):
self.weights = [Value(rng.uniform(-1, 1)) for _ in range(n_inputs)]
self.bias = Value(0)
self.nonlinear = nonlinear
def __call__(self, inputs):
total = sum((w * x for w, x in zip(self.weights, inputs)), self.bias)
return total.tanh() if self.nonlinear else total
def parameters(self):
return self.weights + [self.bias]
class MLP:
def __init__(self, sizes, seed=42):
rng = random.Random(seed)
self.layers = [
[Neuron(n_in, rng, nonlinear=i < len(sizes) - 2)
for _ in range(n_out)]
for i, (n_in, n_out) in enumerate(zip(sizes, sizes[1:]))
]
def __call__(self, inputs):
for layer in self.layers:
inputs = [neuron(inputs) for neuron in layer]
return inputs
def parameters(self):
return [p for layer in self.layers for neuron in layer
for p in neuron.parameters()]MLP([2, 4, 1]) створює два входи, чотири приховані нейрони з tanh і один лінійний вихід. Його параметрами є 8 прихованих ваг, 4 прихованих зміщення, 4 вихідні зміщення та 1 вихідне зміщення: 17 скалярів, які можна навчити.
Навчіть його на XOR, кодуючи однакові входи як і різні входи як . Жодна афінна функція двох входів не може точно відповідати всім чотирьом цілям; приховані нелінійні активації дозволяють моделі підганяти їх.
def train_xor(steps=1000, learning_rate=0.1):
# Same inputs twice equal -1; different inputs equal +1.
examples = [([0, 0], -1), ([0, 1], 1), ([1, 0], 1), ([1, 1], -1)]
model = MLP([2, 4, 1], seed=42)
parameters = model.parameters()
for _ in range(steps):
loss = sum((model(x)[0] - y) ** 2 for x, y in examples) * 0.25
loss.backward()
for parameter in parameters:
parameter.data -= learning_rate * parameter.grad
predictions = [model(x)[0].data for x, _ in examples]
final_loss = sum((p - y) ** 2 for p, (_, y) in zip(predictions, examples)) / 4
return predictions, final_lossСтруктура навчання не змінилася: побудова прогнозів і скалярних втрат, виклик backward() і оновлення параметрів. Двигун бачить операції та краї. Йому не потрібно знати, які операції належать нейрону чи шару.
Запустіть повний файл:
python3 tinygrad.pyЗ наданим початковим кодом, 1000 кроками та швидкістю навчання передбачення XOR округляються до [-1.0, 1.0, 1.0, -1.0] з четвертим знаком після коми. Це невелика демонстрація з фіксованими даними та ініціалізацією, а не гарантія для довільних мереж або швидкості навчання.
До чого призвела втрата
Ми почали з спроби довколишніх значень параметрів у втраті іграшки, а потім вивели правило, яке безпосередньо визначає його локальну швидкість зміни. Прослідкувавши похідні за допомогою простих операцій, ми вибрали функцію прогнозування та приєднали збиток. Заміна зробила цю втрату функцією параметрів моделі, даючи нам те ж саме точне запитання: як зміниться ця мета, якщо зміниться один параметр?
Втрата визначає мету та її чутливість до прогнозу. Модель надає шляхи від параметрів до цього прогнозу. Зворотне поширення поєднує локальні похідні вздовж цих шляхів, додаючи внески там, де шляхи зустрічаються. Градієнтний спуск використовує отримані градієнти параметрів для вибору оновлення.
Для моделі, побудованої на основі операцій із придатними для використання правилами похідних, скалярні втрати дають нашому механізму диференціювання вихідні дані для початку та мету, яку оптимізатор повинен зменшити.
Щоб дізнатися більше про нейронні мережі, див. Як нейронні мережі навчаються: зворотне поширення та градієнтний спуск. Щоб отримати додаткову реалізацію, яка приєднує зворотні функції до операцій, перегляньте Micrograd Engine Карпаті і його Нейронні мережі: матеріали від нуля до героя.