Курсовая работа. Глубокое обучение
Скачать 4.97 Mb.
|
211 После инициализации нашего слоя нужно описать передачу данных вперед. Метод forward В целом, метод forward состоит из приема на вход ndarray x_seq_in фор- мы (batch_size, sequence_length, feature_size) и ее последовательной передачи через все RNNNode данного слоя. В приведенном ниже коде self. nodes — это RNNNode для слоя, а H_in — скрытое состояние слоя: sequence_length = x_seq_in.shape[1] x_seq_out = np.zeros((batch_size, sequence_length, self.output_size)) for t in range(sequence_length): x_in = x_seq_in[:, t, :] y_out, H_in = self.nodes[t].forward(x_in, H_in, self.params) x_seq_out[:, t, :] = y_out Небольшое замечание о скрытом состоянии H_in : скрытое состояние RNNLayer обычно представлено в виде вектора, но операции в каждом RNNNode требуют, чтобы скрытое состояние имело размерность ndarray (batch_size, hidden_size) . Поэтому в начале каждого прямого прохода мы просто «повторяем» скрытое состояние: batch_size = x_seq_in.shape[0] H_in = np.copy(self.start_H) H_in = np.repeat(H_in, batch_size, axis=0) После прямого прохода мы берем среднее значение по наблюдениям, составляющим пакет, чтобы получить обновленное скрытое состояние для этого слоя: self.start_H = H_in.mean(axis=0, keepdims=True) Кроме того, из этого кода видно, что у RNNNode должен быть метод forward , который принимает два массива со следующими размерностями: 212 Глава 6. Рекуррентные нейронные сети y (batch_size, feature_size) ; y (batch_size, hidden_size) и возвращает два массива размерностей: y (batch_size, output_size) ; y (batch_size, hidden_size) Мы рассмотрим реализацию класса RNNNode (и ее варианты) в следую- щем разделе. Но сначала давайте рассмотрим метод backward для класса RNNLayer Метод backward Так как выходом метода forward является x_seq_out , у метода backward на входе должен быть градиент той же формы, что и x_seq_out , под названи- ем x_seq_out_grad . Двигаясь в направлении, противоположном прямому методу, мы передаем этот градиент в обратном направлении через узлы RNN, в конечном итоге возвращая x_seq_in_grad формы (batch_size , sequence_length , self feature_size) в качестве градиента для всего слоя: h_in_grad = np.zeros((batch_size, self.hidden_size)) sequence_length = x_seq_out_grad.shape[1] x_seq_in_grad = np.zeros((batch_size, sequence_length, self.feature_size)) for t in reversed(range(sequence_length)): x_out_grad = x_seq_out_grad[:, t, :] grad_out, h_in_grad = \ self.nodes[t].backward(x_out_grad, h_in_grad, self.params) x_seq_in_grad[:, t, :] = grad_out То есть у RNNNode должен быть метод backward , который, следуя шаблону, является противоположностью метода forward , принимая два массива фигур: RNN: код 213 y (batch_size, output_size) ; y (batch_size, hidden_size) и возвращая два массива фигур: y (batch_size, feature_size) ; y (batch_size, hidden_size) И это работа RNNLayer . Теперь кажется, что осталось только описать ядро рекуррентной нейронной сети: узлы RNNNode , где происходят реальные вычисления. Прежде чем мы это сделаем, давайте проясним роль RNNNode и их вариантов в общей работе RNN Основные элементы узлов RNNNode При работе с RNN разговор обычно начинается именно с узлов RNNNode Но мы рассмотрим узлы последними, так как до этого момента посредством схем и описаний пытались понять самую суть RNN: как структурируются данные и как данные и скрытые состояния передаются между слоями. Оказывается, существует несколько способов реализации самих RNNNode , фактической обработки данных с заданным временным шагом и обнов- ления скрытого состояния слоя. Первый способ — это так называемые «обычные» рекуррентные нейронные сети, которые мы будем также назы- вать «классическими RNN». Однако существуют и другие, более сложные способы реализации RNN. Пример такого способа — это вариант с узлами RNN под названием GRU, что означает «Gated Recurrent Units» (управ- ляемые рекуррентные блоки). Часто говорят, что GRU и другие варианты RNN значительно отличаются от классических RNN, но важно помнить, что в них все равно используется одна и та же структура слоев, которую мы уже рассмотрели. Например, во всех этих сетях одинаково передаются данные во времени и обновляются скрытые состояния на каждом шаге. Единственное отличие между ними — это внутренняя работа этих «узлов». Еще раз подчеркнем: если бы мы реализовали GRULayer вместо RNNLayer , код был бы точно таким же! Ядро прямого прохода выглядит следующим образом: sequence_length = x_seq_in.shape[1] x_seq_out = np.zeros((batch_size, sequence_length, self.output_size)) 214 Глава 6. Рекуррентные нейронные сети for t in range(sequence_length): x_in = x_seq_in[:, t, :] y_out, H_in = self.nodes[t].forward(x_in, H_in, self.params) x_seq_out[:, t, :] = y_out Единственное отличие состоит в том, что каждый «узел» в self nodes будет реализован как GRUNode вместо RNNNode . Метод backward тоже не изменится. Это также почти справедливо для самого известного варианта классиче- ских RNN: LSTM, или ячеек «долгая краткосрочная память» (англ. Long Short Term Memory). Единственная разница состоит в том, что в слоях LSTMLayer нужно, чтобы слой запоминал две величины и обновлял их, когда элементы последовательности передаются во времени: в дополне- ние к «скрытому состоянию» в слое хранится «состояние ячейки», что позволяет лучше моделировать долгосрочные зависимости. В результате возникают небольшие различия между реализациями LSTMLayer и RNNLayer Так, у слоев LSTMLayer будет два массива ndarray для хранения состояния слоя с течением времени: y Ndarray start_H формы (1, hidden_size) , где хранится скрытое состо- яние слоя; y Ndarray start_C формы (1, cell_size) , где хранится состояние ячейки слоя. Каждый узел LSTMNode должен принимать входные данные, а также скрытое состояние и состояние ячейки. На прямом проходе это будет выглядеть так: y_out, H_in, C_in = self.nodes[t].forward(x_in, H_in, C_in self.params) а в методе backward : grad_out, h_in_grad, c_in_grad = \ self.nodes[t].backward(x_out_grad, h_in_grad, c_in_grad, self.params) Мы упомянули всего три варианта, но их существует намного больше, например LSTM со «смотровыми глазкˆами» хранят состояние ячейки в дополнение к скрытому состоянию, а некоторые из них поддержива- RNN: код 215 ют только скрытое состояние 1 . Но в целом, слой, состоящий из узлов LSTMPeepholeConnectionNode , будет вписываться в RNNLayer так же, как и все другие реализации, и методы forward и backward будут такими же. Рассмотренная базовая структура RNN — способ, которым данные на- правляются вперед через слои, а также вперед с течением времени и затем назад во время обратного прохода, — уникальная черта рекуррентных сетей. Реальные структурные различия между классическими RNN и RNN на основе LSTM относительно невелики, а вот их характеристики могут существенно отличаться. Теперь давайте посмотрим на реализацию RNNNode «Классические» узлы RNN RNN принимают данные по одному элементу последовательности за раз; например, если мы хотим спрогнозировать цену на нефть, на каждом временном шаге RNN будет получать информацию о функциях, которые мы используем для прогнозирования цены на данном временном шаге. Кроме того, RNN хранит в своем «скрытом состоянии» кодировку, пред- ставляющую совокупную информацию о том, что произошло на преды- дущих временных шагах. Нам нужно превратить эти данные (а именно признаки текущей точки данных и накопленную информацию от всех предыдущих шагов) в прогноз для этого шага, а также в обновленное скрытое состояние. Чтобы понять, как RNN делает это, вспомним, что происходит в обычной нейронной сети. В прямой нейронной сети каждый слой получает набор «изученных признаков» от предыдущего слоя. Каждый такой набор пред- ставляет собой комбинацию исходных признаков, которую сеть «счита- ет» полезными. Затем слой умножает эти признаки на матрицу весов, что позволяет слою изучать объекты, которые являются комбинациями признаков, полученными слоем в качестве входных данных. Чтобы нор- мализовать выход, мы добавляем к этим новым признакам «смещение» и пропускаем их через функцию активации. В рекуррентных нейронных сетях нужно сделать так, чтобы наше обнов- ленное скрытое состояние включало в себя и входные данные, и старое 1 Загляните в «Википедию», чтобы почитать о разных вариантах LSTM, по адресу oreil.ly/2TysrXj 216 Глава 6. Рекуррентные нейронные сети скрытое состояние. Это похоже на то, что происходит в обычных ней- ронных сетях: 1. Сначала мы объединяем ввод и скрытое состояние. Затем мы умно- жаем это значение на матрицу весов, добавляем смещение и передаем результат через тангенциальную функцию активации. Это и будет наше обновленное скрытое состояние. 2. Затем мы умножаем это новое скрытое состояние на весовую ма- трицу, которая преобразует скрытое состояние в выход с требуемой размерностью. Например, если мы используем этот RNN для про- гнозирования одного непрерывного значения на каждом временном шаге, нам нужно умножить скрытое состояние на весовую матрицу размера (hidden_size, 1) Таким образом, наше обновленное скрытое состояние будет зависеть и от входных данных, полученных на текущем временном шаге, и от предыдущего скрытого состояния, а выходные данные будут являться результатом передачи этого обновленного скрытого состояния через операции полносвязанного слоя. Пора написать код. Пишем код для RNNNode В приведенном ниже коде мы реализуем шаги, описанные абзацем выше. Позже мы сделаем то же самое с GRU и LSTM (как уже поступали с про- стыми математическими функциями, которые обсуждали в главе 1), и идея будет общая: мы сохраняем все значения, вычисленные на прямом проходе, как атрибуты, хранящиеся в классе Node , чтобы затем использо- вать их для вычисления обратного прохода: def forward(self, x_in: ndarray, H_in: ndarray, params_dict: Dict[str, Dict[str, ndarray]] ) -> Tuple[ndarray]: ''' param x: массив numpy формы (batch_size, vocab_size) param H_prev: массив numpy формы (batch_size, hidden_size) return self.x_out: массив numpy формы (batch_size, vocab_size) return self.H: массив numpy формы (batch_size, hidden_size) ''' RNN: код 217 self.X_in = x_in self.H_in = H_in self.Z = np.column_stack((x_in, H_in)) self.H_int = np.dot(self.Z, params_dict['W_f']['value']) \ + params_dict['B_f']['value'] self.H_out = tanh(self.H_int) self.X_out = np.dot(self.H_out, params_dict['W_v']['value']) \ + params_dict['B_v']['value'] return self.X_out, self.H_out Еще одно замечание: поскольку здесь мы не используем класс Param- Operations , хранить параметры придется по-другому. Мы будем хранить их в словаре params_dict , который ссылается на параметры по имени. Кроме того, у каждого параметра будет два ключа: значение и производная (то есть градиент). В прямом проходе нам потребуется только значение. RNNNode: обратный проход На обратном проходе RNNNode просто вычисляет значения градиентов потерь по отношению к входам в RNNNode , учитывая градиенты потерь по отношению к выходам RNNNode . Здесь работает такая же логика, которую мы обсуждали в главах 1 и 2. Поскольку RNNNode предоставляется в виде последовательности операций, то можно просто вычислить производную каждой операции, рассчитанной на ее входе, и последовательно умножить эти производные на те, что были раньше (по правилам умножения матриц), чтобы получить массивы ndarray , где будут храниться градиенты потерь по отношению к каждому из входов. Напишем код: def forward(self, x_in: ndarray, H_in: ndarray, params_dict: Dict[str, Dict[str, ndarray]] ) -> Tuple[ndarray]: ''' param x: массив numpy формы (batch_size, vocab_size) param H_prev: массив numpy формы (batch_size, hidden_size) return self.x_out: массив numpy формы (batch_size, vocab_size) 218 Глава 6. Рекуррентные нейронные сети return self.H: массив numpy формы (batch_size, hidden_size) ''' self.X_in = x_in self.H_in = H_in self.Z = np.column_stack((x_in, H_in)) self.H_int = np.dot(self.Z, params_dict['W_f']['value']) \ + params_dict['B_f']['value'] self.H_out = tanh(self.H_int) self.X_out = np.dot(self.H_out, params_dict['W_v']['value']) \ + params_dict['B_v']['value'] return self.X_out, self.H_out Обратите внимание, что, как и в классе Operaton до этого, формы входов в функцию backward должны соответствовать формам выходов функции forward , а формы выходов backward — формам входов forward Ограничения классических узлов RNN Вспомним, что цель RNN — выявлять зависимости в последовательностях данных. В терминах нашего примера о прогнозировании цены на нефть это означает, что мы должны иметь возможность выявить связь между последовательностью признаков, которые мы определили ранее, и тем, что произойдет с ценой на нефть на следующем шаге. Но сколько по- следних шагов нужно взять? В отношении цен на нефть можно предпо- ложить, что наиболее важной будет «вчерашняя» информация, то есть на один временной шаг назад, «позавчерашняя» будет уже чуть менее важной, и т. д — ценность информации, как правило, снижается по мере продвижения назад во времени. Для многих задач это верно, но есть и такие области применения RNN, где требуется изучать много данных и длительные зависимости. Классиче- ский пример — моделирование языка, то есть построение модели, которая сможет предсказать следующий символ, слово или часть слова, учитывая весьма длинный ряд предыдущих слов или символов (это распространен- ная задача, и мы обсудим некоторые ее особенности позже в этой главе). Для этого классических RNN обычно недостаточно. Теперь, когда мы RNN: код 219 знаем чуть больше, становится ясно, почему: на каждом временном шаге скрытое состояние умножается на одну и ту же весовую матрицу. Рас- смотрим, что происходит, когда мы умножаем число на значение x снова и снова: если x < 1 , число уменьшается в геометрической прогрессии до 0, и если x > 1 , число увеличивается экспоненциально до бесконечности. У рекуррентных нейронных сетей та же проблема: поскольку на каж- дом временном шаге один и тот же набор весов умножается на скрытое состояние, градиент весов со временем становится либо чрезвычайно маленьким, либо чрезвычайно большим. Первая проблема известна как «затухание градиента», а вторая — «взрыв градиента». Оба эти явления затрудняют обучение RNN моделированию долгосрочных зависимостей (50–100 временных шагов), необходимых для высококачественного моделирования языка. Есть две известные модификации классических архитектур RNN, которые мы рассмотрим далее и у которых эта проблема в значительной степени решена. Решение: узлы GRUNode Классические RNN берут входные значения и скрытое состояние, объ- единяют их, а затем с помощью умножения матриц определяют то, на- сколько важна информация в скрытом состоянии по сравнению с входной информацией для прогнозирования выходных данных. Более продвину- тые варианты RNN строятся вокруг идеи о том, что для моделирования долгосрочных зависимостей, таких как языковые, мы иногда получаем информацию, которая говорит, что нужно «забывать» или «сбрасывать» наше скрытое состояние. Простой пример — символы точки «.» или двоеточия «:». Если модель встречает один из этих символов, то знает, что нужно забыть все, что было ранее, и начинать моделировать новую последовательность с нуля. Первый вариант RNN с этой идеей — это GRU, или Gated Recurrent Units (управляемые рекуррентные нейроны), названные так потому, что вход и предыдущее скрытое состояние проходят через серию «шлюзов». 1. Первый шлюз аналогичен операциям, которые выполняются в клас- сических RNN: входное и скрытое состояния объединяются, умно- жаются на матрицу весов, а затем проходят через сигмоиду. Это шлюз «обновления». 220 Глава 6. Рекуррентные нейронные сети 2. Второй шлюз — это шлюз «сброса»: входное и скрытое состояния объединяются, умножаются на весовую матрицу, проходят через сигмоиду, а затем умножаются на предшествующее скрытое состо- яние. Это позволяет сети «учиться забывать», что было в скрытом состоянии, учитывая текущий вход. 3. Затем выход второго шлюза умножается на другую матрицу и пере- дается через функцию Tanh , причем выходные данные получаются новым «потенциальным» скрытым состоянием. 4. Наконец, в скрытое состояние попадает шлюз обновления, умно- женный на «потенциальное» новое скрытое состояние плюс старое скрытое состояние, умноженное на 1, минус шлюз обновления. В этой главе мы рассмотрим два усовершенствованных варианта классических RNN: GRU и LSTM. LSTM более популярны и были изобретены задолго до GRU. Однако GRU — это более простая версия LSTM, в которой яснее видно, как идея шлюзов может позволить RNN «научиться сбрасывать» свое скрытое состояние с учетом входных данных, поэтому начнем именно с GRU. GRUNode: схема На рис. 6.8 узел GRUNode изображен как серия шлюзов. Каждый шлюз со- держит операции плотного слоя: умножение на матрицу весов, добавление смещения и пропуск результата через функцию активации. Функции активации — сигмоида, и тогда результат оказывается в диапазоне от 0 до 1, либо Tanh , в этом случае получается диапазон от –1 до 1. Диапазон каждого промежуточного ndarray , созданного следующим, показан под именем массива. На рис. 6.8, как и на рис. 6.9 и 6.10, входные данные узла окрашены в зе- леный цвет, вычисленные промежуточные величины окрашены в синий цвет, а выходные данные — в красный. Все веса (на рисунке не показаны) содержатся в шлюзах. Обратите внимание, что для обратного распространения через такую сеть нам нужно будет использовать последовательность экземпляров Operation , вычислить производную каждой операции по отношению к ее входу и перемножить результаты. Здесь это явно не показано, так как |