От уклона к обратному распространению ошибки: создаем крошечный автоградный двигатель

В нашей статье о том, как обучаются нейронные сети, мы проследили за моделью в цикле обучения: сделали прогноз, измерили ее ошибку с помощью функции потерь, вычислили градиенты и обновили веса. Чтобы скорректировать веса, мы использовали градиенты — производные, которые говорят нам, как изменяются потери по отношению к каждому весу. Градиентный спуск использует эти производные для выбора направления обновления.

В этой статье мы хотим сосредоточиться на том, как рассчитываются эти градиенты. Если потеря модели равна 99, как мы можем определить, что увеличение одного веса приведет к ее уменьшению? Какую функцию мы дифференцируем и откуда берутся правила ее производных?

Чтобы ответить на этот вопрос, нам нужно проследить за вычислениями, которые привели к потере. Прогноз и потери вместе образуют функцию параметров модели. Мы создадим f(x)=x2f(x)=x^2, вычислим производные с помощью отдельных операций и реализуем небольшой механизм автоматического дифференцирования на Python. Затем мы будем использовать его для обучения модели.

Наш движок использует автоматическое дифференцирование в обратном режиме, или autodiff: он записывает операции при прямой оценке с текущими значениями параметров, а затем применяет производные правила назад. Мы также будем использовать конечные разности для двух отдельных целей: визуализации того, что означает наклон, и независимой проверки ответов движка. Эти оценки ближайших точек относятся к объяснению и проверке; обучающий цикл получает градиенты от автодифференциала.

Вам нужны только базовые знания Python и алгебры. Мы будем вводить исчисление по мере его использования. Реализация следует подходу скалярных вычислений, изложенному в лекции Андрея Карпати по микрограду, с использованием явной таблицы локальных производных на ребрах графа. Для этого не требуются библиотеки машинного обучения.

Начните с функции и ее производной

Рассмотрим:

f(x)=x2.f(x)=x^2.

При x=5x=5 на выходе будет 2525. Правило степени дает свою производную, записываемую f′(x)f'(x) или df/dxdf/dx:

f′(x)=2x,f′(5)=10.f'(x)=2x,\qquad f'(5)=10.

Производная — это наклон касательной в этой точке. Он говорит нам, насколько чувствителен выход к небольшому изменению входных данных:

f(5+Δx)≈25+10Δx.f(5+\Delta x)\approx25+10\Delta x.

Производная — это желаемая величина. Несколько методов позволяют вычислить или приблизить эту величину.

Три способа получить одну и ту же производную

Оставьте функцию f(x)=x2f(x)=x^2 и точку оценки x=5x=5 фиксированной:

МетодЧто он используетРасчет в x=5x=5Результат
Символическая дифференциацияПравила, применяемые к выражениюВыведите 2x2x, затем подставьте 55.1010
Конечные разностиВыходы на соседних входах(f(5.001)−f(5))/0.001(f(5.001)-f(5))/0.001Примерно 10.00110.001
Автоматическая дифференциацияВыполняемые операции и их локальные производные правилаДля x * x добавьте вклады 5+55+51010

Символическое дифференцирование дает производное выражение, которое мы сможем вычислить позже. Autodiff оценивает производные правила, используя значения, записанные во время вычислений. Для этой гладкой функции оба вычисляют производную в выбранной точке с учетом арифметики с плавающей запятой в коде. Конечные разности оценивают его путем сравнения результатов в разных точках.

Обратное распространение ошибки в нейронной сети использует третий подход. Мы предоставляем производные правила для базовых операций, а движок объединяет их с помощью цепного правила. Он может вычислять градиент при текущих значениях параметров, не генерируя одно большое символьное выражение для всей сети.

Автодифференциация посредством одного умножения

Напишите ту же функцию в виде программы:

def f(x):
    return x * x

Мы уже знаем производное правило умножения. Если z=abz=ab, его локальные производные:

∂z∂a=b,∂z∂b=a.\frac{\partial z}{\partial a}=b, \qquad \frac{\partial z}{\partial b}=a.

