От MNIST к Transformer. Часть 4: Gradient Descent. Обучаем нашу модель

От MNIST к Transformer. Часть 4: Gradient Descent. Обучаем нашу модель

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

Это четвёртая статья из цикла «От MNIST к Transformer», цель которого — пошагово пройти путь от простого CUDA-ядра до создания архитектуры Transformer, лежащей в основе современных LLM. Мы не будем использовать готовые высокоуровневые библиотеки. Вместо этого разберём, как всё устроено «под капотом», и пересоберём ключевые механизмы своими руками на низком уровне. Только так можно по-настоящему понять, как работают LLM. В этой статье мы разберём, как работает градиентный спуск, реализуем его и обучим модель на датасете MNIST.

Приготовьтесь: впереди много кода на C++ и CUDA, работа с памятью, погружение в архитектуру GPU и, конечно, математика. Поехали!

О цикле «От MNIST к Transformer»

Это долгий, сложный, но невероятно увлекательный путь. Мы начнём с основ и постепенно будем усложнять задачи. Наша цель — не только понять, как работают современные модели, но и научиться программировать на GPU. Статьи цикла будут выходить постепенно.

  1. Самое начало. Разберёмся с основами работы GPU. Напишем первый код на C++ и CUDA для сложения векторов — «hello world» в мире GPGPU.
  2. Основы работы с памятью. Изучим устройство памяти GPU. Реализуем ядра для построения гистограммы и транспонирования матрицы — последнее пригодится при создании Transformer.
  3. Умножение тензоров. Пишем Linear Layer. Перейдём к тензорам, реализуем умножение и tiled-версию с broadcasting. Создадим полносвязный слой и напишем прямой проход для модели распознавания MNIST.
  4. Gradient Descent. Обучаем нашу модель. Наконец-то мы можем обучить модель. В этой статье разберём градиентный спуск — один из ключевых алгоритмов обучения нейросетей. Реализуем его и обучим первую модель распознавания рукописных цифр.

Gradient Descent и Backpropagation

В прошлой статье мы реализовали прямой проход (forward pass): прогнали изображение через слои и получили ответ. На необученной модели этот ответ, скорее всего, случайный. Чтобы модель начала правильно угадывать цифры, нужно изменять веса так, чтобы ошибка (loss) уменьшалась. Для этого используются алгоритмы Gradient Descent (градиентный спуск) и Backpropagation (обратное распространение ошибки).

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

Backpropagation — это способ передачи ошибки от выхода сети к входу. Если на выходе модель ошиблась, нужно понять, какой вклад в эту ошибку внес каждый вес. Математически это реализуется через правило цепочки (Chain Rule): мы вычисляем локальную производную в каждом слое и умножаем её на накопленный градиент от последующих слоёв.

Функция ошибки

Для задач классификации, таких как MNIST, стандартной функцией ошибки является кросс-энтропия (Cross-Entropy Loss). Она измеряет, насколько плохо модель предсказывает правильный класс. Например, если на изображении цифра 3, а модель выдаёт 70% вероятности для неё — это хороший результат. Если же уверенность мала — ошибка будет высокой.

Пусть:

  • C — количество классов (в MNIST: 10),
  • y_i — истинная метка (1 для правильного класса, 0 иначе),
  • p_i — предсказанная вероятность (после Softmax).

Тогда кросс-энтропия для одного примера:

Для батча результат усредняется по размеру батча.

Chain Rule: как считать градиенты

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

Архитектура нашей сети:

  • Первый линейный слой: z1 = x @ W1 + b1
  • ReLU: a1 = ReLU(z1)
  • Второй линейный слой: z2 = a1 @ W2 + b2
  • Softmax + Loss

Производная ошибки по весам второго слоя вычисляется как:

Рассмотрим сначала градиент по выходу второго слоя z2. Для этого вспомним формулу Softmax:

  • z — вектор логитов (вход Softmax),
  • p — вектор вероятностей (выход Softmax).

