Перейти к содержимому
ПереводыАндрей Карпатый

Zero to Hero, лекция 1: нейросети и backprop с нуля — пишем micrograd

Подробный конспект первой лекции Карпатого: что измеряет производная, как класс Value записывает граф вычислений, как обратный проход раз за разом применяет цепное правило — и как на автограде в полторы сотни строк обучить настоящую нейросеть.

  • Карпатый с пустого Jupyter-ноутбука пишет micrograd — крошечный автоградавтоматическое дифференцирование (автоград)EN automatic differentiation (autograd)Программа записывает операции в граф и сама считает производные по цепному правилу. Для loss → параметры используют обратный режим (reverse mode) — это и есть backprop.: движок, который сам считает производные. В оригинальном репозитории ядро (engine.py) занимает меньше 100 строк, библиотека нейросетей поверх него (nn.py) — около 60.
  • Главная мысль лекции: нейросеть — это просто большое математическое выражение. Входы — данные и веса, выход — предсказание или loss. Обратное распространение (backpropобратное распространение ошибкиEN backpropagation) вообще не знает про нейросети: оно умеет дифференцировать любое выражение, а обучение сетей — лишь одно из применений.
  • Backprop — это цепное правилоцепное правилоEN chain ruleПроизводная композиции — произведение производных звеньев — (f(g(x)))′ = f′(g(x))·g′(x). Математическая основа backprop., применённое рекурсивно от выхода к входам. Каждому узлу графа достаточно знать свою локальную производную: сложение «раздаёт» градиент без изменений, умножение домножает его на значение соседа, tanh — на 1−t21 - t^2.
  • micrograd работает с отдельными числами (скалярами). Настоящие библиотеки упаковывают числа в тензоры, но, по словам Карпатого, математика от этого не меняется — меняется только скорость.
  • Финал: свой многослойный перцептрон (MLP) на 41 параметр учится на четырёх примерах. По дороге Карпатый в кадре ловит два бага, которые делает почти каждый: градиент перезаписывается вместо накопления и градиенты не обнуляются между шагами.

Лекция покрывает сразу два блока курса. Математика — производная, цепное правило, градиент — разобрана в уроках 1.2.1, 1.2.2 и 1.2.3. Сам micrograd курс пишет вместе с вами в модуле 3.2: урок 3.2.1 «Класс Value и вычислительный граф», 3.2.2 «Автоматическое дифференцирование» и 3.2.3 «Обучаем MLP на своём автограде».

Удобнее всего так: пройти урок 1.2.2, посмотреть первую половину лекции (до 1:22), затем уроки 3.2.1–3.2.2 и вторую половину. Если математика уже знакома, можно смотреть лекцию целиком перед модулем 3.2 — курс повторит то же самое в своём темпе и с домашним заданием. Новичку не стоит пропускать ни одной главы: лекция идёт медленно и нарочно показывает ошибки.

Код лекции — два ноутбука в папке lectures/micrograd репозитория nn-zero-to-hero (MIT). Готовая библиотека — репозиторий karpathy/micrograd (MIT). Для отрисовки графов нужен пакет graphviz, остальной код работает на чистом Python. Пояснения на китайском есть в учебной версии курса на китайском (репозиторий karpathy-zero-to-hero-zh-transcripts): ноутбук 001_micrograd.

Таймкоды — из глав в описании видео.

