Введение в рекуррентные нейронные сети вместе с кодом с нуля

Оглавление

  1. Обзор
  2. Модель
  3. Прямое распространение
  4. Обратное распространение во времени (BPTT)
  5. Обновления веса
  6. Усеченное обратное распространение во времени (TBPTT)
  7. Заключение

Обзор

В этой статье мы рассмотрим математические вычисления рекуррентной нейронной сети. Мы исследуем результаты прямого прохода и градиенты обратного прохода. Если вы не знакомы с прямым и обратным распространением в обычной нейронной сети, прочтите эту статью: Пошаговые нейросетевые вычисления прямого и обратного распространения

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

Некоторые популярные применения рекуррентных нейронных сетей - это распознавание речи, прогнозирование временных рядов, NLP (машинный перевод, ответы на вопросы) и распознавание жестов. Существуют различные варианты RNN, в зависимости от количества входов и выходов модели. Ниже приведены 4 основные структуры:

Примеры

  • один к одному: двоичная классификация [отдельные изображения / слова → один вывод]
  • один-ко-многим: подписи к изображениям [изображение → Последовательность слов] (размер варьируется)
  • многие-к-одному: тональность [последовательность слов (переменного размера) → единичная тональность]
  • многие-ко-многим (несоответствие): машинный перевод [последовательность слов → последовательность слов], где обе последовательности имеют переменную длину, и эти длины могут различаться.
  • многие-ко-многим (сопоставление): классификация видео на покадровом уровне [кадр → некоторое решение], где входные и выходные данные имеют одинаковую длину, поскольку решение принимается на каждом временном шаге.

В оставшейся части статьи мы рассмотрим RNN типа "многие ко многим" (соответствие).

Модель

На каждом временном шаге t:

  • x_t: исходный ввод в сеть
  • h_t: скрытое состояние, которое передается во времени от h_ {t-1} до h_t
  • y_t: вывод на каждом временном шаге

Веса распределяются между всеми временными шагами

  • W_x: введите веса.
  • W_h: скрытые веса
  • W_y: веса вывода между скрытым и входным.

Инициализировать весовые матрицы

def __init__(self, input_dim, hidden_dim, output_dim, alpha=1e-3):
    
    # Weights
    self.W_x = np.random.uniform(0, 1, (hidden_dim, input_dim))
    self.W_h = np.random.uniform(0, 1, (hidden_dim, hidden_dim))
    self.W_y = np.random.uniform(0, 1, (output_dim, hidden_dim))

    # Biases
    self.b_h = np.random.uniform(0, 1, (hidden_dim, hidden_dim))
    self.b_y = np.random.uniform(0, 1, (output_dim, hidden_dim))
    self.hidden_dim = hidden_dim
    self.output_dim = output_dim
    self.learning_rate = alpha

Прямое распространение

Шаг прямого распространения аналогичен прямому распространению для обычной нейронной сети. Если вы не знакомы с этим процессом, ознакомьтесь с этой статьей, в которой математические расчеты проводятся поэтапно.

Еще раз, есть три набора веса: W_x, W_h и W_y. в РНС. Эти веса остаются неизменными на всех временных шагах для данной последовательности.

  • W_x - это весовая матрица для входных данных, которые передаются в сеть на каждом временном шаге (в зависимости от структуры).
  • W_h - матрица весов для скрытых входных данных, которые возвращаются в модель (из-за повторяющейся структуры).
  • W_y - матрица весов для выходных данных сети.

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

1. Преобразуйте и объедините входное и скрытое состояние, затем примените функцию активации.

2. Преобразуйте скрытое состояние, затем примените функцию активации для получения вывода.

Функции активации

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

Почему Тан?

Поскольку у RNN есть проблема исчезающего / увеличивающегося градиента, нам нужно сохранить градиент в линейной области функции. Следовательно, нам нужна функция, вторая производная которой может выдерживать долгое время, прежде чем станет равной нулю. Обратите внимание, что Tanh близок к линейному по обе стороны от оси y. Градиент в этих областях близок к 1. По сути, мы выбираем tanh как скрытую активацию, потому что он имеет эти свойства.

def tanh(self, x):
    return np.tanh(x)

Почему Softmax?

