Перейти к содержимому
Ступень 33.2 Свой автоград (micrograd)

Обучаем MLP на своём автограде

Собираем нейрон, слой и MLP поверх Value, пишем цикл обучения с zero_grad и учим сеть разделять две «луны»; разбираем типичные ошибки обучения и сравниваем с тем же кодом на PyTorch.

УРОКstage-3/3-2-micrograd/03-train-mlp.mdx1 ч 40 минУрок 3 из 3
После урока вы сможете
  • Собрать классы Neuron, Layer, MLP поверх Value и посчитать число их параметров
  • Написать цикл обучения (прямой проход → zero_grad → backward → шаг) и обучить сеть на «лунах» до accuracy ≥ 95%
  • Узнавать по симптомам типичные ошибки — забытый zero_grad, одинаковая инициализация, неудачный learning rate, обновление p вместо p.data
  • Сопоставить каждую строку своего кода с PyTorch (nn.Module, zero_grad, backward, optimizer.step)

У нас есть всё, чтобы замкнуть круг. Value считает прямой проход и строит граф (урок 3.2.1), backward() считает градиенты (урок 3.2.2), а градиентный спуск вы знаете с урока 1.4. Осталось собрать из Value нейронынейрон (искусственный нейрон)EN neuron (artificial neuron, unit)Взвешенная сумма входов плюс сдвиг, пропущенная через нелинейность: a = f(w·x + b)., сложить их в слои и многослойный перцептронмногослойный перцептрон (MLP)EN multilayer perceptron (MLP)Чередование линейных слоёв и нелинейных активаций. FFN в блоке трансформера — это MLP с одним скрытым слоем., написать цикл обученияцикл обученияEN training loopПовторять: прямой проход и loss → обнуление градиентов → backward → шаг оптимизатора. и посмотреть, как сеть сама находит границу между классами.

Это не учебная игрушка, а та же самая схема, по которой обучают любую LLM. Отличия только в масштабе: тензоры вместо скаляров, миллиарды параметров вместо 25, Adam вместо простого спуска, GPU вместо одного ядра процессора. Каждой строке нашего кода соответствует строка в PyTorch:

Наш microgradPyTorchЧто делает
Neuron, Layer, MLPnn.Linear, nn.Sequential, nn.Moduleмодель
model.parameters()model.parameters()список обучаемых параметров
model.zero_grad()optimizer.zero_grad()обнулить .grad
loss.backward()loss.backward()обратный проход
p.data -= lr * p.gradoptimizer.step()шаг оптимизатора

Нейрон из урока 3.1.1: tanh⁡(w⊤x+b)\tanh(w^\top x + b). Слой — список нейронов, MLP — список слоёв. Общий предок Module умеет обнулять градиенты всех своих параметров. Наследование (class Neuron(Module)) и метод __call__, который позволяет вызывать объект как функцию, вы писали в уроке 0.2.3:

import random
class Module:
def zero_grad(self):
for p in self.parameters():
p.grad = 0.0
def parameters(self):
return []
class Neuron(Module):
def __init__(self, nin, nonlin=True):
self.w = [Value(random.uniform(-1, 1)) for _ in range(nin)] # случайные веса — важно!
self.b = Value(0.0)
self.nonlin = nonlin
def __call__(self, x): # neuron(x) вызывает этот метод
act = sum((wi * xi for wi, xi in zip(self.w, x)), self.b) # w·x + b
return act.tanh() if self.nonlin else act # tanh или «сырое» значение
def parameters(self):
return self.w + [self.b] # список весов плюс сдвиг
class Layer(Module):
def __init__(self, nin, nout, nonlin=True):
self.neurons = [Neuron(nin, nonlin) for _ in range(nout)]
def __call__(self, x):
return [n(x) for n in self.neurons] # выход слоя — список выходов нейронов
def parameters(self):
# «для каждого нейрона n, для каждого его параметра p — взять p»: один плоский список
return [p for n in self.neurons for p in n.parameters()]
class MLP(Module):
def __init__(self, nin, nouts):
sizes = [nin] + nouts
# tanh во всех слоях, КРОМЕ последнего: на выходе — «сырое» число (логит)
self.layers = [Layer(sizes[i], sizes[i + 1], nonlin=(i < len(nouts) - 1))
for i in range(len(nouts))]
def __call__(self, x):
for layer in self.layers:
x = layer(x) # выход слоя — вход следующего
return x[0] if len(x) == 1 else x # один выход — отдаём само число, а не список
def parameters(self):
return [p for layer in self.layers for p in layer.parameters()]

