NLP总体流程
**总体流程 将原始段落 分句 分词,形成问答对 同时整理出词典 对词典的单词编号,并将分词后的单词问答对,转化为索引列表,问句与答句依然保持一一对应关系 索引列表补齐形成索引矩阵,同时标记单词位置得到布尔矩阵-mask 模型输入:索引矩阵+mask布尔矩阵 模型输出:词典向量矩阵 损失函数:词典向量 与 单词对应的one hot向量 求距离,多分类问题,使用交叉熵 神经元 模拟人的神经,概念很好 但只能处理非常简单的问题 全连接 多个神经元,计算量大 ,可以处理稍微复杂点的问题,但总体来说,还是只能处理简单的问题 卷积 - 流式计算,解决瞬间计算量大的问题, - 可以提取区域特征,提取特征的能力比全连接强 - 之后全连接沦为分类,提取特征的工作就交给卷积了 - 图像领域火了 RNN - 卷积处理序列特征效果不太好,于是RNN诞生了 - 当下的结果是历史序列造成的 - NLP取得重大突破 - 但RNN太简单了,前后依赖的单词10左右 - 于是LSTM诞生了,能处理20个 - 但LSTM的门有点多,结构有些复杂,于是GRU诞生了 - GRU结构更加简单,效果不比LSTM差 seq2seq - 除了分类问题,业务场景得到了极大的扩展, - 比如翻译,语音转文本,信号处理等 - 但处理序列的长度依然只有20个左右 - 于是注意力诞生了,这是革命性的 - 先上seq2seq+注意力依赖能力得到了极大的提升 - 这种结构叫 seq2seq with attention - 但网络结构也不会太复杂,层数也不会太多,因为会梯度消失 提取特征就用attention 之间的发展从全连接开始,到卷积,到RNN,然后开始在这基础上改良 改复杂了,再简单点,又复杂了,又想办法让它简单点 不管是卷积,还是RNN都是提取特征的,但它们提取特征的能力都不如attention 所以到transformer到,来了一次大革新,提取特征用attention了 刚开始attention是提取序列特征的,现在它也能提取区域特征 所以提取特征就用attention了 短接:N多层的神经网络不会导致梯度消失 之前seq2seq有梯度消失的问题 Transformer使用了短接,解决了梯度消失的问题 可以设计很多层网络 缺点 因为短接,也为网络层次数,每次参数改变小 所以需要的训练量非常地大 用于大型项目 Transformer与seq2seq本质一样,基本流程一致 - 都有编码器,解码器 提取特征的方式不一样,它使用attention提取特征 mytab('ulseq_nlpall', 'tbseq_nlpall'); 数据准备 //Width and height var w = 600; var h = 90; var barPadding = 1; //Create SVG element var svg = d3.select("#dachspace_div") .append("svg") .attr("width", w) .attr("height", h); var dataset = [[20,10,60,160]]; svg.selectAll("rect") .data(dataset) .enter() .append("rect") .attr("x", function(d, i) { return d[0]; }) .attr("y", function(d,i) { return d[1]; }) .attr("height", function(d, i) { return d[2]; }) .attr("width", function(d,i) { return d[3]; }).attr("fill","#efef77") .attr("stroke", "#778899") .attr("stroke-width", "3"); svg.selectAll("text") .data(dataset) .enter() .append("text") .text("成批次的单词") .attr("text-anchor", "middle") .attr("x", function(d, i) { return d[0]+d[2]/2 + 50 ; }) .attr("y", function(d) { return d[1] + 33; }) .attr("font-family", "sans-serif") .attr("font-size", "14px") .attr("fill", "#357210"); // svg.selectAll("text") // .data(dataset) // .enter() // .append("text") // .text("将原始段落分句分词,形成问答对") // .attr("text-anchor", "middle") // .attr("x", function(d, i) { // return d[0] + 30 ; // }) // .attr("y", function(d) { // return d[1] + 50; // }) // .attr("font-family", "sans-serif") // .attr("font-size", "14px") // .attr("fill", "#357210"); // dataset = [[300,10,100,240]]; // svg.selectAll("rect") // .data(dataset) // .enter() // .append("rect") // .attr("x", function(d, i) { // return d[0]; // }) // .attr("y", function(d,i) { // return d[1]; // }) // .attr("height", function(d, i) { // return d[2]; // }) // .attr("width", function(d,i) { // return d[3]; // }).attr("fill","#efef77") // .attr("stroke", "#778899") // .attr("stroke-width", "3"); // svg.selectAll("text") // .data(dataset) // .enter() // .append("text") // .text("索引矩阵") // .attr("text-anchor", "middle") // .attr("x", function(d, i) { // return d[0]+d[2]/2 + 30 ; // }) // .attr("y", function(d) { // return d[1] + 20; // }) // .attr("font-family", "sans-serif") // .attr("font-size", "14px") // .attr("fill", "#357210"); 数据准备 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,不影响模型的准确性 注意力提取特征 这个环节非常关键,重要,因此单独设一个专栏讲述,请参考<a href="/att1_trans" target="_blank">多头注意力</a> 组件配合线性变换 原来提取特征的是卷积,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 编码器:由三层编码器层组成,相同的逻辑重复三次 class Encoder(torch.nn.Module): def __init__(self): super().__init__() self.layer_1 = EncoderLayer() self.layer_2 = EncoderLayer() self.layer_3 = EncoderLayer() def forward(self, x, mask): x = self.layer_1(x, mask) x = self.layer_2(x, mask) x = self.layer_3(x, mask) return x mytab('ulseq_dbmalay', 'tbseq_dbmalay'); 解码器 文本向量化 解码层 解码器 下三角矩阵 分词 -- 索引矩阵 -- 向量化 -- 输入模型 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) 解码层计算逻辑: 解码层先对自己求一次注意力y 再求y相对解码数据x的注意力 全连接+激活函数 -- 最终输出y 这个y的元素维度并非标签的特征数,因为为解码层将作用主模型众多可循环层的中的一层 它的特征维度,与整个数据流中元素的特征维数是一致的, 这是一种设计方式 - 每层接口设计特征的输入与输出特征维数不变,其内部扩大再缩小 - 当然也可以一直扩大,最后一步再收缩,比如resnet,这只是“一种”设计方式 元素特征维度到标签元素特征维度的映射由主模型最后一步实现 from tpf.att import MultiHead from tpf.layer import FullyConnectedOutput # 解码器层 class DecoderLayer(torch.nn.Module): """求y相对x的注意力,带补码 - x.shape默认为[B,C,L] - x.shape默认为[B,C,L] """ def __init__(self): super().__init__() # 自注意力提取输入的特征 self.mh1 = MultiHead() # 融合自己的输入和encoder的输出 self.mh2 = MultiHead() # 全连接输出 self.fc = FullyConnectedOutput() def forward(self, x, y, mask_pad_x, mask_tril_y): # 先计算y的自注意力,维度不变 # [b, 50, 32] -> [b, 50, 32] y = self.mh1(y, y, y, mask_tril_y) # 结合x和y的注意力计算,维度不变 # [b, 50, 32],[b, 50, 32] -> [b, 50, 32] y = self.mh2(y, x, x, mask_pad_x) # 全连接输出,维度不变 # [b, 50, 32] -> [b, 50, 32] y = self.fc(y) return y decoder_layer = DecoderLayer() y = decoder_layer(x,y,mask_pad_x,mask_tril_y) y.shape torch.Size([1, 6, 32]) 多个解码层封装为解码器 class Decoder(torch.nn.Module): """解码器 params - x:[B,L,C] - y:[B,L,C] - mask_pad_x:[B,1,L,L],01布尔矩阵 - mask_tril_y:[B,1,L,L],01布尔矩阵 """ def __init__(self): super().__init__() self.layer_1 = DecoderLayer() self.layer_2 = DecoderLayer() self.layer_3 = DecoderLayer() def forward(self, x, y, mask_pad_x, mask_tril_y): y = self.layer_1(x, y, mask_pad_x, mask_tril_y) y = self.layer_2(x, y, mask_pad_x, mask_tril_y) y = self.layer_3(x, y, mask_pad_x, mask_tril_y) return y decoder = Decoder() y = decoder(x,y,mask_pad_x,mask_tril_y) y.shape torch.Size([1, 6, 32]) x输入的是[b,seq_len] -- [b,1,1,50] -- [b,1,50,50] - 将一句话转换为一个近似2维表的矩阵 -- [b,1,50,50] - row是单词,col也是单词,数据则是单词之间的相似度(数字) - 最终这个矩阵化为一个向量,上下文向量,每个句子对应一个向量 - 这个向量参与 解码序列的计算 y输出的也是[b,seq_len] -- [b,1,1,50] -- [b,1,50,50] - 这个求自注意力的过程与x一样 - 不同的是y补码使用的是下三角矩阵 - 同样是每句话下有[50,50]这个矩阵,但因为补码的不同 - 第i句话只有前i个单词是真实的,后n-i个是补码 比如,你好吗,使用下三角矩阵求自注意力 - 你 -- 你 - 好 -- 你好 - 吗 -- 你好吗 - 然后这个[3,3]矩阵化为一个向量,代表y-你好吗 到这里,就变换一个x相关的向量对应一个y相关的向量 - 于是输入x就得到了y,一个序列得到一个序列 - 但这个下三角的计算是复合的 - 正常的应该是 - seq1+y1 -- y2 - seq1+y1y2 -- y3 - seq1+y1y2y3 -- y4 - 但结果表明下三角的计算是有用的,其结果与这种分步计算相同 mytab('ulseq_decdlayert', 'tbseq_decdlayert'); 主模型概述 编码器: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)
``` 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) loss = loss_func(pred, y) ``` - 模型输出的序列与真实序列之间错了一位 - 针对完成的序列来说,模型输入的是前n-1个单词,损失函数只输出了单词n 序列1 :你好 序列2 :你好 序列2的完整序列为:SOS,你,好,EOS 模型输入:SOS,你,好 真实标签:你,好,EOS 损失函数逼迫SOS的输出 与 你 接近,进而做到预测下一步未知单词的目的
预测
``` # 测试 for i, (x, y) in enumerate(loader): break ```
```python 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()])) ```
参考