Как гиперпараметры влияют на обучение
В предыдущей статье мы обучили нейросеть на датасете MNIST, взяв каждое понятие из теоретической статьи (softmax, перекрёстная энтропия, обратное распространение ошибки, градиентный спуск) и применив его к классификации рукописных цифр. Мы построили сеть с нуля на NumPy, повторили её в Keras и достигли 97% точности на тесте с простой двухслойной архитектурой и настройками по умолчанию: один скрытый слой из 128 нейронов, скорость обучения 0.1, размер батча 32.
Каждый из этих выборов — скорость обучения, размер батча, количество слоёв, функция активации — называется гиперпараметром: это настройки, которые задаёте вы, а не выучивает модель. Цель этой статьи — показать, насколько сильно эти выборы влияют на результат (неверная скорость обучения может стать разницей между 97% точности и полным провалом), и дать общие практические рекомендации по каждому из них. Но эти рекомендации — лишь отправные точки, а не готовые ответы. Правильные гиперпараметры находятся экспериментально: меняем одно, смотрим на эффект, корректируем — и зависят они от вашей конкретной модели, датасета и задачи.
У каждого эксперимента ниже есть интерактивный график. Можно кликать по подписям, чтобы показать или скрыть отдельные запуски, переключаться между режимами потерь и точности, включать «show baseline», чтобы добавить исходное измерение со случайными весами (эпоха 0), и разворачивать раздел «Computation log», чтобы увидеть сырой вывод обучения. По умолчанию графики показывают метрики обучения — наш анализ строится на них, — но можно нажать «val», чтобы наложить сверху валидационные метрики для сравнения. Блоки кода со значком marimo (появляется при наведении) ведут в интерактивный ноутбук, где вы можете сами запустить эксперимент и изменить код.
Мы будем использовать тот же датасет и ту же конфигурацию модели, что и в предыдущей статье:
import keras
import numpy as np
# Обучающая выборка: 60000 изображений, тестовая: 10000 изображений
(train_images, train_labels), (test_images, test_labels) = keras.datasets.mnist.load_data()
# Разворачиваем 28×28 → 784 и нормализуем к диапазону [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Скорость обучения
Когда мы разбирали, как обучаются сети, мы видели, как скорость обучения задаёт размер шага градиентного спуска. Слишком маленькая — и обучение идёт очень медленно; слишком большая — и шаг перелетает минимум, из-за чего сходиться к низким потерям становится труднее. Мы показали это на модели с двумя параметрами и интерактивным виджетом. Теперь давайте увидим ровно то же явление на настоящей сети с более чем 100 000 параметров.
Обучим одну и ту же модель четыре раза, меняя только скорость обучения:
for lr in [0.001, 0.01, 0.1, 1.0, 10.0]:
model = keras.Sequential([
keras.layers.Dense(128, activation="relu", input_shape=(784,)),
keras.layers.Dense(10, activation="softmax"),
])
model.compile(
optimizer=keras.optimizers.SGD(learning_rate=lr),
loss="sparse_categorical_crossentropy",
metrics=["accuracy"],
)
model.fit(X_train, y_train, epochs=5, batch_size=32,
validation_split=0.2, verbose=2)Посмотрим, как меняются потери по эпохам для каждой скорости обучения:
Computation log
Первое, что бросается в глаза при включённом «show baseline», — все пять запусков стартуют из одной точки: случайная сеть с потерями около 2.3. Это логично: при 10 классах и случайных весах модель присваивает примерно равную вероятность каждому классу — около 0.1 на класс. Перекрёстная энтропия для правильного класса с вероятностью 0.1 равна . Это и есть базовый уровень — потери модели, которая ничему не научилась.
Дальше посмотрите на lr = 10.0 — полный провал. На первой же эпохе потери взлетают до 12.2: обновления весов перелетают настолько далеко, что веса «взрываются», а затем застревают около 2.5 на все оставшиеся эпохи. Модель выдаёт одинаковое предсказание для любого входа — точность на обучении около 10% (случайное угадывание) и не растёт. Вот что происходит, когда скорость обучения настолько велика, что градиентный спуск вообще не может продвинуться.
Отключим lr=10.0, кликнув по его подписи, — его скачок до 12.2 сжимает ось Y и мешает разглядеть детали остальных кривых. Без него оставшиеся четыре запуска рассказывают более понятную историю:
- lr = 0.001 — потери падают, но мучительно медленно. После 10 эпох они всё ещё на уровне 0.41 — выше, чем у lr=0.1 уже после одной эпохи (0.33). Градиенты указывают в правильном направлении, но каждый шаг настолько крошечный, что модель почти не сдвигается. Ей понадобилось бы гораздо больше эпох, чтобы догнать остальных.
- lr = 0.01 — стабильный прогресс. Потери плавно снижаются с 2.3 до 0.18 к десятой эпохе, и кривая всё ещё идёт вниз: модель явно продолжает улучшаться. При большем числе эпох она достигла бы тех же потерь, что и lr=0.1. Меньшие шаги не ограничивают то, куда вы придёте, — они лишь удлиняют дорогу.
- lr = 0.1 — золотая середина для этой сети. Потери резко падают до 0.03 к десятой эпохе, и кривая выполаживается по мере приближения к минимуму. Большая часть обучения происходит за первые 2–3 эпохи.
- lr = 1.0 — потери сначала падают быстро, но не могут устояться. Они скачут — 0.20, затем 0.25, потом 0.21, потом 0.24 — вместо плавного снижения. Переключитесь на график точности, и вы увидите, что валидационная точность в последние эпохи даже падает. Перелёт не мешает обучению как таковому, но мешает модели донастроиться до хорошего минимума.
На практике подбор скорости обучения — процесс более тонкий, чем просто выбор числа: он связан с выбором оптимизатора, и обычный SGD с вручную подобранной скоростью сегодня почти не используют. Адаптивные оптимизаторы вроде Adam подстраивают размер шага для каждого параметра и поддерживают момент, из-за чего они куда менее чувствительны к начальному выбору скорости обучения. Как шум SGD помогает выбираться из локальных минимумов, как работает адаптивное масштабирование в Adam и почему расписания скорости обучения и разогрев стали стандартом современного обучения — с интерактивными демонстрациями — мы разбираем в следующей статье.
Размер батча
Когда мы разбирали, как учатся нейросети, мы сравнивали мини-батчи по 2 примера с полнопакетным градиентным спуском на 5 точках данных. Версия с мини-батчами была шумнее (потери зигзагом), но сходилась при меньшем общем объёме вычислений. Теперь посмотрим, как размер батча влияет на обучение на настоящем датасете: обучим ту же модель с размерами батча 1, 32, 256 и 60 000 — то есть на всей обучающей выборке разом:
for bs in [1, 32, 256, 60000]:
model = keras.Sequential([
keras.layers.Dense(128, activation="relu", input_shape=(784,)),
keras.layers.Dense(10, activation="softmax"),
])
model.compile(
optimizer=keras.optimizers.SGD(learning_rate=0.1),
loss="sparse_categorical_crossentropy",
metrics=["accuracy"],
)
model.fit(X_train, y_train, epochs=5, batch_size=bs,
validation_split=0.2, verbose=2)Посмотрим, как меняются потери по эпохам для каждого размера батча:
Computation log
Золотая середина — bs=32 и bs=256 — работает хорошо в обоих случаях, причём bs=32 сходится быстрее:
- bs = 32 — потери резко падают с 2.4 до 0.03 к десятой эпохе. При 1500 батчах на эпоху модель получает частые обновления с градиентами, которые шумны, но в целом верны.
- bs = 256 — медленнее, но стабильно. Потери снижаются до 0.17 после 10 эпох, и кривая всё ещё идёт вниз. В каждой эпохе всего 188 батчей (против 1500 при bs=32), то есть обновлений меньше, но каждое опирается на более надёжное усреднение градиента.
Две крайности рассказывают куда более интересную историю:
- bs = 1 — неожиданно плохо при lr=0.1. Потери почти не снижаются и дико скачут от эпохи к эпохе, потому что каждое обновление опирается на одно-единственное изображение, так что градиент отражает особенности именно этого примера, а не датасета в целом. При bs=32 эти индивидуальные различия усредняются в разумную оценку. При bs=1 каждое обновление тянет в случайную сторону, а lr=0.1 делает каждый такой случайный шаг достаточно большим, чтобы свести на нет предыдущий прогресс.
- bs = 60000 — полный батч. Потери снижаются мучительно медленно: с 2.4 до 1.6 после 10 эпох — намного хуже, чем 0.03 у bs=32 за тот же период. Это может показаться нелогичным: разве идеальный градиент, посчитанный по всем изображениям, не лучше шумной оценки по 32? По направлению — да. Но важно и то, какое суммарное расстояние модель проходит по ландшафту потерь: bs=32 делает 15 000 шагов за 10 эпох, тогда как bs=60000 делает всего 1000 шагов даже за 1000 эпох. При одном обновлении на эпоху 10 эпох означают всего 10 градиентных шагов. Идеальный градиент не может компенсировать столь малое число возможностей обновиться.
В наших экспериментах лучший результат дал размер батча 32 — и это вообще хорошее значение по умолчанию. Он уравновешивает качество градиента и частоту обновлений. На практике размер батча ограничен ещё и памятью GPU, поскольку большим батчам нужно больше памяти, чтобы одновременно держать активации и градиенты всех примеров. Если на GPU есть запас, попробуйте 128 или 256 — обучение станет быстрее в пересчёте на эпоху (обновлений меньше, но каждое обрабатывает больше данных параллельно), хотя, возможно, придётся увеличить скорость обучения для компенсации. Размер батча 1 применяется редко: он слишком шумный и не позволяет использовать параллелизм GPU. Очень большие батчи (тысячи и больше) требуют аккуратной настройки скорости обучения и применяются в основном в распределённом обучении на нескольких GPU.
Отдельно стоит отметить, что размер батча и скорость обучения нужно подбирать вместе. Наша lr=0.1 подобрана под bs=32 — она слишком велика для bs=1 (отсюда колебания) и, пожалуй, слишком мала для bs=60000 (которому пригодился бы шаг побольше). Переключите bs=1 на lr=0.001, и он дойдёт до потерь 0.09 — почти как bs=32. Шум не страшен, когда каждый шаг достаточно мал, чтобы ни один плохой градиент не наделал бед. Большим батчам нужны большие скорости обучения, чтобы компенсировать меньшее число обновлений за эпоху. Оценивать размер батча в отрыве от скорости обучения нельзя.
Глубина и ширина сети
В нашей базовой сети один скрытый слой из 128 нейронов. Что будет, если сделать её шире, глубже или и то и другое? Давайте выясним: обучим пять вариантов, оставив всё остальное неизменным (lr=0.1, bs=32, 10 эпох):
configs = {
"narrow (32)": [32],
"baseline (128)": [128],
"wide (512)": [512],
"deep (2×128)": [128, 128],
"deep (3×128)": [128, 128, 128],
}
for name, hidden_sizes in configs.items():
model = keras.Sequential()
model.add(keras.layers.Dense(hidden_sizes[0], activation="relu", input_shape=(784,)))
for size in hidden_sizes[1:]:
model.add(keras.layers.Dense(size, activation="relu"))
model.add(keras.layers.Dense(10, activation="softmax"))
model.compile(
optimizer=keras.optimizers.SGD(learning_rate=0.1),
loss="sparse_categorical_crossentropy",
metrics=["accuracy"],
)
model.fit(X_train, y_train, epochs=10, batch_size=32,
validation_split=0.2, verbose=2)Посмотрим, как меняются потери для каждой архитектуры:
Computation log
Бросается в глаза несколько вещей:
- Ширина помогает: «ширина» здесь означает число нейронов в одном скрытом слое — переход от 32 к 128 и затем к 512 нейронам последовательно снижает потери. У более широкого слоя больше параметров для распознавания закономерностей. Но у 512 в 4 раза больше параметров, чем у 128, при лишь небольшом выигрыше в потерях — убывающая отдача.
- Глубина тоже помогает: добавление второго скрытого слоя (2×128) даёт более низкие потери, чем однослойная базовая сеть, хотя общее число параметров примерно то же. Более глубокие сети могут выучивать иерархические признаки: первый слой может распознавать края, второй — складывать края в формы.
- Больше глубины — больше риска: при 3 слоях итоговые потери совпадают с базовыми, а валидационные потери заметно шумнее. Глубокие сети труднее обучать: градиентам нужно проходить через большее число слоёв, и начинает сказываться проблема затухающего градиента.
В целом для полносвязных сетей начинайте с простого: одного-двух скрытых слоёв часто достаточно. Добавляйте глубину, только если потери более простой модели выходят на плато. По ширине разумный диапазон для полносвязных сетей — 128–512 нейронов на слой. Настоящий выигрыш в архитектуре даёт использование подходящего типа слоя под ваши данные: свёрточные слои для изображений, рекуррентные слои или трансформеры для последовательностей. Эти специализированные архитектуры куда экономнее по параметрам, чем полносвязные слои, которые мы здесь используем: свёрточная сеть может достичь тех же потерь, что и наша модель на 512 нейронов, с долей от её параметров.
Функции активации: sigmoid против ReLU
Функций активации на выбор много — ReLU, sigmoid, tanh, Leaky ReLU, GELU и другие. Здесь мы сосредоточимся на двух, потому что теоретическая статья сделала о них конкретное предсказание: sigmoid должна испытывать трудности в глубоких сетях, потому что её производная всегда меньше 1 (сжимая градиенты по мере их прохождения назад через слои), а ReLU — нет (её производная равна 0 или 1, так что градиенты проходят без изменений). Проверим это предсказание.
В Keras смена функции активации — это просто смена строки: "relu" против "sigmoid". Обучим сети с 1, 3 и 5 скрытыми слоями, чтобы увидеть, как глубина взаимодействует с выбором активации:
for n_layers in [1, 3, 5]:
for activation in ["relu", "sigmoid"]:
model = keras.Sequential()
model.add(keras.layers.Dense(128, activation=activation, input_shape=(784,)))
for _ in range(n_layers - 1):
model.add(keras.layers.Dense(128, activation=activation))
model.add(keras.layers.Dense(10, activation="softmax"))
model.compile(
optimizer=keras.optimizers.SGD(learning_rate=0.1),
loss="sparse_categorical_crossentropy",
metrics=["accuracy"],
)
model.fit(X_train, y_train, epochs=5, batch_size=32,
validation_split=0.2, verbose=2)Посмотрим, как меняются потери для каждого сочетания глубины и функции активации:
Computation log
Результаты подтверждают предсказание из теоретической статьи — причём эффект даже драматичнее, чем ожидалось:
- 1 скрытый слой — обе активации работают хорошо. Потери ReLU падают до 0.03 к десятой эпохе, sigmoid — до 0.15. При одном слое градиент проходит через одну активацию, поэтому сжатие градиента у sigmoid почти не сказывается.
- 3 скрытых слоя — sigmoid начинает отставать. Потери ReLU доходят до 0.016, а sigmoid — лишь до 0.14, почти в 10 раз выше. Sigmoid заметно медленнее в первые эпохи: её потери на третьей эпохе всё ещё выше, чем у ReLU после первой.
- 5 скрытых слоёв — sigmoid полностью проваливается. Потери почти не сдвигаются со стартовых ~2.3 за все 10 эпох: модель фактически ничему не научилась. При этом ReLU с 5 слоями доходит до потерь 0.024 — практически как с 1 и 3 слоями.
Это проблема затухающего градиента в действии. Производная sigmoid всегда меньше 1 (максимум 0.25 при ), поэтому после 5 слоёв перемножения градиент, доходящий до первого слоя, составляет от исходного сигнала. Первые слои не могут учиться, потому что градиент слишком мал, чтобы сдвинуть веса.
У ReLU этой проблемы нет: её производная равна 0 либо 1, так что градиенты проходят без изменений (для активных нейронов). Именно поэтому все три сети на ReLU показывают почти одинаковый результат независимо от глубины.
Сегодня ReLU — активация по умолчанию для большинства полносвязных и свёрточных сетей. Если вы столкнётесь с проблемой «умирающего ReLU» (нейроны выдают ноль на любых входах и перестают учиться), попробуйте Leaky ReLU или ELU — варианты, допускающие небольшой градиент при отрицательном входе. Для архитектур-трансформеров стандартом стала GELU. Sigmoid и tanh всё ещё применяются в конкретных местах: sigmoid — для бинарных выходов (например, последний слой бинарного классификатора), tanh — в механизмах гейтов в LSTM, — но не как универсальные активации скрытых слоёв.
Количество эпох
Во всех экспериментах выше мы обучали по 10 эпох. Но сколько эпох нужно на самом деле? Давайте выясним: обучим нашу лучшую конфигурацию (lr=0.1, размер батча 32) в течение 50 эпох и посмотрим, что произойдёт:
model = keras.Sequential([
keras.layers.Dense(128, activation="relu", input_shape=(784,)),
keras.layers.Dense(10, activation="softmax"),
])
model.compile(
optimizer=keras.optimizers.SGD(learning_rate=0.1),
loss="sparse_categorical_crossentropy",
metrics=["accuracy"],
)
history = model.fit(X_train, y_train, epochs=50, batch_size=32,
validation_split=0.2, verbose=2)Потери на обучении продолжают падать все 50 эпох — с 0.33 на первой эпохе до 0.0015 на пятидесятой. Точность на обучении достигает 100% к двадцать девятой эпохе. Модель идеально запомнила каждое обучающее изображение.
Но валидационные потери рассказывают другую историю: они улучшаются с 0.19 на первой эпохе до 0.075 примерно к тринадцатой, затем перестают улучшаться и начинают медленно расти — 0.078 на двадцатой, 0.081 на тридцатой, 0.085 на пятидесятой. При этом валидационная точность выходит на плато около 98% начиная с тринадцатой эпохи и почти не меняется оставшиеся 37 эпох.
Это переобучение — модель начала запоминать обучающие данные вместо того, чтобы выучивать обобщающиеся закономерности. После определённого момента дальнейшее обучение делает модель хуже в предсказаниях на новых данных, хотя на уже виденных она становится лучше.
Количество эпох — такой же гиперпараметр, как и остальные, но у него есть особенность: слишком мало — и модель недоучилась (недообучение), слишком много — и она запомнила обучающие данные (переобучение). В отличие от скорости обучения или размера батча, где есть золотая середина, которую можно найти и зафиксировать, правильное число эпох зависит от модели, данных и всех остальных ваших гиперпараметров.
Вместо того чтобы угадывать нужное число эпох, обычно используют раннюю остановку — колбэк Keras, который следит за валидационными потерями и автоматически прекращает обучение, когда они перестают улучшаться:
early_stop = keras.callbacks.EarlyStopping(
monitor="val_loss",
patience=5,
restore_best_weights=True,
)
model.fit(X_train, y_train, epochs=100, batch_size=32,
validation_split=0.2,
callbacks=[early_stop])Задайте щедрую верхнюю границу эпох (скажем, 100) и позвольте ранней остановке найти нужную точку. Параметр patience=5 означает, что обучение прекратится, если валидационные потери не улучшались 5 эпох подряд, а restore_best_weights=True откатит модель к эпохе с лучшими валидационными потерями. Так вам вообще не нужно подбирать число эпох — данные сами подскажут, когда остановиться.
Переобучение и раннюю остановку мы разберём подробнее в следующих статьях.
Автоматизированный поиск гиперпараметров
В этой статье мы подбирали гиперпараметры вручную — меняя по одному и наблюдая эффект. Это развивает интуицию, но плохо масштабируется. Когда у вас десятки гиперпараметров и тысячи возможных комбинаций, нужна стратегия поиска и инструмент, который её автоматизирует.
Стоит знать те несколько стратегий, которые такие инструменты реализуют, потому что все они делают компромисс по одной и той же оси: сколько конфигураций вы можете позволить себе оценить. Каждая из них — свой ответ на вопрос «раз каждый запуск стоит времени и вычислений, куда потратить следующий?»
- Поиск по сетке (grid search). Выбираем по несколько значений на гиперпараметр и перебираем все комбинации. Просто и исчерпывающе, но стоимость равна произведению количеств вариантов: три гиперпараметра по пять значений — уже 125 запусков, и дальше растёт лавинообразно (тот же комбинаторный взрыв, из-за которого многомерные пространства так неудобны). Годится, когда запуск дёшев, а ручек всего две-три.
- Случайный поиск (random search). Сэмплируем случайные комбинации вместо решётки. Как ни странно, при том же бюджете это обычно выигрывает у поиска по сетке: как правило, по-настоящему важны лишь несколько гиперпараметров, и случайное сэмплирование пробует больше различных значений вдоль этих важных измерений, не тратя запуски на жёсткую решётку (стандартная ссылка — Bergstra & Bengio, 2012).
- Покоординатный спуск, он же восхождение к вершине. Это автоматизированная форма ровно того, что мы делали вручную всю статью: начать со значений по умолчанию, менять по одному гиперпараметру за раз, оставлять изменение, если результат улучшился, и повторять, пока ничего не помогает. Это дёшево — полную сетку вы никогда не перебираете, — и, в отличие от однократной изолированной настройки каждой ручки, метод возвращается назад, поэтому может уловить, что лучший размер батча зависит от той скорости обучения, на которой вы в итоге остановились. Его слабость классическая для любого жадного локального поиска: он может застрять в локальном оптимуме — конфигурации, где ни одно одиночное изменение не помогает, хотя какое-то совместное изменение помогло бы.
- Байесовская оптимизация. Строим вероятностную модель «конфигурация → результат» по уже сделанным запускам, а затем используем её, чтобы выбрать самую перспективную следующую конфигурацию, балансируя между исследованием неизвестных областей и эксплуатацией хороших. Это самый экономный по числу проб вариант — поэтому он и стоит по умолчанию, когда каждый запуск действительно дорог.
Выбор сводится к стоимости одной оценки. Если запуск занимает секунды, подойдёт поиск по сетке или случайный — просто попробуйте побольше вариантов. Если каждый запуск — это полноценное обучение (или того хуже), вы переходите к методам, которые думают, прежде чем тратить: покоординатный спуск для нескольких ручек, байесовская оптимизация для многих. Хороший пример не из области обучения моделей: в статье AgentDiet подбирали четыре гиперпараметра, где каждая оценка означала прогон LLM-агента по 100 задачам — для сетки это несоизмеримо дорого, — поэтому авторы использовали обычный покоординатный спуск («поменяй одну ручку, оставь улучшения, повтори») и сошлись за два прохода.
Weights & Biases Sweeps и Optuna реализуют всё перечисленное — поиск по сетке, случайный и байесовский, — автоматически отслеживая каждый запуск и сравнивая результаты, а Keras Tuner предлагает то же самое прямо внутри Keras. Они становятся ценными ровно тогда, когда ручная настройка по одному параметру упирается в потолок.