PET 模型和代码分析

标题:Exploiting Cloze Questions for Few Shot Text Classification and Natural Language Inference

标题当中的 cloze 一词,根据 Merriam-Webster 上的翻译,大致可以理解为一项阅读理解测试,总结文本。因此,这个标题的意思是,使用 cloze 测试问题进行小样本文本分类和自然语言推理。

摘要

这篇文章提出了 Pattern Exploiting Training,一种半监督训练技术,将输入组织成 cloze 方式的句子,利用预训练语言模型本身的能力解决问题。对于无标注的数据,将获得一个软标签,之后使用标准的监督学习方式,学习这些数据。在小样本的情况下,PET/iPET 可以获得比监督学习更好的效果。

模型

PET

PET 模型分为三个组成部分。

  • (1) 是一个 pattern 对应的分类器,利用语言模型 PLM 本身的能力进行分类
  • (2) 训练多个 pattern 分类器,输出无监督数据的软标签(logits)
  • (3) 使用无监督数据训练最后的分类器。

原始数据(Best pizza ever!)和一个模板(It was <mask>.) 构成一个新的句子,使用预训练语言模型(PLM)预测 mask 的输出结果,这个结果是一个词表长度大小的向量,从这些向量中选出 n 个 label 对应的分类,计算 softmax 和 argmax,得到分类,计算损失。

img

iPET

iPET 在 PET 的基础上,迭代训练模型。一轮一轮进行训练,每一轮训练出来之后,对下一轮的训练集进行扩充。扩充的方法是,选择上一轮中的部分模型的输出结果,对这些模型输出的 logits 加权求和,softmax,argmax 得到标签,从而扩充了数据。每一轮扩充数据的时候,按倍数放大数据量。

img

继续预训练

毕竟 PET/iPET 是半监督学习,充分利用了未标注数据,而监督学习没有用,这样比较模型的性能是否存在不公平呢?为此作者还进行了实验,先让模型在语料上进一步微调,然后再进行进一步的实验。结果表明,继续预训练对于监督学习、PET 都有提高,不过 PET 的性能还是要比监督学习好。

img

代码分析

PET 模型

整体流程分为三步:

  • 第一步,训练多个 pattern 对应的模型
  • 第二步,合并每个模型输出的无标注数据的 logits
  • 第三步,训练最后的分类器。
# Step 1: Train an ensemble of models corresponding to individual patterns
train_pet_ensemble(ensemble_model_config, ensemble_train_config, ensemble_eval_config, pattern_ids, output_dir,
                   repetitions=ensemble_repetitions, train_data=train_data, unlabeled_data=unlabeled_data,
                   eval_data=eval_data, do_train=do_train, do_eval=do_eval,
                   save_unlabeled_logits=not no_distillation, seed=seed)

if no_distillation:
    return

# Step 2: Merge the annotations created by each individual model
logits_file = os.path.join(output_dir, 'unlabeled_logits.txt')
merge_logits(output_dir, logits_file, reduction)
logits = LogitsList.load(logits_file).logits
assert len(logits) == len(unlabeled_data)
logger.info("Got {} logits from file {}".format(len(logits), logits_file))
for example, example_logits in zip(unlabeled_data, logits):
    example.logits = example_logits

# Step 3: Train the final sequence classifier model
final_model_config.wrapper_type = SEQUENCE_CLASSIFIER_WRAPPER
final_train_config.use_logits = True

train_classifier(final_model_config, final_train_config, final_eval_config, os.path.join(output_dir, 'final'),
                 repetitions=final_repetitions, train_data=train_data, unlabeled_data=unlabeled_data,
                 eval_data=eval_data, do_train=do_train, do_eval=do_eval, seed=seed)

第一步

训练一个 pattern 对应的模型,给无监督数据数据打 logits。

每个 train step 外部的主要逻辑是将数据组装起来,一个 batch 包含一组有监督数据和一组无监督数据。每个 train step 中损失的计算,有监督数据计算 CrossEntropy,无监督数据计算 MLM loss,最后两个 loss 加权求和。

inputs = self.generate_default_inputs(labeled_batch)
mlm_labels, labels = labeled_batch['mlm_labels'], labeled_batch['labels']
outputs = self.model(**inputs)
prediction_scores = self.preprocessor.pvp.convert_mlm_logits_to_cls_logits(mlm_labels, outputs[0])
loss = nn.CrossEntropyLoss()(prediction_scores.view(-1, len(self.config.label_list)), labels.view(-1))
if lm_training:
    lm_inputs = self.generate_default_inputs(unlabeled_batch)
    lm_inputs['labels'] = unlabeled_batch['mlm_labels']
    lm_loss = self.model(**lm_inputs)[0]
    loss = alpha * loss + (1 - alpha) * lm_loss
return loss

第二步

合并无监督数据的 logits,将第一步每个模型输出的 logits 加权合并起来。

