解鎖數據潛力:信息抽取、數據增強與UIE的完美融合
1.信息抽取(Information Extraction)
1.1 IE簡介
信息抽取是 NLP 任務中非常常見的一種任務,其目的在于從一段自然文本中提取出我們想要的關鍵信息結構。
舉例來講,現在有下面這樣一個句子:
新東方烹飪學校在成都。
我們想要提取這句話中所有有意義的詞語,例如:
機構 | 新東方烹飪學校 |
城市 | 成都 |
這個關鍵詞提取任務就叫做**命名實體識別(Named Entity Recognition, NER)**任務,文中的「新東方烹飪學校」和「成都」就被稱為實體(Entity)。
如果我們還想進一步的知道這些詞語之間的關系,例如:
實體 1 | 關系名 | 實體 2 |
---|---|---|
新東方烹飪學校 | 所在地 | 成都 |
這種提取實體之間關系的任務就叫做**關系抽取(Relation Extraction, RE)**任務。
1.2 信息抽取的幾種方法
1.2.1 序列標注(Sequence Labeling)
序列標注通常是指對文中的每一個字(以下簡稱 token)進行分類,即本質是 token classification 任務。
我們對第一小節中的例子做序列標注任務,得到的結果如下:
新 | 東 | ... | 學 | 校 | 在 | 成 | 都 |
---|---|---|---|---|---|---|---|
B - 機構 | I - 機構 | I - 機構 * N | I - 機構 | I - 機構 | O | B - 城市 | I - 城市 |
可以看到,我們對句子中的每一個字(token)都打上了一個類別標簽,我們期望模型要做的事就是去學會每一個字所屬的類別是什么。
Note: 這里用的標注方法是「BIO 標記法」,其中「B-」代表該位置 token 是某一個實體詞語(span)的起始 token;「I-」代表該位置 token 處于某一個詞語的中間(或結尾),「O」則代表該位置 token 不在任何一個實體詞語中。除了「BIO 標記法」外,還有許多其他的標注方式(如 BIOES 等),其本質思路都很類似。
1.2.2 指針網絡(Pointer Network)
序列標注模型有一個天然的缺陷,無法解決解決實體重疊(overlap)的問題。
舉例來講,如果今天我們不僅要提取「機構」,還同時要提取「機構類型」,那么我們期望的提取結果應該為:
機構 | 新東方烹飪學校 |
機構類型 | 學校 |
城市 | 成都 |
可以看到,對于「學校」這兩個字,即屬于「新東方烹飪學校」(機構)這個詞,也存在于「學校」(機構類型)這個詞,那我們在給這兩個字打標簽的時候,究竟應該打成哪個類別呢?
新 | 東 | … | 學 | 校 | 在 | 成 | 都 |
---|---|---|---|---|---|---|---|
B - 機構 | I - 機構 | I - 機構 | ? | ? | O | B - 城市 | I - 城市 |
由此我們可以看到,因為在進行分類時我們通常對一個字(token)只賦予一個標簽,這就導致了 token classification 不能很好的解決實體重疊(一字多標簽)的復雜情況。
Note: 存在一些技巧可以解決該問題,例如可以從單字單分類(CE)衍生到單字多分類(BCE),這里不展開討論。
指針網絡(Pointer Network)通過分別對每一個實體單獨做預測來解決了實體之前的重疊沖突問題。
例如,我們現在要同時預測「機構」和「機構類型」這兩個實體,那么我們就可以設計一個多頭網絡(Multi-Head)來分別預測這兩個實體的實體詞。
其中,
「機構」實體頭中「起始」向量代表這一句話中是「機構」詞語的首字(例子中為「新」);
「機構」實體中「結束」向量代表這一句話中時「機構」詞語的尾字(例子中為「校」)。
通過「起始」和「結束」向量中的首尾字索引就能找到對應實體的詞語。
可以看到,通過構建多頭的任務,指針網絡能夠分別預測「機構」和「機構類型」中的實體詞起始 / 終止位置,即「學校」這個詞語在兩個任務層中都能被抽取出來。
1.3. UIE —— 基于 prompt 的指針網絡
1.3.1 UIE 中的 prompt 是什么?
多頭指針網絡能夠很好的解決實體重疊問題,但缺點在于:不夠靈活。
假定今天我們已經通過指針網絡訓練好了一個提取「機構」、「機構類型」的模型,即將交付時甲方突然提出一個新需求:我們想再多提取一個「機構簡稱」的屬性。
草(一種植物)。
從 2.2 節中的示意圖中我們可以看到,每一個實體類型會對應一個單獨的網絡頭。
這就意味著我們不僅需要重標數據,還需要為新屬性添加一個新的網絡頭,即模型結構會隨著實體類型個數改變而發生變化。
那,能不能有一種辦法去固定住模型的結構,不管今天來多少種類型要識別都能使用同樣的模型結構完成呢?
我們思考一下,模型結構變化的部分是和實體類型強綁定的「頭」部分。
而不同「頭」之間結構其實是完全一樣的:一個「起始」向量 + 一個「終止」向量。
既然「頭」結構完全一樣,我們能不能干脆直接使用一個「頭」去提取不同實體類型的信息呢?
不同「頭」之間的區別在于它們關注的信息不同:「機構頭」只關注「機構」相關的實體詞,「城市頭」只關注「城市」相關的實體詞。
那么我們是不是可以直接在模型輸入的時候就告訴模型:我現在需要提取「某個頭」的信息。
這個用來告訴模型做具體任務的參數就叫 prompt,我們把它拼在輸入中一并喂給模型即可。
通過上圖可以看到,我們將不同的「實體類型」作為 prompt 參數喂給模型,用于「激活」模型參數跟當前「實體類型」相關的參數,從而輸出不同的抽取結果。
Note: 「通過一個輸入參數去激活一個大模型中的不同參數,從而完成不同任務的思路」并不是首次出現,在 meta-learning 中也存在相關的研究,這里的 prompt 參數和 meta-parameter 有著非常類似的思路。
通過引入 prompt,UIE 也能很方便的解決實體之間的關系抽取(Relation Extraction)任務,例如:
1.3.2 UIE 的實現
看完了基本思路,我們來一起看看 UIE 是怎么實現的吧。
- 模型部分
UIE 的模型代碼比較簡單,只需要在 encoder 后構建一個起始層和一個結束層即可:
class UIE(nn.Module):def __init__(self, encoder):"""init func.Args:encoder (transformers.AutoModel): backbone, 默認使用 ernie 3.0Reference:https://github.com/PaddlePaddle/PaddleNLP/blob/a12481fc3039fb45ea2dfac3ea43365a07fc4921/model_zoo/uie/model.py"""super().__init__()self.encoder = encoderhidden_size = 768self.linear_start = nn.Linear(hidden_size, 1)self.linear_end = nn.Linear(hidden_size, 1)self.sigmoid = nn.Sigmoid()def forward(self,input_ids: torch.tensor,token_type_ids: torch.tensor,attention_mask=None,pos_ids=None,) -> tuple:"""forward 函數,返回開始/結束概率向量。Args:input_ids (torch.tensor): (batch, seq_len)token_type_ids (torch.tensor): (batch, seq_len)attention_mask (torch.tensor): (batch, seq_len)pos_ids (torch.tensor): (batch, seq_len)Returns:tuple: start_prob -> (batch, seq_len)end_prob -> (batch, seq_len)"""sequence_output = self.encoder(input_ids=input_ids,token_type_ids=token_type_ids,position_ids=pos_ids,attention_mask=attention_mask,)["last_hidden_state"]start_logits = self.linear_start(sequence_output) # (batch, seq_len, 1)start_logits = torch.squeeze(start_logits, -1) # (batch, seq_len)start_prob = self.sigmoid(start_logits) # (batch, seq_len)end_logits = self.linear_end(sequence_output) # (batch, seq_len, 1)end_logits = torch.squeeze(end_logits, -1) # (batch, seq_len)end_prob = self.sigmoid(end_logits) # (batch, seq_len)return start_prob, end_prob
- ** 訓練部分**
訓練部分主要關注一下 loss 的計算即可。
由于每一個 token 都是一個二分類任務,因此選用 BCE Loss 作為損失函數。
分別計算起始 / 結束向量的 BCE Loss 再取平均值即可,如下所示:
criterion = torch.nn.BCELoss()
...start_prob, end_prob = model(input_ids=batch['input_ids'].to(args.device),token_type_ids=batch['token_type_ids'].to(args.device),attention_mask=batch['attention_mask'].to(args.device))
start_ids = batch['start_ids'].to(torch.float32).to(args.device) # (batch, seq_len)
end_ids = batch['end_ids'].to(torch.float32).to(args.device) # (batch, seq_len)
loss_start = criterion(start_prob, start_ids) # 起止向量loss -> (1,)
loss_end = criterion(end_prob, end_ids) # 結束向量loss -> (1,)
loss = (loss_start + loss_end) / 2.0 # 求平均 -> (1,)
loss.backward()
...
該項目將借用transformers庫來實現paddlenlp版本中UIE,已實現:
-
UIE 預訓練模型自動下載
-
UIE Fine-Tuning 腳本
-
信息抽取、事件抽取數據增強(DA)策略(提升 recall)
-
信息抽取、事件抽取自分析負例生成(Auto Neg)策略(提升 precision)
- 環境安裝
本項目基于 pytorch
+ transformers
實現,運行前請安裝相關依賴包:
pip install -r ../requirements.txttorch
transformers==4.22.1
datasets==2.4.0
evaluate==0.2.2
matplotlib==3.6.0
rich==12.5.1
scikit-learn==1.1.2
requests==2.28.1
2. 數據集準備
項目中提供了一部分示例數據,數據來自DuIE數據集中隨機抽取的100條,數據在 data/DuIE
。
若想使用自定義數據
訓練,只需要仿照示例數據構建數據集構建prompt和content即可:
{"content": "譚孝曾是譚元壽的長子,也是譚派第六代傳人", "result_list": [{"text": "譚元壽", "start": 4, "end": 7}], "prompt": "譚孝曾的父親"}
{"content": "在圣保羅書院中學畢業后,曾鈺成又在中學會考及大學入學考試中名列全港前十名", "result_list": [{"text": "曾鈺成", "start": 12, "end": 15}], "prompt": "人物"}
{"content": "在圣保羅書院中學畢業后,曾鈺成又在中學會考及大學入學考試中名列全港前十名", "result_list": [{"text": "圣保羅書院", "start": 1, "end": 6}], "prompt": "曾鈺成的畢業院校"}
...
doccano導出數據如下所示:
{"text": "譚孝曾是譚元壽的長子,也是譚派第六代傳人", "entities": [{"id": 42517, "label": "人物", "start_offset": 0, "end_offset": 3, "text": "譚孝曾"}, {"id": 42518, "label": "人物", "start_offset": 4, "end_offset": 7, "text": "譚元壽"}], "relations": [{"id": 0, "from_id": 42517, "to_id": 42518, "type": "父親"}]}
...
可以運行 doccano.py
來將標注數據(doccano)轉換為訓練數據(prompt)。
3. 模型訓練
修改訓練腳本 train.sh
里的對應參數, 開啟模型訓練:
python train.py \--pretrained_model "uie-base-zh" \--save_dir "checkpoints/DuIE" \--train_path "data/DuIE/train.txt" \--dev_path "data/DuIE/dev.txt" \--img_log_dir "logs/" \--img_log_name "UIE Base" \--batch_size 32 \--max_seq_len 256 \--learning_rate 5e-5 \--num_train_epochs 20 \--logging_steps 10 \--valid_steps 100 \--device cuda:0
正確開啟訓練后,終端會打印以下信息:
...0%| | 0/1 [00:00<?, ?ba/s]
100%|██████████| 1/1 [00:00<00:00, 6.91ba/s]
100%|██████████| 1/1 [00:00<00:00, 6.89ba/s]
global step 10, epoch: 1, loss: 0.00244, speed: 2.08 step/s
global step 20, epoch: 1, loss: 0.00228, speed: 2.17 step/s
global step 30, epoch: 1, loss: 0.00191, speed: 2.17 step/s
global step 40, epoch: 1, loss: 0.00168, speed: 2.14 step/s
global step 50, epoch: 1, loss: 0.00149, speed: 2.11 step/s
global step 60, epoch: 1, loss: 0.00138, speed: 2.15 step/s
global step 70, epoch: 2, loss: 0.00123, speed: 2.29 step/s
global step 80, epoch: 2, loss: 0.00112, speed: 2.12 step/s
global step 90, epoch: 2, loss: 0.00102, speed: 2.15 step/s
global step 100, epoch: 2, loss: 0.00096, speed: 2.15 step/s
Evaluation precision: 0.80851, recall: 0.84444, F1: 0.82609
best F1 performence has been updated: 0.00000 --> 0.82609
...
在 logs/UIE Base.png
文件中將會保存訓練曲線圖:
4. 模型預測
完成模型訓練后,運行 inference.py
以加載訓練好的模型并應用:
if __name__ == "__main__":from rich import printsentences = ['譚孝曾是譚元壽的長子,也是譚派第六代傳人。']# NER 示例for sentence in sentences:ner_example(model,tokenizer,device,sentence=sentence, schema=['人物'])# SPO 抽取示例for sentence in sentences:information_extract_example(model,tokenizer,device,sentence=sentence, schema={'人物': ['父親'],})
NER和事件抽取在schema的定義上存在一些區別:
-
NER的schema結構為
List
類型,列表中包含所有要提取的實體類型
。 -
信息抽取的schema結構為
Dict
類型,其中Key
的值是所有主語
,Value
對應該主語對應的所有屬性
。 -
事件抽取的schema結構為
Dict
類型,其中Key
的值是所有事件觸發詞
,Value
對應每一個觸發詞下的所有事件屬性
。
python inference.py
得到以下推理結果:
[+] NER Results:
{'人物': ['譚孝曾', '譚元壽']
}[+] Information-Extraction Results:
{'譚孝曾': {'父親': ['譚元壽']}, '譚元壽': {'父親': []}
}
5. 數據增強(Data Augmentation)
信息抽取/事件抽取的數據標注成本較高,因此我們提供幾種針對小樣本下的數據增強策略。
包括:
-
正例:SwapSPO、Mask Then Fill
-
負例:自分析負例生成(Auto Neg)
所有實現均在 Augmenter.py
中,為了便于使用,我們將其封裝為 web 服務以方便調用:
平臺使用 streamlit
搭建,因此使用前需要先安裝三方包:
pip install streamlit==1.17.0
隨后,運行以下命令開啟標注平臺:
streamlit run web_da.py --server.port 8904
在瀏覽器中訪問 ip + 端口(默認8904)即可打開標注平臺。
5.1 正例:SwapSPO 策略介紹
Swap SPO 是一種基于規則的簡單數據增強策略。
將同一數據集中相同 P 的句子分成一組,并隨機交換這些句子中的 S 和 O。
- 策略輸入:
《夜曲》 是 周杰倫 作曲 的一首歌。
《那些你很冒險的夢》 是當下非常火熱的一首歌,作曲 為 林俊杰。
- Swap SPO 后的輸出:
《夜曲》 是當下非常火熱的一首歌,作曲 為 周杰倫。
5.2 正例:Mask Then Fill 策略介紹
Mask Then Fill 是一種基于生成模型的信息抽取數據增強策略。
對于一段文本,我們其分為「關鍵信息段」和「非關鍵信息段」,包含關鍵詞片段稱為「關鍵信息段」。
下面例子中標粗的為 關鍵信息片段
,其余的為 非關鍵片段
。
大年三十 我從 北京 的大興機場 飛回 了 成都。
我們隨機 [MASK] 住一部分「非關鍵片段」,使其變為:
大年三十 我從 北京 [MASK] 飛回 了 成都。
隨后,將該句子喂給 filling 模型(T5-Fine Tuned)還原句子,得到新生成的句子:
大年三十 我從 北京 首都機場作為起點,飛回 了 成都。
Note:
filling 模型是一個生成模型,示例中我們使用中文 T5
微調得到 DuIE 數據集下的模型(暫未開源)。您可以參考 這里 微調一個更適合您自己數據集下的 filling 模型,并將訓練好的模型路徑填寫至 web_da.py
中對應的位置。
...
device = 'cpu' # 指定設備
generated_dataset_height = 800 # 生成樣本展示高度
max_show_num = 500 # 生成樣本最大保存行數
max_seq_len = 128 # 數據集單句最大長度
batch_size = 128 # 負例生成時的batch_sizefilling_model_path = '這里' # fine-tuned filling model
...
5.3 負例:自分析負例生成(Auto Neg)策略介紹
信息抽取中通常會存在 P混淆
的問題,例如:
王文銘,76歲,是西紅市多魚村的大爺。
當我們同時生成 年齡
和 去世年齡
這種非常近義的 prompt 進行抽取時,可能會出現 誤召回
的情況:
prompt: 王文銘的年齡 answer: 76歲 -> 正確
prompt: 王文銘的去世年齡 answer: 76歲 -> 錯誤
因此,我們基于一個已訓練好的模型,自動分析該模型在 訓練集
下存在哪些易混淆的 P,并為這些 P 自動生成負例,以提升模型的 Precision 指標。
將新生成的負例加入 原始訓練數據集
,重新訓練模型即可。
5.4 各種 DA 策略的實驗效果
在 DuIE 100 條數據下測試,各種 DA 策略的效果如下所示(以下 P / R / F1 均取 F1 最高的 Epoch 指標):
DA Policy | Precision(best) | Recall(best) | F1(best) |
---|---|---|---|
baseline | 0.8085 | 0.8444 | 0.8260 |
Swap SPO | 0.8409(↑) | 0.8222 | 0.8314(↑) |
Auto Neg | 0.8297(↑) | 0.8666(↑) | 0.8478(↑) |
Mask Then Fill | 0.9000(↑) | 1.0000(↑) | 0.9473(↑) |
Mask Then Fill & Auto Neg | 0.9777(↑) | 0.9777(↑) | 0.9777(↑) |
-
原作者實現地址:https://github.com/universal-ie/UIE
-
paddle官方:https://github.com/PaddlePaddle/PaddleNLP/tree/develop/model_zoo/uie
-
https://github.com/HarderThenHarder/transformers_tasks/blob/main/UIE
更多優質內容請關注公號:汀丶人工智能;會提供一些相關的資源和優質文章,免費獲取閱讀。