CAIL2021-阅读理解任务-top3-数据预处理模块


代码地址:https://github.com/davidfan1224/CAIL2021_Multi-span_MRC

class SquadExample(object):
    """
    A single training/test example for the Squad dataset.
    For examples without an answer, the start and end position are -1.
    """

    def __init__(self,
                 qas_id,
                 question_text,
                 doc_tokens,
                 orig_answer_text=None,
                 start_position=None,
                 end_position=None,
                 is_impossible=None,
                 is_yes=None,
                 is_no=None,
                 labels=None):
        self.qas_id = qas_id
        self.question_text = question_text
        self.doc_tokens = doc_tokens
        self.orig_answer_text = orig_answer_text
        self.start_position = start_position
        self.end_position = end_position
        self.is_impossible = is_impossible
        self.is_yes = is_yes
        self.is_no = is_no
        self.labels = labels

    def __str__(self):
        return self.__repr__()

    # def __repr__(self):
    #     s = ""
    #     s += "qas_id: %s" % (self.qas_id) + '\n'
    #     s += ", question_text: %s" % (
    #         self.question_text) + '\n'
    #     s += ", doc_tokens: [%s]" % (" ".join(self.doc_tokens)) + '\n'
    #     if self.start_position:
    #         s += ", start_position: %s" % (str(self.start_position)) + '\n'
    #     if self.end_position:
    #         s += ", end_position: %s" % (str(self.end_position)) + '\n'
    #     if self.is_impossible:
    #         s += ", is_impossible: %r" % (self.is_impossible) + '\n'
    #     return s

    def __repr__(self):
        string = ""
        for key, value in self.__dict__.items():
            string += f"{key}: {value}\n"
        return f"<{string}>"

def read_squad_examples(input_file, is_training, version_2_with_negative):
    """Read a SQuAD json file into a list of SquadExample."""
    with open(input_file, "r", encoding='utf-8') as reader:
        input_data = json.load(reader)["data"]

    def is_whitespace(c):
        if c == " " or c == "\t" or c == "\r" or c == "\n" or ord(c) == 0x202F:
            return True
        return False

    examples = []
    change_ques_num = 0
    for entry in tqdm(input_data, desc='convert examples:'):
        for paragraph in entry["paragraphs"]:
            paragraph_text = paragraph["context"]

            doc_tokens = []
            char_to_word_offset = []

            # 这里直接是按照字符进行划分的
            for c in paragraph_text:
                doc_tokens.append(c)
                char_to_word_offset.append(len(doc_tokens) - 1)

            for qa in paragraph["qas"]:
                qas_id = qa["id"]
                question_text = qa["question"]
                if question_text != qa['question']:
                    change_ques_num += 1
                start_position = None
                end_position = None
                orig_answer_text = None
                # 多答案:
                start_positions = []
                end_positions = []
                orig_answer_texts = []

                is_impossible = False
                is_yes = False
                is_no = False
                labels = ['O'] * len(doc_tokens)
                if is_training:
                    if version_2_with_negative:
                        if qa['is_impossible'] == 'false':
                            is_impossible = False
                        else:
                            is_impossible = True
                    # 下面这一行将不坑回答的问题进行过滤掉了
                    if (len(qa["answers"]) != 1) and (not is_impossible):
                        continue

                    if not is_impossible:
                        all_answers = qa["answers"]
                        if len(all_answers) == 0:
                            answers = []
                        else:
                            answers = all_answers[0]
                        if type(answers) == dict:
                            answers = [answers]

                        for answer in answers:
                            orig_answer_text = answer["text"]
                            answer_offset = answer["answer_start"]
                            answer_length = len(orig_answer_text)
                            start_position = char_to_word_offset[answer_offset]
                            end_position = char_to_word_offset[answer_offset + answer_length - 1]
                            # Only add answers where the text can be exactly recovered from the
                            # document. If this CAN'T happen it's likely due to weird Unicode
                            # stuff so we will just skip the example.
                            #
                            # Note that this means for training mode, every example is NOT
                            # guaranteed to be preserved.
                            actual_text = "".join(doc_tokens[start_position:(end_position + 1)])

                            cleaned_answer_text = " ".join(
                                whitespace_tokenize(orig_answer_text))

                            if actual_text.find(cleaned_answer_text) == -1:

                                if cleaned_answer_text == 'YES':
                                    is_yes = True
                                    orig_answer_text = 'YES'
                                    start_position = -1
                                    end_position = -1

                                    labels = ['O'] * len(doc_tokens)

                                    start_positions.append(start_position)
                                    end_positions.append(end_position)
                                    orig_answer_texts.append(orig_answer_text)
                                elif cleaned_answer_text == 'NO':
                                    is_no = True
                                    start_position = -1
                                    end_position = -1
                                    orig_answer_text = 'NO'

                                    labels = ['O'] * len(doc_tokens)

                                    start_positions.append(start_position)
                                    end_positions.append(end_position)
                                    orig_answer_texts.append(orig_answer_text)
                                else:
                                    logger.warning("Could not find answer: '%s' vs. '%s'",
                                                   actual_text, cleaned_answer_text)
                                    continue
                            else:
                                start_positions.append(start_position)
                                end_positions.append(end_position)
                                orig_answer_texts.append(orig_answer_text)
                                start_index = answer['answer_start']
                                end_index = start_index + len(answer['text'])
                                labels[start_index: end_index] = ['I'] * (len(answer['text']))  # 是答案的用I标记,否则用O标记
                    else:
                        start_position = -1
                        end_position = -1
                        orig_answer_text = ""

                        start_positions.append(start_position)
                        end_positions.append(end_position)
                        orig_answer_texts.append(orig_answer_text)
                        labels = ['O'] * len(doc_tokens)


                example = SquadExample(
                    qas_id=qas_id,
                    question_text=question_text,
                    doc_tokens=doc_tokens,
                    orig_answer_text=orig_answer_texts,
                    start_position=start_positions,
                    end_position=end_positions,
                    is_impossible=is_impossible,
                    is_yes=is_yes,
                    is_no=is_no,
                    labels=labels)
                examples.append(example)

    return examples

