**提示:**关于 RNN 的内容将横跨好几篇文章,包括基本的 RNN 结构、支持字符级序列生成的纯 TensorFlow 实现等等。而关于 RNN 的后续文章会包含更多高级主题,比如更加复杂的用于机器翻译任务的 Attention 机制等。
一、概述
使用循环结构拥有很多优势,最突出的一个优势就是它们能够在内存中存储前一个输入的表示。如此,我们就可以更好的预测后续的输出内容。持续追踪内存中的长数据流会出现很多的问题,比如 BPTT 算法中出现的梯度消失(gradient vanishing)问题就是其中之一。幸运的是,我们可以对架构做出一些改进来解决这个问题。
二、CHAR-RNN
我们不会去专门实现一个纯 TensorFlow 版本的单字符生成模型。相反,现在这个模型的目标是从每个输入句子中以每次一个字母的方式来读取字符流,并预测下一个字母是什么。在训练期间,我们将句子中的字母提供给网络,并用于生成输出的字母。而在推断(生成)期间,我们则会将上一次的输出作为新的输入(使用随机的 token 作为第一个输入)。
输入样例:Hello there Charlie, how are you? Today I want a nice day. All I want for myself is a car.
- DATA_SIZE:输入的长度,即
len(input)
; - BATCH_SIZE:每批的序列个数;
- NUM_STEPS:每个切片的 token 数,即序列的长度
seq_len
; - STATE_SIZE:每个隐层状态的隐层节点数,即值
H
; - num_batches:数据集小批量化后的批量数
**注意:**由于我们是一行一行的将数据输入进 RNN 单元的,因此我们需要一列一列的将数据组成张量输入到网络中去,即我们必须把原始数据进行 reshape 处理。此外,每个字母都将作为一个被嵌入的独热编码(one-hot encoding,译注:又称 1-of-k encoding)的向量输入。在上图中,每个句子数据都被完美的切分进了一组组小批量数据,这只不过是为了达到更好的可视化目的,这样你就可以看到输入是怎样被切分的了。在实际的 -RNN 实现中,我们并不关心一个具体的句子,我们只是将整个输入切分成 num_batches 个批次,每个批次彼此独立,所以每个输入的长度都是 num_steps
,即 seq_len
。
三、反向传播
RNN 版本的反向传播 BPTT 刚开始可能有点混乱,尤其是计算隐藏状态对输入的影响之时。下面的代码使用原生 numpy 实现,符合下图中我的公式推导逻辑。
前向传播:
for t in xrange(len(inputs)):
xs[t] = np.zeros((vocab_size,1)) # 独热编码
xs[t][inputs[t]] = 1
hs[t] = np.tanh(np.dot(Wxh, xs[t]) + np.dot(Whh, hs[t-1]) + bh) # 隐藏状态
ys[t] = np.dot(Why, hs[t]) + by # 下一个字符的未归一化对数似然概率
ps[t] = np.exp(ys[t]) / np.sum(np.exp(ys[t])) # 下一个字符的概率
loss += -np.log(ps[t][targets[t],0]) # softmax(交叉熵损失)
反向传播:
for t in reversed(xrange(len(inputs))):
dy = np.copy(ps[t])
dy[targets[t]] -= 1
dWhy += np.dot(dy, hs[t].T)
dby += dy
dh = np.dot(Why.T, dy) + dhnext # 反向传播给 h
dhraw = (1 - hs[t] * hs[t]) * dh # 通过 tanh 的非线性进行反向传播
dbh += dhraw
dWxh += np.dot(dhraw, xs[t].T)
dWhh += np.dot(dhraw, hs[t-1].T)
dhnext = np.dot(Whh.T, dhraw)
张量的形状
在实现之前,我们来谈谈张量的形状。在这个 CHAR-RNN 的例子上讲述张量形状这个概念有点奇怪,因此我会向你解释如何对其进行批量化以及它们是怎样完成 seq2seq 任务的。
这个任务对于一次性输入整行(全部 batch_size
个序列的)seq_len
这点上有点奇怪。通常来说,我们一次只传递一个批量,每个批量都有 batch_size
个序列,所以形状为(batch_size, seq_len)
。我们通常也不会用 seq_len
来做分割,而是取整个序列的长度。对于 seq2seq 任务而言,本系列的第 2、3 和 5 篇文章中看到,我们会将大小为 batch_size
一个批量的序列作为输入,其中每个序列的长度为seq_len
。我们不能像在图中那样指定 seq_len
,因为实际的seq_len
会根据全部样本的特点填充到最大值。我们会在比最大长度短的所有句子后填充一些填充符,从而达到最大值。不过现在还不是深入讨论这个问题的时候。
四、Char-RNN 的 TensorFlow 实现(无 RNN 抽象)
我们将使用没有 RNN 类抽象的纯 TensorFlow 进行实现。同时还将使用我们自己的权重集来真正理解输入数据的流向以及输出是如何生成的。在这里我们只讨论代码里一些重点部分,而完整的代码我将给出相关链接。如果你想要使用 TF RNN 类进行实现,请转到本文第五小节。
重点:
首先我想讨论下如何生成批量化的数据。你可能注意到了,我们有一个额外的步骤,那就是将数据进行批量化处理,然后再将数据分割进 seq_len
。这么做的原因是为了消除 RNN 结构中 BPTT 算法中产生的梯度消失问题。本质上来说,我们并不能同时处理多个字符。这是因为在反向传播中,如果序列太长,梯度就会下降得很快。因此,一个简单的技巧是保存一个 seq_len
长度的输出状态,然后将其作为下一个 seq_len
的 initial_state
。这种由我们自行选择(使用 BPTT 来)处理的个数和更新频率的做法,就是所谓的截断反向传播(truncated backpropagation)。initial_state
从 0 开始,并在每轮计算中进行重置。因此,我们仍然能在一个特定的批次中从之前的 seq_len
序列里保存表示的某些类型。这么做的原因在于,在字符这种级别上,一个极小的序列并不能够学习到足够多的表示。
def generate_batch(FLAGS, raw_data):
raw_X, raw_y = raw_data
data_length = len(raw_X)
# 从原始数据中创建批量数据
num_batches = FLAGS.DATA_SIZE // FLAGS.BATCH_SIZE # 每批的 token
data_X = np.zeros(