神经网络语言建模系列之二:细枝末节

熟悉神经网络语言模型的主体结构并不足以建立性能较好的模型。建立成功的神经网络语言需要注重许多细节处理,如词典的构建、模型初始化、超参的选择等等,均涉及很多对模型性能有较大影响的细节。

       近十几年来,神经网络语言建模(Neural Network Language Modeling, NNLM)一直是人工智能(Artificial Intelligence, AI)领域中的研究热点之一。除了不断增加的学术论文,也有大量的博客,贴文对神经网络语言建模的相关技术进行介绍,包括本系列中的《神经网络语言建模系列之一:基础模型》。但是很少有文章对神经网络语言模型的实现细节进行系统地总结,本文以长短期记忆(Long Short Term Memory, LSTM)循环神经网络(Recurrent Neural Network, RNN)语言模型为例,基于Python语言,采用Tensorflow框架,系统地介绍神经网络语言模型的具体实现过程。

1. 预处理

       语言模型的训练需要大量的文本数据,幸运的是训练语言模型的数据不需要人工标注,属于无监督训练。文本数据可以采用公共数据集,也可以自行收集文本数据。在公共的数据集上训练和测试语言模型,由于数据已被处理完成,即为熟语料,就可以省去许多预处理工作。在此针对收集到的原始文本数据,也被称为生语料,介绍用于训练语言模型的文本数据的预处理。

       对于不同语言的文本,处理的方式会有所区别。笔者熟悉的主要是英文和中文的文本预处理,其他语种文本的预处理经验比较浅。因此,此处以英文和中文的生语料为例,简要介绍文本数据的预处理,基本步骤如下:

  • 文本进行清洗是必不可少的,尤其当数据来源于网络。针对不同来源的数据,清洗的方式会有所不同,但最终的目的都是为了去除文本以外的数据,如网址链接、表情符号、特殊字符、HTML标签等等;
  • 字符转换,在处理中文文本时比较常见,将全角字符转换为半角字符,将繁体中文转换为简体,或者相反;
  • 大小转换,为了降低词典的大小,有时会将文本中的字母统一转成小写或者大写。进行大小写转换后,会丢失部分文本特征,降低模型性能,尤其对于英文这类由字母组成的语言,当然也可以选择不进行转化;
  • 句子分割,即根据文本中的标点符号,将大段的文本切分为句子序列。对于英文这类语言的文本进行切分时,需要进行额外的工作,包括将标点与单词分开,将部分缩写分开。其中缩写的分割,如it's分割为it 's,这样可以减少单词量,当然也可以选择不进行分割。英文的句子分割可采用开源工具NLTK或者斯坦福大学的自然语言处理工具包CoreNLP。对于中文,目前还没有接触到提供句子切分功能的开源的工具包;
  • 如果目标是中文这类没有词边界的语言文本,就需要进行分词。当然,目前也有基于字符级别的语言模型,可以不用进行分词。但是词作为语言中的重要模式,将分词信息引入语言模型,能够帮助模型学习到更多的语言模式。中文分词的开源工具包比较多,比如JiebaHanLP等,分词的精度也会对语言模型的性能产生影响。

       经过预处理,文本数据的格式为每行一句文本,分词之间以空格分隔。完成文本数据的预处理工作后,便可以进行数据集划分。在数据充足的情况下,可分为三个部分:训练集、验证集和测试集。训练集一般占所有数据的80%,用于语言模型的训练;验证集约占总数据量的10%,用于模型超参的调整;测试集由剩余的数据组成,训练好的模型将在该数据集上进行性能测试。

2. 构建词典

       从训练集中构建词典是建立语言模型的首要步骤,而词典的建立就是将训练集中的分词(Token)加入到字典中并分配唯一的索引。此处提到的分词(Token)包括词(Word)、标点以及文本中与词级别相当的字符或者字符串。有时当数据量较大时,分词的数量会很巨大,导致模型的计算量较大,就需要对词典的大小进行限制,将词频较低的分词丢弃掉。从训练集中构建词典的具体实现代码如下所示:

