Как гиперпараметры влияют на обучение

В предыдущей статье мы построили нейросеть на NumPy, повторили её в Keras и получили около 97% валидационной точности на MNIST. Мы использовали один скрытый слой из 128 нейронов, скорость обучения 0.1 и размер батча 32.

Эти настройки называются гиперпараметрами: их выбираем мы, а не выучивает модель. Будем менять их по одному и наблюдать влияние на обучение. Результаты послужат отправной точкой для этой модели и датасета; другим задачам могут понадобиться другие настройки.

У каждого эксперимента ниже есть интерактивный график. Можно кликать по подписям, чтобы показать или скрыть отдельные запуски, переключаться между режимами потерь и точности, включать «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=10, batch_size=32,
              validation_split=0.2, verbose=2)

Посмотрим, как меняются потери по эпохам для каждой скорости обучения:

Loss by Learning Rate
Computation log

Включите «show baseline», чтобы увидеть измерения до обучения. Начальные потери близки к 2.3, с различиями из-за случайной инициализации. Вероятность 0.1 для каждого из десяти классов дала бы −log⁡(0.1)≈2.3-\log(0.1) \approx 2.3, но случайные веса не гарантируют строго равномерного распределения.

При lr = 10.0 обучение в этом запуске не удаётся. Потери подскакивают до 12.2 на первой эпохе, затем остаются около 2.5, а точность — около 10%. Модель не учится различать цифры, но одни эти метрики не доказывают, что она выдаёт одинаковый ответ для каждого изображения.

Отключим lr=10.0, кликнув по его подписи, — его скачок до 12.2 сжимает ось Y и мешает разглядеть детали остальных кривых. Без этой кривой четыре остальных запуска проще сравнить:

  • lr = 0.001 — после десяти эпох потери равны 0.41, выше 0.33 у lr=0.1 уже после одной эпохи. При таком шаге обучение идёт медленно.
  • lr = 0.01 — потери достигают 0.18 и продолжают снижаться. Более долгое обучение может помочь, но не обязано привести к тому же решению, что и lr=0.1.
  • lr = 0.1 — самые низкие итоговые обучающие потери среди этих запусков, около 0.03. Основное начальное улучшение происходит за первые две-три эпохи.
  • lr = 1.0 — обучающие потери достигают примерно 0.15, а валидационные в последних эпохах колеблются около 0.20–0.24. Включите валидационные кривые: точность на валидации к концу также снижается.

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

Размер батча

Сравним размеры батча 1, 32, 256 и 60 000. После выделения 20% данных на валидацию для обучения остаётся 48 000 изображений, поэтому batch_size=60000 объединяет все эти изображения в один батч.

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=10, batch_size=bs,
              validation_split=0.2, verbose=2)

Посмотрим, как меняются потери по эпохам для каждого размера батча:

Loss by Batch Size
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 = 60000 — потери падают примерно с 2.4 до 1.6 за десять эпох. Одна эпоха даёт только одно обновление, то есть за десять эпох — десять шагов против 15 000 при bs=32. При этой скорости обучения полный градиент не компенсирует столь малое число обновлений.

Размер батча 32 хорошо сработал в этом эксперименте. Большие батчи требуют больше памяти для активаций и могут повысить пропускную способность вычислений, но короткая эпоха не обязательно означает меньше времени до той же валидационной точности. Сравнивайте и время обучения, и качество на валидации.

Подбирайте размер батча и скорость обучения вместе. При bs=1 уменьшение lr с 0.1 до 0.001 позволяет снизить потери до 0.09. Большим батчам может помочь большая скорость обучения, но универсального правила масштабирования здесь нет.

Глубина и ширина сети

В нашей базовой сети один скрытый слой из 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)

Посмотрим, как меняются потери для каждой архитектуры:

Loss by Architecture
Computation log

Бросается в глаза несколько вещей:

  • Ширина помогает: «ширина» здесь означает число нейронов в одном скрытом слое — переход от 32 к 128 и затем к 512 нейронам последовательно снижает потери. У более широкого слоя больше параметров для распознавания закономерностей. Но у 512 в 4 раза больше параметров, чем у 128, при лишь небольшом выигрыше в потерях — убывающая отдача.
  • Глубина тоже помогает: добавление второго скрытого слоя (2×128) даёт более низкие потери, чем однослойная базовая сеть, хотя общее число параметров примерно то же. Более глубокие сети могут выучивать иерархические признаки: первый слой может распознавать края, второй — складывать края в формы.
  • Третий слой не улучшил результат в этом запуске: итоговые потери близки к базовым, а валидационные потери колеблются сильнее. Сами кривые не объясняют причину и не доказывают затухания градиентов.

