Bert+LSTM+CRF命名實體識別

語言: CN / TW / HK

本文已參與「新人創作禮」活動,一起開啟掘金創作之路。

Bert+LSTM+CRF命名實體識別

從0開始解析原始碼。

  1. 理解原始碼的邏輯,具體瞭解為什麼使用預訓練的bert,bert有什麼作用,網路的搭建是怎麼樣的,訓練過程是怎麼訓練的,輸出是什麼
  2. 除錯執行原始碼

NER目標

NER是named entity recognized的簡寫,對人名地名機構名日期時間專有名詞等進行識別。

結果輸出標註方法

採用細粒度標註,就是對於每一個詞都給一個標籤,其中連續的詞可能是一個標籤,與原始資料集的結構不同,需要對資料進行處理,轉化成對應的細粒度標註形式。

資料集形式修改

形式:

{ "text": "浙商銀行企業信貸部葉老桂博士則從另一個角度對五道門檻進行了解讀。葉老桂認為,對目前國內商業銀行而言,", "label": { "name": { "葉老桂": [ [9, 11], [32, 34] ] }, "company": { "浙商銀行": [ [0, 3] ] } } }

修改後資料集對應格式:

sentence: ['溫', '格', '的', '球', '隊', '終', '於', '又', '踢', '了', '一', '場', '經', '典', '的', '比', '賽', ',', '2', '比', '1', '戰', '勝', '曼', '聯', '之', '後', '槍', '手', '仍', '然', '留', '在', '了', '奪', '冠', '集', '團', '之', '內', ','] label: ['B-name', 'I-name', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'B-organization', 'I-organization', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O']

資料預處理

對於一個句子不進行分詞,原因是NER為序列標註任務,需要確定邊界,分詞後就可能產生錯誤的分詞結果影響效果(B-x,I-x這種連續性,分詞後會影響元意思表達)。

def preprocess(self, mode):        """       params:           words:將json檔案每一行中的文字分離出來,儲存為words列表           labels:標記文字對應的標籤,儲存為labels       examples:           words示例:['生', '生', '不', '息', 'C', 'S', 'O', 'L']           labels示例:['O', 'O', 'O', 'O', 'B-game', 'I-game', 'I-game', 'I-game']       """ np.savez_compressed(output_dir, words=word_list, labels=label_list)

儲存的檔案也還是一句是一句的,所以後續處理中只有CLS,不需要終止符。

資料集分集與分batch

def dev_split(dataset_dir):    """split dev set"""    data = np.load(dataset_dir, allow_pickle=True)#載入npz檔案    words = data["words"]    labels = data["labels"]    x_train, x_dev, y_train, y_dev = train_test_split(words, labels, test_size=config.dev_split_size, random_state=0)    return x_train, x_dev, y_train, y_dev

呼叫train_test_split實現分train和dev的資料集。

將資料轉化形式,用idx表示,構造NERDataset類表示使用資料集

def __init__(self, words, labels, config, word_pad_idx=0, label_pad_idx=-1):        self.tokenizer = BertTokenizer.from_pretrained(config.bert_model, do_lower_case=True)#呼叫預訓練模型        self.label2id = config.label2id#字典                                                        self.id2label = {_id: _label for _label, _id in list(config.label2id.items())}##字典        self.dataset = self.preprocess(words, labels)#資料集預處理        self.word_pad_idx = word_pad_idx        self.label_pad_idx = label_pad_idx        self.device = config.device ​    def preprocess(self, origin_sentences, origin_labels):        """       Maps tokens and tags to their indices and stores them in the dict data.       examples:           word:['[CLS]', '浙', '商', '銀', '行', '企', '業', '信', '貸', '部']           sentence:([101, 3851, 1555, 7213, 6121, 821, 689, 928, 6587, 6956],                       array([ 1, 2, 3, 4, 5, 6, 7, 8, 9]))           label:[3, 13, 13, 13, 0, 0, 0, 0, 0]       """        data = []        sentences = []        labels = []        # eg. i am cutting tokenize: cutting->[cut,'##ing']自動修改形式變成單數或者恢復原型        for line in origin_sentences:            # replace each token by its index            # we can not use encode_plus because our sentences are aligned to labels in list type            words = []            word_lens = []            for token in line:                words.append(self.tokenizer.tokenize(token))                word_lens.append(len(token))#如果含有英文會出現上面的情況,中文沒有分詞一般是1                #>> [1]*9            # 變成單個字的列表,開頭加上[CLS]            words = ['[CLS]'] + [item for token in words for item in token]            token_start_idxs = 1 + np.cumsum([0] + word_lens[:-1])# np.array:[1,2,3] 自動廣播機制 每個+1 a[1,2,3] a[:-1]->[1,2] 求出每個詞在沒加【cls】的句首字母idx            # 這裡計數tokens在words中的索引,第一個起始位置+1(加了cls)了,所以每一個+1            sentences.append((self.tokenizer.convert_tokens_to_ids(words), token_start_idxs))            #單詞轉化成idx,直接呼叫函式即可        for tag in origin_labels:            label_id = [self.label2id.get(t) for t in tag] #單個句子的tag idx            labels.append(label_id)        for sentence, label in zip(sentences, labels):            data.append((sentence, label))#句子編碼、token在words中的位置、對應的label(一個token可能佔用多個word(cutting->cut+ing)        return data ​

preprocess處理token和word,記錄每個token在word中的起始位置用於後續的對齊,對於每個單詞進行tokennize(中文無變化,英文可能會有,但資料處理過程中將單詞分成字母,所以無影響),然後在句首加上開始字元,因為生成第一個單詞也需要概率因此句首不能省略,然後就是將字元轉化成idx儲存,tag也轉化成idx;

類中的功能函式

def __getitem__(self, idx):#class使用索引    """sample data to get batch"""    word = self.dataset[idx][0]    label = self.dataset[idx][1]    return [word, label] def __len__(self):#class 使用長度    """get dataset size"""    return len(self.dataset)

可以索引訪問與訪問長度。

encode_plus可以直接編碼,但這裡不能使用:align限制

因為單詞要和標籤對應,直接tokennize後編碼,不能確定與標籤的對應關係;

tokennize()

對於英文一個token通過tokennize會得到多個word:cutting->cut+##ing;

np.cumsum(a)累計計數

[1,1,1]--->[1,2,3]

模型架構

首先要明確,是繼承bert基類,然後自定義forward函式就建好網路了,基本結構試:

``` class Module(nn.Module):    def init(self):        super(Module, self).init()        # ......          def forward(self, x):        # ......        return x data = .....  #輸入資料

例項化一個物件

module = Module()

前向傳播

module(data)  

而不是使用下面的

module.forward(data)

```

關於forward的解釋

nn.module中實現時就在call函式中定義了呼叫forward,然後傳參就自動呼叫了。

定義call方法的類可以當作函式呼叫,具體參考Python的面向物件程式設計。也就是說,當把定義的網路模型model當作函式呼叫的時候就自動呼叫定義的網路模型的forward方法。nn.Module 的call方法部分原始碼如下所示:

def __call__(self, *input, **kwargs): result = self.forward(*input, **kwargs)

BERT模式:選擇對應,在程式碼的不同部分都有切換(model.eval();model.train())
  • train
  • eval
  • predict
nonezero()函式

``` a = mat([[1,1,0],[1,1,0],[1,0,3]]) print(a.nonzero())

>>(array([0, 0, 1, 1, 2, 2], dtype=int64), array([0, 1, 0, 1, 0, 2], dtype=int64))

```

squeeze()函式介紹

去掉為1的維度,如[[0,1,2],[1,2,3]]dim(1,2,3)-->squeeze(1)--->[[0,1,2].[1,2,3]]

CRF層訓練

訓練目標:lstm輸出分數+轉移分數+前面序列的累計轉移分數也就是 emission Score和transition Score(ref),函式使用,初始設定只需要標籤數目,後續forward需要batch;如果想要知道結果需要使用decode函式

```

import torch from torchcrf import CRF num_tags = 5  # number of tags is 5 model = CRF(num_tags) emissions = torch.randn(seq_length, batch_size, num_tags) #初始輸入 model(emissions, tags, mask=mask) tensor(-10.8390, grad_fn=)#得到這個句子的概率

沒有tag預測

model.decode(emissions) [[3, 1, 3], [0, 1, 0]] ```

引用這個圖:

模型構造:

class BertNER(BertPreTrainedModel):    def __init__(self, config):        super(BertNER, self).__init__(config)        self.num_labels = config.num_labels ​        self.bert = BertModel(config)#第一層        self.dropout = nn.Dropout(config.hidden_dropout_prob)#非線性層        self.bilstm = nn.LSTM(#LSTM層            input_size=config.lstm_embedding_size,  # 1024            hidden_size=config.hidden_size // 2,  # 1024 因為是雙向LSTM,隱藏層大小為原來的一半            batch_first=True,            num_layers=2,            dropout=config.lstm_dropout_prob,  # 0.5 非線性            bidirectional=True       )        self.classifier = nn.Linear(config.hidden_size, config.num_labels) #得到每個詞對於所有tag的分數        self.crf = CRF(config.num_labels, batch_first=True)#CEF層 ​        self.init_weights()#初始化權重,先全部隨機初始化,然後呼叫bert的預訓練模型中的權重覆蓋 ​

直接使用pytorch已經實現的函式,設定好bert層,後面通過droupout非線性層隨機失活,然後使加上雙向LSTM,注意雙向的隱藏層是將兩個方向的直接拼接,因此每個的長度設定為總的隱藏層輸出長度的一半;然後接線性層,得到的是對於這些tag的每一個的分數,對於每一個位置,都給出是n鍾tag的分數,這些分數作為crf層得到輸入;然後進入crf層;

初始化權重:對於預訓練模型,已經有的引數直接載入,沒有的引數將隨機初始化。

設定前向傳播訓練,:

def forward(self, input_data, token_type_ids=None, attention_mask=None, labels=None,            position_ids=None, inputs_embeds=None, head_mask=None):    input_ids, input_token_starts = input_data    outputs = self.bert(input_ids,                        attention_mask=attention_mask,                        token_type_ids=token_type_ids,                        position_ids=position_ids,                        head_mask=head_mask,                        inputs_embeds=inputs_embeds)    sequence_output = outputs[0]    # 去除[CLS]標籤等位置,獲得與label對齊的pre_label表示    origin_sequence_output = [layer[starts.nonzero().squeeze(1)]                              for layer, starts in zip(sequence_output, input_token_starts)]    # 將sequence_output的pred_label維度padding到最大長度    padded_sequence_output = pad_sequence(origin_sequence_output, batch_first=True)    # dropout pred_label的一部分feature    padded_sequence_output = self.dropout(padded_sequence_output)    lstm_output, _ = self.bilstm(padded_sequence_output)    # 得到判別值    logits = self.classifier(lstm_output)    outputs = (logits,)    if labels is not None:#如果標籤存在就計算loss,否則就是輸出線性層對應的結果,這樣便於通過後續crf的decode函式解碼得到預測結果。        loss_mask = labels.gt(-1)        loss = self.crf(logits, labels, loss_mask) * (-1)        outputs = (loss,) + outputs ​        # contain: (loss), scores        return outputs

如果標籤存在就計算loss,否則就是輸出線性層對應的結果,這樣便於通過後續crf的decode函式解碼得到預測結果。在train.py/evaluate()裡面用到了:

batch_output = model((batch_data, batch_token_starts),                                 token_type_ids=None, attention_mask=batch_masks)[0]            #沒有標籤只會得到線性層的輸出            # (batch_size, max_len - padding_label_len)            batch_output = model.crf.decode(batch_output, mask=label_masks)#得到預測的標籤 ​

各個層的作用為:

bert

提供詞的嵌入表示,通過大規模訓練,得到的結果泛化性更強,因此使用預訓練模型,然引數有個比較好的初始化值。

lstm

從這裡開始是正式的模型內容,這裡是雙向lstm,能夠學習句子的上下文內容,從而給出每個字的標註。

crf

由於原始句法約束,lstm沒有學習到原始的句法約束,因此使用條件隨機場crf層來限制句法要求,從而加強結果。loss為發射分數和轉移分數統一的分數,越小越好

驗證

使用f1 score,兼顧了分類模型的精確率和召回率,最大為1,最小為0,越大越好。

模型訓練

訓練時採用patience_counter策略,如果連續patience_counter次f1值沒有提升,而且已經達到了最小訓練次數,訓練停止,程式碼實現為:

def train(train_loader, dev_loader, model, optimizer, scheduler, model_dir):    """train the model and test model performance"""    # reload weights from restore_dir if specified    if model_dir is not None and config.load_before:        model = BertNER.from_pretrained(model_dir)        model.to(config.device)        logging.info("--------Load model from {}--------".format(model_dir))    best_val_f1 = 0.0#最小值    patience_counter = 0#超過這個次數 f1值連續沒有提升而且已經過了最小訓練次數就終止    # start training    for epoch in range(1, config.epoch_num + 1):        train_epoch(train_loader, model, optimizer, scheduler, epoch)        val_metrics = evaluate(dev_loader, model, mode='dev')#驗證        val_f1 = val_metrics['f1']#得到f1值        logging.info("Epoch: {}, dev loss: {}, f1 score: {}".format(epoch, val_metrics['loss'], val_f1))        improve_f1 = val_f1 - best_val_f1#控制精度連續提升        if improve_f1 > 1e-5:            best_val_f1 = val_f1            model.save_pretrained(model_dir)            logging.info("--------Save best model!--------")            if improve_f1 < config.patience:                patience_counter += 1            else:                patience_counter = 0        else:            patience_counter += 1        # Early stopping and logging best f1        if (patience_counter >= config.patience_num and epoch > config.min_epoch_num) or epoch == config.epoch_num:            logging.info("Best val f1: {}".format(best_val_f1))            break    logging.info("Training Finished!") ​

引數更新,學習率衰減

採用學習率分離,adamW優化採納數,動態調整學習率的策略。

設定控制係數不衰減的項,然後optimizer_grouped_parameters要將全部的引數都寫進去,注意寫法的不同:crf層的引數學習率更高,而且寫法不同是直接的parameters,見下文寫法:

if config.full_fine_tuning:        # model.named_parameters(): [bert, bilstm, classifier, crf]        # 模型是哪個層中的引數        bert_optimizer = list(model.bert.named_parameters())        lstm_optimizer = list(model.bilstm.named_parameters())        classifier_optimizer = list(model.classifier.named_parameters())        no_decay = ['bias', 'LayerNorm.bias', 'LayerNorm.weight'] #控制係數不衰減的項        optimizer_grouped_parameters = [#其他引數在優化過程權重衰減           {'params': [p for n, p in bert_optimizer if not any(nd in n for nd in no_decay)], #bert中衰減項引數             'weight_decay': config.weight_decay},           {'params': [p for n, p in bert_optimizer if any(nd in n for nd in no_decay)],             'weight_decay': 0.0},#不衰減的也要寫出來           {'params': [p for n, p in lstm_optimizer if not any(nd in n for nd in no_decay)],#lstm層的係數             'lr': config.learning_rate * 5, 'weight_decay': config.weight_decay},           {'params': [p for n, p in lstm_optimizer if any(nd in n for nd in no_decay)],             'lr': config.learning_rate * 5, 'weight_decay': 0.0},           {'params': [p for n, p in classifier_optimizer if not any(nd in n for nd in no_decay)],#線性層引數             'lr': config.learning_rate * 5, 'weight_decay': config.weight_decay},           {'params': [p for n, p in classifier_optimizer if any(nd in n for nd in no_decay)],             'lr': config.learning_rate * 5, 'weight_decay': 0.0},           {'params': model.crf.parameters(), 'lr': config.learning_rate * 5}#crf層的引數學習率更高,而且寫法不同是直接的parameters       ]    # only fine-tune the head classifier 如果不微調也就是bert層全部使用原本的權重,不會根據資料集微調    # 問題:預訓練模型的引數只包含bert的?那麼這裡的lstm層為什麼不訓練;預訓練模型,對照表,給定單詞(有一個初始順序)給出編碼然後進入後續模型    else:        param_optimizer = list(model.classifier.named_parameters())        optimizer_grouped_parameters = [{'params': [p for n, p in param_optimizer]}]    optimizer = AdamW(optimizer_grouped_parameters, lr=config.learning_rate, correct_bias=False)    train_steps_per_epoch = train_size // config.batch_size    scheduler = get_cosine_schedule_with_warmup(optimizer,                                                num_warmup_steps=(config.epoch_num // 10) * train_steps_per_epoch,                                                num_training_steps=config.epoch_num * train_steps_per_epoch) ​    # Train the model    logging.info("--------Start Training!--------")    train(train_loader, dev_loader, model, optimizer, scheduler, config.model_dir)

原始碼這裡不微調邏輯存有問題,原github已提交issue,~~暫時沒有迴應(沒用到)~~

結果分析

f1score最終為0.79;

在書籍、公司、遊戲、政府、人名上f1 score都大於0.8,效果較好;

原資料:

| 模型 | BiLSTM+CRF | Roberta+Softmax | Roberta+CRF | Roberta+BiLSTM+CRF | | ------------ | ---------- | --------------- | ----------- | ------------------ | | address | 47.37 | 57.50 | 64.11 | 63.15 | | book | 65.71 | 75.32 | 80.94 | 81.45 | | company | 71.06 | 76.71 | 80.10 | 80.62 | | game | 76.28 | 82.90 | 83.74 | 85.57 | | government | 71.29 | 79.02 | 83.14 | 81.31 | | movie | 67.53 | 83.23 | 83.11 | 85.61 | | name | 71.49 | 88.12 | 87.44 | 88.22 | | organization | 73.29 | 74.30 | 80.32 | 80.53 | | position | 72.33 | 77.39 | 78.95 | 78.82 | | scene | 51.16 | 62.56 | 71.36 | 72.86 | | overall | 67.47 | 75.90 | 79.34 | 79.64 |

這裡使用的是bert預訓練模型,可以看到從預訓練模型上說,和roberta在各個資料上稍微差一些,但最後的差值和原本實驗結果相近。

實驗test時的bad—case分析

槍手這裡系統錯判為組織;

教委錯判為政府;

彩票監管部門認為是政府,實際是組織;

中材中心認為是公司,實際是組織;

槍手錯判;

一些景點和地名分不清;

以及這種

可以看出由於有了條件隨機場的限制,沒有明顯的B-peron後面跟I-name這種錯誤,出現的錯誤大都是內容上的,即使是人也不一定分清,可見這個模型的強大。

參考

是對文章裡面不涉及的部分的進一步解析,適合小白開箱使用。

原始碼為:傳送門

\