12.11. Планирование скорости обучения

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

  • Прежде всего важна величина скорости обучения. Если она слишком велика, оптимизация расходится; если слишком мала, обучение занимает слишком много времени или мы получаем субоптимальный результат. Ранее мы видели, что важную роль играет число обусловленности задачи (см. Section 12.6 для подробностей). Интуитивно это отношение величины изменений в наименее чувствительном направлении к величине изменений в наиболее чувствительном.

  • Во-вторых, не менее важна скорость затухания. Если скорость обучения остается большой, мы можем просто колебаться вокруг минимума и, следовательно, не достигнуть оптимума. Section 12.5 подробно обсуждал этот вопрос, а в Section 12.4 мы проанализировали гарантии производительности. Короче говоря, нам нужно, чтобы скорость обучения уменьшалась, но, вероятно, медленнее, чем в \(\mathcal{O}(t^{-\frac{1}{2}})\), что было бы хорошим выбором для выпуклых задач.

  • Не менее важным аспектом является инициализация. Она относится как к начальной настройке параметров (подробности см. Section 5.4), так и к их начальной эволюции. Этот подход известен под термином разогрев (warmup), то есть скорость, с которой мы изначально начинаем двигаться к решению. Большие шаги на начальном этапе могут быть неэффективными, особенно учитывая, что начальный набор параметров является случайным. Кроме того, начальные направления обновлений могут быть совершенно бессмысленными.

  • Наконец, существует ряд вариантов оптимизации, использующих циклическое изменение скорости обучения. Это выходит за рамки текущей главы. Рекомендуем читателю ознакомиться с подробностями в Izmailov et al. (2018), например, с тем, как можно получить лучшие решения путём усреднения по всему пути параметров.

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

12.11.1. Учебная задача

Рассмотрим учебную задачу, которая достаточно проста для вычислений, но при этом нетривиальна, чтобы проиллюстрировать некоторые ключевые аспекты. В качестве такой задачи мы выбираем слегка модернизированную версию LeNet (relu вместо sigmoid в качестве функции активации, MaxPooling вместо AveragePooling), применённую к набору данных Fashion-MNIST. Кроме того, мы оптимизируем архитектуру сети для повышения производительности. Поскольку большая часть кода является стандартной, мы кратко рассмотрим основы без детального обсуждения. При необходимости обратитесь к Section 7 для освежения знаний.

%matplotlib inline
import math
import torch
from torch import nn
from torch.optim import lr_scheduler
from d2l import torch as d2l


def net_fn():
    model = nn.Sequential(
        nn.Conv2d(1, 6, kernel_size=5, padding=2), nn.ReLU(),
        nn.MaxPool2d(kernel_size=2, stride=2),
        nn.Conv2d(6, 16, kernel_size=5), nn.ReLU(),
        nn.MaxPool2d(kernel_size=2, stride=2),
        nn.Flatten(),
        nn.Linear(16 * 5 * 5, 120), nn.ReLU(),
        nn.Linear(120, 84), nn.ReLU(),
        nn.Linear(84, 10))

    return model

loss = nn.CrossEntropyLoss()
device = d2l.try_gpu()

batch_size = 256
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size=batch_size)

# The code is almost identical to `d2l.train_ch6` defined in the
# lenet section of chapter convolutional neural networks
def train(net, train_iter, test_iter, num_epochs, loss, trainer, device,
          scheduler=None):
    net.to(device)
    animator = d2l.Animator(xlabel='epoch', xlim=[0, num_epochs],
                            legend=['train loss', 'train acc', 'test acc'])

    for epoch in range(num_epochs):
        metric = d2l.Accumulator(3)  # train_loss, train_acc, num_examples
        for i, (X, y) in enumerate(train_iter):
            net.train()
            trainer.zero_grad()
            X, y = X.to(device), y.to(device)
            y_hat = net(X)
            l = loss(y_hat, y)
            l.backward()
            trainer.step()
            with torch.no_grad():
                metric.add(l * X.shape[0], d2l.accuracy(y_hat, y), X.shape[0])
            train_loss = metric[0] / metric[2]
            train_acc = metric[1] / metric[2]
            if (i + 1) % 50 == 0:
                animator.add(epoch + i / len(train_iter),
                             (train_loss, train_acc, None))

        test_acc = d2l.evaluate_accuracy_gpu(net, test_iter)
        animator.add(epoch+1, (None, None, test_acc))

        if scheduler:
            if scheduler.__module__ == lr_scheduler.__name__:
                # Using PyTorch In-Built scheduler
                scheduler.step()
            else:
                # Using custom defined scheduler
                for param_group in trainer.param_groups:
                    param_group['lr'] = scheduler(epoch)

    print(f'train loss {train_loss:.3f}, train acc {train_acc:.3f}, '
          f'test acc {test_acc:.3f}')

Рассмотрим, что произойдет, если запустить этот алгоритм с настройками по умолчанию, например, со скоростью обучения \(0.3\) и провести обучение в течение \(30\) итераций. Обратите внимание, что точность на обучающей выборке продолжает расти, в то время как прогресс в точности на тестовой выборке останавливается после определённого момента. Разрыв между этими двумя кривыми указывает на переобучение.

