NLP总体流程

总体流程

 
将原始段落 分句 分词,形成问答对 
同时整理出词典

对词典的单词编号,并将分词后的单词问答对,转化为索引列表,问句与答句依然保持一一对应关系

索引列表补齐形成索引矩阵,同时标记单词位置得到布尔矩阵-mask

模型输入:索引矩阵+mask布尔矩阵 
模型输出:词典向量矩阵

损失函数:词典向量 与 单词对应的one hot向量 求距离,多分类问题,使用交叉熵

 

    

 

    

 

    
数据准备

成批次的单词

数据准备

 
x为0-9数字,小字字母

y为x的逆序并且大写,x最后一个字母重复两次,逆序后y开头的两个字母相同
- 字母转大写
- 数字取10以内的互补数

数据代码

 
# import os
# 解决mac系统OMP: Error #15: Initializing libiomp5.dylib, but found libomp.dylib already initialized.
# os.environ['KMP_DUPLICATE_LIB_OK']='True'  

import random
import numpy as np
import torch

# 定义字典,数据是0-9数字+小写字母,以及 三个标记 
str_x = '<PAD>,<SOS>,<EOS>,0,1,2,3,4,5,6,7,8,9,q,w,e,r,t,y,u,i,o,p,a,s,d,f,g,h,j,k,l,z,x,c,v,b,n,m'
dict_x = {word: i for i, word in enumerate(str_x.split(','))} 
print(dict_x["<PAD>"])  #0 

#所有的key
dict_xr = [k for k, v in dict_x.items()]  


# 标签是0-9数字+大写字母
dict_y = {k.upper(): v for k, v in dict_x.items()}
dict_yr = [k for k, v in dict_y.items()]



def get_data():
    """获取一对x,y 
    """
    
    # 单词集合,没有标记
    words = [
        '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 
        'q', 'w', 'e', 'r',
        't', 'y', 'u', 'i', 'o', 'p', 'a', 's', 'd', 'f', 'g', 'h', 'j', 'k',
        'l', 'z', 'x', 'c', 'v', 'b', 'n', 'm'
    ]

    # 每个词被选中的概率
    p = np.array([
        1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 
        1,   2,  3,  4,  5,  6,  7,  8,  9, 10, 
        11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26
    ])
    
    # 转概率,所有单词的概率之和为1
    p = p / p.sum()

    # 随机选n个词
    # Return random integer in range [a, b], including both end points.
    n = random.randint(30, 48)
    x = np.random.choice(words, size=n, replace=True, p=p)

    # 采样的结果就是x
    x = x.tolist()

    # y是对x的变换得到的
    # 字母大写,数字取10以内的互补数
    def f(i):
        i = i.upper()
        if not i.isdigit():
            return i
        i = 9 - int(i)
        return str(i)

    y = [f(i) for i in x]

    # 每个标签结尾的字母重复2次,增加任务难度
    y = y + [y[-1]]

    # 逆序
    y = y[::-1]


    # 加上首尾符号
    x = ['<SOS>'] + x + ['<EOS>']
    y = ['<SOS>'] + y + ['<EOS>']

    # 补pad到固定长度
    # 48+2,序列最大长度为50,不足50的补到50
    # y由于重复了一个字母,最大长度为51,不足51的补到51
    x = x + ['<PAD>'] * 50
    y = y + ['<PAD>'] * 51
    
    x = x[:50]
    y = y[:51]

    # 单词序列转 索引列表
    x = [dict_x[i] for i in x]
    y = [dict_y[i] for i in y]

    # 转tensor
    x = torch.LongTensor(x)
    y = torch.LongTensor(y)

    return x, y


# 定义数据集
class Dataset(torch.utils.data.Dataset):
    def __init__(self):
        super(Dataset, self).__init__()

    def __len__(self):
        return 100000

    def __getitem__(self, i):
        return get_data()


# 数据加载器
loader = torch.utils.data.DataLoader(dataset=Dataset(),
                                        batch_size=8,
                                        drop_last=True,
                                        shuffle=True,
                                        collate_fn=None)

