Математика для нейросетей: Матрицы и градиенты

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

От векторов к тензорам: многомерные структуры данных в AI

От векторов к тензорам: многомерные структуры данных в AI

В классической бэкенд-разработке на Go мы редко выходим за пределы двумерных структур. Срез байтов []byte (1D) для чтения из сети, срез срезов [][]float64 (2D) для таблицы базы данных. Но в исходных кодах современных нейросетей вы постоянно будете встречать 4D, 5D и даже 6D-массивы.

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

В этой статье мы разберем универсальный язык общения всех AI-моделей — тензоры.

Лестница размерностей

В предыдущих курсах, изучая NumPy и Pandas, мы уже касались понятия векторизации. Теперь давайте посмотрим на размерности (оси) строго математически, как на эволюцию сложности данных.

  1. Скаляр (0D-тензор). Одно-единственное число. Температура за окном, возраст пользователя, вероятность клика. В математике обозначается просто xx. У него нет осей, его размерность (shape) пуста: ().
  2. Вектор (1D-тензор). Одномерный массив чисел. Вектор описывает один объект через набор признаков (фичей). Например, вектор квартиры: [площадь, этаж, расстояние_до_метро]. В линейной алгебре вектор из nn элементов обозначается как xRnx \in \mathbb{R}^n.
  3. Матрица (2D-тензор). Двумерная таблица. Для бэкендера это [][]float64 или DataFrame. Матрица — это либо набор векторов (например, батч из 100 квартир, где у каждой 3 признака — shape (100, 3)), либо одно черно-белое изображение, где оси — это высота и ширина в пикселях.

До этого момента всё привычно. Матрица отлично описывает табличные данные. Но что, если наш объект сложнее строки в базе данных?

Переход в 3D: Анатомия цвета

Представьте цветную фотографию размером 256×256 пикселей. Одной матрицы 256×256 хватит только для передачи яркости (черно-белое фото). Цветное изображение на экранах формируется из трех базовых цветов: красного (Red), зеленого (Green) и синего (Blue).

Чтобы передать цвет, нам нужны три матрицы 256×256, сложенные стопкой. Это и есть 3D-тензор.

Его размерность (shape) записывается как (256, 256, 3).

  • Первая ось: высота (HH).
  • Вторая ось: ширина (WW).
  • Третья ось: каналы (CC).

Тензор — это просто многомерный массив чисел. Скаляр, вектор и матрица — это частные случаи тензора (0D, 1D и 2D соответственно). Начиная с 3D, математики перестают придумывать новые имена и используют слово «тензор» с указанием количества измерений (осей).

4D-тензоры: Эффективность батчей

В курсе по экосистеме Python мы выяснили, что передавать данные в вычисления по одной штуке (в цикле) — катастрофически медленно из-за накладных расходов интерпретатора и неэффективного использования кэша процессора. Индустрия использует батчи (batches) — пакетную обработку.

Если одна цветная картинка — это 3D-тензор (256, 256, 3), то как передать в видеокарту сразу 32 картинки?

Мы добавляем еще одну ось. Получается 4D-тензор с размерностью (32, 256, 256, 3). В AI-фреймворках (TensorFlow, PyTorch) оси имеют строгий семантический смысл:

  1. Batch (NN) — индекс конкретного изображения в пакете (от 0 до 31).
  2. Height (HH) — координата Y пикселя.
  3. Width (WW) — координата X пикселя.
  4. Channels (CC) — цвет (0=R, 1=G, 2=B).

Примечание: PyTorch по умолчанию использует порядок (N, C, H, W), ставя каналы перед пространственными осями, но суть остается неизменной.

Ментальная модель Senior-разработчика

В Go мы привыкли к строгим интерфейсам и структурам:

type UserRequest struct {
    ID      int
    Payload []byte
}

В машинном обучении на Python строгих структур нет (Duck Typing). Вместо них контрактом выступает shape тензора.

Когда вы читаете документацию к слою нейросети (например, сверточному слою Conv2D), там всегда указано: «Ожидает на вход тензор размерности (N, H, W, C), возвращает тензор размерности (N, H/2, W/2, C2)»*.

Тензор — это универсальный API. Вам не нужно писать классы Image, AudioFeature или TextEmbedding. Вы берете реальный физический объект, кодируете его геометрию в оси тензора и передаете в математический конвейер. Обучение нейросети — это, по сути, последовательность матричных и тензорных умножений, которые «сжимают» или «растягивают» эти оси, пока на выходе не получится тензор с нужным ответом (например, 1D-вектор вероятностей классов).

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

