Pytorch——XLNet 預訓練模型及命名實體識別


介紹

在之前我們介紹和使用了 BERT 預訓練模型和 GPT-2 預訓練模型,分別進行了文本分類和文本生成次。我們將介紹 XLNet 預訓練模型,並使用其進行命名實體識別次。

知識點

  • XLNet 在 BERT 和 GPT-2 上的改進
  • XLNet 模型結構
  • 使用 XLNet 進行命名實體識別次

谷歌的團隊繼 BERT 模型之后,在 2019 年中旬又 提出了 XLNet 模型。XLNet 在多達 20 個任務上均取得了超越 BERT 的成績,還在如問答系統、自然語言推理、情感分析、文本排序等任務上超過了目前的最好成績。

下面是 XLNet 在 GLUE 上的測試結果:

Model MNLI QNLI QQP RTE SST-2 MRPC CoLA STS-B
BERT-Large 86.6 92.3 91.3 70.4 93.2 88.0 60.6 90.0
XLNet-Base 86.8 91.7 91.4 74.0 94.7 88.2 60.2 89.5
XLNet-Large 89.8 93.9 91.8 83.8 95.6 89.2 63.6 91.8

XLNet 在 BERT 和 GPT-2 上的改進

BERT 的缺點

可以說 XLNet 是 BERT 的增強版,但它與 BERT 又有許多不同之處。下面,我們將詳細介紹一下。

在第一次次提到的,BERT 是自編碼模型(Autoencoding),換一種說法來說就是,BERT 以遮蔽語言模型(Masked Language Model)為訓練目標。訓練自回歸模型時,輸入語句的一些詞會被隨機替換成 [MASK] 標簽,然后訓練模型預測被標簽掩蓋的詞。

我們可以從這個過程中看到兩個缺點:

  1. 錯誤地假設了被覆蓋詞與被覆蓋詞之間是獨立的。
  2. 使預訓練和微調時的輸入不統一。

缺點 1 是指,在進行預測時,由於部分詞被 [MASK] 覆蓋,所以當 BERT 模型在預測一個被覆蓋詞時,忽略了其他的覆蓋詞對他的影響。也就是假設了所有被覆蓋詞是不相關的,很明顯可以知道這個假設是錯誤的。

缺點 2 是指,在預訓練是我們使用了 [MASK] 標簽把部分詞覆蓋,而在微調預訓練好的模型時,我們並不會使用到這個標簽,這就導致了預訓練過程和微調過程不符。

GPT-2 的缺點

在上一次次我們介紹了,GPT-2 是自回歸模型(Autoregressive),即通過待預測詞之前或之后的文本作為語境信息進行預測。

從數學上來說,在一個文本序列 x = (x_1, ..., x_T)x=(x1,...,x**T) 中,自回歸模型會計算待預測詞的前方乘積 \(p(x) = \prod^{T}*{t=1}p(x_t|x*{<t}),其中,其中x_{<t}表示表示x_t之前的詞。同理,預測詞的后方乘積可以表示為:之前的詞。同理,預測詞的后方乘積可以表示為:p(x) = \prod^{T}*{t=1}p(x_t|x*{>t})\).

但是自回歸模型的一個很明顯的缺點就是,它只能考慮一個單方向的語境。很多時候的下游任務,如自然語言理解,會同時需要前后兩個方向的語境信息。

介紹完 BERT 所代表的自編碼模型和 GPT-2 所代表的自回歸模型的固有缺點,下面就要介紹一下 XLNet 是如何針對這兩類模型的缺點進行的改進。

XLNet 的改進

研究人員在設計 XLNet 模型時,考慮到要克服自編碼模型和自回歸模型的缺點,並且要結合它們的優勢,設計出了排列語言模型(Permutation Lanuage Model)。如下圖所示,排列語言模型的思想是,既然自回歸語言模型只能獲取單向語境,那就通過改變字詞排列位置的方法將雙向的語句排列到單向。

img

來源

以圖中右上角的部分為例,當字詞排列變為 "2 -> 4 -> 3 -> 1",並且訓練目標為預測第 3 個字詞時,輸入模型的為詞 2 和詞 4 的信息。這樣模型即保留了自回歸模型的特點,又使得模型學習到了雙向的語境信息。

需要注意,改變排列不會實際改變字詞的位置信息,即排在第三位的詞的位置向量還是會對應該詞是第三位的信息。

在具體實現時,通過對注意力機制的掩膜的作用之一就是進行改變來達到改變排列的目的,可以參照下圖紅框部分,

img

來源

