自己動手實現神經網路分詞模型
本文由**羅周楊ofollow,noindex">[email protected] **原創,轉載請註明原作者和出處。
原文連結: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
- 郵箱:[email protected]
- 個人公眾號:stupidmedotme