BERT句向量

在阅读本文之前如果您对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生成句向量

赞 赏