反向传播(BPTT)与循环神经网络(RNN)文本预测

反向传播(BPTT)与循环神经网络(RNN)文本预测

BPTT 与 RNN文本预测

参考博客:

参考论文:
- A guide to recurrent neural networks and backpropagation

本文介绍简单Recurrent Neural Networks(RNN)的基本训练算法BACKPROPAGATION THROUGH TIME (BPTT),并用 python2.7 实现RNN的文本预测。

1. RNN训练方法:BACKPROPAGATION THROUGH TIME (BPTT)

基本RNN结构如下所示:

简单的RNN

stt 时刻的隐藏状态,它表示网络的记忆单元,由前一时刻的隐藏状态和当前时刻的输入来确定,ot 表示 t 时刻的输出,它与隐藏状态有关:

st=f(Uxt+Wst1),假定ftanh
ot=g(Vst),假定 gsoftmax 函数。

U,W,V 分别是输入与隐藏状态、先前隐藏状态与当前隐藏状态、隐藏状态与输出之间的连接权重,可分别叫做输入权重、循环权重、输出权重。

BPTT与传统的反向传播算法基本相同,包含如下三个步骤:
(1) 前向传播计算输出 o 和隐藏状态 s
(2) 反向传播计算误差 δ ,表示模型目标函数 E 对加权输入 nett=(Uxt+Wst1) 的偏导;(不同的是,在BPTT中,δ 的传播沿两个方向,分别为从输出层传递至输入层,以及沿时间 t 反向传播)
(3) 求解模型目标函数 E 对权重 U,W,V 的偏导数。

(1) 前向计算

利用如下两式从输入层 xt 计算每层的隐藏状态 s 和 输出 o
st=f(Uxt+Wst1),假定ftanh
ot=g(Vst),假定 gsoftmax 函数;

(2) 误差 δ 的计算

nett=(Uxt+Wst1) 可以看到,目标函数对 net 的偏导数与时间 t 相关,因此需要求解每个时刻 t 的误差 δt

每一时刻 tδ 从输出层传递至输入层的计算
对于输出层 L
由于 ot=g(Vst) ,则 δLt=Eotg(Vst) ,其中 ytt 时刻的真实输出值;
对于其它层 l
由于 st=f(Uxt+Wst1) ,误差向输入层传播,可得 (δlt)T=(δl+1t)TUf(netlt)

δ 沿时间 t 的反向传播
对于某一隐藏状态层,由 st=f(Uxt+Wst1) 可得 δtδt1 的关系为:
δTt1=δTtWdiag(f(nett1))

(3) 权重偏导数的计算

模型目标函数 E 对输出权重 V 的偏导数:
EV=g(Vst)st

模型目标函数 E 对循环权重 W 的偏导数:
EW=ΣtEtWE 对循环权重 W 的偏导数等于各时刻的偏导数之和。

其中 t 时刻 :
EtW=δTtst1

模型目标函数 E 对输入权重 U 的偏导数:
EU=ΣtEtUE 对输入权重 U 的偏导数等于各时刻的偏导数之和。

其中 t 时刻 :
EtU=δTtxt

2. RNN预测文本实战

本文采用dataset available on Google’s BigQuery的前10000条评论数据作为RNN的训练数据,然后基于训练后的RNN,生成新的评论文本。

(1) 数据预处理

文本标记

首先,将每个训练数据进行词语标记。比如 ‘who cares what you think ?’ 标记为[‘who’, ‘cares’, ‘you’, ‘think’, ‘?’]
这里会用到自然语言处理包nltk中的相关函数。

去除不常用词,标记词典中不存在的词

有些词语在所有句子中可能仅出现过几次,而词典的长度不能过大,否则会加长训练时间。因此,一种简单的做法是将不常用词从词典中去除。

对于词典中不存在的词,可统一标记为 ‘UNKNOWN_TOKEN’,并将 ‘UNKNOWN_TOKEN’ 加入词典。

标记每个样本句的开头和结尾

为便于训练,分别用’START_TOKEN’ 和 ‘END_TOKEN’ 标记每句话的开头和结尾,并将’START_TOKEN’ 和 ‘END_TOKEN’ 加入词典。

将文本转化为词向量

训练前,需将文本转化为数值向量,可将词典中的每个词进行编号。
比如,一个样本为 [‘START_TOKEN’, ‘who’, ‘cares’, ‘what’, ‘you’, ‘think’, ‘?’, ‘END_TOKEN’],其对于的数值向量为 [0,98,1938,53,10,72,19,1],则输入 x=[0,98,1938,53,10,72,19] ,输出 y=[98,1938,53,10,72,19,1] ,各包含7个时刻,即 T=7

(2) RNN实现

文本预处理,获取训练样本

首先实现 tokenFile2vector 类,tokenFile.py

import numpy as np
import nltk, itertools, csv

TXTCODING = 'utf-8'
unknown_token = 'UNKNOWN_TOKEN'
start_token = 'START_TOKEN'
end_token = 'END_TOKEN'