Линейные трансформации: матричное умножение как основа слоев нейросети

Линейные трансформации: матричное умножение как основа слоев нейросети

В классическом бэкенде на Go, когда вам нужно преобразовать структуру UserRequest в UserResponse, вы пишете функцию, которая перекладывает поля, применяет бизнес-логику и возвращает новый объект. В нейросетях роль такой «функции-трансформатора» играет матрица, а сам процесс преобразования данных — это матричное умножение.

В прошлой главе мы разобрались, что данные в AI живут в виде тензоров, а их shape — это строгий API-контракт. Теперь мы посмотрим на главный механизм обработки этих данных. Любой базовый слой нейросети (Dense или Linear layer) под капотом выполняет одну математическую операцию: он берет входной тензор и умножает его на матрицу весов.

Скалярное произведение: атомарная операция

Прежде чем умножать матрицы, посмотрим на базовый кирпичик — умножение двух 1D-векторов. Эта операция называется скалярным произведением (dot product).

Представьте, что вы оцениваете стоимость дома. У вас есть входной вектор признаков xx, описывающий конкретный дом: количество комнат и площадь. И есть вектор весов ww, который описывает, насколько важен каждый признак (цена за комнату и цена за квадратный метр).

Скалярное произведение попарно умножает элементы векторов и складывает их в одно число (скаляр):

y=x1w1+x2w2y = x_1 w_1 + x_2 w_2

Где:

  • xx — вектор данных (например, [3, 100] — 3 комнаты, 100 кв.м).
  • ww — вектор весов (например, [10000, 2000]).
  • yy — итоговая оценка: 3×10000+100×2000=2300003 \times 10000 + 100 \times 2000 = 230000 USD.

Вектор весов ww — это «знания» модели. Изменяя значения в ww, мы меняем логику оценки, не переписывая сам алгоритм (код).

От вектора к матрице: множественные предсказания

Один вектор весов выдает одно число. Но что, если мы хотим по тем же признакам дома предсказать сразу две метрики: цену продажи и стоимость аренды?

Нам понадобятся два разных вектора весов. Если мы объединим их, мы получим 2D-тензор — матрицу WW. Теперь умножение вектора признаков xx на матрицу весов WW выдаст нам не одно число, а новый вектор yy, содержащий оба предсказания.

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

В контексте нейросетей матрица весов WW — это набор связей между нейронами предыдущего слоя и следующего. Если на входе 2 признака, а на выходе мы хотим получить 3 скрытых фичи, размер матрицы будет 2×32 \times 3.

Контракты размерностей: строгая типизация матриц

Матричное умножение не работает произвольно. Для Senior-разработчика проще всего думать об этом как о проверке типов при компиляции. Чтобы умножить матрицу AA на матрицу BB, их размерности (shapes) должны совпадать по строгому правилу:

Внутренние размерности должны быть равны, а внешние образуют размерность результата: (N×M)(N \times M) умножить на (M×K)(M \times K) равно (N×K)(N \times K).

Разберем на примере:

  1. Входной вектор xx имеет размерность (1×2)(1 \times 2) (один дом, два признака).
  2. Матрица весов WW имеет размерность (2×3)(2 \times 3) (два признака превращаем в три новых).
  3. Внутренняя размерность совпадает: 2=22 = 2. Операция валидна.
  4. Результат yy получит размерность (1×3)(1 \times 3). Мы успешно трансформировали 2 признака в 3.

Если вы попытаетесь умножить (1×2)(1 \times 2) на (4×3)(4 \times 3), фреймворк (PyTorch или NumPy) выбросит ошибку, аналогичную type mismatch в Go.

Уравнение линейного слоя: добавляем смещение

Матричного умножения самого по себе недостаточно. Представьте, что дом имеет 0 комнат и 0 площадь (пустой участок). Скалярное произведение 0×w1+0×w20 \times w_1 + 0 \times w_2 всегда даст 0. Но участок земли тоже стоит денег!

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

В итоге мы получаем главную формулу линейного слоя нейросети:

y=xW+by = xW + b

Где:

  • xx — входные данные.
  • WW — матрица весов (трансформирует данные).
  • bb — вектор смещения (сдвигает результат).
  • yy — выходные данные слоя.

Примечание: При сложении xWxW и bb используется механизм Broadcasting, который мы разбирали в курсе по Python. Вектор bb автоматически «растягивается» (дублируется) на все элементы батча.

