本文由**羅周楊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
模式,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
- 郵箱: stupidme.me.lzy@gmail.com
- 個人公眾號:stupidmedotme