ВремяГлава в оригиналеО чём
0:00introчто будет: от пустого ноутбука до обученной сети
0:29micrograd overviewчто такое автоград, пример из README
8:09derivative of a simple function with one inputпроизводная как наклон, численная оценка
14:15derivative of a function with multiple inputsчастные производные на примере a*b + c
19:11starting the core Value object of micrograd and its visualizationкласс Value, граф, рисование через graphviz
32:11manual backpropagation example #1: simple expressionобратный проход руками, цепное правило
51:10preview of a single optimization stepшаг по градиенту увеличивает выход
52:52manual backpropagation example #2: a neuronнейрон с tanh, ещё раз руками
1:09:05implementing the backward function for each operationзамыкания _backward
1:17:36implementing the backward function for a whole expression graphтопологическая сортировка, backward()
1:22:32fixing a backprop bug when one node is used multiple times= против +=
1:27:05breaking up a tanh, exercising with more operationsexp, степень, деление, вычитание
1:39:33doing the same thing but in PyTorch: comparisonтот же нейрон в PyTorch
1:43:55building out a neural net library (multi-layer perceptron) in microgradNeuron, Layer, MLP
1:51:08creating a tiny dataset, writing the loss functionчетыре примера и квадратичный loss
1:58:01collecting all of the parameters of the neural netparameters()
2:01:18doing gradient descent optimization manually, training the networkцикл обучения и баг с zero_grad
2:14:03summary of what we learned, how to go towards modern neural netsитоги, путь к GPT
2:16:50walkthrough of the full code of micrograd on githubобзор репозитория
2:21:13real stuff: diving into PyTorch, finding their backward pass for tanhгде в PyTorch живёт backward для tanh
2:24:42conclusionзаключение

Автоград (automatic gradient) — программа, которая по записанному выражению сама находит производные выхода по всем входам. В README micrograd есть нарочно запутанный пример: из двух чисел a=−4a = -4 и b=2b = 2 через сложения, умножения, степени и ReLU получается gg. Прямой проход даёт g=24.7041g = 24.7041. После вызова g.backward() в a.grad лежит 138.8338138.8338, в b.grad — 645.5773645.5773. Читать эти числа нужно так: если чуть увеличить aa, то gg вырастет примерно в 138 раз сильнее этой прибавки.

Карпатый подчёркивает три вещи.

  1. Нейросеть — частный случай выражения. Входы — данные и веса, выход — предсказания или loss. Backprop работает с любым выражением, нейросети ему не нужны.
  2. Скаляры — ради понятности. micrograd дробит сеть до отдельных сложений и умножений. В реальной работе так никто не делает: числа складывают в тензоры, чтобы считать параллельно. Но математика та же.
  3. Всё остальное — эффективность. Его формулировка (около 6:50): «micrograd — это всё, что нужно для обучения нейросетей, а всё остальное — лишь эффективность». Эффективность, конечно, отдельная огромная тема, но принцип обучения умещается в полторы сотни строк.

Прежде чем писать автоград, Карпатый добивается, чтобы было ясно, что измеряет производная. Он берёт параболу f(x)=3x2−4x+5f(x) = 3x^2 - 4x + 5 и не выводит формулу производной, а оценивает её численно — по определению:

f′(x)≈f(x+h)−f(x)h,h маленькое.f'(x) \approx \frac{f(x + h) - f(x)}{h}, \quad h \text{ маленькое}.