Несколько деталей, которые легко пропустить:

  • sum((wi * xi for ...), self.b) складывает произведения wixiw_i x_i и начинает сумму со сдвига: второй аргумент sum — стартовое значение. Без него sum начнёт с целого 0, и это тоже сработает благодаря __radd__ из урока 3.2.1. Просто в графе появится лишний узел.
  • zip(self.w, x) идёт по весам и входам парами: (w1,x1),(w2,x2),…(w_1, x_1), (w_2, x_2), \dots (урок 0.2.2).
  • __call__ позволяет писать model(x), как в PyTorch (урок 0.2.3).
  • Последний слой линейный. Его выход ss — логит, «уверенность» в классе +1+1. Вероятность и потерю добавит функция потерь, так же как в уроке 3.1.1.

MLP(2, [6, 1]) — это слои 2 → 6 → 1. Параметров (2⋅6+6)+(6⋅1+1)=25(2 \cdot 6 + 6) + (6 \cdot 1 + 1) = 25.

Классический игрушечный датасет make_moons. Две дуги вложены друг в друга, и прямой их не разделить. Сгенерируем его сами, без scikit-learn: по 20 точек на дугу, немного гауссова шума (случайных добавок из нормального распределения, урок 0.3.3), метки −1-1 и +1+1. Точки дуги — это (cos⁡t,sin⁡t)(\cos t, \sin t) при угле tt от 0 до π\pi: так в школе рисуют половину окружности. Вторая дуга перевёрнута и сдвинута. Код можно не разбирать построчно — важен результат: список точек X и список меток y.

import math
import random
def make_moons(n=40, noise=0.1, seed=1):
rng = random.Random(seed)
m = n // 2
X, y = [], []
for i in range(m):
t = math.pi * i / (m - 1) # угол от 0 до π
# две точки на этом угле: (метка, (x, y)) для верхней и для нижней дуги
for label, (a, b) in ((-1.0, (math.cos(t), math.sin(t))), # верхняя дуга
(1.0, (1 - math.cos(t), 0.5 - math.sin(t)))): # нижняя, сдвинутая
a += rng.gauss(0, noise) # гауссов шум: случайная добавка со средним 0
b += rng.gauss(0, noise)
X.append([(a - 0.5) * 1.5, (b - 0.25) * 2.0]) # центрируем и растягиваем к [−2, 2]
y.append(label)
return X, y

Последняя строка — та самая стандартизация признаков из урока 2.2. Если подать сырые координаты, обучение тоже пойдёт, но медленнее. Логистическая регрессия на этих данных даёт около 85% точности, и даже лучшая из всех прямых — 90%: часть точек «рогов» лун лежит по чужую сторону любой прямой. Посмотрим, сколько возьмёт MLP.

Для меток y∈{−1,+1}y \in \lbrace -1, +1 \rbrace удобно записать бинарную cross-entropy так:

L=1n∑i=1nlog⁡(1+e−yisi)L = \frac{1}{n} \sum_{i=1}^{n} \log\left(1 + e^{-y_i s_i}\right)

Словами: для каждой точки берём выход сети sis_i, умножаем на правильную метку yiy_i (то есть ±1\pm 1), считаем log⁡(1+e−yisi)\log(1 + e^{-y_i s_i}) и усредняем по всем точкам. Логарифм здесь натуральный, как во всём курсе.

Это ровно BCE из урока 2.2 с p=σ(s)p = \sigma(s). Проверим при y=+1y = +1: сигмоида σ(s)=1/(1+e−s)\sigma(s) = 1/(1 + e^{-s}), значит, −log⁡σ(s)=−log⁡11+e−s=log⁡(1+e−s)-\log \sigma(s) = -\log \frac{1}{1 + e^{-s}} = \log(1 + e^{-s}) — та же формула (логарифм дроби 1/a1/a равен −log⁡a-\log a, урок 1.0.2). При y=−1y = -1 аналогично получается −log⁡(1−σ(s))-\log(1 - \sigma(s)). Число: y=+1y = +1, s=2s = 2, тогда log⁡(1+e−2)=log⁡1.135≈0.127\log(1 + e^{-2}) = \log 1.135 \approx 0.127 — модель уверенно права, потеря маленькая.

