9.5. Реализация рекуррентной нейронной сети с нуля¶
Теперь мы готовы реализовать рекуррентную нейронную сеть с нуля. В частности, мы обучим эту сеть функционировать в качестве языковой модели на уровне символов (см. Section 9.4) и обучим её на корпусе, состоящем из полного текста книги Герберта Уэллса «Машина времени», следуя шагам обработки данных, описанным в Section 9.2. Мы начинаем с загрузки набора данных.
%matplotlib inline
import math
import torch
from torch import nn
from torch.nn import functional as F
from d2l import torch as d2l
9.5.1. Модель RNN¶
Сначала мы определяем класс для реализации модели RNN
(Section 9.4.2). Обратите внимание, что
количество скрытых единиц num_hiddens — это настраиваемый
гиперпараметр.
class RNNScratch(d2l.Module): #@save
"""The RNN model implemented from scratch."""
def __init__(self, num_inputs, num_hiddens, sigma=0.01):
super().__init__()
self.save_hyperparameters()
self.W_xh = nn.Parameter(
torch.randn(num_inputs, num_hiddens) * sigma)
self.W_hh = nn.Parameter(
torch.randn(num_hiddens, num_hiddens) * sigma)
self.b_h = nn.Parameter(torch.zeros(num_hiddens))
Метод forward ниже описывает, как вычислять выход и скрытое
состояние на любом временном шаге, исходя из текущего входа и состояния
модели на предыдущем шаге. Обратите внимание, что модель RNN перебирает
элементы по внешней размерности inputs, обновляя скрытое состояние
последовательно для каждого временного шага. В данной модели
используется функция активации \(\tanh\) (Section 5.1.2.3).
@d2l.add_to_class(RNNScratch) #@save
def forward(self, inputs, state=None):
if state is None:
# Initial state with shape: (batch_size, num_hiddens)
state = torch.zeros((inputs.shape[1], self.num_hiddens),
device=inputs.device)
else:
state, = state
outputs = []
for X in inputs: # Shape of inputs: (num_steps, batch_size, num_inputs)
state = torch.tanh(torch.matmul(X, self.W_xh) +
torch.matmul(state, self.W_hh) + self.b_h)
outputs.append(state)
return outputs, state
Мы можем подать мини-батч входных последовательностей в модель RNN следующим образом.
batch_size, num_inputs, num_hiddens, num_steps = 2, 16, 32, 100
rnn = RNNScratch(num_inputs, num_hiddens)
X = torch.ones((num_steps, batch_size, num_inputs))
outputs, state = rnn(X)
Проверим, что модель RNN выдает результаты корректных размеров, чтобы убедиться, что размерность скрытого состояния остается неизменной.
def check_len(a, n): #@save
"""Check the length of a list."""
assert len(a) == n, f'list\'s length {len(a)} != expected length {n}'
def check_shape(a, shape): #@save
"""Check the shape of a tensor."""
assert a.shape == shape, \
f'tensor\'s shape {a.shape} != expected shape {shape}'
check_len(outputs, num_steps)
check_shape(outputs[0], (batch_size, num_hiddens))
check_shape(state, (batch_size, num_hiddens))
9.5.2. Языковая модель на основе RNN¶
В следующем RNNLMScratch классе определяется языковая модель на
основе RNN, в которой наша RNN передается через аргумент rnn метода
__init__. При обучении языковых моделей входные и выходные данные
берутся из одного и того же словаря. Следовательно, они имеют одинаковую
размерность, равную размеру словаря. Обратите внимание, что для оценки
модели используется перплексия. Как обсуждалось в
Section 9.3.2, это обеспечивает сопоставимость
последовательностей разной длины.
class RNNLMScratch(d2l.Classifier): #@save
"""The RNN-based language model implemented from scratch."""
def __init__(self, rnn, vocab_size, lr=0.01):
super().__init__()
self.save_hyperparameters()
self.init_params()
def init_params(self):
self.W_hq = nn.Parameter(
torch.randn(
self.rnn.num_hiddens, self.vocab_size) * self.rnn.sigma)
self.b_q = nn.Parameter(torch.zeros(self.vocab_size))
def training_step(self, batch):
l = self.loss(self(*batch[:-1]), batch[-1])
self.plot('ppl', torch.exp(l), train=True)
return l
def validation_step(self, batch):
l = self.loss(self(*batch[:-1]), batch[-1])
self.plot('ppl', torch.exp(l), train=False)
9.5.2.1. Одномерное кодирование¶
Вспомним, что каждый токен представлен числовым индексом, указывающим на позицию соответствующего слова/символа/части слова в словаре. Может возникнуть соблазн построить нейронную сеть с одним входным узлом (на каждом временном шаге), в который индекс подавался бы как скалярное значение. Такой подход работает, когда мы имеем дело с числовыми входными данными, такими как цена или температура, где любые два достаточно близких значения должны рассматриваться одинаково. Но это не совсем корректно. Слова \(45^{\textrm{th}}\) и \(46^{\textrm{th}}\) в нашем словаре — это “their” и “said”, значения которых совершенно не похожи друг на друга.
При работе с такими категориальными данными наиболее распространённой стратегией является представление каждого элемента с помощью one-hot кодирования (см. Section 4.1.1). One-hot кодирование — это вектор, длина которого определяется размером словаря \(N\), где все элементы равны \(0\), за исключением элемента, соответствующего нашему токену, который равен \(1\). Например, если бы словарь содержал пять элементов, то one-hot векторы, соответствующие индексам 0 и 2, выглядели бы следующим образом.
F.one_hot(torch.tensor([0, 2]), 5)
Мини-батчи, которые мы выбираем на каждой итерации, имеют форму (размер
батча, количество временных шагов). Представив каждый вход в виде
one-hot вектора, мы можем рассматривать каждый мини-батч как трёхмерный
тензор, где длина по третьей оси равна размеру словаря (len(vocab)).
Мы часто транспонируем входные данные, чтобы получить выход формы
(количество временных шагов, размер батча, размер словаря). Это
позволяет нам удобнее перебирать внешнюю размерность для обновления
скрытых состояний мини-батча шаг за шагом (например, в методе
forward выше).
@d2l.add_to_class(RNNLMScratch) #@save
def one_hot(self, X):
# Output shape: (num_steps, batch_size, vocab_size)
return F.one_hot(X.T, self.vocab_size).type(torch.float32)
9.5.2.2. Преобразование выходов RNN¶
Языковая модель использует полностью связанный выходной слой для преобразования выходов RNN в предсказания токенов на каждом временном шаге.
@d2l.add_to_class(RNNLMScratch) #@save
def output_layer(self, rnn_outputs):
outputs = [torch.matmul(H, self.W_hq) + self.b_q for H in rnn_outputs]
return torch.stack(outputs, 1)
@d2l.add_to_class(RNNLMScratch) #@save
def forward(self, X, state=None):
embs = self.one_hot(X)
rnn_outputs, _ = self.rnn(embs, state)
return self.output_layer(rnn_outputs)
Давайте проверим, имеет ли результат прямого распространения правильную форму.
model = RNNLMScratch(rnn, num_inputs)
outputs = model(torch.ones((batch_size, num_steps), dtype=torch.int64))
check_shape(outputs, (batch_size, num_steps, num_inputs))
9.5.3. Обрезка градиентов¶
Хотя вы уже привыкли считать нейронные сети «глубокими» в том смысле, что между входом и выходом проходит множество слоёв даже в пределах одного временного шага, длина последовательности вводит новое понятие глубины. Помимо прохождения через сеть в направлении от входа к выходу, входные данные на первом временном шаге должны пройти через цепочку \(T\) слоёв вдоль временных шагов, чтобы повлиять на выход модели на конечном временном шаге. Если рассматривать процесс с обратной стороны, то на каждой итерации мы выполняем обратное распространение градиентов по времени, что приводит к цепочке произведений матриц длины \(\mathcal{O}(T)\). Как упоминалось в Section 5.4, это может привести к численной неустойчивости, вызывая либо взрыв, либо затухание градиентов в зависимости от свойств матриц весов.
Борьба с исчезающими и взрывающимися градиентами — фундаментальная проблема при проектировании рекуррентных нейронных сетей (RNN), которая вдохновила на создание некоторых из самых значительных достижений в архитектуре современных нейронных сетей. В следующей главе мы поговорим о специализированных архитектурах, разработанных для смягчения проблемы исчезающих градиентов. Однако даже современные RNN часто страдают от взрывающихся градиентов. Одним из неэлегантным, но повсеместно используемых решений является простое усечение градиентов, заставляющее результирующие «усеченные» градиенты принимать меньшие значения.
В общем случае при оптимизации некоторой целевой функции методом градиентного спуска мы итеративно обновляем интересующий нас параметр, скажем, вектор \(\mathbf{x}\), сдвигая его в направлении, противоположном градиенту \(\mathbf{g}\) (в стохастическом градиентном спуске этот градиент вычисляется на случайно выбранном мини-батче). Например, при скорости обучения \(\eta > 0\) каждое обновление имеет вид \(\mathbf{x} \gets \mathbf{x} - \eta \mathbf{g}\). Предположим далее, что целевая функция \(f\) достаточно гладкая. Формально мы говорим, что целевая функция липшицева с константой \(L\), то есть для любых \(\mathbf{x}\) и \(\mathbf{y}\) выполняется
Как видно, при обновлении вектора параметров путем вычитания \(\eta \mathbf{g}\) изменение значения целевой функции зависит от скорости обучения, нормы градиента и \(L\) следующим образом:
Иными словами, значение целевой функции не может измениться более чем на \(L \eta \|\mathbf{g}\|\). Небольшое значение этой верхней границы можно рассматривать как с положительной, так и с отрицательной стороны. С одной стороны, мы ограничиваем скорость, с которой можем уменьшить значение целевой функции. С другой стороны, это ограничивает то, насколько сильно мы можем ошибиться на любом одном шаге градиентного спуска.
Когда мы говорим, что градиенты взрываются, мы имеем в виду, что \(\|\mathbf{g}\|\) становится чрезмерно большим. В этом худшем случае мы можем нанести такой ущерб за один шаг градиента, что свести на нет весь прогресс, достигнутый за тысячи итераций обучения. Когда градиенты могут быть настолько большими, обучение нейронной сети часто расходится, не снижая значение целевой функции. В других случаях обучение в конечном итоге сходится, но становится нестабильным из-за резких скачков функции потерь.
Один из способов ограничить величину \(L \eta \|\mathbf{g}\|\) — уменьшить скорость обучения \(\eta\) до крайне малых значений. Это имеет то преимущество, что мы не вносим смещение в обновления. Но что, если крупные градиенты возникают лишь редко? Такое радикальное решение замедляет наш прогресс на каждом шаге, лишь бы справиться с редкими событиями взрыва градиентов. Популярной альтернативой является использование эвристики ограничения градиентов, которая проецирует градиенты \(\mathbf{g}\) на шар заданного радиуса \(\theta\) следующим образом:
Это гарантирует, что норма градиента никогда не превышает \(\theta\) и что обновленный градиент полностью совпадает по направлению с исходным направлением \(\mathbf{g}\). Это также имеет желательный побочный эффект: ограничение влияния, которое любой заданный мини-батч (и любой заданный пример внутри него) может оказать на вектор параметров. Это придает модели определённую степень устойчивости. Если говорить прямо, это хак. Ограничение градиентов означает, что мы не всегда следуем истинному градиенту, и аналитически трудно рассуждать о возможных побочных эффектах. Однако это очень полезный хак, который широко используется в реализациях RNN в большинстве фреймворков глубокого обучения.
Ниже мы определяем метод обрезки градиентов, который вызывается методом
fit_epoch класса d2l.Trainer (см.
Section 3.4). Обратите внимание, что при вычислении
нормы градиента мы объединяем все параметры модели, рассматривая их как
один большой вектор параметров.
@d2l.add_to_class(d2l.Trainer) #@save
def clip_gradients(self, grad_clip_val, model):
params = [p for p in model.parameters() if p.requires_grad]
norm = torch.sqrt(sum(torch.sum((p.grad ** 2)) for p in params))
if norm > grad_clip_val:
for param in params:
param.grad[:] *= grad_clip_val / norm
9.5.4. Обучение¶
Используя набор данных «The Time Machine» (data), мы обучаем
посимвольную языковую модель (model) на основе RNN (rnn),
реализованной с нуля. Обратите внимание, что мы сначала вычисляем
градиенты, затем обрезаем их и, наконец, обновляем параметры модели с
помощью обрезанных градиентов.
data = d2l.TimeMachine(batch_size=1024, num_steps=32)
rnn = RNNScratch(num_inputs=len(data.vocab), num_hiddens=32)
model = RNNLMScratch(rnn, vocab_size=len(data.vocab), lr=1)
trainer = d2l.Trainer(max_epochs=100, gradient_clip_val=1, num_gpus=1)
trainer.fit(model, data)
9.5.5. Декодирование¶
Когда языковая модель обучена, мы можем использовать её не только для предсказания следующего токена, но и для последовательного предсказания каждого последующего, рассматривая ранее предсказанный токен как следующий в последовательности ввода. Иногда нам нужно просто сгенерировать текст, как будто мы начинаем с начала документа. Однако часто полезно задать языковой модели условие, основанное на предоставленном пользователем префиксе. Например, если мы разрабатываем функцию автодополнения для поисковой системы или помогаем пользователям писать электронные письма, мы хотим подать на вход то, что они уже написали (префикс), а затем сгенерировать наиболее вероятное продолжение.
Следующий predict метод генерирует продолжение по одному символу за
раз после обработки предоставленного пользователем prefix. При
переборе символов в prefix мы продолжаем передавать скрытое
состояние на следующий временной шаг, но не генерируем никакого вывода.
Этот этап называется периодом прогрева. После обработки префикса мы
готовы начать генерацию последующих символов, каждый из которых будет
возвращён в модель в качестве входа на следующем временном шаге.
@d2l.add_to_class(RNNLMScratch) #@save
def predict(self, prefix, num_preds, vocab, device=None):
state, outputs = None, [vocab[prefix[0]]]
for i in range(len(prefix) + num_preds - 1):
X = torch.tensor([[outputs[-1]]], device=device)
embs = self.one_hot(X)
rnn_outputs, state = self.rnn(embs, state)
if i < len(prefix) - 1: # Warm-up period
outputs.append(vocab[prefix[i + 1]])
else: # Predict num_preds steps
Y = self.output_layer(rnn_outputs)
outputs.append(int(Y.argmax(axis=2).reshape(1)))
return ''.join([vocab.idx_to_token[i] for i in outputs])
В дальнейшем мы задаём префикс и генерируем ещё 20 символов.
model.predict('it has', 20, data.vocab, d2l.try_gpu())
Хотя реализация описанной выше модели RNN с нуля полезна для понимания, она неудобна. В следующем разделе мы рассмотрим, как использовать фреймворки глубокого обучения для быстрого создания RNN на основе стандартных архитектур и повышения производительности за счёт применения высокооптимизированных функций библиотек.
9.5.6. Итоги¶
Мы можем обучать языковые модели на основе рекуррентных нейронных сетей (RNN) для генерации текста, продолжая заданный пользователем префикс. Простая языковая модель на базе RNN состоит из кодирования входных данных, моделирования с помощью RNN и генерации выходных данных. В процессе обучения обрезка градиентов помогает смягчить проблему взрывающихся градиентов, но не решает проблему затухания градиентов. В эксперименте мы реализовали простую языковую модель на базе RNN и обучили её с использованием обрезки градиентов на последовательностях текста, токенизированных на уровне символов. Используя префикс в качестве условия, мы можем применять языковую модель для генерации наиболее вероятных продолжений, что оказывается полезным во многих приложениях, например, в функциях автодополнения.
9.5.7. Упражнения¶
Предсказывает ли реализованная языковая модель следующий токен на основе всех предыдущих токенов вплоть до самого первого токена в «Машине времени»?
Какой гиперпараметр определяет длину истории, используемой для прогнозирования?
Покажите, что одномерное кодирование эквивалентно выбору отдельного эмбеддинга для каждого объекта.
Настройте гиперпараметры (например, количество эпох, число скрытых единиц, количество временных шагов в мини-батче и скорость обучения), чтобы уменьшить перплекссию. Какое минимальное значение можно достичь, сохраняя эту простую архитектуру?
Замените one-hot-кодирование на обучаемые эмбеддинги. Приводит ли это к улучшению качества модели?
Проведите эксперимент, чтобы определить, насколько хорошо эта языковая модель, обученная на тексте «Машины времени», работает с другими книгами Герберта Уэллса, например с «Войной миров».
Проведите ещё один эксперимент, чтобы оценить перплексность этой модели на книгах, написанных другими авторами.
Измените метод предсказания так, чтобы он использовал сэмплинг, а не выбор наиболее вероятного следующего символа.
Что происходит?
Сместите модель в сторону более вероятных выходов, например, путем выборки из \(q(x_t \mid x_{t-1}, \ldots, x_1) \propto P(x_t \mid x_{t-1}, \ldots, x_1)^\alpha\) для \(\alpha > 1\).
Запустите код в этом разделе без ограничения градиента. Что произойдет?
Замените функцию активации, используемую в этом разделе, на ReLU, и повторите эксперименты. Нужно ли по-прежнему применять обрезку градиента? Почему?