用 python numpy theano 实现 RNN

668 阅读21分钟
原文链接: blog.csdn.net

这是Github的代码地址

在这一部分我们将会用Python从头实现一个完整的RNN,并使用Theano(一个在GPU上执行操作的库)优化我们的实现。 Github上提供了完整的代码。我将跳过一些对于理解循环神经网络不是必要的样板代码,但所有这些代码也都在Github上。

语言模型

我们的目标是用RNN建立一个语言模型,也就是说现在有一个有m个词的句子,语言模型允许我们预测观察句子(在给定数据集中)的概率:

换句话说,句子的概率是每个单词给出它前面的单词的概率的乘积。所以,P(他去买,一些,巧克力)=P(一些|他去买)*P(巧克力|他去买,一些)。

为什么这很有用?为什么我们要给观察一个句子分配一个概率?

首先,这样的模型可以用作打分机制。例如,机器翻译系统通常为输入句子生成多个候选。你可以使用语言模型来选择最可能的句子。直观地来说,可能性最大的句子语法可能是正确的。同样也可以在 语音识别系统中进行打分。

解决语言建模问题也有一个很酷的副作用。因为我们可以预测一个词在给出前面的词出现的概率,所以我们能够生成新的文本。这是一个生成模型。给定现有的单词序列,我们从预测的概率中抽样下一个单词,并重复该过程直到我们有一个完整的句子。 Andrej Karparthy有一个很棒的博客介绍了语言模型能力。他的模型是在单个字符而不是完整的单词上进行训练,其不仅可以莎士比亚的文章也可以生成 Linux代码。

需要注意的是在上述等式中每个词的概率都以所有先前词为条件。在实践中,由于计算或存储器限制,许多模型很难处理这种长期依赖。他们通常只限于查看前面几个字。理论上,RNN可以捕获这样的长期依赖性,但在实践中它有点复杂。我们将在以后的文章中探讨。

训练数据和预处理

为了训练我们的语言模型,我们需要文本来学习。幸运的是,我们不需要任何标签来训练语言模型,只需要原始文本。我从Google的BigQuery上提供的数据集下载了15,000个longish reddit评论。我们的模型将会生成看起来像reddit评论的文本(希望)!但是和大多数机器学习项目一样,我们首先需要做一些预处理以获得正确的数据格式。

  1. TOKENIZE TEXT
    现在我们有了原始文本,但我们希望在每个字的基础上进行预测。这意味着我们必须将我们的评论切分为句子,并将句子切分为词语。我们可以用空格分隔每个句子,但这不会正确处理标点符号,句子“He left!”应该是3个token:“He”, “left”, “!”。我们将使用NLTK的word_tokenize和sent_tokenize方法,它们为我们做了大部分的工作。

  2. 移除低频词
    在我们的文本中的大多数单词只会出现一两次。删除这些不常出现的字词是个好主意。有一个巨大的词汇量会使我们的模型缓慢训练(后面我们会谈到这是为什么),并且因为没有很多这种单词的语境示例,我们将无法正确地学习如何使用它们。这与人类的学习非常相似,要真正理解如何适当地使用一个单词,你需要看到它在不同上下文的用法。
    在我们的代码中,我们将用vocabulary_size 限制常见单词的词汇大小(我设置为8000,但可以随时改变它)。我们用UNKNOWN_TOKEN替换不包括在词汇表中的所有单词。例如,单词“非线性”不在我们的词汇中,则句子“非线性在神经网络中是重要的”变成“UNKNOWN_TOKEN在神经网络中是重要的”。单词UNKNOWN_TOKEN将成为我们词汇的一部分,我们将像任何其他单词一样预测它。当我们生成新文本时,我们可以再次替换UNKNOWN_TOKEN,例如取一个不在我们的词汇表中的随机抽样的单词,或者我们可以只生成句子,直到我们得到一个不包含未知的记号。

  3. 准备特殊的开始和结束标签
    我们还想知道哪些词倾向在一个句子的开始和结束。为此,我们在前面加上一个特殊的SENTENCE_START标签,并为每个句子附加一个特殊的SENTENCE_END标签。这样我们可以提问:鉴于第一个标签SENTENCE_START,下一个字(实际上句子中的第一个字)最可能的什么?

  4. 建立训练数据矩阵
    RNN的输入是向量,而不是字符串。因此,我们在单词和其索引之间创建一个映射,index_to_word和word_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"