Произведение y⋅sy \cdot s называют отступом (margin). Положительный отступ значит «верно» (знак выхода совпал со знаком метки), и чем он больше, тем увереннее:

y⋅sy \cdot s−2-2025
loss2.1270.6930.1270.007
def loss_fn(model, X, y):
total = Value(0.0)
for xi, yi in zip(X, y):
s = model(xi)
total = total + ((s * -yi).exp() + 1).log() # log(1 + e^(−y·s))
return total * (1.0 / len(X))
def accuracy(model, X, y):
# (выход > 0) == (метка > 0) даёт True, если знаки совпали; sum считает True как 1, False как 0
return sum((model(xi).data > 0) == (yi > 0) for xi, yi in zip(X, y)) / len(X)

Для log в Value нужна ещё одна операция с локальной производной 1/x1/x. В задании она уже добавлена. Accuracy — метрика, а не loss (урок 2.2): по ней нельзя взять градиент, зато её понятно показать человеку.

flowchart LR
F["1. Прямой проход<br/>loss = loss_fn(model, X, y)"] --> Z["2. zero_grad()<br/>все p.grad = 0"]
Z --> B["3. loss.backward()<br/>градиенты по графу"]
B --> S["4. Шаг<br/>p.data −= lr · p.grad"]
S -->|"следующая итерация"| F
Одна итерация обучения. Её же, в том же порядке, выполняет каждый шаг обучения LLM
random.seed(1)
X, y = make_moons(n=40, noise=0.1, seed=1)
model = MLP(2, [6, 1]) # 2 → 6 (tanh) → 1 (линейный выход)
print("параметров:", len(model.parameters()))
lr = 5.0
for step in range(50):
loss = loss_fn(model, X, y) # 1. прямой проход по всему датасету
if step % 10 == 0:
print(f"шаг {step:2d} loss = {loss.data:.3f} accuracy = {accuracy(model, X, y):.3f}")
model.zero_grad() # 2. обнуляем градиенты
loss.backward() # 3. обратный проход
for p in model.parameters(): # 4. шаг градиентного спуска
p.data -= lr * p.grad
print(f"итог loss = {loss_fn(model, X, y).data:.3f} accuracy = {accuracy(model, X, y):.3f}")
параметров: 25
шаг 0 loss = 1.126 accuracy = 0.225
шаг 10 loss = 0.256 accuracy = 0.900
шаг 20 loss = 0.179 accuracy = 0.950
шаг 30 loss = 0.050 accuracy = 1.000
шаг 40 loss = 0.030 accuracy = 1.000
итог loss = 0.021 accuracy = 1.000

Разберём каждый шаг.

  1. Прямой проход. Мы прогоняем все 40 точек и строим один граф, от 25 параметров до одного числа loss. С ускоренным Value из задания (в нём обычные числа не становятся узлами) в графе 1907 узлов, с Value из урока 3.2.2 — 2468. Это полный батч (full batch): датасет крошечный. LLM учат на мини-батчах — случайных порциях данных, но схема та же.
  2. zero_grad(). Градиенты копятся через += (урок 3.2.2). Без обнуления .grad хранил бы сумму градиентов всех прошлых шагов. Обнулять нужно только параметры: промежуточные узлы создаются заново при каждом прямом проходе и рождаются с grad = 0.
  3. backward() заполняет .grad у всех узлов графа, но нам нужны только 25 из них — параметры.
  4. Шаг. Двигаем каждый параметр против градиента. Обновляем именно p.data, то есть число внутри узла: сам шаг не должен попадать в граф. В PyTorch за это отвечает torch.no_grad() внутри optimizer.step().

