NLP-情感分析 Prompting
**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 正常工作。
浙公網安備 33010602011771號