[翻译] WILDML RNN系列教程 第二部分 使用Python,NumPy,Theano实现一个RNN

翻译自WILDML博客文章: Recurrent Neural Networks Tutorial, Part 2 - Implementing a RNN with Python, NumPy and Theano

这份教程是比较通俗易懂的RNN教程,从基本知识到RNN的实现,再到GRU/LSTM等变种均有详述。但是原帖中由于渲染的问题,很多LaTeX的公式都显示不了。本文初衷是为了归档这个系列的教程,并解决公式显示的问题。而后索性将其译为中文,方便以后重新回顾。

笔者才疏学浅,翻译过程中难免有误,请见谅,亦烦请勘误。

文中部分术语会按照笔者所认为之惯用词语进行翻译,英文原词亦包括其中,以供参考。

这篇文档时RNN教程的第二部分,第一部分在这里

下面的代码都托管在了Github上

这部分我们将会从无到有实现一个完整的RNN。我们使用Python编写,并使用Theano来优化我们的实现。Theano是一个能够利用GPU进行运算的工具库。这些代码都托管到了Github上。我将会跳过一些对理解RNN不是很重要的辅助代码,但所有代码都已托管到Github上。

语言模型

我们的目标是使用RNN构建一个语言模型。比如,当我们已经有了一个长度为\(m\)个单词的句子,语言模型能让我们去预测这个句子在整个数据集上出现的概率:\(P(w_1,\dots ,w_m)= \prod_{i=1}^mP(w_i \mid w_1,\dots ,w_{i-1})\)

换句话说,这个句子的概率就是“给定之前序列,出现当前词的概率”的连乘。所以说,一个句子“He went to buy some chocolate”的概率,是给定“He went to buy some”条件下"chocolate"的概率乘给定"He went to buy"条件下“some”的概率乘...以此类推。(译注:\(P(He,went,to,buy,some,chocolate)\) = \(P(chocolate \mid He,went,to,buy,some)\) × \(P(some \mid He,went,to,buy)\) × \(P(buy \mid He,went,to)\) × \(P(to \mid He,went)\) × \(P(went \mid He)\))

这有啥用?为什么我们想要知道一个句子的观察概率?

首先,这个模型能够被当做是一个“打分”方法。例如,一个机器翻译系统在给定输入序列之后会产生很多候选的输出。你能够使用语言模型去选择最有可能出现的句子。直觉上说,最大概率的句子就是最有可能正确的句子。相似的打分方法也在语音识别系统中出现过。

但是,解决语言模型的问题的时候也产生了一个很棒的副产品。因为我们能预测给定一个输入序列后下一个词的概率,所以我们能够用它来产生新的文本。这是一个生成模型。给定一个已经存在的词序列,我们能够从预测的概率中采样出下一个单词,重复这个过程,我们就能获得一个完整的句子。Andrej Karparty 有一篇文章证明了语言模型的能力。他的模型被用单独字符训练而不是整个单词,它能够产生任何文本,甚至是莎士比亚和Linux代码。

需要注意的是,上面的等式中每一个词的概率都是以这个词之前所有词所组成的序列为条件下的概率。在实际应用中,很多模型由于计算复杂度和内存大小的限制(译注:还有一个很重要的限制就是句子太长了就会很稀疏),很难去表示过长的序列。它们只能有限地向前看几个单词。RNNs在理论上能捕捉到长序列,但是实际情况还是有一些复杂的。具体内容我们将会在随后的文章中详细介绍。

训练数据和预处理

1. 切分文本(Tokenize Text)

我们拥有原始文本,但是我们想要在每个词的基础上做预测。这就意味着我们需要将文本切分成句子,再将句子切分成单词。我们可以仅仅利用空格分割文本,但是这样就没法处理标点符号。句子“He left!” 应该包含三个Tokens: "He", "left", "!"。所以我们在这里使用NLTK的word_tokenizesent_tokenize方法,这能够帮我们完成大部分麻烦的工作。

2. 删除罕见词

在我们的文本中有很多词只会出现一两次,我们可以将这些不常出现的词删掉。因为如果词表太大,将会加大模型的训练时间(稍后会讲到这点)。而且由于我们没有太多的关于这个词的上下文数据进行训练,所以很难正确地学习到这个词的使用方法。这和我们人类的学习方式很相似。想要真正理解如何正确地使用一个词,你需要在非常多的上下文环境中见过这个词才行。