Для этой задачи MNIST один-два скрытых слоя шириной 128–512 — полезная отправная точка. Прежде чем увеличивать модель, сравните качество на валидации. Важен и тип слоя: свёрточные слои используют пространственную структуру изображений, которую наша полносвязная модель игнорирует.

Функции активации: sigmoid против ReLU

Сравним сигмоиду и ReLU, чтобы проверить поведение градиентов, обсуждавшееся в теоретической статье. Сигмоида на каждом слое даёт производную не больше 0.25; ReLU — 1 для активных нейронов и 0 для неактивных. Эти множители влияют на распространение градиентов в глубокой сети.

В 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=10, batch_size=32,
                  validation_split=0.2, verbose=2)

Посмотрим, как меняются потери для каждого сочетания глубины и функции активации:

Loss by Activation Function
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 слоями.

Эти результаты согласуются с затуханием градиента. В пяти слоях с сигмоидой произведение одних лишь производных активаций не превышает 0.255≈0.0010.25^5 \approx 0.001. На полный градиент влияют и матрицы весов, поэтому это не точная оценка градиента, доходящего до первого слоя.

ReLU не уменьшает градиент на этапе активации для активных нейронов, что помогает объяснить эти результаты. Но это не гарантирует стабильности градиентов во всей сети.

ReLU подходит как отправная точка для этой модели. Leaky ReLU и ELU допускают ненулевые градиенты при отрицательных входах и могут помочь, когда нейроны остаются неактивными. В других архитектурах применяются другие активации, например GELU в некоторых трансформерах. В стандартном LSTM сигмоида управляет гейтами, а tanh используется для значений-кандидатов и преобразования состояния ячейки на выходе.

Количество эпох

Во всех экспериментах выше мы обучали по 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)

Обучающие потери падают с 0.33 на первой эпохе до 0.0015 на пятидесятой. Сообщаемая обучающая точность достигает 100% к эпохе 29. Это отличное соответствие обучающим данным, но само по себе оно не показывает качество обобщения.

Валидационные потери снижаются с 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 обучение остановится после пяти эпох подряд без улучшения валидационных потерь. restore_best_weights=True восстановит веса лучшей валидационной эпохи. Вы по-прежнему выбираете метрику, число эпох ожидания улучшения и максимальный бюджет обучения.

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

Автоматизированный поиск гиперпараметров

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

Стратегии поиска различаются тем, как выбирают следующую конфигурацию и расходуют доступный бюджет оценок:

  • Поиск по сетке (grid search). Выберите конечный набор значений для каждого гиперпараметра и проверьте все комбинации. Для трёх параметров с пятью значениями потребуется 125 запусков. Это удобно при небольшом пространстве поиска и недорогих оценках.
  • Случайный поиск (random search). Выбирайте случайные комбинации из заданных диапазонов или распределений. Если лишь несколько параметров сильно влияют на результат, при том же бюджете можно проверить больше разных значений этих параметров, чем при поиске по сетке. См. Bergstra & Bengio, 2012.
  • Покоординатный спуск. Начните с одной конфигурации, меняйте по одному параметру и сохраняйте улучшения. Повторяйте процесс, чтобы пересматривать прежние решения после изменения других параметров. Полный перебор сетки не нужен, но метод может остановиться там, где ни одно отдельное изменение не помогает, хотя совместное изменение нескольких параметров улучшило бы результат.
  • Байесовская оптимизация. Постройте вероятностную модель по предыдущим конфигурациям и результатам, затем выберите следующую оценку, сочетая исследование новых областей с использованием перспективных результатов. Это может сократить число дорогих оценок, но эффективность зависит от пространства поиска и модели.

Выбирайте стратегию с учётом стоимости оценки, пространства поиска и доступного бюджета. Случайный поиск даёт полезную отправную точку; при дорогих оценках можно использовать локальный поиск или методы на основе модели. Например, в работе об AgentDiet подбирали четыре гиперпараметра на 100 задачах для агента, меняя по одному параметру за раз. После первого раунда авторы изменили два значения, а во втором дальнейшего улучшения не получили.

Weights & Biases Sweeps и Optuna предоставляют инструменты для поиска по сетке, случайного поиска и поиска на основе модели, автоматически отслеживая каждый запуск и сравнивая результаты, а Keras Tuner предлагает то же самое прямо внутри Keras. Эти инструменты помогают управлять экспериментами, когда следить за результатами вручную становится сложно.