百度搜索首届技术创新挑战赛:赛道一

百度搜索技术创新挑战赛(简称STI)是由百度搜索发起,联合四大区域高校、学会共同举办的一项全国性科技竞赛,共有两个赛道。本赛道希望从答案抽取和答案检验两个方面调研真实网络环境下的文档级机器阅读理解技术,以求进一步提升深度智能问答效果,给用户提供更好的搜索体验。

本次比赛共有两个赛道,分别为搜索问答和搜索模型推理优化。本赛道为搜索问答赛道。

赛题背景

近年来,随着机器阅读理解与深度预训练模型等相关技术的发展,抽取式智能问答系统的性能取得了非常明显的提升。然而,在开放领域的搜索场景下得到的网页数据会非常复杂,其中往往存在着网页文档质量参差不齐、长短不一,问题答案分布零散、长度较长等问题,给答案抽取和答案置信度计算带来了较大挑战。

本赛题希望从答案抽取和答案检验两个方面调研真实网络环境下的文档级机器阅读理解技术,以求进一步提升深度智能问答效果,给用户提供更好的搜索体验。

任务概述

本次任务共分为两个子任务,分别涉及基于复杂网页文档内容的答案抽取和答案检验技术,需全部完成。请用飞桨 AI Studio配置的NVIDIA A100完成参赛作品。

排名计算:选手根据提交要求将结果提交至AI Studio后,区域赛将基于两个任务的打榜结果加权平均选出前N名,无需评审。决赛将基于软件延展开发、技术深度、创新性打分和打榜结果最终确定获奖队伍,决赛将有专家评审。

任务概述

当前基于深度预训练模型的机器阅读理解方案在段落级智能问答任务上取得了非常好的性能,但在真实数据环境下的文档级阅读理解任务上的表现仍然难以令人满意。如何在文档长度不定,答案长度较长的数据环境中取得良好且鲁棒的答案抽取效果是子任务1关注的重点。
任务定义

给定一个用户搜索问题集合Q,基于每个搜索问题q,给定搜索引擎检索得到的网页文档集合Dq,其中包括最多40个网页文档。针对每个q-d对,要求参评系统从d中抽取能够回答q的答案片段a。同一文档中的答案可能为不连续的多个片段,文档中也可能不包含答案。

数据集

训练集包含约900个query、30000个query-document对;验证集和测试集各包含约100个 query,3000个query-document对。数据的主要特点为:

- 文档长度普遍较长,质量参差不齐,内部往往包含大量噪声
- 句子级别答案片段,通常由包含完整上下文的若干句子组成
- 标注数据只保证答案片段与搜索问题间的相关性,不保证正确性,且存在不包含答案的文档

数据样例

问题q:备孕偶尔喝冰的可以吗
篇章d:备孕能吃冷的食物吗 炎热的夏天让很多人都觉得闷热...,下面一起来看看吧! 备孕能吃冷的食物吗 在中医养生中,女性体质属阴,不可以贪凉。吃了过多寒凉、生冷的食物后,会消耗阳气,导致寒邪内生,侵害子宫。另外,宫寒是肾阳虚的表现,不会直接导致不孕。但宫寒会引起妇科疾病,所以也不可不防。因此处于备孕期的女性最好不要吃冷的食物。 备孕食谱有哪些 ...
答案a:在中医养生中,女性体质属阴,不可以贪凉。吃了过多寒凉、生冷的食物后,会消耗阳气,导致寒邪内生,侵害子宫。另外,宫寒是肾阳虚的表现,不会直接导致不孕。但宫寒会引起妇科疾病,所以也不可不防。因此处于备孕期的女性最好不要吃冷的食物。

评价指标

计算基于每个query-document对模型预测答案与标注答案间字粒度的准确、召回、F1值,取所有测试数据的平均F1作为最终指标。对于不包含答案的文档,其答案可看做一个特殊token【无答案】,若模型预测出答案,其F1为0,若模型预测【无答案】,其F1为1。

方案介绍

赛题可以视为基础的信息抽取任务,也可以直接视为问答类型的信息抽取问题。我们需要构建一个模型,根据query从document中找到想要的答案。、

如果我们使用BERT 或者 ERNIE 可以直接参考如下思路,对于模型的输出可以直接输出对应的两个位置,对应为回答的开始位置和结束位置。

在这里插入图片描述