if __name__ == '__main__':
    data_file = 'data_sample/cail2021_mrc_small.json'
    examples = read_squad_examples(data_file, is_training=True, version_2_with_negative=True)
    print(examples[0])

这里需要注意的有两点:

  • 这里最初的切分是以字符进行切分的。
  • 有一个labels,这里和文本的长度是一致的,以序列标注的IO格式进行标注,如果是YES和NO类的,那么labels里面就全部是O。

接着就是将每一个example转换为feature了:

class InputFeatures(object):
    """A single set of features of data."""

    def __init__(self,
                 unique_id,
                 example_index,
                 doc_span_index,
                 tokens,
                 token_to_orig_map,
                 token_is_max_context,
                 input_ids,
                 input_mask,
                 segment_ids,
                 paragraph_len,
                 label_id=None,
                 start_position=None,
                 end_position=None,
                 is_impossible=None,
                 unk_mask=None,
                 yes_mask=None,
                 no_mask=None,
                 answer_mask=None,
                 answer_num=None
                 ):
        self.unique_id = unique_id
        self.example_index = example_index
        self.doc_span_index = doc_span_index
        self.tokens = tokens
        self.token_to_orig_map = token_to_orig_map
        self.token_is_max_context = token_is_max_context
        self.input_ids = input_ids
        self.input_mask = input_mask
        self.segment_ids = segment_ids
        self.paragraph_len = paragraph_len
        self.label_id = label_id
        self.start_position = start_position
        self.end_position = end_position
        self.is_impossible = is_impossible
        self.unk_mask = unk_mask
        self.yes_mask = yes_mask
        self.no_mask = no_mask
        self.answer_mask = answer_mask
        self.answer_num = answer_num

    def __repr__(self):
        string = ""
        for key, value in self.__dict__.items():
            string += f"{key}: {value}\n"
        return f"<{string}>"