Батчи: умножение в промышленных масштабах

В реальном AI мы никогда не пропускаем через уравнение y=xW+by = xW + b по одному объекту. Мы используем батчи (пакеты данных).

Если у нас есть батч из 32 домов, наш входной тензор xx будет иметь размерность (32×2)(32 \times 2). Матрица весов WW для слоя остается (2×3)(2 \times 3). Считаем контракты: (32×2)(32 \times 2) умножить на (2×3)(2 \times 3) дает на выходе тензор (32×3)(32 \times 3).

Математика и логика не изменились вообще, но теперь мы за одну C-оптимизированную операцию в памяти обработали сразу 32 объекта. Это и есть та самая векторизация, ради которой мы используем тензоры.

Мы разобрались, как данные протекают через слой нейросети — это просто умножение на матрицу весов. Но откуда берутся правильные числа в матрице WW и векторе bb? Как нейросеть «понимает», какие веса приведут к правильному ответу? Для этого нам понадобится математический аппарат изменения значений — производные и градиенты, к которым мы перейдем в следующей главе.

Производная и градиент: математика изменения весов

Производная и градиент: математика изменения весов

Архитектура линейного слоя y=xW+by = xW + b определяет лишь форму трансформации данных. Если мы инициализируем матрицу весов WW случайными числами и пропустим через неё батч данных, на выходе получится информационный мусор. Слой не знает, какую задачу решает. Чтобы система начала «учиться», нам нужен механизм обратной связи: способ измерить, насколько текущий результат далёк от желаемого, и математически точный компас, указывающий, как именно нужно изменить каждый элемент матрицы WW, чтобы ошибка стала меньше.

Функция потерь: метрика неправоты

Прежде чем что-то оптимизировать, нужно научиться это измерять. В машинном обучении эту роль выполняет функция потерь (Loss function). Она принимает предсказание модели и реальный правильный ответ, а возвращает одно число (скаляр) — оценку того, насколько сильно ошиблась сеть.

Самый базовый пример для задач регрессии — квадратичная ошибка (Squared Error), которая в масштабе всего датасета усредняется до MSE (Mean Squared Error). Для одного объекта она выглядит так:

L=(ypredytrue)2L = (y_{pred} - y_{true})^2

Здесь LL — значение ошибки (Loss), ypredy_{pred} — предсказание модели (например, цена квартиры 10 млн), а ytruey_{true} — реальное значение из обучающей выборки (12 млн). Ошибка для этого объекта составит (1012)2=4(10 - 12)^2 = 4.

Возведение в квадрат решает две задачи: убирает отрицательные значения (ошибка в минус 5 так же плоха, как ошибка в плюс 5) и сильнее «штрафует» модель за крупные промахи. Наша глобальная цель — подобрать такие веса WW, при которых значение LL будет стремиться к нулю для всех данных в батче.

Производная как коэффициент чувствительности

Представьте, что матрица весов состоит всего из одного числа ww. Функция потерь LL становится зависимой от этого единственного параметра. Если мы немного изменим ww, как отреагирует LL?

В программировании мы бы написали цикл, который перебирает значения ww с мелким шагом, вычисляет LL и ищет минимум. В многомерных пространствах нейросетей (где миллиарды параметров) такой брутфорс займёт вечность. Нам нужен аналитический способ мгновенно узнать, куда двигать вес.

Здесь на сцену выходит производная dLdw\frac{dL}{dw}.

Для инженера производная — это не абстрактная касательная из школьного курса геометрии. Это коэффициент чувствительности. Она буквально отвечает на вопрос: «Если я увеличу вес ww на 1 единицу, на сколько единиц изменится ошибка LL?».

Значение dLdw\frac{dL}{dw} Влияние на ошибку LL Действие с весом ww
Положительное (напр., 5) Рост веса увеличивает ошибку Уменьшать вес
Отрицательное (напр., -2) Рост веса уменьшает ошибку Увеличивать вес
Нуль (0) Ошибка не меняется Оставить как есть (локальный минимум)

Частные производные: профилирование параметров

В реальности вес ww никогда не бывает один. У нас есть матрица WW размерностью, например, 256×128256 \times 128. Как понять, кто именно виноват в высокой ошибке LL?

Мы применяем подход, похожий на A/B-тестирование или профилирование конкретного эндпоинта. Мы «замораживаем» все элементы матрицы WW, кроме одного конкретного веса wijw_{ij}, и смотрим, как изменение только этого веса влияет на общую ошибку.

