自从入行NLP领域以来,就一直在做文本分类、文本匹配相关的任务。也关注着相关前沿的算法和论文。从Sentence bert到bert flow,再到苏神的bert Whitening,再到2021年的SIMCSE,文本匹配无监督和有监督SOTA不断在提升。
论文:SimCSE: Simple Contrastive Learning of Sentence Embeddings是陈丹琦组在2021年4月发布的一片文章,文章中将对比学习的思想引入到语义相似度的学习中,获得计算任务SOTA,并且是大幅度提高,值得好好学习!
NLP的任务中最关键最核心的一步就是把文字转化为合适的向量表示,为此学者们就提出了各种各样的方法,从最原始的词袋模型到bert向量的高维空间表示,都是在探索怎么把语句更好的表示出来,映射成向量表示,提高文本语义的空间标识质量。Bert高维空间向量表示比之前的词袋模型、word2vec等要更加优秀,能更好的表示出语义。
众所周知,Bert预训练后的embedding表示在不经过微调直接使用,效果比较差,任何语句进过向量表示后,进行相似度的计算,大都是在0.9左右,区分度很小。有很多研究者对此进行了研究,得出bert空间表示存在坍缩的问题。bert flow论文认为,bert向量存在着各向异性的问题——每个维度上向量的长度不一样,为了解决这个问题,bert flow把这些向量通过normalizing flow映射到高斯分布上;而苏神的bert Whitening则是对语料的向量做特征值分解,然后把当前的坐标系变换到标准正交基上。以上方法都取得了很好的效果,使得bert向量做下游任务有很高的区分度。但是以上方法仍然存在不足,标准化流的表达很困难,白化操作不能解决非线性问题。
另一方面,怎么度量一个模型对于句子的表示质量或者如何评价一个语义空间呢?有研究者提出了衡量对比学习的指标——Alignment and uniformity——Wang and Isola (2020)
Alignment
对齐性——表示样本在语义空间距离。论文中给出的公式:
uniformity
分布性一致性——表示样本在语义空间的分布。论文中给出的公式:
作者的结论是——对于一个语义表示空间,使得这两个指标尽可能的小,那么这个语义表示空间的质量就越高。
对比学习的核心思想——减小正样本的距离,增大负样本的距离。任务训练的时候就需要构造语义相同,表示不同的正样本pair和语义不同的负样本pair。
SIMCSE本质就是一个对比学习,然后很巧妙的、简单的构建了正样本对——无监督情况下实现的,这样意义就很大了。
SIMCSE具体是怎么来构建正样本对的呢?
论文中提到:
为了产生一个正样本对,就是简简单单的直接把同一个输入以不同的dropout masks 输入到encoder中两次,得到的embedding就是不同的,但是语义上有细微的差别。在代码实现训练过程中,同一个batch内,就是有相同的句子复制2遍组成的训练数据。例如:文本数据——[a,b,c,d,e]
组成一个batch的数据就是:[a,a,b,b,c,c,d,d,e,e];这个实现方式很简单,直接对每一个样本复制一份就好了在DataReader中处理可以,也可以在DataLoader的时候添加上collate_fn=collator.collate这样就比较优雅哈哈哈哈。
class CSECollator(object):
def __init__(self,
tokenizer,
features=("input_ids", "attention_mask", "token_type_ids"),
max_len=100):
self.tokenizer = tokenizer
self.features = features
self.max_len = max_len
def collate(self, batch):
new_batch = []
for example in batch:
for i in range(2):
# 每个句子重复两次
new_batch.append({fea: example[fea] for fea in self.features})
new_batch = self.tokenizer.pad(
new_batch,
padding=True,
max_length=self.max_len,
return_tensors="pt"
)
return new_batch
输入到同一个bert encoder中,对于batch内的数据dropout都会生成一个不同的dropout masks——(这里应该是与BertSelfattention层中的dropout机制有关,我还没有彻底的理解清楚,有理解清楚的人可以解释一下),那么[a,a,b,b,c,c,d,d,e,e]得出的embedding—— ——这样就构成了同一个样本之间的正样本对。
伪负样本的影响
这里有一个问题,以上的数据中,是默认为[a,b,c,d,e]是各不相同的,除了是语义相近的正样本,和剩下的batch都视为负样本。然而现实中负例中缺存在很多相似的样本,这个时候该怎么办呢?自然而言比较容易想到的就是减少这样的负例,在无监督的情况下,如何不花费大成本来实现呢?首先想办法去增大预料规模,同时模型训练的同时增大batch_size,这样在同一个batch内就能够减小采样到负例的概率,从而减小这些伪负例对神经网络模型性能的影响。
我这里有个比较消耗时间和规模的想法,使用个简单经过微调的模型,对的领域下的预料亮亮做相似度计算,保留那些两两之间相似度都比较小的样本,或者对一些困难样本,进行人工检查,尽可能保证语料的质量。当然也有学者对这个问题进行研究和探索,有兴趣的同学可以参考论文——Debiased Contrastive Learning和ADACLR: Adaptive Contrastive Learning Of Representation By Nearest Positive Expansion。
InfoNce loss
对比学习一个比较重要的就是它的损失函数,怎么样利用相似样本比较近、不相似样本比较远的思想来更新参数,就需要用loss函数来度量训练数据中相似样本近的程度和不相似样本远的程度来更新模型参数。论文给出的损失函数——infoNCE loss:
其中表示两个相似向量的余弦相似度,t是温度常数,用来控制调节模型对困难样本的关注程度,过大过小都不行。对比损失函数是一个具备困难负样本自发现性质的损失函数,对于样本i,对比学习损失会自动的给困难负样本(距离更近的负样本)更多的惩罚,也就是更大的梯度使得它远离正样本——原理参考知乎博客——CVPR2021自监督学习论文: 理解对比损失的性质以及温度系数的作用——可以把这种情况想象成不同的负样本作为同极点电荷在不同距离处的受力情况,距离越近的点电荷受到的库伦斥力更大,而距离越远的点电荷受到的斥力越小。
那么怎么用代码来实现这个loss函数呢?把看做一个整体视为,该公式化简为:
,其中表示样本i和它的正样本之间的相似度,表示样本i和负样本j的相似度;
CEloss的公式如下:
CEloss和Info NCE loss 具有相似性,只不过标签变成了样本i和正样本匹配就是1,正样本和负样本是0,那么这样通过基于CELoss来实现NCELoss:
1、样本构建正样本,同时生成对应的标签集:
对于给定的[a,a,b,b,c,c,d,d,e,e]数据,得到的向量它的标签结果就是:
对角线上的0红色框框表示本身,不参与相似度计算,也不参与模型更新,非对角线则表示正负样本的组合,那么把这个表转化为index型的label就是:[1,0,3,2,5,4,7,6,.....,2n,2n-1]
一个btach中第2n个样本它的正样本就是2n-1,分类序号就是2n-1;第2n-1个样本的正样本就是2n,分类序号就是2n。实现代码如下:
def compute_infoceLoss(y_pred, tao=0.05, device="cuda"):
"""
:param y_pred: 模型输出,维度[B,H]
:param tao: 温度系数
:param device:
:return:
"""
idxs = torch.arange(0, y_pred.shape[0], device=device)
y_true = idxs + 1 - idxs % 2 * 2
t1 = time.time()
#[B,1,H]
a = y_pred.unsqueeze(1)
# [1,B,H]
b = y_pred.unsqueeze(0)
#[B,B]
similarities = F.cosine_similarity( a, b, dim=2)
t2 = time.time()
print('time is %.4f' % (t2 - t1)) #cpu情况下 B=64 time is 0.2021
t1 = time.time()
#自己实现的cos——similarity计算,貌似比torch.cosine_similarity()要快
#[B,H]
a_new = y_pred
#[H,B]
b_new = y_pred.T
#[B,B]
d = torch.matmul(a_new,b_new)
# [B,B]
length = torch.mm(torch.norm(a_new,dim=1).unsqueeze(1),torch.norm(b_new,dim=0).unsqueeze(0))
cos = d/length
t2 = time.time()
print('time is %.4f'%(t2-t1)) #cpu情况下 B=64 time is 0.0348
#单位对角矩阵——对角线上为1e12很大的值
c = torch.eye(y_pred.shape[0], device=device) * 1e12
# 单位对角矩阵——对角线上为1-1e12很小的值
similarities = similarities - c
similarities = similarities / tao
loss = F.cross_entropy(similarities, y_true)
return torch.mean(loss)
SIMCSE核心的东西就是上述的损失函数和正样本的构建。
论文中给出的原始数据示例,从论文给出的代码层面来看:
# Separate representation [B,H],[B,H]
z1, z2 = pooler_output[:,0], pooler_output[:,1]
# Hard negative
if num_sent == 3:
[B,H]
z3 = pooler_output[:, 2]
#[B,B]
cos_sim = cls.sim(z1.unsqueeze(1), z2.unsqueeze(0))
# Hard negative
if num_sent >= 3:
#[B,B]
z1_z3_cos = cls.sim(z1.unsqueeze(1), z3.unsqueeze(0))
#[B,2B]
cos_sim = torch.cat([cos_sim, z1_z3_cos], 1)
#[B,1]
labels = torch.arange(cos_sim.size(0)).long().to(cls.device)
loss_fct = nn.CrossEntropyLoss()
loss = loss_fct(cos_sim, labels)
一个batch中,它的一个数据集构造应该是这样的:
其中每一列中每个样本和其他的各不相似,互相构成负样本;每一行中只有z1和z2构成正样本,z1和z3构成负样本,采用对比学习的思想,让正样本对([a,a+],[b,b+],[c,c+],[d,d+])的距离更近,负样本对([a,a-],[b,b-],[c,c-],[d,d-],[a,b],[a,c],[a,d],[a,b+],[a,b-]...[a,d],[a,+],[a,d-])的距离更远。这样训练出来的模型再生成embedding应该是具有良好的对齐性和分布性,做文本相似度的效果应该很好会向标注期望目标靠近。
我认为这样构建数据集的很麻烦,在具体的业务下很难做到;比较容易做到的就是针对不同的话(不相似)给出一系列的相似问,负样本不太能很好的给出(花费大量的精力也是能给出的)。所以针对不同的话(不相似)给出一系列的相似问的标注数据情况下,我为人同样可以采用对比学习的思路——这里的负样本对会天然的生成(只要不是同一句话的相似问都是负样本),一个batch内的数据如下图:
正样本对([a,a+],[b,b+],[c,c+],[d,d+])
负样本对([a,b],[a,c],[a,d],[a,b+],......[d,b+],[d,c+])
这样的样本情况下,label如下:
红色框框的表示样本本身,没有意义,不参与相似度计算,也不更新模型参数,其他的则参与计算。label就是[0,1,2,3],一般的就是[0,1,2,3,...,batch_size-1]
这里其实和MOCO中的思想很相似,当然实现方式和损失函数都不一样。
之所以这样设计,就是想尽可能的去掉伪负样本对模型的影响。
项目中需要计算文本相似度,初版没有人工标注的数据,只有一些无标注的语料,为了提高相似度计算方案的效果,这里就可以采用无监督对比学习的SIMCSE。先说说数据集情况,项目中的业务数据,没有详细的标注和分类情况,为了初版的效果就采用了无监督的方案,看看效果,有监督的时候可以使用采用Sentence-bert,也可以采用SIMCSE有监督版本。
看看原始数据:
无监督的时候,直接把所有的单个文本句拿来做数据集,一个batch内由[a,b,c,d,e]得到[a,a,b,b,c,c,d,d,e,e]——代码在上面。整体的训练代码和模型代码很简单,模型代码就是bert给出输出,模型训练就是梯度回传、梯度清零之类的。
有监督的情况下,每一行内构建正样本对,和其他行构建负样本对,所以训练集中的数据如下图:
text_a和text_b互为正样本,每一行之间互为负样本。
模型定义代码如下:
from transformers import BertModel
from transformers import BertPreTrainedModel
import torch
class SimCSESup(BertPreTrainedModel):
def __init__(self,config, pool_type="cls", dropout_prob=0.3,tao=0.05):
super(SimCSESup,self).__init__(config)
config.attention_probs_dropout_prob = dropout_prob
config.hidden_dropout_prob = dropout_prob
self.tao = tao
self.bert = BertModel(config)
assert pool_type in ["cls", "pooler"], "invalid pool_type: %s" % pool_type
self.pool_type = pool_type
def forward(self, input_ids, attention_mask, token_type_ids):
output = self.bert(input_ids,
attention_mask=attention_mask,
token_type_ids=token_type_ids)
#[2B,S,H]
output = output.last_hidden_state
#[2B,H]
output = self.pooling(output,attention_mask)
b_s = int(output.size(0)/2)
#batch内前面一半是text_a
z1 = output[0:b_s, :]
#后面一半是text_b;text_a和text_b互为正样本对
z2 = output[b_s:,:]
#[B,B]
cos_z1_z2 = self.cossimilarity(z1,z2)
# [B,B]
cos_z1_z1 = self.cossimilarity(z1,z1)
#对角矩阵,对角线为1e12
c = torch.eye(cos_z1_z1.shape[0], device=cos_z1_z1.device) * 1e12
cos_z1_z1 = cos_z1_z1-c
#[B,2B]
cos = torch.cat([cos_z1_z2,cos_z1_z1],dim=1)/self.tao
return cos
def cossimilarity(self,v1,v2):
"""
:param v1: [B,H]
:param v2: [B,H]
:return:
"""
v2 = v2.T
d = torch.matmul(v1,v2)
length = torch.mm(torch.norm(v1,dim=1).unsqueeze(1),torch.norm(v2,dim=0).unsqueeze(0))
cos = d/length
return cos
def pooling(self,token_embeddings,attention_mask):
"""
mask平均池化
:param token_embeddings: [B,S]
:param input: [B,S,H]
:return: output_vector [B,H]
"""
output_vectors = []
#attention_mask
attention_mask = attention_mask
#[B,L]------>[B,L,1]------>[B,L,768],矩阵的值是0或者1
input_mask_expanded = attention_mask.unsqueeze(-1).expand(token_embeddings.size()).float()
#这里做矩阵点积,就是对元素相乘(序列中padding字符,通过乘以0给去掉了)[B,L,768]
t = token_embeddings * input_mask_expanded
#[B,768]
sum_embeddings = torch.sum(t, 1)
# [B,768],最大值为seq_len
sum_mask = input_mask_expanded.sum(1)
#限定每个元素的最小值是1e-9,保证分母不为0
sum_mask = torch.clamp(sum_mask, min=1e-9)
#得到最后的具体embedding的每一个维度的值——元素相除
output_vectors.append(sum_embeddings / sum_mask)
#列拼接
output_vector = torch.cat(output_vectors, 1)
return output_vector
就不一一解释代码了,已经做了详细的注释了。
训练代码:
import argparse
import logging
import os
from pathlib import Path
from transformers import BertConfig
import torch
import torch.nn.functional as F
from torch.utils.data import DataLoader
from tqdm import tqdm
from transformers import BertTokenizer
from SimCSESup import SimCSESup
from dataReader_sup import DataReaderSup
os.environ['CUDA_VISIBLE_DEVICES'] = "0"
def parse_args():
parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument("--train_file", type=str,default='./data/shanghai_sup/train_2021-0907.xlsx', help="train text file")
parser.add_argument("--dev_file", type=str, default='./data/shanghai_sup/dev_2021-0907.xlsx',
help="train text file")
parser.add_argument("--pretrained", type=str, default="./pretrain_models/chinese-bert-wwm-ext", help="huggingface pretrained model")
parser.add_argument("--model_out", type=str, default="./output", help="model output path")
parser.add_argument("--num_proc", type=int, default=5, help="dataset process thread num")
parser.add_argument("--max_length", type=int, default=64, help="sentence max length")
parser.add_argument("--batch_size", type=int, default=32, help="batch size")
parser.add_argument("--epochs", type=int, default=30, help="epochs")
parser.add_argument("--lr", type=float, default=1e-5, help="learning rate")
parser.add_argument("--tao", type=float, default=0.05, help="temperature")
parser.add_argument("--device", type=str, default="cuda", help="device")
parser.add_argument("--display_interval", type=int, default=500, help="display interval")
parser.add_argument("--save_interval", type=int, default=860, help="save interval")
parser.add_argument("--pool_type", type=str, default="cls", help="pool_type")
parser.add_argument("--dropout_rate", type=float, default=0.3, help="dropout_rate")
args = parser.parse_args()
return args
def load_data(args, tokenizer):
train_dataset = DataReaderSup(tokenizer,args.train_file,100)
train_dataloader = DataLoader(dataset=train_dataset,batch_size=args.batch_size,shuffle=False)
dev_dataset = DataReaderSup(tokenizer, args.dev_file, 100)
dev_dataloader = DataLoader(dataset=dev_dataset, batch_size=args.batch_size, shuffle=False)
return train_dataloader,dev_dataloader
def train(args):
args.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
tokenizer = BertTokenizer.from_pretrained(args.pretrained)
train_dataloader,dev_dataloader = load_data(args, tokenizer)
conf = BertConfig.from_pretrained(args.pretrained)
# model = SimCSE(conf,args.pretrained, args.pool_type, args.dropout_rate).to(args.device)
model = SimCSESup.from_pretrained(pretrained_model_name_or_path=args.pretrained, config=conf).to(args.device)
optimizer = torch.optim.AdamW(model.parameters(), lr=args.lr)
model_out = Path(args.model_out)
if not model_out.exists():
os.mkdir(model_out)
model.train()
batch_idx = 0
max_acc = 0
for epoch_idx in range(args.epochs):
for batch in tqdm(train_dataloader,ncols=50):
batch_idx += 1
batch = [t.to(args.device) for t in batch]
#比较重要的是——把text_a和text_b合并在一起,在使用infoNCELoss的时候方便计算相似度
input_ids = torch.cat([batch[0],batch[3]],dim=0)
attention_mask = torch.cat([batch[1],batch[4]],dim=0)
token_type_ids = torch.cat([batch[2],batch[5]],dim=0)
pred = model(input_ids=input_ids,attention_mask=attention_mask,token_type_ids=token_type_ids)
labels = torch.arange(pred.size(0)).to(args.device)
loss = F.cross_entropy(pred, labels)
optimizer.zero_grad()
loss.backward()
optimizer.step()
loss = loss.item()
if batch_idx % args.display_interval == 0:
logging.info(f"batch_idx: {batch_idx}, loss: {loss:>10f}")
acc = evaluation(model,dev_dataloader,args)
if acc>max_acc:
max_acc = acc
save_path = os.path.join(model_out, "supvervised")
model.save_pretrained(
save_path)
tokenizer.save_vocabulary(
save_path)
logging.info(f"acc: {acc:>10f}, max_acc: {max_acc:>10f}")
def evaluation(model,dev_dataloader,args):
total = 0
total_correct = 0
model.eval()
with torch.no_grad():
for batch in tqdm(dev_dataloader,ncols=50):
batch = [t.to(args.device) for t in batch]
input_ids = torch.cat([batch[0],batch[3]],dim=0)
attention_mask = torch.cat([batch[1],batch[4]],dim=0)
token_type_ids = torch.cat([batch[2],batch[5]],dim=0)
pred = model(input_ids=input_ids,attention_mask=attention_mask,token_type_ids=token_type_ids)
labels = torch.arange(pred.size(0)).to(args.device)
pred = torch.argmax(pred,dim=1)
correct = (labels==pred).sum()
total_correct += correct
total += pred.size(0)
acc = total_correct/total
return acc
def main():
args = parse_args()
print('args',args)
train(args)
if __name__ == "__main__":
log_fmt = "%(asctime)s|%(name)s|%(levelname)s|%(message)s"
logging.basicConfig(level=logging.INFO, format=log_fmt)
main()
训练中没有采用过多的技巧,什么wamup、学习率衰减、梯度累加、FP16等等,就最简单的那种训练。结果如下:
验证集0.99046的准确率
衡量模型对于当前业务场景下生成的向量空间表示的质量,一个是看对齐性和分布一致性指标,这个就比较学术论文范儿了,二一个就是直接看具体的文本余弦相似度或者标注数据的准确率。这里我采用了直接看具体的文本余弦相似度或者标注数据的准确率的方法。
Sbert采用分类任务微调模型,SIMCSEUNSup无监督、SIMCSESup有监督,结果数据如下:
best_sbert_threshold: 0.7600 -------best_sbert_acc:0.9970
best_simcsesup_threshold: 0.5900 -------best_simcsesup_acc:0.9889
best_simcseunsup_threshold: 0.4200 -------best_simcseunsup_acc:0.8865
还是采用Sbert的分类任务能取得最好的效果,SIMCSESup有监督并没取得最好的效果。这或许与我们构建的训练集方式有关以及batch_size有关(应该尽可能的大,限于显卡的原因只能取64)。
在容易样本上都能给出较高的相似度值,而在困难样本上Sbert各处的相似度值更加的科学——指的是更加符合标注的规律(不一定是真实语义相似的合理性)。
以上就是这篇博客所有的内容了,主要是针对文本相似度最新进展的一个实验对比,学习和思考。有监督的任务还是要比无监督效果好很多,当然有监督也很依赖标注数据的质量。这里的实验结论虽然没有得出对比学习一定能提升效果的结论,但是对比学习的思想和这个方法真的很重要,而且也很有用,后面在合适的数据质量和业务场景下还是要利用起来,当然作为方案备选,也增加了我的方案库!哈哈哈!
加油!我是一个NLP爱好者!
参考文章:
NLP系列之句子向量、语义匹配(二):BERT_avg/BERT_Whitening/SBERT/SimCSE—方法解读
超细节的对比学习和SimCSE知识点
ConSERT|用对比学习做NLP都有哪些坑?
知乎博文SimCSE: Simple Contrastive Learning of Sentence Embeddings
SimCSE: Simple Contrastive Learning of Sentence Embeddings原文
CVPR2021自监督学习论文: 理解对比损失的性质以及温度系数的作用