总体流程 将原始段落 分句 分词,形成问答对 同时整理出词典 对词典的单词编号,并将分词后的单词问答对,转化为索引列表,问句与答句依然保持一一对应关系 索引列表补齐形成索引矩阵,同时标记单词位置得到布尔矩阵-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()]))