Для x * x оба операнда относятся к одному и тому же входу. В x=5x=5 каждый операнд равен 55, поэтому каждая локальная производная также равна 55. Изменение xx влияет на результат через оба пути операндов. Их вклад добавляет:

d(x⋅x)dx=5+5=10at x=5.\frac{d(x\cdot x)}{dx}=5+5=10 \quad\text{at }x=5.

Выберите Evaluate x × x, затем Seed output gradient. Следуйте по очереди за каждым операндом, чтобы увидеть, как накопленный градиент достигает 1010.

Experiment 1 · automatic differentiationOne input, two operand paths
Differentiate x times x through its two operand pathsBoth paths connect the same input x=5 to one multiplication. Stage 0 of 4. Choose the evaluation point x = 5. Both operand paths refer to this same x.Left operand: xRight operand: xShared input xvalue = 5grad so far = —q = x × xvalue = —∂q/∂q = —
Forward: values →Backward: gradient contributions ←
Stage 0 of 4. Choose the evaluation point x = 5. Both operand paths refer to this same x.
Input used for this trace: x = 5.

The trace uses the multiplication edges from the article’s scalar engine. Changing x starts a new trace at that value.

Виджет начинает обратный проход с производной вывода относительно самого себя: dq/dq=1dq/dq=1. Каждый путь умножает входящий градиент на его локальную производную. В обычном 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

Все значения, необходимые для этой производной, были получены в результате вычислений в x=5x=5. Мы использовали правило умножения и сохраненные операнды. Оценка f(5.001)f(5.001) относится к методу конечных разностей в таблице; приведенная выше трассировка автодифференциации заполнена единственной оценочной точкой.

Автоматизация происходит за счет записи этих путей операндов и применения для нас их производных правил. Позже наш класс Value выполнит тот же расчет, когда мы напишем q = x * x и вызовем q.backward().

Знание операций и понимание формы

Предположим, у нас была только таблица:

xxНаблюдаемый результат
11
24
39
…\ldots…\ldots
10100

Эти конечные наблюдения не определяют однозначно функцию между перечисленными точками или ее точную производную в указанной точке. Несколько функций могут согласовываться с каждой строкой и иметь разные наклоны. Одним из вариантов является вычисление оценки конечной разности на основе доступных близлежащих выборок.

С программой return x * x у нас больше информации: мы знаем операции, которые генерируют выходные данные, поэтому можем применять их производные правила. Нейронная сеть также имеет известные вычисления для своих прогнозов и потерь. Обучающие примеры могут быть представлены в виде таблицы, но модель и потери представляют собой исполняемые математические функции.

Знание этих операций также не дает нам автоматического понимания всей кривой. Рассмотрим:

g(x)=sin⁡(x2)+0.2x2,g′(x)=2xcos⁡(x2)+0.4x.g(x)=\sin(x^2)+0.2x^2, \qquad g'(x)=2x\cos(x^2)+0.4x.

Используя эти формулы, мы можем оценить значение и производную в любой выбранной точке. Можете ли вы представить каждый подъем, падение и поворотный момент, просто прочитав их? Переместите Known evaluation point x ниже, затем выберите Reveal full curve. Локальное значение и наклон доступны до того, как будет виден весь график.

Experiment 2 · local slope and global shapeCompute a slope before revealing the curve
g(x) = sin(x²) + 0.2x²
g(x) against xAt x=1, g(x)=1.0415 and its derivative is 1.4806. Only the selected point and its tangent are visible.-4-1-20.50223.545g(x)x
Known operations: x² = 1 → sin(x²) = 0.8415 → add 0.2x² = 0.2.
g(x) = 1.0415 · g′(x) = 2x cos(x²) + 0.4x = 1.4806

The value and derivative are computed from known rules at the selected point. Revealing the curve additionally samples the function across the displayed interval.

Формула полностью определяет функцию. Понимание его глобальной геометрии требует дополнительной оценки или анализа. График дает нам больше этой информации в выбранном интервале, а производная описывает локальную чувствительность в одной точке.