if __name__ == '__main__':
    for (X,y) in loader:
        print(X.shape,y.shape)  #torch.Size([8, 50]) torch.Size([8, 51])
        print(X[0])
        """
        tensor([ 1, 38, 38, 30, 32, 30, 32, 20, 27, 35, 38, 36, 23, 25, 15, 28,  8, 37,
        25, 19, 36, 33, 11, 36, 14, 33, 36, 34, 37, 10, 30, 11, 29, 38,  6, 23,
        29,  2,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0])
        """
        
        print(y[0])
        """
        tensor([ 1, 29, 29, 23,  9, 38, 29,  4, 30,  5, 37, 34, 36, 33, 14, 36,  4, 33,
        36, 19, 25, 37,  7, 28, 15, 25, 23, 36, 38, 35, 27, 20, 32, 30, 32, 30,
        38, 38,  2,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0])
        """
        
        break
    
数据矩阵化-补齐

数据处理阶段通过补0形成矩阵数据

 
也可以说成数据对齐,本质还是将批量的数据矩阵化,
要矩阵化,就先得向量化,即先分析单个样本对应的向量是什么

单个样本是有对应关系的两个序列:seq1,seq2 
或者叫编码序列,解码序列

seq1:长度为[30,48]范围内的一个随机,
然后首端添加一个开始标记,尾端添加一个结束标记,最大长度50
random.randint(30, 48)
seq2比seq1多一个字符

矩阵处理要求其内向量维度相等
为了将不定的序列的长度统一,以最长序列为基准,不足的补0 

数据入模前再从矩阵中取真实长度数据

 
数据是按批次处理的,需要矩阵化,就需要每个向量维数相等

入模时,最好还是原数据多长就多长,把补的部分去掉;
若没去掉模型也能通过学习,判断所补内容无效,只是迭代次数需要多一些 


数据集

自动化的 批次加载

 
# 数据加载器
loader = torch.utils.data.DataLoader(dataset=Dataset(),
                                        batch_size=8,
                                        drop_last=True,
                                        shuffle=True,
                                        collate_fn=None)

for X,y in loader:
    print(X.shape,y.shape)
    print(X[0])
    print(y[0])
    break
sys.exit(0)

数字编码,补齐,成对,成批次

 
$ python main_all.py 
torch.Size([8, 50]) torch.Size([8, 51])
tensor([ 0, 36, 30,  7, 26, 27, 38, 31, 26, 32, 27, 31, 31, 13,  9, 28, 31, 37,
        32, 29, 27, 34, 38, 33, 23, 36, 30, 26, 31, 31, 35, 33, 18,  7, 19, 19,
        33, 28, 30, 25, 36,  1,  2,  2,  2,  2,  2,  2,  2,  2])
tensor([ 0, 36, 36, 25, 30, 28, 33, 19, 19,  8, 18, 33, 35, 31, 31, 26, 30, 36,
        23, 33, 38, 34, 27, 29, 32, 37, 31, 28,  6, 13, 31, 31, 27, 32, 26, 31,
        38, 27, 26,  8, 30, 36,  1,  2,  2,  2,  2,  2,  2,  2,  2])


按概率取词

 
# 每个词被选中的概率
p = np.array([
    1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 
    1,   2,  3,  4,  5,  6,  7,  8,  9, 10, 
    11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26
])

# 转概率,所有单词的概率之和为1
p = p / p.sum()

# 随机选n个词
# Return random integer in range [a, b], including both end points.
n = random.randint(30, 48)
x = np.random.choice(words, size=n, replace=True, p=p)

# 采样的结果就是x
x = x.tolist()

开始与结束标记

 
# 加上首尾符号
x = ['SOS'] + x + ['EOS']
y = ['SOS'] + y + ['EOS']

补齐

 
# 补pad到固定长度
# 48+2,序列最大长度为50,不足50的补到50
# y由于重复了一个字母,最大长度为51,不足51的补到51
x = x + ['PAD'] * 50
y = y + ['PAD'] * 51

x = x[:50]
y = y[:51]

50与51不等长处理

 
硬性要求x与y向量的维数必须相等,所有数据都是在统一的量纲上计算,
所以,这里要么把50提升到51,要么把51降为50