在我们的代码中,限制了词表大小为vocabulary_size个最常见的单词(这里我设置成了8000,但是可以随意改动)。我们将所有没在词表中出现过的词替换成为UNKNOWN_TOKEN。例如如果在词表中没有出现“nonlinearities”这个词,那么句子“nonlineraties are important in neural networks”将会变成“UNKNOWN_TOKEN are important in neural networks”。当然,UNKNOWN_TOKEN这个词也是词表中的一部分,我们也会像其他词一样来预测这个词的出现。当我们产生文本时,可以再一次替换UNKNOWN_TOKEN,例如,随机挑一个不在词表中的词,或者干脆就不断产生句子,直到得到一个不包含任何UNKNOWN_TOKEN的句子。

3. 准备开始和结束标志 (Special Start and End Tokens)

我们还需要学习意味着句子开始和结束的标志。所以我们需要在每个句子中前置一个特殊token SENTENCE_START,再后置一个特殊token SENTENCE_END。这里我们谈一个问题:当给定的第一个token是SENTENCE_START,那最有可能的下一个词(也就是实际句子的第一个词)应该是哪个?

4. 建立训练数据矩阵

我们的RNN的输入数据是向量而非字符串。所以我们建立了一个词和索引之间的映射关系。index_to_wordword_to_index。例如,词"friendly"有可能在下标为2001的位置。所以每一个训练样本\(x\)都应该类似于[0, 179, 341, 416],其中0表示SENTENCE_START。相应的,标注\(y\)应该是[179, 341, 416, 1]这个样子。要记住,我们的目标是预测下一个词,所以\(y\)只是向量\(x\)向右平移一个位置,并且在最后一个元素位置上添加了SENTENCE_END。换句话说,输入词179时的正确预测输出应该是词341,即句子中真正的下一个词。

vocabulary_size = 8000  
unknown_token = "UNKNOWN_TOKEN"  
sentence_start_token = "SENTENCE_START"  
sentence_end_token = "SENTENCE_END"

# 读取数据,并且添加 SENTENCE_START 和 SENTENCE_END 这两个token
print "Reading CSV file..."  
with open('data/reddit-comments-2015-08.csv', 'rb') as f:  
    reader = csv.reader(f, skipinitialspace=True)
    reader.next()
    # 把完整的评论分成多个句子
    sentences = itertools.chain(*[nltk.sent_tokenize(x[0].decode('utf-8').lower()) for x in reader])
    # 附加SENTENCE_START和SENTENCE_END
    sentences = ["%s %s %s" % (sentence_start_token, x, sentence_end_token) for x in sentences]
print "Parsed %d sentences." % (len(sentences))

# 把句子Token化成一堆词
tokenized_sentences = [nltk.word_tokenize(sent) for sent in sentences]

# 统计词频
word_freq = nltk.FreqDist(itertools.chain(*tokenized_sentences))  
print "Found %d unique words tokens." % len(word_freq.items())

# G用最常见的词创建index_to_word和word_to_index这两个向量
# 译注:vocabulary_size减1是为了给unknown token留个空间。
vocab = word_freq.most_common(vocabulary_size-1)  
index_to_word = [x[0] for x in vocab]  
index_to_word.append(unknown_token)  
word_to_index = dict([(w,i) for i,w in enumerate(index_to_word)])

print "Using vocabulary size %d." % vocabulary_size  
print "The least frequent word in our vocabulary is '%s' and appeared %d times." % (vocab[-1][0], vocab[-1][1])

# 用unknown token替换未在词表中的词。
for i, sent in enumerate(tokenized_sentences):  
    tokenized_sentences[i] = [w if w in word_to_index else unknown_token for w in sent]

print "\nExample sentence: '%s'" % sentences[0]  
print "\nExample sentence after Pre-processing: '%s'" % tokenized_sentences[0]

# 创建训练数据。
X_train = np.asarray([[word_to_index[w] for w in sent[:-1]] for sent in tokenized_sentences])  
y_train = np.asarray([[word_to_index[w] for w in sent[1:]] for sent in tokenized_sentences])  

这是一个训练样本的例子:

x:  
"SENTENCE_START what are n't you understanding about this ? !"
[0, 51, 27, 16, 10, 856, 53, 25, 34, 69]

y:  
"what are n't you understanding about this ? ! SENTENCE_END"
[51, 27, 16, 10, 856, 53, 25, 34, 69, 1]

构建RNN

我曾在第一篇教程中剪短介绍了RNNs。

RNN 一个随时间展开的RNN