Для нейронной сети потери могут зависеть от миллионов весов и смещений. Мы знаем программу, которая вычисляет L(w1,w2,…)L(w_1,w_2,\ldots) при выбранной настройке параметра. Его градиент определяет местную чувствительность. Эти вычисления не находят каждый минимум где-либо еще в этом многомерном пространстве. Обучение использует локальную информацию для выбора обновления, а затем рассчитывает новый градиент с обновленными параметрами.

Используйте близлежащие точки для визуализации уклона

Теперь вернемся к f(x)=x2f(x)=x^2 для первого из двух наших применений конечных разностей: построение интуиции наклона. Мы уже вычислили его производную через autodiff. Этот эксперимент иллюстрирует, что эта производная означает геометрически.

Сравните f(5)=25f(5)=25 и f(5.001)=25.010001f(5.001)=25.010001. Их средний наклон составляет:

f(5.001)−f(5)0.001=10.001.\frac{f(5.001)-f(5)}{0.001}=10.001.

Для общего ненулевого изменения входа hh:

f(x+h)−f(x)h=(x+h)2−x2h=2x+h.\frac{f(x+h)-f(x)}{h} =\frac{(x+h)^2-x^2}{h} =2x+h.

Когда hh приближается к нулю, это соотношение приближается к производной 2x2x. В x=5x=5 конечно-разностная оценка приближается к 1010.

Сократите Small change h до 0.0010.001 и наблюдайте, как оранжевая секущая приближается к фиолетовой касательной. Затем переместите Position x на отрицательное значение: отрицательный наклон означает, что небольшое увеличение входного сигнала уменьшает выходной сигнал.

Experiment 3 · finite-difference intuitionNearby values illustrate the slope
f(x) = x² against xAt x=5, the tangent slope is 10. With h=0.8, the secant slope is 10.8.-60-310020330640f(x) = x²x
CurveTangent: exact derivativeSecant: finite difference
(f(x + h) − f(x)) / h = 10.8
f′(x) = 2x = 10

A small increase in x increases f(x). The finite-difference error is h = 0.8.

Purpose: visualize the meaning of a derivative. The training experiments compute gradients with automatic differentiation.

Этот расчет близлежащей точки представляет собой конечную разницу. Позже мы воспользуемся еще одной конечной разностью для независимой проверки двигателя. В обучающих экспериментах градиенты получаются из записанных операций и правил их локальных производных.

Для x2x^2 точное изменение вывода равно 2xΔx+(Δx)22x\Delta x+(\Delta x)^2. Производная захватывает часть первого порядка; он не предсказывает точно произвольные конечные изменения.

Прогноз дает нам возможность оценить

Теперь предположим, что модель получает входные данные x=2x=2 и должна предсказать целевой y=5y=5. Присвойте ему один вес ww и одно смещение bb:

y^=wx+b.\hat y=wx+b.

Шляпа в y^\hat y означает предсказание. На w=1w=1 и b=0b=0 прогноз 22, что на 33 ниже целевого значения.

Что следует свести к минимуму в обучении? Минимизация самого прогноза приведет к его снижению, хотя нам нужно, чтобы он увеличился. Нам нужна функция, которая измеряет расхождение между прогнозом и целью.

Выберите квадрат ошибки:

L=(y^−y)2.L=(\hat y-y)^2.

Текущий убыток составляет (2−5)2=9(2-5)^2=9. Ноль будет означать, что прогноз соответствует целевому значению для этого примера.

Подставьте расчет модели в потери:

L(w,b)=(wx+b−y)2.\boxed{L(w,b)=(wx+b-y)^2.}

Это та связь, которая нам нужна. Теперь потери являются функцией параметров, которые мы можем изменить. Для примера фиксированного обучения (x,y)=(2,5)(x,y)=(2,5):

L(w,b)=(2w+b−5)2.L(w,b)=(2w+b-5)^2.