lr, num_epochs = 0.3, 30
net = net_fn()
trainer = torch.optim.SGD(net.parameters(), lr=lr)
train(net, train_iter, test_iter, num_epochs, loss, trainer, device)

12.11.2. Планировщики

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

lr = 0.1
trainer.param_groups[0]["lr"] = lr
print(f'learning rate is now {trainer.param_groups[0]["lr"]:.2f}')

Более общо мы хотим определить планировщик. При вызове с указанием количества обновлений он возвращает соответствующее значение скорости обучения. Давайте определим простой планировщик, который устанавливает скорость обучения в \(\eta = \eta_0 (t + 1)^{-\frac{1}{2}}\).

class SquareRootScheduler:
    def __init__(self, lr=0.1):
        self.lr = lr

    def __call__(self, num_update):
        return self.lr * pow(num_update + 1.0, -0.5)

Построим график его поведения в заданном диапазоне значений.

scheduler = SquareRootScheduler(lr=0.1)
d2l.plot(torch.arange(num_epochs), [scheduler(t) for t in range(num_epochs)])

Теперь посмотрим, как это работает при обучении на Fashion-MNIST. Мы просто передаём планировщик в качестве дополнительного аргумента алгоритму обучения.

net = net_fn()
trainer = torch.optim.SGD(net.parameters(), lr)
train(net, train_iter, test_iter, num_epochs, loss, trainer, device,
      scheduler)

Это сработало значительно лучше, чем прежде. Бросаются в глаза две вещи: во-первых, кривая стала гораздо более гладкой. Во-вторых, переобучение проявилось в меньшей степени. К сожалению, до сих пор нет однозначного ответа на вопрос, почему определённые стратегии приводят к меньшему переобучению с теоретической точки зрения. Есть аргумент, что меньший шаг градиентного спуска приводит к параметрам, более близким к нулю, и, следовательно, к более простой модели. Однако это не объясняет явление полностью, поскольку мы не останавливаем обучение заранее, а лишь плавно уменьшаем скорость обучения.

12.11.3. Политики

Хотя мы не можем охватить всё разнообразие планировщиков скорости обучения, ниже мы кратко рассмотрим наиболее популярные стратегии. Распространёнными вариантами являются полиномиальное затухание и кусочно-постоянные планы. Кроме того, эмпирические исследования показывают, что косинусные планы скорости обучения хорошо работают на некоторых задачах. Наконец, в ряде случаев полезно использовать «разогрев» оптимизатора перед применением больших значений скорости обучения.

12.11.3.1. Планировщик множителей

Альтернативой полиномиальному затуханию может быть мультипликативное, то есть \(\eta_{t+1} \leftarrow \eta_t \cdot \alpha\) для \(\alpha \in (0, 1)\). Чтобы предотвратить снижение скорости обучения ниже разумной нижней границы, уравнение обновления часто модифицируют до вида \(\eta_{t+1} \leftarrow \mathop{\mathrm{max}}(\eta_{\mathrm{min}}, \eta_t \cdot \alpha)\).

class FactorScheduler:
    def __init__(self, factor=1, stop_factor_lr=1e-7, base_lr=0.1):
        self.factor = factor
        self.stop_factor_lr = stop_factor_lr
        self.base_lr = base_lr

    def __call__(self, num_update):
        self.base_lr = max(self.stop_factor_lr, self.base_lr * self.factor)
        return self.base_lr

scheduler = FactorScheduler(factor=0.9, stop_factor_lr=1e-2, base_lr=2.0)
d2l.plot(torch.arange(50), [scheduler(t) for t in range(50)])

Это также можно реализовать с помощью встроенного планировщика в MXNet, используя объект lr_scheduler.FactorScheduler. Он принимает несколько дополнительных параметров, таких как период разгона, режим разгона (линейный или постоянный), максимальное количество желаемых обновлений и т. д. В дальнейшем мы будем использовать встроенные планировщики по мере необходимости и здесь лишь кратко опишем их функциональность. Как показано, при необходимости довольно просто создать собственный планировщик.

12.11.3.2. Многофакторный планировщик

Общей стратегией обучения глубоких сетей является поддержание скорости обучения кусочно-постоянной и её уменьшение на заданную величину через определённые промежутки времени. То есть, при заданном наборе моментов времени, когда следует уменьшать скорость, например, \(s = \{5, 10, 20\}\) уменьшать \(\eta_{t+1} \leftarrow \eta_t \cdot \alpha\) всякий раз, когда \(t \in s\). Предполагая, что на каждом шаге значения уменьшаются вдвое, мы можем реализовать это следующим образом.

net = net_fn()
trainer = torch.optim.SGD(net.parameters(), lr=0.5)
scheduler = lr_scheduler.MultiStepLR(trainer, milestones=[15, 30], gamma=0.5)

def get_lr(trainer, scheduler):
    lr = scheduler.get_last_lr()[0]
    trainer.step()
    scheduler.step()
    return lr