def merge_logits_lists(logits_lists: List[LogitsList], reduction: str = 'mean') -> LogitsList:
    assert len(set(len(ll.logits) for ll in logits_lists)) == 1
    logits = np.array([ll.logits for ll in logits_lists])
    weights = np.array([ll.score for ll in logits_lists])

    if reduction == 'mean':
        logits = np.mean(logits, axis=0).tolist()
    elif reduction == 'wmean':
        logits = np.average(logits, axis=0, weights=weights).tolist()
    else:
        raise ValueError("Reduction strategy '{}' not implemented".format(reduction))

    return LogitsList(score=-1, logits=logits)

第三步

训练最终的分类器,使用无监督数据的 logits 进行训练,损失函数使用模型蒸馏用的 loss。

def sequence_classifier_train_step(self, batch: Dict[str, torch.Tensor], use_logits: bool = False,
                                   temperature: float = 1, **_) -> torch.Tensor:
    inputs = self.generate_default_inputs(batch)
    if not use_logits:
        inputs['labels'] = batch['labels']

    outputs = self.model(**inputs)

    if use_logits:
        logits_predicted, logits_target = outputs[0], batch['logits']
        return distillation_loss(logits_predicted, logits_target, temperature)
    else:
        return outputs[0]

蒸馏用的 loss,计算 kl 散度。

def distillation_loss(predictions, targets, temperature):
    """Compute the distillation loss (KL divergence between predictions and targets) as described in the PET paper"""
    p = F.log_softmax(predictions / temperature, dim=1)
    q = F.softmax(targets / temperature, dim=1)
    return F.kl_div(p, q, reduction='sum') * (temperature ** 2) / predictions.shape[0]

细节分析

将 mlm logits 转为 cls logits

在 prompt 里面的 mask 输出的 logits 转成 label 对应的 logits。首先 mask 输出的是一个 50265 维度(词表大小)的词向量,然后从这个向量选出标签下标对应的分量。

def convert_mlm_logits_to_cls_logits(self, mlm_labels: torch.Tensor, logits: torch.Tensor) -> torch.Tensor:
    masked_logits = logits[mlm_labels >= 0]
    cls_logits = torch.stack([self._convert_single_mlm_logits_to_cls_logits(ml) for ml in masked_logits])
    return cls_logits

def _convert_single_mlm_logits_to_cls_logits(self, logits: torch.Tensor) -> torch.Tensor:
    # m2c 是 verbalizer 对应的二维向量,每个点的默认值为 -1。
    # 第 (i, j) 个元素表示第 i 个类别对应的第 j 个 label,它的值为 tokenizer 对 label 分词得到的 id。
    m2c = self.mlm_logits_to_cls_logits_tensor.to(logits.device)
    # filler_len.shape() == max_fillers
    # filler_len 是为了处理 verbalizer 可能存在一个种类对应多个标签的情况。
    filler_len = torch.tensor([len(self.verbalize(label)) for label in self.wrapper.config.label_list],
                                dtype=torch.float)
    filler_len = filler_len.to(logits.device)

    # cls_logits.shape() == num_labels x max_fillers  (and 0 when there are not as many fillers).
    # 因为默认值是 -1,因此需要用 torch.max 过滤掉没有意义的点
    # 接着选出 verbalizer 对应的类别
    cls_logits = logits[torch.max(torch.zeros_like(m2c), m2c)]
    cls_logits = cls_logits * (m2c > 0).float()

    # cls_logits.shape() == num_labels
    cls_logits = cls_logits.sum(axis=1) / filler_len
    return cls_logits

关于 m2c 的更多解释:

# yahoo 的 verbalizer 定义了如下的映射
VERBALIZER = {
    "1": [" Society"],
    "2": [" Science"],
    "3": [" Health"],
    "4": [" Education"],
    "5": [" Computer"],
    "6": [" Sports"],
    "7": [" Business"],
    "8": [" Entertainment"],
    "9": [" Relationship"],
    "10": [" Politics"],
}

# m2c 可能会长成下面这个样子
# [[(" Society" 在词表中对应的位置 id)],
#  [(" Science" 在词表中对应的位置 id)],
#  ...
#  [(" Politics" 在词表中对应的位置 id)]]

# 于是我们就可以用以下这行代码选出需要的类别 logits
cls_logits = logits[torch.max(torch.zeros_like(m2c), m2c)]

mlm 是如何进行数据准备的?

MLM 只处理 15% 的数据,并且要去掉 special tokens;在这 15% 的数据当中,80% 使用 MASK 进行遮挡,10% 替换成随机字符,10% 保持不变。RobertaForMaskedLM 接受两个输入,一个是带有 mask 处理的 input_ids,一个是真实的 labels,然后输出的第一值是 MLM loss。

