本身動手實現神經網絡分詞模型

本文由**羅周楊stupidme.me.lzy@gmail.com**原創,轉載請註明原做者和出處。python

原文連接:luozhouyang.github.io/deepseggit

分詞做爲NLP的基礎工做之一,對模型的效果有直接的影響。一個效果好的分詞,可讓模型的性能更好。github

在嘗試使用神經網絡來分詞以前,我使用過jieba分詞,如下是一些感覺:json

  • 分詞速度快
  • 詞典直接影響分詞效果,對於特定領域的文本,詞典不足,致使分詞效果不盡人意
  • 對於含有較多錯別字的文本,分詞效果不好

後面兩點是其主要的缺點。根據實際效果評估,我發現使用神經網絡分詞,這兩個點都有不錯的提高。數組

本文將帶你使用tensorflow實現一個基於BiLSTM+CRF的神經網絡中文分詞模型。bash

完整代碼已經開源: luozhouyang/deepseg 微信

怎麼作分詞

分詞的想法和NER十分接近,區別在於,NER對各類詞打上對應的實體標籤,而分詞對各個字打上位置標籤。網絡

目前,項目一共只有如下5中標籤:app

  • B,處於一個詞語的開始
  • M,處於一個詞語的中間
  • E,處於一個詞語的末尾
  • S,單個字
  • O,未知

舉個更加詳細的例子,假設咱們有一個文本字符串:dom

'上','海','市','浦','東','新','區','張','東','路','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模式,labelsNone

要構建神經網絡給estimator,須要實現一個model_fn(features, labels, mode, params, config),返回一個tf.estimator.EstimatorSepc對象。

更多的內容,請訪問官方文檔。

構建input_fn

首先,咱們的數據輸入須要分三種模式TRAINEVALPREDICT討論。

  • TRAIN模式即模型的訓練,這個時候使用的是數據集是訓練集,須要返回(features,labels)元組
  • EVAL模式即模型的評估,這個時候使用的是數據集的驗證集,須要返回(features,labels)元組
  • PREDICT模式即模型的預測,這個時候使用的數據集是測試集,須要返回(features,None)元組

以上的featureslabels能夠是任意對象,好比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)這個函數。

首先,咱們的參數中的featureslabels都是字符張量,老規矩,咱們須要進行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 號 
複製代碼

以上就是全部內容了!

若是你有任何疑問,歡迎和我交流!

聯繫我

相關文章
相關標籤/搜索