咱们集中注意力看看RNN在语言模型中的样子。输入\(x\)是一个单词序列(就像上面提到的例子一样)每个\(x_t\)是一个单词。除此之外还有一件事情:由于矩阵乘法的定义,我们不能简单地利用索引(index)作为输入,而是应该将每个单词表示成一个one-hot向量,向量长度为vocabulary_size。例如,一个索引是36的词,应该是一个“第36个元素是1,其余元素是0”的向量。每一个\(x_t\)都需要变成一个向量,所以\(x\)将会是一个矩阵,它的每一行都表示了一个单词。我们将会在神经网络的代码中进行这个转换,而不是在预处理中进行。网络的输出\(o\)也与之类似。每一\(o_t\)是一个具有vocabulary_size个元素的向量,每一个元素表示了句子中下一个词是这个位置对应索引的词的概率。

我们回顾一下在第一篇教程中提到的RNN的公式: $$ s_t=tanh(Ux_t+Ws_{t-1}) \\
o_t=softmax(Vs_t)
$$

我发现最好先规定好矩阵和向量的维度。比如在这里我们设置词表大小\(C=8000\),隐层大小\(H=100\)。你可以认为隐层大小就是我们网络的记忆力。设置的大一些,他就能够学习到更加复杂的模式,但相反它需要花费更多的运算性能。所以我们有: $$ x_t\in\mathbb{R}^{8000} \\
o_t\in\mathbb{R}^{8000} \\
s_t\in\mathbb{R}^{100} \\
U\in\mathbb{R}^{100 \times 8000} \\
V\in\mathbb{R}^{8000 \times 100} \\
W\in\mathbb{R}^{100 \times 100}
$$

这些信息非常有用。请记住,\(U\),\(V\)和\(W\)是你的网络江永从数据中学习到的参数,因此我们一共需要学习\(2HC+H^2\)个参数。如果\(C=8000\),\(H=100\),那一共是1,610,000个参数,这也告诉了我们性能的瓶颈在哪里。值得注意的是,由于\(x_t\)是one-hot向量,所以将它乘以\(U\)就相当于从\(U\)中选择一列,我们不需要真的去进行这个乘法操作。所以,在我们的网络中所进行的最大的矩阵乘法运算就是\(Vs_t\)。这也就是我们想要让词表小一些的原因。

有了这些数据,下面我们就开始实现代码。

初始化

我们首先要定义一个RNN类并且初始化参数。我管这个类叫做RNNNumpy,因为我们将会在后面再实现一个基于Theano的版本。初始化参数\(U\),\(V\)和\(W\)有一些技巧。我们不能只将它们初始化成一堆0,因为这会导致每一层出现对称计算(symmetric calculations)。我们必须将他们随机初始化。因为合适的初始化会对训练结果有邮箱,所以现在有很多关于这个方面(译注:初始化方法)的研究。事实证明,最好的初始化方法是跟激活函数相关的(本文中使用\(tanh\)),一个推荐的方法是在\(\left[-\frac{1}{\sqrt{n}},\frac{1}{\sqrt{n}}\right]\)的范围内随机初始化每一个权值,其中\(n\)是上一层节点的输入连接数。这个挺立起来好像挺复杂,但别太担心它,只要你在一个小范围内随机初始化参数就差不多可以。

class RNNNumpy:

    def __init__(self, word_dim, hidden_dim=100, bptt_truncate=4):
        # 分配一些成员变量
        self.word_dim = word_dim
        self.hidden_dim = hidden_dim
        self.bptt_truncate = bptt_truncate
        # 随机初始化网络参数
        self.U = np.random.uniform(-np.sqrt(1./word_dim), np.sqrt(1./word_dim), (hidden_dim, word_dim))
        self.V = np.random.uniform(-np.sqrt(1./hidden_dim), np.sqrt(1./hidden_dim), (word_dim, hidden_dim))
        self.W = np.random.uniform(-np.sqrt(1./hidden_dim), np.sqrt(1./hidden_dim), (hidden_dim, hidden_dim))

在上面的代码中word_dim是词表大小,hidden_dim是隐层大小(我们可以挑一个)。现在不用太担心bptt_truncate参数,我们会在稍后解释。

前向传播(Forward Propagation)

接下来让我们实现钱箱传播算法(预测词概率)。下面的算法是依照上面的公式所定义的:

