卷积语言模型实际上是取了句子最后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 nn
class 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_size
super(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个词的向量降维为isize
self.drop = nn.Dropout(p=dropout, inplace=True) #embedding后dropout
self.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-8
import 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 tqdm
train_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 modin
def train(train_data, tl, model, lossf, optm, cuda_device,
nreport=nreport, tokens_optm=tokens_optm):#nreport为每训练一部分词打一次epoch
model.train() #设置模型在训练的模式
src_grp = train_data["src"] #从输入数据中取出句子
_l = 0.0 #_l用来存当前loss
_t = 0 #_t用来存句子数
_lb = 0.0
_tb = 0
_tom = 0
for _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 += _n
loss.backward() #反向传播求导
if _tom > tokens_optm: #当词数大于时更新参数
optm.step() #参数的更新
optm.zero_grad(set_to_none=True)#参数更新后清空梯度
_tom = 0
if _i % nreport == 0: #每训练5000个batch打印一次
print("Average loss over %d tokens: %.2f"%(_tb, _lb/_tb))
_lb = 0.0
_tb = 0
save_model(model, "checkpoint.pt") #暂存检查点模型
return _l / _t #返回总的loss
def save_model(modin, fname): #保存模型所有内容 权重、偏移、优化
torch.save({name: para.cpu() for name, para in
model.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且设备支持cuda
cuda_device = torch.device("cuda", gpu_id) #配置gpu
torch.set_default_device(cuda_device)
else:
cuda_device = None
if cuda_device is not None: #如果要用gpu
model.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())] #获得字符串构成的训练数据的list
save_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 = input
bsize =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 = _eos
else:
done_trans |= _eos #将_eos中的True赋给done_trans
#_eos中的元素如果为True,则说明在该索引位置上out值为2即结束标志
if done_trans.all().item(): #当全都为True,说明此batch中所有句子预测都为<eos>,即解码完成
break
return 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-8
import 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 #设置是否使用gpu
def extract(lin, vcb): #提取结果的函数
rs = []
for lu in lin:
if lu > 1:
if lu == 2:
break
else:
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,2
model = 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 = None
if cuda_device is not None:
model.to(cuda_device) #判断是否使用cuda
model.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转为list
output = [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