基於機器學習算法和pytorch實現的深度學習模型的中文長文本多分類任務實戰

目錄

一、貝葉斯算法長文本分類

二、TextCNN模型長文本分類

1、word2vec詞向量的訓練

2、padding操作

3、文本向量化

4、TexTCNN模型構造

三、TextRNN模型長文本分類

四、TextRNN+ATT模型長文本分類

五、Bert模型長文本分類(不更新bert權重和更新bert權重)

模型訓練

1、Bert模型不參與訓練

2、Bert模型參數訓練

總結和展望


         

         最近實現了中文長文本的多分類任務,主要是使用了機器學習的算法和基於pytorch實現的深度學習的相關模型。採用的模型分別是:機器學習算法貝葉斯和LightGBM、TextCNN、TextRNN、TextRNN+Att、Bert(分爲權重是否更新2個版本)。之所以採用這幾種模型,是因爲這幾種模型比較主流典型和常用的,弄懂這些模型的一些應用細節,對於實戰的提升還是很有意義的。當然做文本分類任務還有其他許多好的模型,這裏就不一一列出來了,可以自己去拓展,如金字塔模型等等。

本文重在整個任務全流程的實現,對於分類的準確率和模型的性能沒有做很多考慮。由於這裏數據集是專利數據的摘要,文本長度都是很長的,文本長度區間(字爲單位)如下圖:

文本長度的跨度很大,爲了能夠把大部分的數據集都考慮到,選取seq_length==400也就是400個字,這個可能分類準確率效果就有點低了,感覺長文本分類目前並沒有一個很好的解決辦法。主要是因爲模型所有的算法和模型並不能生成一個很好的文檔向量,目前這塊兒的論文也有一些在研究中;同時自己也想過把400個字的文本,每個字的字向量或者對應的多個詞向量直接cat起來形成一個大維度的向量來代表一個文本,會不會有一定的效果——還待實驗——已知的缺陷就是向量維度比較大,對現存和內存要求比較高,很喫機器,同時訓練速度估計也很慢的。

OK,下面就一個一個模型的來詳細實現長文本分類任務。先把整體的一個效果公佈一下:

算法 準確率 備註
機器學習算法

44.14%(GaussianNB)

66.96%(LGBMClassifier)

文本向量使用的是bert提取的;文本長度400字;算法沒有調參;訓練集19.6W,驗證集4.9W

textCNN

65% 詞向量使用的是word2vec;文本長度200個單詞;訓練集19.6W,驗證集4.9W
TextRNN 72.88% Bilstm+Linear;詞向量使用的是word2vec;文本長度50個單詞;訓練集19.6W,驗證集4.9W
TextRNN+Att 70.41% Bilstm+ATT+Linear;詞向量使用的是word2vec;文本長度50個單詞;訓練集19.6W,驗證集4.9W
Bert 72.94% Bert(權重不更新)+Linear;文本長度400字;訓練集19.6W,驗證集4.9W
Bert 83.59% Bert(權重更新)+Linear;文本長度400字;訓練集19.6W,驗證集4.9W

一、貝葉斯算法長文本分類

首先採用貝葉斯算法主要是因爲這個算法很輕量,比較簡答,當然也被證實過在文本分類上是有一定的效果的;於是這裏就從這個模型開始,關於貝葉斯原理和調參之類的這裏不做介紹。

貝葉斯算法做長文本分類的時候,按照一般的步驟就是,數據讀取、數據清洗、詞袋模型、獲取文本對應的向量表示、訓練貝葉斯模型。

本文的數據集情況:訓練集19W,驗證集5W。得出的詞袋模型中的詞語的總量是24W個(沒有去掉高頻詞),把對應的19W條訓練集和5W條驗證集數據轉化爲對應向量,每一條數據都是對應24W維度的向量,用我身邊所有的機器都實現不了,內存要爆炸。

然後試着減少數據量,訓練集4W條驗證集1W條,發現詞袋模型的規模還是有10W個詞,就算去掉高頻詞應該也是很大的,而且這個高頻的程度也不好把控。身邊的機器32G內存仍然扛不住,詞袋模型的方案放棄。

其實這裏做長文本的分類,採用詞袋模型會有一個天然的缺陷性,那就是由詞袋模型生成的文本向量維度很大,而且還是稀疏的,這個就對後續的模型訓練和使用造成消極的影響。

那就試試Bert模型提取文本向量,然後喂入貝葉斯模型中進行分類。這個方案也是和後面的有些類似,但是又有點不同。首先需要把文本轉換爲Bert的輸入向量,然後輸入bert模型,得到下一個模型的輸入向量。就需要一個dataLoader,會使用到cuda加速。代碼如下:

from torch.utils.data import Dataset
from transformers import BertTokenizer
import torch
from tqdm import tqdm
import os
import logging
logger = logging.getLogger(__name__)

