客至汲泉烹茶, 抚琴听者知音

0基础入门神经网络:递归神经网络(RNN)

原文地址

为什么使用RNN?

递归神经网络(Recurrent Neural Networks,RNN)是一种专门处理序列的神经网络。由于它们在处理文本方面的有效性,因此经常用于自然语言处理(NLP)任务。在本文中,我们将探讨RNN是什么,了解它们的工作原理,并在Python中从头开始构建一个真正的RNN。(仅使用numpy)。

这篇文章假定您具有神经网络的基础知识。建议先阅读神经网络基础

最原始(vanilla,原意为香草,在机器学习中等同于raw)的神经网络和CNNs存在一个问题:只能接受固定的输入并产生固定的输出。RNN则不同,它可以接受并输出可变长度序列,以下是RNN的一些例子:

输入为红色,RNN本身为绿色,输出为蓝色。资料来源:Andrej Karpathy

这种处理序列的能力使RNN非常有用。例如:

  • 机器翻译(例如Google Translate)使用“多对多” 型RNN进行。原始文本序列输送到RNN,然后RNN生成翻译后的文本并输出。
  • 情绪分析(例如,这是正面还是负面的评价?)通常使用“多对一”的RNN进行。将要分析的文本输入到RNN中,然后生成一个单一的输出分类(例如,这是正面的评价)。

在本文的后半部分,我们将构建一个“多对一”式的RNN,实现一个最简单的情感分析功能

如何做?

让我们考虑一个“多对多”式的RNN,它接受$x _ { 0 } , x _ { 1 } , \dots x _ { n }$的输入,并产生$y _ { 0 } , y _ { 1 } , \dots y _ { n }$的输出,xi和yi可以是任意维度的向量。

RNN借助一个迭代更新的 隐状态(hidden state)h来工作,h也是一个任意维度的向量。在任意给定的步长t上,有:

  1. 下一个 隐状态$h_t$通过先前的 隐状态$h_{t-1}$和下一个输入 $x_t$计算而来。
  2. 下一个输出$y_t$由$h_t$计算而来。

一个多对多RNN

这就是使RNN 反复出现的原因它对每个步骤使用相同的权重。更具体地说,典型的原始RNN仅使用3组权重来执行其计算:

  • $W_{xh}$用于所有的$x_t\to h_t$链接
  • $W_{hh}$用于所有的$h_{t-1}\to h_t$链接
  • $W_{hy}$用于所有的$h_t\to y_t$链接

同时我们为RNN应用两个偏置(biases):

  • $b_h$,计算$h_t$的时候需要加上它。
  • $b_y$,计算$y_t$的时候需要加上它。

我们用矩阵表示权重,用向量表示偏置。这3个权重和2个偏置构成了整个RNN!

以下是将所有内容组合在一起的方程式:

$$\begin{array} { c } { h _ { t } = \tanh \left( W _ { x h } x _ { t } + W _ { h h } h _ { t - 1 } + b _ { h } \right) } \\ { y _ { t } = W _ { h y } h _ { t } + b _ { y } } \end{array}$$

不要跳过这些方程式。停下来思考一下。另外,请记住,权重是矩阵,其他变量是向量。

所有的权重都使用矩阵乘法,并且将偏置加到乘积结果中。然后,我们将tanh用作第一个方程式的激活函数(但是也可以使用S型等其他激活函数)。

问题

我们将从头开始实现RNN,以执行简单的情感分析任务:确定给定的文本字符串是正面的还是负面的。

这是我为本文汇总的小型数据集的一部分:

TextPositive?
i am good
i am bad
this is very good
this is not bad
i am bad not good
i am not at all happy
this was good earlier
i am not at all bad or sad right now

计划

由于这是一个分类问题,因此我们将使用“多对一” RNN。这类似于我们之前讨论的“多对多” RNN,但是它仅使用最终的 隐状态来输出y

多对一RNN

每 个$x_i$将是一个向量,代表文本中的一个单词。输出$y$是一个包含两个数字的向量,一个代表正面,另一个代表负面。我们将用Softmax将这些值转换为概率,并最终确定正负。

让我们开始构建我们的RNN!

预处理

我前面提到的数据集由两个Python字典组成:

train_data = {
  'good': True,
  'bad': False,
  # ... more data
}

test_data = {
  'this is happy': True,
  'i am good': True,
  # ... more data
}

True = Positive, False = Negative

我们必须进行一些预处理才能将数据转换为可用格式。首先,我们将构建一个包含了数据中所有单词的词汇表:

from data import train_data, test_data