Это называется частной производной и обозначается символом \partial:

Lwij\frac{\partial L}{\partial w_{ij}}

Здесь L\partial L — микроскопическое изменение общей ошибки, а wij\partial w_{ij} — микроскопическое изменение конкретного веса в ii-й строке и jj-м столбце. Например, если этот вес отвечает за влияние площади на цену квартиры, мы смотрим, как его крошечный сдвиг изменит итоговую ошибку, при условии, что все остальные 32 тысячи весов остаются неизменными.

Мы вычисляем такую частную производную для каждого элемента матрицы WW и для каждого элемента вектора смещения bb.

Градиент: тензор направлений

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

Градиент матрицы весов W\nabla W (читается как «набла дубль-вэ») — это матрица точно такой же размерности, где на месте каждого веса стоит его частная производная. Если WW — это матрица 256×128256 \times 128, то и W\nabla W — это матрица 256×128256 \times 128. Это строгий контракт: каждому параметру соответствует ровно одно значение чувствительности.

Физический смысл градиента: вектор градиента всегда указывает в направлении наискорейшего возрастания функции.

Если вы стоите на склоне горы (где высота — это ошибка LL), градиент покажет, в какую сторону нужно сделать шаг, чтобы подняться наверх максимально быстро.

Шаг оптимизации: против течения

Поскольку наша задача — минимизировать ошибку (спуститься с горы), нам нужно двигаться в направлении, противоположном градиенту. Это называется антиградиентом.

Математически обновление весов записывается так:

Wnew=WαWW_{new} = W - \alpha \nabla W

Здесь WnewW_{new} — обновленная матрица весов, WW — текущая матрица, α\alpha (альфа) — скорость обучения (Learning Rate), а W\nabla W — вычисленная матрица градиентов.

Например, если текущий вес равен 0.5, его частная производная равна 2 (ошибка растет при увеличении веса), а α=0.1\alpha = 0.1, то новый вес станет 0.50.1×2=0.30.5 - 0.1 \times 2 = 0.3. Вес уменьшился, как мы и хотели.

Градиент W\nabla W говорит нам, в какую сторону и насколько круто меняется функция, но он не говорит, какой длины шаг нужно сделать. Если вычесть градиент как есть, шаг может оказаться слишком большим: мы перепрыгнем минимум и окажемся на другом склоне горы, где ошибка еще выше. Коэффициент α\alpha (обычно это маленькое число вроде 0.001) выступает в роли масштабирующего фактора. Он ограничивает размер шага, гарантируя плавный спуск.

Именно эта формула Wnew=WαWW_{new} = W - \alpha \nabla W выполняется в цикле обучения тысячи раз. Нейросеть делает предсказание, вычисляет ошибку, находит градиент (матрицу чувствительностей) и делает крошечный шаг против него, обновляя свои веса.

Остается один архитектурный вопрос: как эффективно вычислить частные производные для глубокой сети, где линейные слои идут один за другим, и ошибка должна «протечь» через них все? Этот механизм требует отдельного математического аппарата.

Chain Rule: как ошибка течет сквозь слои (Backpropagation)

Chain Rule: как ошибка течет сквозь слои (Backpropagation)

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

Нейросеть — это матрешка вложенных функций. Выход первого слоя становится входом второго, выход второго — входом третьего, и так до самого конца. Математически двухслойную сеть можно записать как L(W2,A(W1,X))L(W_2, A(W_1, X)), где LL — функция потерь, а AA — первый слой.

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

Цепное правило (Chain Rule)

Цепное правило дифференцирования — это фундаментальный закон математики, который описывает, как найти производную сложной (вложенной) функции.

Принцип предельно логичен: если переменная yy зависит от uu, а uu зависит от xx, то скорость изменения yy относительно xx равна произведению их скоростей изменения.

Формула цепного правила выглядит так:

yx=yu×ux\frac{\partial y}{\partial x} = \frac{\partial y}{\partial u} \times \frac{\partial u}{\partial x}

Где yx\frac{\partial y}{\partial x} — итоговая скорость изменения результата от начального входа, yu\frac{\partial y}{\partial u} — зависимость результата от промежуточного этапа, а ux\frac{\partial u}{\partial x} — зависимость промежуточного этапа от начального входа.