class ReadDataSet(Dataset):
    def __init__(self,data_file_name,args,repeat=1):
        self.max_sentence_length = args.max_sentence_length
        self.repeat = repeat
        self.tokenizer = BertTokenizer.from_pretrained(args.model_path)
        self.process_data_list = self.read_file(args.data_file_path,data_file_name)


    def read_file(self,file_path,file_name):
        file_name_sub = file_name.split('.')[0]
        file_cach_path = os.path.join(file_path,"cached_{}".format(file_name_sub))
        if os.path.exists(file_cach_path):#直接從cach中加載
            logger.info('Load tokenizering from cached file %s', file_cach_path)
            process_data_list = torch.load(file_cach_path)
            return process_data_list
        else:
            file_path = os.path.join(file_path,file_name)
            data_list = []
            with open(file_path, 'r') as f:
                lines = f.readlines()
            for line in tqdm(lines, desc='read data'):
                line = line.strip().split('\t')
                data_list.append((line[0], line[1]))
            process_data_list = []
            for ele in tqdm(data_list, desc="Tokenizering"):
                res = self.do_process_data(ele)
                process_data_list.append(res)
            logger.info('Saving tokenizering into cached file %s',file_cach_path)
            torch.save(process_data_list,file_cach_path)#保存在cach中
            return process_data_list


    def do_process_data(self, params):

        res = []
        sentence = params[0]
        label = params[1]

        input_ids, input_mask = self.convert_into_indextokens_and_segment_id(sentence)
        input_ids = torch.tensor(input_ids, dtype=torch.long)
        input_mask = torch.tensor(input_mask, dtype=torch.long)

        label = torch.tensor(int(label))

        res.append(input_ids)
        res.append(input_mask)
        res.append(label)

        return res

    def convert_into_indextokens_and_segment_id(self, text):
        tokeniz_text = self.tokenizer.tokenize(text[0:self.max_sentence_length])
        input_ids = self.tokenizer.convert_tokens_to_ids(tokeniz_text)
        input_mask = [1] * len(input_ids)

        pad_indextokens = [0] * (self.max_sentence_length - len(input_ids))
        input_ids.extend(pad_indextokens)
        input_mask_pad = [0] * (self.max_sentence_length - len(input_mask))
        input_mask.extend(input_mask_pad)
        return input_ids, input_mask

    def __getitem__(self, item):
        input_ids = self.process_data_list[item][0]
        input_mask = self.process_data_list[item][1]
        label = self.process_data_list[item][2]
        return input_ids, input_mask,label

    def __len__(self):
        if self.repeat == None:
            data_len = 10000000
        else:
            data_len = len(self.process_data_list)
        return data_len


接下來就是把得出的bert向量輸入到NB和LightGBM模型中,整個流程的代碼如下:

from Code.ReadDataSet import ReadDataSet
from torch.utils.data import DataLoader
import torch
from tqdm import tqdm
from transformers import BertModel
import argparse
from sklearn.naive_bayes import GaussianNB
from sklearn.metrics import f1_score,accuracy_score,precision_score,recall_score
from lightgbm import LGBMClassifier

def get_bert_vector(model,train_iter,dev_iter,args):
    model.to('cuda')
    model.eval()

    train_vec = []
    train_label = []
    dev_vec = []
    dev_label = []
    with torch.no_grad():
        for step, batch in enumerate(tqdm(dev_iter, desc='dev iteration:')):
            batch = tuple(t.to('cuda') for t in batch)
            input_ids = batch[0]
            input_mask = batch[1]
            label = batch[2]
            output = model(input_ids, input_mask)[1]#[1]pooler_output,就是cls對應的那個向量,[0]last_hidden_state,這個需要自己取處理纔行
            label = label.to('cpu').numpy().tolist()
            output = output.to('cpu').numpy().tolist()
            dev_vec.extend(output)
            dev_label.extend(label)



        for step, batch in enumerate(tqdm(train_iter, desc='train iteration:')):
            batch = tuple(t.to('cuda') for t in batch)
            input_ids = batch[0]
            input_mask = batch[1]
            label = batch[2]
            output = model(input_ids, input_mask)[1]
            label = label.to('cpu').numpy().tolist()
            output = output.to('cpu').numpy().tolist()
            train_vec.extend(output)
            train_label.extend(label)
    return train_vec,train_label,dev_vec,dev_label