def forward_propagation(self, x):  
    # 向前展开的总步数
    T = len(x)
    # 在前向传播过程中,我们需要保存所有的隐状态以便后续使用
    # 我们在初始化隐状态时多加一维,以便后续使用
    # 译注:这里多加一位在最后一行,能够让代码统一成一个形式。在循环地前向展开时,第0个隐状态在代码上可以依赖于第-1个(在python中即为最后一个)隐状态的值。所以说把这一行0补在最后一行可以在代码上很统一。
    s = np.zeros((T + 1, self.hidden_dim))
    s[-1] = np.zeros(self.hidden_dim)
    # 每一步输出我们也将它们保存起来
    o = np.zeros((T, self.word_dim))
    # 迭代计算每一个时间步
    for t in np.arange(T):
        # 要注意,这里我们用x[t]作为下标去从U中取数据。这与用U和一个one-hot向量相乘的效果一样。
        # 译注:x[t]为一个one-hot向量,只有一个元素是1,其余全0.
        s[t] = np.tanh(self.U[:,x[t]] + self.W.dot(s[t-1]))
        o[t] = softmax(self.V.dot(s[t]))
    return [o, s]

RNNNumpy.forward_propagation = forward_propagation  

函数的返回值不但包括了所有输出,也包括了所有因状态。我们将会利用他们在随后的操作中计算梯度,并防止重复计算。每一个\(o_t\)都是一个表示此表中词语出现概率的向量,比如说当评估我们的模型时,我们希望一个序列中的下一个单词应该获得最高的概率。我们把这个函数称为预测:

def predict(self, x):  
    # 进行前向传播,然后返回最高分的索引
    # 译注:每个向量表示一个词位置,其中vocabulary_size个元素中的每个元素都是该元素索引位置对应单词的概率。选择最大的1个作为这个位置应该的词,以此作为输出。
    o, s = self.forward_propagation(x)
    return np.argmax(o, axis=1)

RNNNumpy.predict = predict  

让我们试试新实现的方法,然后看看输出:

np.random.seed(10)  
model = RNNNumpy(vocabulary_size)  
o, s = model.forward_propagation(X_train[10])  
print o.shape  
print o  
(45, 8000)
[[ 0.00012408  0.0001244   0.00012603 ...,  0.00012515  0.00012488
   0.00012508]
 [ 0.00012536  0.00012582  0.00012436 ...,  0.00012482  0.00012456
   0.00012451]
 [ 0.00012387  0.0001252   0.00012474 ...,  0.00012559  0.00012588
   0.00012551]
 ..., 
 [ 0.00012414  0.00012455  0.0001252  ...,  0.00012487  0.00012494
   0.0001263 ]
 [ 0.0001252   0.00012393  0.00012509 ...,  0.00012407  0.00012578
   0.00012502]
 [ 0.00012472  0.0001253   0.00012487 ...,  0.00012463  0.00012536
   0.00012665]]

对于句子中的每一个词(上例中是45个词),我们的模型都能产生8000个预测值,每一个值都表示下一个单词的概率。需要注意的是,由于我们随机初始化了\(U\),\(V\)和\(W\),所以预测值现在还是完全随机的。下面的方式能够选择出每个词位置概率最高的词。

predictions = model.predict(X_train[10])  
print predictions.shape  
print predictions  
(45,)
[1284 5221 7653 7430 1013 3562 7366 4860 2212 6601 7299 4556 2481 238 2539
 21 6548 261 1780 2005 1810 5376 4146 477 7051 4832 4991 897 3485 21
 7291 2007 6006 760 4864 2182 6569 2800 2752 6821 4437 7021 7875 6912 3575]
计算损失(Loss)

为了训练网络,我们需要找到一种度量模型产生的误差的方法。我们将这个方法称为损失函数(loss function)\(L\)。我们最终的目标则是要寻找到能够在给定训练数据的情况下使损失函数最小化的一组参数。一个常用的选择就是交叉熵函数。如果我们有\(N\)个训练样本(也就是文本中的词)和\(C\)个类别(也就是词表大小),那么损失函数是一个预测\(o\)和标注\(y\)的函数:

$$ L(y,o)=-\frac{1}{N}\sum_{n\in N}y_n\log o_n
$$

这个公式看起来有点儿复杂,但实际上就是把每一个训练样本的损失加在一起,然后算算跟我们的预测的偏差有多大。(译注:这个公式有点儿特殊,由交叉熵改变而来,但只考虑了\(y^{(k)}=1\)的情况,也就是说只考虑了预测正确单词的情况。例如\(y_i=[0,0,1,0]\),而你的模型输出\(o_i=[0.1,0.1,0.5,0]\),那么计算误差时,只计算\(y_i^{(2)}\log o_i^{(2)}\)。这样做有个好处,就是由于每一个向量\(y\)中只有一个元素等于1,所以不用真的进行向量乘法,只需要从被乘向量中“挑”一个出来就可以。这个技巧在前面提到过,也在后面的代码中出现过)\(y\)(正确的词)与\(o\)(我们的预测)相差越大,则损失也就越大。我们实现了一个函数用来计算损失:

