使用BERT生成句向量

愛程式設計真是太好了發表於2019-02-19

轉載請註明出處,原文地址

在閱讀本文之前如果您對BERT並不瞭解,請參閱我的其他博文BERT完全指南

簡介

之前的文章介紹了BERT的原理、並用BERT做了文字分類與相似度計算,本文將會教大家用BERT來生成句向量,核心邏輯程式碼參考了hanxiao大神的bert-as-service,我的程式碼地址如下:

程式碼地址:BERT句向量

傳統的句向量

對於傳統的句向量生成方式,更多的是採用word embedding的方式取加權平均,該方法有一個最大的弊端,那就是無法理解上下文的語義,同一個詞在不同的語境意思可能不一樣,但是卻會被表示成同樣的word embedding,BERT生成句向量的優點在於可理解句意,並且排除了詞向量加權引起的誤差。

BERT句向量

BERT的包括兩個版本,12層的transformer與24層的transformer,官方提供了12層的中文模型,下文也將會基於12層的模型來講解。

每一層transformer的輸出值,理論上來說都可以作為句向量,但是到底應該取哪一層呢,根據hanxiao大神的實驗資料,最佳結果是取倒數第二層,最後一層的值太接近於目標,前面幾層的值可能語義還未充分的學習到。

在這裡插入圖片描述

接下來我們從程式碼的角度來進詳細講解。

先看下args.py檔案,該檔案有幾個句向量的重要引數,前幾個都是路徑,這裡不再詳細解釋,這裡主要說一下layer_indexes引數與max_seq_len引數,layer_indexes表示的是使用第幾層的輸出作為句向量,-2表示的是倒數第二層,max_seq_len表示的是序列的最大長度,因為輸入的長度是不固定的,所以我們需要設定一個最大長度才能確保輸出的維度是一樣的,如果最大長度是20,當輸入的序列長度小於20的時候,就會補0,如果大於20則會擷取前面的部分 ,通常該值會取語料的長度的平均值+2,加2的原因是因為需要拼接兩個佔位符[CLS](表示序列的開始)與[SEP](表示序列的結束)

model_dir = os.path.join(file_path, 'chinese_L-12_H-768_A-12/')
config_name = os.path.join(model_dir, 'bert_config.json')
ckpt_name = os.path.join(model_dir, 'bert_model.ckpt')
output_dir = os.path.join(model_dir, '../tmp/result/')
vocab_file = os.path.join(model_dir, 'vocab.txt')
data_dir = os.path.join(model_dir, '../data/')

num_train_epochs = 10 
batch_size = 32 
learning_rate = 0.00005   
# gpu使用率 
gpu_memory_fraction = 0.8   
# 預設取倒數第二層的輸出值作為句向量 
layer_indexes = [-2]

# 序列的最大程度,單文字建議把該值調小 
max_seq_len = 20

再來看graph.py檔案,該程式碼的主要目的是把預訓練好的模型載入進來,並修改輸出層,我們一步一步來看。

首先建立一個目錄,該目錄用於存放待輸出的檔案,定義bert的配置資訊路徑,根據路徑讀取配置資訊轉化為bert_config物件。

tensorflow.python.tools.optimize_for_inference_lib import optimize_for_inference
tf.gfile.MakeDirs(args.output_dir)

config_fp = args.config_name
logger.info('model config: %s' % config_fp)

# 載入bert配置檔案 with tf.gfile.GFile(config_fp, 'r') as f:
    bert_config = modeling.BertConfig.from_dict(json.load(f))

定義三個佔位符,分別表示的是對應文字的index,mask與type_index,其中index表示的是在詞典中的index,mask表示的是該位置是否有內容,舉個例子,例如序列的最大長度是20,有效的字元只有10個字,加上[CLS]與[SEP]兩個佔位符,那有8個字元是空的,該8個位置設定為0其他位置設定為1,type_index表示的是是否是第一個句子,是第一個句子則設定為1,因為該專案只有一個句子,所以均為1。

logger.info('build graph...')
input_ids = tf.placeholder(tf.int32, (None, args.max_seq_len), 'input_ids')
input_mask = tf.placeholder(tf.int32, (None, args.max_seq_len), 'input_mask')
input_type_ids = tf.placeholder(tf.int32, (None, args.max_seq_len), 'input_type_ids')

根據上面定義的三個佔位符,定義好輸入的張量,例項化一個model物件,該物件就是預訓練好的bert模型,然後從check_point檔案中初始化權重

input_tensors = [input_ids, input_mask, input_type_ids]

model = modeling.BertModel(
  config=bert_config,
  is_training=False,
  input_ids=input_ids,
  input_mask=input_mask,
  token_type_ids=input_type_ids,
  use_one_hot_embeddings=False)

tvars = tf.trainable_variables()

init_checkpoint = args.ckpt_name
(assignment_map, initialized_variable_names) = modeling.get_assignment_map_from_checkpoint(tvars, init_checkpoint)

tf.train.init_from_checkpoint(init_checkpoint, assignment_map)

接下來判斷一下args.index_layeres引數的長度,如果長度為1,則只取改層的輸出,否則遍歷需要取的層,把所有層的weight取出來並拼接成一個768*層數的張量

with tf.variable_scope("pooling"):
  if len(args.layer_indexes) == 1:
        encoder_layer = model.all_encoder_layers[args.layer_indexes[0]]
  else:
        all_layers = [model.all_encoder_layers[l] for l in args.layer_indexes]
        encoder_layer = tf.concat(all_layers, -1)

接下來是句向量生成的核心程式碼,這裡定義了兩個方法,一個mul_mask 和一個masked_reduce_mean,我們先看masked_reduce_mean(encoder_layer, input_mask)這裡呼叫方法時傳入的是encoder_layer即輸出值,與input_mask即是否有有效文字,masked_reduce_mean方法中又呼叫了mul_mask方法,即先把input_mask進行了一個維度擴充套件,然後與encoder_layer相乘,為什麼要維度擴充套件呢,我們看下兩個值的維度,我們還是假設序列的最大長度是20,那麼encoder_layer的維度為[20,768],為了把無效的位置的內容置為0,input_mask的維度為[20],擴充之後變成了[20,1],兩個值相乘,便把input_mask為0的位置的encoder_layer的值改為了0, 然後把相乘得到的值在axis=1的位置進行相加最後除以input_mask在axis=1的維度的和,然後把得到的結果新增一個別名final_encodes

mul_mask = lambda x, m: x * tf.expand_dims(m, axis=-1)
masked_reduce_mean = lambda x, m: tf.reduce_sum(mul_mask(x, m), axis=1) / (
        tf.reduce_sum(m, axis=1, keepdims=True) + 1e-10)

input_mask = tf.cast(input_mask, tf.float32)
pooled = masked_reduce_mean(encoder_layer, input_mask)
pooled = tf.identity(pooled, 'final_encodes')

output_tensors = [pooled]
tmp_g = tf.get_default_graph().as_graph_def()

最後把得到的句向量重新新增進graph中,並返回graph的路徑。

config = tf.ConfigProto(allow_soft_placement=True)
with tf.Session(config=config) as sess:
    logger.info('load parameters from checkpoint...')
    sess.run(tf.global_variables_initializer())
    logger.info('freeze...')
    tmp_g = tf.graph_util.convert_variables_to_constants(sess, tmp_g, [n.name[:-2] for n in output_tensors])
    dtypes = [n.dtype for n in input_tensors]
    logger.info('optimize...')
    tmp_g = optimize_for_inference(
        tmp_g,
  [n.name[:-2] for n in input_tensors],
  [n.name[:-2] for n in output_tensors],
  [dtype.as_datatype_enum for dtype in dtypes],
 False)
tmp_file = tempfile.NamedTemporaryFile('w', delete=False, dir=args.output_dir).name
logger.info('write graph to a tmp file: %s' % tmp_file)
with tf.gfile.GFile(tmp_file, 'wb') as f:
    f.write(tmp_g.SerializeToString())
return tmp_file

實際的使用和BERT做文字分類的方法類似,只是在返回的EstimatorSpec不太一樣,具體細節不在詳解,可參考我的具體程式碼。

with tf.gfile.GFile(self.graph_path, 'rb') as f:
    graph_def = tf.GraphDef()
    graph_def.ParseFromString(f.read())

input_names = ['input_ids', 'input_mask', 'input_type_ids']

output = tf.import_graph_def(graph_def,
                             input_map={k + ':0': features[k] for k in input_names},
                             return_elements=['final_encodes:0'])

return EstimatorSpec(mode=mode, predictions={
    'encodes': output[0]
})

最後再貼一下程式碼地址

BERT生成句向量

相關文章