if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='init params configuration')
    parser.add_argument('--batch_size',type=int,default=100)
    parser.add_argument('--model_path',type=str,default='./pretrain_model/Chinese-BERT-wwm')
    parser.add_argument('--requires_grad', type= bool,default=True)
    parser.add_argument('--data_file_path',type=str,default='Data/patent')
    parser.add_argument('--max_sentence_length',type=int,default=400)
    args = parser.parse_args()
    print(args)

    train_data = ReadDataSet('train.tsv',args)

    train_loader = DataLoader(dataset=train_data, batch_size=args.batch_size, shuffle=True)

    dev_data = ReadDataSet('dev.tsv',args)
    dev_loader = DataLoader(dataset=dev_data, batch_size=args.batch_size, shuffle=True)

    bert_model = BertModel.from_pretrained(args.model_path)
    train_vec, train_label, dev_vec, dev_label = get_bert_vector(bert_model,train_loader,dev_loader,args)


    clf = LGBMClassifier()
    clf.fit(train_vec, train_label)
    dev_pred = clf.predict(dev_vec)
    f1 = f1_score(dev_label, dev_pred, average='macro')
    pre = precision_score(dev_label, dev_pred, average='micro')
    acc = accuracy_score(dev_label, dev_pred)
    recall = recall_score(dev_label, dev_pred, average='micro')
    print('LGBMClassifier f1:', f1)
    print('LGBMClassifier pre:', pre)
    print('LGBMClassifier recall:', recall)
    print('LGBMClassifier acc:', acc)




    clf = GaussianNB()
    clf.fit(train_vec, train_label)
    dev_pred = clf.predict(dev_vec)
    f1 = f1_score(dev_label, dev_pred, average='macro')
    pre = precision_score(dev_label, dev_pred, average='micro')
    acc = accuracy_score(dev_label, dev_pred)
    recall = recall_score(dev_label, dev_pred, average='micro')
    print('GaussianNB f1:', f1)
    print('GaussianNB pre:', pre)
    print('GaussianNB recall:', recall)
    print('GaussianNB acc:', acc)

注意的是bert輸出的向量應該是採用[1]pooler_output,就是cls對應的那個向量;否則用[0]就會要自己做一些處理。還有一個值得注意的地方就是這裏的最後分類結果中運用到了一定的評價指標,這裏由於不是二分類而是多分類,所以就採用了宏觀或者微觀的召回率之類的。結果如下:

 

這個效果看起來還是比較差的,只有44.14%的準確率。可以試試其他機器學習裏的分類器,比如SVM、xgboost或者LightGBM等,應該會有上升的。LIGHTGBM的準確率就大幅度提高了66.96%——這個是機器學習算法比賽中LIGHTGBM算法廣泛被使用的一個原因,效果好速度快。

二、TextCNN模型長文本分類

TextCNN模型的原理不做介紹,這裏直接上方案流程。

這裏使用jieba分詞、去停用詞、然後使用word2vec提取詞向量,直接cat起來形成文檔向量,然後訓練TextCNN。這裏選取這樣的方案,一個是想熟練一下jieba分詞處理這一套技術,同時熟悉word2vec,最後也可以橫向比較一下和其他向量的一個區別,當然這裏沒有嚴格的做對比實驗就是簡單粗略的感受式比較。(我太懶了。。。。。。)

首先可以看看,分詞後的結果,文章詞數量的一個分佈,然後決策選取多少個詞。由下圖很直觀的可以得出,200個詞是不錯的選擇。

這個時候需要注意的是我們採用的word2vec是使用其他語料訓練的,包含了55W個詞,但是我們的數據集是特定領域的仍然出現了很多詞不在word2vec中,因此我們就需要進行word2vec的增量訓練:

1、word2vec詞向量的訓練

代碼如下:

import pandas as pd
from gensim.models import Word2Vec

#文本向量化的時候回出現voo的問題,提前把word2vec做增量訓練,得到新的模型

if __name__ == '__main__':
    train_text = list(pd.read_csv('data/train_word_cut.csv')['text_word_cut'])
    dev_text = list(pd.read_csv('data/dev_word_cut.csv')['text_word_cut'])
    train_text.extend(dev_text)
    model = Word2Vec.load('pretrain_model/word2vec/word2vec_newtrain.model')
    #增量訓練Word2Vec,只訓練新詞,不訓練舊詞
    model.build_vocab(train_text,update=True)
    model.train(train_text, total_examples=model.corpus_count, epochs=5)
    model.save('pretrain_model/word2vec/word2vec_newtrain.model')

2、padding操作

當然由於word2vec訓練的時候會把低頻詞忽略掉,所以得不到低頻詞的詞向量,這裏仍然會出現oov的錯誤。那麼就需要過濾掉文本中的那些不在Word2vec模型中的詞語。這個過程,耗時比較多,200個詞,25W條數據集,然後每個詞好需要在word2vec中做65W(增量訓練後,模型詞典變大)次遍歷。當然也可以直接處理爲出現oov的錯誤直接把這個詞的向量隨意用個固定的向量來代替,這樣可以省略很多預處理的時間,也不會對性能產生很大的影響。

上代碼:主要是做了padding和除去不在word2vec模型中的詞語,保存下來。

import pandas as pd
from gensim.models import Word2Vec
from tqdm import tqdm
from multiprocessing import Pool
"""
分詞後的文本做padding操作!方便後續直接形成word2vec向量操作,順便也
把word2vec外面的詞語給去除掉————發現時間還是要40個小時,得用多進程了。
"""