此圖的其他部分含義在下文會介紹,這里先說一下紅框的部分。以上半部分的第二行為例,表示當對單詞 2 之后的詞進行預測時,對每個單詞添加的掩膜,並且此時的排列順序為 "3 -> 2 -> 4 -> 1"。所以,這時添加到單詞 2 和 3 的掩膜值應為 1 ,表示模型可以看到的詞,而其余詞的值應為 0。同時我們也可以看到圖中第二行中第 2 和 3 列對應的掩膜都被標成了紅色。上半部分的其他行同理。

而圖中下半部分的不同是,下半部分添加的掩膜可以使得模型看不到每個待預測的詞,這樣當模型預測單詞 2 時就不會產生模型已經見過單詞 2 的問題。

XLNet 模型結構

SentencePiece 分詞方法

XLNet 模型使用了 SentencePiece 的分詞方法,SentencePiece 是谷歌開源的自然語言處理工具包。它的原理是統計出現次數多的片段,則認為該片段是一個詞。

SentencePiece 工具特別之處在於,它不依賴於之前的訓練,而是通過從給定的訓練集中學習,並且它不會因為語言不同而有不一樣的表現,因為它把所有字符串中的字符都認為是 Unicode 字符。

可以說使用 SentencePiece 優化了 BERT 使用的 WordPiece 方法對中文分詞效果不好的問題。

雙流自注意力機制

從總體結構上來說,XLNet 的模型結構和 BERT 的結構差異不大,都是以 Transformer 為基礎。但 XLNet 模型使用了特別的注意力機制,即雙流自注意力機制(Two-Stream Self-Attention),XLNet 的雙流自注意力機制使用了兩種特征表征單元,分別為內容表征單元和詢問表征單元。

內容表征單元是對上文信息的表示,會包含當前的詞。詢問表征單元包含對除當前詞之外的上文信息的表示,以及包含當前詞的位置信息,而不能訪問當前詞的內容信息。

內容表征單元與詢問表征單元構成了兩種信息流,這兩種信息流不斷向上傳遞,在最后輸出詢問單元的信息。並且我們可以從下圖的紅框部分看到,最后的輸出的預測結果中對應的字詞順序和輸入時相同。

img

來源

下面詳細了解一下這個圖中的各部分,首先圖(a)部分表示的是內容流注意力(Content stream attention),圖(b)部分表示的是詢問流注意力(Query stream attention)。我們可以看到在圖(b)中詞 1 對應的只有詢問表征單元被輸入,而在圖(a)中詞 1 的內容表征單元也被輸入了。圖(c)部分表示的是模型如何應用雙流自注意力機制。而圖(c)右側是注意力掩膜,我們在上文中也有介紹。添加掩膜的作用除了達到改變排列的目的,還達到了使模型在內容流注意力中能看到當前詞,而在詢問流注意力中不能看到當前詞的目的。

除了上面提到的方法,XLNet 還使用了部分預測的方式,因為自回歸語言模型是從第一個詞預測到最后一個詞,但在預測的初始階段,由於已知的語句信息較少,模型很難收斂,所以實際只選擇預測語句后 1/K 的詞,而前面 1-1/K 的部分作為上下文信息。

XLNet 命名實體識別

上面,我們介紹了 XLNet 在 BERT 和 GPT-2 基礎上的改進之處,以及 XLNet 模型所使用的一些特別的方法。接下來我們將使用 XLNet 的預訓練模型進行命名實體識別次。命名實體識別(Named Entity Recognition,簡稱 NER),是指識別文本中具有特定意義的實體,主要包括人名、地名、機構名、專有名詞等。命名實體識別是信息抽取的重要一步,被廣泛應用在自然語言處理領域。

本次次我們使用的訓練和測試數據來源為 CoNLL-2003, CoNLL-2003 數據集是以新聞語料為基礎,標注的實體有四種,分別為:公司、地點、人名和不屬於以上三類的實體。分別命名為 ORGLOCPERMISC,實體的第一個字標為 B-ORGB-LOCB-PERB-MISC,第二個字標為 I-ORGI-LOCI-PERI-MISC,標注為 O 的字詞表示它不屬於任何一個短語。

下面我們將使用在 PyTorch-Transformers 模型庫中封裝好的 XLNetTokenizer()XLNetModel 類來實際進行一下 XLNet 預訓練模型應用。首先,需要安裝 PyTorch-Transformers。

!pip install pytorch-transformers==1.0  # 安裝 PyTorch-Transformers

因為原始數據結構較復雜,所以我們提前將數據重新整理過,已按照如下的標簽標注數據。

# 標簽數據所對應的字符串含義
label_dict = {'O':0, 'B-ORG':1, 'I-ORG':2, 'B-PER':3, 'I-PER':4, 'B-LOC':5, 'I-LOC':6, 'B-MISC':7, 'I-MISC':8, 'X':9, '[PAD]':10}

