命名实体识别的语料和代码

抱歉~之前比较忙,有很多朋友在我前几篇文章中留言都没有来得及回复,不过大部分留言都是找我要语料和代码的,貌似我在那几篇文章里写的不太清楚,这里就统一整理一下吧。

1.用规则做命名实体识别

原文:用规则做命名实体识别——NER系列(一)

这篇博文就不放出代码了,因为现在基本上不用规则做命名实体识别,当然除非你在某些特定的语料有规律,就完全可以用正则表达式来做。

2.用HMM做命名实体识别

原文:用隐马尔可夫模型(HMM)做命名实体识别——NER系列(二)

代码和语料:https://github.com/lipengfei-558/hmm_ner_organization

3.用CRF做命名实体识别

原文:用CRF做命名实体识别——NER系列(三)

这篇博文是根据我参加的一个比赛写的,当时用到了纯CRF方法,比赛介绍:
https://www.datafountain.cn/competitions/269/details/rule

数据集:https://pan.baidu.com/s/1hdC_W9E62w3Akzlo5pnQuA

用到了CRF++(版本0.58), CRF++ 下载地址:https://pan.baidu.com/s/11lsCwRlJ5DyfVM3NxH61iw

使用方法直接参考官方文档:http://taku910.github.io/crfpp/,或者直接参考网上教程。主要涉及到两点,一是数据的预处理,二是CRF++的模板,事实上,用默认模板也能取得不错的效果。想要更好的效果,就得好好设计一下模板,尝试把一些词性、特征词等特征加进去,不赘述了~

4.LSTM+CRF做命名实体识别

原文:用双向lstm+CRF做命名实体识别(附tensorflow代码)——NER系列(四)

代码是一个老哥在github上开源的,基于tensorflow:https://github.com/guillaumegenthial/sequence_tagging

CoNLL-2003语料集下载地址:https://pan.baidu.com/s/1Pps7QIfePoxc1t8iNSSafg

上面这个是已经做好预处理的语料集,也可以直接用在CRF++上。

最后祝大家新年快乐~

用双向lstm+CRF做命名实体识别(附tensorflow代码)——NER系列(四)


这一篇文章,主要讲一下用深度学习(神经网络)的方法来做命名实体识别。现在最主流最有效的方法基本上就是lstm+CRF了。其中CRF部分,只是把转移矩阵加进来了而已,而其它特征的提取则是交由神经网络来完成。当然了,特征提取这一部分我们也可以使用CNN,或者加入一些attention机制。

接下来,我将参考国外的一篇博客《Sequence Tagging with Tensorflow》,结合tensorflow的代码,讲一下用双向lstm+CRF做命名实体识别。


1.命名实体识别简述

命名实体识别任务本质上就是序列标注任务。来一个例子:

John  lives in New   York  and works for the European Union
B-PER O     O  B-LOC I-LOC O   O     O   O   B-ORG    I-ORG

在CoNLL2003任务中,实体为LOC,PER,ORG和MISC,分别代表着地名,人名,机构名以及其他实体,其它词语会被标记为O。由于有一些实体(比如New York)由多个词组成,所以我们使用用一种简单的标签体系:

B-来标记实体的开始部分,I-来标记实体的其它部分。

我们最终只是想对句子里面的每一个词,分配一个标签。

2.模型

整个模型的主要组成部分就是RNN。我们将模型的讲解分为以下三个部分:

  1. 词向量表示
  2. 词的上下文信息表示
  3. 解码

2.1 词向量表示

对于每一个单词,我们用词向量w \in \mathbb{R}^n来表示,用来捕获词本身的信息。这个词向量由两部分concat起来,一部分是用GloVe训练出来的词向量w_{glove} \in \mathbb{R}^{d_1},另一部分,是字符级别的向量w_{chars} \in \mathbb{R}^{d_2}

在以往,我们会手工提取并表示一些特征,比如用1,0来表示某个单词是否是大写开头,而在这个模型里面,我们不需要人工提取特征,只需要字符级别上面使用双向LSTM,就可以提取到一些拼写层面的特征了。当然了,CNN或者其他的RNN也可以干类似的事情。

Word level representation from characters embeddings

对于每一个单词w = [c_1, \ldots, c_p]里面的每一个字母(区分大小写),我们用c_i \in \mathbb{R}^{d_3}这个向量来表示,对字母级别的embedding跑一个bi-LSTM,然后将最后的隐状态输出拼接起来(因为是双向,所以有两个最后隐状态,如上图),得到一个固定长度的表达w_{chars} \in \mathbb{R}^{d_2},直觉上,我们可以认为这个向量提取了字母级别的特征,比如大小写、拼写规律等等。然后,我们将这个向量w_{chars}和Glove训练好的w_{glove}拼接起来,得到某个词最终的词向量表达:w = [w_{glove}, w_{chars}] \in \mathbb{R}^n,其中n = d_1 + d_2

看一下tensorflow对应的实现代码。

# shape = (batch size, max length of sentence in batch)
word_ids = tf.placeholder(tf.int32, shape=[None, None])

# shape = (batch size)
sequence_lengths = tf.placeholder(tf.int32, shape=[None])

好了,让我们用tensorflow的内置函数来读取word embeddings。假设这个embeddings是一个由GloVe训练出来的numpy数组,那么embeddings[i]表示第i个词的向量表示。

L = tf.Variable(embeddings, dtype=tf.float32, trainable=False)
# shape = (batch, sentence, word_vector_size)
pretrained_embeddings = tf.nn.embedding_lookup(L, word_ids)

在这里,应该使用tf.Variable并且参数设置trainable=False,而不是用tf.constant,否则可能会面临内存问题。

好,接下来,让我们来对字母建立向量。

# shape = (batch size, max length of sentence, max length of word)
char_ids = tf.placeholder(tf.int32, shape=[None, None, None])

# shape = (batch_size, max_length of sentence)
word_lengths = tf.placeholder(tf.int32, shape=[None, None])

为什么这里用这么多None呢?

其实这取决于我们。在我们的代码实现中,我们的padding是动态的,也就是和batch的最大长度对齐。因此,句子长度和单词长度取决于batch。

好了,继续。在这里,我们没有任何预训练的字母向量,所以我们调用tf.get_variable来初始化它们。我们也要reshape一下四维的tensor,以符合bidirectional_dynamic_rnn的所需要的输入。代码如下:

# 1. get character embeddings
K = tf.get_variable(name="char_embeddings", dtype=tf.float32,
    shape=[nchars, dim_char])
# shape = (batch, sentence, word, dim of char embeddings)
char_embeddings = tf.nn.embedding_lookup(K, char_ids)

# 2. put the time dimension on axis=1 for dynamic_rnn
s = tf.shape(char_embeddings) # store old shape
# shape = (batch x sentence, word, dim of char embeddings)
char_embeddings = tf.reshape(char_embeddings, shape=[-1, s[-2], s[-1]])
word_lengths = tf.reshape(self.word_lengths, shape=[-1])

# 3. bi lstm on chars
cell_fw = tf.contrib.rnn.LSTMCell(char_hidden_size, state_is_tuple=True)
cell_bw = tf.contrib.rnn.LSTMCell(char_hidden_size, state_is_tuple=True)

_, ((_, output_fw), (_, output_bw)) = tf.nn.bidirectional_dynamic_rnn(cell_fw,
    cell_bw, char_embeddings, sequence_length=word_lengths,
    dtype=tf.float32)
# shape = (batch x sentence, 2 x char_hidden_size)
output = tf.concat([output_fw, output_bw], axis=-1)

# shape = (batch, sentence, 2 x char_hidden_size)
char_rep = tf.reshape(output, shape=[-1, s[1], 2*char_hidden_size])

# shape = (batch, sentence, 2 x char_hidden_size + word_vector_size)
word_embeddings = tf.concat([pretrained_embeddings, char_rep], axis=-1)

注意sequence_length这个参数的用法,它让我们可以得到最后一个有效的state,对于无效的time steps,dynamic_rnn直接穿过这个state,返回零向量。

2.2 词的上下文信息表示

当有了词向量w之后,就可以对一个句子里的每一个词跑LSTM或者双向LSTM了,然后得到另一个向量表示:h \in \mathbb{R}^k,如下图:
利用双向LSTM提取上下文信息

对应的tensorflow代码很直观,这次我们用每一个隐藏层的输出,而不是最后一个单元的输出。因此,我们输入一个句子,有m个单词:w_1, \ldots, w_m \in \mathbb{R}^n,得到m个输出:h_1, \ldots, h_m \in \mathbb{R}^k。现在的输出,是包含上下文信息的:

cell_fw = tf.contrib.rnn.LSTMCell(hidden_size)
cell_bw = tf.contrib.rnn.LSTMCell(hidden_size)

(output_fw, output_bw), _ = tf.nn.bidirectional_dynamic_rnn(cell_fw,
    cell_bw, word_embeddings, sequence_length=sequence_lengths,
    dtype=tf.float32)

context_rep = tf.concat([output_fw, output_bw], axis=-1)

2.3 解码

最后,我们要对每一个词分配一个tag。用一个全连接层就可以搞定。

假如,一共有9种tag,那么我们可以得到权重矩阵W \in \mathbb{R}^{9 \times k}和偏置矩阵b \in \mathbb{R}^9,最后计算某个词的得分向量s \in \mathbb{R}^9 = W \cdot h + b,s[i]可以解释为,某个词标记成第i个tag的得分,tensorflow的实现是这样的:

W = tf.get_variable("W", shape=[2*self.config.hidden_size, self.config.ntags],
                dtype=tf.float32)

b = tf.get_variable("b", shape=[self.config.ntags], dtype=tf.float32,
                initializer=tf.zeros_initializer())

ntime_steps = tf.shape(context_rep)[1]
context_rep_flat = tf.reshape(context_rep, [-1, 2*hidden_size])
pred = tf.matmul(context_rep_flat, W) + b
scores = tf.reshape(pred, [-1, ntime_steps, ntags])

在这里,我们用zero_initializer来初始化偏置。