def function(params):
    text = params[0]
    wv_words = params[1]
    keep_words = []
    words = text.split(' ')
    for word in words:
        if word in wv_words:
            keep_words.append(word)
        if len(keep_words) >= 200:
            break
    if len(keep_words) < 200:
        padding = ['0'] * (200 - len(keep_words))
        keep_words.extend(padding)
    content = ' '.join(keep_words)
    return content



def text_padding():
    wv_model  = Word2Vec.load('pretrain_model/word2vec/word2vec_newtrain.model')
    wv_words = wv_model.wv.index2word
    train = pd.read_csv('data/train_word_cut.csv')
    train_text = list(train['text_word_cut'])

    train_params = []
    for text in train_text:
        train_params.append((text,wv_words))

    with Pool(12) as pool:
        new_train_text = list(tqdm(pool.imap(function,train_params),total=len(train_params), desc='train set padding:'))
    pool.close()
    pool.join()

    train['text_padding'] = new_train_text
    train = train[['text_padding', 'label']]
    train.to_csv('data/train_padding.csv', index=False)

    dev = pd.read_csv('data/dev_word_cut.csv')
    dev_text = list(dev['text_word_cut'])

    dev_params = []
    for text in dev_text:
        dev_params.append((text, wv_words))

    with Pool(12) as pool:
        new_dev_text = list(tqdm(pool.imap(function, dev_params), total=len(dev_params), desc='dev set padding:'))
    pool.close()
    pool.join()

    dev['text_padding'] = new_dev_text
    dev = dev[['text_padding', 'label']]
    dev.to_csv('data/dev_padding.csv', index=False)



if __name__ == '__main__':
    text_padding()

3、文本向量化

這裏已經把文本進行了padding,因此就文本長度就是固定的了。把一篇篇的文檔進行向量化,那麼操作應該怎樣進行呢?直接簡單粗暴的使用cat式的操作,就是把文檔的每個詞的詞向量cat起來,形成文檔向量(這裏是粗糙的做法,有論文提出一些新的方法,怎麼構造出一個好的文檔向量,進行文本分類和相似性計算)。代碼如下:

    def word2vec_paddings_tensor(self,data_list):
        output = []
        for data,label in tqdm(data_list,desc='text to vord2vec:'):
            vec = []
            for word in data:
                v = self.wv_model[word].tolist()
                vec.append(v)#這裏的vec.append()有點類似cat的操作。
            vec = torch.tensor(vec)#這裏轉化爲tensors,後續可以用到GPU上訓練,vec就是我們需要的文檔向量
            label = torch.tensor(int(label))
            res = (vec,label)
            output.append(res)
        return output

爲了方便的讀取文本形成tensor和用於後續的訓練,就需要創建一個DataReader之類的。這個比較固定了,直接上代碼:

from torch.utils.data import Dataset
import torch
from gensim.models import Word2Vec
import pandas as pd
from tqdm import tqdm
import numpy as np

class ReadDataSet(Dataset):
    def __init__(self,file_path,repeat=1):
        self.max_sentence_length = 200
        self.repeat = repeat
        self.wv_model = Word2Vec.load('pretrain_model/word2vec/word2vec_newtrain.model')
        self.wv_words = self.wv_model.wv.index2word
        self.wv_dim = 100
        self.data_list = self.read_file(file_path)
        self.output = self.word2vec_paddings_tensor(self.data_list)

    def read_file(self,file_path):
        data_list = []
        df = pd.read_csv(file_path)# tsv文件
        texts, labels = df['text_padding'], df['label']
        for text, label in tqdm(zip(texts, labels),desc='read data from csv files:'):
            text = text.split(' ')[0:self.max_sentence_length]
            data_list.append((text,label))
        return data_list

    def word2vec_paddings_tensor(self,data_list):
        output = []
        for data,label in tqdm(data_list,desc='text to vord2vec:'):
            vec = []
            for word in data:
                v = self.wv_model[word].tolist()
                vec.append(v)#這裏的vec.append()有點類似cat的操作。
            vec = torch.tensor(vec)#這裏轉化爲tensors,後續可以用到GPU上訓練,vec就是我們需要的文檔向量
            label = torch.tensor(int(label))
            res = (vec,label)
            output.append(res)
        return output

    def __getitem__(self, item):
        text = self.output[item][0]
        label = self.output[item][1]
        return text,label

    def __len__(self):
        if self.repeat == None:
            data_len = 10000000
        else:
            data_len = len(self.output)
        return data_len

注意上述中的函數:def __getitem__(self, item)和def __len__(self)這兩個函數比較重要。

4、TexTCNN模型構造

簡單的描敘一下TextCNN,就是把二維的CNN卷積神經網絡應用到文本特徵提取中。構造模型的時候注意,卷積核和通道數,以及輸出數目,直接上代碼:

import torch
import torch.nn as nn
import torch.nn.functional as F


