本文从广泛使用的LSTM入手,通过具体代码演示,在具备TensorFlow编程基础之上,入门NLP,然后从最初的NNLM一步一步学下去。NNLM神经网络语言模型对理解word2vec模型有很大的帮助, 包括对后期理解CNN,LSTM进行文本分析时有很大的帮助.
参考论文:A Neural Probabilistic Language Model(2003)
参考资料:https://www.jianshu.com/p/a02ea64d6459 作者:施孙甲由
https://github.com/dengliangshi/lstmlm-example
https://github.com/graykode/nlp-tutorial
以下为个人学习时摘抄的内容:
第一部分:语言建模概述、LSTM具体实践
1.前言
本部分以长短期记忆(Long Short Term Memory, LSTM)循环神经网络(Recurrent Neural Network, RNN)语言模型为例,基于Python语言,采用Tensorflow框架,系统地介绍神经网络语言模型的具体实现过程。
2.预处理
语言模型的训练需要大量的文本数据,幸运的是训练语言模型的数据不需要人工标注,属于无监督训练。文本数据可以采用公共数据集,也可以自行收集文本数据。在公共的数据集上训练和测试语言模型,由于数据已被处理完成,即为熟语料,就可以省去许多预处理工作。在此针对收集到的原始文本数据,也被称为生语料,介绍用于训练语言模型的文本数据的预处理。
对于不同语言的文本,处理的方式会有所区别。此处以英文和中文的生语料为例,简要介绍文本数据的预处理,基本步骤如下:
- 文本进行清洗是必不可少的,尤其当数据来源于网络。针对不同来源的数据,清洗的方式会有所不同,但最终的目的都是为了去除文本以外的数据,如网址链接、表情符号、特殊字符、HTML标签等等;
- 字符转换,在处理中文文本时比较常见,将全角字符转换为半角字符,将繁体中文转换为简体,或者相反;
- 大小转换,为了降低词典的大小,有时会将文本中的字母统一转成小写或者大写。进行大小写转换后,会丢失部分文本特征,降低模型性能,尤其对于英文这类由字母组成的语言,当然也可以选择不进行转化;
- 句子分割,即根据文本中的标点符号,将大段的文本切分为句子序列。对于英文这类语言的文本进行切分时,需要进行额外的工作,包括将标点与单词分开,将部分缩写分开。其中缩写的分割,如
it's
分割为it 's
,这样可以减少单词量,当然也可以选择不进行分割。英文的句子分割可采用开源工具NLTK或者斯坦福大学的自然语言处理工具包CoreNLP。对于中文,目前还没有接触到提供句子切分功能的开源的工具包; - 如果目标是中文这类没有词边界的语言文本,就需要进行分词。当然,目前也有基于字符级别的语言模型,可以不用进行分词。但是词作为语言中的重要模式,将分词信息引入语言模型,能够帮助模型学习到更多的语言模式。中文分词的开源工具包比较多,比如Jieba,HanLP等,分词的精度也会对语言模型的性能产生影响。
经过预处理,文本数据的格式为每行一句文本,分词之间以空格分隔。完成文本数据的预处理工作后,便可以进行数据集划分。在数据充足的情况下,可分为三个部分:训练集、验证集和测试集。训练集一般占所有数据的80%,用于语言模型的训练;验证集约占总数据量的10%,用于模型超参的调整;测试集由剩余的数据组成,训练好的模型将在该数据集上进行性能测试。
3.构建词典
从训练集中构建词典是建立语言模型的首要步骤,而词典的建立就是将训练集中的分词(Token)加入到字典中并分配唯一的索引。此处提到的分词(Token)包括词(Word)、标点以及文本中与词级别相当的字符或者字符串。有时当数据量较大时,分词的数量会很巨大,导致模型的计算量较大,就需要对词典的大小进行限制,将词频较低的分词丢弃掉。从训练集中构建词典的具体实现代码如下所示:
def _collect_token(self, train_file):
"""Build up vocabulary from training dataset.
:Param train_file: the path of training file.
"""
vocab = {} # tokens and their frequency from dataset
input_file = codecs.open(train_file, 'r', 'utf-8')
for sentence in input_file:
for token in sentence.strip().split():
if token not in vocab:
vocab[token] = 0
vocab[token] += 1
input_file.close()
return vocab
从训练数据中收集完分词之后,需要为每个分词分配唯一的索引。但在分配索引之前,需要向词典中加入几个特殊标识符。当对词典大小进行限制时,部分分词未被加入词典,这类分词就成为词典外的词(Out of Vocabulary, OOV)或者未登录词(Unknown Words),另外,验证集或者测试集中也会存在未登录词。通过设定特殊标识符,如oov
或者<unk>
,来表示所有的未登录词,凡是遇到未登录词都用该标识符替换。未登录词的存在使得语言模型的性能下降比较显著,但目前还没有很好的解决方案。未登录词也一致自然语言相关的人工智能任务的难点之一,目前采用字符级或类似的模型可以改善未登录词的影响,这部分内容在本系列的后续内容中会介绍。处理未登录词的标识符,还需加入句子边界的标识符,如<s>
和</s>
,或者<bos>
和<eos>
,将文本数据输入模型时,需要给每句文本加上边界标识符。句子边界也是很重要的特征,能够帮助模型识别语言模式。有时还需要对句子进行填充,需要设置填充标识符,如<pad>
,一般将其索引设为0,对应得词向量全部设定为0。将特殊标识符加入词典后,便可以为每个分词分配唯一的索引,包括特殊标识符,索引将作为分词在语言模型中的唯一标识。索引分配部分的代码如下所示:
def _assign_index(self, vocab, item2id, vocab_size, item_num):
"""
为分词分配索引
Assign each item in vocabulary with an unique index.
:Param vocab : items and their frequency from dataset.
:Param item2id : map items to their index.
:Param vocab_szie: specify the size of target vocabulary.
:Param item_num : count the number of items.
"""
sorted_vocab = sorted(vocab.items(),
key = lambda x: x[1], reverse = True)
if vocab_size > 0:
sorted_vocab = sorted_vocab[:vocab_size]
for item, _ in sorted_vocab:
if item in item2id:
continue
item2id[item] = item_num
item_num += 1
4. 生成批数据
为了利用并行计算进行加速,训练或者测试语言模型时,数据输入采用批处理(Batch)的方式。因此,在将文本数据输入模型之前,不仅需要根据字典将分词序列转换为对应的索引序列,还需要根据指定的批处理(Batch)的大小生成批处理数据。每句文本序列的长度不同,而循环神经网络语言模型的输入序列长度需固定。目前有两种处理方式,一种是对长度不足的文本序列进行填充,对过长的序列进行截断。另一种处理方法是将所有的句子序列看做一个很长的序列,然后分割为长度相同的短序列。
此处通过实例来说明这两种批数据生成方式,假设模型输入序列的长度设定为15,批处理的大小为2,需要对下面两句文本序列进行处理:
例:
当时 我 很 伤心 , 认为 这 辈子 算 完了 。
我 的 孩子 天资 还 不错 , 但 学习 成绩 一般 , 小动作 较多 , 老师 不是 特别 喜欢 , 也 不是 特别 反感 。
根据第一种策略的处理方式,结果如下:
<s> 当时 我 很 伤心 , 认为 这 辈子 算 完了 。 </s> <pad> <pad>
<s> 我 的 孩子 天资 还 不错 , 但 学习 成绩 一般 , 小动作 较多 ,
采用第二种处理策略时,上例中句子序列的处理结果为:
<s> 当时 我 很 伤心 , 认为 这 辈子 算 完了 。 </s> <s> 我
的 孩子 天资 还 不错 , 但 学习 成绩 一般 , 小动作 较多 , 老师
采用第二种方法时,需要注意在生成批数据时,相邻批数据中相同位置的序列应该是连续的,即第批数据的第条序列应该与第批数据的第条数据是连续的文本序列。
本文采用第二种策略生成批数据,处理完句子序列的长度后,根据词典将分词转换为对应的索引,最终输入模型的就是索引序列。除了模型的输入序列,还需要模型输出的目标序列,目标序列于输入序列类似。目标分词为输入分词的下一个词,因此目标序列即为输入序列向前移一位。具体实现代码如下:
def get_batches(self, batch_size, seq_length, data_type):
"""Get batches from specified dataset for model.
:Param batch_size: size of each data batch.
:Param seq_length: length of each sequence in batch.
:Param data_type : target dataset, training, validation or test.
"""
index_vector = []
file_name = ('%s.txt' % data_type)
# get the target data file
data_file = os.path.join(self.data_path, file_name)
input_file = codecs.open(data_file, 'r', 'utf-8')
# get the indexes of special mark
bos_index = self.token2id.get(self.bos_mark)
eos_index = self.token2id.get(self.eos_mark)
oov_index = self.token2id.get(self.oov_word)
# convert token sequence into index one
for line in input_file:
index_vector.append(bos_index)
index_vector.extend([self.token2id.get(token, oov_index)
for token in line.strip().split()])
index_vector.append(eos_index)
index_vector = np.asarray(index_vector, dtype = np.int32)
batch_num = int(len(index_vector) / (batch_size * seq_length))
end_index = batch_num * batch_size * seq_length
input_vector = index_vector[:end_index]
output_vector = np.copy(input_vector)
output_vector[:-1] = input_vector[1:]
output_vector[-1] = input_vector[0]
input_batch = np.split(input_vector.reshape(
batch_size, -1), batch_num, 1)
output_batch = np.split(output_vector.reshape(
batch_size, -1), batch_num, 1)
for index in range(batch_num):
yield input_batch[index], output_batch[index]
input_file.close()
5.语言模型
神经网络语言模型的神经网络结构采用LSTM长短期记忆循环神经网络,模型主体部分利用Tensorflow框架实现。首先是创建占位变量,包括输入分词序列的索引和目标分词序列的索引,即:
# place a holder for input vectors
input_holder = tf.placeholder(shape = [batch_size,
seq_length], name = 'input_holder', dtype = tf.int32)
# place a holder for target token index
target_hoder = tf.placeholder(shape = [batch_size,
seq_length], name = 'target_holder', dtype = tf.int32)
创建词向量矩阵,词向量的数量等于词典中分词的个数。建立词向量的查询表,每个词通过其在字典中的索引,查找词向量矩阵中对应的行,便得到该词的向量。
# create embedding lookup table for tokens
embeddings = tf.get_variable(shape = [vocab_size,
embedding_dim], name = 'embeddings', dtype = tf.float32)
input_tensor = tf.nn.embedding_lookup(embeddings, input_holder)
神经网络语言模型的主体部分就是神经网络结构,本文采用的是长短期记忆循环神经网络,利用TensorFlow框架的实现代码如下。这部分代码中,在实现长短期记忆神经网络的同时,还加入了Dropout机制。作为一项有效且简单的泛化技术,Dropout几乎成了神经网络的标准设置,在涉及神经网络的应用中经常被采用。
def _lstm_layers(self, input_tensor, unit_num, layer_num, keep_prob = 0.5,
is_train = False, is_reuse = False):
"""Long-short term memory (LSTM) recurrent neural network layer.
:Param input_tensor: batch of input data, [batch_size, seq_len, embedding_dim].
:Param unit_num : the size of hidden layer.
:Param layer_num : number of hidden layers.
:Param keep_prob : keep probabilty for dropout, default is 0.5.
:Param is_train : if create graph for training, default is False.
:Param is_reuse : if reuse this graph, default is False.
"""
with tf.variable_scope('LSTM', reuse = is_reuse) as scope:
lstm_cells = []
batch_size = input_tensor.shape[0]
# create lstm cells for lstm hidden layers
for i in range(layer_num):
lstm_cell = tf.nn.rnn_cell.LSTMCell(unit_num, forget_bias = 1.0)
# apply dropout to hidden layers except the last one if training
if is_train and (keep_prob < 1) and (i < layer_num - 1):
wrapper_cell = tf.nn.rnn_cell.DropoutWrapper(lstm_cell,
output_keep_prob = keep_prob)
lstm_cells.append(wrapper_cell)
else:
lstm_cells.append(lstm_cell)
# multiple lstm hidden layers
multi_cells = tf.nn.rnn_cell.MultiRNNCell(lstm_cells, state_is_tuple = True)
# inital state for hidden layer
init_state = multi_cells.zero_state(batch_size, dtype = tf.float32)
# final output and state of hidden layer
output, final_state = tf.nn.dynamic_rnn(inputs = input_tensor,
cell = multi_cells, initial_state = init_state, dtype = tf.float32)
return init_state, final_state, output
神经网络语言模型的输出层为全连接结构的网络层,输出的节点数等于词典中分词的数量,每个节点的输出为对应分词的条件概率,同样通过分词的索引进行对应。
# weight for output layer of language model
weight = tf.get_variable(shape = [unit_num, vocab_size],
name = 'weight', dtype = tf.float32)
# bias terms for output layer of language model
bias = tf.get_variable(shape = [vocab_size], name = 'bias', dtype = tf.float32)
# reshape output of hidden layers to [batch_size * seq_len, hidden_size]
reshape_state = tf.reshape(lstm_output, [-1, unit_num])
# the unnormalized probability
logits = tf.matmul(reshape_state, weight) + bias
模型输出层直接输出的为非归一化的条件概率,需要采用Softmax函数对输出的条件概率进行归一化处理,得到最终的条件概率。文本序列概率评估时,通过索引选取对应的条件概率为目标分词在当前输入下的条件概率。如果进行文本生成,则选取条件概率最大的分词为最终生成的分词。
prob = tf.nn.softmax(tf.reshape(logits)
predict_result = tf.argmax(prob, axis = -1)
模型中除了词向量,还有许多权重矩阵以及偏置向量,这些都需要设定初始值,而矩阵后者向量的初始化方法有多种,可采用均匀分布或者正态分布。 其中,得到应用广泛的是Xavier初始化方法,采用均匀分布,其具体形式如下:
其中,和分别为神经网络第和层的节点数,可以理解为权重矩阵或者向量的输入尺寸和输出尺寸。初始化策略也被认为是重要的泛化技术,因为初始化参数决定了模型所处的空间位置。神经网络的优化最终得到的是局部最优点,模型初始化的起点决定了最终收敛的局部最优点的位置。
6.模型训练
此语言模型训练所采用的优化算法是随机梯度下降算法(Stochastic Gradient Descent, SGD)
7.模型预测
语言模型的预测,即利用训练好的语言模型生成文本或者评估已有文本的概率。对于生成文本,语言模型的第一个输入为句子的起始标识符<s>
,而后的每次输入是上一步产生的分词,从而连续不断地生成分词序列。当遇到句子的结束符</s>
时,便生成了完整的文本。文本概率的评估,就是通过语言模型计算给定上文时,产生当前词的条件概率,从而得到整个文本序列的概率。
完整代码:https://github.com/dengliangshi/lstmlm-example
第二部分 NNLM(Neural Network Language Model) 神经网络语言模型
1.前言
在神经网络(Neural Network, NN)被成功应用于语言建模之前,主流的语言模型为N-gram模型,采用计数统计的方式,在离散空间下表示语言的分布。由于缺乏对词的相似性的有效表示,N-gram语言模型存在严重的数据稀疏问题。虽然引入平滑技术,但数据稀疏问题仍不能得到有效的解决。神经网络语言模型则采用分布式的方式表示词,即通常所说的词向量,将词映射到连续的空间内,有效地解决了数据稀疏问题。并且神经网络具有很强的模式识别能力,神经网络语言模型的性能远优于N-gram模型。但由于神经网络语言模型的计算复杂度远高于N-gram模型,在对实时性有要求的应用场合,如语音识别,仍采用N-gram语言模型。
2.语言模型
假设为来源于某种自然语言的词序列,语言建模的目的就是构建该自然语言中词序列的分布,然后用于评估某个词序列的概率。如果给定的词序列符合语用习惯,则给出高概率,否则给出低概率。在语言建模过程中,采用了链式法则,单个词序列的概率被分解为序列中各个词的条件概率的乘积,而每个词的条件概率为给定其上文时的该词出现的概率。因此,上述词序列的概率可表示为:
不难看出,上述模型的成立时需要前提假设,即在词序列中,每个词只依赖于其上文,而与下文无关。不论是依据语言使用的直观体验,还是人工智能的诸多实践,都验证了该假设是不成立的。在自然语言处理中,如何同时考虑上下文信息已有许多研究成果,此处不进行展开讨论。
语言模型的目标是评估词序列的概率,模型的训练采用最大似然评估准则,最大化模型在训练数据上的似然概率。语言模型训练的目标函数就采用似然函数,即:
其中,为语言模型的参数,为正则项。
语言模型的性能通常采用困惑度(Perplexity, PPL)来衡量,困惑度的定义如下:
困惑度,即模型编码数据所需要的平均字节数的指数,用于衡量模型预测样本的好坏程度。语言模型的困惑度越小,意味着语言模型的分布更接近测试数据的分布。
3.前向神经网络语言模型
神经网络语言模型引起广泛关注是在Bengio et al. (2003)提出前向神经网络(Feed-forward Neural Network, FNN)语言模型之后,但对神经网络语言建模的研究可以追溯到更早之前,如Schmidhuber (1996),Xu and Rudnicky (2000)等。但由于神经网络训练困难,基于当时的硬件条件模型训练的时间较长。直到Bengio et al. (2003)发布其研究成果,神经网络语言模型才引起学术界以及工业界的兴趣。随后Mikolov et al. (2010)将循环神经网络(Recurrent Neural Network, RNN)引入语言建模,使得语言模型的性能得到较大的提升。接着循环神经网络的改进版本,长短期记忆(Long Short Term Memory, LSTM)循环神经网络以及门限循环单元(Gated Recurrent Unit, GRU)神经网络,相继地被用于进一步改善语言建模的性能。另外,卷积神经网络也出乎意料地在语言建模中取得了成功,性能也能够与循环神经网络相比肩。
前向神经网络,又被称为全连接(Fully Connected Neural Network)神经网络,是最早被引入到语言建模中的神经网络结构。前向神经网络一般可表示为:
其中,,为权重矩阵,为输入层的节点数, 为隐层的节点数,输出层的节点数,在语言模型中等于词典的大小,为直接连接输入层与输出层的权重矩阵,和分别为隐层和输出层的偏置项,为输出向量,为激活函数。
Bengio et al. (2003)提出的前向神经网络语言模型的结构如图1所示。由于前向神经网络不具备学习时序依赖关系的能力,如果预测当前词时,考虑所有上文会比较困难。一方面,当上文较长时,网络的输入节点数会较大;另一方面,当前词的上文是变长的,不易处理。因此,前向神经网络语言模型采用了与N-gram模型相似的方法处理上文信息,只考虑前个词,则当前词的条件概率近似地表示为:
此处,又引入了另一个假设,即当前词只依赖于前个词。
建立语言模型的首要工作是从训练数据集中构建词典,并为每个词赋予唯一的索引,然后构建特征矩阵,其中为词典的大小,为特征向量的大小。特征矩阵的每行为对应词的特性向量,即词向量,通过词的索引进行查找。当对当前词进行预测时,取其前个词的词向量,并按照序列顺序进行拼接,形成模型的输入向量,其中。模型的输出为当前上文信息下,词典中各个词的非归一化的条件概率,需要采用Softmax函数对输出概率进行归一化,即:
Bengio et al. (2003)在其2001年版本的论文中提出了两种模型结构。除了上述被称为直连结构(Direct Architecture)的模型,还有一种称为循环结构(Cycling Architecture)的模型。在循环模型中,针对词典中的每个词都训练了一个预测模型,每个模型只输出对应词的未归一化的条件概率,然后采用Softmax对所有输出进行归一化处理。两种模型在布朗语料库上的测试结果如下表所示:
实验结果显示,直连结构的模型性能与循环结构模型的PPL几乎相当。目前循环结构的模型已不再被关注,因为直连结构的模型的PPL略高,并且结构更紧凑。另外,实验数据显示前向神经网络语言模型的PPL远低于3-gram语言模型,体现了神经网络语言模型的优越性。在Bengio et al. (2003)的论文中,输入层与输出层之间的直接连接层以及隐层的偏置项对模型性能的影响得到了分析。引入直接连接层,能够帮助模型快速学习数据中的线性关系,但会使模型的泛化能力降低,从而使得模型的PPL值略有上升,但训练速度加快。而添加或者去除隐层的偏置项并不会对模型性能造成明显的影响。
以下为NNML模型训练的代码:
import tensorflow as tf
import numpy as np
tf.reset_default_graph()
sentences = [ "i like dog", "i love coffee", "i hate milk"]
word_list = " ".join(sentences).split()
word_list = list(set(word_list))
word_dict = {w: i for i, w in enumerate(word_list)}
number_dict = {i: w for i, w in enumerate(word_list)}
n_class = len(word_dict) # number of Vocabulary
# NNLM Parameter
n_step = 2 # number of steps ['i like', 'i love', 'i hate']
n_hidden = 2 # number of hidden units
def make_batch(sentences):
input_batch = []
target_batch = []
for sen in sentences:
word = sen.split()
input = [word_dict[n] for n in word[:-1]]
target = word_dict[word[-1]]
input_batch.append(np.eye(n_class)[input])
target_batch.append(np.eye(n_class)[target])
return input_batch, target_batch
# Model
X = tf.placeholder(tf.float32, [None, n_step, n_class]) # [batch_size, number of steps, number of Vocabulary]
Y = tf.placeholder(tf.float32, [None, n_class])
input = tf.reshape(X, shape=[-1, n_step * n_class]) # [batch_size, n_step * n_class]
H = tf.Variable(tf.random_normal([n_step * n_class, n_hidden]))
d = tf.Variable(tf.random_normal([n_hidden]))
U = tf.Variable(tf.random_normal([n_hidden, n_class]))
b = tf.Variable(tf.random_normal([n_class]))
tanh = tf.nn.tanh(d + tf.matmul(input, H)) # [batch_size, n_hidden]
model = tf.matmul(tanh, U) + b # [batch_size, n_class]
cost = tf.reduce_mean(tf.nn.softmax_cross_entropy_with_logits_v2(logits=model, labels=Y))
optimizer = tf.train.AdamOptimizer(0.001).minimize(cost)
prediction =tf.argmax(model, 1)
# Training
init = tf.global_variables_initializer()
sess = tf.Session()
sess.run(init)
input_batch, target_batch = make_batch(sentences)
for epoch in range(5000):
_, loss = sess.run([optimizer, cost], feed_dict={X: input_batch, Y: target_batch})
if (epoch + 1)%1000 == 0:
print('Epoch:', '%04d' % (epoch + 1), 'cost =', '{:.6f}'.format(loss))
# Predict
predict = sess.run([prediction], feed_dict={X: input_batch})
# Test
input = [sen.split()[:2] for sen in sentences]
print([sen.split()[:2] for sen in sentences], '->', [number_dict[n] for n in predict[0]])