Функция softmax - это, по сути, нормализующая функция, которая принимает вектор логитов и преобразует его в категориальное распределение. Значения z_i вектора Z будут сжаты до 0 ≤ z_i ≤ 1 и сумма всех классов z_i от i до K составит в сумме 1.

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

В качестве альтернативы мы также могли бы добавить пробелы в выходной вектор или пустую строку для дополнения последовательностей. После того, как функция Softmax сожмет логиты в векторе, каждое значение будет находиться в диапазоне от 0 до 1, представляя вероятность того, что это определенная буква. Мы могли бы выбрать argmax выходного вектора в качестве нашего прогноза, где аргументы от 0 до 25 представляют буквы от a до z.

def softmax(self, x):
    return np.exp(x) / np.sum(np.exp(x), axis=0)

Скрытое состояние

Текущее скрытое состояние зависит от предыдущего скрытого состояния и входного вектора на этом текущем временном шаге. По сути, скрытое состояние можно рассматривать как память в сети.

Развернув функцию, обратите внимание, что W_h преобразует предыдущее скрытое состояние, а W_x преобразует входные данные. Затем добавляется член смещения и применяется функция активации гиперболического тангенса.

Мы инициализируем скрытое состояние h_0 всеми нулями. Скрытое состояние следует сбрасывать с каждой новой партией, как рекомендуется здесь. В этой статье мы будем сбрасывать скрытое состояние при каждом прямом проходе.

Выход

Выходное значение вычисляется на каждом временном шаге путем преобразования скрытого состояния с весом W_y и добавления члена смещения b_y . Затем функция Softmax преобразует логиты в категориальное распределение.

Обратите внимание, что выходные данные y_t являются вектором размера N для N и что каждый элемент вектора может быть представлен как i, где i - в наборе от 1 до N.

Другими словами, эта функция - это нормализованный термин для одного класса i. Нормирующий член находится в знаменателе. Этот знаменатель просто собирает все члены от 1 до N в выходном векторе, суммирует их, а затем делит i -й член в выходном векторе на нормализующее значение.

def forward_propagation(self, x):
    T = len(x)
    # for backpropagation calculation
    ## notice the hidden state gets reset each forward pass
    hidden_states = np.zeros(T+1, self.hidden_dim)
    outputs = np.zeros((T, self.output_dim))
   
    # process sequence individually
    for t in range(T):
        hidden_states[t] = self.tanh(np.dot(self.W_h, hidden_states[t-1]) + np.dot(self.W_x, x[t]) + self.b_h)

        y_t = self.softmax(np.dot(self.W_y, hidden_states[t]) + self.b_y)

    return outputs, hidden_states

Обратное распространение во времени (BPTT)

Из-за повторяющейся природы RNN обратное распространение становится более сложным, потому что градиенты веса должны распространяться дальше назад через все предыдущие временные шаги. Это порождает проблему исчезающих градиентов, на которую обращаются LSTMS и ГРУ.

Кросс-энтропийная потеря

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

Обратите внимание, что потеря кросс-энтропии не является функцией расстояния, поскольку L (y, yhat) не равно L (yhat, y). Эта функция потерь вычисляет потери для одного временного шага t, точнее, она вычисляет потери между y_ {t} и yhat_ {t}

def cross_entropy_loss(self, y, yhat): 
    return np.sum(y * np.log(yhat))

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

def calculate_total_loss(self, x, y):

    L_total = 0
    
    for t in range(len(y)): # from t=0 to T
        outputs, hidden_states = self.forward_propagation(x[t])
        yhat = outputs[np.arange(len(y[i])), y[i]]
        L += self.cross_entropy_loss(y, yhat)

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

Почему потеря кросс-энтропии вместо MSE или какой-либо другой потери?

Иногда люди используют softmax loss как взаимозаменяемые с кросс-энтропийными потерями, что технически неверно. Функция softmax, применяемая в качестве функции активации, выводит категориальное распределение по выходам, где сумма этих выходных вероятностей равна 1.

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

В этом векторе, предполагая, что индексирование не начинается с нуля, правильный класс i = 3 или третий член. RNN предсказала класс i = 3 с достоверностью 0,343.

Каждый i представляет отдельный класс из N классов. Обратите внимание, что для классов, имеющих цель 0, весь термин для i-го класса равен 0, а для target_i = 1, y_i * log (yhat_i) = 1 * log (yhat_i) . Поскольку значения в целевом объекте имеют горячую кодировку, они содержат много нулей. Убедитесь, что цели находятся в правильном положении и вы ведете только журнал прогнозов, поскольку для любого target_i = 0 log (0) = undefined.

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

