文本匹配之SimCSE学习(pytorch)

论文地址: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部分,可见, 同一句话, 走两次BertEncoder, 生成两个相似的句向量, 当作正例。

模型结构如下所示:

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_idstoken_type_idsattention_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
  • 3
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值