# Read the data and append SENTENCE_START and SENTENCE_END tokens
print "Reading CSV file..."
with open('data/reddit-comments-2015-08.csv', 'rb') as f:
    reader = csv.reader(f, skipinitialspace=True)
    reader.next()
    # Split full comments into sentences
    sentences = itertools.chain(*[nltk.sent_tokenize(x[0].decode('utf-8').lower()) for x in reader])
    # Append SENTENCE_START and SENTENCE_END
    sentences = ["%s %s %s" % (sentence_start_token, x, sentence_end_token) for x in sentences]
print "Parsed %d sentences." % (len(sentences))

# Tokenize the sentences into words
tokenized_sentences = [nltk.word_tokenize(sent) for sent in sentences]

# Count the word frequencies
word_freq = nltk.FreqDist(itertools.chain(*tokenized_sentences))
print "Found %d unique words tokens." % len(word_freq.items())

# Get the most common words and build index_to_word and word_to_index vectors
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])

# Replace all words not in our vocabulary with the 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]

# Create the training data
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

RNN的介绍在第一篇教程里。
这里写图片描述

让我们具体看看我们的语言模型的RNN是什么样子。输入 x 是一系列单词(就像上面的示例一样),每个 x t 是一个单词。还需要注意一点的是:由于矩阵乘法的工作原理,我们不能简单地使用单词索引(如36)作为输入。相反,我们将每个单词表示为词汇大小的one-hot向量。例如,索引是36的单词这样一个向量:除了在位置36处是1以外,其他位置都是0。因此,每个 x t 都转变成向量, x 是一个矩阵,其中每行表示一个单词。我们将在神经网络代码中进行这个转换。网络的输出 o 具有类似的格式。每个 o t 是一个vocabulary_size元素的向量,每个元素代表该单词作为句子中下一个单词的概率。

让我们回忆一下RNN的方程:

s t =t anh(U x t +W s t −1 )

o t =s oftma x(V s t )

我发现写下矩阵和向量的维度很有用。假设我们选择词汇量C = 8000和隐藏层大小H = 100。你可以把隐藏层大小看作我们网络的“内存”。隐藏层越大我们学习模式就可以越复杂,但计算量也越大。然后我们得到:
x t ∈R 8000
o t ∈R 8000
s t ∈R 100
U ∈R 100 ×8000
W ∈R 100 ×100
V ∈R 8000 ×100

这是很有价值的信息。请记住, U , V和 W 是我们要从数据中学习的网络的参数。因此,我们需要学习总共 2 HC +H 2 个参数。在C = 8000和H = 100的情况下是1,610,000。维度大小同时还告模型的瓶颈。注意,因为 x t 是一个one-hot 向量,所以它与 U 相乘本质上就是从与 U 中选择一列,所以我们需要执行完全乘法。网络中最大的矩阵乘法是 V s t ,这就是为什么我们希望词汇量尽可能小一点。

有了这些,我们就可以开始训练了。

初始化训练参数

我们首先声明一个RNN类来初始化我们的参数,这个类是RNNNumpy。因为我们将实现一个Theano版本,初始化参数 U , V和 W 有点棘手,我们不能将它们初始化为0,因为这将导致所有层中的对称计算。我们必须随机初始化它们。正确的初始化参数对训练结果有影响,在这方面有很多研究。事实证明最好的初始化参数的方式取决于激活函数(我们用的是tanh),一个推荐的方法是在 [ −1 n √ ,1 n √ ] 的区间中随机初始化权重,其中n是来自上一层的输入连接数。这可能听起来过于复杂,但不要担心太多。只要你将参数初始化为小的随机值,它通常工作得很好。