Начальная точность 0.225 хуже монетки: случайные веса по чистой случайности «смотрят не туда». Уже к 20-му шагу сеть проходит порог 95%, к 30-му — 100%. Шаг η=5\eta = 5 кажется огромным по меркам урока 1.4. Но наш loss усреднён по точкам, а сеть крошечная, так что такой шаг тут работает. Как это ломается, увидим ниже.

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

def show_boundary(model, X, y, rows=15, cols=48):
"""Карта решений: '·' — сеть говорит «−1», '░' — «+1»; точки данных: o (−1) и x (+1)."""
grid = [["░" if model([-2.4 + 4.8 * c / (cols - 1), 1.7 - 3.4 * r / (rows - 1)]).data > 0 else "·"
for c in range(cols)] for r in range(rows)]
for (x1, x2), label in zip(X, y):
c = round((x1 + 2.4) / 4.8 * (cols - 1))
r = round((1.7 - x2) / 3.4 * (rows - 1))
if 0 <= r < rows and 0 <= c < cols:
grid[r][c] = "x" if label > 0 else "o"
print("\n".join("".join(row) for row in grid))
show_boundary(model, X, y)
····················o························░░░
··················o·o····o··················░░░░
············o·····o·········o·············░░░░░░
·····o·o··oo················o············░░░░░░░
········································░░░░░░░░
··o···········░░░░░░░········o·········░░x░░░░░░
·o··········░░░░x░░░░░░·······o·······░░░░░░░░░x
····o·····░░░░░x░░░░░░░░············░░░░░░░░░░░░
········░░░░░░░░x░x░░░░░░░···o···o·░░░░░░░░░x░░░
·o·····░░░░░░░░░░░░░░░░░░░░·······░░░░░░░x░░░░░░
·····░░░░░░░░░░░░░░░x░░░░░░░░░··░░░░░░░░░░░░░░░░
···░░░░░░░░░░░░░░░░░░░x░░░░░░░░░░░░░x░░░░░x░░░░░
·░░░░░░░░░░░░░░░░░░░░░░░xx░░░░░░░░░░░x░░░x░░░░░░
░░░░░░░░░░░░░░░░░░░░░░░░░░░x░░░░░x░░x░░░░░░░░░░░
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░x░░░░░░░░░░░░░░░░░░

Граница изогнулась: область «+1» (░) заходит «рогом» под верхнюю дугу. Именно этого не может прямая логистической регрессии. Шесть нейронов tanh⁡\tanh дали шесть «плавных ступенек», и выходной слой сложил из них кривую. Это универсальная аппроксимация из урока 3.1.1 в действии.

Все числа ниже получены на том же датасете и том же зерне, меняется только одна деталь.

1. Забыли zero_grad(). Loss скачет. За первые пять шагов: 1.126 → 0.403 → 0.591 → 1.026 → 1.135 → 1.593, затем он ещё несколько раз уходит выше 1, а с 12-го шага болтается между 0.33 и 0.55. Accuracy через 50 шагов застревает на 0.85. Шаг делается по сумме всех прошлых градиентов, будто с огромной и неуправляемой инерцией. Карпатый ловит этот баг в своём видео прямо по ходу записи. Это самая частая ошибка начинающих и в PyTorch.

2. Все веса — нули. Loss стоит на 0.693=ln⁡20.693 = \ln 2, accuracy 0.5, и ничего не меняется. Почему: tanh⁡(0)=0\tanh(0) = 0, поэтому выход скрытого слоя нулевой, и градиент выходных весов ∝h=0\propto h = 0. Градиент скрытых весов пропорционален выходным весам, а они тоже нули. Остаётся сдвиг выходного нейрона, но классы у нас поровну, 20 на 20, и его градиент при s=0s = 0 тоже нулевой. Всё стоит на месте.

3. Все веса одинаковые (например, 0.5). Обучение идёт, но упирается в 0.875 — уровень линейной модели. После обучения у всех шести скрытых нейронов одинаковые веса (0.456, −1.855)(0.456,\ -1.855). Одинаковые нейроны получают одинаковые градиенты и остаются одинаковыми навсегда. Шесть нейронов работают как один. Поэтому веса инициализируют случайно, чтобы нарушить симметрию. Как выбирать масштаб случайных весов (Xavier, Kaiming), разберём в уроке 3.4.