# Create the vocabulary.
vocab = list(set([w for text in train_data.keys() for w in text.split(' ')]))
vocab_size = len(vocab)
print('%d unique words found' % vocab_size) # 18 unique words found

vocab现在包含训练文本中的所有单词的列表。接下来,我们将分配一个整数索引来表示vocab中的每个单词。

# Assign indices to each word.
word_to_idx = { w: i for i, w in enumerate(vocab) }
idx_to_word = { i: w for i, w in enumerate(vocab) }
print(word_to_idx['good']) # 16 (this may change)
print(idx_to_word[0]) # sad (this may change)
译者注:出现Reloaded modules: data错误是正常的,不影响使用,实在不想看到红字,可以注释掉from data这一句

现在,我们可以用其相应的整数索引表示任何给定的单词!这是必要的,因为RNN无法理解单词-我们必须给它们编号。

最后,回想一下,每个输入RNN的$x_i$都是向量。我们将使用one-hot向量(即独热编码),每个向量中只含有一个1,其他的全是0。向量中的1位于单词对应的整数索引处。

对独热编码不了解的同学,可以参考这篇文章,讲得挺清楚的。

由于我们的词汇表中有18个独特的单词,因此每个单词$x_i$将是一个18维的独热矢量。

import numpy as np

def createInputs(text):
  '''
  Returns an array of one-hot vectors representing the words
  in the input text string.
  - text is a string
  - Each one-hot vector has shape (vocab_size, 1)
  '''
  inputs = []
  for w in text.split(' '):
    v = np.zeros((vocab_size, 1))
    v[word_to_idx[w]] = 1
    inputs.append(v)
  return inputs

前向阶段

是时候应用我们的RNN了!我们将从初始化RNN所需的3个权重和2个偏置开始:

import numpy as np
from numpy.random import randn

class RNN:
  # A Vanilla Recurrent Neural Network.

  def __init__(self, input_size, output_size, hidden_size=64):
    # Weights
    self.Whh = randn(hidden_size, hidden_size) / 1000
    self.Wxh = randn(hidden_size, input_size) / 1000
    self.Why = randn(output_size, hidden_size) / 1000

    # Biases
    self.bh = np.zeros((hidden_size, 1))
    self.by = np.zeros((output_size, 1))

注意:我们将除以1000以减小权重的初始方差。这不是初始化权重的最佳方法,但它很简单并且适用于本文。

我们使用np.random.randn()从标准正态分布中初始化权重。

接下来,让我们实现RNN的前向传递。还记得我们前面看到的这两个方程吗?

$$\begin{array} { c } { h _ { t } = \tanh \left( W _ { x h } x _ { t } + W _ { h h } h _ { t - 1 } + b _ { h } \right) } \\ { y _ { t } = W _ { h y } h _ { t } + b _ { y } } \end{array}$$

下面是代码实现:

class RNN:
  # ...

  def forward(self, inputs):
    '''
    Perform a forward pass of the RNN using the given inputs.
    Returns the final output and hidden state.
    - inputs is an array of one hot vectors with shape (input_size, 1).
    '''
    h = np.zeros((self.Whh.shape[0], 1))

    # Perform each step of the RNN
    for i, x in enumerate(inputs):
      h = np.tanh(self.Wxh @ x + self.Whh @ h + self.bh)

    # Compute the output
    y = self.Why @ h + self.by

    return y, h

很简单,对吧?注意我们第一步将h初始化为零向量,因为之前没有h可供我们使用。

让我们尝试一下:

def softmax(xs):
  # Applies the Softmax Function to the input array.
  return np.exp(xs) / sum(np.exp(xs))

# Initialize our RNN!
rnn = RNN(vocab_size, 2)

inputs = createInputs('i am very good')
out, h = rnn.forward(inputs)
probs = softmax(out)
print(probs) # [[0.50000095], [0.49999905]]

如果您需要有关Softmax的复习,请阅读我对Softmax的简单解释

我们的RNN可以用,但还不是很有用。让我们改变一下……

后向阶段

为了训练RNN,我们需要一个损失函数。我们使用交叉熵损失函数(cross-entropy loss),它通常与Softmax相结合。下面是计算它的公式:

$$L = - \ln \left( p _ { c } \right)$$

其中$p_c$是我们的RNN对正确类别(正面或负面)的预测概率。例如,如果我们的RNN预测正面文本有90%的可能为正面,则损失为:

$$L = - \ln \left(0.9 \right)=0.105$$

想要更多解释?参考 CNNs中关于交叉熵损失 部分的介绍。