def calculate_total_loss(self, x, y):  
    L = 0
    # 对每个句子进行遍历
    for i in np.arange(len(y)):
        o, s = self.forward_propagation(x[i])
        # 我们只关心“正确”的词。
        # 译注:亦即上面提到的,这个损失函数只考虑原本正确的词与该位置对应的实际输出之间的误差。
        # 译注:下面这行代码有些复杂,我们可以仔细分析一下:
        # 译注:o是一个(一个句子中词数量)乘(词表长度)的矩阵。
        # 译注:首先用np.arange(len(y[i]))产生一个0~句子词数的向量,相当于每次选择一个对应位置。
        # 译注:y[i]是一个对应了一句话的向量。其中每一个元素都是标注认为应该选择的词。
        # 译注:这两个向量共同组成了从二维矩阵o中抽取的第一维和第二维下标。也就实现了上面所说的“选择”的效果。
        correct_word_predictions = o[np.arange(len(y[i])), y[i]]
        # 把这些损失值加在一起
        L += -1 * np.sum(np.log(correct_word_predictions))
    return L

def calculate_loss(self, x, y):  
    # 用总总误差除以样例数
    # 译注:这里的样例数可不是句子数,而是所有句子包含的所有词(token)数。
    N = np.sum((len(y_i) for y_i in y))
    return self.calculate_total_loss(x,y)/N

RNNNumpy.calculate_total_loss = calculate_total_loss  
RNNNumpy.calculate_loss = calculate_loss  

让我们回头看看,对于完全随机的预测,损失应该是多少?这将会给我们一个baseline来验证我们的方法是否正确(译注:如果代码实现的正确,那我们随机初始化的网络的损失函数应该等于这个值)。在词表中一共有\(C\)个词,所以每一个词平均被预测到的概率是\(\frac{1}{C}\),所以说损失函数应该是:\(L=-\frac{1}{N}\log \frac{1}{C}=\log C\):

# 为了节约时间,我们就只算一下前1000个词
print "Expected Loss for random predictions: %f" % np.log(vocabulary_size)  
print "Actual loss: %f" % model.calculate_loss(X_train[:1000], y_train[:1000])  

看起来非常接近!要知道,如果你有非常多的数据,那么计算整个数据集的损失是一个非常耗时的操作。

利用随机梯度下降(SGD)和Backpropagation Through Time (BPTT)来训练RNN

回忆一下,我们的目标是找到能使训练数据总损失最小的参数\(U\),\(V\)和\(W\)。最常见的方法是SGD,随机梯度下降(Stochastic Gradient Descent)。SGD背后的想法非常简单。我们我们遍历数据集中的每一个样本,在迭代过程中,去轻轻推动参数朝着减小误差的方向改变。这些方向以损失函数对参数的梯度的方向给出:\(\frac{\partial L}{\partial U}, \frac{\partial L}{\partial V}, \frac{\partial L}{\partial W}\)。随机梯度下降还需要一个学习率,这个值决定了我们每一步迭代对参数修改的大小。SGD是一个非常流行的优化方法,它不仅适用于神经网络,也适合很多其他机器学习算法。现在有很多研究使用用批处理、并行、自适应学习率来改进SGD。尽管这个想法非常简单,但想要高效地实现SGD可能非常复杂。如果你想更多地了解SGD,这篇文章或许是一个不错的开始。由于在网络上有大量的关于它的教程,所以在这里我就不多重复了。我将会实现一个非常简单的SGD版本,即使没有算法优化方面的背景知识,也很容易看明白。

那么,我们应该如何计算上面提到过的梯度?在传统的神经网络中,我们使用的方法叫做反向传播算法(backpropagation algorithm)。在RNN中,我们使用一个对这个算法稍作修改的算法,它的名字叫Backpropagation Through Time (BPTT)。由于参数在网络的所有时间步中被共享使用,所以每一个输出的梯度不止依赖于现在时间步的运算,也包含了之前的时间步的计算。如果你学过微积分,你应该知道其实这就是在应用链式法则。教程的下一部分将会介绍BPTT,所以我不会再详细介绍它的起源。想要更多地了解反向传播算法,可以看看这篇这篇文章。现在,你可以就认为BPTT像是一个黑盒,输入训练样本\((x,y)\),并且得到返回值\(\frac{\partial L}{\partial U}, \frac{\partial L}{\partial V}, \frac{\partial L}{\partial W}\)。

