在自然语言处理(Natural Language Processing)中,任务很多种,大体可以分为以下几种:
而基于HuggingFace Transformer的问答形式分为两种:
抽取式问答: 采用纯Encoder架构(例如BERT),适用于处理事实性问题,例如“谁发明了Transformer架构?”,这些问题的答案通常包含在上下文中;
生成式问答: 采用Encoder-Decoder架构(例如T5、BART),适用于处理开放式问题,例如“天空为什么是蓝色的?”,这些问题的答案通常结合上下文语义再进行抽象表达;
注:项目代码在脚注里的超链接中均可找到,有包含PretrainedModelForQuestionAnswering + Trainer的代码,也有Bert + 自定义模型的代码
网上相关的公开数据集有很多,中文的数据集有CMRC2018
等,英文的数据集有SQUAD
等,数据集形式如下:
id: 用于标记文本唯一性
title: 文本所属的标题
context: 上下文句
question: 问题句
answer: 答案,其中包括:
answer_text: 答案文本内容
answer_start: 答案在context中的起始下标(char级别)
对于输入数据,采用的是“问题 + 上下文”
对的输入形式,两端和中间用特殊符号进行连接,例如:
[CLS]question[SEP]context[SEP]
处理train数据集的最终目标是根据给定的answer text和answer start返回answer token span,并把start和end token级别的下标分别保存在start_positions以及end_positions中。
一般模型可接受的最长输入为384,即question和context加起来最长不能超过384,这就会出现一个问题,即:如果长度超过最长长度怎么办?方法是在做tokenize的时候多加两个参数:truncation="only_second"和return_overflowing_tokens=True,完整的tokenize的函数如下:
batch_inputs = tokenizer(
batch_question,
batch_context,
max_length=384,
padding='max_length',
truncation="only_second",
stride=128,
return_overflowing_tokens=True,
return_offsets_mapping=True,
return_tensors="pt"
)
若输入的句子过长,例如question = 100,context = 500,则切完之后变为(其中stride表明每两句sample之间重合的token有多少个):
其中return_overflowing_tokens会多返回一个键值对,即overflow_to_sample_mapping,该key表明当前第[j]个sample是来自原句的第[i]句example,
例如overflow_to_sample_mapping = [0,0,1,2,3,3],即第[0]和[1]子句是来自原句的第[0]句,第[2]子句是来自原句第[1]句,第[3]子句是原句第[2]句,第[4]和[5]子句是来自原句的第[3]句,这么处理了之后会产生一个问题,原来的句子答案的下标answer_start
已经不准了,不准在于两点:
针对以上两点问题,我们要做以下处理,在做处理之前,要把question和context以batch的形式放入tokenizer中,这样得到最终的结果的个数要比之前多,因为有一些example被切成了更多的sample了,同时,若某一句的offset_mapping被切开了,则第二句是接续编号的,例如,
offset_1 [(0,0),(0,3),(3,5),(5,10),(10,12),(12,20),(0,0),(0,6),(6,9),(9,13),(13,15),(15,18),(18,20),(20,23),(23,25),(25,30),(30,34),(34,35),(35.38),(38.40)]
offset_2 [(0,0),(0,3),(3,5),(5,10),(10,12),(12,20),(0,0),(40,42),(42,45),(45,50),(50,51),(51,54),(54,60),(60,62),(62,64),(64,68),(68,70),(0,0),(0,0),(0,0),(0,0)]
故针对上面这种原句被切分成多个samples的情况,已知在原句的answer_char_start为[index]的情况,返回answer_token_start和answer_token_end,操作如下:
sample_mapping = batch_inputs.pop("overflow_to_sample_mapping")
example_id = sample_mapping[sample_idx]
answer_char_start = batch_answers[example_id]['answer_start'][0]
answer_char_end = answer_char_start + len(batch_answers[example_id]['text'][0])
sentence_type = batch_inputs.sequence_ids(sample_idx) # 找到第i句的句子类型,用answer_char_start和answer_char_end打标
context_token_start = 0
while sentence_type[context_token_start] != 1: # 当等于1时就可以停了
context_token_start += 1
context_token_end = context_token_start
while sentence_type[context_token_end] == 1:
context_token_end += 1
context_token_end -= 1
if answer_char_start < offset[context_token_start][0] or answer_char_end > offset[context_token_end][1]:
# 如果答案的char span不在context的span内的话:
start_positions.append(0)
end_positions.append(0)
else:
answer_token_start = context_token_start
answer_token_end = context_token_end
while offset[answer_token_start][0] < answer_char_start:
answer_token_start += 1
while offset[answer_token_end][1] > answer_char_end:
answer_token_end -= 1
start_positions.append(answer_token_start)
end_positions.append(answer_token_end)
下面处理validation的数据集,与处理train不同的是,validation不需要计算loss了,而是根据模型输出的结果与真实的标签计算F1值,我们知道,模型输出的结果是start_logit和end_logit,将其转换成start position
和end position
后,二者均是token级别的下标,如何映射回原句呢?
很容易想到,由answer token span通过当前sample的offset mapping可以得到answer char span,即offsets[answer_token_start][0]和offsets[answer_token_end][1],故validation data里要返回offset mapping字段;但还需要上下文内容context,才能映射回answer text,故还要返回标志文本唯一性的example id,这样知道得到的answer char span对应哪一句的context,故validation一共返回batch_inputs,offset_mapping和example_ids三个键值对,具体代码如下:
sample_idx = sample_mapping[i]
example_ids.append(batch_id[sample_idx]) # 看被切分后的句子原属于哪个QA对
sequence_ids = batch_inputs.sequence_ids(i) # 标志每句话每个token是属于哪部分,question或context或特殊token
offset = offset_mapping[i] # 若为被切分后的sample,context的offset_mapping是接着上一句连续编码的
offset_mapping[i] = [
o if sequence_ids[k] == 1 else None for k, o in enumerate(offset)
]
最终offset mapping返回的形式是:
[None,None,None,None,None,None,None,(0,6),(6,9),(9,13),(13,15),(15,18),(18,20),(20,23),(23,25),(25,30),(30,34),(34,35),(35.38),(38.40)]
1.可以使用现成的
from transformers import AutoModelForQuestionAnswering
模型返回两个结果:outputs.start_logits和outputs.end_logits
2.也可以自己定义model结构
class BertForExtractiveQA(BertPreTrainedModel):
def __init__(self, config):
super().__init__(config)
self.num_labels = config.num_labels # num_labels = 2
self.bert = BertModel(config, add_pooling_layer=False)
self.dropout = nn.Dropout(config.hidden_dropout_prob)
self.classifier = nn.Linear(config.hidden_size, config.num_labels)
self.post_init()
def forward(self, x):
bert_output = self.bert(**x)
sequence_output = bert_output.last_hidden_state
sequence_output = self.dropout(sequence_output)
logits = self.classifier(sequence_output)
start_logits, end_logits = logits.split(1, dim=-1)
start_logits = start_logits.squeeze(-1).contiguous()
end_logits = end_logits.squeeze(-1).contiguous()
return start_logits, end_logits
根据模型的输出start_logits和end_logits(二者的维度均为(len(dataset), seq_length), 例如(10784,384)),以及其他的一些条件,找到原句的answer_text,步骤如下:
example_id_to_sample_ids = defaultdict(list)
for sample_id, sample in enumerate(features['example_id']):
example_id_to_sample_ids[sample].append(sample_id)
例如 example_id_to_sample_ids =
{‘‘5733be284776f41900661182’’: [0], “5733be284776f4190066117f”: [1,2], “5733be284776f4190066117e”: [3], “5733bf84d058e614000b61be”: [4, 5]}
start_positions = np.argsort(start_logit)[-1: -n_best_size - 1: -1] # 返回概率最大的n_best个下标
end_positions = np.argsort(end_logit)[-1: -n_best_size - 1: -1]
valid_results = []
for answer_token_start in start_positions:
for answer_token_end in end_positions:
if offsets[answer_token_start] is None or offsets[answer_token_end] is None: # 如果预测成question的位置了,跳过
continue
if answer_token_start > answer_token_end: # 如果start在end右边,也跳过
continue
answer_char_start, answer_char_end = offsets[answer_token_start][0], offsets[answer_token_end][1]
valid_results.append({
"text": context[answer_char_start: answer_char_end],
"score": start_logit[answer_token_start] + end_logit[answer_token_end],
})
if len(valid_results) > 0:
best_answer = sorted(valid_results, key=lambda x: x['score'], reverse=True)[0]
else:
best_answer = {"text": "", "score": 0.0}
参考:
[1]. Hugging Face Course
[2]. 小昇的博客
[3]. datawhale github仓库