Разберем на физическом примере. Представьте заводской конвейер:

  1. Скорость вращения шестеренки двигателя (xx) определяет скорость движения ленты конвейера (uu). Допустим, 1 оборот шестеренки сдвигает ленту на 2 метра. То есть ux=2\frac{\partial u}{\partial x} = 2.
  2. Скорость движения ленты (uu) определяет количество выпускаемых игрушек (yy). Допустим, каждые 1 метр ленты выдают 3 готовые игрушки. То есть yu=3\frac{\partial y}{\partial u} = 3.

Как изменение скорости шестеренки повлияет на выпуск игрушек? Мы просто перемножаем эти локальные зависимости: 2×3=62 \times 3 = 6. Один дополнительный оборот шестеренки даст 6 дополнительных игрушек.

В нейросети происходит то же самое. Ошибка зависит от выхода последнего слоя, выход последнего слоя зависит от его входа (который является выходом предыдущего слоя), и так далее до самых первых весов.

Граф вычислений и два прохода

Для Senior-разработчика нейросеть проще всего представить не как набор формул, а как направленный ациклический граф (DAG) — архитектуру, с которой мы уже сталкивались при построении конвейеров данных. Этот граф называется графом вычислений (Computational Graph).

Каждый узел в этом графе — это математическая операция (матричное умножение, сложение, вычисление ошибки). Ребра графа — это тензоры, которые перетекают от одной операции к другой.

Обучение модели состоит из двух строгих фаз:

  1. Прямой проход (Forward Pass). Данные идут от входа к выходу. Каждый узел берет входящие тензоры, применяет свою операцию и передает результат дальше. Критически важный момент: во время прямого прохода узлы кэшируют (сохраняют в память) свои входные данные. Этот кэш понадобится позже.
  2. Обратный проход (Backward Pass). Это и есть алгоритм Backpropagation (обратное распространение ошибки). Сигнал идет в обратную сторону — от функции потерь к входам. Каждый узел получает градиент от следующего за ним узла, умножает его на свой локальный градиент (используя закэшированные данные) и передает результат дальше назад.

Анатомия узла: входящий градиент и локальная производная

Посмотрим на один изолированный узел графа — например, линейный слой, выполняющий операцию Z=XWZ = XW.

Во время обратного прохода этот узел получает «сверху» (от узлов, расположенных ближе к концу сети) тензор. Назовем его входящим градиентом. Он показывает, как сильно финальная ошибка LL реагирует на изменение выхода этого узла (ZZ). Обозначим его LZ\frac{\partial L}{\partial Z}.

У самого узла есть две задачи:

  1. Вычислить градиент для своих весов WW, чтобы их можно было обновить.
  2. Вычислить градиент для своего входа XX, чтобы передать его дальше назад, предыдущему слою.

Здесь вступает в игру цепное правило. Чтобы найти градиент по весам (LW\frac{\partial L}{\partial W}), узел берет входящий градиент и умножает его на свою локальную производную выхода по весам (ZW\frac{\partial Z}{\partial W}):

LW=Входящий градиент×Локальная производная\frac{\partial L}{\partial W} = \text{Входящий градиент} \times \text{Локальная производная}

Где LW\frac{\partial L}{\partial W} — итоговый градиент для обновления весов, Входящий градиент (LZ\frac{\partial L}{\partial Z}) — сигнал об ошибке от следующих слоев, а Локальная производная (ZW\frac{\partial Z}{\partial W}) показывает, как выход текущего узла реагирует на изменение его весов.

Чему равна локальная производная для операции Z=XWZ = XW? Если мы вспомним школьную математику, производная функции y=cxy = cx по переменной xx равна константе cc. В матричном мире логика сохраняется: локальная производная XWXW по WW — это просто входные данные XX. А локальная производная XWXW по XX — это веса WW.

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

  • Градиент для обновления весов: W=XT×Входящий градиент\nabla W = X^T \times \text{Входящий градиент}
  • Градиент для передачи назад: X=Входящий градиент×WT\nabla X = \text{Входящий градиент} \times W^T

Примечание: Операция транспонирования (T^T) здесь нужна исключительно для согласования размерностей матриц (контрактов shape), чтобы результат умножения имел ту же форму, что и исходный тензор.

Итог: элегантность Backpropagation

Алгоритм обратного распространения ошибки гениален своей модульностью. Ни один слой в нейросети не знает о существовании других слоев.