可以注意到上面的標簽中多了 X[PAD] 標簽,它們的含義分別為:因為分詞會使得某些原本完整的單詞斷開,而斷開的多出來的部分我們設為標簽 X[PAD] 標簽對應則的是填補的字符。

接下來要下載數據集,已經提前下載好,網盤鏈接:https://pan.baidu.com/s/18jqTwLNM2Vmf7fOzkh7UgA 提取碼:zko3

下載好數據集后,讀取數據文件。

train_samples = []
train_labels = []

with open('./train.txt', 'r') as f:
    while True:
        s1 = f.readline()
        if not s1:
            # 如果讀取到內容為空,則讀取結束
            break
        s2 = f.readline()
        _ = f.readline()
        train_samples.append(s1.replace('\n', ''))
        train_labels.append(s2.replace('\n', ''))

len(train_samples), len(train_labels)

由於上文提到的,分詞會使某些完整的詞斷開,所以分詞后,我們要在原始數據的基礎上再進行標簽的增加。例如詞 “She's”,會被分為 “She”,“'”,“s”,三個詞,這時就將原本詞 “She's” 對應的標簽 O 標在詞 “She” 上,而 “'”,“s” 分別表為 XX。下面的代碼就是上述標簽修改的具體實現。

from pytorch_transformers import XLNetTokenizer

# 使用 XLNet 的分詞器
tokenizer = XLNetTokenizer.from_pretrained('xlnet-base-cased')

input_ids = []
input_labels = []
for text, ori_labels in zip(train_samples, train_labels):
    l = text.split(' ')
    labels = []
    text_ids = []
    for word, label in zip(l, ori_labels):
        if word == '':
            continue
        tokens = tokenizer.tokenize(word)
        for i, j in enumerate(tokens):
            if i == 0:
                labels.append(int(label))
                text_ids.append(tokenizer.convert_tokens_to_ids(j))
            else:
                labels.append(9)
                text_ids.append(tokenizer.convert_tokens_to_ids(j))
    input_ids.append(text_ids)
    input_labels.append(labels)

len(input_ids), len(input_labels)

在准備好數據后,這里使用 PyTorch 提供的 DataLoader() 構建訓練集數據集表示,使用 TensorDataset() 構建訓練集數據迭代器。

import torch
from torch.utils.data import DataLoader, TensorDataset

del train_samples
del train_labels

for j in range(len(input_ids)):
    # 將樣本數據填充至長度為 128
    i = input_ids[j]
    if len(i) != 128:
        input_ids[j].extend([0]*(128 - len(i)))

for j in range(len(input_labels)):
    # 將樣本數據填充至長度為 128
    i = input_labels[j]
    if len(i) != 128:
        input_labels[j].extend([10]*(128 - len(i)))

# 構建數據集和數據迭代器,設定 batch_size 大小為 8
train_set = TensorDataset(torch.LongTensor(input_ids),
                          torch.LongTensor(input_labels))
train_loader = DataLoader(dataset=train_set,
                          batch_size=8,
                          shuffle=True)
train_loader

檢查是否機器有 GPU,如果有就在 GPU 運行,否則就在 CPU 運行。

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
device

由於 XLNet 預模型體積很大,且托管在外網,所以先從網盤下載預訓練模型。鏈接:https://pan.baidu.com/s/1CySwfsOyh9Id4T85koxAeg 提取碼:lah0

下面構建用於命名實體識別類,命名實體識別本質上是在進行分類,在 XLNet 模型下加入一個 Dropout 層用於防止過擬合,和一個 Linear 全連接層。

import torch.nn as nn
from pytorch_transformers import XLNetModel

class NERModel(nn.Module):
    def __init__(self, num_class=11):
        super(NERModel, self).__init__()
        # 讀取 XLNet 預訓練模型
        self.model = XLNetModel.from_pretrained("./")
        self.dropout = nn.Dropout(0.1)
        self.l1 = nn.Linear(768, num_class)

    def forward(self, x, attention_mask=None):
        outputs = self.model(x, attention_mask=attention_mask)
        x = outputs[0]  # 形狀為 batch * seqlen * 768
        x = self.dropout(x)
        x = self.l1(x)
        return x

定義損失函數。這里使用了交叉熵(Cross Entropy)作為損失函數。

def loss_function(logits, target, masks, num_class=11):
    criterion = nn.CrossEntropyLoss(reduction='none')
    logits = logits.view(-1, num_class)
    target = target.view(-1)
    masks = masks.view(-1)
    cross_entropy = criterion(logits, target)
    loss = cross_entropy * masks
    loss = loss.sum() / (masks.sum() + 1e-12)  # 加上 1e-12 防止被除數為 0
    loss = loss.to(device)
    return loss

實體化類,定義損失函數,建立優化器。

from torch.optim import Adam

model = NERModel()
model.to(device)
model.train()

optimizer = Adam(model.parameters(), lr=1e-5)

