循环神经网络(RNN)简介

循环神经网络(RNN)简介,循环神经网络是一种专门处理序列(sequences)的神经网络。它们通常用于自然语言处理(NLP)任务,因为它们在处理文本方面非常有效。在本文中,我们将探索什么是RNNs,了解它们是如何工作的,并在Python中从头构建一个真正的RNNs(仅使用numpy)。

这篇文章假设有神经网络的基本知识。我对神经网络的介绍涵盖了你需要知道的一切,所以我建议你先读一下。

让我们开始吧!

The Why

普通神经网络(以及CNNs)的一个问题是,它们只对预先确定的大小起作用:它们接受固定大小的输入并产生固定大小的输出。RNNs是有用的,因为它让我们有可变长度的序列作为输入和输出。下面是一些关于RNNs的例子:
循环神经网络(RNN)简介
输入为红色,RNN本身为绿色,输出为蓝色。来源:Andrej Karpathy
这种处理序列的能力使RNNs非常有用。例如:

  • 机器翻译(例如谷歌翻译)是通过“多对多”的RNNs来完成的。原始文本序列被输入一个RNN,然后RNN生成翻译文本作为输出。
  • 情绪分析(例如,这是一个积极的还是消极的评论?)通常是用“多对一”的RNNs来完成的。要分析的文本被输入一个RNN,然后RNN生成一个输出分类(例如,这是一个积极的评论)。

在这篇文章的后面,我们将从头构建一个“多对一”的RNN来执行基本的情绪分析。

The How

让我们考虑一个“多对多”的RNN,它的输入x_0,x_1,…,x_n,希望产生输出y_0,y_1,…,y_n。这些x_iy_i是向量,可以有任意的维数。
RNNs的工作方式是迭代地更新一个隐藏状态h,这是一个向量,也可以有任意的维数。在任意给定的步骤t,

  • 下一个隐藏状态h_t使用前一个隐藏状态h_{t-1}和下一个输入x_t计算来的
  • 下一个输出y_t是用h_t计算得来的

循环神经网络(RNN)简介
多对多RNN

这就是RNN的递归性:它对每个步骤使用相同的权重。更具体地说,一个典型的普通RNN只使用3组权重来进行计算:

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

我们的RNN也会使用两个偏差:

  • $$b_h$$,计算$$h_t$$时相加
  • $$b_y$$,计算$$y_t$$时相加

我们用矩阵表示权重,用向量表示偏差。这3个权重和2个偏差构成了整个RNN!
下面是把所有东西放在一起的方程式:
循环神经网络(RNN)简介
不要略过这些方程式。停下来,盯着这个看一分钟。另外,记住权重是矩阵,其他变量是向量。

所有的权值都使用矩阵乘法,并将偏差添加到结果乘积中。然后,我们使用tanh作为第一个方程的激活函数(但也可以使用sigmoid等其他激活函数)。

不知道什么是激活函数?阅读我之前提到的关于神经网络的介绍。认真对待。

The Problem

让我们动手干吧!我们将从头实现一个RNN来执行一个简单的情绪分析任务:确定给定的文本字符串是积极的还是负面的。
下面是我为这篇文章收集的小数据集中的一些例子:
循环神经网络(RNN)简介

The Plan

由于这是一个分类问题,我们将使用“多对一”RNN。这类似于我们前面讨论的“多对多”RNN,但它只使用最终的隐藏状态产生一个输出y:
循环神经网络(RNN)简介
多对一RNN

每个x_i都是一个向量,表示文本中的一个单词。输出y将是一个包含两个数字的向量,一个表示正数,另一个表示负数。我们将使用Softmax将这些值转换为概率,并最终在正/负之间做出决定。
让我们开始构建我们的RNN!

The Pre-Processing

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

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

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

True = Positive, False = Negative

我们必须做一些预处理才能把数据转换成可用的格式。首先,我们将构建一个包含数据中所有单词的词汇表:
main.py

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中的每个单词。
main.py

# 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)

我们现在可以用对应的整数索引表示任意给定的单词!这是必要的,因为RNNs不能理解单词——我们必须给他们数字。

最后,回忆一下RNN的每个输入x_i都是一个向量。我们将使用一个热向量(one-hot vectors),它包含一个元素为非零,而其他元素都是0。每个热向量中的“1”将位于单词对应的整数索引处。
由于我们的词汇表中有18个独特的单词,每个x_i将是一个18维的一维热向量。
main.py

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

稍后,我们将使用createInputs()创建向量输入,以传递到RNN。

The Forward Phase

是时候开始实现我们的RNN了!我们将从初始化RNN需要的3个权重和2个偏差开始:
rnn.py

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的正向传递(forward pass)。还记得我们之前看到的这两个方程吗?
循环神经网络(RNN)简介
下面是这些同样的方程被写入代码:
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))

    # 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。
让我们来试试:
main.py

# ...

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]]

我们的RNN是有效的,但是还不是很有用。让我们改变……

The Backward Phase

为了训练我们的RNN,我们首先需要一个损失函数。我们将使用交叉熵损失,它通常与Softmax配对。我们是这样计算的:
L=−\ln{(p_c)}
其中p_c是RNN对正确类(正或负)的预测概率。例如,如果我们的RNN预测一个正文本为90%,则损失为:
L = \ln{(0.90)} = 0.105
现在我们有一个损失,我们将训练我们的RNN使用梯度下降来最小化损失。这意味着是时候推导一些梯度了!