现在我们的目标就是使用梯度下降法(gradient descent)训练CNN,以最小化损失。这意味着是时候对梯度求导了!

以下部分假定了您有多元微积分的基本知识。您可以根据需要跳过它,但是即使您不太了解,我还是建议您略读一下。

Ready? Here we go.

定义

首先,做一些定义:

  • 令y代表我们RNN的原始输出。
  • 令p为最终概率:$p = \text{softmax}(y)$。
  • 令c为某个文本集的真实标签(也称为“正确”类)。
  • 令L为CNN的交叉熵损失:$L = -\ln(p_c)$
  • 令$W_{xh}$, $W_{hh}$, 和 $W_{hy}$为RNN中的3个权重矩阵。
  • 令$b_h$ 和 $b_y$为RNN的2个偏置向量。

设定

接下来,我们需要编辑前向阶段以缓存一些数据,供后向阶段使用。在此过程中,我们还将为我们的后向阶段设置框架(skeleton)。代码如下:

# Header: rnn.py
class RNN:
  # ...

  def forward(self, inputs):
    '''
    Perform a forward pass of the RNN using the given inputs.
    Returns the final output and hidden state.
    - inputs is an array of one hot vectors with shape (input_size, 1).
    '''
    h = np.zeros((self.Whh.shape[0], 1))

    # highlight-start
    self.last_inputs = inputs
    self.last_hs = { 0: h }
    # highlight-end

    # Perform each step of the RNN
    for i, x in enumerate(inputs):
      h = np.tanh(self.Wxh @ x + self.Whh @ h + self.bh)
      self.last_hs[i + 1] = h # highlight-line

    # Compute the output
    y = self.Why @ h + self.by

    return y, h

# highlight-start
  def backprop(self, d_y, learn_rate=2e-2):
    '''
    Perform a backward pass of the RNN.
    - d_y (dL/dy) has shape (output_size, 1).
    - learn_rate is a float.
    '''
    pass
# highlight-end
对为什么要缓存感到疑惑?我们在CNN的训练概述 中介绍过,这里的原因类似。

梯度

数学时间到!我们从计算 $\frac{\partial L}{\partial y}$开始,我们知道:

$$ L = -\ln(p_c) = -\ln(\text{softmax}(y_c)) $$

我将保留实际的使用链式法则的求导 $\frac{\partial L}{\partial y}$作为你的练习,但结果非常好:

$$ \frac{\partial L}{\partial y_i} = \begin{cases} p_i & \text{if $i \neq c$} \\ p_i - 1 & \text{if $i = c$} \\ \end{cases} $$

例如,如果我们有 $p = [0.2, 0.2, 0.6]$ ,正确的组是 $c = 0$, 那么有 $\frac{\partial L}{\partial y} = [-0.8, 0.2, 0.6]$. 代码实现也非常简单:

# Header: main.py
# Loop over each training example
for x, y in train_data.items():
  inputs = createInputs(x)
  target = int(y)

  # Forward
  out, _ = rnn.forward(inputs)
  probs = softmax(out)

  # Build dL/dy
  # highlight-start
  d_L_d_y = probs
  d_L_d_y[target] -= 1
  # highlight-end

  # Backward
  rnn.backprop(d_L_d_y) # highlight-line

非常好,接下来我们探讨$W_{hy}$和 $b_y$的梯度,它们仅用于将最终的隐状态(hidden state)转换为RNN的输出。我们有:

$$ \frac{\partial L}{\partial W_{hy}} = \frac{\partial L}{\partial y} * \frac{\partial y}{\partial W_{hy}} $$

$$ y = W_{hy} h_n + b_y $$

其中 $h_n$ 是最终的隐状态。

$$ \frac{\partial y}{\partial W_{hy}} = h_n $$

$$ \frac{\partial L}{\partial W_{hy}} = \boxed{\frac{\partial L}{\partial y} h_n} $$

类似地,

$$ \frac{\partial y}{\partial b_y} = 1 $$

$$ \frac{\partial L}{\partial b_y} = \boxed{\frac{\partial L}{\partial y}} $$

我们现在可以实现backprop()了!

# Header: rnn.py
class RNN:
  # ...

  def backprop(self, d_y, learn_rate=2e-2):
    '''
    Perform a backward pass of the RNN.
    - d_y (dL/dy) has shape (output_size, 1).
    - learn_rate is a float.
    '''
    n = len(self.last_inputs)

    # Calculate dL/dWhy and dL/dby.
    # highlight-start
    d_Why = d_y @ self.last_hs[n].T
    d_by = d_y
    # highlight-end
提醒:在之前的 forward() 我们已经创建了self.last_hs .

