卷积语言模型实际上是取了句子最后ctx_len个词作为上下文输入模型来预测之后的分词。但更好的选择是我们做一个词袋,将所有分词装在词袋中作为上下文,这样预测的分词不只根据最后ctx_len个分词,而是整个词袋中的所有分词。
例如我们的序列是:1 2 3 4 5
则首先词袋中是1,有
首先 1 -> 2
接着 1 + 2 -> 3
接着 1 + 2 + 3 -> 4
最后 1 + 2 + 3 + 4 -> 5
模型的搭建
pytorch求前缀和函数
>>> import torch
>>> a = torch.arange(5)
>>> a.cumsum(dim=-1)
tensor([ 0, 1, 3, 6, 10])
>>> a = torch.randn(3,4)
>>> a
tensor([[-0.0626, -1.4848, -0.4831, 0.4393],[ 0.6631, -0.8985, -0.5386, 1.2894],[ 1.2553, 0.1273, 1.0798, 0.4363]])
>>> a.cumsum(0)
tensor([[-0.0626, -1.4848, -0.4831, 0.4393],[ 0.6005, -2.3833, -1.0217, 1.7287],[ 1.8558, -2.2560, 0.0581, 2.1650]])
我们可以看到,使用cumsum函数可以求制定维度的前缀和累加。使用这个函数帮助我们实现新的分词进入词袋的过程。
#BoWLM.py
#encoding: utf-8
import torch
from torch import nnclass NNLayer(nn.Module):def __init__(self, isize, hsize, dropout,norm_residual=True,**kwargs):super(NNLayer, self,).__init__() ##调用父类的初始化函数self.net = nn.Sequential(nn.Linear(isize, hsize),nn.ReLU(inplace=True), #设置relu激活函数,inplace=True在原始张量上进行nn.Dropout(p=dropout, inplace=False),#设置丢弃率防止过拟合,同时创建一个新的张量nn.Linear(hsize, isize, bias=False), nn.Dropout(p=dropout, inplace=True))self.normer = nn.LayerNorm(isize) #做归一化self.norm_residual = norm_residual #设置变量存储做判断def forward(self, input):_ = self.normer(input) #稳定之后的结果 return (_ if self.norm_residual else input) + self.net(_)#如果参数初始化做的好,就用LayerNorm后的值,否则用原始值class NNLM(nn.Module):def __init__(self, vcb_size, isize, hsize, dropout,nlayer, bindemb=True, **kwargs): #有多少个词,就分多少类,类别数为vcb_sizesuper(NNLM, self).__init__()self.emb = nn.Embedding(vcb_size, isize,padding_idx=0) #<pad>的索引为0#self.comp = nn.Linear(ctx_len * isize, isize, bias=False) #将4个词的向量降维为isizeself.drop = nn.Dropout(p=dropout, inplace=True) #embedding后dropoutself.nets = nn.Sequential(*[NNLayer(isize, hsize, dropout)for _ in range(nlayer)])self.classifier = nn.Linear(isize, vcb_size)if bindemb:self.classifier.weight = self.emb.weight#将emb的权重赋给分类器self.normer = nn.LayerNorm(isize)self.out_normer = nn.LayerNorm(isize)# input: (bsize, seql-1) 句数,句长-1 由于最后一个词是预测不作为输入def forward(self, input):out = self.emb(input)# out: (bsize, seql-1, isize) out = self.drop(out).cumsum(dim=1) #在句子这一维度累加#(bsize, sum_pretex, isize)out = self.normer(out) #使用归一化,使模长均匀out = self.out_normer(self.nets(out))return self.classifier(out) #分类产生参数
相比于卷积模型,词袋语言模型少了卷积以及降维的操作过程。
模型的训练
#BoWtrain.py
#encoding: utf-8import torch
from torch import nn
from BoWLM import NNLM #导入模型
from h5py import File as h5File #读训练数据
from math import sqrt
from random import shuffle #使输入数据乱序,使模型更均衡
from lrsch import SqrtDecayLR
from tqdm import tqdmtrain_data = "train.h5"#之前已经张量转文本的h5文件
isize = 64
hsize = isize * 2 #设置初始参数
dropout = 0.3 #设置丢弃率
nlayer = 4 #设置层数
gpu_id = -1 #设置是否使用gpu
lr = 1e-3 #设置初始学习率
max_run = 8 #设置训练轮数nreport = 5000 #每训练5000个batch打印一次
tokens_optm = 25000 #设置更新参数的词数阈值def init_model_parameters(modin): #初始化模型参数with torch.no_grad(): #不是训练不用求导for para in modin.parameters():if para.dim() > 1: #若维度大于1,说明是权重参数_ = 1.0 / sqrt(para.size(-1))para.uniform_(-_,_) #均匀分布初始化for _m in modin.modules(): #遍历所有小模型if isinstance(_m, nn.Linear):#如果小模型是linear类型if _m.bias is not None: #初始化bias_m.bias.zero_()elif isinstance(_m, nn.LayerNorm):#初始化LayerNorm参数_m.weight.fill_(1.0)_m.bias.zero_()elif isinstance(_m, nn.Embedding):if _m.padding_idx >= 0:_m.weight[_m.padding_idx].zero_()return modindef train(train_data, tl, model, lossf, optm, cuda_device,nreport=nreport, tokens_optm=tokens_optm):#nreport为每训练一部分词打一次epochmodel.train() #设置模型在训练的模式src_grp = train_data["src"] #从输入数据中取出句子_l = 0.0 #_l用来存当前loss_t = 0 #_t用来存句子数_lb = 0.0_tb = 0_tom = 0for _i, _id in tqdm(enumerate(tl, 1)):seq_batch = torch.from_numpy(src_grp[_id][()])#seq_batch:[bsize, seql]_seqlen = seq_batch.size(-1) #取出每个batch的句长if cuda_device is not None:seq_batch = seq_batch.to(cuda_device, non_blocking=True)#将数据放在同一gpu上seq_batch = seq_batch.long() #数据转换为long类型seq_i = seq_batch.narrow(1, 0, _seqlen - 1) #训练数据读取前seql-1的数据#seq_i:[bsize, seql-1]seq_o = seq_batch.narrow(1, 1, _seqlen - 1) #预测数据读取后seql-1的数据做标签#seq_o:[bsize, seql-1]out = model(seq_i) #获得模型结果#out: {bsize, seql-1, vcb_size} vcb_size即预测类别数loss = lossf(out.view(-1, out.size(-1)), seq_o.contiguous().view(-1)) #转换out维度为[bsize*(seql-1),vcb_size],seq_o:[bsize*(seql-1)]_lossv = loss.item()_l += _lossv #整个训练集的loss_lb += _lossv #每个batch的loss_n = seq_o.ne(0).int().sum().item() #seq_o中不是<pad>的位置的数量_t += _n #整个训练集的分词数_tb += _n #每个batch的分词数_tom += _nloss.backward() #反向传播求导if _tom > tokens_optm: #当词数大于时更新参数optm.step() #参数的更新optm.zero_grad(set_to_none=True)#参数更新后清空梯度_tom = 0if _i % nreport == 0: #每训练5000个batch打印一次print("Average loss over %d tokens: %.2f"%(_tb, _lb/_tb))_lb = 0.0_tb = 0save_model(model, "checkpoint.pt") #暂存检查点模型 return _l / _t #返回总的lossdef save_model(modin, fname): #保存模型所有内容 权重、偏移、优化torch.save({name: para.cpu() for name, para inmodel.named_parameters()}, fname)t_data = h5File(train_data, "r")#以读的方式打开训练数据vcb_size = t_data["nword"][()].tolist()[0] #将返回的numpy的ndarray转为list
#在我们的h5文件中存储了nword: 总词数model = NNLM(vcb_size, isize, hsize, dropout, nlayer)
model = init_model_parameters(model) #在cpu上初始化模型
lossf = nn.CrossEntropyLoss(reduction='sum', ignore_index=0,
label_smoothing=0.1)
#设置ignore_index=0,即忽略<pad>的影响if (gpu_id >= 0) and torch.cuda.is_available(): #如果使用gpu且设备支持cudacuda_device = torch.device("cuda", gpu_id) #配置gputorch.set_default_device(cuda_device)
else:cuda_device = Noneif cuda_device is not None: #如果要用gpumodel.to(cuda_device) #将模型和损失函数放在gpu上lossf.to(cuda_device)optm = torch.optim.Adam(model.parameters(), lr=lr,
betas=(0.9, 0.98), eps=1e-08)
#使用model.parameters()返回模型所有参数,
lrm = SqrtDecayLR(optm, lr) #将优化器和初始学习率传入tl = [str(_) for _ in range(t_data["ndata"][()].item())] #获得字符串构成的训练数据的listsave_model(model, "eva.pt")
for i in range(1, max_run + 1):shuffle(tl) #使数据乱序_tloss = train(t_data, tl, model, lossf, optm,cuda_device) #获取每轮训练的损失print("Epoch %d: train loss %.2f"%(i, _tloss)) #打印日志save_model(model, "eva.pt")lrm.step() #每轮训练后更新学习率t_data.close()
在训练脚本中,我们的输入是一个(bsize, seql-1)的张量,即每个batch中句子的前 seq - 1 个分词。标签数据也是(bsize, seql-1)的张量,是每个batch中句子的后 seq - 1 个分词。
若我们有句子:1 2 3 4 5 6 7
seq_i : 1 2 3 4 5 6
seq_o : 2 3 4 5 6 7
通过1预测2,通过1和2 预测3,通过1 ,2和 3 预测4,直到通过 1-6 预测 7,计算预测结果和标签的交叉熵损失函数。
在命令行输入训练模型:
:~/nlp/lm$ python BoWtrain.py
4999it [29:43, 2.79it/s]Average loss over 21151520 tokens: 7.31
9999it [59:34, 2.76it/s]Average loss over 21146102 tokens: 6.84
模型的解码与预测
模型的解码
我们需要在模型文件NNLM类中添加decode方法
#BoWLM.py
#encoding: utf-8
class NNLM(nn.Module): # input: (bsize, seql)def decode(self, input, maxlen=50): #设置最多生成50个分词rs = inputbsize =input.size(0)done_trans = _sum_cache = None #记录是否完成生成for i in range(maxlen):_sum_cache = self.drop(self.emb(rs)).sum(dim=1) if _sum_cache is None else (_sum_cache + self.drop(self.emb(out.squeeze(-1)))) #squeeze(-1) 作用是去掉最后一维# ->(bsize, seql, isize) -> (bsize, isize)#将前seql个词的向量求和#初始化_sum_cache为None,如果不为None说明已有值,则只需要把新的分词累加即可,无需从头再求一遍和out = self.normer(_sum_cache)out = self.out_normer(self.nets(out))out = self.classifier(out).argmax(-1,keepdim=True) #取最后一维分数最高的索引#这一步对应分类,keepdim=True保持维度便于拼接# out:(bsize, isize) -> (bsize, vcb_size) -> (bsize, 1)rs = torch.cat([rs, out], dim=1) #将预测的词拼接到原句后,在第一维度即seql后_eos = out.eq(2) #当遇到<eos>解码停止# _eos:(bsize, 1)if done_trans is None:done_trans = _eoselse:done_trans |= _eos #将_eos中的True赋给done_trans#_eos中的元素如果为True,则说明在该索引位置上out值为2即结束标志if done_trans.all().item(): #当全都为True,说明此batch中所有句子预测都为<eos>,即解码完成breakreturn rs
模型的预测
在模型的预测时,我们需要保证输入句子的<pad>
向量为0,避免对模型训练产生影响。
>>> import torch
>>> from torch import nn
>>> emb = nn.Embedding(5, 3, padding_idx=0)
>>> emb
Embedding(5, 3, padding_idx=0)
>>> emb.weight
Parameter containing:
tensor([[ 0.0000, 0.0000, 0.0000],[ 0.9253, 1.2580, -0.6622],[ 0.0658, 0.1537, -0.3299],[ 0.6379, -0.2940, -0.1276],[-0.7669, 0.5647, 0.1014]], requires_grad=True)
打印emb的weight权重矩阵,我们设置padding_idx=0,则会将下标为0的向量初始化为零向量,避免其干扰训练结果。
但在训练过程中可能会导致零向量被修改,为了避免其被修改,我们在解码时再将其赋值为0:
>>> emb.padding_idx
0
>>> with torch.no_grad():
... emb.weight[emb.padding_idx].zero_()
...
tensor([0., 0., 0.], grad_fn=<Invalid>)
>>> emb.weight
Parameter containing:
tensor([[ 0.0000, 0.0000, 0.0000],[ 0.9253, 1.2580, -0.6622],[ 0.0658, 0.1537, -0.3299],[ 0.6379, -0.2940, -0.1276],[-0.7669, 0.5647, 0.1014]], requires_grad=True)
这里需要注意关闭torch的梯度计算,才能安全修改权重矩阵的值以及其他参数。
#BoWpredict.py
#encoding: utf-8import sys
import torch
from BoWLM import NNLM #读模型
from h5py import File as h5File #读文件
from vcb import load_vcb, reverse_vcb #获取词表isize = 64
hsize = isize * 2 #设置初始参数
dropout = 0.3 #设置丢弃率
nlayer = 4 #设置层数
gpu_id = -1 #设置是否使用gpudef extract(lin, vcb): #提取结果的函数rs = []for lu in lin:if lu > 1:if lu == 2:breakelse:rs.append(vcb[lu]) #返回索引对应词典中的分词return " ".join(rs) #返回空格分隔的解码后的字符串test_data = sys.argv[1]
test_file = h5File(test_data, "r") #读验证集
vcb_size = test_file["nword"][()].tolist()[0] #获取总词数tgt_vcb = reverse_vcb(load_vcb(sys.argv[2], vanilla=False))
#vanilla设置为false,读取词表时需考虑到特殊标记:0,1,2model = NNLM(vcb_size, isize, hsize, dropout, nlayer)
model_file = sys.argv[-1] #获取模型
with torch.no_grad(): #避免求导_ = torch.load(model_file) #加载词典for name, para in model.named_parameters():if name in _:para.copy_(_[name]) #从词典里取出name的参数model.emb.weight[model.emb.padding_idx].zero_()if (gpu_id >= 0) and torch.cuda.is_available():cuda_device = torch.device("cuda", gpu_id)torch.set_default_device(cuda_device)
else:cuda_device = Noneif cuda_device is not None:model.to(cuda_device) #判断是否使用cudamodel.eval()src_grp = test_file["src"]
ens = "\n".encode("utf-8")
with torch.no_grad(), open(sys.argv[3],"wb") as f: #解码避免求导,将预测标签按行写入文件for _ in range(test_file["ndata"][()].item()):#每个batch上遍历seq_batch = torch.from_numpy(src_grp[str(_)][()])if cuda_device is not None:seq_batch = seq_batch.to(cuda_device, non_blocking=True)seq_batch = seq_batch.long() #s数据类型转换output = model.decode(seq_batch).tolist() #将解码后的numpy转为listoutput = [extract(_, tgt_vcb) for _ in output] #将张量转为文本f.write("\n".join(output).encode("utf-8"))f.write(ens) #每个batch间还应有换行test_file.close()
在命令行输入并查看模型预测的结果:
:~/nlp/lm$ python BoWpredict.py test.h5 zh.vcb pred.txt checkpoint.pt
:~/nlp/lm$ less pred.txt