Линейному слою не нужно понимать, что происходит в функции потерь или в слоях до него. Его контракт предельно прост:

  • На прямом проходе: получи XX, умножь на WW, отдай ZZ, сохрани XX в кэш.
  • На обратном проходе: получи входящий градиент, умножь его на кэш XX (получишь градиент для своих весов), умножь входящий градиент на WW (получишь градиент для предыдущего слоя) и отдай его назад.

Именно благодаря этой изоляции состояний и строгим контрактам размерностей, современные фреймворки (PyTorch, TensorFlow) могут автоматически вычислять градиенты для архитектур любой сложности, состоящих из миллиардов параметров. Разработчику достаточно описать прямой проход — а граф вычислений и цепное правило сделают всю работу по сборке градиентов автоматически.

Градиентный спуск и его оптимизаторы: поиск минимума функции потерь

Градиентный спуск и его оптимизаторы: поиск минимума функции потерь

Обратный проход (Backward Pass) честно выполнил свою работу: мы получили тензор градиентов W\nabla W, который точно указывает, в какую сторону нужно изменить каждый вес нейросети, чтобы ошибка выросла. Значит, чтобы ошибка упала, нужно сделать шаг в противоположном направлении — по антиградиенту.

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

Иллюзия точного направления: почему полный батч не работает

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

Индустрия решает эту проблему переходом от полного градиентного спуска к стохастическому (Stochastic Gradient Descent, SGD), а точнее — к его вариации Mini-batch SGD.

Мы разбиваем датасет на небольшие пакеты (батчи) — например, по 32, 64 или 256 примеров. Сеть делает прямой и обратный проход только для одного батча, вычисляет градиент и обновляет веса.

Из-за того, что 32 примера не репрезентуют весь датасет идеально, вычисленный градиент становится «шумным». Он указывает не точно в глобальный минимум, а лишь приблизительно в нужную сторону. Траектория обучения начинает блуждать.

Этот шум — не баг, а фича. В сложных моделях идеальный градиент часто приводит сеть в ближайший локальный минимум (неглубокую ямку на ландшафте), из которой обычный алгоритм уже не может выбраться: производная там равна нулю. Шум от мини-батчей работает как легкая тряска, помогающая «выбить» модель из мелких ловушек и заставить её искать более глубокий, глобальный минимум.

Проблема оврагов: осцилляция вместо прогресса

Даже если мы подобрали идеальный размер батча, базовая формула Wnew=WαWW_{new} = W - \alpha \nabla W сталкивается с топологической проблемой — патологической кривизной ландшафта, или «оврагами».

Представьте, что функция потерь имеет форму длинного узкого каньона, дно которого плавно спускается к минимуму. Стены каньона очень крутые, а уклон вдоль дна — пологий. Градиент всегда перпендикулярен линиям уровня функции. На крутом склоне оврага он будет огромным и направит нас почти строго поперек каньона, к противоположной стене. Продольная составляющая градиента (которая реально ведет нас к цели) окажется ничтожно малой.

В результате алгоритм начинает метаться от одной стены оврага к другой. Если мы уменьшим Learning Rate (α\alpha), чтобы не прыгать по стенам, шаги станут микроскопическими, и мы будем ползти по дну вечность.

Инерция (Momentum): добавляем физику в математику

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

В алгоритме SGD with Momentum мы перестаем опираться только на текущий градиент. Вместо этого мы вводим вектор скорости VV, который накапливает историю предыдущих градиентов.

На каждом шаге мы обновляем скорость:

Vnew=βVold+(1β)WV_{new} = \beta V_{old} + (1 - \beta) \nabla W

Здесь β\beta — коэффициент сохранения инерции (обычно равен 0.9). Он означает: «сохрани 90% предыдущего направления и добавь 10% от нового градиента».

Затем мы обновляем веса, используя эту скорость:

Wnew=WαVnewW_{new} = W - \alpha V_{new}

Что происходит в овраге? Градиенты, направленные поперек оврага (влево-вправо), имеют разные знаки на каждом шаге. При сложении в векторе VV они взаимно уничтожаются. А вот микроскопические градиенты, направленные вдоль дна, всегда имеют один знак. Они накапливаются, и скорость движения к цели экспоненциально возрастает. Momentum гасит паразитные колебания и усиливает полезный сигнал.

Adam: индивидуальный подход к каждому весу

Momentum решает проблему направления, но оставляет открытым вопрос размера шага. В глубоких нейросетях сотни миллионов параметров. Одни веса связаны с частыми признаками и обновляются постоянно. Другие — с редкими аномалиями, и градиент по ним приходит раз в тысячу батчей.