最后,我们需要 $W_{hh}$, $W_{xh}$,和 $b_h$的梯度, 它们被用于RNN的每一步:

$$ \frac{\partial L}{\partial W_{xh}} = \frac{\partial L}{\partial y} \sum_t \frac{\partial y}{\partial h_t} * \frac{\partial h_t}{\partial W_{xh}} $$

因为改变$W_{xh}$ 会影响到每一个 $h_t$, 它们都会影响y和最终的L。为了完全计算$W_{xh}$的梯度, 我们需要通过所有的时间步(timesteps)进行反向传播, 这种方法被称为”基于时间的反向传播算法“(Backpropagation Through Time,BPTT):

基于时间的反向传播算法

$W_{xh}$用于所有 $x_t→h_t$的前向链接,因此我们必须反向传播回每个链接。

一旦我们到达给定的步骤t,我们需要计算$\frac{\partial h_t}{\partial W_{xh}}$:

$$ h_t = \tanh (W_{xh} x_t + W_{hh} h_{t-1} + b_h) $$

$\tanh$ 的导数众所周知:

$$ \frac{d \tanh(x)}{dx} = 1 - \tanh^2(x) $$

同样进行链式求导:

$$ \frac{\partial h_t}{\partial W_{xh}} = \boxed{(1 - h_t^2) x_t} $$

类似地,

$$ \frac{\partial h_t}{\partial W_{hh}} = \boxed{(1 - h_t^2) h_{t-1}} $$

$$ \frac{\partial h_t}{\partial b_h} = \boxed{(1 - h_t^2)} $$

我们需要的最后一个导数是 $\frac{\partial y}{\partial h_t}$. 我们可以递归地计算它:

$$ \begin{aligned} \frac{\partial y}{\partial h_t} &= \frac{\partial y}{\partial h_{t+1}} * \frac{\partial h_{t+1}}{\partial h_t} \\ &= \frac{\partial y}{\partial h_{t+1}} (1 - h_t^2) W_{hh} \\ \end{aligned} $$

我们从最后一个隐状态开始实施BPTT并后向工作, 所以在我们想计算$\frac{\partial y}{\partial h_t}$时我们已经有了 $\frac{\partial y}{\partial h_{t+1}}$ ! 最后一个隐状态, $h_n$是例外:

$$ \frac{\partial y}{\partial h_n} = W_{hy} $$

我们现在有实现BTPP的一切条件,代码如下:

# Header: rnn.py
class RNN:
  # ...

  def backprop(self, d_y, learn_rate=2e-2):
    '''
    Perform a backward pass of the RNN.
    - d_y (dL/dy) has shape (output_size, 1).
    - learn_rate is a float.
    '''
    n = len(self.last_inputs)

    # Calculate dL/dWhy and dL/dby.
    d_Why = d_y @ self.last_hs[n].T
    d_by = d_y

    # Initialize dL/dWhh, dL/dWxh, and dL/dbh to zero.
    d_Whh = np.zeros(self.Whh.shape)
    d_Wxh = np.zeros(self.Wxh.shape)
    d_bh = np.zeros(self.bh.shape)

    # Calculate dL/dh for the last h.
    d_h = self.Why.T @ d_y

    # Backpropagate through time.
    for t in reversed(range(n)):
      # An intermediate value: dL/dh * (1 - h^2)
      temp = ((1 - self.last_hs[t + 1] ** 2) * d_h)

      # dL/db = dL/dh * (1 - h^2)
      d_bh += temp # highlight-line

      # dL/dWhh = dL/dh * (1 - h^2) * h_{t-1}
      d_Whh += temp @ self.last_hs[t].T # highlight-line

      # dL/dWxh = dL/dh * (1 - h^2) * x
      d_Wxh += temp @ self.last_inputs[t].T # highlight-line

      # Next dL/dh = dL/dh * (1 - h^2) * Whh
      d_h = self.Whh @ temp

    # Clip to prevent exploding gradients.
    for d in [d_Wxh, d_Whh, d_Why, d_bh, d_by]:
      np.clip(d, -1, 1, out=d)

    # Update weights and biases using gradient descent.
    self.Whh -= learn_rate * d_Whh
    self.Wxh -= learn_rate * d_Wxh
    self.Why -= learn_rate * d_Why
    self.bh -= learn_rate * d_bh
    self.by -= learn_rate * d_by