def collect_token(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(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

       以上便是从训练集中构建词典的步骤及相关细节,基本流程就是从训练集中收集分词,并设定特殊标识符,然后为分词分配索引。

3. 生成批数据

       为了利用并行计算进行加速,训练或者测试语言模型时,数据输入采用批处理(Batch)的方式。因此,在将文本数据输入模型之前,不仅需要根据字典将分词序列转换为对应的索引序列,还需要根据指定的批处理(Batch)的大小生成批处理数据。每句文本序列的长度不同,而循环神经网络语言模型的输入序列长度需固定。目前有两种处理方式,一种是对长度不足的文本序列进行填充,对过长的序列进行截断。另一种处理方法是将所有的句子序列看做一个很长的序列,然后分割为长度相同的短序列。

       此处通过实例来说明这两种批数据生成方式,假设模型输入序列的长度设定为15,批处理的大小为2,需要对下面两句文本序列进行处理:

例:
当时 我 很 伤心 , 认为 这 辈子 算 完了 。
我 的 孩子 天资 还 不错 , 但 学习 成绩 一般 , 小动作 较多 , 老师 不是 特别 喜欢 , 也 不是 特别 反感 。

根据第一种策略的处理方式,结果如下:

<s> 当时 我 很 伤心 , 认为 这 辈子 算 完了 。 </s> <pad> <pad>
<s> 我 的 孩子 天资 还 不错 , 但 学习 成绩 一般 , 小动作 较多 ,

采用第二种处理策略时,上例中句子序列的处理结果为:

<s> 当时 我 很 伤心 , 认为 这 辈子 算 完了 。 </s> <s> 我
的 孩子 天资 还 不错 , 但 学习 成绩 一般 , 小动作 较多 , 老师

采用第二种方法时,需要注意在生成批数据时,相邻批数据中相同位置的序列应该是连续的,即第《神经网络语言建模系列之二:细枝末节》批数据的第《神经网络语言建模系列之二:细枝末节》条序列应该与第《神经网络语言建模系列之二:细枝末节》批数据的第《神经网络语言建模系列之二:细枝末节》条数据是连续的文本序列。

       本文采用第二种策略生成批数据,处理完句子序列的长度后,根据词典将分词转换为对应的索引,最终输入模型的就是索引序列。除了模型的输入序列,还需要模型输出的目标序列,目标序列于输入序列类似。目标分词为输入分词的下一个词,因此目标序列即为输入序列向前移一位。具体实现代码如下:

def get_batches(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()

4. 语言模型

       神经网络语言模型的神经网络结构采用长短期记忆循环神经网络,模型主体部分利用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(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初始化方法,采用均匀分布,其具体形式如下:

《神经网络语言建模系列之二:细枝末节》

其中,《神经网络语言建模系列之二:细枝末节》《神经网络语言建模系列之二:细枝末节》分别为神经网络第《神经网络语言建模系列之二:细枝末节》《神经网络语言建模系列之二:细枝末节》层的节点数,可以理解为权重矩阵或者向量的输入尺寸和输出尺寸。初始化策略也被认为是重要的泛化技术,因为初始化参数决定了模型所处的空间位置。神经网络的优化最终得到的是局部最优点,模型初始化的起点决定了最终收敛的局部最优点的位置。

5. 模型训练

       语言模型的训练目标是最大化似然函数,损失函数则采用交叉熵。在TensorFlow中实现的代码如下:

# calculate the softmax loss of model
loss = tf.contrib.legacy_seq2seq.sequence_loss_by_example(logits = [logits, ], 
    targets = [tf.reshape(target_hoder, [-1])], weights = [tf.ones([batch_size * seq_length])])
cost = tf.reduce_sum(loss) / batch_size

       语言模型训练所采用的优化算法是随机梯度下降算法(Stochastic Gradient Descent, SGD),也神经网络模型训练的常用优化算法。为了提升神经网络的优化算法性能,很多研究者对随机梯度下降算法进行改进,衍生出多种优化算法,如AdagradRMSpropAdadeltaAdam等,对于不同优化算法的对比可以参考论文S. Ruder (2017)。本文仍采用普通的随机梯度下降算法,如需要改用其他优化算法,可参考TensorFlow中提供的对应接口,对优化器进行修改。

def train_op(batch_cost, grad_cutoff):
    """Training operations for training the whole model.
    :Param batchcost  : value of cost function on current training batch.
    :Param grad_cutoff: vaule for gradients cutoff.
    """
    with tf.variable_scope('Train-OP') as scope:
        learn_rate = tf.Variable(0.0, trainable = False)
        # optimize the model using SGD method
        optimizer = tf.train.GradientDescentOptimizer(learn_rate)
        train_vars = tf.trainable_variables()
        grads, _ = tf.clip_by_global_norm(tf.gradients(batch_cost, train_vars), grad_cutoff)
        global_step = tf.Variable(0, name = 'global_step')
        train_op = optimizer.apply_gradients(grads_and_vars = zip(grads, train_vars),
            global_step = global_step, name = 'apply_gradients')
    return learn_rate, train_op

       语言模型的训练和通常机器学习模型的训练类似,在训练数据上进行多次迭代训练,调整参数使得模型最终收敛。语言模型训练过程中,需要在验证集上进行测试,以评估模型的训练效果,对学习率进行调整,同时为了防止过拟合,需要引入提前终止训练(Early Stop)的策略。提前终止训练是涉及神经网络建模任务中常用的泛化技巧之一,神经网络语言模型的训练也不例外。提前终止训练的策略多种多样,在不同的任务中,也会有所区别。神经网络语言模型训练中,提前终止训练的策略可分为如下两种:

  • 随着训练的进行,不断减小学习率。减小学习率可以从训练开始就进行,也可以在进行一定迭代次数之后进行。学习率的减少方式一般采用指数衰减,如基数取0.97,指数随训练步数线性或呈指数变化,当学习率减少至一定数值时,停止训练;
  • 另一种调整学习率的方法依赖于验证集,如果当前模型在验证集上的表现比上一次迭代步差,则将模型参数恢复为上一次迭代步的状态,同时将学习率减半,重新训练。如果学习率减半的次数超过特定值,则停止训练,一般学习率减半次数设为一次即可。

6. 模型预测

       语言模型的预测,即利用训练好的语言模型生成文本或者评估已有文本的概率。对于生成文本,语言模型的第一个输入为句子的起始标识符<s>,而后的每次输入是上一步产生的分词,从而连续不断地生成分词序列。当遇到句子的结束符</s>时,便生成了完整的文本。文本概率的评估,就是通过语言模型计算给定上文时,产生当前词的条件概率,从而得到整个文本序列的概率。

       本文建立神经网络语言模型的完整代码已发布在Github,感兴趣的读者可前往下载

作者:施孙甲由 (原创)

    原文作者:施孙甲由
    原文地址: https://www.jianshu.com/p/9b51690a98b7
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