首先我要為你們先打好預防針,因為今天的程式碼量非常龐大,這次我們將會一次性地處理Word2Vec、FastText、GloVe各模型的文字前處理方式,並且今天進行對文本資料的去識別化(De-identification)的動作,在該動作中我們需學習以下的知識。
去識別化(De-identification)
的程式建構方式B-I-O
標籤理解與使用去識別化(De-identification)
是一種技術,主要用來去除文章中可能會識別出個人身份的資訊,此技術的目的在於,確保資料在分享或分析時,不會被聯想到特定的組織、人名、或者地址等資訊,這樣的過程可以保護資料主體的隱私權,讓研究、分析和數據工作能在不洩露個人身份的情況下進行。這種技術通常會使用的方法,叫做B-I-O標籤。這種方案將文本序列中的每一個詞語都劃分為三個類別:B(開始)、I(內部)、O(其他),B標籤一般用於標示實體的開始,I標籤用於接續該實體的部分,而完全不相關的部分則會分類為O。而今天我們將會使用CoNLL-2003這一個去識別化資料集來進行比對,我們先來看看以下步驟:
當我們下載CoNLL-2003這一個資料後應該會有train.txt
、valid.txt
、test.txt
這三個資料集,而每一份檔案應該都會跟下述格式相似:
SOCCER NN B-NP O
- : O O
JAPAN NNP B-NP B-LOC
GET VB B-VP O
LUCKY NNP B-NP O
WIN NNP I-NP O
, , O O
CHINA NNP B-NP B-PER
IN IN B-PP O
SURPRISE DT B-NP O
DEFEAT NN I-NP O
首先讓我們慢慢解析這些資料的格式,在這份資料集的首行SOCCER NN B-NP O
,我們可以看到註記了一個詞彙SOCCER
,而在該詞彙後方的部分則表示該詞彙相關的實體註記,例如:名詞 (NN)
、名詞片語 (B-NP)
,其中O
代表這並非一個命名實體。
而在這次的程式實作中,我們需要的是最後一部分的標記,舉例來說第三行的JAPAN NNP B-NP B-LOC
,我們只需要JAPAN
作為輸入B-LOC(地點)
作為標籤即可,所以在這裡我們可以撰寫一個簡單的函式將這些結果拆分開來。
def load_data(file_path):
sentences, labels = [], []
sentence, label = [], []
with open(file_path, 'r', encoding='utf-8') as file:
for line in file:
if not line.strip():
if sentence and label:
sentences.append(sentence)
labels.append(label)
sentence, label = [], []
else:
parts = line.strip().split()
sentence.append(parts[0])
label.append(parts[-1])
return sentences, labels
在該程式中我們首先將每個資料讀進來,並將每個標籤與對應的文字內容儲存起來,而該資料集的每一段句子都設定用\n
來做分隔,因此我們需要建立兩個暫存串列sentence
和label
來處理這部分,這樣每當碰到\n
符號時,我們就將這兩個暫存串列的內容分別放入到完整的串列sentences
和labels
中。
因為我們這次需比較4個模型的優劣,因此會用到2種不同的前處理架構,而第一種前處理架構是不經過預訓練模型的資料進行,在這裡同樣的可以使用我們的TorchText函式庫進行處理。這裡的處理方式與先前相同,直接使用Counter
進行計數後,交付給vocab
處理即可。
def torchText(all_sentences, all_labels, specials = ('<PAD>', '<UNK>')):
token_counter, label_counter = Counter(), Counter()
for sentence, labels in zip(all_sentences, all_labels):
token_counter.update(sentence)
label_counter.update(labels)
token_voc = vocab(token_counter, specials=specials)
token_voc.set_default_index(token_voc.get_stoi()['<UNK>'])
label_voc = vocab(label_counter)
return token_voc, label_voc
小提示:
在資料前處理的階段,我們通常會預先定義所有需要去識別化的標籤。因此對於label_voc
,我們並不需執行加入特殊符號的步驟,只需直接進行轉換就能達成我們的目標。
而在預訓練模型的過程中我們則需要完成兩項工作,第一點當然是首先是下載模型了,所以這時我們需要先安裝gensim這個函式庫,我們可以透過以下程式安裝它
pip install gensim
在這個函式庫中我們可以透過gensim.downloader.load()
方法來取得大量的預訓練模型,而在這次的實作中,我們將使用word2vec-google-news-300
、glove-wiki-gigaword-100
以及fasttext-wiki-news-subwords-300
這三個預訓練模型來進行訓練。當我們取得這些模型後,還需要進行從目標資料集中提取出詞彙的動作,所以我們需要撰寫一個函數,使其通過這個函數方便地切換這些預訓練模型的向量。
import gensim.downloader as api
def pre_trained_model(model_name, all_sentences, all_labels, specials = ('<PAD>', '<UNK>')):
# 下載模型
model = api.load(model_name)
# 通過上面的torchText函述進行詞彙的切割
token_voc, label_voc = torchText(all_sentences, all_labels, specials = specials)
# 取得<UNK>的索引
unk_idx = token_voc.get_stoi()['<UNK>']
# 建立串列與一個紀錄詞彙的字典
pretrained_voc, word2vec_voc = [], {}
for word in token_voc.get_stoi(): # 取得所有詞彙
idx = model.key_to_index.get(word, unk_idx) # 當無法轉換時返回<unk>
if idx != unk_idx: # 不加入無法轉換的詞嵌入向量
pretrained_voc.append(model[idx])
word2vec_voc.update({word:1})
word2vec_voc = vocab(word2vec_voc, specials=specials) # 更新詞彙表
word2vec_voc.set_default_index(word2vec_voc.get_stoi()['<UNK>'])
pretrained_emb = torch.tensor(pretrained_voc) # 建立新詞嵌入向量
pretrained_emb = torch.cat((torch.zeros(len(specials), pretrained_emb.shape[1]), pretrained_emb))
return word2vec_voc, label_voc, pretrained_emb
然而在程式中我們還需要執行新增向量的操作,這是因為我們在這些預訓練模型中並未使用到特殊標籤,所以在取得該詞彙的資料時,還需對其進行向量的組合,在程式裡我們先通過了torch.zeros
創建出一個與specials
長度相等的向量空間,接下來再使用torch.cat
將此向量與已創建完畢的pretrained_emb
向量進行拼接,使其向量能夠與我們的輸入長度相等。
小提示:
為了方便觀看,在這裡並未將fastText的輸入轉換成subword的格式,如果你需要找到轉換的方式可以參閱昨天的【Day 16】解析詞嵌入預訓練模型的奧秘(下)-fastText中Subword建立的重要性,或是直接到我的GitHub中查看程式碼,已找到最正確的切割方式。
我們在建立完這些預訓練詞向量與詞彙表之後,還需將這些詞轉換成數字,這部分我們應該都非常熟悉了,只需要用lookup_indices
就能實現。
def tokens2nums(sentences, labels, token_voc, label_voc):
token_nums, label_nums = [], []
for word, label in zip(sentences, labels):
token_num = token_voc.lookup_indices(word)
label_num = label_voc.lookup_indices(label)
token_nums.append(torch.tensor(token_num))
label_nums.append(torch.tensor(label_num))
return token_nums, label_nums
小提示:
我已將這步前的所有程式碼都整合成一個名為Preprocessing.py
的檔案,這是因為由於我們需要訓練四種模型,這些程式碼將會被多次呼叫,所以將其整合到單一檔案中不僅可以增加視覺美觀性,也能提高重複利用性。
在這個步驟中,我們還需要獲取一些超參數,以便作為模型後續的輸入參數。
PAD_IDX = token_voc.get_stoi()['<PAD>']
O_IDX = label_voc.get_stoi()['O']
INPUT_DIM = len(token_voc)
OUTPUT_DIM = len(label_voc)
embedding_dim = 300
在這裡我們必須獲取兩個填充索引值PAD_IDX
及O_IDX
,前者主要用來設定詞嵌入層的參數,而後者則是在去識別化時的重要參數,而這項餐數是因為O
這個標籤在資料集中的數量最多,因此在評估模型的準確率時,這些O
的出現常會對我們的結果造成影響,所以我們需要在計算時忽略這些結果。
在這個步驟裡,因資料長度較短所以我們同樣會先使用pad_sequence
來進行序列填充的動作。
from torch.nn.utils.rnn import pad_sequence
x_train, y_train = pad_sequence(x_train, padding_value=PAD_IDX, batch_first=True), \
pad_sequence(y_train, padding_value=PAD_IDX, batch_first=True)
x_valid, y_valid = pad_sequence(x_valid, padding_value=PAD_IDX, batch_first=True), \
pad_sequence(y_valid, padding_value=PAD_IDX, batch_first=True)
接下來將這些訓練與驗證資料集一同放入到pytorch中的Dataset
與DataLoader
中,這樣子我們就可以開始訓練模型了
from torch.utils.data import Dataset, DataLoader
import torch
class NERDataset(Dataset):
def __init__(self, x, y):
self.x = x
self.y = y
def __getitem__(self, index):
return self.x[index], self.y[index]
def __len__(self):
return len(self.x)
trainset = NERDataset(x_train, y_train)
validset = NERDataset(x_valid, y_valid)
train_loader = DataLoader(trainset, batch_size = 1024, shuffle = True, num_workers = 0, pin_memory = True)
valid_loader = DataLoader(validset, batch_size = 1024, shuffle = True, num_workers = 0, pin_memory = True)
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
在這個步驟中,我們使用了一個LSTM模型來實現去識別化的功能,不過不同於先前的操作,這次不再只使用模型中的最後一個隱狀態進行訓練,而是運用了整個LSTM的隱狀態,而也因為這樣的改動,所以在模型後續的訓練過程中,還需要做出一些調整才能夠正常運作。
import torch.nn as nn
import torch.optim as optim
class NERModel(nn.Module):
def __init__(self, vocab_size, embedding_dim, hidden_dim, tagset_size):
super(NERModel, self).__init__()
self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx = PAD_IDX)
self.lstm = nn.LSTM(embedding_dim, hidden_dim, batch_first=True, bidirectional=True)
self.hidden2tag = nn.Linear(hidden_dim * 2, tagset_size)
def forward(self, sentence):
embeds = self.embedding(sentence)
lstm_out, _ = self.lstm(embeds)
tag_space = self.hidden2tag(lstm_out)
return tag_space
hidden_dim = 100
model = NERModel(INPUT_DIM, embedding_dim, hidden_dim, OUTPUT_DIM).to(device)
try:
model.embedding.weight.data.copy_(pretrained_emb)
except:
pass
optimizer = optim.Adam(model.parameters(), lr=1e-3)
criterion = nn.CrossEntropyLoss()
而在這時我們也可以將這些預訓練的詞嵌入權重通通導入到該模型中了。
在模型訓練時,我們需要處理兩件重要事情,首先由於我把所有的隱狀態都輸出了,這導致輸出維度多出了一個維度,為解決此問題,我們需要採用view
方法將其攤平進行計算,當然你也可以選擇其他的計算方式,只需要每次攤平後的序保持相同就可以了。
from sklearn.metrics import f1_score
def train(epoch):
train_loss, train_acc = 0, 0
train_pbar = tqdm(train_loader, position=0, leave=True)
model.train()
all_preds, all_true = [], []
for input_datas in train_pbar:
features, labels = [i.to(device) for i in input_datas]
optimizer.zero_grad()
outputs = model(features)
loss = criterion(outputs.view(-1, OUTPUT_DIM), labels.view(-1))
loss.backward()
optimizer.step()
train_pbar.set_description(f'Train Epoch {epoch}')
train_pbar.set_postfix({'loss':f'{loss:.3f}'})
_, preds = torch.max(outputs, dim = 2)
train_loss += loss.item() # 模型總損失
all_preds.extend(preds.cpu().numpy())
all_true.extend(labels.cpu().numpy())
all_true = np.concatenate(all_true, axis=0)
all_preds = np.concatenate(all_preds, axis=0)
idx = all_true != O_IDX
return f1_score(all_true[idx], all_preds[idx], average = 'micro'), train_acc/len(trainset)
再來就是有關於Loss值這項指標的問題,因在去識別化的任務中的輸出標籤中出現的太多的O
,所以在這部分我們很難去計算出它的實際Loss值,當然我們也能夠直接忽略到O
的Loss值給清除掉,但這樣做的結果可能就會導致模型的調整時發生錯誤,此在這裡我們需要使用到一個全新的指標F1-Score,該公式的計算方式如下
該公式中主要有兩個部分需要解釋,第一個是精確度(Precision)
,它表示實際為陽性的樣本中被正確預測為陽性的比例,另一個是召回率(Recall)
,它代表的是所有陽性樣本中被正確預測為陽性的比例。
讓我們以一個例子來解釋這兩者之間的區別,精確度可以用來評估門禁系統,因為該指標強調了不能誤放非法人員,而召回率則適合用在逃犯檢測系統中,該指標代表即使誤抓了許多人,也不能放過任何一名逃犯,不過這兩種評估方式都可能偏極端,因此有了F1-Score,它是一種在兩者之間取得平衡的算法
因此我們在進行去識別化的任務時,時常使用這個指標,而對於該算法,我們不需要自己手動進行計算,僅需要使用sklearn中的函式,就能輕易地求得該公式但是我們需要特別留意的是,在去識別化的任務中應該要忽略O_IDX
,以免導致計算上的錯誤。
在這裡,我們依然使用原有的訓練程式進行訓練,但不同的是,我們將儲存指標的方式改為F1-Score,同時忽略了最後生成的Loss圖(因為無效)。
epochs = 10000 # 訓練次數
early_stopping = 10 # 模型訓練幾次沒進步就停止
stop_cnt = 0 # 計數模型是否有進步的計數器
model_path = 'model.ckpt' # 模型存放路徑
show_loss = False # 是否顯示訓練折線圖
best_f1 = 0 # 最佳的準確率
loss_record = {'train':[], 'valid':[]} # 訓練紀錄
for epoch in range(epochs):
train_f1, train_loss= train(epoch)
valid_f1, valid_loss = valid(epoch)
loss_record['train'].append(train_loss)
loss_record['valid'].append(valid_loss)
# 儲存最佳的模型權重
if best_f1 < valid_f1:
best_f1 = valid_f1
torch.save(model.state_dict(), model_path)
print(f'Saving Model With F1 {best_f1:.5f}')
stop_cnt = 0
else:
stop_cnt+=1
# Early stopping
if stop_cnt == early_stopping:
output = "Model can't improve, stop training"
print('-' * (len(output)+2))
print(f'|{output}|')
print('-' * (len(output)+2))
break
print(f'Train Loss: {train_loss:.5f} Train F1: {train_f1:.5f}', end='| ')
print(f'Valid Loss: {valid_loss:.5f} Valid F1: {valid_f1:.5f}', end='| ')
print(f'Best F1: {best_f1:.5f}', end ='\n\n')
if show_loss:
show_training_loss(loss_record)
當你執行這個程式時,你會注意到F1-Score幾乎都是0,而會發生這種情況其實是因為在訓練這些預訓練模型時,並未將去識別化這個因素考量進來,所以我們先在使用LSTM進行訓練時,其實就是在微調這些權重,這樣子我們將能夠讓該權重更適合我們的任務,現在我們來看一下表格中這些模型訓練出來的成果:
名稱 | LSTM | Word2Vec| GloVe| fastText
------------- | -------------
F1-score |0.69471|0.75022 |0.77032|0.83053|
在以上結果中,我們可以看到fastText的訓練結果在比較與其他三個模型後顯示了最佳的效能,這個優異的成果主要要歸功於subword的特性,因為在處理去識別化的過程中,我們經常會遇到像是HWSI-1246(ID)這類的資訊,而這些ID基本上是不可能被組成詞彙的,所以這些標籤並不會被Word2Vec和GloVe這兩個模型所建立,因此該部分將被轉換成<UNK>
這一個特殊字元,所以這些模型都是通過前後文的關聯來推斷這個<unk>
詞彙的出現時間來進行去識別化的動作。
而fastText與之不同的是,它會透過使用subword特性來將這些向量進行分割和重組,因此它不會出現太多的<UNK>
字元,所以在最終的結果展示上,fastText模型表現得最為出色。
我們可以從這項實驗中我們發現,導入預訓練模型的LSTM模型其效能可以大幅超越獨立訓練的結果,並且每一個模型的演進,都會比原先的模型再更好一些,因此我們更應著重學習這些模型的理論,這樣在遭遇問題時,才能熟練地修改模型中的演算法。然而還有一個我們要重視的預訓練模型ELMO,它的這項技術正是當今熱門模型的基礎,因此明天我將花些時間為大家詳細介紹ELMO這個預訓練模型。
那麼我們明天再見!
內容中的程式碼都能從我的GitHub上取得:
https://github.com/AUSTIN2526/iThome2023-learn-NLP-in-30-days