def convert_examples_to_features(examples, tokenizer, max_seq_length,
                                 doc_stride, max_query_length, is_training, max_n_answers=1):
    """Loads a data file into a list of `InputBatch`s."""

    unique_id = 1000000000

    features = []
    unk_tokens = {}

    label_map = {'O': 0, 'I': 1}

    convert_token_list = {
        '“': '"', "”": '"', '…': '...', '﹤': '<', '﹥': '>', '‘': "'", '’': "'",
        '﹪': '%', 'Ⅹ': 'x', '―': '-', '—': '-', '﹟': '#', '㈠': '一'
    }
    example_index = 0
    for example in tqdm(examples, desc='convert_examples_to_features:'):
        # 对问题进行分词
        query_tokens = tokenizer.tokenize(example.question_text)
        # 如果问题的最大长度超过了设置的则截断
        if len(query_tokens) > max_query_length:
            query_tokens = query_tokens[0:max_query_length]

        labellist = example.labels

        tok_to_orig_index = []
        orig_to_tok_index = []
        all_doc_tokens = []
        for (i, token) in enumerate(example.doc_tokens):
            orig_to_tok_index.append(len(all_doc_tokens))
            sub_tokens = tokenizer.tokenize(token)
            if "[UNK]" in sub_tokens:
                if token in unk_tokens:
                    unk_tokens[token] += 1
                else:
                    unk_tokens[token] = 1

            for sub_token in sub_tokens:
                tok_to_orig_index.append(i)
                all_doc_tokens.append(sub_token)

        tok_start_position = None
        tok_end_position = None
        # multi answers
        tok_start_positions = []
        tok_end_positions = []

        if is_training and example.is_impossible:
            tok_start_position = -1
            tok_end_position = -1

            tok_start_positions.append(-1)
            tok_end_positions.append(-1)

        if is_training and not example.is_impossible:
            for orig_answer_text, start_position, end_position in zip(example.orig_answer_text, example.start_position, example.end_position):

                tok_start_position = orig_to_tok_index[start_position]
                if end_position < len(example.doc_tokens) - 1:
                    tok_end_position = orig_to_tok_index[end_position + 1] - 1
                else:
                    tok_end_position = len(all_doc_tokens) - 1
                (tok_start_position, tok_end_position) = _improve_answer_span(
                    all_doc_tokens, tok_start_position, tok_end_position, tokenizer,
                    orig_answer_text)

                tok_start_positions.append(tok_start_position)
                tok_end_positions.append(tok_end_position)

        # The -3 accounts for [CLS], [SEP] and [SEP]
        max_tokens_for_doc = max_seq_length - len(query_tokens) - 3

        # We can have documents that are longer than the maximum sequence length.
        # To deal with this we do a sliding window approach, where we take chunks
        # of the up to our max length with a stride of `doc_stride`.
        _DocSpan = collections.namedtuple(  # pylint: disable=invalid-name
            "DocSpan", ["start", "length"])
        doc_spans = []
        start_offset = 0
        while start_offset < len(all_doc_tokens):
            length = len(all_doc_tokens) - start_offset
            if length > max_tokens_for_doc:
                length = max_tokens_for_doc
            doc_spans.append(_DocSpan(start=start_offset, length=length))
            if start_offset + length == len(all_doc_tokens):
                break
            start_offset += min(length, doc_stride)

            while start_offset < len(all_doc_tokens) and all_doc_tokens[start_offset - 1] != "," and all_doc_tokens[start_offset - 1] != "。":   # 滑窗 保留完整一句话 此处是英文的,
                start_offset += 1

        for (doc_span_index, doc_span) in enumerate(doc_spans):
            tokens = []
            token_to_orig_map = {}
            token_is_max_context = {}
            segment_ids = []
            doc_span_labels = []
            label_ids = []
            tokens.append("[CLS]")
            segment_ids.append(0)
            label_ids.append(label_map["O"])

            for token in query_tokens:
                tokens.append(token)
                segment_ids.append(0)
                label_ids.append(label_map["O"])
            tokens.append("[SEP]")
            segment_ids.append(0)
            label_ids.append(label_map["O"])

            for i in range(doc_span.length):
                split_token_index = doc_span.start + i
                doc_span_labels.append(labellist[split_token_index])
            for i in range(doc_span.length):
                split_token_index = doc_span.start + i
                token_to_orig_map[len(tokens)] = tok_to_orig_index[split_token_index]

                is_max_context = _check_is_max_context(doc_spans, doc_span_index,
                                                       split_token_index)
                token_is_max_context[len(tokens)] = is_max_context
                tokens.append(all_doc_tokens[split_token_index])
                segment_ids.append(1)
                label_ids.append(label_map[doc_span_labels[i]])
            paragraph_len = doc_span.length

            tokens.append("[SEP]")
            segment_ids.append(1)
            label_ids.append(label_map["O"])

            input_ids = tokenizer.convert_tokens_to_ids(tokens)

            # The mask has 1 for real tokens and 0 for padding tokens. Only real
            # tokens are attended to.
            input_mask = [1] * len(input_ids)

            # Zero-pad up to the sequence length.
            while len(input_ids) < max_seq_length:
                input_ids.append(0)
                input_mask.append(0)
                segment_ids.append(0)
                label_ids.append(label_map["O"])

            assert len(input_ids) == max_seq_length
            assert len(input_mask) == max_seq_length
            assert len(segment_ids) == max_seq_length

            span_is_impossible = example.is_impossible

            start_position = None
            end_position = None
            # multi answers
            start_positions = []
            end_positions = []

            if is_training and not example.is_impossible:
                doc_start = doc_span.start
                doc_end = doc_span.start + doc_span.length - 1

                for tok_start_position, tok_end_position in zip(tok_start_positions, tok_end_positions):  # 多个答案 计算各自起始位置
                    out_of_span = False
                    if not (tok_start_position >= doc_start and
                            tok_end_position <= doc_end):
                        out_of_span = True
                    if out_of_span:
                        start_position = max_seq_length
                        end_position = max_seq_length
                    else:
                        doc_offset = len(query_tokens) + 2
                        start_position = tok_start_position - doc_start + doc_offset
                        end_position = tok_end_position - doc_start + doc_offset

                    start_positions.append(start_position)
                    end_positions.append(end_position)

            unk_mask, yes_mask, no_mask = [0], [0], [0]
            if is_training and example.is_impossible:   # no answer
                start_position = max_seq_length
                end_position = max_seq_length
                unk_mask = [1]

                if start_positions:
                    start_positions.clear()
                    end_positions.clear()

                    start_positions.append(start_position)
                    end_positions.append(end_position)
                else:
                    start_positions.append(start_position)
                    end_positions.append(end_position)

            elif is_training and example.is_yes:   # YES
                start_position = max_seq_length+1
                end_position = max_seq_length+1
                yes_mask = [1]

                if start_positions:
                    start_positions.clear()
                    end_positions.clear()

                    start_positions.append(start_position)
                    end_positions.append(end_position)
                else:
                    start_positions.append(start_position)
                    end_positions.append(end_position)

            elif is_training and example.is_no:   # NO
                start_position = max_seq_length+2
                end_position = max_seq_length+2
                no_mask = [1]

                if start_positions:
                    start_positions.clear()
                    end_positions.clear()

                    start_positions.append(start_position)
                    end_positions.append(end_position)
                else:
                    start_positions.append(start_position)
                    end_positions.append(end_position)

            # 如果答案列表长度 大于设置的阈值最大答案数量 则随机选择
            if len(start_positions) > max_n_answers:
                idxs = np.random.choice(len(start_positions), max_n_answers, replace=False)
                st = []
                en = []
                for idx in idxs:
                    st.append(start_positions[idx])
                    en.append(end_positions[idx])
                start_positions = st
                end_positions = en

            answer_num = len(start_positions) if not example.is_impossible else 0

            answer_mask = [1 for _ in range(len(start_positions))]
            for _ in range(max_n_answers - len(start_positions)):
                start_positions.append(0)
                end_positions.append(0)
                answer_mask.append(0)



            features.append(
                InputFeatures(
                    unique_id=unique_id,
                    example_index=example_index,
                    doc_span_index=doc_span_index,
                    tokens=tokens,
                    token_to_orig_map=token_to_orig_map,
                    token_is_max_context=token_is_max_context,
                    input_ids=input_ids,
                    input_mask=input_mask,
                    segment_ids=segment_ids,
                    paragraph_len=paragraph_len,
                    start_position=start_positions,
                    end_position=end_positions,
                    is_impossible=example.is_impossible,
                    unk_mask=unk_mask,
                    yes_mask=yes_mask,
                    no_mask=no_mask,
                    answer_mask=answer_mask,
                    answer_num=answer_num,
                    label_id=label_ids
                ))
            unique_id += 1
        example_index += 1
    if is_training:
        with open("unk_tokens_clean", "w", encoding="utf-8") as fh:
            for key, value in unk_tokens.items():
                fh.write(key+" " + str(value)+"\n")

    return features

tokenizer = BertTokenizer.from_pretrained('model_hub/chinese-bert-wwm-ext/')
features, output_examples = convert_examples_to_features(examples, tokenizer, 512, 128, 64, True, max_n_answers=3)
for i, (example, feature) in enumerate(zip(output_examples, features)):
    if i < 10:
        print(example)
        print(feature)
        print("="*100)

结果:



====================================================================================================


====================================================================================================


====================================================================================================


====================================================================================================

可以看到,会对太长的句子进行切分。
我们看看一些特殊的情况:



对于不能够回答的问题,会将start_position和end_position里设置为512,并且answer_mask设置为[1,0,0],answer_num为0。
对于是YES/NO类型的问题:

start_position: [513, 0, 0]
end_position: [513, 0, 0]
is_impossible: False
unk_mask: [0]
yes_mask: [1]
no_mask: [0]
answer_mask: [1, 0, 0]
answer_num: 1
>

对应的是start_position、end_position里513或者514。