class TextCNN(nn.Module):
    def __init__(self):
        super(TextCNN,self).__init__()

        class_num = 8
        embedding_dim = 100
        ci = 1
        kernel_num = 25
        # kernel_sizes = [3,4,5]
        # self.convs = nn.ModuleList([nn.Conv2d(ci,kernel_num,(k,embedding_dim/2))for k in kernel_sizes])
        # #含義說明:nn.Conv2d(ci,kernel_num,(k,embedding_dim))
        # #ci就是輸入的通道數目,是要和數據對的上的;kernel_num這裏的意思就是輸出通道數目;(k,embedding_dim)卷積核的形狀,也就是2維度的k*embedding_dim
        # #nn.Conv2d(ci,cj,k)這裏的K就是表示卷積核的形狀是正方形的,k*k

        self.conv1 = nn.Conv2d(ci, kernel_num, (3, int(embedding_dim))) #這裏一定要輸入4維向量[B,C,L,D]
        self.conv2 = nn.Conv2d(ci, kernel_num, (5, int(embedding_dim)))
        self.conv3 = nn.Conv2d(ci, kernel_num, (7, int(embedding_dim)))
        self.conv4 = nn.Conv2d(ci, kernel_num, (9, int(embedding_dim)))

        self.dropout = nn.Dropout(0.5)#丟掉10%
        self.classificer = nn.Linear(kernel_num*4,class_num)

    def conv_and_pool(self, x, conv):
        #(B, Ci, L, D)
        x = F.relu(conv(x))#(B,kernel_num,L-3+1,D-D+1)
        x = x.squeeze(3)# (B, kernel_num, L-3+1)
        x = F.max_pool1d(x, x.size(2))#(B, kernel_num,1)
        x = x.squeeze(2)# (B,kernel_num) squeeze壓縮維度
        return x

    def forward(self,x):
        #size(B,L,D)
        x = x.unsqueeze(1)  #(B, Ci, L, D)#unsqueeze增加維度

        x1 = self.conv_and_pool(x, self.conv1)  # (B,kernel_num)
        x2 = self.conv_and_pool(x, self.conv2)  # (B,kernel_num)
        x3 = self.conv_and_pool(x, self.conv3)  # (B,kernel_num)
        x4 = self.conv_and_pool(x, self.conv4)  # (B,kernel_num)

        x = torch.cat((x1, x2, x3,x4), 1)  # (B,len(Ks)*kernel_num)
        x = self.dropout(x)  # (B, len(Ks)*kernel_num)
        logit = self.classificer(x)  # (B, C)
        return logit

注意每個輸入輸出向量shape的對應,不熟悉的就需要慢慢調試。代碼註釋中已經對卷積核的一些情況作了一些說明。

模型訓練沒有特別注意的事項,只需要把batch_size、學習率,優化器以及學習率調整策略設置好。當然模型可視化監控,可以使用tensorboardx來監控loss、準確率變化以及模型的結構等。具體的代碼也不放在這裏了,文末放上自己的github,上面有全部的項目代碼。這裏有必要把tensorboardx監控loss和準確率及模型之類的代碼說一說。

from tensorboardX import SummaryWriter 
writer = SummaryWriter('runs/exp')



 writer.add_scalar('train_loss', loss.item(), global_step=global_step)
 writer.add_scalar('dev_loss', dev_loss.item(), global_step=global_step)
 writer.add_scalar('train acc', train_acc, global_step=global_step)
 writer.add_scalar('dev acc', dev_acc, global_step=global_step)

writer.close()

在訓練代碼中添加上以上代碼,其中SummaryWriter('runs/exp')就是確定把你的運行日誌保存到'runs/exp‘路徑下。然後把各種指標添加到add_scalar中。最後在項目目錄下終端執行以下命令:

tensorboard  --logdir=runs

結果就會出來一個瀏覽器地址,打開就可以看到我們訓練的過程了。上圖:

訓練過程中的訓練集和驗證集的準確率已經loss變化情況都能很直觀的觀測!最終觀測到的準確率是65%。

三、TextRNN模型長文本分類

TextRNN其實就是一個Bilstm+Linear的網絡,整個流程和方案其實和上面的TextCNN是一樣的。主要是模型的結構不一樣的,直接上TextRNN結構代碼:

import torch
import torch.nn as nn

"""
TextRNN,其實就是利用了Bilstm把句子的最後時刻或者說是最後那個字(這裏可能不好理解)的hidden state,拿出來喂入分類器中,進行分類的。
這裏仍然沒有使用隨機的embedding,我們仍然使用word2vec的詞向量,經過操作來生成文本向量。
開始hidden_size設置爲200,發現效果太差了,loss都不下降的
50詞語的時候驗證集準確率能到73%

訓練過程中還是要監控驗證集準確率
"""