Само по себе число 99 не говорит нам, в какую сторону двигаться. При b=0b=0 и w=1w=1, и w=4w=4 приносят убыток 99, но им нужно двигаться в противоположных направлениях. Мы дифференцируем функцию, вызвавшую убыток, оцененную по текущим параметрам.

То же самое вычисление, с весом в качестве переменной

Удерживайте обучающий вход x=2x=2, цель y=5y=5 и смещение b=0b=0 фиксированными. Убыток становится обычной функцией одной переменной:

L(w)=(2w−5)2=4w2−20w+25.L(w)=(2w-5)^2=4w^2-20w+25.

Мы можем вручную дифференцировать это небольшое выражение и подставить текущий вес точно так же, как мы это сделали для x2x^2:

ШагЗнакомая функцияПотеря модели с фиксированными данными обучения
Напишите функциюf(t)=t2f(t)=t^2L(w)=(2w−5)2L(w)=(2w-5)^2
Дифференцироватьf′(t)=2tf'(t)=2tL′(w)=8w−20L'(w)=8w-20
Вставьте текущее значениеt=5t=5w=1w=1
Получите производнуюf′(5)=10f'(5)=10L′(1)=−12L'(1)=-12

Буква, обозначающая переменную, изменилась с tt на ww. Входные данные и цель обучения — фиксированные числа внутри L(w)L(w); мы спрашиваем, как потеря реагирует на вес. То же различие применимо, когда мы используем всю партию: входные и целевые значения партии остаются фиксированными, в то время как мы дифференцируемся по параметрам.

Эта модель достаточно мала, чтобы ее можно было разложить в полином. Для более крупной сети autodiff записывает операции пересылки с текущими настройками параметров и оценивает их локальные производные правила в обратном направлении. Он вычисляет числовые значения производных параметров без предварительного построения одной расширенной символьной формулы для градиента сети.

Какую производную мы возьмем?

Что касается веса, нам нужно:

∂L∂w.\frac{\partial L}{\partial w}.

Прочтите это так: как быстро изменятся потери, если мы изменим вес, оставив другие независимые величины фиксированными? Символ ∂\partial используется, потому что LL зависит от нескольких переменных. Для этого расчета входные данные, цель и смещение остаются фиксированными.

Модель и потеря образуют композицию. Сначала вычислите прогноз, затем вычтите цель, а затем возведите ошибку в квадрат:

w ⟶ y^=wx+b ⟶ e=y^−y ⟶ L=e2.w\ \longrightarrow\ \hat y=wx+b \ \longrightarrow\ e=\hat y-y \ \longrightarrow\ L=e^2.

Каждая операция имеет локальную производную:

ОперацияЧто изменится?Локальная производная
y^=wx+b\hat y=wx+bИзмените ww, зафиксируйте x,bx,b.∂y^/∂w=x\partial\hat y/\partial w=x
e=y^−ye=\hat y-yИзмените y^\hat y, зафиксируйте yy.∂e/∂y^=1\partial e/\partial\hat y=1
L=e2L=e^2Изменить eedL/de=2edL/de=2e

Правило цепочки умножает эту чувствительность на пути:

∂L∂w=dLde∂e∂y^∂y^∂w=2e⋅1⋅x=2(y^−y)x.\frac{\partial L}{\partial w} =\frac{dL}{de} \frac{\partial e}{\partial\hat y} \frac{\partial\hat y}{\partial w} =2e\cdot1\cdot x =2(\hat y-y)x.

При наших стартовых значениях e=−3e=-3 и x=2x=2:

∂L∂w=2(−3)(2)=−12.\frac{\partial L}{\partial w}=2(-3)(2)=-12.

Таким образом, увеличение веса 0.010.01 должно изменить потери примерно на −12×0.01=−0.12-12\times0.01=-0.12. Точная новая потеря — (2.02−5)2=8.8804(2.02-5)^2=8.8804, замена −0.1196-0.1196. Согласие улучшается по мере уменьшения шага.

Для смещения меняется последний фактор: ∂y^/∂b=1\partial\hat y/\partial b=1. Итак:

∂L∂b=2(y^−y)=−6.\frac{\partial L}{\partial b}=2(\hat y-y)=-6.

Производные по всем обучаемым параметрам вместе образуют градиент. Здесь этот вектор — ∇L=(−12,−6)\nabla L=(-12,-6) в порядке (w,b)(w,b).

Выбор потери меняет сигнал обучения

Модель сообщает нам, как ее прогноз реагирует на вес. Потери говорят нам, насколько большое значение это изменение прогноза имеет для цели.

Для того же прогноза сравните две потери:

ПотеряПроизводная по прогнозуПроизводная по весу
Квадратичная ошибка: (y^−y)2(\hat y-y)^22(y^−y)2(\hat y-y)2(y^−y)x2(\hat y-y)x
Абсолютная ошибка: ∣y^−y∣\lvert\hat y-y\rvertsign⁡(y^−y)\operatorname{sign}(\hat y-y), вдали от нуляsign⁡(y^−y)x\operatorname{sign}(\hat y-y)x, вдали от нуля

В нашей отправной точке градиент прогнозирования равен −6-6 для квадратичной ошибки и −1-1 для абсолютной ошибки. Умножение на ту же производную модели, x=2x=2, дает градиенты веса −12-12 и −2-2.

Переключение между Squared error и Absolute error ниже. Переместите Weight w, наблюдая как за градиентом прогноза, так и за градиентом веса. Входные данные остаются фиксированными, поэтому меняется только первый фактор.

Experiment 4The loss changes the slope
Loss L(w) against weight wInput x=2, target y=5, bias b=0. Prediction 2, loss 9, weight gradient -12.-100.7513.752.527.54.2541.25655Loss L(w)weight w
Current weightLocal slopeAfter the proposed step
ŷ = 2 × 1 = 2 · target = 5 · L = 9
∂L/∂ŷ = -6 · ∂ŷ/∂w = 2 · ∂L/∂w = -12
w next = 1 − 0.1 × (-12) = 2.2 · next loss = 0.36

x = 2, y = 5, and b = 0 stay fixed. This experiment updates only w.

Квадрат ошибки дает больший градиент прогнозирования, когда величина ошибки больше. Абсолютная ошибка имеет постоянную величину градиента по отношению к прогнозу, если ошибка не равна нулю. Его производная не определена в нуле; виджет выбирает там нулевой субградиент.

Ни одна потеря не обеспечивает правил дифференциации. Эти правила пришли из исчисления. Выбор потерь определяет функцию, на которую действуют эти правила, и, следовательно, исходный сигнал, распространяющийся через модель.

Производная становится обновлением

Градиентный спуск вычитает масштабированный градиент:

wnew=w−η∂L∂w.w_{\text{new}}=w-\eta\frac{\partial L}{\partial w}.

Скорость обучения η\eta задает размер шага. С квадратичной ошибкой w=1w=1 и η=0.1\eta=0.1:

wnew=1−0.1(−12)=2.2.w_{\text{new}}=1-0.1(-12)=2.2.

Если смещение равно нулю, новый прогноз — 4.44.4, а новый убыток — 0.360.36. Используйте Reset и Take one step в эксперименте по потерям, чтобы воспроизвести этот расчет.

Здесь мы обновили только вес. Если мы также обновим смещение по тому же градиенту, оно станет 0.60.6, что даст прогноз 2.2×2+0.6=52.2\times2+0.6=5 и нулевую потерю для этого единственного примера. Это разные обновления, поэтому они дают разные результаты.

Отрицательная производная предполагает увеличение параметра; положительная производная предполагает ее уменьшение. Это местное заявление. Большой шаг может пройти минимальный и увеличить потери. Обратное распространение ошибки вычисляет градиент; оптимизатор и скорость его обучения определяют, что с ним делать.

Обратное распространение ошибки — это правило цепочки через граф

Мы можем записать вычисления в виде графика. В каждом блоке хранится число, и каждая операция связывает свой результат со значениями, использованными для его создания.