class RNNNumpy:

    def __init__(self, word_dim, hidden_dim=100, bptt_truncate=4):
        # Assign instance variables
        self.word_dim = word_dim
        self.hidden_dim = hidden_dim
        self.bptt_truncate = bptt_truncate
        # Randomly initialize the network parameters
        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参数,我们将在稍后的内容解释。

前向传播

接下来,让我们实现上面方程定义的前向传播(预测词概率):

def forward_propagation(self, x):
    # The total number of time steps
    T = len(x)
    # During forward propagation we save all hidden states in s because need them later.
    # We add one additional element for the initial hidden, which we set to 0
    s = np.zeros((T + 1, self.hidden_dim))
    s[-1] = np.zeros(self.hidden_dim)
    # The outputs at each time step. Again, we save them for later.
    o = np.zeros((T, self.word_dim))
    # For each time step...
    for t in np.arange(T):
        # Note that we are indxing U by x[t]. This is the same as multiplying U with a one-hot vector.
        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 是一个表示词汇中的所有的单词概率向量,但有时,例如,当评估我们的模型时,我们想要的是下一个具有最高概率的单词。我们称这个函数为predict:

def predict(self, x):
    # Perform forward propagation and return index of the highest score
    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]

计算损失

为了训练我们的网络,我们需要一种方法来测量它所产生的错误。我们称之为损失函数 L ,我们的目标是找到使我们的训练数据的损失函数最小化的参数 U , V和 W .常见的损失函数是交叉熵损失。如果我们有N个训练样例(文本中的单词的数目)和 C 个类别(词汇量的大小),则我们的预测 o 和真实标签y的损失由下式给出:
L (y,o) =−1 N ∑ n ∈N y n lo go n

公式看起来有点复杂,但本质上是 y ( 正 确 的 词 ) 和 o (预测的词)距离越远,损失就越大。实现函数calculate_loss:

def calculate_total_loss(self, x, y):
    L = 0
    # For each sentence...
    for i in np.arange(len(y)):
        o, s = self.forward_propagation(x[i])
        # We only care about our prediction of the "correct" words
        correct_word_predictions = o[np.arange(len(y[i])), y[i]]
        # Add to the loss based on how off we were
        L += -1 * np.sum(np.log(correct_word_predictions))
    return L

def calculate_loss(self, x, y):
    # Divide the total loss by the number of training examples
    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

让我们退后一步思考什么是随机预测的损失。这将是我们一个基准,并可以确保我们的实现是正确的。我们的词汇中有C个词,因此每个词应该(平均)以1 / C的概率被预测,这将导致 L =−1 N N log 1 C =l og C 的损失。

# Limit to 1000 examples to save time
print "Expected Loss for random predictions: %f" % np.log(vocabulary_size)
print "Actual loss: %f" % model.calculate_loss(X_train[:1000], y_train[:1000])
Expected Loss for random predictions: 8.987197
Actual loss: 8.987440

结果八九不离十!但是请记住,评估完整数据集的损失是一项耗时的操作,数据集太大的话,可能需要几个小时的时间!

用SGD和BPTT训练RNN

记住,我们想要找到使训练数据上的总损失最小的参数 U , V和 W 。最常见的方法是SGD,随机梯度下降。 SGD背后的想法非常简单。我们迭代所有的训练样例,在每次迭代中,我们将参数微调到减少误差的方向。这些方向由损失的梯度给出: ∂ L ∂ U , ∂ L ∂ V , ∂ L ∂ W 。 SGD还需要一个学习率,它定义了我们在每次迭代中需要多大的步长。 SGD是最流行的优化方法,不仅用于神经网络,而且用于许多其他机器学习算法。因此,已经有很多关于如何使用批处理,并行性和自适应学习率来优化SGD的研究。即使基本思想很简单,以一种真正有效的方式实现SGD可能变得非常复杂。如果你想了解更多关于SGD 这是一个好的开始。在这里我将实现一个简单的SGD版本,即使没有优化的背景也很容易理解。

