[學習筆記]神經網絡之二:使用Bert進行二分類

本篇記錄一下如何使用bert進行二分類。這裏用到的庫是pyotrch-pretrained-bert,原生的bert使用的是TensorFlow,這個則是pytorch版本。

本篇文章主要參考了基於BERT fine-tuning的中文標題分類實戰的代碼以及如何用 Python 和 BERT 做中文文本二元分類?的數據。

1.樣本數據

首先是要訓練的文本數據,部分文本內容如下:

圖1 部分文本數據

該文本是來自於知乎: 如何用 Python 和 BERT 做中文文本二元分類?的數據源,該作者使用BERT進行二分類達到了88%左右的準確率。

數據鏈接,原作者把數據pickle序列化,我個人在使用的時候是反序列化到了一個excel文件,並且分成了兩個工作表,分別是train工作表和test工作表,代碼如下:

import pandas as pd


df = pd.read_pickle('dianping_train_test.pickle')
writer = pd.ExcelWriter(dianping_train_test.xls')
df[0].to_excel(writer, 'train')
df[1].to_excel(writer, 'test')
writer.close()

這一步的目的是爲了查看文本內容,訓練樣本1600,測試樣本400。

2.網絡結構

首先導入包:

import time
import pandas as pd
import numpy as np
import torch
from torch import nn
from sklearn.metrics import classification_report
from concurrent.futures import ThreadPoolExecutor
from torch.utils.data import TensorDataset, DataLoader
from pytorch_pretrained_bert import BertTokenizer, BertModel
from pytorch_pretrained_bert.optimization import BertAdam

pyotrch-pretrained-bert中提供了一些預定義的網絡模型,其中就有用於分類的模型,導入語句如下:

from pytorch_pretrained_bert import BertForSequenceClassification

在本文中,並未使用到這個類。我參照着該類寫了一個ClassifyModel:

class ClassifyModel(nn.Module):
    def __init__(self, pretrained_model_name_or_path, num_labels, is_lock=False):
        super(ClassifyModel, self).__init__()
        self.bert = BertModel.from_pretrained(pretrained_model_name_or_path)
        self.classifier = nn.Linear(768, num_labels)
        if is_lock:
            # 加載並凍結bert模型參數
            for name, param in self.bert.named_parameters():
                if name.startswith('pooler'):
                    continue
                else:
                    param.requires_grad_(False)

    def forward(self, input_ids, token_type_ids=None, attention_mask=None):
        _, pooled = self.bert(input_ids, token_type_ids, attention_mask, output_all_encoded_layers=False)
        logits = self.classifier(pooled)
        return logits

ClassifyModel類比BertForSequenceClassification類多的功能就是可以凍結Bert預訓練模型的參數,從而加快訓練速度、佔用資源和訓練效果

關於凍結參數,目前我參考的是9012年,該用bert打比賽了這篇帖子,不過我對是否凍結所有的bert參數抱有疑問,因爲凍結後的效果並不好,基於這個問題,我稍微查看了下pytorch的部分源碼:

class BertModel(BertPreTrainedModel):
    def __init__(self, config):
        super(BertModel, self).__init__(config)
        self.embeddings = BertEmbeddings(config)
        self.encoder = BertEncoder(config)
        self.pooler = BertPooler(config)
        self.apply(self.init_bert_weights)

    def forward(self, input_ids, token_type_ids=None, attention_mask=None, output_all_encoded_layers=True):
        if attention_mask is None:
            attention_mask = torch.ones_like(input_ids)
        if token_type_ids is None:
            token_type_ids = torch.zeros_like(input_ids)

        extended_attention_mask = extended_attention_mask.to(dtype=next(self.parameters()).dtype) # fp16 compatibility
        extended_attention_mask = (1.0 - extended_attention_mask) * -10000.0

        embedding_output = self.embeddings(input_ids, token_type_ids)
        encoded_layers = self.encoder(embedding_output,
                                      extended_attention_mask,
                                      output_all_encoded_layers=output_all_encoded_layers)
        sequence_output = encoded_layers[-1]
        pooled_output = self.pooler(sequence_output)
        if not output_all_encoded_layers:
            encoded_layers = encoded_layers[-1]
        return encoded_layers, pooled_output

在__init__ 函數中,BertModel類定義了embedding、encoder和pooler,顧名思義,embedding爲嵌入層,encoder爲編碼層,pooler則是一個全連接層。

在forward函數中,如果output_all_encoded_layers=True,那麼encoded_layer就是12層transformer的結果,否則只返回最後一層transformer的結果,pooled_output的輸出就是最後一層Transformer經過self.pooler層後得到的結果。

那麼接下來我們看一看這個BertPooler的定義:

class BertPooler(nn.Module):
    def __init__(self, config):
        super(BertPooler, self).__init__()
        self.dense = nn.Linear(config.hidden_size, config.hidden_size)
        self.activation = nn.Tanh()

    def forward(self, hidden_states):
        # We "pool" the model by simply taking the hidden state corresponding
        # to the first token.
        first_token_tensor = hidden_states[:, 0]
        pooled_output = self.dense(first_token_tensor)
        pooled_output = self.activation(pooled_output)
        return pooled_output

 從__init__函數中可以瞭解到,BertPooler是一個輸入爲768,輸出爲768、激活函數是tanh的全連接層;forward函數則表明,該層傳入的是[CLS]的字向量,Bert論文中提到:“每個序列的第一個標記始終是特殊分類嵌入([CLS])。該特殊標記對應的最終隱藏狀態(即, Transformer 的輸出)被用作分類任務中該序列的總表示”,可以理解爲[CLS]代表的向量包含了這個句子的含義。

然後我就進行了不精確測試,參數如下:batch_size=32 max_seq_len=200 learning_rate=5e-2 epochs=4

第一次是把Bert的所有參數全部凍結,即:

# 加載並凍結bert模型參數
for param in self.bert.parameters():
    param.requires_grad_(False)

forward代碼:

    def forward(self, input_ids, token_type_ids=None, attention_mask=None):
        encoded_layers, pooled = self.bert(input_ids, token_type_ids, attention_mask, output_all_encoded_layers=False)
        logits = self.classifier(pooled)
        return logits

 得到的結果如下:

圖2 第一次測試

 綜合幾次,基本上精確率在80%左右

 第二次是把Bert的pooler層以外的所有參數都凍結:

            # 加載並凍結bert模型參數
            for name, param in self.bert.named_parameters():
                if name.startswith('pooler'):
                    continue
                else:
                    param.requires_grad_(False)

forward函數不變,效果如下:

圖3 第二次測試

 綜合幾次,精確率在85%-86%之間

得到的結論如下:不凍結Bert的pooler權重,凍結其他層,訓練結果提升,訓練時間減少,內存佔用減少

由於這是一個二分類,而pooler out輸出的維度是768維,因此還需要在Bert的pooler out輸出後加個輸入爲768,輸出爲2的全連接層。微調則主要微調的新加入的這個全連接層的權重鏈接矩陣。

3.數據格式轉換

對於Bert來說,我們的輸入表示能夠在一個標記序列中清楚地表徵單個或一對文本句子,BERT會把給定序列對應的標記嵌入、句子嵌入和位置嵌入求和來構造其輸入表示,

圖5 輸入的可視化表示

 

bert要求數據輸入有着特定的格式,並且序列的最大長度爲512,這裏設最大長度爲max_seq_len(由於硬件限制或者是文本長度,往往不需要設置到512):

  1. 首先,需要使用bert的分字工具(BertTokenizer)對輸入文本進行分字,然後把字ID化,並且在兩邊分別加上[CLS]和[SEP],此爲第一個輸入seq;
  2. 接着,對於那些小於max_seq_len的文本,需要以0進行填充,而爲了表徵seq中的符號是有意義的,所以還需要一個mask,對於那些大於max_seq_len的文本,需要進行截斷,此爲第二個輸入seq_mask;
  3. 最後,還有一個segment,段的概念,由於這裏是單句,所以這個值爲0,此爲第三個輸入segment。

第一步和第二步綜合得到了Token embedding ;第三步得到了segment embedding,第一步的文本順序則可以得到Position Embedding。

class DataProcessForSingleSentence(object):
    def __init__(self, bert_tokenizer, max_workers=10):
        """
        :param bert_tokenizer: 分詞器
        :param max_workers:  包含列名comment和sentiment的data frame
        """
        self.bert_tokenizer = bert_tokenizer
        self.pool = ThreadPoolExecutor(max_workers=max_workers)

    def get_input(self, dataset, max_seq_len=30):
        sentences = dataset.iloc[:, 1].tolist()
        labels = dataset.iloc[:, 2].tolist()
        # 切詞
        token_seq = list(self.pool.map(self.bert_tokenizer.tokenize, sentences))
        # 獲取定長序列及其mask
        result = list(self.pool.map(self.trunate_and_pad, token_seq,
                                    [max_seq_len] * len(token_seq)))
        seqs = [i[0] for i in result]
        seq_masks = [i[1] for i in result]
        seq_segments = [i[2] for i in result]

        t_seqs = torch.tensor(seqs, dtype=torch.long)
        t_seq_masks = torch.tensor(seq_masks, dtype=torch.long)
        t_seq_segments = torch.tensor(seq_segments, dtype=torch.long)
        t_labels = torch.tensor(labels, dtype=torch.long)

        return TensorDataset(t_seqs, t_seq_masks, t_seq_segments, t_labels)

    def trunate_and_pad(self, seq, max_seq_len):
        # 對超長序列進行截斷
        if len(seq) > (max_seq_len - 2):
            seq = seq[0: (max_seq_len - 2)]
            # 添加特殊字符
        seq = ['[CLS]'] + seq + ['[SEP]']
        # id化
        seq = self.bert_tokenizer.convert_tokens_to_ids(seq)
        # 根據max_seq_len與seq的長度產生填充序列
        padding = [0] * (max_seq_len - len(seq))
        # 創建seq_mask
        seq_mask = [1] * len(seq) + padding
        # 創建seq_segment
        seq_segment = [0] * len(seq) + padding
        # 對seq拼接填充序列
        seq += padding
        assert len(seq) == max_seq_len
        assert len(seq_mask) == max_seq_len
        assert len(seq_segment) == max_seq_len
        return seq, seq_mask, seq_segment

4.數據讀取

在有了DataProcessForSingleSentence類之後,接着就要把訓練數據和測試數據加載進來:

def load_data(filepath, pretrained_model_name_or_path, max_seq_len, batch_size):
    """
    加載excel文件,有train和test 的sheet
    :param filepath: 文件路徑
    :param pretrained_model_name_or_path: 使用什麼樣的bert模型
    :param max_seq_len: bert最大尺寸,不能超過512
    :param batch_size: 小批量訓練的數據
    :return: 返回訓練和測試數據迭代器 DataLoader形式
    """
    io = pd.io.excel.ExcelFile(filepath)
    raw_train_data = pd.read_excel(io, sheet_name='train')
    raw_test_data = pd.read_excel(io, sheet_name='test')
    io.close()
    # 分詞工具
    bert_tokenizer = BertTokenizer.from_pretrained(pretrained_model_name_or_path, do_lower_case=True)
    processor = DataProcessForSingleSentence(bert_tokenizer=bert_tokenizer)
    # 產生輸入句 數據
    train_data = processor.get_input(raw_train_data, max_seq_len)
    test_data = processor.get_input(raw_test_data, max_seq_len)

    train_iter = DataLoader(dataset=train_data, batch_size=batch_size, shuffle=True)
    test_iter = DataLoader(dataset=test_data, batch_size=batch_size, shuffle=True)
    return train_iter, test_iter

這裏使用pytorch的DataLoader類,來進行批量樣本訓練。

5.評估

每經過一代,都可以對訓練結果進行測試:

def evaluate_accuracy(data_iter, net, device):
    # 記錄預測標籤和真實標籤
    prediction_labels, true_labels = [], []
    with torch.no_grad():
        for batch_data in data_iter:
            batch_data = tuple(t.to(device) for t in batch_data)
            # 獲取給定的輸出和模型給的輸出
            labels = batch_data[-1]
            output = net(*batch_data[:-1])
            predictions = output.softmax(dim=1).argmax(dim=1)
            prediction_labels.append(predictions.detach().cpu().numpy())
            true_labels.append(labels.detach().cpu().numpy())

    return classification_report(np.concatenate(true_labels), np.concatenate(prediction_labels))

測試結果使用sklearn包的classification_report函數,該函數會返回精確度,準確率、召回率和F1。

6.訓練

一切都準備好之後,就可以對模型進行訓練了:

if __name__ == '__main__':
    batch_size, max_seq_len = 32, 200
    train_iter, test_iter = load_data('dianping_train_test.xls', 'bert-base-chinese', max_seq_len, batch_size)
    # 加載模型
    # model = BertForSequenceClassification.from_pretrained('bert-base-chinese', num_labels=2)
    model = ClassifyModel('bert-base-chinese', num_labels=2, is_lock=True)
    print(model)

    optimizer = BertAdam(model.parameters(), lr=5e-05)
    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
    model.to(device)
    loss_func = nn.CrossEntropyLoss()

    for epoch in range(4):
        start = time.time()
        model.train()
        # loss和精確度
        train_loss_sum, train_acc_sum, n = 0.0, 0.0, 0
        for step, batch_data in enumerate(train_iter):
            batch_data = tuple(t.to(device) for t in batch_data)
            batch_seqs, batch_seq_masks, batch_seq_segments, batch_labels = batch_data

            logits = model(batch_seqs, batch_seq_masks, batch_seq_segments)
            logits = logits.softmax(dim=1)
            loss = loss_func(logits, batch_labels)
            loss.backward()
            train_loss_sum += loss.item()
            train_acc_sum += (logits.argmax(dim=1) == batch_labels).sum().item()
            n += batch_labels.shape[0]
            optimizer.step()
            optimizer.zero_grad()
        # 每一代都判斷
        model.eval()

        result = evaluate_accuracy(test_iter, model, device)
        print('epoch %d, loss %.4f, train acc %.3f, time: %.3f' %
              (epoch + 1, train_loss_sum / n, train_acc_sum / n, (time.time() - start)))
        print(result)

    torch.save(model, 'fine_tuned_chinese_bert.bin')

BERT可以使用cpu進行微調,但是速度比較慢因此這裏首先會判斷GPU是否可用,可用的話device="cuda",否則device="cpu"。然後需要把模型和tensor全都轉到device上進行計算。

存儲在不同位置中的數據是不可以直接進行計算的,如果對在GPU上的數據進⾏行行運算,那麼結果還是存放在GPU上,所以在evaluate_accuracy函數中需要把預測結果調用cpu()。

bert論文中指出:

圖6 官方譯文摘選

 因此這裏我選定了batch_size=32 learngin_rate=5e-5 epochs=4 max_seq_len=200進行訓練。

batch_size和max_seq_len的選取和硬件有着很大的關係,比如我的電腦顯卡爲gtx 1660ti 6G,由於我這裏凍結了bert的參數,所以可以設置batch_szie=32 max_seq_len=200,不然batch_size和max_seq_len都要進行幾乎一半的縮小。

貼上我的第四代訓練的結果:

圖7 第二次測試第四代結果

 參考:

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章