class TextRNN(nn.Module):
    def __init__(self):
        super(TextRNN,self).__init__()
        self.embedding_dim = 100 #文本詞或者字的向量維度
        self.hidden_size = 50 #lstm的長度,可以和seq_legth一樣,也可以比它長
        self.layer_num = 2
        self.class_num = 8


        self.lstm = nn.LSTM(self.embedding_dim, # x的特徵維度,即embedding_dim
                            self.hidden_size, # stm的長度,可以和seq_legth一樣,也可以比它長
                            self.layer_num, # 把lstm作爲一個整體,然後堆疊的個數的含義
                            batch_first=True,
                            bidirectional=True
                            )
        self.classificer = nn.Linear(self.hidden_size*2,self.class_num)#bidirectional雙向就是2,單向就是1

    def forward(self,x):
        #x的維度爲(batch_size, time_step, input_size=embedding_dim)

        # 隱層初始化
        # h0維度爲(num_layers*direction_num, batch_size, hidden_size)
        # c0維度爲(num_layers*direction_num, batch_size, hidden_size)
        h0 = torch.zeros(self.layer_num*2,x.size(0),self.hidden_size).to('cuda')
        c0 = torch.zeros(self.layer_num*2,x.size(0),self.hidden_size).to('cuda')

        #out維度爲(batch_size, seq_length, hidden_size * direction_num)
        out,(hn,cn)  =self.lstm(x,(h0,c0))
        #最後一步的輸出, 即(batch_size, -1, output_size)
        logit = self.classificer(out[:,-1,:])  # (B, C)
        return logit

這個模型中,需要注意的就是Lstm模型構建的參數,就是embedding_dim、hidden_size和layer_num的設置,還有輸入向量和(h0,c0)等。說明一下:這裏lstm的time_step也就是代碼中的hidden_size應該就是等同於句子的長度,或者要比它長,短了應該不行的。這個方案使用的詞向量採用的是word2vec,文檔向量仍然是用每個詞的詞向量進行cat來表示的。另外一方面,關於模型效果和loss變化與輸入向量的長度的關係。

TextRNN,其實就是利用了Bilstm把句子的最後時刻或者說是最後那個字(這裏可能不好理解)的hidden state,拿出來喂入分類器中,進行分類的。
這裏仍然沒有使用隨機的embedding,我們仍然使用word2vec的詞向量,經過操作來生成文本向量。
開始hidden_size設置爲200,發現效果太差了,loss都不下降的
50詞語的時候驗證集準確率能到73%

個人經驗,lstm對文本的長度300個字以內,訓練的時候還算比較容易。直接貼上訓練過程的最終結果和訓練指標變化。

最終的顯示結果是驗證集準確率72.88%,但是這裏用的是50個詞語的長度,和上面的TextCNN結果那個不能進行嚴格的比較。

四、TextRNN+ATT模型長文本分類

顧名思義,這個模型就是在上述的模型中添加一個attention機制。attention機制有很多類型,這裏就是用了普通的軟注意力機制。直接上代碼:

import torch
import torch.nn as nn
import torch.nn.functional as F

"""
這裏需要實現一個attention模塊,這裏就是用一般的attention,而不是特殊的self-attention機制等
attention的一種公式:
M = tanh(H)
a = softmax(WM)
att_score = H*a
上面的是矩陣形式
"""

class TextRNN_Att(nn.Module):
    def __init__(self):
        super(TextRNN_Att,self).__init__()
        self.embedding_dim = 100
        self.hidden_size = 50 #lstm的長度,可以和seq_legth一樣,也可以比它長
        self.layer_num = 2
        self.class_num = 8
        self.attention_size = 256

        self.lstm = nn.LSTM(self.embedding_dim, # x的特徵維度,即embedding_dim
                            self.hidden_size, #lstm的時間長度,這裏可以表示爲文本長度
                            self.layer_num, #把lstm作爲一個整體,然後堆疊的個數的含義
                            batch_first=True,
                            bidirectional=True
                            )

        self.classificer = nn.Linear(self.hidden_size*2,self.class_num)#bidirectional雙向就是2,單向就是1

    def attention(self,lstm_output):#lstm_output[batch_size, seq_length, hidden_size * direction_num]
        """
        :param lstm_output:
        :return: output
        這個是普通注意力機制attention的一種公式:
        M = tanh(H)
        a = softmax(WM)
        att_score = H*a
        上面的是矩陣形式
        """
        #初始化一個權重參數w_omega[hidden_size*layer_num,attention_size]
        #u_omega[attention_size,1]
        w_omega = nn.Parameter(torch.zeros(self.hidden_size*self.layer_num,self.attention_size)).to('cuda')
        u_omega = nn.Parameter(torch.zeros(self.attention_size,1)).to('cuda')

        #att_u[b,seq_length,attention_size]
        att_u = torch.tanh(torch.matmul(lstm_output,w_omega))

        # print('att_u',att_u)
        # print('att_u', att_u.size())

        #att_a[b, seq_length, 1]
        att_a = torch.matmul(att_u,u_omega)
        # print('att_a', att_a)
        # print('att_a', att_a.size())

        # att_score[b, seq_length, 1]
        att_score = F.softmax(att_a,dim=1)
        # print('att_score', att_score)
        # print('att_score', att_score.size())

        # att_output[b, seq_length, hidden_size * direction_num]
        att_output = lstm_output*att_score
        # print('att_output', att_output)
        # print('att_output', att_output.size())

        # output[b, hidden_size * direction_num]
        output = torch.sum(att_output,dim=1)
        # print('output', output)
        # print('output', output.size())

        return output

    def forward(self,x):
        #x的維度爲(batch_size, time_step, input_size=embedding_dim)

        # 隱層初始化
        # h0維度爲(num_layers*direction_num, batch_size, hidden_size)
        # c0維度爲(num_layers*direction_num, batch_size, hidden_size)
        h0 = torch.zeros(self.layer_num*2,x.size(0),self.hidden_size).to('cuda') #定義一定要用torch.zeros(),torch.Tensor()只是定義了一個類型,並沒有賦值
        c0 = torch.zeros(self.layer_num*2,x.size(0),self.hidden_size).to('cuda')

        #out維度爲(batch_size, seq_length, hidden_size * direction_num)
        lstm_out,(hn,cn)  =self.lstm(x,(h0,c0))

        # attn_output[b, hidden_size * direction_num]
        attn_output = self.attention(lstm_out)#注意力機制

        logit = self.classificer(attn_output)

        return logit

