从jieba分词到BERT-wwm——中文自然语言处理(NLP)基础分享系列(10)

训练孪生LSTM深度学习网络的代码

首先,我们把需要导入的包集中放在最前面。

import numpy as np 
import pandas as pd 
import pickle 
import torch 
import torch.nn as nn 

from torchtext.vocab import build_vocab_from_iterator 

from torchtext.data.functional import simple_space_split 
from torchtext.data.functional import numericalize_tokens_from_iterator 

from torchtext.functional import truncate 
from torchtext.functional import to_tensor 

from torch.utils.data import Dataset 
from torch.utils.data import DataLoader 
from torch.utils.data import random_split 

将目标变量进行One-hot Encoding

为了便于后续处理,这里需要修改一下用来处理训练数据中的label字段的自定义函数。

label_to_index = { 'unrelated' : 0 , 'agreed' : 1 , 'disagreed' : 2 } 
label_pipeline = lambda x : np.eye(3)[label_to_index [ x ]] 
label_pipeline('agreed').tolist()
[0.0, 1.0, 0.0]

你可以看到现在每个label 都从1 个数字变成一个3 维的向量(Vector)。
每1 维度则对应到1 个分类:
• [1, 0, 0]代表label 为unrelated
• [0, 1, 0]代表label 为agreed
• [0, 0, 1]代表label 为disagreed

用这样的方式表达label 的好处是我们可以把分类结果想成概率分布。比方说[0.7, 0.2, 0.1],此预测结果代表模型认为这两个新闻标题的关系有70 % 的概率为unrelated、20 % 的概率是 agreed 而10 % 为disagreed。
这样我们会比较好计算预测结果跟正确解答之间差距,模型就会自动修正学习方向,想尽办法拉近这个差距。

定义一些作为模型超参数的全局变量。

MAX_LEN = 20 
TEST_SPLIT = 0.1 

BATCH_SIZE = 32 
EMBEDDING_DIM = 64 

LSTM_UNITS = 100 
DROP_OUT = 0.2 

LEARNING_RATE = 0.001 

输入数据预处理

为了便于后续处理,myDataset 子类做了以下修改:

# 对于词典外的生词用0作为index

# 对lable的One-hot处理需要做一下修改

# 增加了一个自定义方法get_vocab_size用来返回词典规模

# 自定义一个DataSet 对输入数据进行预处理 
class myDataset(Dataset): 
    def __init__(self, picked_file , max_len=20, transform=None): 
        super().__init__() 
        pkl_file_rb = open(picked_file, 'rb') 
        train =pickle.load(pkl_file_rb) 

        corpus = pd.concat([train . title1_tokenized, train . title2_tokenized]) 
        corpus = [c for c in corpus] 

        vocab = build_vocab_from_iterator(simple_space_split(corpus),\
                                          min_freq=2, specials=[""]) 
        vocab.set_default_index(0) # 对于词典外的生词用0作为index 
        self.vocab_size = vocab.__len__() # 词典大小规模  
        
        y_train = train.label.apply(label_pipeline) 
        
        # 数字化处理成对的新闻标题A和B 
        tensor_x = {} 
        for i in range(2): 
            x = train.title1_tokenized if i==0 else train.title2_tokenized 
            tmp_x = [] 
            ids_iter_x = numericalize_tokens_from_iterator\
            (vocab,simple_space_split([c for c in x])) 
            for ids in ids_iter_x: 
                tmp_x.append( truncate([num for num in ids],MAX_LEN )) 
            tensor_x[i] = to_tensor(tmp_x, padding_value=0) 
        
        # 新闻标题A和B的数据拼接  
        self.x = torch.stack([tensor_x[0], tensor_x[1]], 1) 
        
        # 对lable的One-hot处理需要做一下修改 
        y_train_list = [y.tolist() for y in y_train.values]
        self.y = torch.from_numpy(np.asarray(y_train_list)) 
        
        self.transform = transform 

    def __len__(self): 
        return len(self.y)  # 数据集长度 
    
    def __getitem__(self, index): 
        x = self.x[index]  # tensor类型 
        y = self.y[index] 
        if self.transform is not None: 
            x = self.transform(x)  # 对输入进行某些变换 
        
        return x, y 
    
    def get_vocab_size(self): 
        return self.vocab_size # 词典规模 

下面用Pytorch 函数random_split 分割训练集和验证集。

如果你还要问这个save_file文件哪里来的? 请参看一下本系列的第3期。

full_dataset = myDataset(r'./save_file',max_len=MAX_LEN) 
dataset_size = len(full_dataset) 
test_size = int(TEST_SPLIT * dataset_size)
train_size = dataset_size - test_size
print(train_size,test_size)