x序列的最大长度是48,但最后一字母重复了两次,y的最大序列达到49

y再加上开始和结束标记,最大长度达到51,
如果取前50个,那么部分序列将会丢失最后一个结束标记

评估一下丢失的比例,random.randint(30, 48),注意这是python的包
取值范围为[30,48],取值为48的概率为1/19,
通常脏数据比例若低于1/100其影响则忽略,这里将近1/20,比1/100大的多,
但丢失的仅是最后一个结束标记,影响好像又没那么大

最后一个结束标记影响了什么?正常情况是:
上下文+最后一个单词 -- 结束标记
上下文+结束标记 -- 结束标记
...最多49次循环

但若没有了结束标记,并且刚好遇到最后一个不是结束标记而是单词的情况,则是如下情况:
上下文+倒数第二个单词 -- 最后一个单词
这就是第49次循环,然后序列生成结束

在最多49次循环的代码逻辑下,序列最后位置,是单词还是结束标记,一点都不影响序列生成的正确性
到此,将解码序列的51降为50,不影响模型的准确性

注意力提取特征

 
这个环节非常关键,重要,因此单独设一个专栏讲述,请参考多头注意力

组件配合线性变换

 
原来提取特征的是卷积,RNN,现在用了注意力
整个神经网络的设计中,除了提取特征,还有其他的一些组件:比如,

归一化:加速收敛,注意力计算涉及多层全连接网络,数据分布偏移是必然的,

激活函数:增加非线性能力,注意力的计算全是线性变换,增加一些非线性因素是必要的

dropout:健壮性 

当然了,主体还是那个线性变换 -- 全连接分类

加上组件就形成下面的全连接层:

 
# 全连接输出层
class FullyConnectedOutput(torch.nn.Module):
    """
    使用短接进行微调
    - 特征数先变大再变小
    - 并且是变回原来的大小,这样才能使用短接相加 
    """
    def __init__(self,features=32):
        super().__init__()
        self.fc = torch.nn.Sequential(
            torch.nn.Linear(in_features=features, out_features=features*4),
            torch.nn.ReLU(),
            torch.nn.Linear(in_features=features*4, out_features=features),
            torch.nn.Dropout(p=0.1)
        )

        self.norm = torch.nn.LayerNorm(normalized_shape=features,
                                        elementwise_affine=True)

    def forward(self, x):
        # 保留下原始的x,后面要做短接用
        clone_x = x.clone()

        # 单词维度归一化
        x = self.norm(x)

        # 线性全连接运算
        # [b, seq_len, feature_nums] -> [b, seq_len, feature_nums]
        out = self.fc(x)

        # 做短接
        out = clone_x + out

        return out      

编码器与编码层

编码器层:一次多头注意力提取特征+一层全连接变换

 
# 编码器层
class EncoderLayer(nn.Module):
    def __init__(self):
        super().__init__()
        # 多头注意力
        self.mh = MultiHead()

        # 全连接输出
        self.fc = FullyConnectedOutput()

    def forward(self, x, mask):
        # 计算自注意力,维度不变
        # [b, 50, 32] -> [b, 50, 32]
        score = self.mh(x, x, x, mask)

        # 全连接输出,维度不变
        # [b, 50, 32] -> [b, 50, 32]
        out = self.fc(score)

        return out
    

 

    

 


解码器

分词 -- 索引矩阵 -- 向量化 -- 输入模型

 
import jieba
import torch
from torch import nn
from tpf.vec3 import mask_pad
from tpf.vec3 import mask_tril
from tpf.layer import DecoderLayer
    

索引矩阵生成

 
seq1= "你现阶段的目标是?"
seq2=  "不上班还有钱花"

sentence1 = jieba.lcut(seq1)
word2id = {}
word2id["PAD"] = 0  # 补长度 是为了批次处理
len_add = len(word2id)
word2id.update({word:(index+len_add) for index,word in enumerate(set(sentence1))}) 


sentence2 = jieba.lcut(seq2)
len_add = len(word2id)
word2id.update({word:(index+len_add) for index,word in enumerate(set(sentence2))})