具體的attention模塊兒,看對應的公式就可以得到具體的實現。具體的模型中各個向量輸入的維度和意義都在註釋代碼中予以註釋,可以詳細閱讀。後續模型訓練和數據文本讀取都是同TextCNN那個方案中的是一樣的,不做說明了。貼上結果和訓練過程:

可以看到這裏的驗證集的準確率在BiLstm添加上了Attention模型了,效果還有些降低。這裏可能的原因是attention捕捉的特徵用於分類,反而在這裏並沒有直接使用lstm的效果要好,所以還是不能太迷信模型,一定要去做實驗!

五、Bert模型長文本分類(不更新bert權重和更新bert權重)

首先說一下使用bert模型來做分類的方案,其實差不多。第一種就是把Bert模型僅僅是看着一個提取向量的工具,本身不參與任務的訓練過程中,bert模型的權重參數就不更新;另外一種還是把bert模型看作一個提取向量的工具,本身也是參與任務的訓練過程的,bert模型的權重參數會更新——這種方案其實就是fine-tune。其實這兩種方法的代碼實現可以說是幾乎一模一樣的,唯一的一個就是在模型的構建的時候是不是把梯度鎖住,這樣就能進行權重更新和權重不更新的切換。

TextBert模型的構建,其實邏輯很簡單,就是輸入文本,經過bert,得到輸出向量,然後把輸出向量喂入分類器中(nn.Linear())等,就可以得到分類結果。上代碼:

import torch
import torch.nn as nn
import torch.nn.functional as F
from transformers import BertModel

"""
這裏的模型設置的是bert模型在訓練的過程中不會改變權重,這個可以和bert權重參與訓練做對比
"""

class TextBert(nn.Module):
    def __init__(self,args):
        super(TextBert,self).__init__()
        self.bert = BertModel.from_pretrained(args.model_path)
        #param.requires_grad = False 訓練的時候不改變初始預訓練bert的權重值
        for param in self.bert.parameters():
            param.requires_grad = args.requires_grad

        self.cl1 = nn.Linear(768,768)
        self.dropout = nn.Dropout(0.5)
        self.cl2 = nn.Linear(768,8)

    def forward(self,input_ids,input_mask):
        embedding = self.bert(input_ids,input_mask)[0]
        mean_embedding = torch.mean(embedding,dim=1)
        x = self.dropout(mean_embedding)
        x = self.cl1(x)
        x = self.dropout(x)
        logit = self.cl2(x)
        return logit
param.requires_grad = False 訓練的時候不改變初始預訓練bert的權重值

這個參數就能控制Bert模型本身是否參與訓練從而更新參數的。這個方案麻煩的地方在於把文本數據轉化爲向量,然後能夠根據batch_size大小來喂入模型中,所以這裏就需要一個dataLoader類似的模塊。我們自己寫一個代碼如下:

from torch.utils.data import Dataset
from transformers import BertTokenizer
import torch
from tqdm import tqdm
import os
import logging
logger = logging.getLogger(__name__)