这里需要深入一下模型的实现细节:

  • query和documnet是一起输入给模型,一般情况下query在前面。
  • 回答对应的输出可以通过模型输出后的全连接层完成分类,当然回归也可以。

如果采用QA的思路,则需要将比赛数据集转换为QA的格式,特别是文本的处理:长文本需要进行截断。

方案代码

步骤1:解压数据集

!pip install paddle-ernie > log.log
# !cp data/data174963/data_task1.tar /home/aistudio/
!tar -xf /home/aistudio/data_task1.tar

步骤2:读取数据集

# 导入常见的库
import numpy as np
import pandas as pd
import os, sys, json
# 读取训练集、测试集和验证集
train_json = pd.read_json('data_task1/train_data/train.json', lines=True)
test_json = pd.read_json('data_task1/test_data/test.json', lines=True)
dev_json = pd.read_json('data_task1/dev_data/dev.json', lines=True)
# 查看数据集样例
train_json.head(1)
answer_listtitleurlanswer_start_listdoc_textqueryorg_answer
0[渝北区,重庆市辖区,属重庆主城区、重庆大都市区,地处重庆市西北部。东邻长寿区、南与江北区毗...渝北区_百度百科https://baike.baidu.com/item/渝北区/2531151[4]渝北区 渝北区,重庆市辖区,属重庆主城区、重庆大都市区,地处重庆市西北部。东邻长寿区、南与江...渝北区面积渝北区,重庆市辖区,属重庆主城区、重庆大都市区,地处重庆市西北部。东邻长寿区、南与江北区毗邻...
train_json.iloc[0]
answer_list          [渝北区,重庆市辖区,属重庆主城区、重庆大都市区,地处重庆市西北部。东邻长寿区、南与江北区毗...
title                                                         渝北区_百度百科
url                           https://baike.baidu.com/item/渝北区/2531151
answer_start_list                                                  [4]
doc_text             渝北区 渝北区,重庆市辖区,属重庆主城区、重庆大都市区,地处重庆市西北部。东邻长寿区、南与江...
query                                                            渝北区面积
org_answer           渝北区,重庆市辖区,属重庆主城区、重庆大都市区,地处重庆市西北部。东邻长寿区、南与江北区毗邻...
Name: 0, dtype: object

这里的数据集为如下格式:

  • query:用户搜索query
  • title:网页标题
  • url:网页网页
  • doc_tet:网页文档正文
  • answer_list:文档中包含的答案,可能有多条不连续的答案,以列表存储
  • answer_start_list:答案开始的字符位置
  • org_answer:合并文档中所有答案,用于评测,无答案的为NoAnswer

训练集和验证集将包含上面所有字段,而测试集将只包含title、url、doc_text和query

test_json.iloc[0]
title                                Win11蓝牙鼠标连接不上电脑怎么办-路由器之家
url                         https://www.wanqh.com/176866.html
doc_text    Win11蓝牙鼠标连接不上电脑怎么办  路由器网投稿:文章是关于"Win11蓝牙鼠标连接不上...
query                                         连接不到外设是win11问题吗
Name: 0, dtype: object

步骤3:加载ERNIE模型

这里我们使用paddlenlp==2.0.7,当然你也可以选择更高的版本。更高的版本会将损失计算也封装进去,其他的部分区别不大。

import paddle
import paddlenlp
print('paddle version', paddle.__version__)
print('paddlenlp version', paddlenlp.__version__)

from paddlenlp.transformers import ErnieForQuestionAnswering, ErnieTokenizer
tokenizer = ErnieTokenizer.from_pretrained('ernie-1.0')
model = ErnieForQuestionAnswering.from_pretrained('ernie-1.0')
paddle version 2.1.2
paddlenlp version 2.0.0


[2022-11-13 16:42:34,151] [    INFO] - Already cached /home/aistudio/.paddlenlp/models/ernie-1.0/vocab.txt
[2022-11-13 16:42:34,163] [    INFO] - Already cached /home/aistudio/.paddlenlp/models/ernie-1.0/ernie_v1_chn_base.pdparams
W1113 16:42:34.166468  3756 device_context.cc:404] Please NOTE: device: 0, GPU Compute Capability: 8.0, Driver API Version: 11.2, Runtime API Version: 11.2
W1113 16:42:34.169667  3756 device_context.cc:422] device: 0, cuDNN Version: 8.2.
# 对文档的文档进行划分、计算文档的长度
train_json['doc_sentence'] = train_json['doc_text'].str.split('。')
train_json['doc_sentence_length'] = train_json['doc_sentence'].apply(lambda doc: [len(sentence) for sentence in doc])
train_json['doc_sentence_length_max'] = train_json['doc_sentence_length'].apply(max)
train_json = train_json[train_json['doc_sentence_length_max'] < 10000] # 删除了部分超长文档