但是我们如何计算我们上面提到的那些梯度呢?在传统的神经网络中,我们通过反向传播算法来做到这一点。在RNN中,我们使用(BPTT)算法。由于网络中的参数在所有步骤都共享,所以每个输出的梯度不仅取决于当前时刻的计算,而且取决于先前的时刻。如果你知道微积分,那么它只是应用了链式规则。本教程的下一部分是关于BPTT的,所以我不会在这里详细推导。关于反向传播的一般介绍检查这个这篇文章。现在你可以把BPTT当作一个黑盒子。它接受训练样例 ( x, y) 并返回梯度 ∂ L ∂ U , ∂ L ∂ V , ∂ L ∂ W 。

def bptt(self, x, y):
    T = len(y)
    # Perform forward propagation
    o, s = self.forward_propagation(x)
    # We accumulate the gradients in these variables
    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 each output backwards...
    for t in np.arange(T)[::-1]:
        dLdV += np.outer(delta_o[t], s[t].T)
        # Initial delta calculation
        delta_t = self.V.T.dot(delta_o[t]) * (1 - (s[t] ** 2))
        # Backpropagation through time (for at most self.bptt_truncate steps)
        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
            # Update delta for next step
            delta_t = self.W.T.dot(delta_t) * (1 - s[bptt_step-1] ** 2)
    return [dLdU, dLdV, dLdW]

RNNNumpy.bptt = bptt

梯度检查

每当进行反向传播时,最好也进行梯度检查,这是一种验证你的实现是否正确的方法。梯度检查背后的想法是参数的导数等于该点的斜率,我们可以通过稍微改变参数来近似:
∂ L ∂ θ ≈lim h →0 J (θ+ h)− J(θ−h) 2 h

然后比较使用反向传播计算的梯度和用上述方法估计的梯度。如果没有大的差别,那我们的计算是正确的。近似计算需要计算每个参数的总损耗,因此梯度检查代价非常昂贵(在上面的例子中我们有超过一百万个参数)。所以在一个较小的词汇模型上执行梯度检查是一个好主意。

def gradient_check(self, x, y, h=0.001, error_threshold=0.01):
    # Calculate the gradients using backpropagation. We want to checker if these are correct.
    bptt_gradients = self.bptt(x, y)
    # List of all parameters we want to check.
    model_parameters = ['U', 'V', 'W']
    # Gradient check for each parameter
    for pidx, pname in enumerate(model_parameters):
        # Get the actual parameter value from the mode, e.g. model.W
        parameter = operator.attrgetter(pname)(self)
        print "Performing gradient check for parameter %s with size %d." % (pname, np.prod(parameter.shape))
        # Iterate over each element of the parameter matrix, e.g. (0,0), (0,1), ...
        it = np.nditer(parameter, flags=['multi_index'], op_flags=['readwrite'])
        while not it.finished:
            ix = it.multi_index
            # Save the original value so we can reset it later
            original_value = parameter[ix]
            # Estimate the gradient using (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)
            # Reset parameter to original value
            parameter[ix] = original_value
            # The gradient for this parameter calculated using backpropagation
            backprop_gradient = bptt_gradients[pidx][ix]
            # calculate The relative error: (|x - y|/(|x| + |y|))
            relative_error = np.abs(backprop_gradient - estimated_gradient)/(np.abs(backprop_gradient) + np.abs(estimated_gradient))
            # If the error is to large fail the gradient check
            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

# To avoid performing millions of expensive calculations we use a smaller vocabulary size for checking.
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

现在我们能够计算参数的梯度,因此可以实现SGD。分两个步骤:1.函数sdg_step计算一个批次的渐变并执行更新。 2.通过训练集迭代并调整学习速率的外循环。