Этот термин эпсилон предотвращает деление на 0.

Градиенты

Чтобы избежать избыточности, я откажусь от деталей вычислений градиента. Если вы хотите увидеть математику, проверьте это. Он проходит через все этапы получения градиентов RNN.

def backward_propagation_through_time(self, x, y):
    # from forward pass  
    outputs = self.outputs
    hidden_states = self.hidden_states
    # accumulate gradients here
    dLdX = np.zeros(self.W_x.shape)
    dLdH = np.zeros(self.W_h.shape)
    dLdY = np.zeros(self.W_y.shape)
    d_out = outputs
    d_out[np.arange(len(y)), y] -= 1 # y_hat - y
    # backward propagation starting from T = len(y)
    for t in range(len(y)-1, -1, -1): # T-1 to 0
         dLdY += np.outer(d_out[t], hidden_states[t].T)
         dt = np.dot(self.W_y, d_out[t])*(1-hidden_states[t]**2)
         # from current timestep to 0
         for t_i in range(t, -1, -1): 
               dLdX += np.outer(dt, hidden_states[t_i-1])
               dldX[:, x[t_i]] += dt
               # update for next step t_i-1
               dt = np.dot(self.W_h, delta_t)*(1-hidden_states[t_i-1]**2)
    
      return dLdX, dLdH, dLdY # gradients

Обновления веса

Учитывая скорость обучения альфа, мы обновим веса, используя эту формулу.

def update_weights(self, dLdX, dLdH, dLdY):
    self.W_x -= self.learning_rate * dLdX
    self.W_h -= self.learning_rate * dLdH
    self.W_y -= self.learning_rate * dLdY

Усеченное обратное распространение во времени (TBPTT)

Усеченный BPTT сохраняет вычислительные преимущества Backpropagation Through Time (BPTT), избавляя при этом от необходимости полного обратного отслеживания всей последовательности данных на каждом этапе.

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

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

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

Усеченное обратное распространение во времени изменяет BPTT с учетом двух основных параметров: k1 и k2.

  • k1: количество временных шагов для прямого прохода между обновлениями веса, не включая обратный проход. Этот параметр влияет на время обучения.
  • k2: количество временных шагов для обратного прохода. Этот параметр можно уменьшить, чтобы устранить исчезающие градиенты, чтобы градиенты не уходили так далеко назад. Однако он должен быть достаточно большим, чтобы отображать временную структуру последовательности.

Алгоритм

k1 временных шагов для прогнозирования → k2 временных шагов для вычислений ошибок и градиентов → обновить веса → промыть и повторить

Исчезающие градиенты

Обучение RNN зависит от объединения производных, что приводит к трудностям в изучении долгосрочных зависимостей. Если у нас есть длинное предложение, например «Коричнево-черная собака, которая играла с кошкой и мышкой, была немецкой овчаркой», сеть будет с трудом научиться предсказывать слово «пастырь», которое зависит от коричневого и черный пес. Это связано с вычислением градиента W_x.

В этом предложении из 18 слов «пастырь» - 18-е слово. Градиент dL_18 / dy_18 должен быть полностью связан с W_x. Если мы просто развернем скрытый градиент dh_15 / dh_2, который представляет собой слово коричневый (слово 2) и слово пастырь (слово 15), мы заметим множество операций умножения между терминами.

Умножение значений между 0 и 1 быстро уменьшается до 0, поэтому градиент быстро приближается к 0 или «исчезает». Это приводит к тому, что сеть не обучается, потому что, если градиент равен 0, веса никогда не будут обновляться. Это главный недостаток RNN. Однако ГРУ и LSTM решают эти проблемы.

Заключение

В этой статье мы представили RNN вместе с кодом в numpy. Мы изучили математические аспекты обучения RNN как на прямой, так и на обратной фазе распространения. Обсуждались вопросы, связанные с RNN, такие как исчезающие и взрывающиеся градиенты. Мы также рассмотрели, как RNN обучаются на практике с использованием усеченного BPTT и преимущества этого алгоритма.

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

Далее: Простой код дешифрования RNN

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

использованная литература