Словами: сдвинь вход на крошечное hh, посмотри, на сколько изменился выход, и раздели на hh («подъём на пробег»). В точке x=3x = 3 получается почти ровно 14 (и правда: f′(x)=6x−4f'(x) = 6x - 4, 6⋅3−4=146 \cdot 3 - 4 = 14). В точке x=−3x = -3 наклон отрицательный, −22-22: при движении вправо функция идёт вниз. В точке x=2/3x = 2/3 наклон нулевой — это вершина параболы.

Почему символьно никто не дифференцирует: выражение настоящей нейросети содержит десятки тысяч слагаемых. А численная оценка ломается, если hh слишком мало: числа с плавающей точкой конечны. Проверим:

def f(x):
return 3*x**2 - 4*x + 5
for h in [1e-3, 1e-6, 1e-10, 1e-14, 1e-16]:
print(h, (f(3 + h) - f(3)) / h)
# 0.001 14.003 — грубо, но близко
# 1e-06 14.000003 — хорошо
# 1e-10 14.0000012
# 1e-14 14.21 — уже ошибки округления
# 1e-16 0.0 — 3 + 1e-16 в float64 равно 3, разность исчезла

Дальше — функция трёх входов d=a⋅b+cd = a \cdot b + c в точке a=2a = 2, b=−3b = -3, c=10c = 10. Сдвигаем по очереди каждый вход и видим: по aa наклон −3-3 (это значение bb), по bb наклон 22 (значение aa), по cc наклон 11. Это и есть частные производные — урок 1.2.3 называет их так же.

3. Класс Value: число, которое помнит своё происхождение

Заголовок раздела «3. Класс Value: число, которое помнит своё происхождение»

Нейросети — огромные выражения, и нужна структура данных, которая их хранит. Карпатый заводит класс Value:

  • data — само число;
  • _prev — из каких узлов получено (детей хранит множество, а в конструктор их передают кортежем _children);
  • _op — какой операцией: '+', '*', 'tanh';
  • label — подпись для картинки;
  • grad — производная итогового выхода по этому узлу. Сначала 0: считаем, что узел на выход не влияет.

Сложение и умножение работают через магические методымагический метод (dunder-метод)EN special method, magic methodМетоды вида __add__, __len__, __call__: Python вызывает их сам, когда вы пишете a + b, len(a), a(x). Python: выражение a + b Python превращает в вызов a.__add__(b). Метод возвращает новый Value и запоминает, кто его родил. Так при обычном вычислении L = (a*b + c) * f в памяти сам собой строится вычислительный графвычислительный графEN computational graphСхема вычисления — узлы-операции и рёбра-значения. По нему идёт прямой проход (значения) и обратный (градиенты).. Функция draw_dot (около 40 строк поверх graphviz) рисует его: прямоугольники — значения, кружки — операции.

flowchart LR
a["a = 2"] --> m(("*"))
b["b = -3"] --> m
m --> e["e = -6"]
e --> p(("+"))
c["c = 10"] --> p
p --> d["d = 4"]
d --> m2(("*"))
f["f = -2"] --> m2
m2 --> L["L = -8"]
Граф выражения L = (a·b + c)·f из лекции: прямой проход идёт слева направо, обратный — справа налево.

Теперь нужно заполнить grad у каждого узла: ∂L/∂(узел)\partial L / \partial(\text{узел}). Карпатый делает это вручную, от конца к началу, и каждое значение проверяет численно. Для этого он пишет функцию-«песочницу» lol(): она дважды строит граф, во второй раз добавив hh к одному из узлов, и печатает (L2−L1)/h(L_2 - L_1)/h. Карпатый называет это встроенной проверкой градиента (gradient checkпроверка градиентаEN gradient checkСравнение аналитического градиента с численным (центральные разности) по относительной ошибке. Главный способ найти баг в backward.).

УзелКак полученГрадиентПочему
LLвыход11изменение LL на hh меняет LL на hh
ffL=d⋅fL = d \cdot fd=4d = 4производная произведения по ff — второй множитель
ddL=d⋅fL = d \cdot ff=−2f = -2то же, симметрично
cc, eed=e+cd = e + c−2-2сложение передаёт градиент без изменений
aae=a⋅be = a \cdot b−2⋅b=6-2 \cdot b = 6цепное правило: градиент ee × локальная производная
bbe=a⋅be = a \cdot b−2⋅a=−4-2 \cdot a = -4то же

Самое важное место лекции — градиент cc. Узел «плюс» ничего не знает о том, что происходит дальше в графе. Он знает только локальную производную: ∂d/∂c=1\partial d / \partial c = 1. А нам нужна ∂L/∂c\partial L / \partial c. Связывает их цепное правило:

∂L∂c=∂L∂d⋅∂d∂c=(−2)⋅1=−2.\frac{\partial L}{\partial c} = \frac{\partial L}{\partial d} \cdot \frac{\partial d}{\partial c} = (-2) \cdot 1 = -2.

Карпатый поясняет его примером из Википедии: машина едет вдвое быстрее велосипеда, велосипед вчетверо быстрее пешехода — значит, машина в 2⋅4=82 \cdot 4 = 8 раз быстрее пешехода. Скорости изменения перемножаются.

Отсюда два правила, которые дальше повторяются всю серию:

  • сложение — раздатчик: градиент выхода просто копируется во все входы;
  • умножение — обменник: градиент входа равен градиенту выхода, умноженному на значение другого входа.

Backprop целиком, по словам Карпатого, — это рекурсивное применение цепного правила назад по графу. Чтобы показать, зачем всё это, он делает один шаг «оптимизации»: сдвигает листья a,b,c,fa, b, c, f на 0.01⋅grad0.01 \cdot \text{grad}, то есть по направлению градиента. LL растёт с −8-8 до −7.29-7.29, как и ожидалось.

Второй пример — модель нейрона: входы x1,x2x_1, x_2, веса w1,w2w_1, w_2, смещение bb, сумма n=x1w1+x2w2+bn = x_1 w_1 + x_2 w_2 + b и функция активации tanhгиперболический тангенсEN hyperbolic tangent (tanh)tanh x = (eˣ − e⁻ˣ)/(eˣ + e⁻ˣ) — «сигмоида» со значениями от −1 до 1. Производная 1 − tanh² x., которая сжимает любое число в интервал от −1-1 до 11. Число b=6.8813735870195432b = 6.8813735870195432 Карпатый подбирает нарочно, чтобы числа вышли круглыми: при x1=2x_1 = 2, w1=−3w_1 = -3, x2=0x_2 = 0, w2=1w_2 = 1 получается n≈0.8814n \approx 0.8814 и o=tanh⁡(n)≈0.7071o = \tanh(n) \approx 0.7071, то есть 1/21/\sqrt 2.

tanh реализован одной операцией, а не через экспоненты. Его локальная производная берётся из таблицы: ddntanh⁡(n)=1−tanh⁡2(n)=1−o2\frac{d}{dn}\tanh(n) = 1 - \tanh^2(n) = 1 - o^2. Подставляем o2=0.5o^2 = 0.5 и получаем ровно 0.50.5. Дальше работают два правила из прошлого раздела:

УзелГрадиентКомментарий
nn, bb, x1w1x_1w_1, x2w2x_2w_20.50.5через tanh, потом плюсы раздают
x1x_1w1⋅0.5=−1.5w_1 \cdot 0.5 = -1.5
w1w_1x1⋅0.5=1x_1 \cdot 0.5 = 1
x2x_2w2⋅0.5=0.5w_2 \cdot 0.5 = 0.5
w2w_2x2⋅0.5=0x_2 \cdot 0.5 = 0вход нулевой — вес ни на что не влияет

Ноль у w2w_2 Карпатый объясняет интуитивно: пока x2=0x_2 = 0, шевели этот вес как угодно — выход нейрона не изменится, значит, и производная нулевая.

6. Автоматизируем: _backward и топологическая сортировка

Заголовок раздела «6. Автоматизируем: _backward и топологическая сортировка»

Хватит считать руками. Каждая операция при создании результата кладёт в него маленькую функцию _backward — замыканиезамыканиеEN closureВнутренняя функция, которая запоминает переменные внешней функции и после её завершения. Так устроены планировщики learning rate., которое помнит входы и знает, как передать им градиент. Для листьев это пустая функция. В кадре случается смешная ошибка: Карпатый пишет out._backward = _backward() со скобками, сохраняет None вместо функции и получает «‘NoneType’ object is not callable». Он оставляет её в видео.

Вызывать _backward нужно в правильном порядке: узел можно обработать только тогда, когда градиент от всех его потребителей уже пришёл. Такой порядок даёт топологическая сортировкатопологическая сортировкаEN topological sortПорядок узлов ориентированного графа без циклов, в котором каждый узел идёт после всех, от кого зависит. backward() обходит его в обратную сторону.: узел добавляется в список только после всех своих детей, а обратный проход идёт по списку задом наперёд. Код из ноутбука лекции (MIT), комментарии наши:

import math
class Value:
def __init__(self, data, _children=(), _op=''):
self.data = data
self.grad = 0.0 # dL/d(этот узел); 0 — «пока не влияет»
self._backward = lambda: None # у листьев передавать градиент некому
self._prev = set(_children) # из каких узлов получен
self._op = _op # какой операцией
def __add__(self, other):
other = other if isinstance(other, Value) else Value(other) # 2 → Value(2)
out = Value(self.data + other.data, (self, other), '+')
def _backward(): # сложение раздаёт градиент обоим входам
self.grad += 1.0 * out.grad
other.grad += 1.0 * 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(): # tanh'(x) = 1 - tanh(x)^2
self.grad += (1 - t**2) * out.grad
out._backward = _backward
return out
def backward(self):
topo, visited = [], set()
def build_topo(v): # узел попадает в список после всех детей
if v not in visited:
visited.add(v)
for child in v._prev:
build_topo(child)
topo.append(v)
build_topo(self)
self.grad = 1.0 # базовый случай: dL/dL = 1
for node in reversed(topo): # от выхода к листьям
node._backward()
x1, x2 = Value(2.0), Value(0.0)
w1, w2 = Value(-3.0), Value(1.0)
b = Value(6.8813735870195432)
o = (x1*w1 + x2*w2 + b).tanh()
o.backward()
print(round(o.data, 4), x1.grad, w1.grad, x2.grad, w2.grad)
# 0.7071 -1.5 1.0 0.5 0.0 (с точностью до округления)

«Не стоит слишком радоваться», — говорит Карпатый и показывает баг. Возьмём b = a + a при a=3a = 3. Производная bb по aa равна 2, а первая версия кода выдаёт 1. Причина: в __add__ и self, и other — один и тот же объект, и вторая запись grad = ... затирает первую. Тот же баг ломает любое выражение, где переменная встречается больше одного раза, например f=(a⋅b)⋅(a+b)f = (a \cdot b) \cdot (a + b).

Исправление — многомерное цепное правило: если узел влияет на выход по нескольким путям, вклады путей складываются. Поэтому в _backward везде стоит +=, а grad стартует с нуля. Курс разбирает это в разделе «Накопление градиентов: почему +=» урока 3.2.2 и раньше — в разделе «Ветвление: градиенты складываются» урока 1.2.2.

8. Больше операций — и свобода в выборе «атомов»

Заголовок раздела «8. Больше операций — и свобода в выборе «атомов»»

Чтобы потренироваться, Карпатый раскладывает tanh на более простые операции: tanh⁡(n)=e2n−1e2n+1\tanh(n) = \dfrac{e^{2n} - 1}{e^{2n} + 1}. Для этого нужны:

  • __radd__ и __rmul__, чтобы работало 2 * a: Python сначала пробует int.__mul__, не справляется и вызывает запасной метод у правого операнда (перегрузка операторовперегрузка операторовEN operator overloading в уроке 0.2.3);
  • exp — его локальная производная равна самому exe^x, которое уже лежит в out.data;
  • степень с постоянным показателем, x**k, с производной kxk−1k x^{k-1}; деление выражается как a * b**-1;
  • вычитание как сложение с отрицанием, а отрицание — умножение на −1-1.

Граф становится длиннее, но прямой проход и градиенты листьев совпадают с версией, где tanh — одна операция. Вывод, который Карпатый повторит в следующих лекциях: на каком уровне делать операции, решать вам. Можно дифференцировать отдельный плюс, а можно целый tanh. Важно лишь, чтобы операция умела прямой проход и знала свою локальную производную. (В ноутбуке оставлено примечание: в видео в exp по ошибке стояло = вместо +=, в коде это исправлено.)

micrograd намеренно повторяет API PyTorch. Тот же нейрон на PyTorch выглядит так:

import torch
# тензоры из одного числа; .double() — float64, как обычные числа Python
x1 = torch.Tensor([2.0]).double(); x1.requires_grad = True
x2 = torch.Tensor([0.0]).double(); x2.requires_grad = True
w1 = torch.Tensor([-3.0]).double(); w1.requires_grad = True
w2 = torch.Tensor([1.0]).double(); w2.requires_grad = True
b = torch.Tensor([6.8813735870195432]).double(); b.requires_grad = True
o = torch.tanh(x1*w1 + x2*w2 + b)
o.backward()
print(o.item()) # 0.7071...
print(x2.grad.item(), w2.grad.item(), x1.grad.item(), w1.grad.item())
# ≈ 0.5 0.0 -1.5 1.0 — те же числа, что у micrograd

Отличия Карпатый перечисляет сам. В PyTorch всё — тензоры, то есть массивы чисел. По умолчанию они хранятся в float32, отсюда приведение к double. У листьев requires_grad по умолчанию выключен ради экономии: градиенты по входным данным обычно не нужны. .item() достаёт число из тензора с одним элементом. В остальном micrograd — это частный случай PyTorch, когда все тензоры состоят из одного числа. Подробно это сравнение проводит урок 3.3.1.

Поверх Value строится то, что в micrograd лежит в nn.py:

import random
class Neuron:
def __init__(self, nin):
self.w = [Value(random.uniform(-1, 1)) for _ in range(nin)] # по весу на вход
self.b = Value(random.uniform(-1, 1)) # смещение
def __call__(self, x):
act = sum((wi * xi for wi, xi in zip(self.w, x)), self.b) # w·x + 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):
outs = [n(x) for n in self.neurons]
return outs[0] if len(outs) == 1 else outs
def parameters(self):
return [p for n in self.neurons for p in n.parameters()]
class MLP: # перцептрон — слои друг за другом
def __init__(self, nin, nouts):
sz = [nin] + nouts
self.layers = [Layer(sz[i], sz[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()]

Метод __call__ позволяет вызывать объект как функцию: n(x). Хитрость с sum(..., self.b): у sum есть второй аргумент — начальное значение, и сумма начинается сразу со смещения. Сеть MLP(3, [4, 4, 1]) — три входа, два скрытых слоя по четыре нейрона и один выход. Параметров 3⋅4+4=163 \cdot 4 + 4 = 16, 4⋅4+4=204 \cdot 4 + 4 = 20 и 4+1=54 + 1 = 5, всего 41.

Данные — четыре примера по три числа и желаемые ответы 1,−1,−1,11, -1, -1, 1. Это крошечная бинарная классификация. Качество измеряет одно число — функция потерьфункция потерьEN loss function: сумма квадратов разностей между предсказаниями и ответами (MSEсреднеквадратичная ошибка (MSE)EN mean squared error (MSE)Корень из MSE — RMSE (root mean squared error), измеряется в тех же единицах, что и целевая переменная.). Loss равен нулю только при идеальных предсказаниях и тем больше, чем сильнее промахи. Карпатый рисует граф этого loss и ужасается: в нём четыре полных прямых прохода сети и квадратичная потеря поверх. Градиенты получают все листья, в том числе входные данные, но данные менять нельзя. Нужны только градиенты по весам и смещениям.

Градиент указывает направление, в котором loss растёт. Поэтому шаг делается в обратную сторону: p.data += -0.01 * p.grad. Карпатый сначала ошибается со знаком и разбирает на конкретном весе, почему нужен минус. Цикл «прямой проход → обратный проход → шаг» повторяется. Loss падает с 4.84 до 4.36, потом до 3.9 и дальше.

С шагом Карпатый экспериментирует. При слишком большом learning rate сеть «перепрыгивает»: градиент описывает loss только локально, и далёкий шаг уводит туда, где картина совсем другая. Подбор шага он называет «тонким искусством» (subtle art). Подробно про learning rateскорость обученияEN learning rate — в уроке 1.4.1.

Затем оформляется настоящий цикл обучения:

xs = [[2.0, 3.0, -1.0], [3.0, -1.0, 0.5], [0.5, 1.0, 1.0], [1.0, 1.0, -1.0]]
ys = [1.0, -1.0, -1.0, 1.0] # желаемые ответы
n = MLP(3, [4, 4, 1])
for k in range(20):
ypred = [n(x) for x in xs] # прямой проход
loss = sum((yout - ygt)**2 for ygt, yout in zip(ys, ypred)) # сумма квадратов ошибок
for p in n.parameters():
p.grad = 0.0 # обнулить! иначе += копит градиенты прошлых шагов
loss.backward() # обратный проход
for p in n.parameters():
p.data += -0.1 * p.grad # шаг против градиента
print(k, loss.data)

И тут Карпатый признаётся, что в первой версии этого цикла не было обнуления градиентов. Это одна из самых частых ошибок, и он делал её, по его словам, «в двадцатый раз в жизни». Он вспоминает свой старый твит со списком типичных ошибок в нейросетях — эта там под номером три. Раз _backward прибавляет к grad, градиенты всех прошлых шагов складывались. Сеть всё равно обучилась, причём быстрее: задача слишком простая, а накопленный градиент работал как огромный шаг. На задаче посложнее такой баг сорвал бы обучение. Урок Карпатого — нейросеть может «работать» и с багом, поэтому одной сходимости loss мало. После исправления спуск идёт медленнее, но честно.

Карпатый подытоживает. Нейросеть — математическое выражение. Входы — данные и параметры. Поверх стоит loss, который мал, когда сеть делает то, что нужно. Backprop даёт градиент. Градиентный спуск много раз сдвигает параметры против градиента. GPT с сотнями миллиардов параметров обучается на тех же принципах. Отличий немного: вместо квадратичной потери там кросс-энтропияперекрёстная энтропияEN cross-entropy для предсказания следующего токена, а вместо простого шага — более сложное правило обновления. Сеть, обученная предсказывать следующее слово на текстах из интернета, проявляет удивительные свойства, но механика обучения та же.

Обзор репозитория micrograd показывает, чем он отличается от кода лекции:

  • в engine.py вместо tanh нелинейность ReLU (на момент записи tanh в библиотеке не было). Разница, по словам Карпатого, несущественна, а tanh в лекции выбран потому, что его производная сложнее и лучше тренирует;
  • в nn.py есть базовый класс Module с методом zero_grad() — как nn.Module в PyTorch;
  • тесты в test/ сравнивают прямой и обратный проход micrograd с PyTorch на нескольких выражениях;
  • demo.ipynb — задача посложнее: точки двух классов на плоскости («луны»). Там сеть побольше, мини-батчи, другая потеря (max-margin, она же hinge), L2-регуляризация и убывающий learning rate. Курс решает ту же задачу в уроке 3.2.3, только с логистической потерей.

В конце Карпатый ищет в исходниках PyTorch обратный проход tanh — аналог нашей строки (1 - t**2) * out.grad. Минут пятнадцать поиска ничего не дают: по запросу «tanh» находятся тысячи упоминаний в сотнях файлов. В итоге код нашёлся через чужое обсуждение: есть отдельное ядро для CPU и отдельное для GPU. CPU-версия длинная, потому что обрабатывает комплексные числа и тип bfloat16, а в глубине стоит та же формула «градиент × (1 − b²)». Его вывод: большие библиотеки сделаны, чтобы ими пользоваться, а не чтобы их читать. micrograd простой, а найти нужное место в «настоящем» коде трудно.

Последнее, что он показывает, — как добавить в PyTorch собственную операцию. Нужно унаследоваться от torch.autograd.Function и описать два метода: forward (как считать) и backward (локальный градиент). В документации пример — многочлен Лежандра третьей степени. Это ровно то, что мы делали с Value: если операция умеет прямой проход и знает свою локальную производную, она становится ещё одним «кубиком лего» в большом графе.

Где курс объясняет то же самое и что есть сверх курса

Заголовок раздела «Где курс объясняет то же самое и что есть сверх курса»
Тема лекцииГде в курсеЧто в лекции сверх курса
Производная как наклон, численная оценка1.2.1эксперимент с размером hh и ошибками округления — в видео вживую
Цепное правило, граф, сумма по ветвям1.2.2, 1.2.3ручной backprop с численной проверкой каждого узла
Нейрон, слой, MLP; прямой и обратный проход3.1.1, 3.1.2—
Класс Value, перегрузка операторов, рисование графа3.2.1draw_dot через graphviz, разбор tanh на exp
_backward, топологическая сортировка, +=3.2.2баги в кадре: None вместо функции, перезапись градиента
Neuron / Layer / MLP, цикл обучения, zero_grad3.2.3как баг с обнулением маскируется на простой задаче
Сравнение с PyTorch3.3.1поиск backward tanh в исходниках PyTorch, autograd.Function
Градиентный спуск, learning rate1.4.1—

Курс в модуле 3.2 идёт дальше лекции в двух местах. Сеть учится на «лунах», а не на четырёх точках. И есть домашнее задание s3-hw01-micrograd с автотестами, которое сверяет ваш движок с эталоном.

  1. Повторите лекцию с пустого ноутбука, не копируя код. Карпатый советует именно так: смотреть, ставить на паузу, набирать самому.
  2. Для выражения L=(a⋅b+c)⋅fL = (a \cdot b + c) \cdot f посчитайте все градиенты на бумаге, затем сверьте с L.backward() и с численной проверкой.
  3. Упражнения из описания видео (Colab-ноутбук; его копия с пояснениями на китайском лежит в репозитории karpathy-zero-to-hero-zh-transcripts, файл 001_micrograd/micrograd_exercises.ipynb):
    • для f(a,b,c)=−a3+sin⁡(3b)−1/c+b2.5−a0.5f(a, b, c) = -a^3 + \sin(3b) - 1/c + b^{2.5} - a^{0.5} выведите аналитический градиент в точке (2,3,4)(2, 3, 4), затем оцените его численно;
    • сравните обычную разность f(x+h)−f(x)h\frac{f(x+h) - f(x)}{h} с симметричной f(x+h)−f(x−h)2h\frac{f(x+h) - f(x-h)}{2h}: какая точнее при том же hh?
    • допишите в Value операции exp, log, деление и другие, соберите softmax и отрицательное лог-правдоподобие, сравните градиенты с PyTorch.
  4. Уберите из _backward все += (замените на =) и найдите самое короткое выражение, на котором градиент становится неверным.
  5. Уберите обнуление градиентов из цикла обучения и сравните кривые loss при learning rate 0.01 и 0.1.
  6. Добавьте в Value метод relu() и обучите MLP с ReLU вместо tanh. Что меняется в скорости обучения?
ENRU
autograd engineавтоград, движок автоматического дифференцирования
backpropagationобратное распространение ошибки, backprop
derivative / slopeпроизводная / наклон
chain ruleцепное правило
local derivativeлокальная производная
computational graph, expression graphвычислительный граф
leaf nodeлист графа
topological sortтопологическая сортировка
gradient accumulation (+=)накопление градиента
gradient checkпроверка градиента
forward pass / backward passпрямой проход / обратный проход
neuron, weights, biasнейрон, веса, смещение
activation function, squashing functionфункция активации, «сжимающая» функция
multi-layer perceptron (MLP)многослойный перцептрон
loss, mean squared errorфункция потерь, среднеквадратичная ошибка
gradient descent, step size, learning rateградиентный спуск, размер шага, learning rate
zero gradобнуление градиентов
learning rate decayзатухание learning rate