# Performs one step of SGD.
def numpy_sdg_step(self, x, y, learning_rate):
    # Calculate the gradients
    dLdU, dLdV, dLdW = self.bptt(x, y)
    # Change parameters according to gradients and learning rate
    self.U -= learning_rate * dLdU
    self.V -= learning_rate * dLdV
    self.W -= learning_rate * dLdW

RNNNumpy.sgd_step = numpy_sdg_step
# Outer SGD Loop
# - model: The RNN model instance
# - X_train: The training data set
# - y_train: The training data labels
# - learning_rate: Initial learning rate for SGD
# - nepoch: Number of times to iterate through the complete dataset
# - 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):
    # We keep track of the losses so we can plot them later
    losses = []
    num_examples_seen = 0
    for epoch in range(nepoch):
        # Optionally evaluate the loss
        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)
            # Adjust the learning rate if loss increases
            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 each training example...
        for i in range(len(y_train)):
            # One SGD step
            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个示例,因此一次迭代训练(整个数据集上的迭代)将需要几个小时。多次迭代训练将需要几天,甚至几周!与许多公司和研究人员使用的数据相比,我们仍在使用小数据集。现在怎么办?

幸运的是,有很多方法可以加快我们的代码执行速率。我们可以坚持使用相同的模型,并使代码运行更快,或者可以我们以较低的计算成本修改模型,或两者都用。研究人员已经使用了许多方法来使模型在计算上代价更低,例如通过使用分级softmax或添加投影层以避免大矩阵乘法(也见这里这里)。但我想保持我们的模型简单,所以走第一条路线:使用GPU使运行更快。在这之前,我们试着用一个小数据集运行SGD,并检查损失是否真的减少:

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

我们的实现至少做了一些有用的事情,就像我们想要的那样损失减少了。

用Theno 和GPU训练RNN

我以前写过一个关于Theano的教程,因为我们的逻辑将保持完全一样,我不会在这里再次优化代码。我定义了一个RNNTheano类,用Theano中的相应计算替换numpy计算。就像本文的其余部分,代码也是可用的 Github代码。

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

这一次,一个SGD步骤在我的Mac(没有GPU)需要70毫秒,在g2.2xlarge亚马逊EC2(有GPU)上是23毫秒。这比初始使用时间有了15倍的改进,这意味着我们可以训练模型,以小时/天而不是以星期为度量了。我们仍可以做大量的优化,但现在已经够好了。

为了帮助你避免花费数天训练模型,我已经预训练了一个隐藏层维度为50和词汇量为8000的Theano模型。在大约20小时内进行了50次迭代训练。损失仍然在减少,训练时间更长一点可能会生成一个更好的模型。你可以随意尝试并训练更长时间。你可以在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):
    # We start the sentence with the start token
    new_sentence = [word_to_index[sentence_start_token]]
    # Repeat until we get an 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]
        # We don't want to sample unknown words
        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 = []
    # We want long sentences, not sentences with one or two words
    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’s和or’s之前),并用标点符号结束句子。有时它模仿互联网语言风格,如多个感叹号或表情。

然而,绝大多数生成的句子没有意义或语法错误(我选择了生成最好的句子)。一个原因可能是我们训练网络时间不够长(或没有使用足够的训练数据),但它很可能不是主要原因。我们的vanilla RNN无法生成有意义的文本,因为它无法学习相隔几步的单词之间的依赖关系。这也是为什么RNN在第一次出现时没有获得普及的原因。他们在理论上很漂亮,但在实践中没有很好地工作,我们没有及时地了解其中原由。

幸运的是,现在已经更好地理解了RNN训练中的困难。在本教程的下一部分中,我们将更详细地探讨反向传播时间(BPTT)算法,并演示所谓的消失梯度问题。这将激励我们转向更复杂的RNN模型,例如LSTM,这是NLP中许多任务的当前状态(并且可以产生更好的reddit评论!)。在本教程中学到的一切也适用于LSTM和其他RNN模型,所以如果一个vanilla RNN的结果比你预期的差,不要感到灰心。