def _mask_tokens(self, input_ids):
    """ Prepare masked tokens inputs/labels for masked language modeling: 80% MASK, 10% random, 10% original. """
    labels = input_ids.clone()
    # We sample a few tokens in each sequence for masked-LM training (with probability 0.15)
    probability_matrix = torch.full(labels.shape, 0.15)
    special_tokens_mask = [self.tokenizer.get_special_tokens_mask(val, already_has_special_tokens=True) for val in
                            labels.tolist()]
    probability_matrix.masked_fill_(torch.tensor(special_tokens_mask, dtype=torch.bool), value=0.0)

    masked_indices = torch.bernoulli(probability_matrix).bool()

    # if a version of transformers < 2.4.0 is used, -1 is the expected value for indices to ignore
    if [int(v) for v in transformers_version.split('.')][:3] >= [2, 4, 0]:
        ignore_value = -100
    else:
        ignore_value = -1

    labels[~masked_indices] = ignore_value  # We only compute loss on masked tokens

    # 80% of the time, we replace masked input tokens with tokenizer.mask_token ([MASK])
    indices_replaced = torch.bernoulli(torch.full(labels.shape, 0.8)).bool() & masked_indices
    input_ids[indices_replaced] = self.tokenizer.convert_tokens_to_ids(self.tokenizer.mask_token)

    # 10% of the time, we replace masked input tokens with random word
    indices_random = torch.bernoulli(torch.full(labels.shape, 0.5)).bool() & masked_indices & ~indices_replaced
    random_words = torch.randint(len(self.tokenizer), labels.shape, dtype=torch.long)
    input_ids[indices_random] = random_words[indices_random]

    # The rest of the time (10% of the time) we keep the masked input tokens unchanged
    return input_ids, labels

prompt 是如何进行数据准备的?数据怎么流动?

原始数据集 -> 
生成数据集 -> 
获取输入特征(input_ids, attention_mask, token_type_ids, mlm_labels 等) ->
    -> BERT 编码 
        -> 截断到最大长度(设置原始文本为 shortenable,pattern 为 not shortenable)
输入到 BERT 中 -> 
根据 pattern 的 mlm_labels 提取出 logits -> 
计算 loss

iPET 模型

iPET 在 PET 的基础上,迭代训练 pattern 对应的模型。每次迭代训练的时候,会对训练集进行更新,使用无标注数据生成标签补充训练集数据。下一次迭代的时候,将会使用这些新的数据,再加上一开始的训练集。

数据集更新大致有如下流程:

  1. 更新大小,每次迭代更新数据量变大 5 倍。第一次的数据量是 40 个,第二次的数据量是 240 个,第三次的数据量是 1240 个。
  2. 选择新的数据,其他 pattern 模型保留 25%,对这些模型输出的 logits 进行加权求和,
  3. 使用 softmax 加上 argmax 生成标签,选出上述计算的样本容量大小,输出到文件里面。

在代码里面,假设有 6 个 pattern,p0, p1, .., p5。第一步是排除掉当前的 pattern,假设剩下 p1, p2, ..., p5。25% 的作用是用来挑选出 25% 的 pattern,向下取整,假设挑选出来的是 p2,然后使用挑选出来的 pattern 输出的 logits 进行加权求和(如果有多个),计算 softmax,然后挑选出补充集合大小的数量。

实验设置

论文作者在两篇文章的附录部分,附带了对实验具体配置的讨论。

超参数的设置

  • batch size,原作者应该用的是 4,累积 4 个 steps 进行一次反向传播。(实验过程中用 4 个样本,且不累加梯度,效果也可以。RoBERTa-Base)
  • maximum length,和 batch size 一起,刚好可以让模型塞满一张 1080 Ti 显卡。(代码中默认配置是 256,运行代码过程中可以观察到显存最高达到 11268 MiB)
  • learning rate,1e-5。作者发现 5e-5 学习率对监督学习的效果不够稳定。
  • 对于每个 PET 模型,training steps,250 steps。每个 PET 模型更新的 step 需要 4x4 个样本,1000 个样本需要跑 4 个 epochs,每个 epochs 运行 1000 / 16 = 62.5 个 steps。在数
    据比较充裕的情况下,建议使用 2 ~ 10 个 epochs。每个 step 用一个有标签数组计算交叉熵,三个无标签数据计算 MLM 任务。对于 x-stance 这个任务,需要先运行 3 个 epochs 进行初始化。
  • 对于最终的分类器,5000 steps。因此使用一共 20000 个无标注样本。
  • 蒸馏使用的 Temperature,2。
  • mlm 损失,作者观察到 mlm 损失比交叉熵损失大很多,因此要想办法混合两种损失,作者选用了几个超参数 [1e-3, 1e-4, 1e-5] 进行实验,使用 100 个例子,划分为训练集和验证集,然后找到最大的验证集准确率。
  • 每个 pattern 重复训练 3 次,最终模型集成的时候都会用上。(实验过程中,一开始嫌慢,直接只训练一个,集成起来效果也不错)
  • iPET 数据集大小,每个迭代 quintuple(五倍)数据量。
  • iPET 数据集创建,每次使用其他模型输出的全部数据的 25% 来作为候选集,然后从候选集再挑出固定样本大小。
posted @ 2022-12-10 10:29  楷哥  阅读(739)  评论(0编辑  收藏  举报