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

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

這里需要深入一下模型的實現細節(jié):
query和documnet是一起輸入給模型,一般情況下query在前面。
回答對應的輸出可以通過模型輸出后的全連接層完成分類,當然回歸也可以。
思路二:QA
如果采用QA的思路,則需要將比賽數據集轉換為QA的格式,特別是文本的處理:長文本需要進行截斷。
方案代碼
詳情可參考源Notebook:
https://aistudio.baidu.com/aistudio/projectdetail/5013840(一鍵運行提交)
步驟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)train_json.iloc[0]test_json.iloc[0]步驟3:加載ERNIE模型
這里我們使用paddlenlp==2.0.7,當然你也可以選擇更高的版本。更高的版本會將損失計算也封裝進去,其他的部分區(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')
# 對文檔的文檔進行劃分、計算文檔的長度
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]test_json.iloc[10]步驟4:構建數據集
接下來需要構建QA任務的數據集,這里的數據集需要處理為如下的格式:
query [SEP] sentence of document
訓練集數據集處理
train_encoding = []
# for idx in range(len(train_json)):
for idx in range(10000):
# 讀取原始數據的一條樣本
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))
驗證集數據集處理
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:模型訓練與驗證
# 優(yōu)化器
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()
# 存儲最優(yōu)模型
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}')
# 關閉dropout
model = 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))
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')
改進方向
從精度改變大小,可以從以下幾個角度改進:訓練數據 > 數據處理 > 模型與預訓練 > 模型集成
訓練數據:使用全量的訓練數據 數據處理:對文檔進行切分,現在使用 。進行切分,后續(xù)也可以嘗試其他。模型與預處理:嘗試ERNIE版本,或者進行預訓練。 模型集成: 嘗試不同的數據劃分得到不同的模型 嘗試不同的文本處理方法得到不同的模型
當然也可以考慮其他數據,如不同的網頁擁有答案的概率不同,以及從標題可以判斷是否包含答案。
完整代碼也可以點擊左下角“原文鏈接”進行查看。
整理不易,點贊三連↓
