论文链接:https://arxiv.org/abs/1908.10084
文章在已有的语义匹配模型的基础上提出了基于Bert的句义匹配孪生网络
模型介绍:将两个句子通过Bert(注意:在对句子相似度建模时,两个句子经过的Bert层应该是共享权重的,及同一个Bert)进行特征提取后,取最后一层的hidde_layers进行pooling,文章试验了直接取CLS向量、max_pooling、mean_pooling,结果显示mean_pooling效果最好。将pooling后得到的两个句子向量进行特征交叉,文章尝试了多种交叉方式,|u-v|的效果最好,当然使用者可以根据具体任务和场景自行尝试多种交叉方法;最后通过softmax层。
训练好模型之后,我们可以将语料库中的句子通过单塔转化为对应的句子向量,当待匹配句子进入时,通过向量相似度检索来直接搜索相似句子,节省了大量的模型推理时间。
tensorflow 2.0.0
transformers 3.1.0
class BertNerModel(tf.keras.Model):
dense_layer = 512
class_num = 2
drop_out_rate = 0.5
def __init__(self,pretrained_path,config,*inputs,**kwargs):
super(BertNerModel,self).__init__()
config.output_hidden_states = True
self.bert = TFBertModel.from_pretrained(pretrained_path,config=config,from_pt=True)
self.liner_layer = tf.keras.layers.Dense(self.dense_layer,activation='relu')
self.softmax = tf.keras.layers.Dense(self.class_num,activation='softmax')
self.drop_out = tf.keras.layers.Dropout(self.drop_out_rate)
def call(self,input_1):
hidden_states_1,_,_ = self.bert((input_1['input_ids'],input_1['token_type_ids'],input_1['attention_mask']))
hidden_states_2,_,_ = self.bert((input_1['input_ids_2'],input_1['token_type_ids_2'],input_1['attention_mask_2']))
hidden_states_1 = tf.math.reduce_mean(hidden_states_1,1)
hidden_states_2 = tf.math.reduce_mean(hidden_states_2,1)
concat_layer = tf.concat((hidden_states_1,hidden_states_2,tf.abs(tf.math.subtract(hidden_states_1, hidden_states_2))),1,)
drop_out_l = self.drop_out(concat_layer)
Dense_l = self.liner_layer(drop_out_l)
outputs = self.softmax(Dense_l)
print(outputs.shape)
return outputs
这里比较难受的是,在自定义模型的时候本来想直接继承transformers的TFBertPreTrainedModel类,但是发现这传入训练数据的时候需要以元组的形式传入,但是在tf model.fit的时候会报错无法识别元组+datasets的数据,因此这里改为继承tf.keras.Model,在类中直接加入TFBertModel.from_pretrained加载之后的TFBertModel,再在后面接自定义的层。
def data_proceed(path,batch_size,tokenizer):
data = pd.read_csv(path)
data = data.sample(frac=1)
inputs_1 = tokenizer(list(data['sentence1']), padding=True, truncation=True, return_tensors="tf",max_length=30)
inputs_2 = tokenizer(list(data['sentence2']), padding=True, truncation=True, return_tensors="tf",max_length=30)
inputs_1 = dict(inputs_1)
inputs_1['input_ids_2'] = inputs_2['input_ids']
inputs_1['token_type_ids_2'] = inputs_2['token_type_ids']
inputs_1['attention_mask_2'] = inputs_2['attention_mask']
label = list(data['label'])
steps = len(label)//batch_size
x = tf.data.Dataset.from_tensor_slices((dict(inputs_1),label))
return x,steps
optimizer = tf.keras.optimizers.Adam(learning_rate=5e-5)
bert_ner_model.compile(optimizer=optimizer,loss='sparse_categorical_crossentropy',metrics=['acc'])
bert_ner_model.fit(train_data,epochs=5,verbose=1,steps_per_epoch=steps_per_epoch,
validation_data=test_data,validation_steps=validation_steps)
原文中提到了在训练时warm up learning rate的训练技巧
由于刚开始训练时,模型的权重(weights)是随机初始化的,此时若选择一个较大的学习率,可能带来模型的不稳定(振荡),选择Warmup预热学习率的方式,可以使得开始训练的几个epoches或者一些steps内学习率较小,在预热的小学习率下,模型可以慢慢趋于稳定,等模型相对稳定后再选择预先设置的学习率进行训练,使得模型收敛速度变得更快,模型效果更佳。
该段摘自深度学习训练策略-学习率预热Warmup
class WarmupExponentialDecay(Callback):
def __init__(self,lr_base=0.0002,decay=0,warmup_epochs=0,steps_per_epoch=0):
self.num_passed_batchs = 0 #一个计数器
self.warmup_epochs=warmup_epochs
self.lr=lr_base #learning_rate_base
self.decay=decay #指数衰减率
self.steps_per_epoch=steps_per_epoch #也是一个计数器
def on_batch_begin(self, batch, logs=None):
# params是模型自动传递给Callback的一些参数
if self.steps_per_epoch==0:
#防止跑验证集的时候呗更改了
if self.params['steps'] == None:
self.steps_per_epoch = np.ceil(1. * self.params['samples'] / self.params['batch_size'])
else:
self.steps_per_epoch = self.params['steps']
if self.num_passed_batchs < self.steps_per_epoch * self.warmup_epochs:
K.set_value(self.model.optimizer.lr,
self.lr*(self.num_passed_batchs + 1) / self.steps_per_epoch / self.warmup_epochs)
else:
K.set_value(self.model.optimizer.lr,
self.lr*((1-self.decay)**(self.num_passed_batchs-self.steps_per_epoch*self.warmup_epochs)))
self.num_passed_batchs += 1
def on_epoch_begin(self,epoch,logs=None):
#用来输出学习率的,可以删除
print("learning_rate:",K.get_value(self.model.optimizer.lr))
在实际应用中,负样本往往来自于负采样,大量的负采样会时训练时负样本数量远多余正样本数量导致训练样本不平衡,且软负采样的负样本往往非常弱,在模型推理时置信度一般较高,加入focal loss可以让模型专注于那些置信度低的比较难区分的样本,提高模型的训练效果。
详细可以查看我的之前的博客Tensorlfow2.0 二分类和多分类focal loss实现和在文本分类任务效果评估
def sparse_categorical_crossentropy(y_true, y_pred):
y_true = tf.reshape(y_true, tf.shape(y_pred)[:-1])
y_true = tf.cast(y_true, tf.int32)
y_true = tf.one_hot(y_true, K.shape(y_pred)[-1])
return tf.keras.losses.categorical_crossentropy(y_true, y_pred)
def loss_with_gradient_penalty(model,epsilon=1):
def loss_with_gradient_penalty_2(y_true, y_pred):
loss = tf.math.reduce_mean(sparse_categorical_crossentropy(y_true, y_pred))
embeddings = model.variables[0]
gp = tf.math.reduce_sum(tf.gradients(loss, [embeddings])[0].values**2)
return loss + 0.5 * epsilon * gp
return loss_with_gradient_penalty_2
#使用方法:
model.compile(optimizer=optimizer, loss=softmax_focal_loss,metrics=[tf.keras.metrics.SparseCategoricalAccuracy()])
效果:
在公开数据集上:该focal_loss可以很好的抑制模型过拟合且模型效果也有1个多点的提升。
本次训练使用的数据集:LCQMC 是哈尔滨工业大学在自然语言处理国际顶会 COLING2018 构建的问题语义匹配数据集,其目标是判断两个问题的语义是否相同。准确率能达到90%+
但在实际测试时发现,模型推理相似的问句条件比较严格,无法做到真的根据语义进行匹配,(对于同义词、别名等无法识别区分)需要应用到实际生产工作则对训练样本的要求比较严格。
拓展思考:由于该孪生模型的两个句子共享一个bert参数,因此要求两个句子的分布或者说两个句子必须来自统一场景,需要在格式、长度、风格、句式上比较相近。因此在问句匹配、句子相似度判断等工作上能有不错的表现。但可能不适用于类似评论于商品相关度等任务的分析(因为评论文本于商品介绍文本不统一,经过同一个Bert会产生偏差)因此思考对于此类问题,借鉴双塔模型,使用两个不同的Bert来提取两种分布的句子特征,或许仍能有不错的标签,之后有机会会试验一下~