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


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


1. 前言

       近十几年来,神经网络语言建模(Neural Network Language Modeling, NNLM)一直是人工智能(Artificial Intelligence, AI)领域中的研究热点之一。除了不断增加的学术论文,也有大量的博客,贴文对神经网络语言建模的相关技术进行介绍,包括本系列中的《神经网络语言建模系列之一:基础模型》。但是很少有文章对神经网络语言模型的实现细节进行系统地总结,本文以长短期记忆(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(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或者,来表示所有的未登录词,凡是遇到未登录词都用该标识符替换。未登录词的存在使得语言模型的性能下降比较显著,但目前还没有很好的解决方案。未登录词也一致自然语言相关的人工智能任务的难点之一,目前采用字符级或类似的模型可以改善未登录词的影响,这部分内容在本系列的后续内容中会介绍。处理未登录词的标识符,还需加入句子边界的标识符,如,或者,将文本数据输入模型时,需要给每句文本加上边界标识符。句子边界也是很重要的特征,能够帮助模型识别语言模式。有时还需要对句子进行填充,需要设置填充标识符,如,一般将其索引设为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

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

4. 生成批数据

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

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

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

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

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

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

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

采用第二种方法时,需要注意在生成批数据时,相邻批数据中相同位置的序列应该是连续的,即第批数据的第条序列应该与第批数据的第条数据是连续的文本序列。

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

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

5. 语言模型

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

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

6. 模型训练

       语言模型的训练目标是最大化似然函数,损失函数则采用交叉熵。在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),也神经网络模型训练的常用优化算法。为了提升神经网络的优化算法性能,很多研究者对随机梯度下降算法进行改进,衍生出多种优化算法,如Adagrad、RMSprop、Adadelta、Adam等,对于不同优化算法的对比可以参考论文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,指数随训练步数线性或呈指数变化,当学习率减少至一定数值时,停止训练;
  • 另一种调整学习率的方法依赖于验证集,如果当前模型在验证集上的表现比上一次迭代步差,则将模型参数恢复为上一次迭代步的状态,同时将学习率减半,重新训练。如果学习率减半的次数超过特定值,则停止训练,一般学习率减半次数设为一次即可。

7. 模型预测

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

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

作者:施孙甲由 (原创)

你可能感兴趣的:(神经网络语言建模系列之二:细枝末节)