sentence = []
sentence.append([ word2id[word]  for word in sentence1])
sentence.append([ word2id[word]  for word in sentence2])

#矩阵对齐
max_seq_len = 6 
index_mat = []
for word_index in sentence:#取每个句子的索引列表进行对齐 
    word_index = word_index+[word2id["PAD"]]*max_seq_len
    word_index = word_index[:6]
    index_mat.append(word_index) 
    

从索引矩阵中求得补码矩阵:01布尔矩阵,0代表对应位置为单词索引,1为补码

 
index_mat
[[6, 3, 2, 5, 4, 1], [9, 8, 7, 10, 0, 0]]

#索引编码并转换shape为[batch_size,seq_len]
x = torch.tensor(index_mat[0]).unsqueeze(dim=0) #B.shape = [1, 6]
y = torch.tensor(index_mat[1]).unsqueeze(dim=0) #B.shape = [1, 6]

#对索引矩阵进行补码
mask_pad_x = mask_pad(x,padding_index=0)  #mask.shape = [1, 1, 6, 6]
mask_tril_y = mask_tril(y,padding_index=0)
    
    

索引向量化

 
#索引向量化
embed = nn.Embedding(num_embeddings=11,embedding_dim=32, padding_idx=0)
x = embed(x) #x.shape = torch.Size([1, 6, 32])
y = embed(y) 
    

 

    

 


主模型概述

 
编码器:x对自己求几遍/层注意力,然后将数据x传给解码器

解码器:y先自求一遍注意力,再对x求注意力,这个过程来上几遍后,映射到标签维度

主模型:x -- 编码器 -- 解码器 -- 全连接映射到标签 

 
整个过程虽然有很多层,但主体计算是注意力

注意力的计算,有两分支
1. 数据进入模型先clone一份,保存一份原数据,相当于保存了一个镜像
2. 数据开始真正注意力的计算,拆分,交换维度,矩阵乘法求得分,做softmax,再矩阵乘法得上下文向量.... 

最后一步输出的是:步骤1+步骤2,
两个分支相加,即短接,解决了多层网络梯度消失的问题,

每次注意力计算只是改变一点点
每次模型计算也只是改变一点点

这是tranformer核心(注意力+序列等整个计算流程)中的核心(短接,真正解决了序列长度依赖的问题)

主模型代码

默认数据流转的维度为embedding_dim,最后一层全连接转到字典个数

 
import torch 
from torch import nn 
from tpf.nlp.mask import mask_pad
from tpf.nlp.mask import mask_tril
from tpf.mapping import Decoder,Encoder 
from tpf.nlp.pos import PositionEmbedding 


# 主模型
class Transformer(torch.nn.Module):
    """Transformer主流程
    模型定义参数
    - in_features:输入元素向量特征数,比如单词的embedding_dim
    - out_features:输出向量特征数,比如标签的维度,词典单词个数

    模型对象参数
    - x:[B,seq_len],批次索引矩阵,进入模型后先计算补码后编码
    - y:[B,seq_len],批次索引矩阵,进入模型后先计算补码后编码

    返回
    - 标签向量,特征数为单词个数 
    """
    def __init__(self,seq_len=50,in_features=32,out_features=39):
        super().__init__()

        self.seq_len = seq_len

        # 位置编码和词嵌入层
        # out_features对应标签,单词个数/元素个数
        self.embed_x = PositionEmbedding(seq_len=seq_len,num_embeddings=out_features,embedding_dim=in_features)
        self.embed_y = PositionEmbedding(seq_len=seq_len,num_embeddings=out_features,embedding_dim=in_features)
        
        #单词维度/特征数,在神经网络的流转中保持了不变 
        self.encoder = Encoder(features=in_features)
        self.decoder = Decoder(features=in_features)

        #单词embedding_dim 映射到 标签维度
        self.fc_out = torch.nn.Linear(in_features, out_features)

    def forward(self, x, y):
        """x,y为索引矩阵
        # x = [8, 50]
        # y = [8, 51]
        """
        # [b, 1, 50, 50]
        mask_pad_x = mask_pad(x)
        mask_tril_y = mask_tril(y)

        # 编码,添加位置信息
        # x = [b, 50] -> [b, 50, 32]
        # y = [b, 50] -> [b, 50, 32]
        x, y = self.embed_x(x), self.embed_y(y)

        # 编码层计算
        # x: [b, 50, 32] -> [b, 50, 32]
        # mask_pad_x:[b,1,50,50]
        x = self.encoder(x, mask_pad_x)

        # 解码层计算
        # [b, 50, 32],[b, 50, 32] -> [b, 50, 32]
        y = self.decoder(x, y, mask_pad_x, mask_tril_y)

        # 全连接输出,维度改变
        # [b, 50, 32] -> [b, 50, 39]
        y = self.fc_out(y)

        return y

 
