论文地址:https://arxiv.org/pdf/2104.08821.pdf
代码链接:https://github.com/seanzhang-zhichen/simcse-pytorch
simcse模型一共包含两种训练方式,包括无监督部分和有监督部分。论文中模型的整体结构如下:
论文首先使用了一种无监督的方法,仅仅使用dropout来对数据进行数据增广,即 将同一句子输入预训练的编码器两次,通过dropout可以得到两个不同的编码向量,这两条编码向量即作为“正对”。接着从小批量中其他的句子都作为负样本,模型预测否定句中的正句。有监督的方法使用自然语言推理( Natural Language Inference,NLI )数据集进行句子嵌入的成功基础上,在对比学习中融合已经标注完成的句子对(上图(b))。
使用dropout作为数据增强:
论文将其视为一种最小形式的数据增强方式:正对取完全相同的句子,并且他们的嵌入只在dropout掩码中不同。
无监督训练
在无监督部分,上面已经说明了,使用dropout进行数据增强来构建正样本,而负样本则实在同一个batch中的其他句子。
在无监督部分,结合了两个数据集中的样本进行训练,
snli_train = './data/data/cnsd-snli/train.txt'
sts_train = './data/data/STS-B/cnsd-sts-train.txt'
if model_type == "unsup":
train_data_snli = load_data('snli', snli_train, model_type)
train_data_sts = load_data('sts', sts_train, model_type)
train_data = train_data_snli + [_[0] for _ in train_data_sts] # 两个数据集组合
两个数据集中的样本展示如下:
cnsd-sts-train.txt:这个数据集中每条样本包括两个句子,并且给出了它们之间的相似度,中间用“||”隔开。在无监督训练是我们只需要每条样本的第一条句子,将其输入到bert的编码器中,得到两次生成的编码向量,即为相似样本对。
train.txt:这个数据集中每条样本包括一个原始句子origin,对应的正样本entailment,对应的负样本contradiction。在无监督训练是我们只需要每条样本的origin,将其输入到bert的编码器中,得到两次生成的编码向量,即为相似样本对。
对数据集的具体处理流程如下面代码:
def load_data(name, path, model_type="unsup"):
"""根据名字加载不同的数据集"""
def load_snli_data(path):
with jsonlines.open(path, 'r') as f:
if model_type == "unsup":
return [line.get('origin') for line in f]
elif model_type == "sup":
return [(line['origin'], line['entailment'], line['contradiction']) for line in f]
def load_lqcmc_data(path):
with open(path, 'r', encoding='utf8') as f:
return [line.strip().split('\t')[0] for line in f]
def load_sts_data(path):
with open(path, 'r', encoding='utf8') as f:
return [(line.split("||")[1], line.split("||")[2], line.split("||")[3]) for line in f]
assert name in ["snli", "lqcmc", "sts"]
if name == 'snli':
return load_snli_data(path)
return load_lqcmc_data(path) if name == 'lqcmc' else load_sts_data(path)
使用dropout来进行数据增强,具体实现过程如下面代码:
class TrainDataset(Dataset):
def __init__(self, data, tokenizer, model_type="unsup"):
self.data = data
self.tokenizer = tokenizer
self.model_type = model_type
def text2id(self, text):
'''
text_ids是一个字典,包含三个变量
'input_ids':对应的值是一个张量,包含了输入文本中每个词对应的ID。
'token_type_ids':对应的值是一个张量,表示每个词的类型ID(因为BERT可以处理两段文本,所以需要用这个ID区分两段文本)。在这里因为输入的两段文本是相同的,所以这个ID没有实际的意义。
'attention_mask':对应的值是一个张量,表示每个位置的词是否应该被模型关注。在这里,实际的文本位置上的值是1,填充的位置上的值是0。
'''
if self.model_type == "unsup":
text_ids = self.tokenizer([text, text], max_length=MAXLEN, truncation=True, padding='max_length', return_tensors='pt')
elif self.model_type == "sup":
text_ids = self.tokenizer([text[0], text[1], text[2]], max_length=MAXLEN, truncation=True, padding='max_length', return_tensors='pt')
return text_ids
def __len__(self):
return len(self.data)
def __getitem__(self, index):
return self.text2id(self.data[index])
与原本的Bert相比,simcse只改变了dropout部分,可见, 同一句话, 走两次Bert
的Encoder
, 生成两个相似的句向量, 当作正例。
模型结构如下所示:
class SimcseUnsupModel(nn.Module):
def __init__(self, pretrained_bert_path, drop_out) -> None:
super(SimcseUnsupModel, self).__init__()
self.pretrained_bert_path = pretrained_bert_path
config = BertConfig.from_pretrained(self.pretrained_bert_path)
config.attention_probs_dropout_prob = drop_out
config.hidden_dropout_prob = drop_out
self.bert = BertModel.from_pretrained(self.pretrained_bert_path, config=config)
def forward(self, input_ids, attention_mask, token_type_ids, pooling="cls"):
out = self.bert(input_ids, attention_mask, token_type_ids, output_hidden_states=True)
if pooling == "cls":
return out.last_hidden_state[:, 0]
if pooling == "pooler":
return out.pooler_output
if pooling == 'last-avg':
last = out.last_hidden_state.transpose(1, 2)
return torch.avg_pool1d(last, kernel_size=last.shape[-1]).squeeze(-1)
if self.pooling == 'first-last-avg':
first = out.hidden_states[1].transpose(1, 2)
last = out.hidden_states[-1].transpose(1, 2)
first_avg = torch.avg_pool1d(first, kernel_size=last.shape[-1]).squeeze(-1)
last_avg = torch.avg_pool1d(last, kernel_size=last.shape[-1]).squeeze(-1)
avg = torch.cat((first_avg.unsqueeze(1), last_avg.unsqueeze(1)), dim=1)
return torch.avg_pool1d(avg.transpose(1, 2), kernel_size=2).squeeze(-1)
在训练过程中,bert的tokenizer会输出三个张量:input_ids
, token_type_ids
, attention_mask。
input_ids:
这是将文本输入转换为BERT模型可接受的整数序列的主要部分。它的第一维为batch_size
, 第二维是输入的句子数量, 输入了两个句子(同一个句子输入bert
两次), 所以第二维是2, 第三维是句子的max_length。
token_type_ids:
在BERT模型中,句子对任务(例如句子分类、问答等)需要输入两个句子的编码。为了区分这两个句子,需要使用token_type_ids。
attention_mask:
由于BERT模型可以接受变长的序列作为输入,为了处理不同长度的文本,需要使用attention_mask来指示模型哪些标记是有效的(1)和哪些是填充的(0)。attention_mask的长度与input_ids相同,并具有相同的维度。有效标记对应的位置为1,填充标记对应的位置为0。
计算损失的过程如下:
def simcse_unsup_loss(self, y_pred):
# 生成句子的索引
y_true = torch.arange(y_pred.shape[0], device=self.device)
# 生成每个句子对应的真实标签,即当前位置上的句子对应的正例在这个batch中的索引
'''
与第0个句子相似的句子索引为1
与第1个句子相似的句子索引为0
与第2个句子相似的句子索引为3
与第2个句子相似的句子索引为2
'''
y_true = (y_true - y_true % 2 * 2) + 1
# 将一个batch中的句子量量计算相似度
sim = F.cosine_similarity(y_pred.unsqueeze(1), y_pred.unsqueeze(0), dim=-1)
# 将对角线上的数值放大为一个较大的数, 消除对角线上自身loss的影响(负无穷计算交叉熵时几乎为0)
sim = sim - torch.eye(y_pred.shape[0], device=self.device) * 1e12
# 乘以超参数温度系数, 至于为什么是0.05, 只能说实验表明, 0.05效果好
sim = sim / 0.05
# 用交叉熵损失表示对比损失, 将相似句子看作分类, 拉近与正例的距离, 拉远与负例的距离, 同一个batch中, 除了输入bert两次的那个句子互为正例, 其他句子都是负例
loss = F.cross_entropy(sim, y_true)
return loss
有监督训练
训练数据展示:
与无监督不同, 无监督的输入为单个text
句子, 而有监督的数据集为 [句子, 正样本, 负样本]
的三元组
有监督训练和无监督训练的区别主要在于损失的计算:
def simcse_sup_loss(self, y_pred):
y_true = torch.arange(y_pred.shape[0], device=self.device) # 构建 0 ~ (batch_size x 3 - 1) 的索引向量
use_row = torch.where((y_true + 1) % 3 != 0)[0] # 去除其中的负样本
y_true = (use_row - use_row % 3 * 2) + 1 # 构建标签,一个长度为(batch_size x 2)的向量,每个元素代表此位置的句子所对应的正样本在这个batch_size中的索引
sim = F.cosine_similarity(y_pred.unsqueeze(1), y_pred.unsqueeze(0), dim=-1) # 一个batch_size中的句子两两计算相似度,此时对角线上表示每个句子与自身的相似度,数值应该为1
sim = sim - torch.eye(y_pred.shape[0], device=self.device) * 1e12 # 消除对角线上自身loss的影响
sim = torch.index_select(sim, 0, use_row) # 选择去除其中的负样本
sim = sim / 0.05 # 乘以超参数温度系数
loss = F.cross_entropy(sim, y_true)
return loss
首先是生成一个batch中的索引向量
y_true = torch.arange(y_pred.shape[0], device=self.device)
接着选择使用的索引,由于每第三句为负样本,所以不使用第三句,把同一个batch中的其他样本作为负样本
use_row = torch.where((y_true + 1) % 3 != 0)[0]
丢弃第三句后的真实label
y_true = (use_row - use_row % 3 * 2) + 1
两两计算相似度, 此时sim
的维度是[192, 192]
, 包含了第三句的负例
sim = F.cosine_similarity(y_pred.unsqueeze(1), y_pred.unsqueeze(0), dim=-1)
消除对角线上维度的影响
sim = sim - torch.eye(y_pred.shape[0], device=self.device) * 1e12
挑选出有用的行
sim = torch.index_select(sim, 0, use_row)
计算交叉熵损失, 与无监督的方法一致
loss = F.cross_entropy(sim, y_true)
预测
在进行预测时,首先对两个句子a和b进行编码,接着将两个句子分别经过训练好的模型得到最终的向量,最后计算两个向量之间的余弦相似度即可
代码过程如下:
def predict(tokenizer, model, text_a, text_b):
token_a = tokenizer([text_a], max_length=64, truncation=True, padding='max_length', return_tensors='pt')
token_b = tokenizer([text_b], max_length=64, truncation=True, padding='max_length', return_tensors='pt')
model.eval()
with torch.no_grad():
source_input_ids = token_a.get('input_ids').squeeze(1).to(DEVICE)
source_attention_mask = token_a.get('attention_mask').squeeze(1).to(DEVICE)
source_token_type_ids = token_a.get('token_type_ids').squeeze(1).to(DEVICE)
source_pred = model(source_input_ids, source_attention_mask, source_token_type_ids)
# target [batch, 1, seq_len] -> [batch, seq_len]
target_input_ids = token_b.get('input_ids').squeeze(1).to(DEVICE)
target_attention_mask = token_b.get('attention_mask').squeeze(1).to(DEVICE)
target_token_type_ids = token_b.get('token_type_ids').squeeze(1).to(DEVICE)
target_pred = model(target_input_ids, target_attention_mask, target_token_type_ids)
# concat
sim = F.cosine_similarity(source_pred, target_pred, dim=-1).item()
return sim