4. Неудачный learning rate. Accuracy после 50 шагов:

η\eta0.050.552050
accuracy0.8000.8751.0001.0000.875
что видноползётмедленно, пока на уровне линейной моделихорошосначала скачет (loss до 12.7), потом сходитсяloss взлетает до 27 и скачет до конца

Маленький шаг просто не успевает за 50 итераций. Слишком большой перепрыгивает минимум, как в уроке 1.4. При ещё большем шаге (у нас — уже при η=230\eta = 230) exp в loss переполняется: OverflowError: math range error.

5. Обновили p, а не p.data. Код for p in model.parameters(): p = p - lr * p.grad выполняется без ошибок, но loss не меняется. p - ... создаёт новый узел Value и кладёт его в переменную цикла p, а параметр модели остаётся прежним. Тот же эффект даст p -= ...: у Value нет __isub__, и Python превращает это в p = p - ....

Один шаг на 40 точках — граф примерно из двух тысяч узлов-объектов Python. Возьмите сеть побольше, MLP(2, [16, 16, 1]), и 100 точек: граф одного шага вырастет примерно до 65 тысяч узлов. В micrograd такой шаг занимает около секунды, в PyTorch — меньше миллисекунды. На нашей машине разница была в тысячу с лишним раз.

Причина — гранулярность. У нас каждое число — отдельный узел, и каждая операция проходит через интерпретатор Python. PyTorch делает одну операцию над целой матрицей: nn.Linear — это наш Layer, у которого все нейроны и все примеры батча считаются одним умножением матриц на оптимизированном C++/CUDA-коде. Граф — тот же, просто узлов в нём единицы, а не тысячи. Переход к тензорам — тема модуля 3.3.

Этот блок — предпросмотр модуля 3.3: вы ещё не учили PyTorch, но уже можете узнать в нём каждую строку своего кода. Запустите, если PyTorch установлен, или просто сопоставьте строки с комментариями.

import math
import random
import torch
import torch.nn as nn
import torch.nn.functional as F
def make_moons(n=40, noise=0.1, seed=1): # тот же генератор, что в задании
rng = random.Random(seed)
m = n // 2
X, y = [], []
for i in range(m):
t = math.pi * i / (m - 1)
for label, (a, b) in ((-1.0, (math.cos(t), math.sin(t))), (1.0, (1 - math.cos(t), 0.5 - math.sin(t)))):
a += rng.gauss(0, noise)
b += rng.gauss(0, noise)
X.append([(a - 0.5) * 1.5, (b - 0.25) * 2.0])
y.append(label)
return X, y
X, y = make_moons()
X, y = torch.tensor(X), torch.tensor(y) # списки → тензоры (таблицы чисел PyTorch)
torch.manual_seed(0)
model = nn.Sequential(nn.Linear(2, 6), nn.Tanh(), nn.Linear(6, 1)) # тот же MLP(2, [6, 1])
opt = torch.optim.SGD(model.parameters(), lr=5.0)
for step in range(50):
s = model(X).squeeze(1) # (40,) — по одному числу на точку
loss = F.softplus(-y * s).mean() # log(1 + e^(−y·s)), устойчиво
opt.zero_grad() # = model.zero_grad()
loss.backward() # = наш loss.backward(), только над тензорами
opt.step() # = p.data -= lr * p.grad
# знак выхода (> 0 → 1, иначе 0) переводим в ±1 и сравниваем с метками; среднее — доля верных
acc = ((model(X).squeeze(1) > 0).float() * 2 - 1 == y).float().mean()
print(f"loss = {loss.item():.3f}, accuracy = {acc.item():.3f}")
loss = 0.035, accuracy = 1.000

Те же четыре шага, только opt.zero_grad() стоит перед backward(), а F.softplus(-y * s) — устойчивая версия нашего log(1 + exp(-y·s)). Начальные веса другие (PyTorch инициализирует иначе), поэтому числа не совпадут с micrograd до знака. Ради интереса поменяйте значение torch.manual_seed: результат зависит от начальных весов. Например, с torch.manual_seed(1) сеть за 50 шагов доходит только до accuracy 0.900.