train_dataset,test_dataset = random_split(full_dataset,[train_size, test_size]) 
vocab_size = full_dataset.get_vocab_size() 
c:\users\hp\appdata\local\programs\python\python37\lib\site-packages\torch\_jit_internal.py:1138: UserWarning: The inner type of a container is lost when calling torch.jit.isinstance in eager mode. For example, List[int] would become list and therefore falsely return True for List[float] or List[str].
  warnings.warn("The inner type of a container is lost when "


288497 32055

定义我们的深度学习网络

我们通过子类化nn.Module 定义我们的神经网络Net,并在**init** 方法中初始化神经网络层,它主要包括以下结构:

一个用来生成词向量(word Vector)的embedding层;
一个默认2层的共享LSTM,这里为了灵活性增加了一个bidirectional参数从,用来区分是单向还是双向。所谓双向LSTM(Bi-LSTM),简单理解就是一个文档除了从左向右处理一遍,再倒过来从右向左处理一遍,这样会形成不同的记忆;
一个组合分类器,串联了Dropout+Linear+ReLU+Linear层。

在方法forward 中实现对输入数据的操作,代码详细注释了tensor维数的变换过程。

class Net(nn.Module): 
    def __init__(self,num_embeddings=vocab_size, embedding_dim=EMBEDDING_DIM, padding_idx=0, max_norm=True,\
                 hidden_size=LSTM_UNITS,num_layers=2, bidirectional=False, batch_first=True,drop_out=DROP_OUT): 
        super(Net, self).__init__() 
        self.embedding = nn.Embedding(num_embeddings=num_embeddings, embedding_dim=embedding_dim,\
                                      padding_idx=padding_idx, max_norm=max_norm) 
        self.shared_lstm = nn.LSTM(input_size=embedding_dim, hidden_size=hidden_size,\
                                   num_layers=num_layers, bidirectional=bidirectional, batch_first=batch_first) 
        num_direction = 2 if bidirectional else 1   # 单向/双向LSTM可选 
        self.classifier = nn.Sequential(
            nn.Dropout(drop_out),
            nn.Linear(2 * num_layers * hidden_size * num_direction, 84),
            nn.ReLU(),
            nn.Linear(84, 3)
        )
        
    def forward(self, x): 
        #top_ 和 bm_ 分别对应来自新闻标题A和B的数据 
        top_embedded = self.embedding(x)[:,0,:,:] 
        bm_embedded = self.embedding(x)[:,1,:,:] 
        #print('top_embedded.size():',top_embedded.size()) 
        #[BATCH_SIZE, MAX_LEN, EMBEDDING_DIM] 
        
        output, (hn, cn) = self.shared_lstm(top_embedded) 
        #top_output = output 
        #print('hn.size(): ',hn.size()) 
        #[num_layers * num_direction, BATCH_SIZE, hidden_size] 

        hn = torch.transpose(hn, 0, 1).contiguous() #转换第0维和第1维,将BATCH_SIZE放到最前 
        #print('hn.size(): ',hn.size()) 
        #[BATCH_SIZE, num_layers * num_direction, hidden_size] 
        
        top_output = hn.view(hn.shape[0], -1) #简化处理,使用LSTM隐层输出 
        #print('top_output.size(): ',top_output.size()) 
        #[BATCH_SIZE, num_layers * num_direction * hidden_size] 

        output, (hn, cn) = self.shared_lstm(bm_embedded) 
        #bm_output = output 
        
        hn = torch.transpose(hn, 0, 1).contiguous() 
        bm_output = hn.view(hn.shape[0], -1) #简化处理,使用LSTM隐层输出 

        merged = torch.cat((top_output,bm_output),dim=1) 
        #是否还记得上回说的「孪生网络」?我们需要把来自新闻标题A和B的成对数据做个拼接 
        #print('merged.size(): ',merged.size()) 
        #[BATCH_SIZE, 2 * num_layers * num_direction * hidden_size] 
        
        logits = self.classifier(merged)
        
        return logits 

下面定义训练函数和验证函数。
在训练循环中,参数优化步骤如下(后面我们会对损失、梯度和优化器再做详细解释):

1、调用optimizer.zero_grad()以重置模型参数的梯度。默认情况下梯度渐变会累加;为了防止重复计算,在每次迭代时明确地将它们归零。
2、调用net(inputs)执行forward,基于当下训练参数计算输出值,通过criterion(outputs, labels)计算损失loss。
3、调用来反向传播预测损失loss.backward()。PyTorch 存储每个参数的损失梯度。
4、一旦有了梯度,调用optimizer.step()通过在反向传递中收集的梯度来调整参数。

def train(net, trainloader, optimizer, device, criterion): 
    for epoch in range(2):  # 在数据集上循环多次  

        running_loss = 0.0 
        for i, data in enumerate(trainloader, 0): 
            # 获取输入数据  
            inputs, labels = data 
            # inputs: [BATCH_SIZE, 2, MAX_LEN] labels: [BATCH_SIZE, 3] 

            # 将参数梯度归零  
            optimizer.zero_grad() 

            # forward + backward + optimize 
            outputs = net(inputs) 
            loss = criterion(outputs, labels) 
            loss.backward() 
            optimizer.step() 

            # 打印统计数据  
            running_loss += loss.item() 
            if i % 1000 == 999:    # 每1000个小批量打印一次 
                print('[%d, %5d] loss: %.3f' %(epoch + 1, i + 1, running_loss / 1000)) 
                running_loss = 0.0 

    print('Finished Training') 
def valid(net, testloader): 
    correct = 0 
    total = 0 
    with torch.no_grad(): 
        for data in testloader: 
            inputs, labels = data 
            outputs = net(inputs) 
            #print(outputs.data) 
            #print(labels.argmax(1)) 
            #print(labels.size(0)) 
            _, predicted = torch.max(outputs.data, 1) 
            # 按维度dim 返回最大值以及最大值的索引 
            #print(predicted) 
            total += labels.size(0) 
            correct += (predicted == labels.argmax(1)).sum().item() 

    print('Accuracy of the network on the valid dataset: %.1f %%' % (100 * correct / total)) 
device = torch.device("cuda" if torch.cuda.is_available() else "cpu") 
print(f"Using {device} device") 
Using cpu device
model = Net(bidirectional=False).to(device) 
print(f"Model structure: {model}\n\n")
Model structure: Net(
  (embedding): Embedding(61841, 64, padding_idx=0, max_norm=True)
  (shared_lstm): LSTM(64, 100, num_layers=2, batch_first=True)
  (classifier): Sequential(
    (0): Dropout(p=0.2, inplace=False)
    (1): Linear(in_features=400, out_features=84, bias=True)
    (2): ReLU()
    (3): Linear(in_features=84, out_features=3, bias=True)
  )
)

关于损失、梯度和优化器

损失函数
在上面One-hot编码时,我们提到过需要计算预测结果跟正确解答之间差距,而损失函数正是用来衡量得到的结果与目标值的相异程度。
我们在训练时要最小化的损失函数。为了计算损失,我们使用给定数据样本的输入进行预测,并将其与真实数据标签值进行比较。

我们的任务使用的是交叉熵损失nn.CrossEntropyLoss,它结合nn.LogSoftmaxnn.NLLLoss,将对 logits 进行归一化并计算预测误差。

梯度
为了优化神经网络中参数的权重,我们需要计算我们的损失函数对参数的导数,也就是所谓的梯度。
当参数w 有不同值时,损失函数的值也有所不同。训练模型时会持续修正参数w 以期最小化损失函数,沿梯度下降的方向修正w 是一个常用方法。
为了计算梯度,我们调用loss.backward()

优化器
优化是在每个训练步骤中调整模型参数以减少模型误差的过程。优化算法定义了如何执行这个过程,所有优化逻辑都封装在optimizer对象中。在这里,我们使用Adam 优化器,需要通过注册模型需要训练的参数并传入学习率超参数来初始化优化器。

criterion = nn.CrossEntropyLoss() 
optimizer = torch.optim.Adam(model.parameters(), lr=LEARNING_RATE) 
train_loader = DataLoader(train_dataset, batch_size=BATCH_SIZE, shuffle=False) 
train(net=model, trainloader=train_loader, optimizer=optimizer, device=device, criterion=criterion)
[1,  1000] loss: 0.629
[1,  2000] loss: 0.487
[1,  3000] loss: 0.455
[1,  4000] loss: 0.448
[1,  5000] loss: 0.424
[1,  6000] loss: 0.421
[1,  7000] loss: 0.417
[1,  8000] loss: 0.406
[1,  9000] loss: 0.408
[2,  1000] loss: 0.376
[2,  2000] loss: 0.367
[2,  3000] loss: 0.362
[2,  4000] loss: 0.367
[2,  5000] loss: 0.355
[2,  6000] loss: 0.355
[2,  7000] loss: 0.351
[2,  8000] loss: 0.345
[2,  9000] loss: 0.347
Finished Training
test_loader = DataLoader(test_dataset, batch_size=BATCH_SIZE, shuffle=False) 
valid(net=model, testloader=test_loader)
Accuracy of the network on the valid dataset: 83.1 %

结果是83.1%,比之前最好的TF-IDF未压缩的77.4%有所提升!
这个是单向LSTM的结果,如果改成双向的Bi-LSTM还会稍好一点,可以修改模型参数bidirectional=True 自行尝试。


好了,就到这儿吧。

本系列共12期,将分6天发布。相关代码已全部分享在我的github项目(moronism189/chinese-nlp-stepbystep)下,急的可以先去那里下载。

你可能感兴趣的:(自然语言处理,深度学习,lstm,pytorch)