# 解析评论文件为数值向量
class tokenFile2vector:
    def __init__(self, file_path, dict_size):
        self.file_path = file_path
        self.dict_size = dict_size

    # 将文本拆成句子,并加上句子开始和结束标志
    def _get_sentences(self):
        sents = []
        with open(self.file_path, 'rb') as f:
            reader = csv.reader(f, skipinitialspace=True)
            # 去掉表头 
            reader.next()
            # 解析每个评论为句子
            sents = itertools.chain(*[nltk.sent_tokenize(x[0].decode(TXTCODING).lower()) for x in reader])
            sents = ['%s %s %s' % (start_token, sent, end_token) for sent in sents]
            print 'Get {} sentences.'.format(len(sents))

            return sents

    # 得到每句话的单词,并得到字典及字典中每个词的下标
    def _get_dict_wordsIndex(self, sents):
        sent_words = [nltk.word_tokenize(sent) for sent in sents]
        word_freq = nltk.FreqDist(itertools.chain(*sent_words))
        print 'Get {} words.'.format(len(word_freq))

        common_words = word_freq.most_common(self.dict_size-1)
        # 生成词典
        dict_words = [word[0] for word in common_words]
        dict_words.append(unknown_token)
        # 得到每个词的下标,用于生成词向量
        index_of_words = dict((word, ix) for ix, word in enumerate(dict_words))

        return sent_words, dict_words, index_of_words

    # 得到训练数据
    def get_vector(self):
        sents = self._get_sentences()
        sent_words, dict_words, index_of_words = self._get_dict_wordsIndex(sents)

        # 将每个句子中没包含进词典dict_words中的词替换为unknown_token
        for i, words in enumerate(sent_words):
            sent_words[i] = [w if w in dict_words else unknown_token for w in words]

        X_train = np.array([[index_of_words[w] for w in sent[:-1]] for sent in sent_words])
        y_train = np.array([[index_of_words[w] for w in sent[1:]] for sent in sent_words])

        return X_train, y_train, dict_words, index_of_words

基于以上实现的 tokenFile2vector 类,获得训练样本 X_train, y_train:

file_path = r'/results-20170508-103637.csv'
dict_size = 8000
myTokenFile = tokenFile2vector(file_path, dict_size)
X_train, y_train, dict_words, index_of_words = myTokenFile.get_vector()

实现RNN

首先生成 myRNN类,myrnn.py

import tokenFile
import numpy as np

# 输出单元激活函数
def softmax(x):
    x = np.array(x)
    max_x = np.max(x)
    return np.exp(x-max_x) / np.sum(np.exp(x-max_x))

class myRNN:
    def __init__(self, data_dim, hidden_dim=100, bptt_back=4):
        # data_dim: 词向量维度,即词典长度; hidden_dim: 隐单元维度; bptt_back: 反向传播回传时间长度
        self.data_dim = data_dim
        self.hidden_dim = hidden_dim
        self.bptt_back = bptt_back

        # 初始化权重向量 U, W, V; U为输入权重; W为递归权重; V为输出权重
        self.U = np.random.uniform(-np.sqrt(1.0/self.data_dim), np.sqrt(1.0/self.data_dim), 
                                   (self.hidden_dim, self.data_dim))
        self.W = np.random.uniform(-np.sqrt(1.0/self.hidden_dim), np.sqrt(1.0/self.hidden_dim), 
                                   (self.hidden_dim, self.hidden_dim))
        self.V = np.random.uniform(-np.sqrt(1.0/self.hidden_dim), np.sqrt(1.0/self.hidden_dim), 
                                   (self.data_dim, self.hidden_dim))

    # 前向传播
    def forward(self, x):
        # 向量时间长度
        T = len(x)

        # 初始化状态向量, s包含额外的初始状态 s[-1]
        s = np.zeros((T+1, self.hidden_dim))
        o = np.zeros((T, self.data_dim))

        for t in xrange(T):
            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]

    # 预测输出        
    def predict(self, x):
        o, s = self.forward(x)
        pre_y = np.argmax(o, axis=1)
        return pre_y

    # 计算损失, softmax损失函数, (x,y)为多个样本
    def loss(self, x, y):
        cost = 0        
        for i in xrange(len(y)):
            o, s = self.forward(x[i])
            # 取出 y[i] 中每一时刻对应的预测值
            pre_yi = o[xrange(len(y[i])), y[i]]
            cost -= np.sum(np.log(pre_yi))

        # 统计所有y中词的个数, 计算平均损失
        N = np.sum([len(yi) for yi in y])
        ave_loss = cost / N

        return ave_loss

    # 求梯度, (x,y)为一个样本
    def bptt(self, x, y):
        dU = np.zeros(self.U.shape)
        dW = np.zeros(self.W.shape)
        dV = np.zeros(self.V.shape)

        o, s = self.forward(x)
        delta_o = o
        delta_o[xrange(len(y)), y] -= 1

        for t in np.arange(len(y))[::-1]:
            # 梯度沿输出层向输入层的传播
            dV += delta_o[t].reshape(-1, 1) * s[t].reshape(1, -1)  # self.data_dim * self.hidden_dim
            delta_t = delta_o[t].reshape(1, -1).dot(self.V) * ((1 - s[t-1]**2).reshape(1, -1)) # 1 * self.hidden_dim

            # 梯度沿时间t的传播
            for bpt_t in np.arange(np.max([0, t-self.bptt_back]), t+1)[::-1]:
                dW += delta_t.T.dot(s[bpt_t-1].reshape(1, -1))
                dU[:, x[bpt_t]] = dU[:, x[bpt_t]] + delta_t

                delta_t = delta_t.dot(self.W.T) * (1 - s[bpt_t-1]**2)

        return [dU, dW, dV]

    # 计算梯度   
    def sgd_step(self, x, y, learning_rate):
        dU, dW, dV = self.bptt(x, y)

        self.U -= learning_rate * dU
        self.W -= learning_rate * dW
        self.V -= learning_rate * dV

    # 训练RNN  
    def train(self, X_train, y_train, learning_rate=0.005, n_epoch=5):
        losses = []
        num_examples = 0

        for epoch in xrange(n_epoch):   
            for i in xrange(len(y_train)):
                self.sgd_step(X_train[i], y_train[i], learning_rate)
                num_examples += 1

            loss = self.loss(X_train, y_train)
            losses.append(loss)
            print 'epoch {0}: loss = {1}'.format(epoch+1, loss)
            # 若损失增加,降低学习率
            if len(losses) > 1 and losses[-1] > losses[-2]:
                learning_rate *= 0.5
                print 'decrease learning_rate to', learning_rate

训练RNN

rnn = myRNN(dict_size, hidden_dim=100, bptt_back=4)
rnn.train(X_train[:200], y_train[:200], learning_rate=0.005, n_epoch=10)

可得如下结果:

epoch 1: loss = 8.97211993132
epoch 2: loss = 8.93082011501
epoch 3: loss = 6.7136525424
epoch 4: loss = 6.21936677548
epoch 5: loss = 6.00813231779
epoch 6: loss = 5.87637648866
epoch 7: loss = 5.78373455074
epoch 8: loss = 5.71807642521
epoch 9: loss = 5.63435329796
epoch 10: loss = 5.56515764008

(3) 用RNN进行文本预测

基于训练好的RNN模型,我们可以得到下一个词将会是什么,从而生成新的文本。

unknown_token = 'UNKNOWN_TOKEN'
start_token = 'START_TOKEN'
end_token = 'END_TOKEN'

def generate_text(rnn, dict_words, index_of_words):
    # dict_words: type list; index_of_words: type dict
    sent = [index_of_words[start_token]]
    # 预测新词,知道句子的结束(END_TOKEN)
    while not sent[-1] == index_of_words[end_token]:
        next_probs, _ = rnn.forward(sent)
        sample_word = index_of_words[unknown_token]

        # 按预测输出分布进行采样,得到新的词
        while sample_word == index_of_words[unknown_token]:
            samples = np.random.multinomial(1, next_probs[-1])
            sample_word = np.argmax(samples)
        # 将新生成的有含义的词(即不为UNKNOWN_TOKEN的词)加入句子
        sent.append(sample_word)

    new_sent = [dict_words[i] for i in sent[1:-1]]
    new_sent_str = ' '.join(new_sent)

    return new_sent_str

生成文本举例:

sent_str = generate_text(rnn, dict_words, index_of_words)
print 'Generate sentence:', sent_str

可得如下类似结果:

例子:
it 's this he your wealth decisions roof of .

mao dam , many are things a go a issue n't you works that a half

3. RNN中的梯度消失或爆炸问题

上边生成文本的结果可知,RNN生成的文本效果比较poor。一个可能的原因是训练数据不足、预处理不精细等等,然而,更重要的原因是RNN对于句子中跨度较大的词间的依赖关系无能为力。其实,从BPTT中可以发现,梯度的求解过程中存在这’消失的梯度’或’爆炸的梯度’问题。

消失的梯度:

比如,在 δTt1=δTtWdiag(f(nett1)) 中,若 ftanh (或 sigmoid),其将函数值限定在 [1,1] (sigmoid[0,1]),将其导数限定在 [0,1] (sigmoid[0,1/4]),因此,误差 δt 沿时间 t 的长时间传播将导致其值趋于0,从而使相应的权重梯度趋于0,造成梯度消失的现象,失去学习效果。

爆炸的梯度:

Wdiag(f(nett1)) 值非常大时,误差 δt 沿时间 t 的长时间传播将导致其值急剧增大,造成梯度爆炸。与梯度消失相比,梯度爆炸比较易于发现,比如梯度出现 NaN;此外,也可以通过限定梯度的最大阈值来避免梯度爆炸的影响。

梯度消失问题解决方法:

目前已有一些解决梯度消失的方法,比如,合适的权值初始化;使用 ReLU 替代 tanhsigmoid (因为 ReLU 导数为0或1,不太可能造成梯度消失现象)。

更有效且受关注的解决方法是采用 Long Short-Term Memory (LSTM) 或 Gated Recurrent Unit (GRU) 结构,他们专为解决梯度消失而生。

RNN的介绍先到这里,后续跟进。

阅读更多

没有更多推荐了,返回首页