Соберите Neuron, Layer, MLP, zero_grad, функцию потерь, accuracy и цикл обучения. Value с операцией log и генератор «лун» уже готовы. Value в задании чуть ускорен: обычные числа не превращаются в отдельные узлы графа. Тесты проверят каждую деталь отдельно, а затем обучат MLP(2, [6, 1]) за 50 шагов и потребуют accuracy ≥ 95%. Обучение в браузере займёт пару секунд.

Обучаем MLP на «лунах»7 тестов
import math
import random


# ── Автоград из прошлого урока (готов, менять не нужно) ─────────────────────
# Это тот же Value, но чуть ускоренный: __slots__ экономит память, _prev — кортеж,
# а обычные числа (входы x, константы) не превращаются в отдельные узлы графа.
class Value:
    __slots__ = ("data", "grad", "_backward", "_prev", "_op")

    def __init__(self, data, _children=(), _op=""):
        self.data = data
        self.grad = 0.0
        self._backward = None
        self._prev = _children
        self._op = _op

    def __repr__(self):
        return f"Value(data={self.data:.4f}, grad={self.grad:.4f})"

    def __add__(self, other):
        if isinstance(other, Value):
            out = Value(self.data + other.data, (self, other), "+")

            def _backward():
                self.grad += out.grad
                other.grad += out.grad
        else:                                   # other — обычное число
            out = Value(self.data + other, (self,), "+")

            def _backward():
                self.grad += out.grad
        out._backward = _backward
        return out

    def __mul__(self, other):
        if isinstance(other, Value):
            out = Value(self.data * other.data, (self, other), "*")

            def _backward():
                self.grad += other.data * out.grad
                other.grad += self.data * out.grad
        else:
            out = Value(self.data * other, (self,), "*")

            def _backward():
                self.grad += other * out.grad
        out._backward = _backward
        return out

    def tanh(self):
        t = math.tanh(self.data)
        out = Value(t, (self,), "tanh")

        def _backward():
            self.grad += (1 - t * t) * out.grad

        out._backward = _backward
        return out

    def exp(self):
        out = Value(math.exp(self.data), (self,), "exp")

        def _backward():
            self.grad += out.data * out.grad

        out._backward = _backward
        return out

    def log(self):
        out = Value(math.log(self.data), (self,), "log")

        def _backward():
            self.grad += out.grad / self.data

        out._backward = _backward
        return out

    def backward(self):
        topo, visited = [], set()

        def build(v):
            if v not in visited:
                visited.add(v)
                for child in v._prev:
                    build(child)
                topo.append(v)

        build(self)
        self.grad = 1.0
        for v in reversed(topo):
            if v._backward is not None:         # у листьев распространять нечего
                v._backward()

    def __neg__(self):
        return self * -1.0

    def __radd__(self, other):
        return self + other

    def __rmul__(self, other):
        return self * other

    def __sub__(self, other):
        return self + (-other)


def make_moons(n=40, noise=0.1, seed=1):
    """Две «луны» по n/2 точек, метки −1 и +1; координаты сдвинуты и растянуты к масштабу ~[−2, 2]."""
    rng = random.Random(seed)
    m = n // 2
    X, y = [], []
    for i in range(m):
        t = math.pi * i / (m - 1)
        for label, (a, b) in ((-1.0, (math.cos(t), math.sin(t))), (1.0, (1 - math.cos(t), 0.5 - math.sin(t)))):
            a += rng.gauss(0, noise)
            b += rng.gauss(0, noise)
            X.append([(a - 0.5) * 1.5, (b - 0.25) * 2.0])
            y.append(label)
    return X, y


# ── Нейросеть поверх Value: ваша часть ──────────────────────────────────────
class Module:
    def zero_grad(self):
        """Обнулить .grad у всех параметров модели."""
        # TODO: ваш код
        pass

    def parameters(self):
        return []


class Neuron(Module):
    def __init__(self, nin, nonlin=True):
        self.w = [Value(random.uniform(-1, 1)) for _ in range(nin)]   # веса — случайные, не нули!
        self.b = Value(0.0)
        self.nonlin = nonlin

    def __call__(self, x):
        """x — список из nin чисел или Value. Вернуть tanh(w·x + b), а при nonlin=False — просто w·x + b."""
        # TODO: ваш код
        ...

    def parameters(self):
        """Список всех параметров нейрона: веса и сдвиг."""
        # TODO: ваш код
        ...