def bptt(self, x, y):  
    T = len(y)
    计算前向传播
    o, s = self.forward_propagation(x)
    # 我们在这些变量中累加梯度
    dLdU = np.zeros(self.U.shape)
    dLdV = np.zeros(self.V.shape)
    dLdW = np.zeros(self.W.shape)
    delta_o = o
    delta_o[np.arange(len(y)), y] -= 1.
    # 从后向前计算每一个输出
    # 译注:下面是产生一个序列,然后倒叙
    for t in np.arange(T)[::-1]:
        # 译注:下面这行是在以隐变量值为权重,按比例分配输出残差到输出节点。
        dLdV += np.outer(delta_o[t], s[t].T)
        # 开始计算残差
        delta_t = self.V.T.dot(delta_o[t]) * (1 - (s[t] ** 2))
        # BPTT算法(最多展开self.bptt_truncate步)
        for bptt_step in np.arange(max(0, t-self.bptt_truncate), t+1)[::-1]:
            # print "Backpropagation step t=%d bptt step=%d " % (t, bptt_step)
            dLdW += np.outer(delta_t, s[bptt_step-1])              
            dLdU[:,x[bptt_step]] += delta_t
            # 为下一步更新残差
            delta_t = self.W.T.dot(delta_t) * (1 - s[bptt_step-1] ** 2)
    return [dLdU, dLdV, dLdW]

RNNNumpy.bptt = bptt  
梯度检查(Gradient Checking)

无论何时,你最好在实现BP算法的时候同时实现一个梯度检查算法。这个算法能帮你验证你实现的代码是否正确。梯度检查背后的思想是参数的导数近似于在某一点的斜率。我们能够轻微地改变一点参数值,然后除以改动大小:

$$ \frac{\partial L}{\partial \theta}\approx \lim_{h\rightarrow 0}\frac{J(\theta+h)-J(\theta-h)}{2h} $$

然后我们可以对比通过BP算法得到的梯度和我们估算出来的梯度。如果没有太大的区别就说明实现的没问题。由于估算方法需要对每一个参数都计算损失,所以梯度检查是一个非常低效的算法(我们上面的例子中有超过100万个参数)。所以最好在一个小数量级上进行。

def gradient_check(self, x, y, h=0.001, error_threshold=0.01):  
    # 使用BP算法计算得到梯度。我们的目的是想要知道这个结果对不对。
    bptt_gradients = self.bptt(x, y)
    # 列举所有需要检查的参数
    model_parameters = ['U', 'V', 'W']
    # 对每个参数进行梯度检查
    for pidx, pname in enumerate(model_parameters):
        # 得到一个真正的参数值,比如说model.W
        parameter = operator.attrgetter(pname)(self)
        print "Performing gradient check for parameter %s with size %d." % (pname, np.prod(parameter.shape))
        # 遍历参数矩阵中的每一个元素,例如 (0,0), (0,1), ...
        it = np.nditer(parameter, flags=['multi_index'], op_flags=['readwrite'])
        while not it.finished:
            ix = it.multi_index
            # 保存好原始值,以便一会儿恢复回来。
            original_value = parameter[ix]
            # 用 (f(x+h) - f(x-h))/(2*h)估计参数。
            parameter[ix] = original_value + h
            gradplus = self.calculate_total_loss([x],[y])
            parameter[ix] = original_value - h
            gradminus = self.calculate_total_loss([x],[y])
            estimated_gradient = (gradplus - gradminus)/(2*h)
            # 把参数恢复成原始值
            parameter[ix] = original_value
            # 获得试用BP算法得到的梯度
            backprop_gradient = bptt_gradients[pidx][ix]
            # 计算这两个梯度的相对误差: (|x - y|/(|x| + |y|))
            relative_error = np.abs(backprop_gradient - estimated_gradient)/(np.abs(backprop_gradient) + np.abs(estimated_gradient))
            # 如果误差太大,说明有问题。
            if relative_error > error_threshold:
                print "Gradient Check ERROR: parameter=%s ix=%s" % (pname, ix)
                print "+h Loss: %f" % gradplus
                print "-h Loss: %f" % gradminus
                print "Estimated_gradient: %f" % estimated_gradient
                print "Backpropagation gradient: %f" % backprop_gradient
                print "Relative Error: %f" % relative_error
                return
            it.iternext()
        print "Gradient check for parameter %s passed." % (pname)

RNNNumpy.gradient_check = gradient_check

