内容简介:本文由**罗周杨原文链接:分词作为NLP的基础工作之一,对模型的效果有直接的影响。一个效果好的分词,可以让模型的性能更好。
本文由**罗周杨 stupidme.me.lzy@gmail.com **原创,转载请注明原作者和出处。
原文链接: luozhouyang.github.io/deepseg
分词作为NLP的基础工作之一,对模型的效果有直接的影响。一个效果好的分词,可以让模型的性能更好。
在尝试使用神经网络来分词之前,我使用过jieba分词,以下是一些感受:
- 分词速度快
- 词典直接影响分词效果,对于特定领域的文本,词典不足,导致分词效果不尽人意
- 对于含有较多错别字的文本,分词效果很差
后面两点是其主要的缺点。根据实际效果评估,我发现使用神经网络分词,这两个点都有不错的提升。
本文将带你使用tensorflow实现一个基于BiLSTM+CRF的神经网络中文分词模型。
完整代码已经开源: luozhouyang/deepseg 。
怎么做分词
分词的想法和 NER 十分接近,区别在于,NER对各种词打上对应的实体标签,而分词对各个字打上位置标签。
目前,项目一共只有以下5中标签:
- B,处于一个词语的开始
- M,处于一个词语的中间
- E,处于一个词语的末尾
- S,单个字
- O,未知
举个更加详细的例子,假设我们有一个文本字符串:
'上','海','市','浦','东','新','区','张','东','路','1387','号' 复制代码
它对应的分词结果应该是:
上海市 浦东新区 张东路 1387 号 复制代码
所以,它的标签应该是:
'B','M','E','B','M','M','E','B','M','E','S','S' 复制代码
所以,对于我们的分词模型来说,最重要的任务就是,对于输入序列的每一个token,打上一个标签,然后我们处理得到的标签数据,就可以得到分词效果。
用神经网络给序列打标签,方法肯定还有很多。目前项目使用的是 双向LSTM网络后接CRF 这样一个网络。这部分会在后面详细说明。
以上就是我们分词的做法概要,如你所见,网络其实很简单。
Estimator
项目使用tensorflow的 estimator API 完成,因为estimator是一个高级封装,我们只需要专注于核心的工作即可,并且它可以轻松实现分布式训练。如果你还没有尝试过,建议你试一试。
estimator的官方文档可以很好地帮助你入门:estimator
使用estimator构建网络,核心任务是:
- 构建一个高效的数据输入管道
- 构建你的神经网络模型
对于数据输入管道,本项目使用tensorflow的Dataset API,这也是官方推荐的方式。
具体来说,给estimator喂数据,需要实现一个 input_fn
,这个函数不带参数,并且返回 (features, labels)
元组。当然,对于 PREDICT
模式, labels
为 None
。
要构建神经网络给estimator,需要实现一个 model_fn(features, labels, mode, params, config)
,返回一个 tf.estimator.EstimatorSepc
对象。
更多的内容,请访问官方文档。
构建input_fn
首先,我们的数据输入需要分三种模式 TRAIN
、 EVAL
、 PREDICT
讨论。
-
TRAIN
模式即模型的训练,这个时候使用的是数据集是 训练集 ,需要返回(features,labels)
元组 -
EVAL
模式即模型的评估,这个时候使用的是数据集的 验证集 ,需要返回(features,labels)
元组 -
PREDICT
模式即模型的预测,这个时候使用的数据集是 测试集 ,需要返回(features,None)
元组
以上的 features
和 labels
可以是任意对象,比如 dict
,或者是自己定义的 python class
。实际上,比较推荐使用dict的方式,因为这种方式比较灵活,并且在你需要导出模型到serving的时候,特别有用。这一点会在后面进一步说明。
那么,接下来可以为上面三种模式分别实现我们的 inpuf_fn
。
对于最常见的 TRAIN
模式:
def build_train_dataset(params): """Build data for input_fn in training mode. Args: params: A dict Returns: A tuple of (features,labels). """ src_file = params['train_src_file'] tag_file = params['train_tag_file'] if not os.path.exists(src_file) or not os.path.exists(tag_file): raise ValueError("train_src_file and train_tag_file must be provided") src_dataset = tf.data.TextLineDataset(src_file) tag_dataset = tf.data.TextLineDataset(tag_file) dataset = _build_dataset(src_dataset, tag_dataset, params) iterator = dataset.make_one_shot_iterator() (src, src_len), tag = iterator.get_next() features = { "inputs": src, "inputs_length": src_len } return features, tag 复制代码
使用tensorflow的Dataset API很简单就可以构建出数据输入管道。首先,根据参数获取训练集文件,分别构建出一个 tf.data.TextLineDataset
对象,然后构建出数据集。根据数据集的迭代器,获取每一批输入的 (features,labels)
元组。每一次训练的迭代,这个元组都会送到 model_fn
的前两个参数 (features,labels,...)
中。
根据代码可以看到,我们这里的 features
是一个 dict
,每一个键都存放着一个 Tensor
:
-
inputs
:文本数据构建出来的字符张量,形状是(None,None)
-
inputs_length
:文本分词后的长度张量,形状是(None)
而我们的 labels
就是一个张量,具体是什么呢?需要看一下 _build_dataset()
函数做了什么:
def _build_dataset(src_dataset, tag_dataset, params): """Build dataset for training and evaluation mode. Args: src_dataset: A `tf.data.Dataset` object tag_dataset: A `tf.data.Dataset` object params: A dict, storing hyper params Returns: A `tf.data.Dataset` object, producing features and labels. """ dataset = tf.data.Dataset.zip((src_dataset, tag_dataset)) if params['skip_count'] > 0: dataset = dataset.skip(params['skip_count']) if params['shuffle']: dataset = dataset.shuffle( buffer_size=params['buff_size'], seed=params['random_seed'], reshuffle_each_iteration=params['reshuffle_each_iteration']) if params['repeat']: dataset = dataset.repeat(params['repeat']).prefetch(params['buff_size']) dataset = dataset.map( lambda src, tag: ( tf.string_split([src], delimiter=",").values, tf.string_split([tag], delimiter=",").values), num_parallel_calls=params['num_parallel_call'] ).prefetch(params['buff_size']) dataset = dataset.filter( lambda src, tag: tf.logical_and(tf.size(src) > 0, tf.size(tag) > 0)) dataset = dataset.filter( lambda src, tag: tf.equal(tf.size(src), tf.size(tag))) if params['max_src_len']: dataset = dataset.map( lambda src, tag: (src[:params['max_src_len']], tag[:params['max_src_len']]), num_parallel_calls=params['num_parallel_call'] ).prefetch(params['buff_size']) dataset = dataset.map( lambda src, tag: (src, tf.size(src), tag), num_parallel_calls=params['num_parallel_call'] ).prefetch(params['buff_size']) dataset = dataset.padded_batch( batch_size=params.get('batch_size', 32), padded_shapes=( tf.TensorShape([None]), tf.TensorShape([]), tf.TensorShape([None])), padding_values=( tf.constant(params['pad'], dtype=tf.string), 0, tf.constant(params['oov_tag'], dtype=tf.string))) dataset = dataset.map( lambda src, src_len, tag: ((src, src_len), tag), num_parallel_calls=params['num_parallel_call'] ).prefetch(params['buff_size']) return dataset 复制代码
虽然代码都很直白,在此还是总结一下以上数据处理的步骤:
,
上述过程,最重要的就是 padded_batch
这一步了。经过之前的处理,现在我们的数据包含以下三项信息:
src src_len tag
把数据喂入网络之前,我们需要对这些数据进行对齐操作。什么是 对齐
呢?顾名思义:在这一批数据中,找出最长序列的长度,以此为标准,如果序列比这个长度更短,则文本序列在末尾追加特殊标记(例如 <PAD>
),标签序列在末尾追加标签的特殊标记(例如 O
)。因为大家的长度都是不定的,所以要补齐多少个特殊标记也是不定的,所以 padded_shapes
里面设置成 tf.TensorShape([None])
即可,函数会自动计算长度的差值,然后进行补齐。
而 src_len
一项是不需要对齐的,因为所有的 src_len
都是一个scalar。
至此, TRAIN
模式下的数据输入准备好了。
EVAL
模式下的数据准备和 TRAIN
模式一模一样,唯一的差别在于使用的数据集不一样, TRAIN
模式使用的是 训练集
,但是 EVAL
使用的是 验证集
,所以只需要改一下文件即可。以下是 EVAL
模式的数据准备过程:
def build_eval_dataset(params): """Build data for input_fn in evaluation mode. Args: params: A dict. Returns: A tuple of (features, labels). """ src_file = params['eval_src_file'] tag_file = params['eval_tag_file'] if not os.path.exists(src_file) or not os.path.exists(tag_file): raise ValueError("eval_src_file and eval_tag_file must be provided") src_dataset = tf.data.TextLineDataset(src_file) tag_dataset = tf.data.TextLineDataset(tag_file) dataset = _build_dataset(src_dataset, tag_dataset, params) iterator = dataset.make_one_shot_iterator() (src, src_len), tag = iterator.get_next() features = { "inputs": src, "inputs_length": src_len } return features, tag 复制代码
至于 PREDICT
模式,稍微有点特殊,因为要对序列进行预测,我们是没有标签数据的。所以,我们的数据输入只有 features
这一项, labels
这一项只能是 None
。该模式下的数据准备如下:
def build_predict_dataset(params): """Build data for input_fn in predict mode. Args: params: A dict. Returns: A tuple of (features, labels), where labels are None. """ src_file = params['predict_src_file'] if not os.path.exists(src_file): raise FileNotFoundError("File not found: %s" % src_file) dataset = tf.data.TextLineDataset(src_file) if params['skip_count'] > 0: dataset = dataset.skip(params['skip_count']) dataset = dataset.map( lambda src: tf.string_split([src], delimiter=",").values, num_parallel_calls=params['num_parallel_call'] ).prefetch(params['buff_size']) dataset = dataset.map( lambda src: (src, tf.size(src)), num_parallel_calls=params['num_parallel_call'] ).prefetch(params['buff_size']) dataset = dataset.padded_batch( params.get('batch_size', 32), padded_shapes=( tf.TensorShape([None]), tf.TensorShape([])), padding_values=( tf.constant(params['pad'], dtype=tf.string), 0)) iterator = dataset.make_one_shot_iterator() (src, src_len) = iterator.get_next() features = { "inputs": src, "inputs_length": src_len } return features, None 复制代码
整体的思路差不多,值得注意的是, PREDICT
模式的数据不能够打乱数据。同样的进行对齐和分批之后,就可以通过迭代器获取到 features
数据,然后返回 (features,labels)
元组,其中 labels=None
。
至此,我们的input_fn就实现了!
值得注意的是,estimator需要的 input_fn
是一个没有参数的函数,我们这里的 input_fn
是有参数的,那怎么办呢?用 funtiontools
转化一下即可,更详细的内容请查看源码。
还有一个很重要的一点,
很多项目都会在这个 input_fn
里面讲字符序列转化成数字序列
,但是我们没有这么做,而是 依然保持是字符
,为什么:
因为这样就可以把这个转化过程放到网络的构建过程中,这样的话,导出模型所需要的 serving_input_receiver_fn
的构建就会很简单!
这一点详细地说明一下。如果我们把字符数字化放到网络里面去,那么我们导出模型所需要的 serving_input_receiver_fn
就可以这样写:
def server_input_receiver_fn() receiver_tensors{ "inputs": tf.placeholder(dtype=tf.string, shape=(None,None)), "inputs_length": tf.placeholder(dtype=tf.int32, shape=(None)) } features = receiver_tensors.copy() return tf.estimator.export.ServingInputReceiver( features=features, receiver_tensors=receiver_tensors) 复制代码
可以看到, 我们在这里也不需要把接收到的字符张量数字化 !
相反,如果我们在处理数据集的时候进行了字符张量的数字化,那就意味着构建网络的部分 没有数字化这个步骤 !所有 喂给网络的数据都是已经数字化的 !
这也就意味着,
你的 serving_input_receiver_fn
也需要对字符张量数字化
!这样就会使得代码比较复杂!
说了这么多,其实就一点:
-
在
input_fn
里面不要把字符张量转化成数字张量!把这个过程放到网络里面去!
构建神经网络
接下来是最重要的步骤,即构建出我们的神经网络,也就是实现 model_fn(features,labels,mode,params,config)
这个函数。
首先,我们的参数中的 features
和 labels
都是字符张量,老规矩,我们需要进行 word embedding
。代码很简单:
words = features['inputs'] nwords = features['inputs_length'] # a UNK token should placed in the first row in vocab file words_str2idx = lookup_ops.index_table_from_file( params['src_vocab'], default_value=0) words_ids = words_str2idx.lookup(words) training = mode == tf.estimator.ModeKeys.TRAIN # embedding with tf.variable_scope("embedding", reuse=tf.AUTO_REUSE): variable = tf.get_variable( "words_embedding", shape=(params['vocab_size'], params['embedding_size']), dtype=tf.float32) embedding = tf.nn.embedding_lookup(variable, words_ids) embedding = tf.layers.dropout( embedding, rate=params['dropout'], training=training) 复制代码
接下来,把词嵌入之后的数据,输入到一个 双向LSTM 网络:
# BiLSTM with tf.variable_scope("bilstm", reuse=tf.AUTO_REUSE): # transpose embedding for time major mode inputs = tf.transpose(embedding, perm=[1, 0, 2]) lstm_fw = tf.nn.rnn_cell.LSTMCell(params['lstm_size']) lstm_bw = tf.nn.rnn_cell.LSTMCell(params['lstm_size']) (output_fw, output_bw), _ = tf.nn.bidirectional_dynamic_rnn( cell_fw=lstm_fw, cell_bw=lstm_bw, inputs=inputs, sequence_length=nwords, dtype=tf.float32, swap_memory=True, time_major=True) output = tf.concat([output_fw, output_bw], axis=-1) output = tf.transpose(output, perm=[1, 0, 2]) output = tf.layers.dropout( output, rate=params['dropout'], training=training) 复制代码
BiLSTM出来的结果,接入一个CRF层:
logits = tf.layers.dense(output, params['num_tags']) with tf.variable_scope("crf", reuse=tf.AUTO_REUSE): variable = tf.get_variable( "transition", shape=[params['num_tags'], params['num_tags']], dtype=tf.float32) predict_ids, _ = tf.contrib.crf.crf_decode(logits, variable, nwords) return logits, predict_ids 复制代码
返回的 logits
用来计算loss,更新权重。
损失计算如下:
def compute_loss(self, logits, labels, nwords, params): """Compute loss. Args: logits: A tensor, output of dense layer labels: A tensor, the ground truth label nwords: A tensor, length of inputs params: A dict, storing hyper params Returns: A loss tensor, negative log likelihood loss. """ tags_str2idx = lookup_ops.index_table_from_file( params['tag_vocab'], default_value=0) actual_ids = tags_str2idx.lookup(labels) # get transition matrix created before with tf.variable_scope("crf", reuse=True): trans_val = tf.get_variable( "transition", shape=[params['num_tags'], params['num_tags']], dtype=tf.float32) log_likelihood, _ = tf.contrib.crf.crf_log_likelihood( inputs=logits, tag_indices=actual_ids, sequence_lengths=nwords, transition_params=trans_val) loss = tf.reduce_mean(-log_likelihood) return loss 复制代码
定义好了损失,我们就可以选择一个 优化器 来训练我们的网络啦。代码如下:
def build_train_op(self, loss, params): global_step = tf.train.get_or_create_global_step() if params['optimizer'].lower() == 'adam': opt = tf.train.AdamOptimizer() return opt.minimize(loss, global_step=global_step) if params['optimizer'].lower() == 'momentum': opt = tf.train.MomentumOptimizer( learning_rate=params.get('learning_rate', 1.0), momentum=params['momentum']) return opt.minimize(loss, global_step=global_step) if params['optimizer'].lower() == 'adadelta': opt = tf.train.AdadeltaOptimizer() return opt.minimize(loss, global_step=global_step) if params['optimizer'].lower() == 'adagrad': opt = tf.train.AdagradOptimizer( learning_rate=params.get('learning_rate', 1.0)) return opt.minimize(loss, global_step=global_step) # TODO(luozhouyang) decay lr sgd = tf.train.GradientDescentOptimizer( learning_rate=params.get('learning_rate', 1.0)) return sgd.minimize(loss, global_step=global_step) 复制代码
当然,你还可以添加一些 hooks
,比如在 EVAL
模式下,添加一些统计:
def build_eval_metrics(self, predict_ids, labels, nwords, params): tags_str2idx = lookup_ops.index_table_from_file( params['tag_vocab'], default_value=0) actual_ids = tags_str2idx.lookup(labels) weights = tf.sequence_mask(nwords) metrics = { "accuracy": tf.metrics.accuracy(actual_ids, predict_ids, weights) } return metrics 复制代码
至此,我们的网络构建完成。完整的 model_fn
如下:
def model_fn(self, features, labels, mode, params, config): words = features['inputs'] nwords = features['inputs_length'] # a UNK token should placed in the first row in vocab file words_str2idx = lookup_ops.index_table_from_file( params['src_vocab'], default_value=0) words_ids = words_str2idx.lookup(words) training = mode == tf.estimator.ModeKeys.TRAIN # embedding with tf.variable_scope("embedding", reuse=tf.AUTO_REUSE): variable = tf.get_variable( "words_embedding", shape=(params['vocab_size'], params['embedding_size']), dtype=tf.float32) embedding = tf.nn.embedding_lookup(variable, words_ids) embedding = tf.layers.dropout( embedding, rate=params['dropout'], training=training) # BiLSTM with tf.variable_scope("bilstm", reuse=tf.AUTO_REUSE): # transpose embedding for time major mode inputs = tf.transpose(embedding, perm=[1, 0, 2]) lstm_fw = tf.nn.rnn_cell.LSTMCell(params['lstm_size']) lstm_bw = tf.nn.rnn_cell.LSTMCell(params['lstm_size']) (output_fw, output_bw), _ = tf.nn.bidirectional_dynamic_rnn( cell_fw=lstm_fw, cell_bw=lstm_bw, inputs=inputs, sequence_length=nwords, dtype=tf.float32, swap_memory=True, time_major=True) output = tf.concat([output_fw, output_bw], axis=-1) output = tf.transpose(output, perm=[1, 0, 2]) output = tf.layers.dropout( output, rate=params['dropout'], training=training) logits, predict_ids = self.decode(output, nwords, params) # TODO(luozhouyang) Add hooks if mode == tf.estimator.ModeKeys.PREDICT: predictions = self.build_predictions(predict_ids, params) prediction_hooks = [] export_outputs = { 'export_outputs': tf.estimator.export.PredictOutput(predictions) } return tf.estimator.EstimatorSpec( mode=mode, predictions=predictions, export_outputs=export_outputs, prediction_hooks=prediction_hooks) loss = self.compute_loss(logits, labels, nwords, params) if mode == tf.estimator.ModeKeys.EVAL: metrics = self.build_eval_metrics( predict_ids, labels, nwords, params) eval_hooks = [] return tf.estimator.EstimatorSpec( mode=mode, loss=loss, eval_metric_ops=metrics, evaluation_hooks=eval_hooks) if mode == tf.estimator.ModeKeys.TRAIN: train_op = self.build_train_op(loss, params) train_hooks = [] return tf.estimator.EstimatorSpec( mode=mode, loss=loss, train_op=train_op, training_hooks=train_hooks) 复制代码
还是推荐去看源码。
模型的训练、估算、预测和导出
接下来就是训练、估算、预测或者导出模型了。这个过程也很简单,因为使用的是estimator API,所以这些步骤都很简单。
项目中创建了一个 Runner
类来做这些事情。具体代码请到项目页面。
如果你要训练模型:
python -m deepseg.runner \ --params_file=deepseg/example_params.json \ --mode=train 复制代码
或者:
python -m deepseg.runner \ --params_file=deepseg/example_params.json \ --mode=train_and_eval 复制代码
如果你要使用训练的模型进行预测:
python -m deepseg.runner \ --params_file=deepseg/example_params.json \ --mode=predict 复制代码
如果你想导出训练好的模型,部署到tf serving上面:
python -m deepseg.runner \ --params_file=deepseg/example_params.json \ --mode=export 复制代码
以上步骤,所有的参数都在 example_params.json
文件中,根据需要进行修改即可。
另外,本身的代码也相对简单,如果不满足你的需求,可以直接修改源代码。
根据预测结果得到分词
还有一点点小的提示,模型预测返回的结果是 np.ndarray
,需要将它转化成字符串数组。代码也很简单,就是用 UTF-8
去解码 bytes
而已。
拿预测返回结果的 predict_tags
为例,你可以这样转换:
def convert_prediction_tags_to_string(prediction_tags): """Convert np.ndarray prediction_tags of output of prediction to string. Args: prediction_tags: A np.ndarray object, value of prediction['prediction_tags'] Returns: A list of string predictions tags """ return " ".join([t.decode('utf8') for t in prediction_tags]) 复制代码
如果你想对文本序列进行分词,目前根据以上处理,你得到了预测的标签序列,那么要得到分词的结果,只需要根据标签结果处理一下原来的文本序列即可:
def segment_by_tag(sequences, tags): """Segment string sequence by it's tags. Args: sequences: A two dimension source string list tags: A two dimension tag string list Returns: A list of segmented string. """ results = [] for seq, tag in zip(sequences, tags): if len(seq) != len(tag): raise ValueError("The length of sequence and tags are different!") result = [] for i in range(len(tag)): result.append(seq[i]) if tag[i] == "E" or tag[i] == "S": result.append(" ") results.append(result) return results 复制代码
举个具体的例子吧,如果你有一个序列:
sequence = [ ['上', '海', '市', '浦', '东', '新', '区', '张', '东', '路', '1387', '号'], ['上', '海', '市', '浦', '东', '新', '区', '张', '衡', '路', '333', '号'] ] 复制代码
你想对这个序列进行分词处理,那么经过我们的神经网络,你得到以下标签序列:
tags = [ ['B', 'M', 'E', 'B', 'M', 'M', 'E', 'B', 'M', 'E', 'S', 'S'], ['B', 'M', 'E', 'B', 'M', 'M', 'E', 'B', 'M', 'E', 'S', 'S'] ] 复制代码
那么,怎么得到分词结果呢?就是利用上面的 segment_by_tag
函数即可。
得到的分词结果如下:
上海市 浦东新区 张东路 1387 号 上海市 浦东新区 张衡路 333 号 复制代码
以上就是所有内容了!
如果你有任何疑问,欢迎和我交流!
联系我
- 微信: luozhouyang0528
- 邮箱: stupidme.me.lzy@gmail.com
- 个人公众号:stupidmedotme
以上所述就是小编给大家介绍的《自己动手实现神经网络分词模型》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!
猜你喜欢:- 基于海量公司分词ES中文分词插件
- 北大开源全新中文分词工具包:准确率远超THULAC、结巴分词
- 复旦大学提出中文分词新方法,Transformer连有歧义的分词也能学
- 分词,难在哪里?
- 隐马尔可夫分词
- 【NLP】分词算法综述
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。