開始訓練。

from torch.autograd import Variable
import time

pre = time.time()

epoch = 3

for i in range(epoch):
    for batch_idx, (data, target) in enumerate(train_loader):
        data, target = Variable(data).to(device), Variable(target).to(device)

        optimizer.zero_grad()

        # 生成掩膜
        mask = []
        for sample in data:
            mask.append([1 if i != 0 else 0 for i in sample])
        mask = torch.FloatTensor(mask).to(device)

        output = model(data, attention_mask=mask)

        # 得到模型預測結果
        pred = torch.argmax(output, dim=2)

        loss = loss_function(output, target, mask)
        loss.backward()

        optimizer.step()

        if ((batch_idx+1) % 10) == 1:
            print('Train Epoch: {} [{}/{} ({:.0f}%)]\tLoss:{:.6f}'.format(
                i+1, batch_idx, len(train_loader), 100. *
                batch_idx/len(train_loader), loss.item()
            ))

        if batch_idx == len(train_loader)-1:
            # 在每個 Epoch 的最后輸出一下結果
            print('labels:', target)
            print('pred:', pred)

print('訓練時間:', time.time()-pre)

訓練結束后,可以使用驗證集觀察模型的訓練效果。

讀取驗證集數據和構建數據迭代器的方式與訓練集相同。

eval_samples = []
eval_labels = []

with open('./dev.txt', 'r') as f:
    while True:
        s1 = f.readline()
        if not s1:
            break
        s2 = f.readline()
        _ = f.readline()
        eval_samples.append(s1.replace('\n', ''))
        eval_labels.append(s2.replace('\n', ''))

len(eval_samples)

# 這里使用和訓練集同樣的方式修改標簽,不再贅述
input_ids = []
input_labels = []
for text, ori_labels in zip(eval_samples, eval_labels):
    l = text.split(' ')
    labels = []
    text_ids = []
    for word, label in zip(l, ori_labels):
        if word == '':
            continue
        tokens = tokenizer.tokenize(word)
        for i, j in enumerate(tokens):
            if i == 0:
                labels.append(int(label))
                text_ids.append(tokenizer.convert_tokens_to_ids(j))
            else:
                labels.append(9)
                text_ids.append(tokenizer.convert_tokens_to_ids(j))
    input_ids.append(text_ids)
    input_labels.append(labels)

del eval_samples
del eval_labels

for j in range(len(input_ids)):
    # 將樣本數據填充至長度為 128
    i = input_ids[j]
    if len(i) != 128:
        input_ids[j].extend([0]*(128 - len(i)))

for j in range(len(input_labels)):
    # 將樣本數據填充至長度為 128
    i = input_labels[j]
    if len(i) != 128:
        input_labels[j].extend([10]*(128 - len(i)))

# 構建數據集和數據迭代器,設定 batch_size 大小為 1
eval_set = TensorDataset(torch.LongTensor(input_ids),
                         torch.LongTensor(input_labels))
eval_loader = DataLoader(dataset=eval_set,
                         batch_size=1,
                         shuffle=False)
eval_loader

將模型設置為驗證模式,輸入驗證集數據。

from tqdm import tqdm_notebook as tqdm

model.eval()

correct = 0
total = 0

for batch_idx, (data, target) in enumerate(tqdm(eval_loader)):
    data = data.to(device)
    target = target.float().to(device)

    # 生成掩膜
    mask = []
    for sample in data:
        mask.append([1 if i != 0 else 0 for i in sample])
    mask = torch.Tensor(mask).to(device)

    output = model(data, attention_mask=mask)

    # 得到模型預測結果
    pred = torch.argmax(output, dim=2)

    # 將掩膜添加到預測結果上,便於計算准確率
    pred = pred.float()
    pred = pred * mask
    target = target * mask

    pred = pred[:, 0:mask.sum().int().item()]
    target = target[:, 0:mask.sum().int().item()]

    correct += (pred == target).sum().item()
    total += mask.sum().item()

print('正確分類的標簽數:{},標簽總數:{},准確率:{:.2f}%'.format(
    correct, total, 100.*correct/total))

可以看到最后的准確率在 90% 以上。在應用提取實體時,有時還會用添加規則的方式提取完整實體詞,或將最后預測標簽時的 Softmax 層替換為 條件隨機場 提高准確率,由於上述內容不是次重點,感興趣的同學可以自行查找資料學習。

總結

在本次次我們了解了 BERT 和 GPT-2 的升級版 XLNet,它綜合了兩個模型的優點,即不會引入遮蔽標簽產生的噪聲、不會產生預訓練和微調不符的問題,並且能夠同時融合上下文的語境信息。然后我們進行了 XLNet 的命名實體識別次,得到了不錯的表現。

相關鏈接


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM