Обучаем MLP на своём автограде
Собираем нейрон, слой и MLP поверх Value, пишем цикл обучения с zero_grad и учим сеть разделять две «луны»; разбираем типичные ошибки обучения и сравниваем с тем же кодом на PyTorch.
Зачем это нужно
Заголовок раздела «Зачем это нужно»У нас есть всё, чтобы замкнуть круг. 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:
| Наш micrograd | PyTorch | Что делает |
|---|---|---|
Neuron, Layer, MLP | nn.Linear, nn.Sequential, nn.Module | модель |
model.parameters() | model.parameters() | список обучаемых параметров |
model.zero_grad() | optimizer.zero_grad() | обнулить .grad |
loss.backward() | loss.backward() | обратный проход |
p.data -= lr * p.grad | optimizer.step() | шаг оптимизатора |
Нейрон, слой, MLP поверх Value
Заголовок раздела «Нейрон, слой, MLP поверх Value»Нейрон из урока 3.1.1: . Слой — список нейронов, 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)складывает произведения и начинает сумму со сдвига: второй аргументsum— стартовое значение. Без негоsumначнёт с целого0, и это тоже сработает благодаря__radd__из урока 3.2.1. Просто в графе появится лишний узел.zip(self.w, x)идёт по весам и входам парами: (урок 0.2.2).__call__позволяет писатьmodel(x), как в PyTorch (урок 0.2.3).- Последний слой линейный. Его выход — логит, «уверенность» в классе . Вероятность и потерю добавит функция потерь, так же как в уроке 3.1.1.
MLP(2, [6, 1]) — это слои 2 → 6 → 1. Параметров .
Данные: две «луны»
Заголовок раздела «Данные: две «луны»»Классический игрушечный датасет make_moons. Две дуги вложены друг в друга, и прямой их не разделить. Сгенерируем
его сами, без scikit-learn: по 20 точек на дугу, немного гауссова шума (случайных добавок из нормального
распределения, урок 0.3.3), метки и . Точки дуги — это
при угле от 0 до : так в школе рисуют половину окружности. Вторая дуга перевёрнута и
сдвинута. Код можно не разбирать построчно — важен результат: список точек X и список меток y.
import mathimport 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.
Функция потерь
Заголовок раздела «Функция потерь»Для меток удобно записать бинарную cross-entropy так:
Словами: для каждой точки берём выход сети , умножаем на правильную метку (то есть ), считаем и усредняем по всем точкам. Логарифм здесь натуральный, как во всём курсе.
Это ровно BCE из урока 2.2 с . Проверим при : сигмоида , значит, — та же формула (логарифм дроби равен , урок 1.0.2). При аналогично получается . Число: , , тогда — модель уверенно права, потеря маленькая.
Произведение называют отступом (margin). Положительный отступ значит «верно» (знак выхода совпал со знаком метки), и чем он больше, тем увереннее:
| 0 | 2 | 5 | ||
|---|---|---|---|---|
| loss | 2.127 | 0.693 | 0.127 | 0.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 нужна ещё одна операция с локальной производной . В задании она уже добавлена.
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
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.0for 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.gradprint(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Разберём каждый шаг.
- Прямой проход. Мы прогоняем все 40 точек и строим один граф, от 25 параметров до одного числа
loss. С ускореннымValueиз задания (в нём обычные числа не становятся узлами) в графе 1907 узлов, сValueиз урока 3.2.2 — 2468. Это полный батч (full batch): датасет крошечный. LLM учат на мини-батчах — случайных порциях данных, но схема та же. zero_grad(). Градиенты копятся через+=(урок 3.2.2). Без обнуления.gradхранил бы сумму градиентов всех прошлых шагов. Обнулять нужно только параметры: промежуточные узлы создаются заново при каждом прямом проходе и рождаются сgrad = 0.backward()заполняет.gradу всех узлов графа, но нам нужны только 25 из них — параметры.- Шаг. Двигаем каждый параметр против градиента. Обновляем именно
p.data, то есть число внутри узла: сам шаг не должен попадать в граф. В PyTorch за это отвечаетtorch.no_grad()внутриoptimizer.step().
Начальная точность 0.225 хуже монетки: случайные веса по чистой случайности «смотрят не туда». Уже к 20-му шагу сеть проходит порог 95%, к 30-му — 100%. Шаг кажется огромным по меркам урока 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» (░) заходит «рогом» под верхнюю дугу. Именно этого не может прямая логистической регрессии. Шесть нейронов дали шесть «плавных ступенек», и выходной слой сложил из них кривую. Это универсальная аппроксимация из урока 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 стоит на , accuracy 0.5, и ничего не меняется. Почему: , поэтому выход скрытого слоя нулевой, и градиент выходных весов . Градиент скрытых весов пропорционален выходным весам, а они тоже нули. Остаётся сдвиг выходного нейрона, но классы у нас поровну, 20 на 20, и его градиент при тоже нулевой. Всё стоит на месте.
3. Все веса одинаковые (например, 0.5). Обучение идёт, но упирается в 0.875 — уровень линейной модели. После обучения у всех шести скрытых нейронов одинаковые веса . Одинаковые нейроны получают одинаковые градиенты и остаются одинаковыми навсегда. Шесть нейронов работают как один. Поэтому веса инициализируют случайно, чтобы нарушить симметрию. Как выбирать масштаб случайных весов (Xavier, Kaiming), разберём в уроке 3.4.
4. Неудачный learning rate. Accuracy после 50 шагов:
| 0.05 | 0.5 | 5 | 20 | 50 | |
|---|---|---|---|---|---|
| accuracy | 0.800 | 0.875 | 1.000 | 1.000 | 0.875 |
| что видно | ползёт | медленно, пока на уровне линейной модели | хорошо | сначала скачет (loss до 12.7), потом сходится | loss взлетает до 27 и скачет до конца |
Маленький шаг просто не успевает за 50 итераций. Слишком большой перепрыгивает минимум, как в уроке 1.4. При ещё
большем шаге (у нас — уже при ) 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 - ....
Почему micrograd медленный
Заголовок раздела «Почему micrograd медленный»Один шаг на 40 точках — граф примерно из двух тысяч узлов-объектов Python. Возьмите сеть побольше, MLP(2, [16, 16, 1]), и 100
точек: граф одного шага вырастет примерно до 65 тысяч узлов. В micrograd такой шаг занимает около секунды, в PyTorch —
меньше миллисекунды. На нашей машине разница была в тысячу с лишним раз.
Причина — гранулярность. У нас каждое число — отдельный узел, и каждая операция проходит через интерпретатор
Python. PyTorch делает одну операцию над целой матрицей: nn.Linear — это наш Layer, у которого все нейроны и все
примеры батча считаются одним умножением матриц на оптимизированном C++/CUDA-коде. Граф — тот же, просто узлов в нём
единицы, а не тысячи. Переход к тензорам — тема модуля 3.3.
То же на PyTorch (локально)
Заголовок раздела «То же на PyTorch (локально)»Этот блок — предпросмотр модуля 3.3: вы ещё не учили PyTorch, но уже можете узнать в нём каждую строку своего кода. Запустите, если PyTorch установлен, или просто сопоставьте строки с комментариями.
import mathimport randomimport torchimport torch.nn as nnimport 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%.
Обучение в браузере займёт пару секунд.
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)
Проверь себя
Заголовок раздела «Проверь себя»Neuron→Layer→MLPповерхValue: , последний слой линейный.MLP(2, [6, 1])— 25 параметров.- Цикл обучения: прямой проход и loss →
zero_grad()→backward()→p.data -= lr * p.grad. Ровно так же устроен шаг обучения любой LLM. - На «лунах» сеть за 50 шагов доходит до 100% и рисует изогнутую границу, которую не может провести логистическая регрессия (85%).
- Симптомы ошибок: без
zero_gradloss скачет, при нулевых весах loss застывает на , при одинаковых весах сеть работает как линейная модель, при неудачном обучение ползёт или взрывается. - micrograd медленный из-за скалярной гранулярности. Тензоры решают это, не меняя идею. А ваш
Valueещё вернётся: в уроке 4.6.1 поверх такого же автограда будет обучаться микро-GPT.
Дальше: модуль 3.3 — PyTorch. Тензоры, device и dtype, requires_grad и .grad. Всё, что вы написали руками,
окажется на своих местах, только в тысячу раз быстрее.