Начните с самого правого поля. Производная убытка относительно себя равна единице:

∂L∂L=1.\frac{\partial L}{\partial L}=1.

Затем работайте в обратном направлении. Ящик получает градиент, сообщающий нам, как конечная потеря реагирует на значение этого ящика. Чтобы передать вклад на один из его входов, умножьте его на локальную производную операции:

input gradient contribution=output gradient×local derivative.\boxed{\text{input gradient contribution} =\text{output gradient}\times\text{local derivative}.}

Выберите Seed gradient, затем Backpropagate one operation, пока не дойдете до ww и xx. Каждый отображаемый градиент является производной от окончательной потери, даже если поле представляет собой промежуточный результат.

Experiment 5Follow the loss backward
wvalue 1grad —xvalue 2grad —p = w × xvalue 2grad —bvalue 0grad —ŷ = p + bvalue 2grad —y (target)value 5grad —e = ŷ − yvalue -3grad —L = e²value 9grad —Values flow → · apply local derivative rules ←

Scroll the graph sideways to follow all the operations.

Stage 0 of 5. Forward pass: evaluate the operations from left to right. Each box stores its value. Gradients have not been seeded yet.
Gradients revealed so far. “grad” means ∂L / ∂(box value).
ValueForward valueGradient
w1—
x2—
p = w × x2—
b0—
ŷ = p + b2—
y (target)5—
e = ŷ − y-3—
L = e²9—

График также дает производные по входным и целевым значениям. Это не делает их обучаемыми. Выбираем, какие значения обновляет оптимизатор: здесь только ww и bb. В более глубокой сети «вход» слоя может быть выходом предыдущего слоя, поэтому его градиент становится путем обратно к более ранним параметрам.

Важное различие появляется, когда значение имеет несколько путей потери. Вклады этих путей добавить. Например:

q=a⋅a+a⟹dqda=a+a+1=2a+1.q=a\cdot a+a \quad\Longrightarrow\quad \frac{dq}{da}=a+a+1=2a+1.

В a=3a=3 этой производной является 77. Обратный проход должен сохранить все три вклада, включая оба использования aa при умножении. Вот почему наш движок будет использовать += при распространении градиентов.

Постройте число, которое запоминает свое вычисление

Обычное число с плавающей точкой Python хранит значение. Наш Value также будет хранить:

  • grad: производная выбранного конечного результата по этому значению.
  • edges: последовательность пар (parent, local_derivative), записывающая, как было вычислено это значение.

Сложение и умножение становятся операциями построения графов. Для z=a+bz=a+b обе производные ребра равны 11. Для z=abz=ab это bb и aa.

Вот первая часть движка, включая целочисленные степени и активацию, которую мы будем использовать позже:

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 представляет собой гладкую функцию активации с производной 1−tanh⁡2(z)1-\tanh^2(z). Это позволяет сети выражать нелинейные отношения.

Удобные операторы 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. complete Python file включает в себя движок, модели и работоспособные примеры. Вы также можете download 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 в результате зарегистрированных операций на w=1w=1. Чтобы проверить его реализацию, оцените потери отдельно по обе стороны от этого веса:

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

Это центральное различие представляет собой численную проверку, а не алгоритм дифференцирования двигателя. Такая проверка каждого параметра требует двух оценок для каждого параметра. Вместо этого автоматическое дифференцирование в обратном режиме повторно использует один записанный график для вычисления всех градиентов параметров для скалярного выходного сигнала, при этом работа пропорциональна размеру графика.

Чрезвычайно малые значения hh в конечном итоге приводят к отмене операций с плавающей запятой, поэтому меньшее значение не всегда лучше для этой числовой проверки. Приведенные ниже обучающие циклы получают свои градиенты путем вызова backward() на текущем графике.

Изучите несколько примеров

Один обучающий пример не может определить уникальную строку: многие пары (w,b)(w,b) удовлетворяют 2w+b=52w+b=5. Добавьте еще примеры:

Вход xxЦель yy
01
13
25
37