d2l.plot(torch.arange(num_epochs), [get_lr(trainer, scheduler)
                                  for t in range(num_epochs)])

Идея, лежащая в основе такого кусочно-постоянного графика скорости обучения, заключается в том, чтобы позволить оптимизации протекать до тех пор, пока не будет достигнута стационарная точка с точки зрения распределения весов. Затем (и только тогда) мы уменьшаем скорость обучения, чтобы получить более качественную аппроксимацию хорошего локального минимума. Приведённый ниже пример показывает, как это позволяет получать всё более точные решения.

train(net, train_iter, test_iter, num_epochs, loss, trainer, device,
      scheduler)

12.11.3.3. Планировщик косинусного затухания

Довольно запутанная эвристика была предложена в Loshchilov and Hutter (2016). Она основана на наблюдении, что в начале обучения мы, возможно, не хотим слишком резко снижать скорость обучения, а в конце, наоборот, хотим «уточнить» решение, используя очень маленькую скорость обучения. Это приводит к расписанию косинусного типа со следующей функциональной формой для скоростей обучения в диапазоне \(t \in [0, T]\).

(12.11.1)\[\eta_t = \eta_T + \frac{\eta_0 - \eta_T}{2} \left(1 + \cos(\pi t/T)\right)\]

Здесь \(\eta_0\) — начальная скорость обучения, \(\eta_T\) — целевая скорость обучения в момент времени \(T\). Кроме того, для \(t > T\) мы просто фиксируем значение на уровне \(\eta_T\), не увеличивая его снова. В следующем примере мы установим максимальное число шагов обновления \(T = 20\).

class CosineScheduler:
    def __init__(self, max_update, base_lr=0.01, final_lr=0,
               warmup_steps=0, warmup_begin_lr=0):
        self.base_lr_orig = base_lr
        self.max_update = max_update
        self.final_lr = final_lr
        self.warmup_steps = warmup_steps
        self.warmup_begin_lr = warmup_begin_lr
        self.max_steps = self.max_update - self.warmup_steps

    def get_warmup_lr(self, epoch):
        increase = (self.base_lr_orig - self.warmup_begin_lr) \
                       * float(epoch) / float(self.warmup_steps)
        return self.warmup_begin_lr + increase

    def __call__(self, epoch):
        if epoch < self.warmup_steps:
            return self.get_warmup_lr(epoch)
        if epoch <= self.max_update:
            self.base_lr = self.final_lr + (
                self.base_lr_orig - self.final_lr) * (1 + math.cos(
                math.pi * (epoch - self.warmup_steps) / self.max_steps)) / 2
        return self.base_lr

scheduler = CosineScheduler(max_update=20, base_lr=0.3, final_lr=0.01)
d2l.plot(torch.arange(num_epochs), [scheduler(t) for t in range(num_epochs)])

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

net = net_fn()
trainer = torch.optim.SGD(net.parameters(), lr=0.3)
train(net, train_iter, test_iter, num_epochs, loss, trainer, device,
      scheduler)

12.11.3.4. Warmup

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

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

scheduler = CosineScheduler(20, warmup_steps=5, base_lr=0.3, final_lr=0.01)
d2l.plot(torch.arange(num_epochs), [scheduler(t) for t in range(num_epochs)])

Обратите внимание, что сеть сходится быстрее на начальном этапе (в частности, обратите внимание на производительность в течение первых 5 эпох).

net = net_fn()
trainer = torch.optim.SGD(net.parameters(), lr=0.3)
train(net, train_iter, test_iter, num_epochs, loss, trainer, device,
      scheduler)

Метод warmup можно применять к любому планировщику скорости обучения (не только к косинусному). Более подробное обсуждение планировщиков скорости обучения и множество дополнительных экспериментов приведены в работе (Gotmare et al., 2018). В частности, авторы показывают, что фаза warmup ограничивает степень расхождения параметров в очень глубоких сетях. Это интуитивно понятно, поскольку мы ожидаем значительного расхождения из-за случайной инициализации в тех частях сети, которые в начале обучения продвигаются медленнее всего.

12.11.4. Резюме

  • Уменьшение скорости обучения в процессе обучения может привести к повышению точности и (что наиболее удивительно) снижению переобучения модели.

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

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

  • Период разминки перед оптимизацией может предотвратить расходимость.

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

12.11.5. Упражнения

  1. Экспериментируйте с поведением оптимизации при заданной фиксированной скорости обучения. Какую наилучшую модель можно получить таким образом?

  2. Как изменится сходимость, если изменить показатель степени убывания скорости обучения? Для удобства проведения экспериментов используйте PolyScheduler.

  3. Примените планировщик косинусного затухания к крупным задачам компьютерного зрения, например, к обучению на ImageNet. Как он влияет на производительность по сравнению с другими планировщиками?

  4. Как долго должно длиться время разгона?

  5. Можно ли связать оптимизацию и сэмплирование? Начните с использования результатов из Welling and Teh (2011) о стохастической градиентной динамике Ланжевена.

Обсуждения