.. _sec_minibatch_sgd: Стохастический градиентный спуск на мини-батчах =============================================== До сих пор мы рассматривали два крайних подхода к обучению на основе градиентов: :numref:`sec_gd` использует весь набор данных для вычисления градиентов и обновления параметров за один проход. Напротив, :numref:`sec_sgd` обрабатывает по одному обучающему примеру за раз, чтобы продвигаться вперед. У каждого из них есть свои недостатки. Градиентный спуск не отличается *эффективностью использования данных*, когда данные очень похожи. Стохастический градиентный спуск не отличается *вычислительной эффективностью*, поскольку процессоры и графические процессоры не могут в полной мере использовать возможности векторизации. Это наводит на мысль, что существует промежуточный вариант, и на самом деле именно его мы использовали во всех рассмотренных примерах. Векторизация и кэш-память ------------------------- В основе решения использовать мини-батчи лежит вычислительная эффективность. Это проще всего понять, рассмотрев параллелизацию с использованием нескольких GPU и серверов. В этом случае нам необходимо отправить как минимум одно изображение на каждый GPU. При наличии 8 GPU на сервер и 16 серверов минимальный размер мини-батча уже составляет 128. Вопросы становятся более сложными при работе с отдельными графическими процессорами (GPU) или даже центральными процессорами (CPU). Эти устройства имеют несколько типов памяти, часто несколько типов вычислительных блоков и различные ограничения пропускной способности между ними. Например, центральный процессор имеет небольшое количество регистров, а также кэш-память уровней L1, L2 и, в некоторых случаях, даже L3 (которая является общей для разных ядер процессора). Эти кэш-памяти обладают увеличивающимся объёмом и задержкой (и одновременно уменьшающейся пропускной способностью). Достаточно сказать, что процессор способен выполнять гораздо больше операций, чем может обеспечить интерфейс основной памяти. Во-первых, процессор с тактовой частотой 2 ГГц, 16 ядрами и поддержкой векторизации AVX-512 способен обрабатывать до :math:`2 \cdot 10^9 \cdot 16 \cdot 32 = 10^{12}` байт в секунду. Возможности графических процессоров (GPU) легко превышают это значение в 100 раз. С другой стороны, серверный процессор среднего класса может обладать пропускной способностью памяти не более 100 ГБ/с, то есть менее чем в десять раз ниже той, которая необходима для обеспечения процессора данными. Ситуацию усугубляет то, что не все обращения к памяти равнозначны: интерфейсы памяти обычно имеют ширину 64 бита или больше (например, до 384 бит на GPU), поэтому чтение одного байта требует затрат, сопоставимых с выполнением значительно более широкой операции доступа. Во-вторых, первый доступ сопряжен со значительными накладными расходами, тогда как последовательный доступ обходится относительно дешево (это явление часто называют пакетным чтением). Существует множество других аспектов, которые следует учитывать, например, кэширование при наличии нескольких сокетов, чиплетов и других структур. Более подробное обсуждение см. в этой `статье в Wikipedia `__. Способ смягчения этих ограничений заключается в использовании иерархии кэшей процессора, которые достаточно быстры, чтобы обеспечивать процессор данными. Именно это является *главной* причиной использования батчинга в глубоком обучении. Для простоты рассмотрим матричное умножение, например :math:`\mathbf{A} = \mathbf{B}\mathbf{C}`. У нас есть несколько вариантов вычисления :math:`\mathbf{A}`. Например, мы можем попробовать следующее: 1. Мы можем вычислить :math:`\mathbf{A}_{ij} = \mathbf{B}_{i,:} \mathbf{C}_{:,j}`, то есть вычислять его поэлементно с помощью скалярных произведений. 2. Мы могли бы вычислять :math:`\mathbf{A}_{:,j} = \mathbf{B} \mathbf{C}_{:,j}`, то есть выполнять вычисления по одной колонке за раз. Аналогично мы могли бы вычислять :math:`\mathbf{A}` по одной строке :math:`\mathbf{A}_{i,:}` за раз. 3. Мы можем просто вычислить :math:`\mathbf{A} = \mathbf{B} \mathbf{C}`. 4. Мы можем разбить :math:`\mathbf{B}` и :math:`\mathbf{C}` на более мелкие блочные матрицы и вычислять :math:`\mathbf{A}` по одному блоку за раз. Если мы последуем первому варианту, нам придется каждый раз копировать одну строку и один столбец в процессор для вычисления каждого элемента :math:`\mathbf{A}_{ij}`. Хуже того, поскольку элементы матрицы расположены в памяти последовательно, при чтении одного из двух векторов нам приходится обращаться ко многим разрозненным участкам памяти. Второй вариант гораздо более предпочтителен. В этом случае мы можем удерживать столбец :math:`\mathbf{C}_{:,j}` в кэше процессора, продолжая обход по :math:`\mathbf{B}`. Это вдвое снижает требования к пропускной способности памяти, что обеспечивает более быстрый доступ. Конечно, третий вариант является наиболее желательным. К сожалению, большинство матриц не помещаются в кэш целиком (о чем, собственно, и идет речь). Однако четвертый вариант предлагает практически полезную альтернативу: мы можем перемещать блоки матрицы в кэш и выполнять умножение локально. Оптимизированные библиотеки берут это на себя. Давайте посмотрим, насколько эффективны эти операции на практике. Помимо вычислительной эффективности, значительны накладные расходы, связанные с использованием Python и самого фреймворка глубокого обучения. Напомним, что при каждом выполнении команды интерпретатор Python отправляет запрос в движок MXNet, который должен вставить его в вычислительный граф и обработать в процессе планирования. Такие накладные расходы могут существенно снизить производительность. Таким образом, настоятельно рекомендуется использовать векторизацию (и матрицы) whenever это возможно. .. raw:: latex \diilbookstyleinputcell .. code:: python %matplotlib inline import time import numpy as np import torch from torch import nn from d2l import torch as d2l A = torch.zeros(256, 256) B = torch.randn(256, 256) C = torch.randn(256, 256) Поскольку в оставшейся части книги мы будем часто проводить бенчмарки времени выполнения, определим таймер. .. raw:: latex \diilbookstyleinputcell .. code:: python class Timer: #@save """Record multiple running times.""" def __init__(self): self.times = [] self.start() def start(self): """Start the timer.""" self.tik = time.time() def stop(self): """Stop the timer and record the time in a list.""" self.times.append(time.time() - self.tik) return self.times[-1] def avg(self): """Return the average time.""" return sum(self.times) / len(self.times) def sum(self): """Return the sum of time.""" return sum(self.times) def cumsum(self): """Return the accumulated time.""" return np.array(self.times).cumsum().tolist() timer = Timer() Поэлементное присваивание просто перебирает все строки и столбцы :math:`\mathbf{B}` и :math:`\mathbf{C}` соответственно, чтобы присвоить значение :math:`\mathbf{A}`. .. raw:: latex \diilbookstyleinputcell .. code:: python # Compute A = BC one element at a time timer.start() for i in range(256): for j in range(256): A[i, j] = torch.dot(B[i, :], C[:, j]) timer.stop() Более быстрый способ — выполнять присваивание по столбцам. .. raw:: latex \diilbookstyleinputcell .. code:: python # Compute A = BC one column at a time timer.start() for j in range(256): A[:, j] = torch.mv(B, C[:, j]) timer.stop() Наконец, наиболее эффективный способ — выполнить всю операцию одним блоком. Обратите внимание, что умножение любых двух матриц :math:`\mathbf{B} \in \mathbb{R}^{m \times n}` и :math:`\mathbf{C} \in \mathbb{R}^{n \times p}` занимает примерно :math:`2mnp` операций с плавающей запятой, если скалярное умножение и сложение считать отдельными операциями (на практике они объединены). Таким образом, умножение двух матриц размера :math:`256 \times 256` требует :math:`0.03` миллиардов операций с плавающей запятой. Рассмотрим соответствующую скорость выполнения этих операций. .. raw:: latex \diilbookstyleinputcell .. code:: python # Compute A = BC in one go timer.start() A = torch.mm(B, C) timer.stop() gigaflops = [0.03 / i for i in timer.times] print(f'performance in Gigaflops: element {gigaflops[0]:.3f}, ' f'column {gigaflops[1]:.3f}, full {gigaflops[2]:.3f}') .. _sec_minibatches: Мини-пакеты ----------- Ранее мы считали само собой разумеющимся, что для обновления параметров мы будем считывать *минипакеты* данных, а не отдельные наблюдения. Теперь мы кратко обоснуем этот подход. Обработка отдельных наблюдений требует выполнения множества одиночных умножений матрицы на вектор (или даже вектора на вектор), что является довольно дорогостоящей операцией и создает значительные накладные расходы для базового фреймворка глубокого обучения. Это относится как к вычислению выхода сети при обработке данных (часто называемому выводом), так и к вычислению градиентов для обновления параметров. То есть это применимо всякий раз, когда мы выполняем :math:`\mathbf{w} \leftarrow \mathbf{w} - \eta_t \mathbf{g}_t` где .. math:: \mathbf{g}_t = \partial_{\mathbf{w}} f(\mathbf{x}_{t}, \mathbf{w}) Мы можем повысить *вычислительную* эффективность этой операции, применяя её к мини-батчу наблюдений за один раз. То есть мы заменяем градиент :math:`\mathbf{g}_t` по одному наблюдению на градиент по небольшому мини-батчу .. math:: \mathbf{g}_t = \partial_{\mathbf{w}} \frac{1}{|\mathcal{B}_t|} \sum_{i \in \mathcal{B}_t} f(\mathbf{x}_{i}, \mathbf{w}) Рассмотрим, как это влияет на статистические свойства :math:`\mathbf{g}_t`: поскольку как :math:`\mathbf{x}_t`, так и все элементы мини-батча :math:`\mathcal{B}_t` выбираются равномерно случайным образом из обучающей выборки, математическое ожидание градиента остается неизменным. С другой стороны, дисперсия значительно уменьшается. Поскольку градиент мини-батча состоит из усреднения :math:`b \stackrel{\textrm{def}}{=} |\mathcal{B}_t|` независимых градиентов, его стандартное отклонение уменьшается в :math:`b^{-\frac{1}{2}}` раза. Само по себе это хорошо, поскольку это означает, что обновления более надёжно согласуются с полным градиентом. Наивно это может указывать на то, что выбор большого мини-батча :math:`\mathcal{B}_t` всегда желателен. Однако после определённого момента дальнейшее снижение стандартного отклонения становится незначительным по сравнению с линейным ростом вычислительных затрат. На практике мы выбираем размер мини-батча, который достаточно велик для обеспечения хорошей вычислительной эффективности, но при этом помещается в память графического процессора (GPU). Чтобы проиллюстрировать экономию, рассмотрим следующий фрагмент кода. В нём выполняется то же самое матричное умножение, но разбитое на «мини-батчи» по 64 столбца за раз. .. raw:: latex \diilbookstyleinputcell .. code:: python timer.start() for j in range(0, 256, 64): A[:, j:j+64] = torch.mm(B, C[:, j:j+64]) timer.stop() print(f'performance in Gigaflops: block {0.03 / timer.times[3]:.3f}') Как видно, вычисления на мини-батче по эффективности практически не уступают вычислениям на полной матрице. Однако следует проявить осторожность. В :numref:`sec_batch_norm` мы использовали вид регуляризации, который сильно зависел от дисперсии в мини-батче. При увеличении размера мини-батча дисперсия уменьшается, что снижает эффект от добавления шума благодаря батч-нормализации. Подробности о том, как масштабировать и вычислять соответствующие члены, см., например, в :cite:t:`Ioffe.2017`. Чтение набора данных -------------------- Рассмотрим, как эффективно формировать мини-пакеты из данных. В дальнейшем мы будем использовать набор данных, разработанный NASA для тестирования шума от крыльев различных самолетов [https://archive.ics.uci.edu/dataset/291/airfoil+self+noise], чтобы сравнить эти алгоритмы оптимизации. Для удобства мы возьмем только первые :math:`1,500` примеров. Данные проходят предобработку путем вайтенинга (whitening), то есть мы вычитаем среднее значение и приводим дисперсию к :math:`1` для каждой координаты. .. raw:: latex \diilbookstyleinputcell .. code:: python #@save d2l.DATA_HUB['airfoil'] = (d2l.DATA_URL + 'airfoil_self_noise.dat', '76e5be1548fd8222e5074cf0faae75edff8cf93f') #@save def get_data_ch11(batch_size=10, n=1500): data = np.genfromtxt(d2l.download('airfoil'), dtype=np.float32, delimiter='\t') data = torch.from_numpy((data - data.mean(axis=0)) / data.std(axis=0)) data_iter = d2l.load_array((data[:n, :-1], data[:n, -1]), batch_size, is_train=True) return data_iter, data.shape[1]-1 Реализация с нуля ----------------- Вспомним реализацию стохастического градиентного спуска на мини-батчах из :numref:`sec_linear_scratch`. В дальнейшем мы приводим несколько более общую реализацию. Для удобства она имеет ту же сигнатуру вызова, что и другие алгоритмы оптимизации, представленные далее в этой главе. В частности, мы добавляем входной параметр состояния ``states`` и помещаем гиперпараметры в словарь ``hyperparams``. Кроме того, в функции обучения мы будем усреднять потери каждого примера в мини-батче, поэтому градиент в алгоритме оптимизации не нужно делить на размер батча. .. raw:: latex \diilbookstyleinputcell .. code:: python def sgd(params, states, hyperparams): for p in params: p.data.sub_(hyperparams['lr'] * p.grad) p.grad.data.zero_() Далее мы реализуем универсальную функцию обучения, чтобы упростить использование других алгоритмов оптимизации, представленных в этой главе. Она инициализирует модель линейной регрессии и может быть использована для обучения модели с помощью стохастического градиентного спуска на мини-батчах, а также других алгоритмов, рассмотренных далее. .. raw:: latex \diilbookstyleinputcell .. code:: python #@save def train_ch11(trainer_fn, states, hyperparams, data_iter, feature_dim, num_epochs=2): # Initialization w = torch.normal(mean=0.0, std=0.01, size=(feature_dim, 1), requires_grad=True) b = torch.zeros((1), requires_grad=True) net, loss = lambda X: d2l.linreg(X, w, b), d2l.squared_loss # Train animator = d2l.Animator(xlabel='epoch', ylabel='loss', xlim=[0, num_epochs], ylim=[0.22, 0.35]) n, timer = 0, d2l.Timer() for _ in range(num_epochs): for X, y in data_iter: l = loss(net(X), y).mean() l.backward() trainer_fn([w, b], states, hyperparams) n += X.shape[0] if n % 200 == 0: timer.stop() animator.add(n/X.shape[0]/len(data_iter), (d2l.evaluate_loss(net, data_iter, loss),)) timer.start() print(f'loss: {animator.Y[0][-1]:.3f}, {timer.sum()/num_epochs:.3f} sec/epoch') return timer.cumsum(), animator.Y[0] Рассмотрим, как протекает оптимизация при градиентном спуске по всему батчу. Это можно реализовать, установив размер мини-батча равным 1500 (то есть общему количеству примеров). В результате параметры модели обновляются всего один раз за эпоху. Прогресс незначителен. Фактически после 6 шагов он полностью останавливается. .. raw:: latex \diilbookstyleinputcell .. code:: python def train_sgd(lr, batch_size, num_epochs=2): data_iter, feature_dim = get_data_ch11(batch_size) return train_ch11( sgd, None, {'lr': lr}, data_iter, feature_dim, num_epochs) gd_res = train_sgd(1, 1500, 10) Когда размер батча равен 1, для оптимизации используется стохастический градиентный спуск. Для упрощения реализации мы выбрали постоянную (хоть и небольшую) скорость обучения. В стохастическом градиентном спуске параметры модели обновляются всякий раз, когда обрабатывается пример. В нашем случае это означает 1500 обновлений за эпоху. Как мы видим, снижение значения целевой функции замедляется после одной эпохи. Хотя оба метода обработали 1500 примеров за одну эпоху, стохастический градиентный спуск требует больше времени, чем градиентный спуск, в нашем эксперименте. Это связано с тем, что стохастический градиентный спуск обновляет параметры чаще, а обработка отдельных наблюдений по одному менее эффективна. .. raw:: latex \diilbookstyleinputcell .. code:: python sgd_res = train_sgd(0.005, 1) Наконец, когда размер батча равен 100, для оптимизации используется стохастический градиентный спуск с мини-батчами. Время, необходимое для одной эпохи, меньше, чем при стохастическом градиентном спуске, и меньше, чем при градиентном спуске с полным батчем. .. raw:: latex \diilbookstyleinputcell .. code:: python mini1_res = train_sgd(.4, 100) Уменьшение размера батча до 10 приводит к увеличению времени каждой эпохи, поскольку выполнение каждой партии становится менее эффективным. .. raw:: latex \diilbookstyleinputcell .. code:: python mini2_res = train_sgd(.05, 10) Теперь сравним зависимость времени от функции потерь для предыдущих четырёх экспериментов. Как видно, хотя стохастический градиентный спуск сходится быстрее градиентного спуска с точки зрения количества обработанных примеров, ему требуется больше времени для достижения того же уровня функции потерь, поскольку вычисление градиента по одному примеру менее эффективно. Мини-батч стохастический градиентный спуск позволяет найти баланс между скоростью сходимости и вычислительной эффективностью. Размер мини-батча 10 эффективнее стохастического градиентного спуска; размер мини-батча 100 даже превосходит градиентный спуск по времени выполнения. .. raw:: latex \diilbookstyleinputcell .. code:: python d2l.set_figsize([6, 3]) d2l.plot(*list(map(list, zip(gd_res, sgd_res, mini1_res, mini2_res))), 'time (sec)', 'loss', xlim=[1e-2, 10], legend=['gd', 'sgd', 'batch size=100', 'batch size=10']) d2l.plt.gca().set_xscale('log') Краткая реализация ------------------ В Gluon мы можем использовать класс ``Trainer`` для вызова алгоритмов оптимизации. Это используется для реализации универсальной функции обучения. Мы будем применять её на протяжении всей текущей главы. .. raw:: latex \diilbookstyleinputcell .. code:: python #@save def train_concise_ch11(trainer_fn, hyperparams, data_iter, num_epochs=4): # Initialization net = nn.Sequential(nn.Linear(5, 1)) def init_weights(module): if type(module) == nn.Linear: torch.nn.init.normal_(module.weight, std=0.01) net.apply(init_weights) optimizer = trainer_fn(net.parameters(), **hyperparams) loss = nn.MSELoss(reduction='none') animator = d2l.Animator(xlabel='epoch', ylabel='loss', xlim=[0, num_epochs], ylim=[0.22, 0.35]) n, timer = 0, d2l.Timer() for _ in range(num_epochs): for X, y in data_iter: optimizer.zero_grad() out = net(X) y = y.reshape(out.shape) l = loss(out, y) l.mean().backward() optimizer.step() n += X.shape[0] if n % 200 == 0: timer.stop() # `MSELoss` computes squared error without the 1/2 factor animator.add(n/X.shape[0]/len(data_iter), (d2l.evaluate_loss(net, data_iter, loss) / 2,)) timer.start() print(f'loss: {animator.Y[0][-1]:.3f}, {timer.sum()/num_epochs:.3f} sec/epoch') Использование Gluon для повторения последнего эксперимента показывает идентичное поведение. .. raw:: latex \diilbookstyleinputcell .. code:: python data_iter, _ = get_data_ch11(10) trainer = torch.optim.SGD train_concise_ch11(trainer, {'lr': 0.01}, data_iter) Итоги ----- - Векторизация повышает эффективность кода за счёт снижения накладных расходов, возникающих из-за фреймворка глубокого обучения, а также благодаря лучшей локальности данных и кэшированию на процессорах и графических процессорах. - Существует компромисс между статистической эффективностью, обеспечиваемой стохастическим градиентным спуском, и вычислительной эффективностью, достигаемой за счёт обработки больших партий данных одновременно. - Мини-батч стохастический градиентный спуск сочетает в себе лучшие качества обоих подходов: вычислительную и статистическую эффективность. - В стохастическом градиентном спуске с мини-батчами мы обрабатываем батчи данных, полученные путём случайной перестановки обучающих данных (то есть каждое наблюдение обрабатывается только один раз за эпоху, albeit в случайном порядке). - Рекомендуется уменьшать скорости обучения в процессе **обучения**. - В целом, мини-батч стохастический градиентный спуск обеспечивает более быструю сходимость к меньшему риску, чем стохастический градиентный спуск и градиентный спуск, если оценивать время по часам. Упражнения ---------- 1. Измените размер батча и скорость обучения и наблюдайте за тем, как меняется значение целевой функции и затрачиваемое время на каждую эпоху. 2. Изучите документацию MXNet и используйте функцию ``Trainer`` класса ``set_learning_rate``, чтобы уменьшать скорость обучения стохастического градиентного спуска на мини-батчах до 1/10 от её предыдущего значения после каждой эпохи. 3. Сравните стохастический градиентный спуск с мини-батчами с вариантом, который фактически *выбирает с возвращением* из обучающей выборки. Что происходит? 4. Злой джинн незаметно для вас дублирует ваш набор данных (то есть каждое наблюдение встречается дважды, а размер набора данных удваивается, но никто вам об этом не сообщает). Как изменится поведение стохастического градиентного спуска, стохастического градиентного спуска с мини-батчами и градиентного спуска? `Обсуждения `__