本文由**羅周楊stupidme.me.lzy@gmail.com**原創,轉載請註明原做者和出處。python
原文連接:luozhouyang.github.io/deepseggit
分詞做爲NLP的基礎工做之一,對模型的效果有直接的影響。一個效果好的分詞,可讓模型的性能更好。github
在嘗試使用神經網絡來分詞以前,我使用過jieba分詞,如下是一些感覺:json
後面兩點是其主要的缺點。根據實際效果評估,我發現使用神經網絡分詞,這兩個點都有不錯的提高。數組
本文將帶你使用tensorflow實現一個基於BiLSTM+CRF的神經網絡中文分詞模型。bash
完整代碼已經開源: luozhouyang/deepseg 。微信
分詞的想法和NER十分接近,區別在於,NER對各類詞打上對應的實體標籤,而分詞對各個字打上位置標籤。網絡
目前,項目一共只有如下5中標籤:app
舉個更加詳細的例子,假設咱們有一個文本字符串:dom
'上','海','市','浦','東','新','區','張','東','路','1387','號'
複製代碼
它對應的分詞結果應該是:
上海市 浦東新區 張東路 1387 號
複製代碼
因此,它的標籤應該是:
'B','M','E','B','M','M','E','B','M','E','S','S'
複製代碼
因此,對於咱們的分詞模型來講,最重要的任務就是,對於輸入序列的每個token,打上一個標籤,而後咱們處理獲得的標籤數據,就能夠獲得分詞效果。
用神經網絡給序列打標籤,方法確定還有不少。目前項目使用的是雙向LSTM網絡後接CRF這樣一個網絡。這部分會在後面詳細說明。
以上就是咱們分詞的作法概要,如你所見,網絡其實很簡單。
項目使用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
對象。
更多的內容,請訪問官方文檔。
首先,咱們的數據輸入須要分三種模式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 號
複製代碼
以上就是全部內容了!
若是你有任何疑問,歡迎和我交流!