Эти точки лежат на y=2x+1y=2x+1, поэтому мы можем проверить, находит ли обучение w≈2w\approx2 и b≈1b\approx1.

Определите потерю как среднее четырехквадратной ошибки:

L(w,b)=14∑i=14(wxi+b−yi)2.L(w,b)=\frac14\sum_{i=1}^{4}(wx_i+b-y_i)^2.

Общий вес появляется во всех четырех прогнозах. В обратном направлении каждый путь вносит свой вклад в один и тот же w.grad. Умножение на 1/41/4 масштабирует все вклады в соответствии со средними потерями. Суммирование вместо усреднения приведет к умножению обоих градиентов на четыре.

Используйте Step для проверки одного обновления, затем Run 100 steps. Попробуйте скорость обучения 0.30.3 из состояния сброса, чтобы увидеть рост потерь, даже если градиенты рассчитаны правильно.

Experiment 6Use the gradients to learn a line
prediction ŷ against input xAfter 0 updates, w=-1, b=0, mean squared error=41.5. The four targets lie on y=2x+1.-0.3-40.6-0.751.52.52.45.753.39prediction ŷinput x
Model: ŷ = wx + bTargetsPrediction errors
Step 0 · w = -1 · b = 0 · MSE = 41.5
log₁₀(MSE) against training stepLoss history on a logarithmic scale. A value of −3 means MSE 0.001. Losses below 10⁻¹² are plotted at −12.0-32.5-1.755-0.57.50.75102log₁₀(MSE)training step

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

Оба градиента получаются в результате одного и того же прямого прохода до изменения любого параметра. Следующая итерация пересчитывает прогнозы, потери и производные с обновленными параметрами. Возвращенные потери оцениваются после последнего обновления, поэтому они описывают возвращаемые веса.

Используйте тот же движок для нейронной сети

До сих пор модель была аффинной. Небольшая нейронная сеть не нуждается в новом механизме дифференциации: ее прямой проход представляет собой более крупный состав операций, которые мы уже поддерживаем.

Нейрон умножает входные данные на веса, добавляет смещение и при необходимости применяет tanh. MLP объединяет слои нейронов. В этом примере регрессии мы сохраняем линейный последний слой:

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, кодируя одинаковые входные данные как −1-1, а разные входные данные как +1+1. Никакая аффинная функция двух входов не может точно соответствовать всем четырем целям; скрытые нелинейные активации позволяют модели соответствовать им.

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 шагов и скоростью обучения 0.10.1 прогнозы XOR округляются до [-1.0, 1.0, 1.0, -1.0] с четырьмя знаками после запятой. Это небольшая демонстрация с фиксированными данными и инициализацией, а не гарантия произвольных сетей или скорости обучения.

Чему способствовала потеря

Мы начали с производной от x2x^2, для которой не требовалось понятия модели или обучения. Затем мы выбрали функцию прогнозирования и прикрепили потери. Замена сделала потерю функцией параметров, что дало нам возможность задать точный вопрос: как изменится эта цель, если изменится один параметр?

Потери определяют цель и ее чувствительность к прогнозу. Модель предоставляет пути от параметров к этому прогнозу. Обратное распространение ошибки объединяет локальные производные вдоль этих путей, добавляя вклады в местах пересечения путей. Градиентный спуск использует полученные градиенты параметров для выбора обновления.

Для модели, построенной на основе операций с применимыми правилами производной, скалярная потеря дает нашему механизму дифференциации исходные данные, с которых можно начать, и цель, которую оптимизатор должен уменьшить.

Более широкую информацию о нейронных сетях см. в разделе «Как обучаются нейронные сети: обратное распространение ошибки и градиентный спуск» (/posts/1001/ru/how-neural-networks-learn-backpropagation-gradient-descent). Дополнительную реализацию, которая присоединяет обратные функции к операциям, см. в движке микрограда Карпати и его Нейронные сети: материалы от нуля до героя.

Просмотр изображения

Масштаб: жест щипка или + / −. Перемещение: перетаскивание или стрелки. Esc — закрыть.