Использовать единый глобальный Learning Rate α\alpha для всех параметров — неэффективно. Нам нужен механизм, который будет автоматически делать большие шаги для «редких» весов и аккуратные маленькие шаги для «частых».

Эту задачу решает Adam (Adaptive Moment Estimation) — стандарт де-факто в современной AI-разработке.

Adam поддерживает сразу два состояния (кэша) для каждого отдельного веса в матрице:

  1. Первый момент (Momentum): экспоненциальное скользящее среднее самих градиентов. Работает как инерция, сглаживая направление.
  2. Второй момент (RMSProp): экспоненциальное скользящее среднее квадратов градиентов. Этот кэш накапливает информацию о том, насколько сильно «штормило» этот конкретный вес в прошлом.

Логика обновления в Adam: мы берем сглаженное направление (первый момент) и делим его на корень из сглаженной дисперсии (второй момент).

Если какой-то вес получает огромные градиенты, его второй момент быстро растет. Деление на большое число автоматически уменьшает фактический размер шага для этого веса. И наоборот: веса с крошечными градиентами получают буст, так как их знаменатель близок к нулю.

Adam освобождает инженера от необходимости маниакально подбирать Learning Rate. Базовое значение α=0.001\alpha = 0.001 в Adam работает "из коробки" для 90% архитектур, потому что алгоритм сам масштабирует шаг для каждого тензора в графе вычислений индивидуально.

Понимание того, как градиенты накапливаются и масштабируются, критически важно при отладке. Если ваша модель выдает NaN вместо предсказаний, проблема часто кроется не в архитектуре, а в «взрыве» градиентов, с которым не справился оптимизатор, требуя жесткого ограничения (Gradient Clipping).

Практика: реализация градиентного спуска на NumPy через Vibecoding

Практика: реализация градиентного спуска на NumPy через Vibecoding

Пять предыдущих глав мы собирали математический фундамент: тензоры, линейные трансформации, производные, цепное правило и оптимизаторы. Сейчас мы объединим это в работающий код. Мы не будем использовать готовые фреймворки вроде PyTorch. Вместо этого мы напишем собственный микро-фреймворк для машинного обучения на чистом NumPy, используя парадигму Vibecoding.

Как Senior-разработчик, вы знаете: чтобы ИИ сгенерировал надежную систему, ему нужно задать строгие контракты. Математика нейросетей — это и есть набор жестких контрактов размерностей.

Проектирование API: Математика как интерфейс

В Go мы бы начали с описания interface. В Python для нейросетей стандарт де-факто — это объектно-ориентированный подход, где каждый слой инкапсулирует внутреннее состояние (веса). Архитектурно слой всегда выполняет две операции: прямой проход (Forward) и обратный проход (Backward). В современных фреймворках обратный проход генерируется автоматически, но для полного понимания мы реализуем оба метода явно.

Сформулируем архитектурный контракт для линейного слоя, опираясь на выводы из прошлых глав.

Прямой проход (Forward): На вход поступает батч данных XX. Слой должен умножить его на матрицу весов WW и прибавить вектор смещения bb. Формула: Z=XW+bZ = XW + b Контракт размерностей: если XX имеет форму (Batch, In), а WW имеет форму (In, Out), то результат ZZ обязан иметь форму (Batch, Out). Например, если мы обрабатываем батч из 32 пользователей (Batch=32), у каждого по 10 признаков (In=10), и хотим получить 5 скрытых фичей (Out=5), то матрица XX размером 32×1032 \times 10 умножается на веса WW размером 10×510 \times 5, давая на выходе ZZ размером 32×532 \times 5.

Обратный проход (Backward): На вход поступает градиент ошибки по выходу этого слоя dZdZ. Слой должен вычислить три вещи:

  1. Градиент по весам W\nabla W для оптимизатора.
  2. Градиент по смещению b\nabla b для оптимизатора.
  3. Градиент по входу X\nabla X для передачи предыдущему слою (Chain Rule).

Чтобы вычислить градиенты, слою потребуются данные из прямого прохода (XX). Значит, метод Forward должен кэшировать XX в состоянии объекта (аналог структуры в Go).

Vibecoding: Промпт для линейного слоя

Теперь переведем наши математические контракты в промпт для Claude Code или Cursor Composer. Мы не пишем циклы — мы требуем векторизацию через NumPy.