class ReadDataSet(Dataset):
    def __init__(self,data_file_name,args,repeat=1):
        self.max_sentence_length = args.max_sentence_length
        self.repeat = repeat
        self.tokenizer = BertTokenizer.from_pretrained(args.model_path)
        self.process_data_list = self.read_file(args.data_file_path,data_file_name)


    def read_file(self,file_path,file_name):
        file_name_sub = file_name.split('.')[0]
        file_cach_path = os.path.join(file_path,"cached_{}".format(file_name_sub))
        if os.path.exists(file_cach_path):#直接從cach中加載
            logger.info('Load tokenizering from cached file %s', file_cach_path)
            process_data_list = torch.load(file_cach_path)
            return process_data_list
        else:
            file_path = os.path.join(file_path,file_name)
            data_list = []
            with open(file_path, 'r') as f:
                lines = f.readlines()
            for line in tqdm(lines, desc='read data'):
                line = line.strip().split('\t')
                data_list.append((line[0], line[1]))

            process_data_list = []
            for ele in tqdm(data_list, desc="Tokenizering"):
                res = self.do_process_data(ele)
                process_data_list.append(res)
            logger.info('Saving tokenizering into cached file %s',file_cach_path)
            torch.save(process_data_list,file_cach_path)#保存在cach中
            return process_data_list


    def do_process_data(self, params):

        res = []
        sentence = params[0]
        label = params[1]

        input_ids, input_mask = self.convert_into_indextokens_and_segment_id(sentence)
        input_ids = torch.tensor(input_ids, dtype=torch.long)
        input_mask = torch.tensor(input_mask, dtype=torch.long)

        label = torch.tensor(int(label))

        res.append(input_ids)
        res.append(input_mask)
        res.append(label)

        return res

    def convert_into_indextokens_and_segment_id(self, text):
        tokeniz_text = self.tokenizer.tokenize(text[0:self.max_sentence_length])
        input_ids = self.tokenizer.convert_tokens_to_ids(tokeniz_text)
        input_mask = [1] * len(input_ids)

        pad_indextokens = [0] * (self.max_sentence_length - len(input_ids))
        input_ids.extend(pad_indextokens)
        input_mask_pad = [0] * (self.max_sentence_length - len(input_mask))
        input_mask.extend(input_mask_pad)
        return input_ids, input_mask

    def __getitem__(self, item):
        input_ids = self.process_data_list[item][0]
        input_mask = self.process_data_list[item][1]
        label = self.process_data_list[item][2]
        return input_ids, input_mask,label

    def __len__(self):
        if self.repeat == None:
            data_len = 10000000
        else:
            data_len = len(self.process_data_list)
        return data_len


注意到bert模型輸入需要3個向量,它們分別是input_ids、segment_ids和pos_ids等。所以需要把文本對應的這3個向量得到,然後轉化爲tensor類型。由於數據量比較巨大,所以在第一次得到這些tensor後,可以做一個序列化操作,保存在本地,下次訓練的時候,可以直接讀取加快速度,這裏序列化採用的是torch.save()方法。同時爲了提高模型的準確率,我們文本的長度沒有選擇Bert—base的極限510,而是選擇了400,期待能得到好的結果。

模型訓練

當bert模型權重更新的時候,這個時候初始的LR一定要設置爲比較常見的1e-5、2e-5之類的,另外優化器也使用比較常見的AdamW。其他的也就沒有什麼可說的了,早停止呀,epoch設置等都是比較基礎的。

當bert模型權重不更新的時候,初始的LR可以設置的稍微大一些,0.001之類的比較常見的,其他的同上。

直接上結果:

1、Bert模型不參與訓練

準確率72.94%,都要比上述的模型效果要好,果然還是bert厲害!

2、Bert模型參數訓練

準確率83.59%,比之前的所有模型的性能都要好,而且還是採用了400字的長度。模型可以說這個效果是完全吊打其他的模型。

總結和展望

本文針對中文長文本的多分類問題,做了不同模型的全流程實現方案的展示,意在熟悉每個流程的coding和模型的一些細節。同時也可以對不同模型在長文本分類的效果上有一個基線,以後做類似的任務,就能很快的選擇技術方案和排錯。同時也對模型訓練過程監控的可視化顯示有了一個嘗試,說明越來越有煉丹師的氣質了呀!哈哈哈哈哈哈

展望,其實目前業界對廣泛的長文本分類並沒有效果很好的方法,不同的論文中也提出了一些嘗試和方法。在我的另一篇博客中——bert模型簡介、transformers中bert模型源碼閱讀、分類任務實戰和難點總結——是有提到的,比如說暴力截斷呀、特定選取、滑窗法之類的。最近在看一篇論文,文章的創新思路也比較奇特,實驗部分提到的文本分類的效果很好,得到了state-of-art。有空了可以把它的算法做做實驗看看,如果效果挺好,那麼長文本分類任務就有了一種好的解決辦法。後續應該會有一個博客分享的。

關於這個多模型的長文本分類項目我全部的代碼,在我的github上。另外有關數據集的問題,這個是公開的一個專利數據集,可以自行去搜索一下,我記得數據集好像是在國傢什麼專利機構的網站上,貌似要註冊一下,然後才能下載的。

該項目全部代碼地址:https://github.com/HUSTHY/classificationTask

 

參考文章:

Pytorch CNN搭建(NLP)

pytorch實現textCNN

中文文本分類 pytorch實現

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

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