有了分数之后,我们有两种方案用来计算最后的tag:

  • softmax:将得分归一化为概率。
  • 线性CRF:第一种方案softmax,只做了局部的考虑,也就是说,当前词的tag,是不受其它的tag的影响的。而事实上,当前词tag是受相邻词tag的影响的。定义一系列词w_1, \ldots, w_m,一系列的得分向量s_1, \ldots, s_m,还有一系列标签y_1, \ldots, y_m,线性CRF的计算公式是这样的:

    \[\begin{aligned}C(y_1, \ldots, y_m) &= b[y_1] &+ \sum_{t=1}^{m} s_t [y_t] &+ \sum_{t=1}^{m-1} T[y_{t}, y_{t+1}] &+ e[y_m]\\&= \text{begin} &+ \text{scores} &+ \text{transitions} &+ \text{end}\end{aligned}\]

在上面的式子里,T是转移矩阵,尺寸为\mathbb{R}^{9 \times 9},用来刻画相邻tag的依赖、转移关系;e, b \in \mathbb{R}^9是结束、开始tag的代价向量。下面是一个计算例子:转移得分计算例子

了解了CRF得分式子,接下来要做两件事:

  • 找到得分最高的tag序列。
  • 计算句子的tag概率分布。

“仔细想想,计算量是不是太大了?”

没错,计算量相当大。就上面的例子而言,有9种tag,一个句子有m个单词,一共有9^m种可能,代价太大了。

幸运的是,由于式子有递归的特性,所以我们可以用动态规划的思想来解决这个问题。假设\tilde{s}_{t+1} (y^{t+1})是时间步t+1, \ldots, m的解(每个时间步都是有9种可能的),那么,继续往前推,时间步t, \ldots, m的解,可以由下式表示:

    \[\begin{aligned}\tilde{s}_t(y_t) &= \operatorname{argmax}_{y_t, \ldots, y_m} C(y_t, \ldots, y_m)\\&= \operatorname{argmax}_{y_{t+1}} s_t [y_t] + T[y_{t}, y_{t+1}] + \tilde{s}_{t+1}(y^{t+1})\end{aligned}\]

每一个递归步骤的复杂度为O(9 \times 9),由于我们进行了m步,所以总的复杂度是O(9 \times 9 \times m)

最后,我们需要在CRF层应用softmax,将得分概率分布计算出来。我们得计算出所有的可能,如下式子:

    \[\begin{aligned}Z = \sum_{y_1, \ldots, y_m} e^{C(y_1, \ldots, y_m)}\end{aligned}\]

上面提到的递归思想在这里也可以应用。先定义Z_t(y_t),表示从时间步t开始、以y_t为tag开始的序列,计算公式如下:

    \[\begin{aligned}Z_t(y_t) &= \sum_{y_{t+1}} e^{s_t[y_t] + T[y_{t}, y_{t+1}]} \sum_{y_{t+2}, \ldots, y_m} e^{C(y_{t+1}, \ldots, y_m)} \\&= \sum_{y_{t+1}} e^{s_t[y_t] + T[y_{t}, y_{t+1}]} \ Z_{t+1}(y_{t+1})\\\log Z_t(y_t) &= \log \sum_{y_{t+1}} e^{s_t [y_t] + T[y_{t}, y_{t+1}] + \log Z_{t+1}(y_{t+1})}\end{aligned}\]

最后,序列概率计算式子如下:

    \[\begin{aligned}\mathbb{P}(y_1, \ldots, y_m) = \frac{e^{C(y_1, \ldots, y_m)}}{Z}\end{aligned}\]

2.4 训练

最后,就是训练部分了。训练的损失函数采用的是cross-entropy(交叉熵),计算公式如下:

    \[\begin{aligned}- \log (\mathbb{P}(\tilde{y}))\end{aligned}\]

其中,\tilde{y}为正确的标注序列,它的概率\mathbb{P}计算公式如下:

  • CRF:\mathbb{P}(\tilde{y}) = \frac{e^{C(\tilde{y})}}{Z}
  • local softmax:\mathbb{P}(\tilde{y}) = \prod p_t[\tilde{y}^t]

“额..CRF层的损失很难计算吧..?”

没错,但是大神早就帮你做好了。在tensorflow里面,一行就能调用。下面的代码会帮我们计算CRF的loss,同时返回矩阵T,以助我们做预测:

# shape = (batch, sentence)
labels = tf.placeholder(tf.int32, shape=[None, None], name="labels")

log_likelihood, transition_params = tf.contrib.crf.crf_log_likelihood(
scores, labels, sequence_lengths)

loss = tf.reduce_mean(-log_likelihood)

local softmax的loss计算过程很经典,但我们需要用tf.sequence_mask将sequence转化为bool向量:

losses = tf.nn.sparse_softmax_cross_entropy_with_logits(logits=scores, labels=labels)
# shape = (batch, sentence, nclasses)
mask = tf.sequence_mask(sequence_lengths)
# apply mask
losses = tf.boolean_mask(losses, mask)

loss = tf.reduce_mean(losses)

最后,定义train op:

optimizer = tf.train.AdamOptimizer(self.lr)
train_op = optimizer.minimize(self.loss)

2.5 使用模型

最后的预测步骤很直观:

labels_pred = tf.cast(tf.argmax(self.logits, axis=-1), tf.int32)

至于CRF层,仍然用到上面提到过的动态规划思想。

# shape = (sentence, nclasses)
score = ...
viterbi_sequence, viterbi_score = tf.contrib.crf.viterbi_decode(
                                score, transition_params)

最终通过这份代码,F1值能跑到90%到91%之间。


3.后记

神经网络做NER,大部分套路都是这样:用基本的RNN、CNN模型做特征提取,最后加上一层CRF,再加点attention机制能稍微提升一下效果,基本上就到瓶颈了。

在2017年6月份,谷歌团队出品这篇论文《Attention Is All You Need》还是给我们带来不少震撼的,不用RNN,CNN,只用attention机制,就刷新了翻译任务的最好效果。所以,我们是不是可以想,把这种结构用到命名实体识别里面呢?

果然,已经有人开始做相关研究。《Deep Semantic Role Labeling with Self-Attention》这篇论文发表于2017年12月,实现了一个类似刚才说到的谷歌的模型,做的是SRL任务,也取得了不错的效果,同时他们也有放出实现代码:https://github.com/XMUNLP/Tagger

值得学习一下。

另外,用多模态来做实体识别也是一个方向,特别是对于一些类似微博的语料(有图片),这样做效果更佳。

代码和语料:
https://www.lookfor404.com/命名实体识别的语料和代码/

用CRF做命名实体识别——NER系列(三)

在上一篇文章《用隐马尔可夫模型(HMM)做命名实体识别——NER系列(二)》中,我们使用HMM模型来做命名实体识别,将问题转化为统计概率问题,进行求解。显然,它的效果是非常有限的。

在深度学习技术火起来之前,主流的、最有效的方法,就是CRF(条件随机场)模型。本文不对CRF模型进行展开讲解,而是结合我之前参加的CCF BDCI的其中一个赛题,直接用CRF++工具进行实战。下面直接进入正题。

1.赛题解读

赛题介绍:https://www.datafountain.cn/competitions/269/details/rule

任务描述

总结一下,这个题目要求我们对数据集中的每条记录,提取出正文中的主要机构实体,判断这些机构的风险等级(中性,正向或负向),并为每个实体生成相应的摘要和关键词。

