Обучаем настоящую сеть на MNIST
В предыдущей статье мы разобрали все понятия, стоящие за обучением нейросети: прямые проходы, функции потерь, обратное распространение, градиентный спуск, скорости обучения, мини-батчи, затухающие градиенты. Для объяснения механики мы использовали игрушечный пример подгонки прямой с 2 параметрами: по входу предсказать . Это была регрессия: сеть выдаёт одно число, которое может принимать любое значение на непрерывной шкале — как цена или температура, — а насколько мы промахнулись, мы измеряли среднеквадратичной ошибкой (функцией потерь, выбранной для той задачи).
Здесь мы займёмся другим — классификацией. Вместо одного числа сеть будет выдавать вероятность для каждой категории и выбирать наибольшую: посмотреть на рукописную цифру и решить, какая она. Мы возьмём классический датасет MNIST — 70 000 изображений рукописных цифр от 0 до 9.
Сначала мы построим всё с нуля на NumPy, вручную написав прямой проход, функцию потерь, обратное распространение и градиентный спуск, чтобы вы увидели каждый шаг процесса обучения. Затем покажем то же самое на Keras — высокоуровневом фреймворке, который делает всё это автоматически в несколько строк кода. Keras может работать поверх PyTorch, TensorFlow или JAX — эти бэкенды берут на себя тяжёлую работу (ускорение на GPU, автоматическое вычисление градиентов), а Keras даёт поверх них чистый интерфейс. NumPy слишком медленный для настоящего обучения, но идеален для понимания происходящего — и этот код напрямую соответствует тому, что Keras делает под капотом.
Датасет
MNIST — это набор из 70 000 рукописных цифр (0–9): 60 000 для обучения и 10 000 для тестирования. Каждое изображение имеет размер 28×28 пикселей в градациях серого. На каждую цифру приходится примерно 6000 обучающих изображений — всего 60 000 для обучения и 10 000 для тестирования.
Вот несколько примеров:
Изображения и метки хранятся как массивы NumPy. Посмотрим на сырые данные.
# 60,000 images, each a matrix of 28×28 pixels
>>> train_images.shape
(60000, 28, 28)
# A single image: 28 rows × 28 columns of pixel values (0–255)
>>> train_images[0].shape
(28, 28)
>>> train_images[0].dtype
dtype('uint8')
# 60,000 labels: first image is "5", second is "0", ...
>>> train_labels
array([5, 0, 4, ..., 5, 6, 8], dtype=uint8)
# 10,000 test images, same 28×28 matrices
>>> test_images.shape
(10000, 28, 28)
# 10,000 labels for test images
>>> test_labels
array([7, 2, 1, ..., 4, 5, 6], dtype=uint8)Каждое значение в сетке 28×28 — это интенсивность пикселя. Поскольку каждый пиксель хранится в одном байте (8 бит), он может представлять значений — от 0 до 255. В сырых данных 0 означает чёрный, а 255 — белый, так что цифры MNIST это белые штрихи на чёрном фоне. Это не особенность MNIST — так интенсивность пикселей хранят практически все цифровые изображения. Цветные изображения используют 3 байта на пиксель (по одному на красный, зелёный и синий), но MNIST в градациях серого, так что одного байта достаточно.
Эта сетка чисел 28×28 и есть изображение: каждое число напрямую отображается в оттенок серого. Кликните по любому пикселю ниже и введите новое значение, чтобы увидеть это в действии. Переключателем можно переходить между исходным представлением (белое на чёрном) и инвертированным видом (чёрное на белом, который читается легче):
Заметьте, что значения пикселей в увеличенной сетке при переключении не меняются — они всегда показывают сырые данные (то, что реально лежит в массиве). Переключатель меняет лишь то, как эти значения отображаются в цвета на экране. Пиксель со значением 255 в данных всегда равен 255; он просто рисуется белым в исходном режиме и чёрным в инвертированном. Никакой скрытой структуры здесь нет — изображение и есть эти 784 числа. Измените число — измените пиксель.
Каждое изображение снабжено меткой — цифрой, которую оно изображает (0–9). Вместе эти два массива, изображения и метки, — это всё, на чём сеть будет учиться. Задача проста: по изображению 28×28 предсказать, что за цифра на нём. Это задача классификации с 10 классами, а сеть, которую мы построим, называется многоклассовым классификатором. В классификации класс — это одна из возможных меток, одна категория, которую модель может выбрать. У нас 10 классов (цифры 0–9) примерно по 6000 обучающих примеров на цифру (не строго поровну: у цифры 1 их 6742, а у цифры 5 всего 5421).
От регрессии к классификации
При переходе от регрессионной задачи предыдущей статьи () к классификации цифр нам понадобится:
- Выходной слой — вместо одного нейрона, выдающего одно число, нам нужно 10 нейронов (по одному на цифру), которые дают распределение вероятностей. Для этого нужна новая функция активации на выходном слое: softmax.
- Функция потерь — вместо среднеквадратичной ошибки, говорящей, насколько далеко число, нам нужна кросс-энтропийная функция потерь, говорящая, насколько модель была уверена в правильном классе.
- Градиент — формула градиента меняется под новую функцию потерь. Градиент кросс-энтропии в связке с softmax оказывается на удивление чистым.
- Активация скрытого слоя — между слоями нужна нелинейная функция активации, чтобы сеть могла учить сложные паттерны. Мы возьмём ReLU, ту самую активацию из примеров с нейроном и слоем в предыдущей статье.
Основной алгоритм — прямой проход, обратное распространение, градиентный спуск, мини-батчи — остаётся прежним. Разберём каждый кусочек.
Выходной слой и softmax
В регрессии сеть выдаёт одно число. В классификации ей нужно выбрать из набора категорий — в нашем случае одну из 10 цифр. Сеть должна сказать «я думаю, это 2» с какой-то степенью уверенности.
Мы решаем это, дав выходному слою 10 нейронов — по одному на цифру. Каждый нейрон выдаёт оценку, называемую логитом (название идёт от «log-odds» в логистической регрессии, но на практике это просто «сырая оценка до softmax»), которая показывает, насколько сильно сеть верит, что на входе именно эта цифра. Больше оценка — больше уверенность.
При 10 цифрах наш выходной слой выдаёт 10 логитов — по одному на класс. Чтобы примеры и визуализации были проще, в коде ниже мы возьмём всего 3 класса (цифры 0, 1, 2) — математика работает ровно так же, просто чисел меньше.
Для данного входного изображения выход может выглядеть так:
Изображение заходит внутрь, сеть прогоняет его через свои слои, и наружу выходят 3 числа — по одному на цифру. Цифра 2 получила самую высокую оценку (4.2), так что это и есть предсказание сети. В коде:
logits = [1.5, -0.3, 4.2]
# 0 1 2
# The network's guess: digit 2 (highest logit = 4.2)
# argmax returns the index of the largest value, not the value itself
prediction = np.argmax(logits) # 2Но сырые логиты — это не вероятности: они могут быть любыми числами, положительными или отрицательными, и не суммируются в 1. Чтобы превратить их в настоящее распределение вероятностей, используют функцию softmax — одну из самых распространённых операций в глубоком обучении, встречающуюся везде, от классификаторов изображений до LLM вроде ChatGPT (где она превращает сырые оценки по словам словаря в вероятность следующего токена):
Почему «softmax»? Потому что это более мягкая версия max. Жёсткий max просто выбрал бы наибольшее значение и проигнорировал всё остальное — [0, 0, 1]. Softmax делает почти то же самое, когда один логит доминирует, но когда логиты сопоставимо велики, остальные значения тоже получают заметный вес. Сравните:
| логиты | жёсткий max | softmax |
|---|---|---|
| [1.0, 8.5, 1.0] | [0, 1, 0] | [0.1%, 99.8%, 0.1%] |
| [3.0, 3.5, 3.0] | [0, 1, 0] | [24.4%, 51.2%, 24.4%] |
| [3.0, 3.0, 3.0] | не определён (ничья) | [33.3%, 33.3%, 33.3%] |
Жёсткий max даёт бинарный ответ — победитель забирает всё. Softmax даёт плавное распределение, непрерывно меняющееся при изменении входов. Когда модель не уверена (логиты близки), softmax это отражает. Когда модель уверена (один логит гораздо больше), softmax приближается к жёсткому max. Именно это плавное поведение и делает возможным обратное распространение: через softmax можно вычислять градиенты, потому что он дифференцируем всюду.
У жёсткого max нет полезного градиента (выход постоянен между скачками), а softmax никогда не выдаёт ровно 0 или 1 — поскольку при любом , каждый класс всегда получает какую-то вероятность, — так что градиент всегда определён и всегда ненулевой. Это видно в виджете ниже: попробуйте задать первым двум логитам 0, а третьему большое число. Даже когда один класс доминирует, остальные никогда не доходят ровно до 0%.
Softmax работает в три шага (на наших примерных логитах [1.5, -0.3, 4.2]):
- Возвести в степень каждого логита — поскольку при любом , это превращает каждое значение, даже отрицательное, в положительное число: , ,
- Сложить все положительные значения —
- Разделить каждое значение на эту сумму — , и так далее
Это нормирует список так, что он в сумме даёт ровно 1 — настоящее распределение вероятностей.
Кроме дифференцируемости, у softmax есть ещё одно важное свойство: эффект усиления экспоненты. Именно поэтому softmax использует , а не просто делит сырые логиты на их сумму. Исходные логиты (1.5, -0.3, 4.2) отличаются всего на несколько единиц. Но экспонента превращает аддитивные различия в мультипликативные: , тогда как , а . Разница логитов в 2.7 (между 4.2 и 1.5) превращается в 15-кратную разницу после экспоненты. После деления на сумму цифра 2 получает 92.7% вероятности — экспонента превращает «чуть выше» в «почти наверняка». Это ровно то, чего мы хотим от классификатора: уверенное предсказание, когда один класс явно набирает больше остальных.
Этим эффектом усиления можно управлять с помощью параметра температуры . Формула становится такой:
Деление на перед возведением в степень масштабирует различия между логитами. Попробуйте потянуть ползунок T:
- T → 0 (низкая температура) — различия усиливаются, распределение становится острым. Наибольший логит получает почти 100%. Это приближается к жёсткому max.
- T = 1 — стандартный softmax. Деление на 1 ничего не меняет, так что это равносильно отсутствию .
- T → ∞ (высокая температура) — различия сглаживаются, все логиты становятся похожи, а распределение стремится к равномерному (по 33.3%).
Если вы пользовались ChatGPT или другими LLM, вы видели этот параметр — это тот же ползунок «temperature». LLM используют softmax, чтобы превратить свои выходные логиты в распределение вероятностей по следующему слову. Низкая температура заставляет модель почти всегда выбирать самое вероятное слово (детерминированно, однообразно). Высокая температура размазывает вероятность по многим словам (креативно, иногда бессмысленно). Параметр температуры управляет этим компромиссом.
Вот как мы реализовали бы softmax на Python:
def softmax(logits):
# Subtract max for numerical stability (doesn't change the result,
# but prevents overflow when computing e^z for large values)
exp = np.exp(logits - np.max(logits))
return exp / np.sum(exp)
probs = softmax(logits)
# [0.063, 0.010, 0.927]
# 0 1 2
# The highest probability is 92.7% for digit 2Обратите внимание, что мы ещё и вычитаем max. Это нужно, потому что softmax вычисляет , а экспонента растёт крайне быстро. Если логит равен 1000, то переполняется в бесконечность в плавающей арифметике:
>>> import numpy as np
>>> np.exp(1000)
inf # overflow — can't compute
>>> np.exp(1000) / (np.exp(1) + np.exp(2) + np.exp(1000))
nan # infinity / infinity = undefinedВычитание максимального логита из всех значений перед возведением в степень сдвигает наибольшее значение в 0, а всё остальное делает отрицательным. Относительный порядок сохраняется — по-прежнему наибольшее, потому что всегда меньше 1, — так что вероятности получаются те же:
>>> logits = [1, 2, 1000]
>>> max(logits)
1000
>>> [z - max(logits) for z in logits]
[-999, -998, 0] # largest becomes 0, others become negative
>>> np.exp([-999, -998, 0])
array([0., 0., 1.]) # e⁰ = 1 is the largest (e^negative is always < 1), no overflowВот как математически получается, что вероятности одинаковы с вычитанием и без него:
Множитель сокращается, зато мы избежали вычисления опасно больших экспонент. Это стандартный приём численной устойчивости, который вы встретите в любой реализации softmax.
ReLU: функция активации для скрытых слоёв
Одну функцию активации мы уже разобрали — softmax на выходном слое, превращающий логиты в вероятности. Но нам нужна функция активации и на скрытых слоях. Обученная модель из предыдущей статьи () была одной линейной операцией — ни функции активации, ни скрытых слоёв.
Там же объяснялось, почему складывать слои без функции активации бессмысленно: цепочка линейных операций схлопывается в одну линейную операцию. Чтобы учить сложные паттерны, а не только прямые линии, между слоями нужна нелинейность. Предыдущая статья представила функции активации как решение, показав несколько вариантов в виджете нейрона (sigmoid, перцептрон, ReLU).
Мы возьмём ReLU (Rectified Linear Unit) — самый распространённый выбор в современных сетях:
Она пропускает положительные значения без изменений и обрезает отрицательные в ноль. Потяните ползунки ниже: когда взвешенная сумма положительна (зелёное), значение проходит. Когда отрицательна (красное), выход обрезается в 0:
Поведение видно, если двигать оба ползунка: важна именно взвешенная сумма w × x, а не отдельные значения. Попробуйте задать и вход, и вес отрицательными: произведение положительно, так что ReLU его пропускает. Она обрезает в ноль только тогда, когда отрицательно само произведение.
ReLU к тому же отлично подходит для обучения через обратное распространение. Производная функции активации напрямую входит в произведение цепного правила, когда мы вычисляем градиент потерь. В предыдущей статье градиент веса скрытого слоя выглядел так:
dw11 = x1 * relu_deriv(z1) * v1 * 2 * error
# ↑ activation derivative — one factor in the chainГрадиент потерь — это произведение локальных производных вдоль пути. relu_deriv — один из этих множителей, так что когда он равен 1, градиент проходит без изменений. Когда он равен 0, он обнуляет всё произведение, и нейрон на этом входе ничему не учится — это проблема умирающего ReLU. Но на практике она гораздо безобиднее беды сигмоиды: с ReLU умирает лишь часть нейронов, а остальные несут градиент в полную силу. С сигмоидой каждый нейрон уменьшает градиент и перестаёт учиться — это называется насыщением, и эффект накапливается по слоям. Подробно эту проблему мы разберём, когда дойдём до нормализации дальше в статье.
Почему сигмоида хуже ReLU с точки зрения градиентов?
Производная сигмоиды равна . Поскольку сигмоида выдаёт значение между 0 и 1, это , что максимально при (при ): . По мере удаления от нуля в любую сторону сигмоида насыщается к 0 или 1, а производная стягивается к 0.
Значит, производная сигмоиды всегда лежит между 0 и 0.25, то есть она уменьшает градиент на каждом слое. Уже после 5 слоёв: . Градиент сжался в 1000 раз, и ранние слои почти не учатся. ReLU полностью этого избегает — её производная равна 1 для активных нейронов, так что градиент проходит в полную силу.
Итак, в нашей сети две разные функции активации, играющие две разные роли: ReLU в скрытых слоях (вносит нелинейность, чтобы сеть могла учить сложные паттерны) и softmax на выходном слое (превращает сырые оценки в вероятности).
Функция потерь
Теперь нам нужна функция потерь. В предыдущей статье мы использовали среднеквадратичную ошибку — квадрат разности между предсказанием и целью, усреднённый по всем точкам данных. Для регрессии это имело смысл: предсказание было одним числом, цель — одним числом, и мы хотели, чтобы они были близки.
Для классификации мы используем кросс-энтропийные потери. Идея проста: взять отрицательный логарифм вероятности, которую модель присвоила правильному классу.
Чем ниже вероятность, тем выше потери:
- Модель говорит, что шанс верного ответа 95%: — низкие потери, хорошее предсказание
- Модель говорит 60%: — умеренные потери, недостаточная уверенность
- Модель говорит 1%: — очень высокие потери, почти полная ошибка
Почему логарифм? У него ровно те свойства, которые нужны функции потерь:
- Поскольку — если модель отдала 100% правильному классу, потери равны нулю. Идеальное предсказание, штрафовать нечего.
- Поскольку при — у штрафа нет потолка. Чем увереннее модель в неверном ответе, тем выше растут потери, без ограничения. Это создаёт крайне сильный сигнал исправлять уверенно неверные предсказания.
- Кривая крута около 0 и полога около 1 — поскольку производная () велика при малом и мала при около 1, градиент автоматически даёт большие поправки, когда модель ошибается, и мягкие подталкивания, когда она близка.
Разберём последний пункт подробнее. Два графика ниже показывают и рядом. Посмотрите на зелёный график — именно его мы используем как функцию потерь:
- Когда близко к 0 (модель ошибается), кривая круто взлетает: малое изменение вероятности вызывает большое изменение потерь.
- Когда близко к 1 (модель права), кривая выполаживается: потери почти не меняются.
Теперь сравните с синим графиком — это зеркальное отражение: круто около 1, полого около 0, что было бы неправильным поведением для функции потерь. Поэтому мы её инвертируем — минус переворачивает кривую так, чтобы градиент был сильнее всего именно там, где он нужнее всего.
Приятный побочный эффект минуса: мы знаем, что числа между 0 и 1 всегда отрицателен, например , так что и потери были бы отрицательными, тогда как потери должны быть положительными. Минус переводит их в положительное значение: .
Эта крутизна напрямую влияет на обратное распространение. Производная равна . Когда модель сильно ошибается (), градиент равен — мощный сигнал исправиться. Когда она уже близка (), градиент всего — мягкое подталкивание.
Потяните ползунки ниже, чтобы увидеть, как меняются потери при изменении вероятностей — следите за жёлтой точкой, движущейся по кривой :
Попробуйте потянуть ползунок цифры 2 (истинного класса) вправо — по мере приближения её вероятности к 100% потери падают почти до нуля. Потяните влево — по мере падения вероятности потери круто взлетают. Эта крутизна означает, что и градиент велик, так что модель получает мощный сигнал исправиться, когда она уверенно ошибается, и мягкое подталкивание, когда она уже близка к верному ответу.
На Python функция потерь выглядит так. Она принимает предсказанные моделью вероятности и истинную метку (индекс правильного класса — 0, 1 или 2) и возвращает вероятности, присвоенной этому классу:
def cross_entropy_loss(probs, label):
# label is the index of the correct class
p_correct = probs[label] # e.g. probs[2] = 0.94
return -np.log(p_correct) # -log(0.94) = 0.062
# Model is confident and correct → low loss
cross_entropy_loss([0.05, 0.01, 0.94], label=2) # 0.062
# Model is uncertain → moderate loss
cross_entropy_loss([0.30, 0.30, 0.40], label=2) # 0.916
# Model is confident but wrong → high loss
cross_entropy_loss([0.80, 0.15, 0.05], label=2) # 2.996Потери против точности
Во время обучения мы отслеживаем и потери, и точность — они связаны, но это не одно и то же.
Точность измеряет долю верных предсказаний. Она бинарна и не интересуется уверенностью: каждое предсказание либо верное, либо нет, без частичного зачёта. Предсказание 0.51 для правильного класса считается верным, как и 0.99 — оба добавляют одинаковую +1 к счёту точности. А вот потери сильно различаются: 0.67 для первого и 0.01 для второго.
Потери (кросс-энтропия) измеряют, сколько вероятности модель отдала правильному ответу — чем меньше вероятность, тем выше штраф. Это непрерывная величина, отражающая качество предсказания, а не только его правильность. Потери непрерывны — им важно, сколько вероятности вы отдали правильному классу, а не только то, была ли она наибольшей. Две модели могут иметь одинаковую точность, но очень разные потери: одна еле-еле права, другая уверенно права.
Вот как это выглядит в реальном логе обучения:
Epoch 1/10 — accuracy: 0.5117 — loss: 1.7934 — val_accuracy: 0.7520 — val_loss: 1.3155
Epoch 2/10 — accuracy: 0.7806 — loss: 1.0726 — val_accuracy: 0.8377 — val_loss: 0.8382
Epoch 5/10 — accuracy: 0.8627 — loss: 0.5560 — val_accuracy: 0.8798 — val_loss: 0.4901На первой эпохе accuracy: 0.5117 означает, что модель предсказала правильную цифру для 51% обучающих изображений: для каждого изображения класс с наибольшей вероятностью сравнивается с истинной меткой, и считается доля верных предсказаний. Это уже заметно выше базовых 10% случайного угадывания среди 10 классов, но всё ещё ошибка на половине изображений.
Потери 1.79 говорят больше — они означают, что модель в среднем отдаёт правильному классу низкую вероятность. Это может происходить двумя способами: модель может предсказывать правильный класс, но с низкой уверенностью (скажем, 0.3 вместо 0.9), или, что хуже, уверенно ошибаться, отдавая большую часть вероятности неверному классу. Высокие потери обычно определяются вторым случаем: несколько уверенно неверных предсказаний вносят в потери гораздо больше, чем множество неуверенно верных.
Из-за разницы между точностью и потерями они не всегда движутся вместе:
- Потери падают, точность стоит — модель становится увереннее в предсказаниях, которые и так делала правильно. Например, если модель уже предсказывает «7» для изображения семёрки, но её уверенность растёт с 0.4 до 0.9, точность не меняется (она и так была верной), а потери заметно падают.
- Точность растёт, и потери тоже растут — модель даёт больше верных предсказаний, но становится самоуверенной на неверных. Представьте, что модель исправила 5 изображений, которые раньше путала (точность улучшилась), но одновременно стала очень уверенной (0.95) на 2 изображениях, которые ошибает. Потери от этих 2 уверенных ошибок могут перевесить выигрыш от 5 новых верных ответов.
- Обе стоят на месте — модель застряла. Веса меняются, но предсказания по сути не отличаются. Так часто бывает, когда скорость обучения слишком мала или модель исчерпала свою ёмкость.
На практике при здоровом обучении обе метрики обычно улучшаются вместе — лог выше типичный тому пример. Когда они расходятся, это сигнал, что происходит что-то интересное (или проблемное).
Градиент
В предыдущей статье мы видели, что обучение требует вычисления градиента потерь по каждому параметру — производной, говорящей, в какую сторону подтолкнуть каждый вес, чтобы уменьшить ошибку. Для примера с подгонкой прямой мы показали, как пришли к формуле градиента MSE. Теперь нам нужен градиент кросс-энтропийных потерь по логитам (сырым оценкам до softmax).
Вот как выглядит один нейрон выходного слоя: логит — это взвешенная сумма до применения функции активации:
Заметьте, что у самого softmax нет обучаемых параметров — это просто функция, преобразующая логиты в вероятности. Обучаемые веса находятся в нейроне, который считает взвешенную сумму и выдаёт логит. Как только у нас есть градиент по логитам, он течёт дальше назад через эти веса и в скрытые слои, обновляя всё по пути.
Формула для MSE, к которой мы пришли в предыдущей статье, имела ясную структуру: dw = 2 * mean(error * x) — ошибка, умноженная на вход, усреднённая по примерам.
Каждая часть возникла из цепного правила, применённого к функции потерь. Теперь нужно проделать то же самое для кросс-энтропии в связке с softmax. Разберём это в три части:
- Градиент через softmax (выходной слой) — вывести градиент кросс-энтропии + softmax по логитам и прийти к чистой формуле
- Градиент через ReLU (скрытый слой) — показать, как градиент течёт через функцию активации скрытого слоя
- Собираем всё вместе — сцепить обе части в полный обратный проход
Полная прямая цепочка такова: входы → скрытый слой (веса + ReLU) → логиты → softmax → вероятности → кросс-энтропия → потери. Обратный проход идёт в обратную сторону: мы начинаем с потерь и применяем цепное правило назад через кросс-энтропию, softmax, а затем скрытые слои.
Математика ниже сложнее, чем градиент MSE из предыдущей статьи — чтобы пользоваться результатом, не обязательно следить за каждым шагом. Если хотите, можете сразу перейти к итоговой формуле и коду. Но если хочется увидеть, откуда она берётся, вот вывод по шагам.
Градиент через softmax (выходной слой)
Шаг 1: производная потерь. Потери равны , где — правильный класс. Производную мы уже знаем: это . Значит:
Шаг 2: производная softmax. Теперь у нас есть производная потерь по . Но нужна производная по логитам , потому что именно их и производят веса слоя (как показано на диаграмме выше). Чтобы навести мост, надо знать: как softmax превращает изменение в изменение ?
Softmax это . По правилу дифференцирования частного возникают два случая:
Если (подталкиваем логит собственного класса):
Это похоже на производную сигмоиды — так и есть. Каждый выход softmax локально ведёт себя как сигмоида.
Если (подталкиваем другой логит):
Первый член числителя равен 0, потому что не зависит от . Увеличение одного логита всегда уменьшает остальные вероятности — они обязаны в сумме давать 1.
Шаг 3: цепное правило. Перемножаем две производные:
- Для правильного класса ():
- Для неверного класса ():
Результат
После всех этих выкладок члены красиво сокращаются. Градиент кросс-энтропийных потерь в связке с softmax для каждого выходного нейрона равен:
Как это считать на практике? С помощью — вектора one-hot, из одних нулей с единицей на позиции правильного класса. Поскольку для правильного класса и для всех остальных, вычитание из даёт для правильного класса и для остальных — ровно кусочную формулу выше:
На Python это просто:
grad_z = probs - one_hot_label # gradient w.r.t. logitsВот и всё — градиент по логитам это просто разница между тем, что модель предсказала, и тем, каким был ответ. Вероятности минус цель.
Посмотрим, что это значит, на конкретном примере с 3 классами. Если модель выдаёт вероятности [0.06, 0.01, 0.93], а правильный класс — 2:
- Цель (one-hot) это
[0, 0, 1]— вся вероятность должна быть на классе 2 - Градиент равен
[0.06 − 0, 0.01 − 0, 0.93 − 1]=[0.06, 0.01, −0.07]
Знак говорит о направлении: класс 2 получает отрицательный градиент (−0.07), то есть «подними этот логит, чтобы увеличить его вероятность». Классы 0 и 1 получают положительные градиенты, то есть «опусти эти логиты». Величина говорит, насколько: классу 0 (0.06) нужна большая поправка, чем классу 1 (0.01), потому что к нему утекло больше вероятности.
Заметьте, что — это градиент по логитам, а не по весам. Помните, выходной нейрон вычисляет (логит), а затем softmax превращает его в вероятность. Так что полная цепочка от потерь к весам состоит из двух частей:
Первая часть () — это то, что мы только что вывели: как потери меняются с логитами. Вторая часть () — производная по , то есть просто вход. Их перемножение даёт градиент по весам:
grad_z = probs - one_hot_label # part 1: loss → logits (p - y)
grad_W = np.outer(grad_z, x) # part 1 × part 2: logits → weights
grad_b = grad_z # bias gradient (same as grad_z)
grad_input = W.T @ grad_z # gradient to pass to the previous layergrad_input = W.T @ grad_z — это градиентный сигнал, передаваемый предыдущему слою. Тот слой делает то же самое, но с ReLU вместо softmax.
Градиент через ReLU (скрытый слой)
Скрытый слой вычисляет , затем применяет ReLU: . Мы вывели это в предыдущей статье: когда градиент приходит от следующего слоя (grad_output), цепное правило умножает его на производную ReLU:
Если нейрон был активен (), градиент проходит без изменений. Если он был неактивен (), градиент обнуляется. Затем, как и в выходном слое, мы продолжаем цепочку, чтобы получить градиенты по весам:
grad_z = grad_output * (z > 0) # multiply by ReLU derivative (0 or 1)
grad_W = np.outer(grad_z, x) # chain rule: gradient for weights
grad_b = grad_z # gradient for biases
grad_input = W.T @ grad_z # pass to the previous layerСобираем всё вместе
Полный обратный проход сцепляется через каждый слой, начиная с потерь:
- Выходной слой: → вычислить
grad_W,grad_b, передатьgrad_inputназад - Скрытый слой: получить
grad_input, умножить на производную ReLU → вычислитьgrad_W,grad_b, передатьgrad_inputназад - Повторить для любых дополнительных скрытых слоёв
Схема одинакова на каждом слое — отличается только производная активации (softmax или ReLU). Вот полный обратный проход для нашей сети (один скрытый слой + выходной):
# Forward pass (for reference)
h = relu(W1 @ x + b1) # hidden layer: inputs → ReLU
z = W2 @ h + b2 # output layer: hidden → logits
p = softmax(z) # softmax: logits → probabilities
loss = -np.log(p[label]) # cross-entropy loss
# Backward pass: chain rule from loss back to weights
# 1. Output layer gradient (softmax + cross-entropy)
grad_z2 = p.copy()
grad_z2[label] -= 1 # p - y
grad_W2 = np.outer(grad_z2, h) # weight gradient
grad_b2 = grad_z2 # bias gradient
grad_h = W2.T @ grad_z2 # pass gradient to hidden layer
# 2. Hidden layer gradient (ReLU)
grad_z1 = grad_h * (z1 > 0) # multiply by ReLU derivative
grad_W1 = np.outer(grad_z1, x) # weight gradient
grad_b1 = grad_z1 # bias gradient
# 3. Update all weights
W2 -= lr * grad_W2
b2 -= lr * grad_b2
W1 -= lr * grad_W1
b1 -= lr * grad_b1Ровно это и будут реализовывать наши HiddenLayer.backward() и OutputLayer.backward() в коде на NumPy ниже — те же шаги, просто обёрнутые в классы.
Виджет ниже прогоняет именно этот цикл на крошечной сети: заменитель изображения 2×2 подаётся в скрытый слой из 2 нейронов и выход на 3 класса. Выберите вход, укажите правильную цифру и посмотрите, как заполняется обратный проход: ошибка выхода grad_z2 = p − y, ошибка скрытого слоя grad_z1 и матрицы градиентов весов grad_W2 и grad_W1. Нажмите Apply update — и потери упадут: тот же шаг W -= lr * grad_W, сделанный видимым.
Мини-батчи и эпохи
В предыдущей статье мы ввели стохастический градиентный спуск (SGD): вместо того чтобы пройтись по всем точкам данных, собрать градиенты для каждой и усреднить их в одно обновление, мы разбиваем данные на маленькие мини-батчи и обновляем веса после каждого батча. Размер батча — это просто то, по скольким примерам вы усредняете перед обновлением весов: та же формула градиента, то же усреднение, просто другое число примеров.
Как мы видели в предыдущей статье, где на батч приходилось 2 точки из 5, это вносит некоторый шум — градиент по 2 примерам не укажет ровно туда же, куда градиент по всем 5, — но гораздо более частые обновления с лихвой это окупают.
Посмотрим, как это разыгрывается на наших 60 000 обучающих изображений.
Без мини-батчей (полнобатчевый градиентный спуск) один проход по данным выглядит так:
- Прямой проход всех 60 000 изображений через сеть
- Вычисление потерь, усреднённых по всем 60 000 предсказаниям
- Обратное распространение для получения градиента (усреднённого по всем изображениям)
- Одно обновление весов
Это 1 обновление весов после просмотра каждого изображения. Градиент очень точен (он учитывает весь датасет), но модель ничему не учится, пока не обработает все 60 000 изображений.
С мини-батчами размера 32 тот же проход выглядит совсем иначе:
- Взять изображения 1–32, прямой проход, вычислить потери, обратное распространение, обновить веса
- Взять изображения 33–64, прямой проход, вычислить потери, обратное распространение, обновить веса
- Взять изображения 65–96, прямой проход, вычислить потери, обратное распространение, обновить веса
- … повторить для всех мини-батчей
Это 1875 обновлений весов за тот же проход по данным.
Каждый отдельный градиент шумнее (он основан всего на 32 изображениях вместо 60 000), но модель улучшается 1875 раз, а не один. К моменту, когда она увидела все данные, она уже внесла сотни поправок — она учится по ходу, а не ждёт до конца. (Крайний случай — размер батча 1: обновление после каждого изображения. Это 60 000 обновлений за эпоху, но каждый градиент очень шумный, ведь он основан на одном примере.)
Когда модель прошла через все мини-батчи — все 1875, — она увидела каждое обучающее изображение ровно один раз. Такой полный проход по всему датасету называется эпохой. Обычно мы обучаем несколько эпох — каждый проход по данным улучшает модель дальше.
Число батчей на эпоху определяется размером датасета и размером батча:
В начале каждой эпохи мы перемешиваем данные, чтобы мини-батчи каждый раз были другими — это мешает модели выучивать закономерности в порядке следования, а не в самих изображениях.
Вы можете спросить, как выбрать правильное число эпох. Оно зависит от того, когда модель перестаёт улучшаться на невиданных данных. Слишком мало эпох — модель недоучилась; слишком много — она начинает запоминать обучающие данные вместо обобщения. На практике задают большое максимальное число эпох и позволяют ранней остановке решить, когда действительно остановиться: она следит за валидационной точностью и прекращает обучение, когда та выходит на плато.
Если запустить обучение на 5 эпох, мы получим:
То есть градиентный спуск отрабатывает 9375 раз, каждый раз обрабатывая батч из 32 изображений, вычисляя усреднённый градиент и подталкивая каждый вес в сети.
Построение модели
Построим модель, которая принимает 784 входа (пикселей) и выдаёт 10 вероятностей классов (по одной на цифру). Мы используем ту же структуру HiddenLayer, что и в предыдущей статье: матрица весов , вектор смещений и функция активации:
В предыдущей статье наша модель была одним линейным уравнением — всего 2 параметра, ни слоёв, ни функций активации. Мы вычисляли градиент напрямую, и передавать ничего назад не требовалось, потому что позади ничего не было. Теперь, с несколькими слоями, каждый слой должен вычислять собственные градиенты и передавать градиентный сигнал предыдущему слою — это и есть обратный проход.
Математика цепного правила та же, что мы вывели выше, просто обёрнутая в класс:
class HiddenLayer:
def __init__(self, n_inputs, n_neurons):
# We use initialization proposed by Kaiming He et al. (2015):
# random weights scaled by sqrt(2/n) — keeps activations balanced
# for ReLU networks (too large → explode, too small → vanish)
self.W = np.random.randn(n_neurons, n_inputs) * np.sqrt(2.0 / n_inputs)
self.b = np.zeros(n_neurons)
def forward(self, x):
self.x = x # save for backward pass
self.z = self.W @ x + self.b # weighted sum
self.out = np.maximum(0, self.z) # ReLU activation
return self.out
def backward(self, grad_output):
# ReLU derivative: 1 if z > 0, else 0
grad_z = grad_output * (self.z > 0)
# Gradients for this layer's parameters (chain rule)
self.grad_W = np.outer(grad_z, self.x) # ∂loss/∂W = grad_z ⊗ x
self.grad_b = grad_z # ∂loss/∂b = grad_z
# Gradient to pass to the previous layer (chain rule continues)
return self.W.T @ grad_z # ∂loss/∂x = Wᵀ · grad_z
def update(self, lr):
# Gradient descent: update parameters
self.W -= lr * self.grad_W
self.b -= lr * self.grad_bУ слоя три метода:
forward: вычислить , применить ReLUbackward: получить градиент от следующего слоя, вычислить локальные градиенты по цепному правилу и вернуть градиент для передачи предыдущему слоюupdate: применить градиентный спуск — вычестьlr × градиентиз каждого параметра
Мы отделяем backward от update, чтобы можно было накапливать градиенты по мини-батчу перед обновлением — ровно как делает Keras.
grad_input в конце — это градиентный сигнал, передаваемый назад предыдущему слою; так цепное правило «течёт» по сети, в точности как на диаграмме из предыдущей статьи:
Forward: x ──▶ Layer 1 ──▶ Layer 2 ──▶ Output ──▶ Loss
Backward: x ◀── Layer 1 ◀── Layer 2 ◀── Output ◀── ∂LКаждый слой получает градиент справа, вычисляет собственные градиенты параметров (чтобы обновить и ) и передаёт остаток градиента влево.
Теперь создадим и выходной слой, который использует softmax вместо ReLU (поскольку последний слой должен выдавать вероятности, а не ReLU-активации):
class OutputLayer:
def __init__(self, n_inputs, n_classes):
self.W = np.random.randn(n_classes, n_inputs) * np.sqrt(2.0 / n_inputs)
self.b = np.zeros(n_classes)
def forward(self, x):
self.x = x
self.z = self.W @ x + self.b
# Softmax instead of ReLU
exp = np.exp(self.z - np.max(self.z))
self.probs = exp / np.sum(exp)
return self.probs
def backward(self, label):
# Combined softmax + cross-entropy gradient: p - y
grad_z = self.probs.copy()
grad_z[label] -= 1
# Same gradient formulas as Layer
self.grad_W = np.outer(grad_z, self.x)
self.grad_b = grad_z
return self.W.T @ grad_z
def update(self, lr):
self.W -= lr * self.grad_W
self.b -= lr * self.grad_bТеперь можно собрать сеть. Начнём с простой архитектуры — один скрытый слой из 128 нейронов:
# 784 inputs → 128 hidden neurons → 10 output classes
layer1 = HiddenLayer(784, 128)
output = OutputLayer(128, 10)Но почему 128? Почему не 1 или не 10?
Каждый скрытый нейрон — это детектор признака. Он смотрит на все 784 пикселя через свой вектор весов и выдаёт одно число: «насколько сильно мой паттерн проявляется в этом изображении?». Один нейрон может настроиться на горизонтальный штрих сверху (полезно, чтобы отличать 5 от 7), другой — на замкнутую петлю в нижней половине (6, 8, 9), третий — на диагональ справа вверху. Затем выходной слой взвешивает все 128 этих сигналов, чтобы решить, какая цифра наиболее вероятна.
Скалярное произведение, которое считает выходной слой, — hidden @ W₂[:, digit] — это ровно выравнивание в духе косинусной близости между двумя векторами: вектором активаций признаков, который скрытый слой выдал для этого изображения, и вектором весов конкретной цифры (столбцом W₂), кодирующим «какие признаки важны для этой цифры и насколько». Оценка цифры высока, когда её шаблон хорошо выравнивается с признаками, которые изображение действительно вызвало, — та же идея, что и оценка того, смотрят ли две стрелки в похожие стороны. Побеждает класс с наибольшей оценкой, как ближайший сосед при поиске по косинусной близости.
Всего с 1 скрытым нейроном каждое изображение сжималось бы в один скаляр ещё до того, как его увидит выходной слой. Тогда 10 оценок классов были бы 10 линейными функциями от этого одного числа — геометрически вам пришлось бы расставить все 10 цифр вдоль одной прямой. На одномерной прямой нет такого порядка, при котором область каждой цифры отделена от остальных девяти, так что сеть не может представить разделение на 10 классов через одномерное бутылочное горлышко, сколько её ни обучай. Ширина скрытого слоя задаёт размерность, с которой работает выходной слой, а 10 классам нужно больше одной.
Само по себе 128 — комфортный дефолт: достаточно большой, чтобы хорошо подогнать MNIST (97–98% с одним слоем), достаточно маленький, чтобы обучаться за секунды на CPU. Возьмите 64 — потеряете около полпроцента; возьмите 512 — выиграете чуть-чуть, обучаясь медленнее и переобучаясь сильнее. Мы поварьируем это в следующей статье и посмотрим, что будет.
Это параметров в первом слое и в выходном — всего 101 770 параметров. По сравнению с 2 параметрами ( и ) в примере с подгонкой прямой из предыдущей статьи это в 50 000 раз больше. И всё же алгоритм обучения идентичен.
Та же сеть на Keras
Keras называет свои слои Dense — сокращение от «densely connected» («плотно соединённый», он же «полносвязный»), то есть каждый вход соединён с каждым нейроном. Слой Dense выполняет ровно то же вычисление, что и наши классы на NumPy выше: — умножить входы на веса, добавить смещение, применить функцию активации. Разница в том, что Keras сам занимается инициализацией весов, прямым проходом, вычислением градиентов и обновлением параметров.
Наш HiddenLayer(784, 128) с ReLU превращается в Dense(128, activation="relu"), а OutputLayer(128, 10) с softmax — в Dense(10, activation="softmax"). Заметьте, что в Keras вы указываете только число выходов: модели нужен keras.Input(shape=(784,)) наверху, чтобы знать размер входа, а дальше Keras сам выводит вход каждого слоя из выхода предыдущего. Так что Dense(10) знает, что у него 128 входов, потому что предыдущий слой выдаёт 128.
Dense-слой, полносвязный, FFNN, MLP — в чём разница?
Эти термины используют как взаимозаменяемые, но описывают они разные вещи:
- Dense / полносвязный слой — про разводку одного слоя: каждый вход соединён с каждым нейроном, та самая операция , которую мы строим. Это строительный блок.
- MLP (многослойный перцептрон) — целая сеть из уложенных друг на друга плотных слоёв с нелинейностями между ними. Модель, которую мы только что построили, и есть MLP: 784 → 128 (ReLU) → 10 (softmax).
- FFNN (нейросеть прямого распространения) — описывает топологию: данные текут в одну сторону, от входа к выходу, без петель и без памяти о предыдущих входах. Наша сеть тоже прямого распространения.
Так что для сети вроде нашей подходят все три ярлыка: это полносвязный MLP, и он прямого распространения. Одна тонкость: «прямое распространение» шире, чем «плотный». Свёрточная нейросеть (CNN) тоже прямого распространения (петель нет), но её слои свёрточные, а не плотные, так что не всякая FFNN построена из плотных слоёв. А рекуррентная нейросеть (RNN) не является сетью прямого распространения, потому что заворачивает собственный выход обратно на вход, чтобы нести память по последовательности. Именно этот контраст — прямое распространение против рекуррентности — и объясняет, почему эти названия фигурируют как отдельные архитектуры, когда модели сравнивают на задачах вроде языковых, где порядок слов важен.
Sequential складывает их в модель, где выход каждого слоя подаётся в следующий:
model = keras.Sequential([
# 784 inputs (28×28 image pixels)
keras.Input(shape=(784,)),
# 128 neurons/outputs, ReLU activation
keras.layers.Dense(128, activation="relu"),
# 10 neurons/outputs (one per digit), softmax activation
keras.layers.Dense(10, activation="softmax"),
])
model.summary()
# Total params: 101,770 — same number we counted by handПостроение цикла обучения
Цикл обучения — тот же 4-шаговый процесс из предыдущей статьи: прямой проход, потери, обратное распространение, градиентный спуск. Для каждого мини-батча мы прогоняем прямой проход и обратное распространение по каждому изображению, накапливая градиенты, затем усредняем их и один раз обновляем веса. Это соответствует тому, что Keras делает внутри:
def train(layers, output_layer, X_train, y_train, X_val, y_val,
epochs=5, lr=0.1, batch_size=32):
n = X_train.shape[0]
history = {"train_loss": [], "train_acc": [], "val_acc": []}
for epoch in range(epochs):
# Shuffle training data at the start of each epoch
indices = np.random.permutation(n)
X_shuffled = X_train[indices]
y_shuffled = y_train[indices]
epoch_loss = 0.0
correct = 0
all_layers = layers + [output_layer]
for i in range(0, n, batch_size):
X_batch = X_shuffled[i:i+batch_size]
y_batch = y_shuffled[i:i+batch_size]
bs = len(X_batch)
# Accumulate gradients over the mini-batch
accumulated = {id(l): (np.zeros_like(l.W), np.zeros_like(l.b))
for l in all_layers}
for x, label in zip(X_batch, y_batch):
# 1. Forward pass
h = x
for layer in layers:
h = layer.forward(h)
probs = output_layer.forward(h)
# 2. Loss computation
loss = -np.log(probs[label] + 1e-10)
epoch_loss += loss
correct += (np.argmax(probs) == label)
# 3. Backpropagation (compute gradients, don't update yet)
grad = output_layer.backward(label)
for layer in reversed(layers):
grad = layer.backward(grad)
# Accumulate gradients
for l in all_layers:
accumulated[id(l)][0][:] += l.grad_W
accumulated[id(l)][1][:] += l.grad_b
# 4. Gradient descent: average gradients and update
for l in all_layers:
l.grad_W = accumulated[id(l)][0] / bs
l.grad_b = accumulated[id(l)][1] / bs
l.update(lr)
# Track metrics
train_loss = epoch_loss / n
train_acc = correct / n
val_acc = evaluate(layers, output_layer, X_val, y_val)
history["train_loss"].append(train_loss)
history["train_acc"].append(train_acc)
history["val_acc"].append(val_acc)
print(f"Epoch {epoch+1}/{epochs} — loss: {train_loss:.4f}, "
f"train acc: {train_acc:.2%}, val acc: {val_acc:.2%}")
return history
def evaluate(layers, output_layer, X, y):
correct = 0
for x, label in zip(X, y):
h = x
for layer in layers:
h = layer.forward(h)
probs = output_layer.forward(h)
correct += (np.argmax(probs) == label)
return correct / len(y)Это тот же цикл SGD из предыдущей статьи: перемешать данные, разбить на мини-батчи и для каждого примера прогнать прямой проход, вычислить потери, распространить градиенты назад и обновить веса. Отличие лишь в том, что вместо 5 точек данных и 2 параметров у нас 60 000 изображений и 101 770 параметров.
Вот то же самое на Keras — весь цикл обучения выше схлопывается в три строки:
model.compile(
optimizer=keras.optimizers.SGD(learning_rate=0.1), # gradient descent with lr=0.1
loss="sparse_categorical_crossentropy", # cross-entropy loss
metrics=["accuracy"],
)
history = model.fit(
X_train, y_train,
epochs=5,
batch_size=32,
validation_split=0.2, # hold out 20% of training data for validation
)compile настраивает функцию потерь и оптимизатор, а fit прогоняет полный цикл — прямой проход, потери, обратное распространение и градиентный спуск для каждого мини-батча в каждой эпохе.
Разберём аргументы:
"sparse_categorical_crossentropy"— это те самые кросс-энтропийные потери, которые мы вывели выше. В названии три части: «sparse» означает, что мы передаём метки целыми числами (например,3), а не one-hot векторами ([0,0,0,1,0,0,0,0,0,0]); «categorical» — что мы классифицируем по нескольким категориям; «crossentropy» — та самая функция потерь . В Keras есть и"categorical_crossentropy"(без «sparse») для случая, когда метки уже в one-hot: математика та же, просто другой формат входа.SGD(learning_rate=0.1)— стохастический градиентный спуск, то же правило обновленияw = w - lr * dwиз предыдущей статьи. В Keras есть и более продвинутые оптимизаторы (Adam, RMSprop и др.), автоматически адаптирующие скорость обучения, но обычный SGD — это то, чем мы пользовались, и здесь он работает хорошо.validation_split=0.2— откладывает 20% обучающих данных (12 000 изображений) в валидационную выборку. После каждой эпохи Keras оценивает модель на этих отложенных изображениях (только прямой проход, без обновления весов), чтобы мы могли следить за обобщающей способностью. Оставшиеся 48 000 изображений используются для собственно обучения.
Подготовка данных
Прежде чем начать обучение сети, нужно подготовить сырые пиксельные данные — сеть не может работать с сетками целых чисел 28×28 напрямую. Мы сделаем две вещи:
- Развернуть — переформатировать сетку 28×28 в один вектор из 784 чисел
- Нормализовать — перевести значения пикселей из в
Разворачивание нужно потому, что наша сеть использует плотные (полносвязные) слои, где каждый вход соединён с каждым нейроном — они ожидают плоский список чисел, а не двумерную сетку. Другие архитектуры вроде свёрточных нейросетей (CNN) могут работать прямо с двумерной формой, но мы пока останемся с плотными слоями. Разворачивание переформатирует сетку 28×28 в один вектор из 784 чисел — наша сеть использует плотные (полносвязные) слои, где каждый вход соединён с каждым нейроном, так что они ожидают плоский список чисел, а не двумерную сетку. Другие архитектуры вроде свёрточных нейросетей (CNN) могут работать прямо с двумерной формой, но мы пока останемся с плотными слоями.
flatImage = image.reshape(784) # [0, 0, 0, ..., 156, 252, 128, ..., 0, 0] — 784 valuesНормализация помогает держать входные значения в том же масштабе, что и веса. Веса обычно инициализируются маленькими случайными числами около 0, так что если входы доходят до 255, взвешенные суммы становятся огромными, что ведёт к большим активациям, большим градиентам и нестабильному обучению. Деление на 255 переводит всё в — диапазон, в котором маленькие случайные веса с самого начала дают разумные выходы. Это стандартная практика машинного обучения, а не специфика MNIST.
Без нормализации нейроны могут насыщаться: функция активации застревает в плоской области, где её производная ~0, градиент исчезает, и сеть перестаёт учиться. Это тот же механизм, что стоит за проблемой затухающего градиента. Подробную интерактивную демонстрацию того, как ненормализованные входы вызывают насыщение, см. в статье про умирающий ReLU.
Нормализация переводит значения пикселей из в :
# Normalize pixel values from [0, 255] to [0, 1]
normalizedImage = flatImage / 255.0 # [0.0, 0.0, 0.0, ..., 0.61, 0.99, 0.50, ..., 0.0, 0.0]Обучающая, валидационная и тестовая выборки
Прежде чем запускать обучение, важно понять одно различие. У нас 60 000 обучающих изображений и 10 000 тестовых, но на самом деле нам нужно три выборки, а не две:
- Обучающая выборка — данные, на которых модель учится. Каждую эпоху модель обрабатывает каждое изображение этой выборки, вычисляет градиенты и обновляет веса. Эти изображения модель видит снова и снова.
- Валидационная выборка — после каждой эпохи модель делает прямой проход по этим изображениям, чтобы посчитать потери и точность, но эти потери нужны только для отчётности: ни обратного распространения, ни вычисления градиентов, ни обновления весов. Это чистое измерение. Оно говорит нам, насколько хорошо модель обобщает: если она даёт 98% на обучающих данных, но только 90% на валидационных, значит, она запоминает обучающие изображения, а не учит общие закономерности. Если валидационная точность перестала расти, а обучающая продолжает лезть вверх, модель переобучается.
- Тестовая выборка — полностью отдельный набор, оцениваемый однократно в самом конце, когда все решения об обучении уже приняты. Это истинная мера того, насколько хорошо модель обобщает на изображения, которых она никогда не видела.
В терминах нашего 4-шагового цикла обучения:
| Обучающая | Валидационная | Тестовая | |
|---|---|---|---|
| 1. Прямой проход | да | да | да |
| 2. Вычисление потерь | да | да (только для отчётности) | да (только для отчётности) |
| 3. Обратное распространение | да | нет | нет |
| 4. Обновление весов | да | нет | нет |
Для обучающих изображений выполняются все 4 шага — модель учится. Для валидационных и тестовых выполняются только шаги 1 и 2: модель измеряет, как у неё дела, но не меняет веса на основе увиденного.
Почему бы не использовать тестовую выборку для обеих задач? Потому что каждый раз, когда вы проверяете качество на наборе данных и принимаете на этом основании решение (например, «продолжать ли обучение?»), вы исподволь подгоняетесь под эти данные. Если использовать тестовую выборку, чтобы решить, когда остановить обучение, тестовая точность станет оптимистически смещённой. Валидационная выборка принимает это смещение на себя, оставляя тестовую честной.
Валидационная выборка также используется для настройки гиперпараметров — таких решений, как скорость обучения, размер батча, число слоёв и число эпох. Вы пробуете разные настройки, сравниваете валидационную точность и выбираете лучшую. Тестовая выборка на всё это время остаётся запечатанной, чтобы дать несмещённую итоговую оценку.
На практике валидационную выборку можно получить, отделив часть обучающих данных. Keras упрощает это с помощью validation_split:
history = model.fit(
X_train, y_train,
epochs=5,
batch_size=32,
validation_split=0.2, # use 20% of training data as validation
)Или можно разделить вручную и передать отдельный набор через validation_data=(X_val, y_val). Мы используем validation_split=0.2 — Keras отложит 20% обучающих данных (12 000 изображений) на валидацию и будет обучаться на оставшихся 48 000. Все 10 000 тестовых изображений остаются полностью в стороне для итоговой оценки.
Обучение модели
Теперь мы наконец готовы запустить обучение. Сначала загрузим и подготовим датасет — MNIST настолько стандартен, что Keras включает его как встроенный:
import keras
import numpy as np
(train_images, train_labels), (test_images, test_labels) = keras.datasets.mnist.load_data()
# Flatten 28×28 → 784 and normalize to [0, 1]
X_train = train_images.reshape(-1, 784).astype("float32") / 255.0
X_test = test_images.reshape(-1, 784).astype("float32") / 255.0
y_train = train_labels
y_test = test_labels
print(f"Training: {X_train.shape[0]} images, {X_train.shape[1]} pixels each")
print(f"Test: {X_test.shape[0]} images")
# Training: 60000 images, 784 pixels each
# Test: 10000 imagesТеперь у нас 60 000 обучающих изображений (каждое — вектор из 784 значений между 0 и 1) и их метки (каждая 0–9). Тестовая выборка отдельно — мы используем её только для проверки того, насколько хорошо модель обобщает на цифры, которых она не видела во время обучения.
Обучим нашу сеть:
# Split: 80% train, 20% validation (same as Keras validation_split=0.2)
n_val = int(0.2 * len(X_train))
X_val, y_val = X_train[:n_val], y_train[:n_val]
X_train_sub, y_train_sub = X_train[n_val:], y_train[n_val:]
layer1 = HiddenLayer(784, 128)
output = OutputLayer(128, 10)
history = train([layer1], output, X_train_sub, y_train_sub, X_val, y_val,
epochs=5, lr=0.1, batch_size=32)
# Epoch 1/5 — loss: 0.3260, train acc: 90.70%, val acc: 94.58%
# Epoch 2/5 — loss: 0.1608, train acc: 95.30%, val acc: 95.97%
# Epoch 3/5 — loss: 0.1147, train acc: 96.64%, val acc: 96.49%
# Epoch 4/5 — loss: 0.0898, train acc: 97.45%, val acc: 96.94%
# Epoch 5/5 — loss: 0.0737, train acc: 97.87%, val acc: 97.27%Вот как выглядит обучение за 5 эпох: левый график показывает падение потерь по мере обучения модели, правый — рост точности. Зелёные точки — валидационная точность (val_accuracy), измеренная в конце каждой эпохи на отложенных изображениях, на которых модель никогда не обучается:
~97% валидационной точности после 5 эпох — сеть верно классифицирует почти все отложенные изображения, на которых она не обучалась. Неплохо для простой двухслойной сети всего за 5 эпох обучения.
Вы можете заметить, что уже первая эпоха показывает ~90% точности. Это потому, что точность измеряется в конце эпохи — к этому моменту модель уже обработала все 1500 батчей (48 000 изображений) и сделала 1500 обновлений весов. Если бы мы отслеживали точность по батчам, вы бы увидели, что она стартует около 10% (случайное угадывание для 10 цифр) и быстро растёт в течение первой эпохи.
Есть одна любопытная деталь, на которую стоит обратить внимание. Присмотритесь к зазору между синей (обучение) и зелёной (валидация) линиями, особенно на графике потерь. Обучающие потери продолжают ровно падать, а валидационные выполаживаются около 4–5 эпохи и даже начинают ползти вверх. Это начало переобучения: модель начинает запоминать обучающие данные, а не учить закономерности, обобщающиеся на новые изображения. После 5 эпох зазор невелик, но если продолжать обучение 20+ эпох, обучающая точность подберётся к 100%, а валидационная встанет или начнёт падать. Именно поэтому нам и нужна валидационная выборка — она подсказывает, когда остановиться. Переобучение мы подробно разберём в следующей статье.
Попробуйте сами
У нас есть сопроводительный ноутбук, где можно запустить реализации на Keras и на NumPy бок о бок. Все ячейки выполняются автоматически — вы увидите сначала вывод обучения на Keras, затем вывод обучения на NumPy с теми же метриками.
Вывод Keras выглядит так:

Вот что означает каждая часть:
1500/1500— завершено 1500 мини-батчей из 1500 (48 000 обучающих изображений / 32 на батч). Пока обучение идёт, вы видите, как счётчик растёт (например,18/1500означает, что сделано 18 батчей).5s 4ms/step— эпоха заняла 5 секунд, ~4 миллисекунды на батчaccuracy: 0.9529иloss: 0.1631— метрики обучения, усреднённые по всем батчам эпохиval_accuracy: 0.9585иval_loss: 0.1434— валидационные метрики, вычисленные один раз в конце эпохи на отложенных 12 000 изображениях
Числа у Keras и NumPy не будут идентичными (разная случайная инициализация весов), но должны быть близкими — обе около ~97% валидационной точности после 5 эпох. Это подтверждает, что наш код на NumPy с нуля реализует тот же алгоритм, что и Keras.
Что мы разобрали
Мы взяли каждое понятие из предыдущей статьи и применили его к реальной задаче — классификации рукописных цифр с более чем 100 000 параметров вместо подгонки прямой с двумя:
- Классификация против регрессии: softmax превращает сырые логиты в вероятности, кросс-энтропия измеряет, насколько эти вероятности неверны
- Прямой проход: вход течёт через слои, каждый вычисляет — та же формула, теперь в матричном виде
- Обратное распространение: градиент softmax + кросс-энтропии упрощается до , а цепное правило течёт назад по слоям ровно так, как мы вывели вручную
- Цикл обучения: перемешать, разбить на мини-батчи, прямой проход → потери → обратное распространение → обновление, повторяемое для каждого батча каждой эпохи
Алгоритм идентичен тому, что мы построили в предыдущей статье. Keras его автоматизирует, но под капотом это то же вычисление, которое наш код на NumPy выполняет шаг за шагом.
Но мы всё время использовали настройки по умолчанию — один скрытый слой из 128 нейронов, скорость обучения 0.1, размер батча 32. Что будет, если их изменить? В следующей статье мы систематически поварьируем каждый элемент — скорость обучения, размер батча, глубину сети, функции активации — и разберём переобучение, раннюю остановку и то, как диагностировать, когда обучение идёт не так.