**NLP-情感分析 Prompting **
注:本文是 Transformers 快速入门 Prompting 章节的学习笔记,更详细的分析请参见原文。
写在前面
Github 地址:https://github.com/Lockegogo/NLP_Tasks/tree/main/text_cls_prompt_senti
本项目使用 Prompting 方法完成情感分析任务。Prompting 方法的核心思想是借助模板将问题转换为与预训练任务类似的形式来处理。
例如要判断标题 “American Duo Wins Opening Beach Volleyball Match” 的新闻类别,使用 Prompting 的步骤为:
- 构建 Prompt 模板:“This is a [MASK] News: x”
- 应用模板:“This is a [MASK] News: American Duo Wins Opening Beach Volleyball Match”
- 模型预测:从模型的输出序列中抽取出 [MASK] token 对应的表示,然后运用 MLM head 预测 [MASK] token 对应词表中每个 token 的分数(logits),我们只返回类别单词对应位置的分数用于分类。
数据预处理
这里我们选择中文情感分析语料库 ChnSentiCorp 作为数据集,其包含各类网络评论接近一万条,可以从本仓库下载。
语料已经划分好了训练集、验证集、测试集(分别包含 9600、1200、1200 条评论),一行是一个样本,使用 TAB
分隔评论和对应的标签,“0” 表示消极,“1” 表示积极。
最常见的 Prompting 方法就是借助模板将问题转换为 MLM 任务来解决。这里我们定义模板形式为 "总体上来说很 [MASK]。{x}",其中 x 表示评论文本,并且规定如果 [MASK] 被预测为 “好” 就判定情感为 “积极”,如果预测为 “差” 就判定为 “消极”,即 “积极” 和 “消极” 标签对应的 label word 分别为 “好” 和 “差”。
可以看到,MLM 任务与序列标注任务很相似,也是对 token 进行分类,并且类别是整个词表,不同之处在于 MLM 任务只需要对文中特殊的 [MASK] token 进行标注,因此在处理数据时我们需要:
- 记录下模板中所有 [MASK] 的位置,以便在模型的输出序列中将它们的表示取出
- 记录下 label word 对应的 token ID,因为我们实际上只关心模型在这些词语上的预测结果
首先我们编写模板和 verbalizer 对应的函数:
def get_prompt(x):
prompt = f'总体上来说很[MASK]。{x}'
return {
'prompt': prompt,
'mask_offset': prompt.find('[MASK]')
}
def get_verbalizer(tokenizer):
return {
'pos': {'token': '好', 'id': tokenizer.convert_tokens_to_ids("好")},
'neg': {'token': '差', 'id': tokenizer.convert_tokens_to_ids("差")}
}
例如,第一个样本转换后的模板为:
from transformers import AutoTokenizer
checkpoint = "bert-base-chinese"
tokenizer = AutoTokenizer.from_pretrained(checkpoint)
comment = '这个宾馆比较陈旧了,特价的房间也很一般。总体来说一般。'
print('verbalizer:', get_verbalizer(tokenizer))
prompt_data = get_prompt(comment)
prompt, mask_offset = prompt_data['prompt'], prompt_data['mask_offset']
encoding = tokenizer(prompt, truncation=True)
tokens = encoding.tokens()
# 将 [MASK] 从原来句子中的位置映射到 encoding 之后的位置
mask_idx = encoding.char_to_token(mask_offset)
print('prompt:', prompt)
print('prompt tokens:', tokens)
print('mask idx:', mask_idx)
输出如下:
verbalizer: {'pos': {'token': '好', 'id': 1962}, 'neg': {'token': '差', 'id': 2345}}
prompt: 总体上来说很[MASK]。这个宾馆比较陈旧了,特价的房间也很一般。总体来说一般。
prompt tokens: ['[CLS]', '总', '体', '上', '来', '说', '很', '[MASK]', '。', '这', '个', '宾', '馆', '比', '较', '陈', '旧', '了', ',', '特', '价', '的', '房', '间', '也', '很', '一', '般', '。', '总', '体', '来', '说', '一', '般', '。', '[SEP]']
mask idx: 7
可以看到 BERT 分词器正确地将 “[MASK]” 识别为一个 token,并且记录下 [MASK] token 在序列中的索引。
但是这种做法要求我们能够从词表中找到合适的 label word 来代表每一个类别,并且 label word 只能包含一个 token,而很多时候这是无法实现的。因此,另一种常见做法是为每个类别构建一个可学习的虚拟 token,然后运用类别描述来初始化虚拟 token 的表示,最后使用这些虚拟 token 来扩展模型的 MLM 头。
例如,这里我们可以为 “积极” 和 “消极” 构建专门的虚拟 token “[POS]” 和 “[NEG]”,并且设置对应的类别描述为 “好的、优秀的、正面的评价、积极的态度” 和 “差的、糟糕的、负面的评价、消极的态度”。下面我们扩展一下上面的 verbalizer 函数,添加一个 vtype
参数来区分两种 verbalizer 类型:
def get_verbalizer(tokenizer, vtype):
assert vtype in ['base', 'virtual']
return {
'pos': {'token': '好', 'id': tokenizer.convert_tokens_to_ids("好")},
'neg': {'token': '差', 'id': tokenizer.convert_tokens_to_ids("差")}
} if vtype == 'base' else {
'pos': {
'token': '[POS]', 'id': tokenizer.convert_tokens_to_ids("[POS]"),
'description': '好的、优秀的、正面的评价、积极的态度'
},
'neg': {
'token': '[NEG]', 'id': tokenizer.convert_tokens_to_ids("[NEG]"),
'description': '差的、糟糕的、负面的评价、消极的态度'
}
}
vtype = 'virtual'
# add label words
if vtype == 'virtual':
# 将新添加的 token 添加进模型的词表
tokenizer.add_special_tokens({'additional_special_tokens': ['[POS]', '[NEG]']})
print('verbalizer:', get_verbalizer(tokenizer, vtype=vtype))
训练模型
对于 MLM 任务,可以直接使用 Transformers 库封装好的 AutoModelForMaskedLM
类。由于 BERT 已经在 MLM 任务上进行了预训练,因此借助模板我们甚至可以在不微调的情况下 (Zero-shot) 直接使用模板来预测情感极性。例如我们的第一个样本:
import torch
from transformers import AutoModelForMaskedLM
checkpoint = "bert-base-chinese"
model = AutoModelForMaskedLM.from_pretrained(checkpoint)
text = "总体上来说很[MASK]。这个宾馆比较陈旧了,特价的房间也很一般。总体来说一般。"
inputs = tokenizer(text, return_tensors="pt")
token_logits = model(**inputs).logits
# Find the location of [MASK] and extract its logits
mask_token_index = torch.where(inputs["input_ids"] == tokenizer.mask_token_id)[1]
mask_token_logits = token_logits[0, mask_token_index, :]
# Pick the [MASK] candidates with the highest logits
top_5_tokens = torch.topk(mask_token_logits, 5, dim=1).indices[0].tolist()
for token in top_5_tokens:
print(f"'>>> {text.replace(tokenizer.mask_token, tokenizer.decode([token]))}'")
'>>> 总体上来说很好。这个宾馆比较陈旧了,特价的房间也很一般。总体来说一般。'
'>>> 总体上来说很棒。这个宾馆比较陈旧了,特价的房间也很一般。总体来说一般。'
'>>> 总体上来说很差。这个宾馆比较陈旧了,特价的房间也很一般。总体来说一般。'
'>>> 总体上来说很般。这个宾馆比较陈旧了,特价的房间也很一般。总体来说一般。'
'>>> 总体上来说很赞。这个宾馆比较陈旧了,特价的房间也很一般。总体来说一般。'
但是这种方法不够灵活,我们还是采用继承 Transformers 库预训练模型的方式手工构建模型,结构如下:
Using cpu device
initialize embeddings of [POS] and [NEG]
BertForPrompt(
(bert): BertModel()
(cls): BertOnlyMLMHead(
(predictions): BertLMPredictionHead(
(transform): BertPredictionHeadTransform(
(dense): Linear(in_features=768, out_features=768, bias=True)
(transform_act_fn): GELUActivation()
(LayerNorm): LayerNorm((768,), eps=1e-12, elementwise_affine=True)
)
(decoder): Linear(in_features=768, out_features=21128, bias=True)
)
)
)
如果采用虚拟 label word,我们除了向模型词表中添加 “[POS]” 和 “[NEG]” token 以外,还按照我们在 verbalizer 中设置的描述来初始化这两个 token 的嵌入。
- 用分词器将描述文本转换为对应的 token 列表 $t_1, t_2, ..., t_n $
- 然后初始化对应的表示为这些 token 嵌入的平均 $\frac{1}{n} \sum_{i=1}^n \boldsymbol{E}\left(t_i\right) $
if args.vtype == "virtual":
sp_tokens = ["[POS]", "[NEG]"]
tokenizer.add_special_tokens({"additional_special_tokens": sp_tokens})
model.resize_token_embeddings(len(tokenizer))
verbalizer = get_verbalizer(tokenizer, vtype=args.vtype)
with torch.no_grad():
pos_id, neg_id = verbalizer["pos"]["id"], verbalizer["neg"]["id"]
pos_tokenized = tokenizer(verbalizer["pos"]["description"])
pos_tokenized_ids = tokenizer.convert_tokens_to_ids(pos_tokenized)
neg_tokenized = tokenizer(verbalizer["neg"]["description"])
neg_tokenized_ids = tokenizer.convert_tokens_to_ids(neg_tokenized)
new_embedding = model.bert.embeddings.word_embeddings.weight[
pos_tokenized_ids
].mean(axis=0)
model.bert.embeddings.word_embeddings.weight[pos_id, :] = (
new_embedding.clone().detach().requires_grad_(True)
)
new_embedding = model.bert.embeddings.word_embeddings.weight[
neg_tokenized_ids
].mean(axis=0)
model.bert.embeddings.word_embeddings.weight[neg_id, :] = (
new_embedding.clone().detach().requires_grad_(True)
注意,向模型词表中添加 token 包含两个步骤:
- 通过
tokenizer.add_special_tokens()
向分词器中添加 token,这样分词器就能在分词时将这些词分为独立的 token - 通过
model.resize_token_embeddings()
扩展模型的词表大小
与之前相比,本次我们构建的 BertForPrompt 模型中增加了两个特殊的函数:get_output_embeddings()
和 set_output_embeddings()
,负责调整模型的 MLM head。
class BertForPrompt(BertPreTrainedModel):
def __init__(self, config):
super().__init__(config)
self.bert = BertModel(config, add_pooling_layer=False)
self.cls = BertOnlyMLMHead(config)
# Initialize weights and apply final processing
self.post_init()
def get_output_embeddings(self):
return self.cls.predictions.decoder
def set_output_embeddings(self, new_embeddings):
self.cls.predictions.decoder = new_embeddings
def forward(self, batch_inputs, batch_mask_idxs, label_word_id, labels=None):
bert_output = self.bert(**batch_inputs)
sequence_output = bert_output.last_hidden_state
batch_mask_reps = batched_index_select(
sequence_output, 1, batch_mask_idxs.unsqueeze(-1)
).squeeze(1)
pred_scores = self.cls(batch_mask_reps)[:, label_word_id]
loss = None
if labels is not None:
loss_fn = nn.CrossEntropyLoss()
loss = loss_fn(pred_scores, labels)
return loss, pred_scores
如果删除这两个函数,那么在调用 model.resize_token_embeddings()
时,就仅仅会调整模型词表的大小,而不会调整 MLM head,即运行上面的代码输出的张量维度依然是 21128。如果你不需要预测新添加 token 在 mask 位置的概率,那么即使删除这两个函数,代码也能正常运行,但是对于本文这种需要预测的情况就不行了。
为了让模型适配我们的任务,这里首先通过 batched_index_select
函数从 BERT 的输出序列中抽取出 [MASK] token 对应的表示,在运用 MLM head 预测出该 [MASK] token 对应词表中每个 token 的分数之后,我们只返回类别对应 label words 的分数用于分类。
模型对每个样本都应该输出 “消极” 和 “积极” 两个类别对应 label word 的预测 logits 值。
需要注意的是,如果采用虚拟 label word,模型是无法直接进行预测的。在扩展了词表之后,MLM head 的参数矩阵尺寸也会进行调整,新加入的参数都是随机初始化的,此时必须进行微调才能让 MLM head 正常工作。