# 对文档的文档进行划分、计算文档的长度
dev_json['doc_sentence'] = dev_json['doc_text'].str.split('。')
dev_json['doc_sentence_length'] = dev_json['doc_sentence'].apply(lambda doc: [len(sentence) for sentence in doc])
dev_json['doc_sentence_length_max'] = dev_json['doc_sentence_length'].apply(max)
dev_json = dev_json[dev_json['doc_sentence_length_max'] < 10000] # 删除了部分超长文档

# 对文档的文档进行划分、计算文档的长度
test_json['doc_sentence'] = test_json['doc_text'].str.split('。')
test_json['doc_sentence_length'] = test_json['doc_sentence'].apply(lambda doc: [len(sentence) for sentence in doc])
test_json['doc_sentence_length_max'] = test_json['doc_sentence_length'].apply(max)
train_json.iloc[10]
answer_list                [渝北区位于重庆市北部,长江北岸,嘉陵江下游东岸的三角地带,幅员面积1452平方公里,有耕地...
title                                                             重庆市渝北区地名介绍
url                           http://www.tcmap.com.cn/chongqing/yubeiqu.html
answer_start_list                                                      [788]
doc_text                   重庆市渝北区  [移动版]  2022年4月,渝北区被确定为“十四五”时期“无废城市”建设名...
query                                                                  渝北区面积
org_answer                 渝北区位于重庆市北部,长江北岸,嘉陵江下游东岸的三角地带,幅员面积1452平方公里,有耕地6...
doc_sentence               [重庆市渝北区  [移动版]  2022年4月,渝北区被确定为“十四五”时期“无废城市”建设...
doc_sentence_length        [47, 44, 78, 41, 46, 39, 46, 33, 31, 38, 46, 3...
doc_sentence_length_max                                                  418
Name: 10, dtype: object
test_json.iloc[10]
title                             Win11连接无线鼠标没反应什么原因_Win11连接无线鼠标没反应的解决技巧_U教授
url                             http://www.ujiaoshou.com/xtjc/154037266.html
doc_text                   Win11连接无线鼠标没反应什么原因 Win11连接无线鼠标没反应的解决技巧  电脑升级成w...
query                                                        连接不到外设是win11问题吗
doc_sentence               [Win11连接无线鼠标没反应什么原因 Win11连接无线鼠标没反应的解决技巧  电脑升级成...
doc_sentence_length        [87, 53, 67, 39, 42, 11, 38, 31, 19, 23, 38, 3...
doc_sentence_length_max                                                   87
Name: 10, dtype: object

步骤4:构建数据集

接下来需要构建QA任务的数据集,这里的数据集需要处理为如下的格式:

query [SEP] sentence of document
  • 训练集数据集处理
train_encoding = []

# for idx in range(len(train_json)):
for idx in range(5000):

    # 读取原始数据的一条样本
    title = train_json.iloc[idx]['title']
    answer_start_list = train_json.iloc[idx]['answer_start_list']
    answer_list = train_json.iloc[idx]['answer_list']
    doc_text = train_json.iloc[idx]['doc_text']
    query = train_json.iloc[idx]['query']
    doc_sentence = train_json.iloc[idx]['doc_sentence']
    
    #  对于文章中的每个句子
    for sentence in set(doc_sentence):

        # 如果存在答案
        for answer in answer_list:
            answer = answer.strip("。")
            
            # 如果问题 + 答案 太长,跳过
            if len(query + sentence) > 512:
                continue
            
            # 对问题 + 答案进行编码
            encoding = tokenizer.encode(query, sentence, max_seq_len=512, return_length=True, 
                return_position_ids=True, pad_to_max_seq_len=True, return_attention_mask=True)
            
            # 如果答案在这个句子中,找到start 和 end的 位置
            if answer in sentence:            
                encoding['start_positions'] = len(query) + 2 + sentence.index(answer)
                encoding['end_positions'] = len(query) + 2 + sentence.index(answer) + len(answer)

            # 如果不存在,则位置设置为0
            else:
                encoding['start_positions'] = 0
                encoding['end_positions'] = 0
            
            # 存储正样本
            if encoding['start_positions'] != 0:
                train_encoding.append(encoding)
            
            # 对负样本进行采样,因为负样本太多
            # 正样本:query + sentence -> answer 的情况
            # 负样本:query + sentence -> No answer 的情况
            if encoding['start_positions'] == 0 and np.random.randint(0, 100) > 99:
                train_encoding.append(encoding)
    
    if len(train_encoding) % 500 == 0:
        print(len(train_encoding))
0
500
1000
2000
2500
  • 验证集数据集处理
val_encoding = []

for idx in range(len(dev_json)):
# for idx in range(200):
    title = dev_json.iloc[idx]['title']
    answer_start_list = dev_json.iloc[idx]['answer_start_list']
    answer_list = dev_json.iloc[idx]['answer_list']
    doc_text = dev_json.iloc[idx]['doc_text']
    query = dev_json.iloc[idx]['query']
    doc_sentence = dev_json.iloc[idx]['doc_sentence']

    for sentence in set(doc_sentence):
        for answer in answer_list:
            answer = answer.strip("。")

            if len(query + sentence) > 512:
                continue
            
            encoding = tokenizer.encode(query, sentence, max_seq_len=512, return_length=True, 
                return_position_ids=True, pad_to_max_seq_len=True, return_attention_mask=True)

            if answer in sentence:            
                encoding['start_positions'] = len(query) + 2 + sentence.index(answer)
                encoding['end_positions'] = len(query) + 2 + sentence.index(answer) + len(answer)
            else:
                encoding['start_positions'] = 0
                encoding['end_positions'] = 0
            
            if encoding['start_positions'] != 0:
                val_encoding.append(encoding)

            if encoding['start_positions'] == 0 and np.random.randint(0, 100) > 99:
                val_encoding.append(encoding)
  • 测试集数据集处理
test_encoding = []
test_raw_txt = []
for idx in range(len(test_json)):
    title = test_json.iloc[idx]['title']
    doc_text = test_json.iloc[idx]['doc_text']
    query = test_json.iloc[idx]['query']
    doc_sentence = test_json.iloc[idx]['doc_sentence']

    for sentence in set(doc_sentence):
        if len(query + sentence) > 512:
            continue
        
        encoding = tokenizer.encode(query, sentence, max_seq_len=512, return_length=True, 
            return_position_ids=True, pad_to_max_seq_len=True, return_attention_mask=True)

        test_encoding.append(encoding)
        test_raw_txt.append(
            [idx, query, sentence]
        )

步骤5:批量数据读取

# 手动将数据集进行批量打包
def data_generator(data_encoding, batch_size = 6):
    for idx in range(len(data_encoding) // batch_size):
        batch_data = data_encoding[idx * batch_size : (idx+1) * batch_size]
        batch_encoding = {}
        for key in batch_data[0].keys():
            if key == 'seq_len':
                continue
            batch_encoding[key] = paddle.to_tensor(np.array([x[key] for x in batch_data]))
        
        yield batch_encoding

步骤6:模型训练与验证

# 优化器
optimizer = paddle.optimizer.SGD(0.0005, parameters=model.parameters())

# 损失函数
loss_fct = paddle.nn.CrossEntropyLoss()
best_val_start_acc = 0

for epoch in range(10):
    # 每次打乱训练集,防止过拟合
    np.random.shuffle(train_encoding)
    
    # 训练部分
    train_loss = []
    for batch_encoding in data_generator(train_encoding, 10):

        # ERNIE正向传播
        start_logits, end_logits = model(batch_encoding['input_ids'], batch_encoding['token_type_ids'])

        # 计算损失
        start_loss = loss_fct(start_logits, batch_encoding['start_positions'])
        end_loss = loss_fct(end_logits, batch_encoding['end_positions'])
        total_loss = (start_loss + end_loss) / 2
        
        # 参数更新
        total_loss.backward()
        train_loss.append(total_loss)
        optimizer.step()
        optimizer.clear_gradients()
    
    # 验证部分
    val_start_acc = []
    val_end_acc = []
    with paddle.no_grad():
        for batch_encoding in data_generator(val_encoding, 10):

            # ERNIE正向传播
            start_logits, end_logits = model(batch_encoding['input_ids'], batch_encoding['token_type_ids'])

            # 计算识别精度
            start_acc = paddle.mean((start_logits.argmax(1) == batch_encoding['start_positions']).astype(float))
            end_acc = paddle.mean((end_logits.argmax(1) == batch_encoding['end_positions']).astype(float))

            val_start_acc.append(start_acc)
            val_end_acc.append(end_acc)

    # 转换数据格式为float
    train_loss = paddle.to_tensor(train_loss).mean().item()
    val_start_acc = paddle.to_tensor(val_start_acc).mean().item()
    val_end_acc = paddle.to_tensor(val_end_acc).mean().item()
    
    # 存储最优模型
    if val_start_acc > best_val_start_acc:
        paddle.save(model.state_dict(), 'model.pkl')
        best_val_start_acc = val_start_acc
    
    # 每个epoch打印输出结果
    print(f'Epoch {epoch}, {train_loss:3f}, {val_start_acc:3f}/{val_end_acc:3f}')
Epoch 0, 5.377475, 0.119880/0.051807
Epoch 1, 4.731729, 0.209639/0.050000
Epoch 2, 4.284176, 0.227711/0.058434
Epoch 3, 4.038408, 0.254819/0.069277
Epoch 4, 3.891453, 0.255422/0.064458
Epoch 5, 3.787116, 0.260241/0.078916
Epoch 6, 3.690544, 0.265663/0.080723
Epoch 7, 3.621104, 0.260241/0.074096
Epoch 8, 3.552423, 0.256024/0.081325
Epoch 9, 3.477381, 0.256024/0.083133
# 关闭dropout
model.eval()

步骤7:模型预测

test_start_idx = []
test_end_idx = []

# 对测试集中query 和 sentence的情况进行预测
with paddle.no_grad():
    for batch_encoding in data_generator(test_encoding, 12):
        start_logits, end_logits = model(batch_encoding['input_ids'], batch_encoding['token_type_ids'])
        
        test_start_idx += start_logits.argmax(1).tolist()
        test_end_idx += end_logits.argmax(1).tolist()
        
        if len(test_start_idx) % 500 == 0:
            print(len(test_start_idx), len(test_encoding))
1500 61835
3000 61835
4500 61835
6000 61835
7500 61835
9000 61835
10500 61835
12000 61835
13500 61835
15000 61835
16500 61835
18000 61835
19500 61835
21000 61835
22500 61835
24000 61835
25500 61835
27000 61835
28500 61835
30000 61835
31500 61835
33000 61835
34500 61835
36000 61835
37500 61835
39000 61835
40500 61835
42000 61835
43500 61835
45000 61835
46500 61835
48000 61835
49500 61835
51000 61835
52500 61835
54000 61835
55500 61835
57000 61835
58500 61835
60000 61835
61500 61835
test_submit = [''] * len(test_json)

# 对预测结果进行后处理
for (idx, query, sentence), st_idx, end_idx in zip(test_raw_txt, test_start_idx, test_end_idx):

    # 如果start 或 end位置识别失败,或 start位置 晚于 end位置
    if st_idx == 0 or end_idx == 0 or st_idx >= end_idx:
        continue
    
    # 如果start位置在query部分
    if st_idx - len(query) - 2 < 0:
        continue
    
    test_submit[idx] += sentence[st_idx - len(query) - 2: end_idx - len(query) - 2]
# 生成提交结果
with open('subtask1_test_pred.txt', 'w') as up:
    for x in test_submit:
        if x == '':
            up.write('1\tNoAnswer\n')
        else:
            up.write('1\t'+x+'\n')

方案总结

  1. 借助QA的思路,本代码可以使用ERNIE快速完成模型训练与提交。
  2. 本思路可以在测试集上进行预测,预测步骤需要11分钟。
  3. 模型优化器和学习率已经调节多次,后续也可以自己调节。

改进方向

从精度改变大小,可以从以下几个角度改进:训练数据 > 数据处理 > 模型与预训练 > 模型集成

  • 训练数据:使用全量的训练数据
  • 数据处理:对文档进行切分,现在使用进行切分,后续也可以尝试其他。
  • 模型与预处理:尝试ERNIE版本,或者进行预训练。
  • 模型集成:
    • 尝试不同的数据划分得到不同的模型
    • 尝试不同的文本处理方法得到不同的模型

当然也可以考虑其他数据,如不同的网页拥有答案的概率不同,以及从标题可以判断是否包含答案。


此文章为搬运
原项目链接

Logo

学大模型,用大模型上飞桨星河社区!每天8点V100G算力免费领!免费领取ERNIE 4.0 100w Token >>>

更多推荐