注意事项:

  • 为了方便,我们合并$\frac{\partial L}{\partial y} * \frac{\partial y}{\partial h}$ 为 $\frac{\partial L}{\partial h}$ .
  • 我们不断更新变量 d_h ,它包含最新的 $\frac{\partial L}{\partial h_{t+1}}$, 我们需要计算 $\frac{\partial L}{\partial h_t}$.
  • 完成BPTT后, 我们使用np.clip() 方法对小于-1或大于1的梯度值进行裁剪,这有助于缓解爆炸性梯度(exploding gradient)问题,即包含大量相乘项时梯度会变得非常大。梯度消失问题对于原始RNN来说是一个很大的问题-像LSTM这样的更复杂的RNN通常能更容易地解决这些问题。
  • 计算完所有梯度后,我们将使用梯度下降更新权重和偏差。

我们的RNN完成了!

测试

首先,我们将编写一个辅助函数来使用RNN处理数据:

# Header: main.py
import random

def processData(data, backprop=True):
  '''
  Returns the RNN's loss and accuracy for the given data.
  - data is a dictionary mapping text to True or False.
  - backprop determines if the backward phase should be run.
  '''
  items = list(data.items())
  random.shuffle(items)

  loss = 0
  num_correct = 0

  for x, y in items:
    inputs = createInputs(x)
    target = int(y)

    # Forward
    out, _ = rnn.forward(inputs)
    probs = softmax(out)

    # Calculate loss / accuracy
    loss -= np.log(probs[target])
    num_correct += int(np.argmax(probs) == target)

    if backprop:
      # Build dL/dy
      d_L_d_y = probs
      d_L_d_y[target] -= 1

      # Backward
      rnn.backprop(d_L_d_y)

  return loss / len(data), num_correct / len(data)

现在,我们可以编写训练循环:

# Header: main.py
# Training loop
for epoch in range(1000):
  train_loss, train_acc = processData(train_data)

  if epoch % 100 == 99:
    print('--- Epoch %d' % (epoch + 1))
    print('Train:\tLoss %.3f | Accuracy: %.3f' % (train_loss, train_acc))

    test_loss, test_acc = processData(test_data, backprop=False)
    print('Test:\tLoss %.3f | Accuracy: %.3f' % (test_loss, test_acc))

运行main.py应该输出如下内容:

--- Epoch 100
Train:  Loss 0.688 | Accuracy: 0.517
Test:   Loss 0.700 | Accuracy: 0.500
--- Epoch 200
Train:  Loss 0.680 | Accuracy: 0.552
Test:   Loss 0.717 | Accuracy: 0.450
--- Epoch 300
Train:  Loss 0.593 | Accuracy: 0.655
Test:   Loss 0.657 | Accuracy: 0.650
--- Epoch 400
Train:  Loss 0.401 | Accuracy: 0.810
Test:   Loss 0.689 | Accuracy: 0.650
--- Epoch 500
Train:  Loss 0.312 | Accuracy: 0.862
Test:   Loss 0.693 | Accuracy: 0.550
--- Epoch 600
Train:  Loss 0.148 | Accuracy: 0.914
Test:   Loss 0.404 | Accuracy: 0.800
--- Epoch 700
Train:  Loss 0.008 | Accuracy: 1.000
Test:   Loss 0.016 | Accuracy: 1.000
--- Epoch 800
Train:  Loss 0.004 | Accuracy: 1.000
Test:   Loss 0.007 | Accuracy: 1.000
--- Epoch 900
Train:  Loss 0.002 | Accuracy: 1.000
Test:   Loss 0.004 | Accuracy: 1.000
--- Epoch 1000
Train:  Loss 0.002 | Accuracy: 1.000
Test:   Loss 0.003 | Accuracy: 1.000

看起来不错!

是否想亲自尝试或修改此代码?在浏览器中运行此RNNGithub上也可获得这段代码。

尾声

在这篇文章中,我们介绍了基础的递归神经网络,包括它们是什么,它们如何工作,它们为什么有用,如何训练它们以及如何代码实现。您可以更深入地学习:

  • 了解长短期记忆网络( Long short-term memory), 一个更强大更流行的RNN框架,或者了解门控递归单元 ( Gated Recurrent Units,GRUs), (LSTM的一个著名变体)的信息。
  • 尝试使用ML库,例如 TensorflowKeras,或PyTorch
  • 阅读有关双向RNN(Bidirectional RNNs)的信息,该RNN可以处理前向和后向序列,以输出层可以获得更多信息
  • 尝试字嵌入(Word Embeddings)如GloVeWord2Vec,其可以用于将字符转换为转换为更有用的矢量形式。
  • 查看自然语言工具包(Natural Language Toolkit,NLTK),这是一个流行的Python库,用于处理人类语言数据。

添加新评论