# 为了避免太大的计算开销,我们把词表大小调小一些进行检查。
grad_check_vocab_size = 100  
np.random.seed(10)  
model = RNNNumpy(grad_check_vocab_size, 10, bptt_truncate=1000)  
model.gradient_check([0,1,2,3], [1,2,3,4])  
实现随机梯度下降

现在我们能实现一个SGD算法从我们的参数中计算得到梯度。主要需要做这两步:1. 实现函数sdg_step,这能够对每一个batch计算得到梯度,然后进行更新。2. 一个外部循环遍历整个训练数据集,然后调节学习率。

# 执行一步SGD
def numpy_sdg_step(self, x, y, learning_rate):  
    # 计算梯度
    dLdU, dLdV, dLdW = self.bptt(x, y)
    # 根据梯度和学习率改变参数
    self.U -= learning_rate * dLdU
    self.V -= learning_rate * dLdV
    self.W -= learning_rate * dLdW

RNNNumpy.sgd_step = numpy_sdg_step  
# SGD的外层循环
# - model: RNN模型实例
# - X_train: 训练集
# - y_train: 训练集标注
# - learning_rate: SGD的初始学习率
# - nepoch: 对整个数据集遍历的次数
# - evaluate_loss_after: Evaluate the loss after this many epochs
def train_with_sgd(model, X_train, y_train, learning_rate=0.005, nepoch=100, evaluate_loss_after=5):  
    # 我们跟踪损失的变化以便后来画图
    losses = []
    num_examples_seen = 0
    for epoch in range(nepoch):
        # 选择性地计算损失函数
        if (epoch % evaluate_loss_after == 0):
            loss = model.calculate_loss(X_train, y_train)
            losses.append((num_examples_seen, loss))
            time = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
            print "%s: Loss after num_examples_seen=%d epoch=%d: %f" % (time, num_examples_seen, epoch, loss)
            # 如果损失在上升,则调整学习率
            if (len(losses) > 1 and losses[-1][1] > losses[-2][1]):
                learning_rate = learning_rate * 0.5 
                print "Setting learning rate to %f" % learning_rate
            sys.stdout.flush()
        # 对每一个训练样本
        for i in range(len(y_train)):
            # 进行一步SGD
            model.sgd_step(X_train[i], y_train[i], learning_rate)
            num_examples_seen += 1

完成!让我们试试需要训练多久。

np.random.seed(10)  
model = RNNNumpy(vocabulary_size)  
%timeit model.sgd_step(X_train[10], y_train[10], 0.005)

不好,每一步SGD大约需要350毫秒。在整个训练集中我们有大约80,000个样本,每一个epoch(需要在整个数据集上进行迭代)大约需要花费几个小时。执行几个epoch可能需要几天甚至几个星期。而且我们的这个数据集与很多公司和研究者的工作相比也是非常小的。现在改怎么办?

幸运的是,有很多种方法能够使我们的代码加速。一条路是仍然用一样的模型,修改代码让代码跑出更快的速度。另一条路是将我们的模型修改的更加容易计算。当然也可以两条路同时走。研究者们放了很多种方式去简化模型的计算过程,例如,通过hierarchical softmax或者添加投影层(projection layers)来避免大矩阵乘法(可以参考这里这里)。但是我们想要让这个模型简单一些,所以走上第一条路:实现一个能用GPU的代码来执行得快一点儿。在做这件事之前,我们首先用一个比较小的数据集来验证损失真的在训练中减小。

np.random.seed(10)  
# Train on a small subset of the data to see what happens
model = RNNNumpy(vocabulary_size)  
losses = train_with_sgd(model, X_train[:100], y_train[:100], nepoch=10, evaluate_loss_after=1)  
2015-09-30 10:08:19: Loss after num_examples_seen=0 epoch=0: 8.987425  
2015-09-30 10:08:35: Loss after num_examples_seen=100 epoch=1: 8.976270  
2015-09-30 10:08:50: Loss after num_examples_seen=200 epoch=2: 8.960212  
2015-09-30 10:09:06: Loss after num_examples_seen=300 epoch=3: 8.930430  
2015-09-30 10:09:22: Loss after num_examples_seen=400 epoch=4: 8.862264  
2015-09-30 10:09:38: Loss after num_examples_seen=500 epoch=5: 6.913570  
2015-09-30 10:09:53: Loss after num_examples_seen=600 epoch=6: 6.302493  
2015-09-30 10:10:07: Loss after num_examples_seen=700 epoch=7: 6.014995  
2015-09-30 10:10:24: Loss after num_examples_seen=800 epoch=8: 5.833877  
2015-09-30 10:10:39: Loss after num_examples_seen=900 epoch=9: 5.710718  