class Layer(Module):
    def __init__(self, nin, nout, nonlin=True):
        """Слой из nout нейронов, у каждого nin входов. Сохраните их в self.neurons."""
        # TODO: ваш код
        ...

    def __call__(self, x):
        """Список выходов всех нейронов слоя."""
        # TODO: ваш код
        ...

    def parameters(self):
        # TODO: параметры всех нейронов одним плоским списком
        ...


class MLP(Module):
    def __init__(self, nin, nouts):
        """MLP(2, [8, 1]): слои 2 → 8 → 1. Все слои с tanh, КРОМЕ последнего (он линейный).
        Слои создавайте по порядку, от входа к выходу. Сохраните их в self.layers."""
        # TODO: ваш код
        ...

    def __call__(self, x):
        """Прогнать x через все слои. Если на выходе один нейрон — вернуть сам Value, а не список."""
        # TODO: ваш код
        ...

    def parameters(self):
        # TODO: ваш код
        ...


def loss_fn(model, X, y):
    """Средняя логистическая потеря по датасету: (1/n) · Σ log(1 + exp(−y_i · s_i)), где s_i = model(X[i]).
    Вернуть Value (по нему будем вызывать backward)."""
    # TODO: ваш код
    ...


def accuracy(model, X, y):
    """Доля примеров, где знак model(x).data совпадает со знаком метки y (±1). Вернуть число от 0 до 1."""
    # TODO: ваш код
    ...


def train(model, X, y, steps=50, lr=5.0):
    """Градиентный спуск по всему датасету. На каждом шаге:
    1) loss = loss_fn(...)  2) model.zero_grad()  3) loss.backward()  4) p.data -= lr * p.grad.
    Вернуть список значений loss (обычные числа: loss.data) по шагам."""
    # TODO: ваш код
    ...


# Проверьте себя кнопкой «Запустить» (обучение займёт пару секунд). Цель — accuracy ≥ 0.95
try:
    random.seed(1)
    X, y = make_moons()
    model = MLP(2, [6, 1])
    print("параметров:", len(model.parameters()))
    history = train(model, X, y, steps=50, lr=5.0)
    print(f"loss: {history[0]:.3f} → {history[-1]:.3f}, accuracy = {accuracy(model, X, y):.3f}")
except Exception as e:  # пока не всё готово
    print("Ещё не работает:", type(e).__name__, e)
Проверь себя6 вопросов
1 Какой порядок шагов в итерации обучения правильный?
2 Сколько параметров у MLP(3, [4, 4, 1])?
3 Метка y=−1y = -1, выход сети s=2s = 2. Чему равна логистическая потеря log⁡(1+e−ys)\log(1 + e^{-ys})? (до двух знаков)
4 Все веса MLP с активацией tanh инициализировали нулями. Что произойдёт при обучении?
5 Какие утверждения верны?Можно выбрать несколько ответов
6 Почему micrograd не используют для больших сетей?
  • Neuron → Layer → MLP поверх Value: tanh⁡(w⊤x+b)\tanh(w^\top x + b), последний слой линейный. MLP(2, [6, 1]) — 25 параметров.
  • Цикл обучения: прямой проход и loss → zero_grad() → backward() → p.data -= lr * p.grad. Ровно так же устроен шаг обучения любой LLM.
  • На «лунах» сеть за 50 шагов доходит до 100% и рисует изогнутую границу, которую не может провести логистическая регрессия (85%).
  • Симптомы ошибок: без zero_grad loss скачет, при нулевых весах loss застывает на ln⁡2\ln 2, при одинаковых весах сеть работает как линейная модель, при неудачном η\eta обучение ползёт или взрывается.
  • micrograd медленный из-за скалярной гранулярности. Тензоры решают это, не меняя идею. А ваш Value ещё вернётся: в уроке 4.6.1 поверх такого же автограда будет обучаться микро-GPT.

Дальше: модуль 3.3 — PyTorch. Тензоры, device и dtype, requires_grad и .grad. Всё, что вы написали руками, окажется на своих местах, только в тысячу раз быстрее.