将整个编码矩阵,解码矩阵输入主模型,以训练模型参数

最后一步全连接将元素特征维数映射到词典个数
    
解码序列预测

 
编码序列x 与解码序列的y 的shape皆为[B,seq_len,hidden_size/embedding_dim]

输入是[B,seq_len],在模型内编码,
即将[B,seq_len,1]扩展到[B,seq_len,embedding_dim]
这里就规定所有单词变换的维度皆为embedding_dim,简化一下 

模型输出[B,seq_len,embedding_dim],做一个全连接映射到[B,seq_len,dict_size]
求最大值的索引,转化为[B,seq_len]索引矩阵,这就与最初的输入,也与业务对上了 

整个过程皆以批次,以矩阵的形式进行计算
但解码序列是一个生成模型,是前N个元素生成第N+1个元素

要兼顾两个点

 
1. 计算过程中矩阵shape必须一致

2. 第i次计算只涉及前i个单词/元素,因为后面的单词还没生成,这是预测,尚未知 

解决办法:以PAD补全向量使得矩阵shape一致,设置PAD的值为0表示该元素无效

 
第1次计算,输入y0[SOS,PAD,PAD,...,PAD],  输出y1[SOS,单词1,PAD,PAD,...,PAD]
第2次计算,输入y1[SOS,单词1,PAD,...,PAD],输出y2[SOS,单词1,单词2,PAD,...,PAD]
...
第n次计算,输入yn-1[SOS,单词1,单词2,...,PAD],输出yn[SOS,单词1,单词2,...,单词n]    

这里使用序列长度控制最大循环次数

预测伪算法

 
输入:数据X(索引序列向量)及数据字典dict 
输出:数据y,一个索引序列向量
计算:
- 从x中提取特征
- 建立一个seq_len-1次 循环,每次生成一个y元素
  - 第一个y元素为sos的索引y0,初始化向量y=[1,0,0,...,0]后面全是PAD-0
  - y1 = model(x,y0,mask_x,mask_y),y=[1,index_y1,0,...,0]
  - y2 = model(x,y1,mask_x,mask_y),y=[1,index_y1,index_y2,...,0]
  - ... 每次循环填充向量y一个元素,y向量后面的PAD-0逐步被替换为单词索引 
  - 直到结束