还不错,看起来我们实现的没啥大问题,损失函数真的在减小,确实是我们想要的结果。

使用THEANO和GPU来训练我们的网络

之前我曾经写过一篇介绍Theano的文章,我们代码的逻辑完全是一样的,我不会再介绍一遍。我定义了一个RNNTheano类,将其中的NumPy计算代码替换成Theano的计算代码。代码也托管到了GitHub

np.random.seed(10)  
model = RNNTheano(vocabulary_size)  
%timeit model.sgd_step(X_train[10], y_train[10], 0.005)

这次,在我的没有GPU的Mac上每一步SGD大约需要70毫秒。在亚马逊EC2配有GPU的g2.2xlarge实例上需要大约23毫秒。这比我们刚开始实现的那一个版本快了15倍左右。也就是说我们不再需要几星期,只需要几个小时或者几天的时间就能完成训练了。除此之外我们仍然可以做很多的优化,但目前来说已经足够了。

为了避免花太多的时间,我已经训练好了一个Theano模型,它的隐层维度是50,词表大小是8000. 我花费了20个小时训练了50个epoch。其实这个模型还可以继续训练以得到更低的损失和更好的性能,但我着急发表这篇文章没有太多时间继续训练。你可以随意尝试训练更久一些。你能够在Github库的data/trained-model-theano.npz这个地方找到模型的参数。然后使用load_model_parameters_theano函数:

from utils import load_model_parameters_theano, save_model_parameters_theano

model = RNNTheano(vocabulary_size, hidden_dim=50)  
# losses = train_with_sgd(model, X_train, y_train, nepoch=50)
# save_model_parameters_theano('./data/trained-model-theano.npz', model)
load_model_parameters_theano('./data/trained-model-theano.npz', model)  
生成文本

现在我们能利用模型去生成新的文本了!让我们时下你个辅助函数来产生新的句子:

def generate_sentence(model):  
    # 我们用start token来开始一个句子
    new_sentence = [word_to_index[sentence_start_token]]
    # 重复这个过程,直到我们得到一个end token
    while not new_sentence[-1] == word_to_index[sentence_end_token]:
        next_word_probs = model.forward_propagation(new_sentence)
        sampled_word = word_to_index[unknown_token]
        # 我们不选择未知单词
        while sampled_word == word_to_index[unknown_token]:
            samples = np.random.multinomial(1, next_word_probs[-1])
            sampled_word = np.argmax(samples)
        new_sentence.append(sampled_word)
    sentence_str = [index_to_word[x] for x in new_sentence[1:-1]]
    return sentence_str

num_sentences = 10  
senten_min_length = 7

for i in range(num_sentences):  
    sent = []
    # 我们想要的句子是比较长的句子,而不是只有一两个词的句子
    while len(sent) > senten_min_length:
        sent = generate_sentence(model)
    print " ".join(sent)

下面是我挑的几个句子,我已经把首字母大写了:

  • Anyway, to the city scene you’re an idiot teenager.
  • What ? ! ! ! ! ignore!
  • Screw fitness, you’re saying: https
  • Thanks for the advice to keep my thoughts around girls.
  • Yep, please disappear with the terrible generation.

从生成的句子中能发现一些有趣的地方。模型成功地学到了语法。它还能正确地使用逗号(经常出现在and'sor's之前),而且还能以句号结尾。有时候还会模仿网络语言,比如很多感叹号和微笑符。

然而,大部分产生的句子都是有语法错误而且没有意义的(上面的结果是我选择的最好的结果)。一个原因是我们进行的训练还不够(或者没有使用更多地训练数据)。这是有可能的,但这不是主要原因。我们的基本的RNN模型不能产生有意义的文本,是因为它很难学到距离这个词太远的词。这也是为什么RNNs在刚被提出时没有得到广泛的应用。它在理论上非常完美但实际工作中并不是很好,并且我们现在还不能很快地知道原因。

幸运的是,训练RNNs的难点,在现在已经是很好理解的了。在下面的几篇文章中,我们将会更深入地研究BPTT算法,并将揭示一个叫做梯度扩散(vanishing gradient)问题。这将会迫使我们去使用更加复杂的RNN模型,比如LSTMs,这个模型能够在很多NLP任务中达到目前为止最好的效果(你也可以产生更好的reddit评论!)。即便基本的RNN模型不如你预期的好,也不用失落,因为在这篇教程中你学到的所有东西也都在LSTMs和其他RNN模型中适用。

再次感谢你,请在评论区提问或给出反馈!请别忘记check out代码

Friskit

继续阅读此作者的更多文章