百度NLP賽事實(shí)踐解讀!
在開放領(lǐng)域的搜索場(chǎng)景下得到的網(wǎng)頁(yè)數(shù)據(jù)會(huì)非常復(fù)雜,其中往往存在著網(wǎng)頁(yè)文檔質(zhì)量參差不齊、長(zhǎng)短不一,問(wèn)題答案分布零散、長(zhǎng)度較長(zhǎng)等問(wèn)題,給答案抽取和答案置信度計(jì)算帶來(lái)了較大挑戰(zhàn)。
本文基于百度搜索技術(shù)創(chuàng)新挑戰(zhàn)賽中的搜索問(wèn)答賽題,對(duì)搜索問(wèn)答類任務(wù)做詳細(xì)解讀。本文思路如圖:

(本文實(shí)踐講解框架,其中改進(jìn)思路見文末)
賽題背景
本賽題希望調(diào)研真實(shí)網(wǎng)絡(luò)環(huán)境下的文檔級(jí)機(jī)器閱讀理解技術(shù),共分為兩個(gè)子任務(wù),涉及基于復(fù)雜網(wǎng)頁(yè)文檔內(nèi)容的答案抽取和答案檢驗(yàn)技術(shù)(詳細(xì)任務(wù)定義可參考賽事官網(wǎng))。
https://aistudio.baidu.com/aistudio/competition/detail/660/0/introduction
難點(diǎn)分析
如何在文檔長(zhǎng)度不定,答案長(zhǎng)度較長(zhǎng)的數(shù)據(jù)環(huán)境中取得良好且魯棒的答案抽取效果是子任務(wù)1關(guān)注的重點(diǎn)。
方案介紹
賽題可以視為基礎(chǔ)的信息抽取任務(wù),也可以直接視為問(wèn)答類型的信息抽取問(wèn)題。我們需要構(gòu)建一個(gè)模型,根據(jù)query從document中找到想要的答案。
思路一:BERT或ERNIE
如果我們使用BERT 或者 ERNIE 可以直接參考如下思路,模型的輸出可以為對(duì)應(yīng)的兩個(gè)位置,分別是回答的開始位置和結(jié)束位置。

