自己动手实现神经网络分词模型

分词作为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 号

 

以上就是所有内容了!

 

本文由**罗周杨 stupidme.me.lzy@gmail.com
**原创,转载请注明原作者和出处。

原文链接: luozhouyang.github.io/deepseg

发表评论

电子邮件地址不会被公开。 必填项已用*标注