Производная Softmax зависит от того, совпадают ли индексы:

Для i = j: ∂p_i / ∂z_j = p_i (1 - p_i)
Для i ≠ j: ∂p_i / ∂z_j = -p_i p_j

Это можно записать компактно с помощью символа Кронекера δij:

∂p_i / ∂z_j = p_i (δij - p_j)

С учётом кросс-энтропии, градиент ошибки по z2 оказывается очень простым:

∂L / ∂z2 = p - y

Это ключевой результат: градиент на выходе равен разнице между предсказанием и истинной меткой.

Теперь пройдём назад через второй линейный слой. Градиент по весам W2 и смещению b2:

∂L / ∂W2 = a1T @ (∂L / ∂z2)
∂L / ∂b2 = sum(∂L / ∂z2, axis=0)

Далее — градиент по активации a1, который передаётся через ReLU. Производная ReLU равна 1 при положительных значениях и 0 при отрицательных. То есть:

∂L / ∂z1 = (∂L / ∂a1) * (z1 > 0)

И, наконец, градиенты по W1 и b1:

∂L / ∂W1 = xT @ (∂L / ∂z1)
∂L / ∂b1 = sum(∂L / ∂z1, axis=0)

Таким образом, мы вычисляем все градиенты, двигаясь от выхода к входу.

Обновление весов: Gradient Descent

После вычисления градиентов наступает финальный шаг — обновление весов. Мы делаем шаг в сторону, противоположную градиенту:

W = W - lr * ∂L / ∂W

где lrкоэффициент скорости обучения (learning rate).

Этот шаг — суть градиентного спуска. В коде он выглядит так:

Для одного слоя обновление включает вычисление градиентов по весам и смещениям, а затем их корректировку.

Обучение сети

Полный цикл обучения:

  1. Прямой проход: вычислить выход модели и loss.
  2. Обратный проход: вычислить градиенты через backpropagation.
  3. Обновить веса с помощью градиентного спуска.

Мы обучаем модель на батчах по 60 изображений в течение 10 эпох. Начальное значение loss — около 2.3, что соответствует случайному угадыванию (вероятность 10%). По мере обучения loss снижается до 0.3–0.4.

  • Loss ~ 2.3: случайные предсказания (точность ~10%).
  • Loss ~ 1.0: модель начинает понимать данные (точность 50–60%).
  • Loss ~ 0.3: хороший результат (точность 90% и выше).

На тестовой выборке мы достигаем точности около 93% — значит, модель научилась распознавать рукописные цифры.

Adam Optimizer

Обычный градиентный спуск — основа, но не предел. В современных моделях чаще используют Adam Optimizer (Adaptive Moment Estimation) — улучшенную версию, которая адаптирует скорость обучения для каждого веса отдельно и учитывает инерцию.

Adam использует два вспомогательных тензора для каждого веса:

  • m — первый момент (скользящее среднее градиента),
  • v — второй момент (скользящее среднее квадрата градиента).

Они обновляются на каждом шаге:

m = β₁·m + (1 - β₁)·g
v = β₂·v + (1 - β₂)·g²

где g — текущий градиент, β₁ ≈ 0.9, β₂ ≈ 0.999.

Веса обновляются так:

W = W - lr · m / (√v + ε)

где ε — малое число (например, 1e-8), чтобы избежать деления на ноль.

Хотя мы не реализуем Adam в этой статье, важно понимать, что именно он используется в современных LLM вместо простого SGD.

Заключение

Мы разобрали один из ключевых механизмов обучения нейросетей — градиентный спуск и обратное распространение ошибки. Полноценно обучили двухслойную полносвязную сеть на MNIST и достигли точности 93%. Самое главное — заложили фундамент для построения Transformer.

В следующих статьях мы начнём собирать Transformer по блокам — от внимания до полной архитектуры, лежащей в основе всех современных LLM.

Надеюсь, было понятно и, главное, интересно. Продолжение следует…

Читать оригинал