NLP transformer抽取式问答项目详解

Introduction

在自然语言处理(Natural Language Processing)中,任务很多种,大体可以分为以下几种:

  1. 句子级别分类任务,例如情感分类任务,检测电子邮件是否为垃圾邮件任务等;
  2. 单词级别的分类任务,例如命名实体识别(Named Entity Recognition, NER),词性标注(Part-of-Speech tagging, POS);
  3. 文本生成任务,包括根据提示prompt生成内容,以及完形填空等;
  4. 抽取式问答任务,给定一个问题和上下文,根据上下文中提供的信息提取问题的答案;
  5. 基于给定句子生成新句子任务,例如翻译任务,summary任务;

而基于HuggingFace Transformer的问答形式分为两种:

抽取式问答: 采用纯Encoder架构(例如BERT),适用于处理事实性问题,例如“谁发明了Transformer架构?”,这些问题的答案通常包含在上下文中;
生成式问答: 采用Encoder-Decoder架构(例如T5、BART),适用于处理开放式问题,例如“天空为什么是蓝色的?”,这些问题的答案通常结合上下文语义再进行抽象表达;

注:项目代码在脚注里的超链接中均可找到,有包含PretrainedModelForQuestionAnswering + Trainer的代码,也有Bert + 自定义模型的代码

Dataset

网上相关的公开数据集有很多,中文的数据集有CMRC2018等,英文的数据集有SQUAD等,数据集形式如下:

id: 用于标记文本唯一性
title: 文本所属的标题
context: 上下文句
question: 问题句
answer: 答案,其中包括:
	answer_text: 答案文本内容
	answer_start: 答案在context中的起始下标(char级别)

Preprocess train data

对于输入数据,采用的是“问题 + 上下文”对的输入形式,两端和中间用特殊符号进行连接,例如:

[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有多少个):

  1. 100 + [0:284],
  2. 100 + [156:440],
  3. 100 + [312:596] (最后96个token是加了padding的)

其中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已经不准了,不准在于两点:

  1. 当把context拼到question后面时,原来的answer_start位置就不对了,需要加上len(question)的长度;
  2. 若把一句example切成两句sample后,答案只可能出现在其中一个sample里了,另外一句中就没有答案了;

针对以上两点问题,我们要做以下处理,在做处理之前,要把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,操作如下:

  1. 根据answer char start以及answer text计算出answer char end的位置,方法为先根据sample_id以及sample mapping找到当前sample属于原句第几句,并拿到对应的answer,根据answer char start以及answer text计算出answer char 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])
  1. 找到上下文的token_span,方法是根据当前sample的sequence_ids(形如[None,0,0,0,0,0,None,1,1,1,1,1,1,1,1,1,1,None]),先找context_token_start,while循环,当不为1时就继续往前,直到遇到1位置;context_token_end从context_token_start开始,当为1时就继续往前,直到不为1,此时context_token_end走过了1位,往回退一位;
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
  1. 利用上面两项的结果找answer token span,遍历当前offsets,用answer_token_start和answer_token_end固定context token span,分别往里收缩,直到找到合适的answer_token_span为止;
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)
  1. 最后,返回被tokenize后的batch_inputs以及start_positions和end_positions即可

Preprocess validation data

下面处理validation的数据集,与处理train不同的是,validation不需要计算loss了,而是根据模型输出的结果与真实的标签计算F1值,我们知道,模型输出的结果是start_logit和end_logit,将其转换成start positionend 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三个键值对,具体代码如下:

  1. 返回example_ids(后处理时要特殊处理下)
sample_idx = sample_mapping[i]
example_ids.append(batch_id[sample_idx])  # 看被切分后的句子原属于哪个QA对
  1. 返回offset_mapping,要特殊处理下,将question部分置None,防止模型预测到question位置了
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)]

Model

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

Prediction

根据模型的输出start_logits和end_logits(二者的维度均为(len(dataset), seq_length), 例如(10784,384)),以及其他的一些条件,找到原句的answer_text,步骤如下:

  1. 根据之前保存的example_ids返回一个键值对,key为example_ids,value为对应的sample_ids
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]}

  1. 依次遍历每个example,拿出其对应的上下文context,以及对应的所有sample_ids,开始遍历每个sample_id;
  2. 拿到该条sample_id对应的两个预测结果start_logit和end_logit,以及offsets,用numpy返回最大概率的前n_bests个下标:
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]
  1. 依次遍历每对start_position和end_position,看它们对应的文本内容,以及对应的score
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],
        })
  1. 若valid_results不为空,则按照score排序,拿到得分最大的,当作当前sample的预测结果,若为空,则返回空字符串
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仓库

本图文内容来源于网友网络收集整理提供,作为学习参考使用,版权属于原作者。
THE END
分享
二维码
< <上一篇
下一篇>>