Промпт для ИИ: Напиши класс LinearLayer на Python с использованием только библиотеки numpy.

  1. В конструкторе __init__(input_dim, output_dim) инициализируй матрицу весов W случайными числами (нормальное распределение) и вектор b нулями. Создай поля dW и db для хранения градиентов.
  2. Метод forward(X): принимает матрицу X, сохраняет ее в self.X (кэш для обратного прохода) и возвращает результат линейной трансформации (X * W + b).
  3. Метод backward(dZ): принимает градиент от следующего слоя. Вычисляет self.dW (X транспонированное умножить на dZ) и self.db (сумма dZ по оси батча). Возвращает градиент для предыдущего слоя dX (dZ умножить на W транспонированное).
  4. Используй строгие Type Hints.

В ответ ИИ сгенерирует лаконичный и математически точный класс. Обратите внимание, как элегантно математика ложится на методы NumPy: матричное умножение превращается в оператор @, а суммирование батча для вектора смещения — в np.sum(dZ, axis=0).

import numpy as np

class LinearLayer:
    def __init__(self, input_dim: int, output_dim: int):
        # Инициализация весов (W) и смещения (b)
        self.W = np.random.randn(input_dim, output_dim) * 0.01
        self.b = np.zeros((1, output_dim))

        # Место для хранения градиентов
        self.dW = np.zeros_like(self.W)
        self.db = np.zeros_like(self.b)
        self.X_cache = None

    def forward(self, X: np.ndarray) -> np.ndarray:
        self.X_cache = X
        return X @ self.W + self.b

    def backward(self, dZ: np.ndarray) -> np.ndarray:
        # Chain Rule в действии
        self.dW = self.X_cache.T @ dZ
        self.db = np.sum(dZ, axis=0, keepdims=True)

        dX = dZ @ self.W.T
        return dX

Контракт Оптимизатора

Слой умеет считать градиенты, но не должен сам изменять свои веса. Разделение ответственности — ключевой паттерн в ML. За обновление параметров отвечает Оптимизатор.

Сформулируем контракт для базового алгоритма SGD (Stochastic Gradient Descent), который мы разбирали ранее.

Промпт для ИИ: Напиши класс SGD. В конструкторе он принимает список слоев (модель) и learning_rate. Класс должен иметь метод step(), который проходит по всем слоям и обновляет их веса по формуле: W = W - learning_rate * dW (аналогично для b). Также нужен метод zero_grad(), который обнуляет dW и db у всех слоев, чтобы градиенты не накапливались между итерациями.

Оптимизатор инкапсулирует в себе гиперпараметр α\alpha (Learning Rate) и логику шага против градиента.

Священный Грааль ML: Цикл обучения

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

Теперь мы подошли к главному паттерну машинного обучения — циклу обучения (Training Loop). Независимо от того, обучаете ли вы простую линейную регрессию на NumPy или гигантскую LLM на кластере GPU, внутри происходит один и тот же танец из четырех шагов.

  1. Forward Pass: Пропускаем батч данных через сеть, получаем предсказания.
  2. Loss Calculation: Сравниваем предсказания с реальными ответами, вычисляем скаляр ошибки и первичный градиент.
  3. Backward Pass: Пробрасываем градиент ошибки от конца сети к началу (Backpropagation), вычисляя W\nabla W для каждого слоя.
  4. Optimizer Step: Оптимизатор сдвигает веса против градиента.

Посмотрим, как этот паттерн выглядит в коде:

# Инициализация
layer = LinearLayer(input_dim=2, output_dim=1)
optimizer = SGD(layers=[layer], learning_rate=0.01)

# Цикл обучения (Epochs)
for epoch in range(100):
    # 1. Прямой проход
    predictions = layer.forward(X_batch)

    # 2. Ошибка и ее градиент (MSE)
    loss = np.mean((predictions - Y_batch) ** 2)
    d_loss = 2 * (predictions - Y_batch) / len(X_batch)

    # 3. Обратный проход
    layer.backward(d_loss)

    # 4. Обновление весов
    optimizer.step()

    # Сброс градиентов для следующей итерации
    optimizer.zero_grad()

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

Мы прошли путь от понимания многомерных массивов до написания собственного движка автоматического дифференцирования и оптимизации. Вы только что реализовали ядро любой современной AI-системы. Разница между этим кодом на NumPy и промышленным PyTorch — лишь в том, что PyTorch умеет строить граф вычислений автоматически и выполнять матричные умножения на тысячах ядер видеокарты. Этим мы и займемся в следующем модуле.