自己動手實現神經網路分詞模型

luozhouyang發表於2018-11-28

本文由**羅周楊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模式,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 號 
複製程式碼

以上就是所有內容了!

如果你有任何疑問,歡迎和我交流!

聯絡我

相關文章