下一节假设您具备多元微积分的基本知识。如果你愿意,你可以跳过它,但我建议即使你不太明白也要略读一下。我们将在获得结果时逐步编写代码,甚至表面级别的理解也会有所帮助。

Definitions

首先,一些定义如下:

  • 令y表示RNN的原始输出。
  • 设p为最终概率:p=softmax(y)
  • 让c引用某个文本示例的真实标签,也就是“正确”类
  • 设L为交叉熵损失:L=−\ln{(p_c)}
  • W_{xh},W_{hh}W_{hy}做为我们的RNN中的3个权重矩阵
  • b_h,b_y为RNN中的两个偏置向量

Setup

接下来,我们需要编辑正向阶段来缓存一些数据,以便在反向阶段中使用。在此过程中,我们还将为反向阶段设置骨架。它是这样的:
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))

    self.last_inputs = inputs
    self.last_hs = { 0: h }

    # 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

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

    return y, h

  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

想知道为什么要进行缓存吗?请阅读我在如何训练CNN中的介绍,在这里我们做同样的事情。

Gradients

数学的时间到了。让我们从计算\frac{\partial L}{\partial y}开始。我们知道:
循环神经网络(RNN)简介
我将用链式法则把\frac{\partial L}{\partial y}的实际推导留给你们做练习,但是结果非常好:
循环神经网络(RNN)简介
例如,如果我们让p=[0.2,0.2,0.6] 和正确的类 C为0,那么我们可以得到\frac{\partial L}{\partial y}=[−0.8,0.2,0.6]这也很容易转化为代码:
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
  d_L_d_y = probs
  d_L_d_y[target] -= 1

  # Backward
  rnn.backprop(d_L_d_y)

好了。接下来,让我们研究一下W_{hy}b_y的梯度,它们只用于将最终的隐藏状态转换为RNN的输出。我们有:
循环神经网络(RNN)简介

其中h_n为最终隐藏状态。因此,
循环神经网络(RNN)简介
同样的,
循环神经网络(RNN)简介
我们现在可以开始实现backprop()了!
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

注意一下: 我们先前在forward()创建了 self.last_hs

最后,我们需要 W_{hh}W_{xh}b_h的梯度,这些梯度在 RNN 期间每一步都使用。我们有:
循环神经网络(RNN)简介

因为改变W_{xh}会影响每一个h_t,每个h_t都会影响y,最终影响L。为了充分计算出W_{xh}的梯度,我们需要通过所有的时间步长进行反向传播,这就是所谓的时间反向传播(Backpropagation through Time, BPTT):
循环神经网络(RNN)简介
时间反向传播

W_{xh}用于所有x_t–>h_t的正向链接,所以我们必须反向传播回这些链接。

一旦我们到达给定的步骤t,我们需要计算\frac{\partial h_t}{\partial W_{xh}}:
循环神经网络(RNN)简介

tanh的导数是众所周知的:
循环神经网络(RNN)简介
我们像往常一样使用链式法则:
循环神经网络(RNN)简介
同样地,
循环神经网络(RNN)简介
我们最不需要的是\frac{\partial y}{\partial h_t}。我们可以递归地计算:
循环神经网络(RNN)简介

我们将从最后一个隐藏状态开始实现BPTT并向后工作,所以当我们要计算\frac{\partial y}{\partial h_t}时,我们已经有\frac{\partial y}{\partial h_{t+1}}了!例外情况是最后一个隐藏状态,h_n:
循环神经网络(RNN)简介

现在我们终于实现了BPTT和finish backprop()所需的一切:
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

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

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

      # 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}

  • 我们不断更新一个包含最新 \frac{\partial L}{\partial h_{t+1}} 的 d_h 变量,我们需要计算 \frac{\partial L}{\partial h_t}

  • 在完成BPTT之后,我们将np.clip()的梯度值设置为小于-1或大于1。这有助于缓解爆炸梯度问题,这是当梯度变得非常大,因为有很多乘项。对于普通的RNNs来说,梯度的爆炸或消失是很有问题的——像LSTMs这样更复杂的RNNs通常能够更好地处理它们。
  • 一旦所有梯度计算完毕,我们就使用梯度下降更新权重和偏差。

我们完成了RNN。

The Culmination

终于到了我们等待的时刻——让我们测试一下我们的RNN!
首先,我们将编写一个助手函数来处理数据与我们的RNN:
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)

现在,我们可以编写训练循环:
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

从我们自己建立的RNN来看还不错。
想自己尝试或修改这段代码吗?在浏览器中运行这个RNN。它也可以在Github上使用。

The End

就是这样!在这篇文章中,我们完成了循环神经网络的演练,包括它们是什么,它们是如何工作的,它们为什么有用,如何训练它们,以及如何实现它们。不过,你还有很多事情可以做:

  • 了解长短时记忆网络(LSTM),一个更强大和流行的RNN架构,或门控递归单元(GRUs), LSTM的一个著名变体。
  • 使用适当的ML库(如Tensorflow、Keras或PyTorch)来试验更大/更好的RNNs。
  • 阅读有关双向RNNs的信息,它向前和向后处理序列,以便输出层可以获得更多信息。
  • 尝试像GloVe或Word2Vec这样的Word Embeddings,它们可以将单词转换成更有用的向量表示形式。
  • 查看自然语言工具包(NLTK),这是一个用于处理人类语言数据的流行Python库。

感谢你的阅读!

Python教程

Java教程

Web教程

数据库教程

图形图像教程

大数据教程

开发工具教程

计算机教程