预测函数实现

 
# 预测函数
def predict(self, x, dict_y):
    """根据序列x预测序列y 
    - y的第1个单词为SOS标记 
    """
    # x = [1, 50]
    self.eval()


    # [1, 1, 50, 50]
    mask_pad_x = mask_pad(x)

    # x编码,添加位置信息
    # [1, 50] -> [1, 50, 32]
    x = self.embed_x(x)

    # 编码层计算,维度不变
    # [1, 50, 32] -> [1, 50, 32]
    x = self.encoder(x, mask_pad_x)

    #序列长度为seq_len,第一个单词为SOS标记,余下seq_len-1个需要预测
    ydeal_count = self.seq_len-1

    # 初始化输出,这个是固定值
    # [1, 50]
    # [[1,0,0,0...]]
    # 每次输入的shape是[1, 50]但第i次处理只有前i个词有效,其余词为PAD
    target = [dict_y['<SOS>']] + [dict_y['<PAD>']] * ydeal_count
    target = torch.LongTensor(target).unsqueeze(0)

    # 遍历生成第1个词到第49个词
    for i in range(ydeal_count):
        # [1, 50]
        y = target

        # [1, 1, 50, 50]
        mask_tril_y = mask_tril(y)

        # y编码,添加位置信息
        # [1, 50] -> [1, 50, 32]
        y = self.embed_y(y)

        # 解码层计算,维度不变
        # [1, 50, 32],[1, 50, 32] -> [1, 50, 32]
        # 虽然输入的是y向量,多个特征,但会结合mask,只计算mask位置上的单词,
        # 因此每次参与计算的,只有i个 
        y = self.decoder(x, y, mask_pad_x, mask_tril_y)

        # 全连接输出,39分类
        # [1, 50, 32] -> [1, 50, 39]
        # 每次输出的只有一个单词
        # y向量第1个维度是批次,每2个维度是seq_len,第3维是单词维度
        # 只要序列维度中第i个位置的输出,所以这个out还不是最终结果  
        out = self.fc_out(y)

        # 取出当前词的输出
        # [1, 50, 39] -> [1, 39]
        out = out[:, i, :]

        # 取出分类结果
        # [1, 39] -> [1]
        out = out.argmax(dim=1).detach()

        # 以当前词预测下一个词,填到结果中
        target[:, i + 1] = out

    return target

训练

模型,损失函数,优化器

 
# 模型
model = Transformer()

# 损失函数
loss_func = torch.nn.CrossEntropyLoss()

# 优化器,optim并不像其他方法那样在torch.nn下,而是直接在torch下
optim = torch.optim.Adam(model.parameters(), lr=2e-3)

 
模型输入:[batch_size,seq_len]的索引矩阵,
模型输出:[batch_size,seq_len,dict_count],特征数为字典数的单词向量 组成的矩阵 
标签矩阵:[batch_size,seq_len]的索引矩阵

损失函数使用交叉熵,计算模型输出与标签矩阵之间偏差,
优化器迫使 特征数为字典数的单词向量 向标签索引对应的 特征数为字典数的One Hot向量 逼近

训练

 
for epoch in range(1):
for i, (x, y) in enumerate(loader):
    # x = [8, 50]
    # y = [8, 51]

    # 在训练时,是拿y的每一个字符输入,预测下一个字符,所以不需要最后一个字
    # 主要还是因为y是51,x是50,二者要保持一致 
    # [8, 50, 39]
    pred = model(x, y[:, :-1])

    # [8, 50, 39] -> [400, 39]
    # 多少个单词,展平到单词维度,准备按批次计算偏差
    pred = pred.reshape(-1, 39)

    # [8, 51] -> [400]
    # y[:,0]为SOS标记的索引
    y = y[:, 1:].reshape(-1)

    # 忽略pad,忽略一个批次中所有的PAD
    select = y != dict_y['<PAD>']
    pred = pred[select]  #选出pred中所有单词位置的向量
    y = y[select]        #选出y中所有单词的索引,形成一个新的全是单词索引的向量

    #pred[-1,dict_count],y单词索引向量
    #比如,y[0]=2,one hot转标签向量为[0,0,1],期望模型输出的向量为[0,0.2,0.8],最好是[0,0.1,0.9],
    #损失减少的方向就是index=2位置上的数据尽量接近1,其他位置尽量逼近0 
    loss = loss_func(pred, y)
    optim.zero_grad()
    loss.backward()
    optim.step()

    if i % 200 == 0:
        # [select, 39] -> [select]
        pred = pred.argmax(1)
        correct = (pred == y).sum().item()
        accuracy = correct / len(pred)
        lr = optim.param_groups[0]['lr']
        print(epoch, i, lr, loss.item(), accuracy)

预测

 
# 测试
for i, (x, y) in enumerate(loader):
    break

 
for i in range(8):
    print(i)
    print(''.join([dict_xr[i] for i in x[i].tolist()]))
    print(''.join([dict_yr[i] for i in y[i].tolist()]))
    print(''.join([dict_yr[i] for i in predict(x[i].unsqueeze(0))[0].tolist()]))

参考