這里需要深入一下模型的實(shí)現(xiàn)細(xì)節(jié):
query和documnet是一起輸入給模型,一般情況下query在前面。
回答對(duì)應(yīng)的輸出可以通過(guò)模型輸出后的全連接層完成分類,當(dāng)然回歸也可以。
思路二:QA
如果采用QA的思路,則需要將比賽數(shù)據(jù)集轉(zhuǎn)換為QA的格式,特別是文本的處理:長(zhǎng)文本需要進(jìn)行截?cái)唷?/span>
方案代碼
詳情可參考源Notebook:
https://aistudio.baidu.com/aistudio/projectdetail/5013840(一鍵運(yùn)行提交)
步驟1:解壓數(shù)據(jù)集
!pip install paddle-ernie > log.log
# !cp data/data174963/data_task1.tar /home/aistudio/
!tar -xf /home/aistudio/data_task1.tar
步驟2:讀取數(shù)據(jù)集
# 導(dǎo)入常見的庫(kù)
import numpy as np
import pandas as pd
import os, sys, json
# 讀取訓(xùn)練集、測(cè)試集和驗(yàn)證集
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)
# 查看數(shù)據(jù)集樣例
train_json.head(1)train_json.iloc[0]test_json.iloc[0]步驟3:加載ERNIE模型
這里我們使用paddlenlp==2.0.7,當(dāng)然你也可以選擇更高的版本。更高的版本會(huì)將損失計(jì)算也封裝進(jìn)去,其他的部分區(qū)別不大。
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')
# 對(duì)文檔的文檔進(jìn)行劃分、計(jì)算文檔的長(zhǎng)度
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] # 刪除了部分超長(zhǎng)文檔
# 對(duì)文檔的文檔進(jìn)行劃分、計(jì)算文檔的長(zhǎng)度
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] # 刪除了部分超長(zhǎng)文檔
# 對(duì)文檔的文檔進(jìn)行劃分、計(jì)算文檔的長(zhǎng)度
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]test_json.iloc[10]步驟4:構(gòu)建數(shù)據(jù)集
接下來(lái)需要構(gòu)建QA任務(wù)的數(shù)據(jù)集,這里的數(shù)據(jù)集需要處理為如下的格式:
query [SEP] sentence of document
訓(xùn)練集數(shù)據(jù)集處理
train_encoding = []
# for idx in range(len(train_json)):
for idx in range(10000):
# 讀取原始數(shù)據(jù)的一條樣本
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']
# 對(duì)于文章中的每個(gè)句子
for sentence in set(doc_sentence):
# 如果存在答案
for answer in answer_list:
answer = answer.strip("。")
# 如果問(wèn)題 + 答案 太長(zhǎng),跳過(guò)
if len(query + sentence) > 512:
continue
# 對(duì)問(wèn)題 + 答案進(jìn)行編碼
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)
# 如果答案在這個(gè)句子中,找到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)
# 如果不存在,則位置設(shè)置為0
else:
encoding['start_positions'] = 0
encoding['end_positions'] = 0
# 存儲(chǔ)正樣本
if encoding['start_positions'] != 0:
train_encoding.append(encoding)
# 對(duì)負(fù)樣本進(jìn)行采樣,因?yàn)樨?fù)樣本太多
# 正樣本:query + sentence -> answer 的情況
# 負(fù)樣本: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))
驗(yàn)證集數(shù)據(jù)集處理
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)
測(cè)試集數(shù)據(jù)集處理
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:批量數(shù)據(jù)讀取
# 手動(dòng)將數(shù)據(jù)集進(jìn)行批量打包
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:模型訓(xùn)練與驗(yàn)證
# 優(yōu)化器
optimizer = paddle.optimizer.SGD(0.0005, parameters=model.parameters())
# 損失函數(shù)
loss_fct = paddle.nn.CrossEntropyLoss()
best_val_start_acc = 0
for epoch in range(10):
# 每次打亂訓(xùn)練集,防止過(guò)擬合
np.random.shuffle(train_encoding)
# 訓(xùn)練部分
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'])
# 計(jì)算損失
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
# 參數(shù)更新
total_loss.backward()
train_loss.append(total_loss)
optimizer.step()
optimizer.clear_gradients()
# 驗(yàn)證部分
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'])
# 計(jì)算識(shí)別精度
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)
# 轉(zhuǎn)換數(shù)據(jù)格式為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()
# 存儲(chǔ)最優(yōu)模型
if val_start_acc > best_val_start_acc:
paddle.save(model.state_dict(), 'model.pkl')
best_val_start_acc = val_start_acc
# 每個(gè)epoch打印輸出結(jié)果
print(f'Epoch {epoch}, {train_loss:3f}, {val_start_acc:3f}/{val_end_acc:3f}')
# 關(guān)閉dropout
model = model.eval()
步驟7:模型預(yù)測(cè)
test_start_idx = []
test_end_idx = []
# 對(duì)測(cè)試集中query 和 sentence的情況進(jìn)行預(yù)測(cè)
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))
test_submit = [''] * len(test_json)
# 對(duì)預(yù)測(cè)結(jié)果進(jìn)行后處理
for (idx, query, sentence), st_idx, end_idx in zip(test_raw_txt, test_start_idx, test_end_idx):
# 如果start 或 end位置識(shí)別失敗,或 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]
# 生成提交結(jié)果
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')
改進(jìn)方向
從精度改變大小,可以從以下幾個(gè)角度改進(jìn):訓(xùn)練數(shù)據(jù) > 數(shù)據(jù)處理 > 模型與預(yù)訓(xùn)練 > 模型集成
訓(xùn)練數(shù)據(jù):使用全量的訓(xùn)練數(shù)據(jù) 數(shù)據(jù)處理:對(duì)文檔進(jìn)行切分,現(xiàn)在使用 。進(jìn)行切分,后續(xù)也可以嘗試其他。模型與預(yù)處理:嘗試ERNIE版本,或者進(jìn)行預(yù)訓(xùn)練。 模型集成: 嘗試不同的數(shù)據(jù)劃分得到不同的模型 嘗試不同的文本處理方法得到不同的模型
當(dāng)然也可以考慮其他數(shù)據(jù),如不同的網(wǎng)頁(yè)擁有答案的概率不同,以及從標(biāo)題可以判斷是否包含答案。
完整代碼也可以點(diǎn)擊左下角“原文鏈接”進(jìn)行查看。
整理不易,點(diǎn)贊三連↓