我接下来主要讲提取实体这一部分,用的是CRF模型,训练直接使用CRF++工具(http://taku910.github.io/crfpp/)(似乎被墙了?)。

2.算法流程图

算法流程图

3.算法说明

3.1 定义实体标注集

为了确保最后的机构实体识别准确度,使用BMEWO标注集,各个标注的意义如下:

B:实体的开头

M:实体的中间部分

E:实体的结束

W:单独成实体

O:句子的其它成分

比如下面这个句子(已做分词处理):

山西      相立      山泉      饮品      开发      有限公司     生产      的桶装  饮用水  检出      铜绿      假   单胞菌

背后的标注为:

山西/B  相立/M 山泉/M 饮品/M 开发/M 有限公司/E  生产/O  的/O 桶装/O     饮用水/O     检出/O  铜绿/O   假/O 单胞菌/O

3.2训练文本、测试文本预处理

对训练文本进行中文分词、去除停用词的处理,并根据上述的标注集进行标注。同时,除了词本身,还引入了4个特征:

特征:【词性】,用jieba分词识别出来的词性

特征②:【是否是特征词】,该词是特征词,标记1;不是特征词,标记0。这里的特征词是指“实体通常的结尾词”,比如“有限公司”,“药监局”,“超市”等等,这些特征词来源于两个地方:

  1. 从训练集中分词得到。
  2. 从开源中文分词工具hanlp的机构名词典中整理得到。

特征③:【是否是地点】,该词是地点,标记为isloc;该词不是地点,标记为notloc。这里的地点信息我们是从jieba的分词词性标注功能中得到的,词性标注为ns的一般是地点。

特征④:【是否是句子结束】,该词是这个句子的结束词,标记为isend;否则标记为notend。

训练文本在经过预处理之后,格式如下:

宁夏      ns   0     isloc      notend B

物美      nz   0     notloc  notend M

超市      v     1     notloc  notend M

有限公司    n     1     notloc  notend M

森林公园    n     0     notloc  notend M

   n     1     notloc  isend    E

其中,第一列为词本身,第二列为特征①,第三列为特征②,第四列为特征③,第五列为特征④,第六列列为正确标注。

测试文本的预处理和上面的基本一样,区别在于,测试文本没有正确的实体标注,所以测试文本的预处理文件只有五列。最后我们要用CRF模型预测的是第六列标注。

3.3训练CRF模型

CRF模型的训练,需要一个特征模板,以便能够自动在训练文本中提取特征函数,特征模板的定义直接决定了最后的识别效果

针对此次的机构实体,我们定义了几种特征模板,最终选择了以下模板:

# Unigram

U01:%x[-2,0]

U02:%x[-1,0]

U03:%x[0,0]

U04:%x[1,0]

U05:%x[2,0]

U06:%x[-2,0]/%x[-1,0]/%x[0,0]

U07:%x[-1,0]/%x[0,0]/%x[1,0]

U08:%x[0,0]/%x[1,0]/%x[2,0]

U09:%x[-1,0]/%x[0,0]

U10:%x[0,0]/%x[1,0]

U11:%x[-1,1]/%x[0,1]/%x[1,1]

U12:%x[-1,1]/%x[0,1]

U13:%x[0,1]/%x[1,1]

U14:%x[0,1]/%x[0,2]

U15:%x[-1,1]/%x[0,2]

U16:%x[-2,1]/%x[-1,1]/%x[0,2]

U17:%x[-1,1]/%x[0,2]/%x[1,1]

U18:%x[0,2]/%x[1,1]/%x[2,1]

U19:%x[0,0]/%x[0,2]

U20:%x[-1,0]/%x[0,2]

U21:%x[-2,0]/%x[-1,0]/%x[0,2]

U22:%x[-1,0]/%x[0,2]/%x[1,0]

U23:%x[0,2]/%x[1,0]/%x[2,0]

# Bigram

B

下面解释一下上述特征模板:

①Unigram类型

每一行%x[#,#]生成一个CRFs中的点(state)函数: f(s, o), 其中s为t时刻的标签(output),o为t时刻的上下文以及特征信息。

比如:U06:%x[-2,0]/%x[-1,0]/%x[0,0]

U06是指这个特征模板的编号,对于%x[-2,0]而言,%x是指找到的字符;[-2,0]是定位信息,其中中括号里面的-2是指当前词的前两个词,0是指第0列。后面用/连接的是多个特征的组合。

对于以下的训练文本:

宁夏     ns   0     isloc      notend B

物美     nz   0     notloc  notend M

超市     v     1     notloc  notend M

有限公司   n     1     notloc  notend M

森林公园   n     0     notloc  notend M

  n     1     notloc  isend    E

假如当前识别到第三行,则U06:%x[-2,0]/%x[-1,0]/%x[0,0]对应识别出来的文本为宁夏/物美/超市。

这就相当于我们在文本中找到的一条特征。

②Bigram类型

每一行%x[#,#]生成一个CRFs中的边(Edge)函数:f(s’, s, o), 其中s’为t – 1时刻的标签.也就是说,Bigram类型与Unigram大致机同,只是还要考虑到t – 1时刻的标签.这里只写一个B,默认生成f(s’, s).

有了特征模板以及训练文本文件,就可以进行CRF模型训练了,我们采用了CRF++这个开源工具包进行训练,使用默认参数,最终模型识别出来的特征有11616755条。

3.4预测、生成实体

有了上述预处理测试文本和训练生成的CRF模型,我们可以进行测试文本的标签预测,生成crf_test_output.txt

由于crf_test_output.txt里面预测的是每个词背后的标注,我们还要做一个后处理工作才能把真正的实体提取出来。

用正则表达式B+M*E+或者W匹配文本,然后将其背后的文字提取出来,就是识别出来的机构实体。

3.4效果和缺点

在使用CRF模型之后,我们得到了不错的效果。线下训练文本的实体召回率可以达到91.3%,另外,识别出来的无效实体也少了很多。

和基于规则的实体识别相比,它有着以下优点:

  • 通过特征模板,能够最大限度的挖掘文本的特征,而不需要人工提取。
  • 能够考虑大量的上下文信息、特征。
  • 考虑了相邻词的标注信息,这是传统的最大熵算法所不具备的。
  • 和神经网络模型相比,CRF模型的可解释性强,具体到每一个特征都有可以解释的意义。因此调整起来比较容易。

当然,这个模型也不是完美的,比如,我们训练的这个模型就比较“看重”机构特征词。举个例子,如果“下属公司”单独出现,则它也可能会被识别为机构名,需要我们人工定义一些规则将其去除。


CRF++工具的使用就没有介绍了,训练的过程只需要预处理语料以及模板文件,预处理语料格式和模板文件,在上文已经体现出来了,感兴趣的朋友,缺少语料或者工具,可以找我要。

代码和语料:
https://www.lookfor404.com/命名实体识别的语料和代码/

用隐马尔可夫模型(HMM)做命名实体识别——NER系列(二)

上一篇文章里《用规则做命名实体识别——NER系列(一)》,介绍了最简单的做命名实体识别的方法–规则。这一篇,我们循序渐进,继续介绍下一个模型——隐马尔可夫模型。

隐马尔可夫模型,看上去,和序列标注问题是天然适配的,所以自然而然的,早期很多做命名实体识别和词性标注的算法,都采用了这个模型。

这篇文章我将基于码农场的这篇文章《层叠HMM-Viterbi角色标注模型下的机构名识别》,来做解读。但原文中的这个算法实现是融入在HanLP里面的。不过他也有相应的训练词典,所以我在这篇文章里面也给出一个python实现,做一个简单的单层HMM模型,来识别机构名。

代码地址:https://github.com/lipengfei-558/hmm_ner_organization

1.隐马尔可夫模型(HMM)

隐马尔可夫模型(Hidden Markov Model,HMM),是一个统计模型。

关于这个模型,这里有一系列很好的介绍文章:http://www.52nlp.cn/category/hidden-markov-model

隐马尔可夫模型有三种应用场景,我们做命名实体识别只用到其中的一种——求观察序列的背后最可能的标注序列

即根据输入的一系列单词,去生成其背后的标注,从而得到实体。

2.在序列标注中应用隐马尔可夫模型

HMM中,有5个基本元素:{N,M,A,B,π},我结合序列标志任务对这5个基本元素做一个介绍:

  • N:状态的有限集合。在这里,是指每一个词语背后的标注。
  • M:观察值的有限集合。在这里,是指每一个词语本身。
  • A:状态转移概率矩阵。在这里,是指某一个标注转移到下一个标注的概率。
  • B:观测概率矩阵,也就是发射概率矩阵。在这里,是指在某个标注下,生成某个词的概率。
  • π:初始概率矩阵。在这里,是指每一个标注的初始化概率。

而以上的这些元素,都是可以从训练语料集中统计出来的。最后,我们根据这些统计值,应用维特比(viterbi)算法,就可以算出词语序列背后的标注序列了。

命名实体识别本质上就是序列标注,只需要自己定义好对应的标签以及模式串,就可以从标注序列中提取出实体块了。

3.实战:用HMM实现中文地名识别

3.1 参考论文以及网站

  • 张华平, 刘群. 基于角色标注的中国人名自动识别研究[J]. 计算机学报, 2004, 27(1):85-91.
  • 俞鸿魁, 张华平, 刘群. 基于角色标注的中文机构名识别[C]// Advances in Computation of Oriental Languages–Proceedings of the, International Conference on Computer Processing of Oriental Languages. 2003.
  • 俞鸿魁, 张华平, 刘群,等. 基于层叠隐马尔可夫模型的中文命名实体识别[J]. 通信学报, 2006, 27(2):87-94.
  • 码农场:层叠HMM-Viterbi角色标注模型下的机构名识别

3.2 任务

命名实体识别之中文机构名的识别。

3.3 语料

HanLP(https://github.com/hankcs/HanLP/releases)提供的语料:

我用的是data-for-1.3.3.zip,百度网盘下载地址:

https://pan.baidu.com/s/1o8Rri0y

下载后解压,我们要用的语料路径如下:

\data-for-1.3.3\data\dictionary\organization

其中,里面有两个我们要用到的语料文件,nt.txtnt.tr.txt。这两个文件的数据统计自人民日报语料库。

① nt.txt:

词语标注统计词典,比如里面有一行是这样的:

会议 B 163 C 107 A 10

意思是,会议这个词作为B标签出现了163次,作为C标签出现了107次,作为A标签出现了10次.

② nt.tr.txt:

标签转移矩阵。如下图:nt.tr.txt文件

即,每一个标签转移到另一个标签的次数。比如第二行第四列的19945,代表着【A标签后面接着是C标签】出现了19945次。

以上语料我都提取出来放到代码目录的./data下了。

3.4 代码实现

代码的思路很直观,只要按照上面第2部分所说的,准备好5元组数据,然后用viterbi算法解码即可。

3.4.1 N:状态的有限集合

在机构名识别的这个任务中,论文《基于角色标注的中文机构名识别》把状态(角色)定义为以下集合:原论文状态角色表

然而在HanLP的语料中,只有以下的标签,有多出来的,又不一样的:

A,B,C,D,F,G,I,J,K,L,M,P,S,W,X,Z

经过我的整理,完整的状态(角色)集合如下:

角色 意义 例子
A 上文 参与亚太经合组织的活动
B 下文 中央电视台报道
X 连接词 北京电视台天津电视台
C 特征词的一般性前缀 北京电影学院
F 特征词的人名前缀 何镜堂纪念馆
G 特征词的地名性前缀 交通银行北京分行
K 特征词的机构名、品牌名前缀 中共中央顾问委员会

 

 

美国摩托罗拉公司

I 特征词的特殊性前缀 中央电视台

 

 

中海油集团

J 特征词的简称性前缀 政府
D 机构名的特征词 国务院侨务办公室
Z 非机构成分  
L 方位词 上游

 

 

M 数量词 36
P 数量+单位(名词) 三维

 

 

两国

W 特殊符号,如括号,中括号 ()

 

 

【】

S 开始标志 始##始

本程序以上面我整理的这个表格的状态角色为准(因为HanLP的语料词典里面就是这样定义的)。

3.4.2 M:观察值的有限集合

在这里,观察值就是我们看到的每个词。

不过有一个地方要注意一下,在语料词典nt.txt中,除了所有词语之外,还有下面8个特殊词语:

  • 始##始
  • 末##末
  • 未##串
  • 未##人
  • 未##团
  • 未##地
  • 未##数
  • 未##时

这些词语可以在层叠HMM中发挥作用,加进去可以提高识别精度,因为很多机构名里面都有人名和地名。

在使用我的这份代码之前,你可以用分词工具先识别出相关的词性,然后将对应命中的词语替换为上面的8个特殊词语,再调用函数,精确率会大大提高。

3.4.3 A:状态转移概率矩阵

在这里,它是指某一个标注转移到下一个标注的概率。

generate_data.pygenerate_transition_probability()函数就是干这事的,它会生成一个transition_probability.txt,即转移概率矩阵。

3.4.4 B:观测概率矩阵(发射概率矩阵)

在这里,他是指在某个标注下,生成某个词的概率。

generate_data.pygenerate_emit_probability()函数就是干这事的,它会生成一个emit_probability.txt,即观测概率矩阵(发射概率矩阵)。

3.4.5 π:初始概率矩阵

在这里,它是指每一个标注的初始化概率。

generate_data.pygenertate_initial_vector()函数就是干这事的,它会生成一个initial_vector.txt,即初始概率矩阵。

3.4.6 维特比(viterbi)算法解码

这部分代码是参考《统计方法》里面的实现写的,做了些调整,使之可以适用于这个机构名识别的任务。函数为viterbi() ,位于OrgRecognize.py里面。

使用这个函数,就能获得最佳标注序列。

3.4.7 匹配标注序列,得到机构名

在3.4.6里面,我们可以得到一个标注序列,哪些标注代表着实体呢?

HanLP作者整理了一个nt.pattern.txt(我也放置在./data/nt.pattern.txt下了),里面是所有可能是机构名的序列模式串(有点粗暴,哈哈),然后用Aho-Corasick算法来进行匹配。

为了简单起见突出重点,我的代码实现里,用的是循环遍历匹配,具体的实现在OrgRecognize.py里面的get_organization,函数的作用是,输入原词语序列、识别出来的标注序列和序列模式串,输出识别出来的机构名实体。

3.4.8 使用程序

代码地址:https://github.com/lipengfei-558/hmm_ner_organization

环境以及依赖:

  • python2.7
  • jieba分词(可选)

首先,运行以下脚本,生成transition_probability.txtemit_probability.txt以及initial_vector.txt

python generate_data.py

然后,运行

python OrgRecognize.py

就可以了,不出意外,“中海油集团在哪里”这句话,会识别出“中海油集团”这个机构实体。

具体输入的句子逻辑,可以在main函数里面灵活修改,也可以结合jieba一起用。另外,python2.7的中文编码问题要注意了,如果你的输出序列很奇怪,很有可能是编码问题。

4.总结、待改进

用HMM来实现的命名实体识别算法,关键在于标签的自定义,你需要人工定义尽可能多的标签,然后在训练语料集里面自动标注这些标签,这也是最麻烦的地方。标注完语料集,生成HMM中的转移概率、初始概率、发射概率就很简单了,就是纯粹的统计。

整个模型也没什么参数,用这些统计的数字即可计算。

算法可能可以改进的点如下:

  1. 针对命名实体的维特比(viterbi)算法中,如果遇到未登录词,默认发射概率为0。我们可以额外引入相似度机制来解决这个问题,比如利用同义词表或者词向量相似度,我们找到和未登录词相似、同时也在观测概率矩阵里面出现的词语,用这个词语的发射概率(或者对其乘一个缩放系数),来代替未登录词的发射概率。
  2. 初始化概率对最终效果的影响有待考证。因为初始化概率影响着单词序列第一个词的标注,假如,仅仅用发射概率来决定第一个词的标注,效果会不会更好?

HMM算法默认只考虑前一个状态(词)的影响,忽略了更多上下文信息(特征)。后来的MEMM、CRF,都是循序渐进的改进方法。传统机器学习方法里面,CRF是主流,下一篇我会继续介绍CRF在命名实体识别任务上的应用。

代码和语料:
https://www.lookfor404.com/命名实体识别的语料和代码/

用规则做命名实体识别——NER系列(一)

兑现自己上一篇立下的flag,从头开始写这几个月对命名实体识别这个任务的探索历程。这是这个系列的第一篇——用规则来做命名实体识别。

1.什么是命名实体识别

命名实体识别(Named Entity Recognition,简称NER),是一个基本的NLP任务,按照传统,下面是百度百科对它的解释:

 一般来说,命名实体识别的任务就是识别出待处理文本中三大类(实体类、时间类和数字类)、七小类(人名、机构名、地名、时间、日期、货币和百分比)命名实体。

当然了,你可以自己定义这个任务,比如识别商品名,也是这个任务范畴。

说得更泛一点,命名实体识别本质上是序列标注问题。

2.为什么要做命名实体识别

第一次接触这个任务的时候,我也问过这个问题,甚至还觉得我直接搞一个数据库,把所有实体都存进去,然后直接匹配不就好了。

事实证明我太天真了。因为,命名实体识别存在以下几个问题:

  1. 实体数量巨大,而且在不断增长。举个例子,每年那么多新的机构、公司,要收集起来也是一个头条的问题。
  2. 实体组合多。很多实体都是组合词,包括中英文组合也很多。
  3. 歧义多。比如拿我的名字举个例,“李鹏飞起来了”,是不是可以识别出两个人名。/坏笑

等等等等,反正,这个任务是有意义的。

3.用规则来做命名实体识别

好了,进入今天的主题了。

先说一句,命名实体识别有非常多的方法,其中基于规则是最古老、最笨的方法,但是为了熟悉这个任务,以及认识到这个方法有多麻烦,我还是进行了探索。

2017CCF BDCI(CCF 大数据与计算智能大赛)有个赛题:《基于机构实体的智能摘要和风险等级识别》,里面有个子任务就是识别机构实体。

我先用规则来做,因为语料集里面大多数的实体,都是有规律的,比如:命名实体数据

上面命名实体都用颜色标注出来了,可以看到,基本上的组成机构是这样的:

若干地名+若干其他成分+若干特征词

其中,特征词指的是诸如公司有限公司管理局这些词语。

那么这个基于规则的实体识别算法就呼之欲出了。步骤如下:

1.分词

我用的是jieba分词:https://github.com/fxsjy/jieba

将原始文本进行分词,同时使用jieba的词性标注功能。(这个步骤也可以去停用词,依你的语料集而定)

2.收集特征词词典

将训练文本中的实体取出来,对于每一个实体,进行分词,取最后一个词,存入词典,最后做去重。

如果想把词典做的更细致,可以借助外部词典,比如hanlp的词典,具体他的词典构造,我将在之后写hmm模型的时候作介绍。

3.标注序列

对文本分词后的词语,进行标注。

①如果这个词语的词性为ns(地名),则将这个词标记为S。(如果不想用jieba的词性标注,这里也可以用地名词典来识别,需自己整理)

②如果这个词语在【步骤2】的特征词词典里,则将这个词标记为E。

③其它情况,标记为O。

这样,我们就把输入的句子转化为了标注序列。比如:

我 来到 北京 天安门

就会被标记为:

OOSE

4.正则匹配,识别实体

我们上面说到,对于这个语料集,我们发现实体的组成规律是这样的:

若干地名+若干其他成分+若干特征词

那么,我们只需要对标注后的序列做一个正则表达式的匹配即可。模式为:

S+O*E+

上面表达式的意思是,必须以1个以上地名开头,以1个以上特征词结尾,中间成分,数量就无所谓了。

匹配出符合要求的字符,将其背后的中文组合起来,就是我们要的实体。

你可以任意定义标注和模式,来适应你的规则。


方法很笨很暴力。

但是你不得不说,如果有的任务很简单,很有规律,用规则来提取实体是个不错的选择,因为它不费时,不费力,能解决问题。

RNN教程4-实现GRU/LSTM网络

这是RNN教程的第四部分,也是最后一部分。我们将讨论两个RNN模型的变种—LSTM(Long Short Term Memory)和GRU(Gated Recurrent Units)。

RNN翻译教程目录:

英文出处

关于lstm,有一篇不错的教程:http://colah.github.io/posts/2015-08-Understanding-LSTMs/

这是LSTM模型提出的原始论文《LSTM can solve hard long time lag problems
》,作者是S Hochreiter和 J Schmidhuber,这个模型也是在NLP领域里面应用的最广的一个。

而GRU则是在2014年被提出来,《Learning Phrase Representations using RNN Encoder–Decoder for Statistical Machine Translation》,是LSTM的一个简单变体,他们有许多相同的属性。

我们先从LSTM开始,然后再看看GRU有什么不同。

LSTM网络

RNN教程第三部分,我们提到了梯度消失的问题,也正是这个问题,导致了RNN无法学习长期依赖。LSTM就是用来解决这个问题的,因为它引入了一个叫做“门”(gating)的机制。为了搞明白这个门是什么,我们先看看LSTM是怎么计算隐藏层st的:

LSTM计算式子
LSTM计算式子

上面的这些式子看上去好像挺复杂的,但它没你想象的那么难。首先,我们需要注意到,LSTM层只是计算隐藏层的另一种方式

在之前,也就是原始的RNN,我们用s_{t}=\tanh(Ux_{t}+Ws_{t-1})这个式子来计算隐藏层,隐藏层的输入单元有两个,一个是当前时间步的输入x_{t},另一个是上一个隐藏状态s_{t-1}LSTM单元干的也是一样的事情,只不过换了个方式而已!这是理解LSTM的关键。现在,你最终可以把LSTM和GRU当作一个黑盒来使用,只要给你一个当前时间的输入以及上一个状态,你就可以计算出下一个状态了。正如下图:

简化LSTM和GRU模型
简化LSTM和GRU模型

有了这样的铺垫之后,我们就可以来看看LSTM的隐藏层是怎么来计算的了。Chris Olah有一篇博文介绍的很清楚(http://colah.github.io/posts/2015-08-Understanding-LSTMs/),为了不做重复工作,我在这里会给出比较简洁的解释。当然,想要理解更深刻一些,我建议你还是去拜读一下他的那篇博客。好了,我们开始吧。

LSTM计算式子
LSTM计算式子

  • i,f,o分别被称为input(输入)门,forget(遗忘)门和输出(output)门。注意,他们有着相同结构的等式,只不过参数是不一样的。他们之所以被称为门,是因为sigmoid函数会将这些向量映射到0到1这个区间,然后拿它们和其它的向量相乘,你就能决定究竟让多少其它的向量“通过”这个门。输入门能让你决定放多少当前时间步新计算的信息通过,遗忘门能让你决定放多少上个状态的信息通过,最后的输出门能让你决定放多少信息给到更高一层神经网络或者下一个时间步。这三个门都有同样的维度ds,也就是隐藏层的大小。
  • g是基于当前输入和先前隐藏状态计算的“候选”隐藏状态。这个式子和我们在原始RNN中的式子是一样的,我们只是把参数U,W改成了U^{g}W^{g}而已。在原始RNN中,我们是直接把g的值当作一个状态输出给下一个状态,在LSTM中,我们将用输入门来挑选g的一部分值,再输出给下一个状态。
  • c_{t}就是内部的记忆单元了。它是上一个记忆单元和遗忘门相乘的结果,再加上隐藏候选状态g和输入门相乘的结果。这样,我们既可以完全忘记上一个记忆单元的内容(把遗忘门全部置为0),也可以完全忽略掉新的输入状态(把输入门全部置为0),当然现实情况是我们需要的结果是这两种极端情况之间。
  • 给定了记忆单元c_{t},我们最终要计算出隐藏状态s_{t},由式子可知,s_{t}是由输出门和记忆单元(用tanh处理)相乘得到的。并非所有的内部记忆单元都与其他隐藏状态相关。

下面是出自《Empirical evaluation of gated recurrent neural networks on sequence modeling》这篇论文的一张图,描述LSTM的内部细节:

LSTM内部细节
LSTM内部细节

直观上来看,原始的RNN似乎可以看作是LSTM的一个特例—当你将输入门全部置为1,、遗忘门全部置为0(你总是忽略之前的记忆单元)、输出门全部置为1,你就会几乎得到标准的RNN,只不过多了个tanh压缩了一下输出。正是这个“门机制”让LSTM能够明确的建立长期记忆依赖,通过学习这些门的参数,神经网络能够更好的利用这些记忆。

要注意的是,LSTM的结构也有几种变种。一个最常见的变种就是创建peephole(窥见孔),这个peephole能让门不仅仅依赖于上一个状态s_{t-1},也依赖于上一个内部记忆单元c_{t-1}。在门方程增加附选项即可。这篇论文(LSTM: A Search Space Odyssey)评估了不同的lstm结构。

GRU网络

GRU网络背后的思想和LSTM非常相似,它的式子如下:

GRU计算式子
GRU计算式子

GRU中有两个门,重置(reset)门r和更新(update)门z。从直观上看,重置门决定了新的输入和上一个记忆怎么组合,更新门决定了保留多少前面的记忆信息。如果我们把重置门全部置为1,更新门全部置为0,我们就又重新得到原始的RNN模型了。它的思想和LSTM是一致的,都是用门机制来学习长期记忆以来,但是它有几个关键的区别:

  • GRU有两个门,LSTM有3个。
  • GRU没有内部记忆单元(c_{t}),也没有LSTM中中的输出门。
  • LSTM的输入门和遗忘门,在GRU中被整合成一个更新门z;而重置门r被直接用到前一个隐藏状态上面了。因此,LSTM中重置门的职责实际上被分为GRU中的rz
  • 计算输出的时候,我们不会再多用一次非线性的变换了。

下面是出自《Empirical evaluation of gated recurrent neural networks on sequence modeling》这篇论文的一张图,描述GRU的内部细节:

GRU内部细节
GRU内部细节

GRU和LSTM的对比

现在你已经见识到这两个为了解决RNN梯度消失问题而提出的模型了,你也可能会好奇:我该用哪个?GRU是比较新的模型(2014年提出的),关于它的一些利弊现在还没有探索清楚。从这两篇论文来看: Empirical Evaluation of Gated Recurrent Neural Networks on Sequence Modeling  和 An Empirical Exploration of Recurrent Network Architectures,两个模型难以分出胜负。在许多任务里面,这两个模型有不同的表现,然而,更多情况下,调整超参数会比选择模型更重要

GRU有更少的参数(U、W规模比较小),所以训练起来会更快一些,而且需要的训练数据也少一些。相对应的,如果你有足够的训练数据,LSTM会是更好的选择。

实现

让我们回顾一下RNN教程第2部分提到的语言模型,这次,我们用GRU来实现。(LSTM的实现也差不多,只不过式子不一样而已)

我们通过之前theano版本的代码来修改,还是要记住这一点,GRU(或者LSTM)层只是换了一种方式计算隐藏层而已。所以,我们需要做的,仅仅是在前向转播的代码里,把隐藏层的计算步骤修改一下。

def forward_prop_step(x_t, s_t1_prev):
      # This is how we calculated the hidden state in a simple RNN. No longer!
      # s_t = T.tanh(U[:,x_t] + W.dot(s_t1_prev))
       
      # Get the word vector
      x_e = E[:,x_t]
       
      # GRU Layer
      z_t1 = T.nnet.hard_sigmoid(U[0].dot(x_e) + W[0].dot(s_t1_prev) + b[0])
      r_t1 = T.nnet.hard_sigmoid(U[1].dot(x_e) + W[1].dot(s_t1_prev) + b[1])
      c_t1 = T.tanh(U[2].dot(x_e) + W[2].dot(s_t1_prev * r_t1) + b[2])
      s_t1 = (T.ones_like(z_t1) - z_t1) * c_t1 + z_t1 * s_t1_prev
       
      # Final output calculation
      # Theano's softmax returns a matrix with one row, we only need the row
      o_t = T.nnet.softmax(V.dot(s_t1) + c)[0]
 
      return [o_t, s_t1]

在我们的实现代码里面,我们添加了偏置单元b、c,这在之前的等式里面没有体现。当然我们也要更改U、W的初始化,因为我们现在有这不同的大小了,在这里代码就不贴出来了,在github(https://github.com/dennybritz/rnn-tutorial-gru-lstm)上面有,我还加了一个word embedding层E。

这很简单,但是梯度怎么办?我们仍然可以跟以前一样应用链式求导法则,但实际上,更多人用一些库来完成这个步骤,比如theano。如果你还是想自己动手计算梯度,你可能需要实现不同模块的求导算法。在这里,我们让theano直接帮我们计算:

# Gradients using Theano
dE = T.grad(cost, E)
dU = T.grad(cost, U)
dW = T.grad(cost, W)
db = T.grad(cost, b)
dV = T.grad(cost, V)
dc = T.grad(cost, c)

很好!为了得到更好的结果,我们使用了一些小技巧。下面就来说说看。

使用rmsprop来进行参数的更新

RNN教程第二部分,我们使用了最基本的SGD算法来进行参数的更新,然而它表现的并不是那么好。如果你把学习率设置得很低,SGD确实会让你得到好的训练效果,但是这样就太慢了。为了解决这个问题,有很多SGD算法的变种,包括 (Nesterov) Momentum Method), AdaGradAdaDelta 和 rmsprop,这里有一篇文章总结得很好(http://cs231n.github.io/neural-networks-3/#update)。

在这部分教程,我将使用rmsprop这个方法,它背后的思想是:根据先前的梯度和来调整每个参数的学习率。直观上我们可以这么理解,就是频繁出现的特征获得较小的学习率(因为他们的梯度总和更大),而比较少出现的特征就获得更大的学习率。

Rmsprop算法的实现非常简单,对于每一个参数,我们对应存储一个缓存变量,在梯度下降的过程中我们更新参数以及缓存变量,正如下式(以W为例):

cacheW = decay * cacheW + (1 - decay) * dW ** 2
W = W - learning_rate * dW / np.sqrt(cacheW + 1e-6)

衰减率设置在0.9到0.95之间,另外1e-6是为了避免分母为0

添加embedding层

使用word embedding(词嵌入)层是改进模型精确度的流行方法,其中word2vecGloVe都是比较出名的。和用one-hot表示句子不同,这种词嵌入的方法用低维(通常是几百维)的向量来表示词语,这有个好处,那就是能通过向量来判断两个词的语义是否相近,因为如果他们意思相近的话,他们的向量就很相近。要使用这些向量,需要预训练语料库。从直觉上来看,使用word embedding层,就相当于你告诉神经网络词语的意思,从而神经网络就不需要学习关于这些词语的知识了。

增加第二个GRU层

为我们的神经网络增加多一层,能够使得模型捕捉更高层次的交互信息,你可以加多几层,但我对这个实验并没有做过多的尝试。你可能在2,3层的计算之后,发现计算值逐渐减小,而且如果你没有足够的数据,增加层数是没有效果的,另外可能造成过拟合。

增加一层GRU或者LSTM
增加一层GRU或者LSTM

增加一层GRU或者LSTM是很简单的,我们只需要修改前向传播的计算过程以及初始化函数:

# GRU Layer 1
z_t1 = T.nnet.hard_sigmoid(U[0].dot(x_e) + W[0].dot(s_t1_prev) + b[0])
r_t1 = T.nnet.hard_sigmoid(U[1].dot(x_e) + W[1].dot(s_t1_prev) + b[1])
c_t1 = T.tanh(U[2].dot(x_e) + W[2].dot(s_t1_prev * r_t1) + b[2])
s_t1 = (T.ones_like(z_t1) - z_t1) * c_t1 + z_t1 * s_t1_prev
 
# GRU Layer 2
z_t2 = T.nnet.hard_sigmoid(U[3].dot(s_t1) + W[3].dot(s_t2_prev) + b[3])
r_t2 = T.nnet.hard_sigmoid(U[4].dot(s_t1) + W[4].dot(s_t2_prev) + b[4])
c_t2 = T.tanh(U[5].dot(s_t1) + W[5].dot(s_t2_prev * r_t2) + b[5])
s_t2 = (T.ones_like(z_t2) - z_t2) * c_t2 + z_t2 * s_t2_prev

最后,完整代码戳这里:https://github.com/dennybritz/rnn-tutorial-gru-lstm/blob/master/gru_theano.py

关于RNN教程,就翻译到这里了,过程中有修改、删除部分内容,有什么问题可以评论里一起探讨,谢谢!

RNN教程3-BPTT算法以及消失的梯度

这是RNN教程的第三部分。

RNN翻译教程目录:

英文出处

上一部分,我们从零开始实现了一个RNN,但是并没有对其中的BPTT算法作详细的解释。在这一部分,我们将简要的介绍一下RNN,并解释一下它和传统的反向传播算法有什么不同。接着,我们将会尝试着去理解梯度消失问题,也正是因为存在这个问题,LSTM和GRU才会被提出来,这两个模型在NLP领域相当流行。

为了深刻理解这部分教程,我建议你最好熟悉一下反向传播算法,下面三篇教程可供参考,他们的难度是逐渐增大的:

http://cs231n.github.io/optimization-2/
http://colah.github.io/posts/2015-08-Backprop/
http://neuralnetworksanddeeplearning.com/chap2.html

BPTT算法

让我们快速的回顾一下RNN的基本式子。注意,这里有个小小的改动,那就是o变成了\hat{y},这是为了和要引用的一些文献保持一致:

    \[ s_{t}=\tanh(Ux_{t}+Ws_{t-1}) \]

    \[ \hat{y}_{t}=\rm softmax(Vs_{t}) \]

同样,定义损失函数为交叉熵损失函数如下:

交叉熵损失函数

y_{t}是正确的标签,\hat{y}_{t}是我们的预测。我们以一个句子序列为一个训练样本,所以总的误差就是每一个时间步(单词)的误差和。

RNN反向传播
RNN反向传播

记住,我们的目标是计算误差对应U,V,W的梯度,从而用SGD算法来更新U,V,W。正如我们上面做的—把误差相加,在这里我们也把训练样本中每一个时间步的梯度相加起来:

    \[ \frac{\partial E}{\partial W}=\sum_{t} \frac{\partial E_{t}}{\partial W} \]

RNN结构图
RNN结构图

为了计算这些梯度,我们使用导数的链式法则。这正是反向传播算法中从最后一层将误差向前传播的思想。接下来,为了能有具体的理解,我们将用E3作为例子:

以E3为例子的BPTT算法

在上面的式子中,z_{3}=Vs_{3},\bigotimes是外积的意思。事实上,上面这个式子的推导还是省略了几个步骤的,包括softmax函数与交叉熵结合的求导,关于里面的细节,可以参考这两篇文章:

http://www.jianshu.com/p/ffa51250ba2e
http://blog.csdn.net/u014313009/article/details/51045303

但这里的重点还不在于推导的过程,重点在于\frac{\partial E_{3}}{\partial V}这个值,只取决于当前时间步的一些值:\hat{y}_{3}y_{3}s_{3},如果你有了这些值,计算关于V的梯度只是简单的矩阵乘法而已。

而对于\frac{\partial E_{3}}{\partial W}(还有U)而言,事情就不一样了,我们来看一下链式式子:

链式求导式子1
链式求导式子1

请注意, 这个式子取决于s_{2}的值,而s_{2}由取决于Ws_{1}的值…依此类推。所以,如果我们想得到相对于W的导数,我们就不能把s_{2}当作一个常量。重新应用链式法则,我们得到:

链式求导式子2
链式求导式子2

上面的式子用到了复合函数的链式求导法则。再上个图加深理解:

BPTT复合函数链式求导
BPTT复合函数链式求导

注意,上面的步骤其实和标准的反向传播算法是一样的,关键的不同在于我们将每个时间步对于W的梯度都加了起来。在传统的NN里面,我们不共享权重,所以我们不用做这种加法。和传统的反向传播算法一样,我们仍然可以定义残差,然后计算梯度。

下面上代码:

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: dL/dz
        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)
            # Add to gradients at each previous step
            dLdW += np.outer(delta_t, s[bptt_step-1])              
            dLdU[:,x[bptt_step]] += delta_t
            # Update delta for next step dL/dz at t-1
            delta_t = self.W.T.dot(delta_t) * (1 - s[bptt_step-1] ** 2)
    return [dLdU, dLdV, dLdW]

从代码中我们可以看到,为什么RNN这么难以训练—因为序列太长了,可能超过20个单词,所以你要传播很多层。而在实际上,很多人将反向传播截断成比较少的步骤,正如上面代码中的bptt_truncate参数定义的那样。

梯度消失和梯度爆炸

关于这部分内容,就不翻译了,感兴趣的朋友可以参考我之前写过的【关于梯度消失和梯度爆炸】,而在原始RNN当中,这个问题尤其突出。

下一篇,也是最后一篇教程,我们将关注LSTM模型和GRU模型。

RNN教程2-用python、numpy和theano实现RNN

在这个第2部分,我们将用python实现一个完整的RNN网络。用到numpy和theano这两个库。

RNN翻译教程目录:

英文出处

1.语言模型

我们的目标是用RNN网络来训练出一个语言模型。简单来说下语言模型是怎么回事,假如我们有一个句子,这个句子由m个单词组成,语言模型允许我们,在观测到这个句子时,能够预测下一个单词是什么(也就是一个单词在此基础上出现的概率),也就是条件概率:
条件概率

举个例子。有一个句子“He went to buy some chocolate”,这个句子是怎么生成的呢?我们可以将其看作给定了”He”这个条件之后出现”went”的概率,乘以给定了”He went”这个条件之后出现”to”的概率,乘以…(以此类推)给定了”He went to buy some”这个条件之后出现”chocolate”的概率。

为什么我们要将概率应用到这上面来呢?

首先,这个模型可以用来当作一个评分模型,比如,在机器翻译系统中,通常会生成多个待选的结果,这时候,我们可以选择那个概率最高的句子作为输出。

再者,这个语言模型还有一个很酷的性质,那就是我们可以生成新的文本,Andrej Karparthy的一篇讲述RNN的有效性的博文里面提到,我们可以通过基于RNN的语言模型来生成新的文本,从莎士比亚的诗集到linux的源码,都是可以的。

我们需要注意到,上面提到的概率模型需要用到一个句子里面的所有文字信息,而很多模型是做不到记忆那么多前置信息的。虽然RNN在理论上是为这个而生,但是,它在长期记忆方面的能力还是有所欠缺的。这个后面会继续探讨。

下面开始讲基于RNN的语言模型的代码实现。

2.训练数据和预处理

为了训练这个语言模型,我们需要训练数据。就好像我们学讲话一样,都是通过大量的练习,才会慢慢形成后来的讲话习惯。

幸运的是,这个模型的实现并不需要任何的人工标记。训练数据,我们选择的是reddit上的15000个较长的评论(原文放在bigtable上面数据集链接貌似已经失效,感兴趣的可以去搜一下,也可以用别的语料来训练,原理是一样的)。我们期望能实现一个模型,能够生成类似于reddit的这些评论的文字。好了,先来预处理一下训练数据。

2.1 分词

我们需要把原始语料用的每一段文字切分成句子,然后每一个句子再切分成单词。英文的分词比较简单,可以直接用NLTK(http://www.nltk.org/)的分词系统,里面word_tokenizesent_tokenize这两个方法就足够了。当然NLTK也支持中文接口,可以参看这篇文章:在NLTK中使用斯坦福中文分词器

2.2 过滤掉低频词

在我们的语料库里面,有的单词只出现一到两次,我们最好把这些低频词给去掉,因为如果词汇太多的话,训练会非常慢。而且,对于这些低频词来说,我们没有足够的上下文信息来支撑他们的训练。这类似我们人类的学习,要学习一个词的意义,我们需要在更多的语境里面看到它们。

在代码里面vocabulary_size代表着词汇表的规模(我将词汇表的大小设置为8000,代表着8000个最常出现的单词,当然你也可以更改啦)。对于词汇表里没有的单词,我们将它设置为UNKNOWN_TOKEN。举个例子,如果我们的词汇表里面没有nonlinearities这个单词,那么句子“nonlineraties are important in neural networks”就会被表示成“UNKNOWN_TOKEN are important in Neural Networks”,UNKNOWN_TOKEN这个词也是在词汇表里面的。最后,当我们需要预测单词的时候,如果预测出来的单词是UNKNOWN_TOKEN的话,我们可以用选择词汇表之外的任意一个单词来替代它,又或者,干脆我们就不要生成含有UNKNOWN_TOKEN的文本。

2.3 开始和结束标记

这个模型是用来生成文本的,文本该怎么开头,又怎么结束呢?为了解决这个问题,我们可以用两个特殊的标记来代表开头和结束。对于每一组训练数据,我们在句子的开头增加SENTENCE_START这个标记,在句子的结尾增加SENTENCE_END这个标记。

2.4 建立训练数据矩阵

循环神经网络RNN的输入都是向量,而不是我们数据集里面的字符串,所以我们需要将数据集的字符串映射成向量。在代码里,用的是这两个方法:index_to_wordword_to_index,单词和索引之间可以相互映射。比如,”how”,”are”,”you”这3个词可能处于词汇表的第4,100,7733个位置,那么一个训练输入句子“how are you“就会被表达成[0,4,100,7733],其中的0是上面提到的开始标记SENTENCE_START的位置。由于我们是为了训练语言模型,那么对应的输出应该是每个单词往后移动一个位置,对应为[4,100,7733,1],其中的1是上面提到的结束标记SENTENCE_END。下面是实现的代码片段:

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]

3.构建RNN

关于RNN的基本介绍可以看第一篇教程

RNN结构图
RNN结构图

让我们具体的看看RNN怎么应用在我们的语言模型上。输入x就是一系列单词,每一个x_{t}就是一个单独的单词。但是,为了矩阵乘法运算能够起作用,我们不能使用上面例子所说的单词索引表达方法(比如这个[51, 27, 16, 10, 856, 53, 25, 34, 69, 1]
)。那么我们用什么呢?依旧是one-hot表达方法。one-hot很简单,比如我们上面说的单词表的大小为8000,某个词的索引是36,那么用one-hot来表示这个词的话,我们可以表示成一个长度为8000的数组,其中第36位为1,其它位均为0.

因此,每一个x_{t}(单词)都会是一个长度为8000的数组向量,而每一个x就会是一个矩阵,每一行代表一个单词。我们将会在构建神经网络的代码里展示这种变形,而不是在预处理的时候。对应的,o_{t}也是类似的形式,是个8000维的数组,理论上,o_{t}也是一个one-hot表示,而实际上,在我们的神经网络里,训练的结果是–o_{t}会被表示成8000个概率值,每一个概率对应着输出这个词的可能性,我们要选择概率最大的词语作为输出。

让我们重新看看公式:

    \[ s_{t}=\tanh(Ux_{t}+Ws_{t-1}) \]

    \[ o_{t}=\rm softmax(Vs_{t}) \]

我发现一个有用的经验,那就是把每一层的矩阵、向量大小给写出来,有助于你理解整个神经网络的结构。我们假设记忆单元之间连接的神经元数量H=100(称之为隐藏层)。隐藏层越大,可以学到的模式就更复杂,当然需要的计算量也更大。下面就是整个结构的大小:

结构大小

请记住,U,V和W是我们这个模型的核心,也就是神经网络的权重参数,它们最终就是我们从训练数据中学习到的东西。因此,我们需要学习的参数数量为2HC+H^2,在具体的这个网络中,C=8000,H=100,代进去结果就是1610000。注意一下,由于xt是one-hot向量(一位为1,其它均为0),所以第一步它和U的全连接乘法,本质上没什么计算量,只是一个选择Ucolumn的过程。因此,主要的矩阵乘法计算在Vs_{t}这一步。这也是我们希望词汇表越小越好的原因。

有了以上的准备,我们开始实现过程吧。

3.1 初始化

我们从一个初始化所有权重的RNN类开始,我将其命名为RNNNumpy,因为我们使用Theano来实现的。初始化U,V,W是有一点小技巧(tricky)的,我们不能将它们全都置为0,因为这样的话,所有层的计算都会变成对称的。我们需要先随机赋值。很多研究表明,一个初始化数值是会对最后的训练结果产生影响。

事实证明,最好的初始化方法,取决于我们用的是哪种激活函数(在我们的例子里是tanh),另外,还有一种推荐的方法,那就是将权重随机初始化,范围在[-\frac{1}{\sqrt{n}},\frac{1}{\sqrt{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,先不用管它,我们在后面会解释。

3.2 前向传播

接下来,让我们实现前向传播算法:

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]

注意,在函数的最后,不仅仅返回了输出层,还返回了隐藏状态层。因为我们需要用他们来计算梯度。每一个o_{t}都是一个8000维的向量,代表着输出每一个词的概率,我们只想要概率最高的那个词,所以,下面用一个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)

让我们来试运行一下

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个值,对应着词典中的每一个词可能是下一个单词的概率。当然,现在还没有开始训练,所有的值都是随机的。

然后下面看下predict函数:

predictions = model.predict(X_train[10])
print predictions.shape
print predictions

它给我们返回了45个结果,对应着词典的索引:

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

3.3 计算误差

为了训练我们的网络,我们需要需要量化误差,我们将这种量化的函数称为损失函数L,而我们的目标则是找到最优的U,V,W,使得误差最小。一个经常使用的误差函数叫做 cross-entropy loss(交叉熵损失函数)(关于交叉熵损失函数有一篇中文博客可以看下:http://blog.csdn.net/u012162613/article/details/44239919)。假设我们有N个训练样本(注意,这里不是指N个句子,而是训练样本中所有的单词的个数,因为对于这个模型而言,每输入一个单词,会对应一个输出),还有C个类别(也就是词典的大小,8000),损失函数里面有两个参数:预测输出o和实际标注y,如下式:

    \[ L(y,o)=-\frac{1}{N}\sum_{n\subseteq N}y_n\log{o_n} \]

上面的式子看上去好像有点复杂,但我们仔细看一下,它本质其实就是在度量预测输出值o和实际标注值的差距。原始的交叉熵损失函数
也是这么干的,他有一个重要的结论就是输出值和实际值越接近,整个损失函数就越小。(再重新具体看一下这个式子,yn是一个8000维的one-hot向量,和上面【构建RNN】步骤里面红色字一样,它只是起一个选择的作用,而o_{n}是一个概率,假如o_{n}越接近1,那么\log{o_{n}}就越接近0,损失函数的值就越小,这和上面的解释是吻合的。举个例子,比如y_{n}=[0,0,1],o_{n}=[0.1,0.4,0.5],\sum_{n\subseteq N}y_n\log{o_n}=1\times \log{0.5}= \log{0.5},看到没有,其他的0我们是可以忽略的,而且如果预测越精准(概率趋于1),\sum_{n\subseteq N}y_n\log{o_n}\approx \log{1}=0,也就是损失几乎为0)

下面两个函数就是具体总损失,以及平均损失。

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

3.4 用SGD算法和BPTT算法训练RNN模型

记住,我们想要通过训练数据,训练出能够使得损失最小的U,V,W。最常见的做法是SGD(Stochastic gradient descent)随机梯度下降。SGD背后的原理非常简单,我们循环遍历所有的训练样本,在每一次的循环当中,我们使得参数(也就是这里的U,V,W)的沿着某个方向下降,从而能让损失减少。这里所说的方向,就是由损失的梯度决定的:\frac{\partial L}{\partial U},\frac{\partial L}{\partial V},\frac{\partial L}{\partial W}

SGD算法还需要一个学习率( learning rate),这个学习率定义了我们在每一次迭代中,要跨多大的步子(学习率越大,学习速度越快,但容易“穿越“,导致找不到最优解;学习率越小,学习速度越慢)。这个算法不仅仅是应用在神经网络上,它在很多传统的机器学习算法上面都有大量的应用。关于这个算法,你可以深入的去了解,也有很多研究是针对它的,这是一篇教程:http://cs231n.github.io/optimization-1/

再回到我们的问题上来,我们该如何计算上面所提到的梯度呢?在传统的神经网络中,我们使用的是反向传播算法,而在RNN中,我们使用BPTT(Backpropagation Through Time )算法,用中文直白的理解就是,跨越时间的反向传播算法。为什么在RNN里面就不一样了呢?因为在这个模型当中,所有时间步的参数是共享权重的,而每一个输出的梯度,不仅仅取决于当前时间步的计算结果,还取决于在此之前所有时间步的计算结果。当然了,我们解决这个问题也是使用链式法则。关于反向传播算法,可以参看我之前写的【从梯度下降到反向传播(附计算例子)】,也可以参看这两篇英文博客:

http://cs231n.github.io/optimization-2/
http://colah.github.io/posts/2015-08-Backprop/

现在,暂且把BPTT算法当作一个黑盒子来使用吧,在下一篇教程中,我会详细介绍这个算法的。我们来看下代码实现,bptt函数最后会返回三个梯度,用于更新权重:

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]

3.5 梯度检查

当你在实现一个反向传播算法的时候,你可以同时做一个梯度检查,来检验你的算法是否正确。检查的背后原理也很简单,那就是从导数的定义出发,也就是下面的这个式子:

导数的定义

下面是实现代码:

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)

3.6 SGD算法的实现

有了上面的准备工作,我们就可以使用SGD算法来更新权重了。我倾向于用两个步骤来实现它:1. sgd_step方法:在一个batch上更新权重。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

搞定!

4.用TheanoGPU来训练我们的神经网络

原文作者在之前有写过关于Theano的一个教程(http://www.wildml.com/2015/09/speeding-up-your-neural-network-with-theano-and-the-gpu/),这里就不多赘述了,作者用theano实现了上面的神经网络,可以在github上面淘到:https://github.com/dennybritz/rnn-tutorial-rnnlm

使用示例:

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

显然速度会得到大量的提升。

如果你的电脑性能不太好的话,可能训练上好几天都不行。为此,我放出我自己预训练的theano模型:https://github.com/dennybritz/rnn-tutorial-rnnlm/blob/master/data/trained-model-theano.npz

使用方法:

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)

5.生成文本

现在已经有了模型了,我们来看看生成文本的效果:

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.

瞧瞧上面生成的句子,有一些有意思的东西值得注意。这个模型成功的学习到了语法,逗号和句号都基本放对位置了,有时候它还能模仿一些网络用语还有符号表情。

然而!!大部分生成的句子都是没有什么实际意义的,又或者有一些语法错误(上面几句是我挑的比较好的了)。分析一下原因。

首先可能是因为我们的训练时间还不够,或者训练数据不够。看上去这个原因很充分,但它其实并不是最主要的原因。

最主要的原因在于模型本身:我们的这个原始RNN模型不能学习到相隔几个词之外的依赖关系。这很奇怪,理论上这个模型就是为了长期依赖而生的,但实际上它还是表现不佳。

幸运的是,对于为什么RNN的训练那么困难已经不太难理解了(可以看这论文:http://arxiv.org/abs/1211.5063)。

下一篇教程,我们将会详细的探索BPTT算法,还会阐述一下梯度消失问题。这给了我们动力去探索更复杂的RNN模型,比如LSTM,这个NLP任务中表现很好的模型。别担心,至此为止你学到的东西,应用到LSTM上面也是一样的!

RNN教程1-RNN的基本介绍

了解RNN循环神经网络,我是从wildml的博客开始的。为了加深印象和理解,将其博客的系列教程作一个翻译,加上自己的理解,和大家分享。原文在这里:http://www.wildml.com/2015/09/recurrent-neural-networks-tutorial-part-1-introduction-to-rnns/

RNN翻译教程目录:

英文出处

这是教程系列第一篇:RNN的基本介绍。

Recurrent Neural Networks (RNNs),也就是循环神经网络,在NLP任务上非常流行。

在这个教程里,我们将会使用RNN来实现一个语言模型。语言模型的应用是有两方面,第一方面,它允许我们给任意句子打分,打分的依据则是这些句子在现实世界中出现的可能性,这给了我们语法和语义上的度量,在机器翻译领域应用较多。第二方面,一个语言模型可以让我们生成新的文本,Andrej Karpathy的这篇博文(http://karpathy.github.io/2015/05/21/rnn-effectiveness/)就展示了RNN的强大和有效性,它可以训练莎士比亚的诗集,从而生成类似莎士比亚诗集的文本。

以下内容是基于你已经对基本的神经网络和反向传播算法有了一定的了解,如果没有,可以先行了解。

1.什么是RNN

RNN背后的思想,是要利用时序信息。在传统的神经玩咯中,我们假设所有的输入(包括输出)都是相互独立的,然而对于许多任务而言,这太局限了。比如,你想根据一句没说完的话,来预测下一个词语,那你就需要上文的信息了。RNN中的Recurrent之所以称为循环,是因为它们对序列的每个元素执行相同的任务,输出取决于先前的计算。从另一个角度来思考RNN,我们可以想象它有一个“记忆”模块,储存着之前计算的信息。理论上,RNN是可以利用任意长的序列信息的,然而实际上,它只能利用有限的前几步的信息,下面是一个典型的RNN模型:典型的RNN模型

左边是RNN模型,展开之后,就是右边的样子。

举个简单的例子,如果我们关注的是由5个单词组成的句子,那么这个神经网络可以横向展开成5层,一层代表一个单词。下面解释一下图里的字母符号代表的意义:

x_{t}:在时刻t时的输入。

s_{t}:在时刻t时的隐藏状态,也就是“记忆模块”,它的值,由当前的输入x_{t}和前一个状态s_{t-1}决定,计算公式为s_{t}=f(Ux_{t}+Ws_{t-1}),f通常是非线性的激活函数,比如tanh或者ReLU函数。

o_{t}:在时刻t时的输出。比如,如果我们想要预测一句话里的下一个单词,那么在这里的输出就可以表示为一个词典序列,值为每一个词的概率。 o_{t}=softmax(Vs_{t})

需要注意的几点:

  • 你可以将s_{t}认为是神经网络的记忆单元,s_{t}存储了前面步骤的信息。在时刻t时的输出o_{t}仅仅是通过当前的记忆单元算出来的。虽然这个模型看起来比较理想化,但事实上它无法存储太多前面步骤的信息。
  • 传统的DNN模型一般对于每一层都使用不同的参数权重,而RNN横向的每一步都是共享参数权重的,也就是上面的U、V、W。这反映出一个事实,那就是这个模型对于每一步都在做同样的事情,只不过他们的输入不同而已。这样的好处是减少了很多计算量。
  • 上面的图片里的每一个时间节点,都会有一个输出,对于一些任务来说这是多余的。比如在情感分析里,我们只关心这个句子最终表达的情绪,而不是每一个单词表达的情绪。同样的,也不是必须得在每一个时间点都有输入。

2.RNN 能干嘛

RNN在许多NLP任务中取得了成功,当然这里也得提一下,通常用的最多的是LSTM模型,它能比原始的RNN存储更长的时序信息,而且你不用担心,LSTM只是RNN的一个改进,在之后的教程会介绍,它们的区别在于,LSTM计算隐藏状态s_{t}的方式不同。

好了,来看看RNN 能干嘛。

2.1 语言模型和文本生成

通过训练RNN模型,我们可以基于给定的一个单词序列,预测下一个单词是是什么。这对语言模型和文本生成来说是很有用的。

语言模型能让我们去度量生成一个句子的概率,这在机器翻译里面是非常重要的(因为如果能算出来一个高概率的句子,那么这个句子通常来讲是比较准确的)。

文本生成就更不用说了,通过训练大量的样本,我们可以用一个模型来生成新的文本。
下面是三篇论文,都是关于语言模型和文本生成的。

2.2 机器翻译

机器翻译和语言建模异曲同工,对于机器翻译而言,输入就是某一种语言的单词序列,输出是另一种语言的单词序列。区别在于,机器翻译需要读取了所有的输入之后,才能生成完整的输出,毕竟完整的上下文才能推导出最终的意思。

下图是应用于机器翻译的一个RNN模型,图片来源:http://cs224d.stanford.edu/lectures/CS224d-Lecture8.pdf:
应用于机器翻译的一个RNN模型

下面是关于机器翻译的一些论文:

2.3 语音识别

根据输入的语音信号,输出生成的文字。相关论文:

Towards End-to-End Speech Recognition with Recurrent Neural Networks

2.4 生成图片的文字描述

RNN还可以和CNN(convolutional Neural Network即卷积神经网络)组合一起做模型,为没有标记的图片生成文字描述,并取得了惊人的效果。这个组合模型甚至还能将生成的文字和图片中特征的位置对应。如下图:
生成图片的文字描述

以下是相关研究:
http://cs.stanford.edu/people/karpathy/deepimagesent/

3.训练RNN模型

RNN模型的训练过程和传统神经网络训练过程是类似的,我们同样的使用反向传播算法,当然了,具体的过程会有点不同,因为在RNN中,横向展开的每一层都是共享权重的,每一个输出的梯度(gradient)不仅仅依赖于当下这个时间点,还依赖于过去的时间点。举个例子,想要计算时间点t=4的梯度,我们需要反向传播3个时间点,并把梯度相加。这个算法就叫做BPTT(Backpropagation Through Time),后面会介绍。现在只需要知道,BPTT算法本身是有局限性的,它不能长期记忆,还会引起梯度消失和梯度爆炸问题,LSTM就是用来解决这个问题的,后面也会介绍LSTM。

4.扩展的RNN模型

过去这些年,许多学者提出了一些复杂的RNN模型的变体,用来克服原始RNN(vanilla RNN)的缺点。下面是一些典型的模型。

4.1 双向RNN( Bidirectional RNNs )

双向RNN从字面上就比较好理解,它的特点是:某个时间点的输出不仅仅依赖于过去的记忆,也依赖于后面发生的事情。最简单的例子那就是我们常做的英文完形填空了,填写缺失的单词,需要综合上下文的意思。

这个模型实现起来也很简单,只需要在原始RNN的基础上,加一个反方向的RNN即可。如下图:
双向RNN模型

4.2 多层(双向)RNN

在双向RNN的基础上,加多几层神经网络,就是多层RNN了,它有更强的学习能力,也需要更多的训练数据,如下图:
多层(双向)RNN

4.3 LSTM网络

LSTM在业界非常流行。它和基本RNN的结构是一样的,只不过它用不同的函数来计算隐藏层而已,LSTM的记忆被称为“cell”,我们暂且把它当作黑盒吧,从本质上来讲,这一些cell决定了要从前一段记忆中“记住”或者“忘记”什么东西。对于长期的记忆来说,这种模型非常奏效,这里有一篇很好的文章解释LSTM:
http://colah.github.io/posts/2015-08-Understanding-LSTMs/

5.总结

通过这第一篇教程,希望你能对RNN有一个初步的了解,并且知道它能干什么。在下一篇教程,我们将用python来实现一个简单的、基于RNN的语言模型。

关于梯度消失以及梯度爆炸

上一篇介绍了梯度下降法,对于一些浅层的神经网络来说,可以很容易看到效果,因为每一次迭代,我们都可以将误差降低。而对于深层次的神经网络来说,容易出现一个问题,那就是梯度消失以及梯度爆炸。

来看看这两个问题是怎么产生的。

以下内容参考了哈工大翻译的神经网络教程

为了弄清楚为何会出现消失的梯度,来看看一个极简单的深度神经网络:每一层都只有一个单一的神经元。下图就是有三层隐藏层的神经网络:

简单神经网络

这里,w_1,w_2... 是权重,而 b_1,b_2... 是偏置,C 则是某个代价函数。第j个神经元的输出 ,其中a_j=f(z_j)是激活函数,z_j = w_j*a_{j-1}+b_j 是神经元的带权输入,\delta_{i}为对应节点的残差。

为了揭示梯度消失和梯度爆炸的问题,我们计算\frac{\partial C}{\partial b_1},看看会发生什么。

接下来是计算过程。

结合上一篇文章【从梯度下降到反向传播(附计算例子)】的式子(4)、(6):

    \[ \frac{\partial J(W,b)}{\partial b_{i}^{l}}=\delta _{i}^{(l+1)}\qquad(4) \]

    \[ \delta _{i}^{(l)} = (\sum_{j=1}^{s_{l+1}}W_{ji}^{(l)}\delta_{j}^{(l+1)})\cdot {f}'(z_{i}^{(l)})\qquad(6) \]

式子(4)里面的J(W,b),也就是我们上面所说的代价函数C

由式子(4),可以得到:

    \[ \frac{\partial C}{\partial b_1}=\delta_2 \]

由式子(6),继续进行迭代计算

    \[ \begin{aligned} \frac{\partial C}{\partial b_1}&=\delta_2\\ &={f}'(z_1)w_2\delta_3\\ &={f}'(z_1)w_2{f}'(z_2)w_3\delta_4\\ &={f}'(z_1)w_2{f}'(z_2)w_3{f}'(z_3)w_4\delta_5 \end{aligned} \]

最后的delta_5,就是最后一个节点的残差,由链式求导法则可知:

    \[ delta_5= \frac{\partial C}{\partial a_4}{f}'(z_4) \]

代入上式,得到最终结果:

    \[ \frac{\partial C}{\partial b_1}={f}'(z_1)w_2{f}'(z_2)w_3{f}'(z_3)w_4\frac{\partial C}{\partial a_4}{f}'(z_4) \]

除了最后一项,该表达式是一系列形如w_j{f}'(z_j)的乘积,我们假设,这里的激活函数f用的是sigmoid函数,sigmoid的图像如下:

sigmoid函数图像

我们关注的是它的导数,其导数的图像为:

sigmoid函数导数图像

该导数在{f}'(0)=\frac{1}{4}时达到最高。现在,如果我们使用标准方法来初始化网络中的权重,那么会使用一个均值0标准差为1的高斯分布。因此所有的权重通常会满足\left | w_j \right |<1。有了这些信息,我们发现会有w_j{f}'(z_j)< \frac{1}{4}。并且在我们进行了所有这些项的乘积时,最终结果肯定会指数级下降:项越多,乘积的下降的越快。 这,就是梯度消失出现的原因。 同样的,如果我们选择不同的权重初始化方法、以及不同的激活函数,我们也有可能得到w_j{f}'(z_j)>1的结果,经过多层累乘,梯度会迅速增长,造成梯度爆炸。

因此,不稳定的梯度问题(梯度消失和梯度爆炸)产生的原因是:在前面的层上的梯度是来自后面的层上项的乘积。

当存在过多的层次时,就出现了内在本质上的不稳定场景。唯一让所有层都接近相同的学习速度的方式是所有这些项的乘积都能得到一种平衡。如果没有某种机制或者更加本质的保证来达成平衡,那网络就很容易不稳定了。简而言之,真实的问题就是神经网络受限于不稳定梯度的问题。所以,如果我们使用标准的基于梯度的学习算法,在网络中的不同层会出现按照不同学习速度学习的情况。

为了解决这个问题,有很多的策略,比如,nlp领域常用的lstm模型,能够解决梯度消失的问题。之后会继续介绍。