人工智能-自然语言处理(NLP)-应用场景-聊天机器人(二):Seq2Seq【CHAT/闲聊机器人】--> BeamSearch算法预测【替代贪心算法预测】

在项目准备阶段我们知道,用户说了一句话后,会判断其意图,如果是想进行闲聊,那么就会调用闲聊模型返回结果。

目前市面上的常见闲聊机器人有微软小冰这种类型的模型,很久之前还有小黄鸡这种体验更差的模型

常见的闲聊模型都是一种seq2seq的结构。

一、准备训练数据

单轮次的聊天数据非常不好获取,所以这里我们从github上使用一些开放的数据集来训练我们的闲聊模型

数据地址:https://github.com/codemayq/chaotbot_corpus_Chinese

主要的数据有两个:

  1. 小黄鸡的聊天语料:噪声很大
    在这里插入图片描述
  2. 微博的标题和评论:质量相对较高
    在这里插入图片描述
    在这里插入图片描述

二、数据的处理和保存

由于数据中存到大量的噪声,可以对其进行基础的处理,然后分别把input和target使用两个文件保存,即input.txt文件中的第N行为“问”,target.txt文件中的第N行为“答”

  • 会把“以单个字分词后的句子”作为特征值、目标值(存放在input_word.txt、target_word.txt),
  • 把“以词语分词后的句子”作为特征值、目标值(存放在input.txt、target.txt)

1、小黄鸡的语料的处理

def format_xiaohuangji_corpus(word=False):
    """处理小黄鸡的语料"""
    if word:
        corpus_path = "./chatbot/corpus/xiaohuangji50w_nofenci.conv"
        input_path = "./chatbot/corpus/input_word.txt"
        output_path = "./chatbot/corpus/output_word.txt"
    else:

        corpus_path = "./chatbot/corpus/xiaohuangji50w_nofenci.conv"
        input_path = "./chatbot/corpus/input.txt"
        output_path = "./chatbot/corpus/output.txt"

    f_input = open(input_path,"a")
    f_output = open(output_path,"a")
    pair = []
    for line in tqdm(open(corpus_path),ascii=True):
        if line.strip() == "E":
            if not pair:
                continue
            else:
                assert len(pair) == 2,"长度必须是2"
                if len(pair[0].strip())>=1 and len(pair[1].strip())>=1:
                    f_input.write(pair[0]+"\n")
                    f_output.write(pair[1]+"\n")
                pair = []
        elif line.startswith("M"):
            line = line[1:]
            if word:
                pair.append(" ".join(list(line.strip())))
            else:
                pair.append(" ".join(jieba_cut(line.strip())))

详细版:

"""
处理闲聊机器人的语料
"""
import re
from utils import cut
from tqdm import tqdm

def clean_line(line):
    """处理句子中的标点符号"""
    line = re.sub("\^.*?\^","\^***\^",line)
    line = re.sub("\(.*?\)","\(***\)",line)

    result = []  #【【】,【】,[word,True,False],[word,True]】
    temp =""
    for word in line:
        if word.isalpha() or word.isdigit():
            if len(temp)>0:
                result.append([temp,True])
                temp = "" #如果temp里面只有一个字符
            result.append([word,False])
        else:
            temp += word

    if len(temp) > 0:
        result.append([temp, True])

    #把result中第二个位置为True的进行替换
    if result[0][-1]:
        result = result[1:]
    #经过上一步后,有可能为空列表
    if len(result)>0:
        if result[-1][-1]:
            result = result[:-1]+[["。",False]]

    final_result = []
    for i in result:
        if i[-1]: #为标点的情况
            if "!" in i[0] or "!" in i[0]:
                final_result.append(["!",False])
            elif "…" in i[0]:
                final_result.append(["…", False])
            else:
                final_result.append([",",False])

        else:
            final_result.append(i)
    return "".join([i[0] for i in final_result])


def clean_group(group):
    """
    清理group中的输出
    :param group: [q,a]
    :return: [q,a]/bool
    """
    #判断句子是否为纯标点英文数字,或者是其他的语言--》判断一句话中是否有中文
    if not re.findall("[\u4e00-\u9fa5]",group[0]):
        return False
    if not re.findall("[\u4e00-\u9fa5]",group[1]):
        return False

    #问题中包含`笑话`两个字的
    if re.findall("笑话|糗百|运势|运程",group[0]):
        return False

    #处理连续的多个标点
    group[0] = clean_line(group[0])
    group[1] = clean_line(group[1])

    #小黄鸡,小通
    group[0] = re.sub("小通|鸡鸡","小智",group[0]).strip()
    group[1] = re.sub("小通|鸡鸡","小智",group[1]).strip()

    #判断句子是否为空
    if len(group[0])<1 or len(group[1])<1:
        return False
    return group

def save_group(group,fq,fa,by_word):
    """保存问答对"""

    fq.write(" ".join(cut(group[0],by_word=by_word))+"\n")
    fa.write(" ".join(cut(group[1],by_word=by_word))+"\n")


def process_xiaohuangji(by_word,fq,fa):
    data_path = "./corpus/classify/小黄鸡未分词.conv"

    groups = []  #[[q,a],[q,a],[q,a]]
    group = []
    bar = tqdm(open(data_path).readlines(),desc="小黄鸡数据读取...")
    for line in bar:
        if line.startswith("E"):
            if group:
                groups.append(group)
                group = []
        elif line.startswith("M"):
            group.append(line[1:].strip())
    if group:
        groups.append(group)

    for group in tqdm(groups,desc="小黄鸡数据保存..."):  #一个group就是一个问答对
        group = clean_group(group)
        if not group:
            continue
        # print("q:",group[0])
        # print("a:",group[1])
        # print("*"*30)
        save_group(group,fq,fa,by_word)

def start_process(by_word=True):
    fq = open("./corpus/chatbot/input.txt","a")	# 特征值保存路径
    fa = open("./corpus/chatbot/target.txt","a") # 目标值保存路径
    process_xiaohuangji(by_word,fq,fa)

if __name__=="__main__":
	start_process()

2、微博语料的处理

def format_weibo(word=False):
    """
    微博数据存在一些噪声,未处理
    :return:
    """
    if word:
        origin_input = "./chatbot/corpus/stc_weibo_train_post"
        input_path = "./chatbot/corpus/input_word.txt"

        origin_output = "./chatbot/corpus/stc_weibo_train_response"
        output_path = "./chatbot/corpus/output_word.txt"

    else:
        origin_input = "./chatbot/corpus/stc_weibo_train_post"
        input_path = "./chatbot/corpus/input.txt"

        origin_output = "./chatbot/corpus/stc_weibo_train_response"
        output_path = "./chatbot/corpus/output.txt"

    f_input = open(input_path,"a")
    f_output = open(output_path, "a")
    with open(origin_input) as in_o,open(origin_output) as out_o:
        for _in,_out in tqdm(zip(in_o,out_o),ascii=True):
            _in = _in.strip()
            _out = _out.strip()

            if _in.endswith(")") or _in.endswith("」") or _in.endswith(")"):
                _in = re.sub("(.*)|「.*?」|\(.*?\)"," ",_in)
            _in = re.sub("我在.*?alink|alink|(.*?\d+x\d+.*?)|#|】|【|-+|_+|via.*?:*.*"," ",_in)

            _in = re.sub("\s+"," ",_in)
            if len(_in)<1 or len(_out)<1:
                continue

            if word:
                _in = re.sub("\s+","",_in)  #转化为一整行,不含空格
                _out = re.sub("\s+","",_out)
                if len(_in)>=1 and len(_out)>=1:
                    f_input.write(" ".join(list(_in)) + "\n")
                    f_output.write(" ".join(list(_out)) + "\n")
            else:
                if len(_in) >= 1 and len(_out) >= 1:
                    f_input.write(_in.strip()+"\n")
                    f_output.write(_out.strip()+"\n")

    f_input.close()
    f_output.close()

3、处理后的结果

在这里插入图片描述
在这里插入图片描述

三、构造文本序列化和反序列化方法

和之前的操作相同,需要把文本能转化为数字,同时还需实现方法把数字转化为文本

# word_sequence.py
import config
import pickle

class Word2Sequence():
    UNK_TAG = "UNK"
    PAD_TAG = "PAD"
    SOS_TAG = "SOS"
    EOS_TAG = "EOS"

    UNK = 0
    PAD = 1
    SOS = 2
    EOS = 3

    def __init__(self):
        self.dict = {
    
    
            self.UNK_TAG :self.UNK,
            self.PAD_TAG :self.PAD,
            self.SOS_TAG :self.SOS,
            self.EOS_TAG :self.EOS
        }
        self.count = {
    
    }
        self.fited = False

    def to_index(self,word):
        """word -> index"""
        assert self.fited == True,"必须先进行fit操作"
        return self.dict.get(word,self.UNK)

    def to_word(self,index):
        """index -> word"""
        assert self.fited , "必须先进行fit操作"
        if index in self.inversed_dict:
            return self.inversed_dict[index]
        return self.UNK_TAG

    def __len__(self):
        return len(self.dict)

    def fit(self, sentence):
        """
        :param sentence:[word1,word2,word3]
        :param min_count: 最小出现的次数
        :param max_count: 最大出现的次数
        :param max_feature: 总词语的最大数量
        :return:
        """
        for a in sentence:
            if a not in self.count:
                self.count[a] = 0
            self.count[a] += 1

        self.fited = True

    def build_vocab(self, min_count=1, max_count=None, max_feature=None):

        # 比最小的数量大和比最大的数量小的需要
        if min_count is not None:
            self.count = {
    
    k: v for k, v in self.count.items() if v >= min_count}
        if max_count is not None:
            self.count = {
    
    k: v for k, v in self.count.items() if v <= max_count}

        # 限制最大的数量
        if isinstance(max_feature, int):
            count = sorted(list(self.count.items()), key=lambda x: x[1])
            if max_feature is not None and len(count) > max_feature:
                count = count[-int(max_feature):]
            for w, _ in count:
                self.dict[w] = len(self.dict)
        else:
            for w in sorted(self.count.keys()):
                self.dict[w] = len(self.dict)

        # 准备一个index->word的字典
        self.inversed_dict = dict(zip(self.dict.values(), self.dict.keys()))

    def transform(self, sentence,max_len=None,add_eos=False):
        """
        实现吧句子转化为数组(向量)
        :param sentence:
        :param max_len:
        :return:
        """
        assert self.fited, "必须先进行fit操作"

        r = [self.to_index(i) for i in sentence]
        if max_len is not None:
            if max_len>len(sentence):
                if add_eos:
                    r+=[self.EOS]+[self.PAD for _ in range(max_len-len(sentence)-1)]
                else:
                    r += [self.PAD for _ in range(max_len - len(sentence))]
            else:
                if add_eos:
                    r = r[:max_len-1]
                    r += [self.EOS]
                else:
                    r = r[:max_len]
        else:
            if add_eos:
                r += [self.EOS]
        # print(len(r),r)
        return r

    def inverse_transform(self,incides):
        """
        把数字序列转化为字符
        :param incides: [int,int,int]
        :return: [str,str,str]
        """
        result = []
        for i in incides:
            temp = self.inverse_dict.get(i, "<UNK>")
            if temp != self.EOS_TAG:
                result.append(temp)
            else:
                break
        return "".join(result)

#之后导入该word_sequence使用
word_sequence = pickle.load(open("./pkl/ws.pkl","rb")) if not config.use_word else pickle.load(open("./pkl/ws_word.pkl","rb"))



if __name__ == '__main__':
    from word_sequence import Word2Sequence
    from tqdm import tqdm
    import pickle

    word_sequence = Word2Sequence()
    #词语级别
    input_path = "../corpus/input.txt"
    target_path = "../corpus/output.txt"
    for line in tqdm(open(input_path).readlines()):
        word_sequence.fit(line.strip().split())
    for line in tqdm(open(target_path).readlines()):
        word_sequence.fit(line.strip().split())
	
    #使用max_feature=5000个数据
    word_sequence.build_vocab(min_count=5,max_count=None,max_feature=5000)
    print(len(word_sequence))
    pickle.dump(word_sequence,open("./pkl/ws.pkl","wb"))

四、构建对话Dataset和DataLoader【input_x: 发起句 & target_y: 应答句】

创建dataset.py 文件,准备数据集

import torch
import config
from torch.utils.data import Dataset,DataLoader
from word_sequence import word_sequence


class ChatDataset(Dataset):
    def __init__(self):
        super(ChatDataset,self).__init__()

        input_path = "../corpus/input.txt"	# 对话的“发起句”作为特征值
        target_path = "../corpus/output.txt"	# 对话的“应答句”作为目标值
        if config.use_word:
            input_path = "../corpus/input_word.txt"
            target_path = "../corpus/output_word.txt"

        self.input_lines = open(input_path).readlines()
        self.target_lines = open(target_path).readlines()
        assert len(self.input_lines) == len(self.target_lines) ,"input和target文本的数量必须相同"
    def __getitem__(self, index):
        input_x = self.input_lines[index].strip().split()
        target_y = self.target_lines[index].strip().split()
        if len(input_x) == 0 or len(target_y)==0:
            input_x = self.input_lines[index+1].strip().split()
            target_y = self.target_lines[index+1].strip().split()
        input_x_len = min(len(input_x),config.max_len)	#此处句子的长度如果大于max_len,那么应该返回max_len
        target_y_len = min(len(target_y),config.max_len)	#此处句子的长度如果大于max_len,那么应该返回max_len
        return input_x,target_y,input_x_len ,target_y_len 

    def __len__(self):
        return len(self.input_lines)

def collate_fn(batch):
    # 1、排序:对batch按照input的长度进行排序
    batch = sorted(batch,key=lambda x:x[2],reverse=True)
    # 2、进行zip操作
    input_x, target_y, input_x_length, target_y_length = zip(*batch)

    # 3、进行文本序列化操作,并转为LongTensor
    input_x = torch.LongTensor([word_sequence.transform(i, max_len=config.max_len) for i in input_x])
    target_y = torch.LongTensor([word_sequence.transform(i, max_len=config.max_len, add_eos=True) for i in target_y])
    input_x_length = torch.LongTensor(input_x_length)
    target_y_length = torch.LongTensor(target_y_length)

    return input_x, target_y, input_x_length, target_y_length

data_loader = DataLoader(dataset=ChatDataset(),batch_size=config.batch_size,shuffle=True,collate_fn=collate_fn,drop_last=True)

if __name__ == '__main__':
    for idx, (input_x, target_y, input_lenght, target_y_length) in enumerate(data_loader):
        print(idx)
        print(input_x)
        print(target_y)
        print(input_lenght)
        print(target_y_length)

五、完成encoder编码器逻辑

import torch.nn as nn
from word_sequence import word_sequence
import config


class Encoder(nn.Module):
    def __init__(self):
        super(Encoder,self).__init__()
        self.vocab_size = len(word_sequence)
        self.dropout = config.dropout
        self.embedding_dim = config.embedding_dim
        self.embedding = nn.Embedding(num_embeddings=self.vocab_size,embedding_dim=self.embedding_dim,padding_idx=word_sequence.PAD)
        self.gru = nn.GRU(input_size=self.embedding_dim,
                          hidden_size=config.hidden_size,
                          num_layers=1,
                          batch_first=True,
                          bidirectional=True,
                          dropout=config.dropout)

    def forward(self, input,input_len):
        input_embeded = self.embedding(input)

        #对输入进行打包
        input_packed = pack_padded_sequence(input_embeded,input_len,batch_first=True)
        #经过GRU处理
        output,hidden = self.gru(input_packed)
        # print("encoder gru hidden:",hidden.size())
        #进行解包
        output_paded,seq_len = pad_packed_sequence(output,batch_first=True,padding_value=config.input_ws.PAD)
        #获取最上层的正向和反向最后一个时间步的输出,表示整个句子
        encoder_hidden = torch.cat([hidden[-2],hidden[-1]],dim=-1).unsqueeze(0) #[1,batch_size,128*2]
        #[bathc_size,seq_len,128*2]
        return output_paded,encoder_hidden  #[1,batch_size,128*2]

六、完成decoder解码器的逻辑

import torch
import torch.nn as nn
import config
import random
import torch.nn.functional as F
from word_sequence import word_sequence

class Decoder(nn.Module):
    def __init__(self):
        super(Decoder,self).__init__()
        self.max_seq_len = config.max_len
        self.vocab_size = len(word_sequence)
        self.embedding_dim = config.embedding_dim
        self.dropout = config.dropout

        self.embedding = nn.Embedding(num_embeddings=self.vocab_size,embedding_dim=self.embedding_dim,padding_idx=word_sequence.PAD)
        self.gru = nn.GRU(input_size=self.embedding_dim,
                          hidden_size=config.hidden_size,
                          num_layers=1,
                          batch_first=True,
                          dropout=self.dropout)
        self.log_softmax = nn.LogSoftmax()

        self.fc = nn.Linear(config.hidden_size,self.vocab_size)

    def forward(self, encoder_hidden,target,target_length):
        # encoder_hidden [batch_size,hidden_size*2]
        # target [batch_size,seq-len]

        decoder_input = torch.LongTensor([[word_sequence.SOS]]*config.batch_size).to(config.device)	# 初始化解码器的input
        decoder_hidden = encoder_hidden # 初始化解码器的hidden_state, 形状为:[batch_size,hidden_size*2]【*2是因为编码器使用了bidirectional,所以编码器的输出维度为hidden_size*2】
        decoder_outputs = torch.zeros(config.batch_size,config.max_len,self.vocab_size).to(config.device) # 初始化解码器的输出,形状为: [batch_size,seq_len,14]

        for t in range(config.max_len):
            decoder_output_t , decoder_hidden = self.forward_step(decoder_input,decoder_hidden)
            decoder_outputs[:,t,:] = decoder_output_t
            value, index = torch.topk(decoder_output_t, 1) # 获取当前时间步的预测值  index [batch_size,1]
            decoder_input = index	# 使用当前时间步的预测值作为下一个时间步的输入
        return decoder_outputs,decoder_hidden

    def forward_step(self,decoder_input,decoder_hidden):
        """
        :param decoder_input:[batch_size,1]
        :param decoder_hidden: [1,batch_size,hidden_size*2]
        :return: out:[batch_size,vocab_size],decoder_hidden:[1,batch_size,hidden_size*2]
        """
        embeded = self.embedding(decoder_input)  #embeded: [batch_size,1 , embedding_dim]
        out,decoder_hidden = self.gru(embeded,decoder_hidden) #out [1, batch_size, hidden_size*2], decoder_hidden:[1,batch_size,hidden_size*2]
        out = out.squeeze(0)
        out = F.log_softmax(self.fc(out),dim=-1)#[batch_Size, vocab_size]
        out = out.squeeze(1)
        # print("out size:",out.size(),decoder_hidden.size())
        return out,decoder_hidden

七、完成seq2seq的模型

import torch
import torch.nn as nn

class Seq2Seq(nn.Module):
    def __init__(self,encoder,decoder):
        super(Seq2Seq,self).__init__()
        self.encoder = encoder
        self.decoder = decoder

    def forward(self, input,target,input_length,target_length):
        encoder_outputs,encoder_hidden = self.encoder(input,input_length)
        decoder_outputs,decoder_hidden = self.decoder(encoder_hidden,target,target_length)
        return decoder_outputs,decoder_hidden

    def prediction(self,inputs,input_length):
        encoder_outputs,encoder_hidden = self.encoder(inputs,input_length)
        decoded_sentence = self.decoder.prediction(encoder_hidden)
        return decoded_sentence

八、完成训练逻辑

为了加速训练,可以考虑在gpu上运行,那么在我们自顶一个所以的tensor和model都需要转化为CUDA支持的类型。

当前的数据量为500多万条,在GTX1070(8G显存)上训练,大概需要90分一个epoch,耐心的等待吧

import torch
import config
from torch import optim
import torch.nn as nn
from encoder import Encoder
from decoder import Decoder
from seq2seq import Seq2Seq
from dataset import data_loader as train_dataloader
from word_sequence import word_sequence

encoder = Encoder()
decoder = Decoder()
model = Seq2Seq(encoder,decoder)

#device在config文件中实现
model.to(config.device)

print(model)

model.load_state_dict(torch.load("model/seq2seq_model.pkl"))
optimizer =  optim.Adam(model.parameters())
optimizer.load_state_dict(torch.load("model/seq2seq_optimizer.pkl"))
criterion= nn.NLLLoss(ignore_index=word_sequence.PAD,reduction="mean")

# 自定义初始化参数
for name, param in model.named_parameters():
   if 'bias' in name:
       torch.nn.init.constant_(param, 0.0)
   elif 'weight' in name:
       torch.nn.init.xavier_normal_(param)


def get_loss(decoder_outputs,target):
    target = target.view(-1) #[batch_size*max_len]
    decoder_outputs = decoder_outputs.view(config.batch_size*config.max_len,-1)
    return criterion(decoder_outputs,target)

loss_list = []
def train(epoch):
    for idx,(input,target,input_length,target_len) in enumerate(train_dataloader):
        input = input.to(config.device)
        target = target.to(config.device)
        input_length = input_length.to(config.device)
        target_len = target_len.to(config.device)

        optimizer.zero_grad()
        ##[seq_len,batch_size,vocab_size] [batch_size,seq_len]
        decoder_outputs,decoder_hidden = model(input,target,input_length,target_len)
        loss = get_loss(decoder_outputs,target)
        loss.backward()
        optimizer.step()
		loss_list.append(loss.item())
        print('Train Epoch: {} [{}/{} ({:.0f}%)]\tLoss: {:.6f}'.format(
            epoch, idx * len(input), len(train_dataloader.dataset),
                   100. * idx / len(train_dataloader), loss.item()))

        torch.save(model.state_dict(), "model/seq2seq_model.pkl")
        torch.save(optimizer.state_dict(), 'model/seq2seq_optimizer.pkl')
        pickle.dump(loss_list, open("model/loss_list.pkl", "wb"))

if __name__ == '__main__':
    for i in range(10):
        train(i)

训练10个epoch之后的效果如下,可以看出损失依然很高:

Train Epoch: 9 [2444544/4889919 (50%)]	Loss: 4.923604
Train Epoch: 9 [2444800/4889919 (50%)]	Loss: 4.364594
Train Epoch: 9 [2445056/4889919 (50%)]	Loss: 4.613254
Train Epoch: 9 [2445312/4889919 (50%)]	Loss: 4.143538
Train Epoch: 9 [2445568/4889919 (50%)]	Loss: 4.412729
Train Epoch: 9 [2445824/4889919 (50%)]	Loss: 4.516526
Train Epoch: 9 [2446080/4889919 (50%)]	Loss: 4.124945
Train Epoch: 9 [2446336/4889919 (50%)]	Loss: 4.777015
Train Epoch: 9 [2446592/4889919 (50%)]	Loss: 4.358538
Train Epoch: 9 [2446848/4889919 (50%)]	Loss: 4.513412
Train Epoch: 9 [2447104/4889919 (50%)]	Loss: 4.202757
Train Epoch: 9 [2447360/4889919 (50%)]	Loss: 4.589584

九、模型评估

import numpy as np
import pickle
from chatbot.seq2seq import Seq2Seq	# 导入模型

def eval():
    model = Seq2Seq().to(config.device)
    model.eval()
    model.load_state_dict(torch.load("./chatbot/models/model.pkl"))

    loss_list = []
    data_loader = get_dataloader(train=False)
    bar = tqdm(data_loader,total=len(data_loader),desc="当前进行评估")
    with torch.no_grad():
        for idx,(input,target,input_len,target_len) in enumerate(bar):
            input = input.to(config.device)
            target = target.to(config.device)
            input_len = input_len.to(config.device)
            decoder_outputs,predict_result = model.evaluate(input,input_len) #[batch_Size,max_len,vocab_size]
            loss = F.nll_loss(decoder_outputs.view(-1,len(config.target_ws)),target.view(-1),ignore_index=config.input_ws.PAD)
            loss_list.append(loss.item())
            bar.set_description("idx:{} loss:{:.6f}".format(idx,np.mean(loss_list)))
    print("当前的平均损失为:",np.mean(loss_list))
    
if __name__ == '__main__':
    for i in range(5):
        eval()

十、Seq2Seq闲聊机器人的优化

1、seq2seq中使用teacher forcing【用在训练阶段的Decoder中】

在前面的seq2seq的案例中,我们介绍了teacher frocing是什么,当时我们的输入和输出很相似,所以当时我们的teacher forcing是在每个time step中实现的,那么现在我们的输入和输出不同的情况下,该如何使用呢?

我们可以在decoder的每个batch遍历time step的外层使用teacher forcing

代码如下:

use_teacher_forcing = random.random() > 0.5
if use_teacher_forcing: #使用teacher forcing
    for t in range(config.max_len):
        decoder_output_t, decoder_hidden, decoder_attn_t = self.forward_step(decoder_input, decoder_hidden,
                                                                             encoder_outputs)
        decoder_outputs[:, t, :] = decoder_output_t
        #使用正确的输出作为下一步的输入
        decoder_input = target[:, t].unsqueeze(1)  # [batch_size,1]

else:#不适用teacher forcing,使用预测的输出作为下一步的输入
    for t in range(config.max_len):
        decoder_output_t ,decoder_hidden,decoder_attn_t = self.forward_step(decoder_input,decoder_hidden,encoder_outputs)
        decoder_outputs[:,t,:] = decoder_output_t
        value, index = torch.topk(decoder_output_t, 1) # index [batch_size,1]
        decoder_input = index

解码器的完整代码:

import torch
import torch.nn as nn
import config
import random
import torch.nn.functional as F
from word_sequence import word_sequence

class Decoder(nn.Module):
    def __init__(self):
        super(Decoder,self).__init__()
        self.max_seq_len = config.max_len
        self.vocab_size = len(word_sequence)
        self.embedding_dim = config.embedding_dim
        self.dropout = config.dropout

        self.embedding = nn.Embedding(num_embeddings=self.vocab_size,embedding_dim=self.embedding_dim,padding_idx=word_sequence.PAD)
        self.gru = nn.GRU(input_size=self.embedding_dim,
                          hidden_size=config.hidden_size,
                          num_layers=1,
                          batch_first=True,
                          dropout=self.dropout)
        self.log_softmax = nn.LogSoftmax()

        self.fc = nn.Linear(config.hidden_size,self.vocab_size)

    def forward(self, encoder_hidden,target,target_length):
        # encoder_hidden [batch_size,hidden_size*2]
        # target [batch_size,seq-len]

        decoder_input = torch.LongTensor([[word_sequence.SOS]]*config.batch_size).to(config.device)	# 初始化解码器的input
        decoder_hidden = encoder_hidden # 初始化解码器的hidden_state, 形状为:[batch_size,hidden_size*2]【*2是因为编码器使用了bidirectional,所以编码器的输出维度为hidden_size*2】
        decoder_outputs = torch.zeros(config.batch_size,config.max_len,self.vocab_size).to(config.device) # 初始化解码器的输出,形状为: [batch_size,seq_len,14]
        if random.random() > 0.5:
            for t in range(config.max_len):
                decoder_output_t , decoder_hidden = self.forward_step(decoder_input,decoder_hidden)
                decoder_outputs[:,t,:] = decoder_output_t
                value, index = torch.topk(decoder_output_t, 1) # 获取当前时间步的预测值  index [batch_size,1]
                decoder_input = index	# 使用当前时间步的预测值作为下一个时间步的输入
        else:
            for t in range(config.chatbot_target_max_len):
                decoder_output_t , decoder_hidden = self.forward_step(decoder_input,decoder_hidden)
                decoder_outputs[:,t,:] = decoder_output_t
                decoder_input = target[:,t].unsqueeze(-1)   #把真实值作为下一步的输入
        return decoder_outputs,decoder_hidden

    def forward_step(self,decoder_input,decoder_hidden):
        """
        :param decoder_input:[batch_size,1]
        :param decoder_hidden: [1,batch_size,hidden_size*2]
        :return: out:[batch_size,vocab_size],decoder_hidden:[1,batch_size,hidden_size*2]
        """
        embeded = self.embedding(decoder_input)  #embeded: [batch_size,1 , embedding_dim]
        out,decoder_hidden = self.gru(embeded,decoder_hidden) #out [1, batch_size, hidden_size*2], decoder_hidden:[1,batch_size,hidden_size*2]
        out = out.squeeze(0)
        out = F.log_softmax(self.fc(out),dim=-1)#[batch_Size, vocab_size]
        out = out.squeeze(1)
        # print("out size:",out.size(),decoder_hidden.size())
        return out,decoder_hidden

2、使用梯度裁剪

前面,我们给大家介绍了梯度消失(梯度过小,在多层计算后导致其值太小而无法计算)梯度爆炸(梯度过大,导致其值在多层的计算后太大而无法计算)

在常见的深度神经网络中,特别是RNN中,我们经常会使用梯度裁剪的手段,来抑制过大的梯度,能够有效防止梯度爆炸。

梯度裁剪的实现非常简单,仅仅只需要设置一个阈值,把梯度大于该阈值时设置为该阈值。

在这里插入图片描述

实现代码:

loss.backward()
#进行梯度裁剪
nn.utils.clip_grad_norm_(model.parameters(),[5,10,15])
optimizer.step()

3、使用Attention机制【用在Decoder中】

4、BeamSearch算法预测【替代贪心算法预测】

4.1 Beam Search的介绍

在进行模型评估的过程中,每次我们选择概率最大的token id作为输出,那么整个输出的句子的概率就是最大的么?

在这里插入图片描述
Beam search的又被称作束集搜索,是一种seq2seq中用来优化输出结果的算法(不在训练过程中使用,而是在评估或预测时使用)。

例如:传统的获取解码器输出的过程中,每次只选择概率最大的那个结果,作为当前时间步的输出,等到输出结束,我们会发现,整个句子可能并不通顺。虽然在每一个时间步上的输出确实是概率最大的,但是整体的概率确不一定最大的,我们经常把它叫做greedy search[贪心算法]

为了解决上述的问题,可以考虑计算全部的输出的概率乘积,选择最大概率的路径,这样可以达到全局最优解。但是这样的话,意味着如果句子很长,候选词很多,那么需要保存的数据就会非常大,需要计算的数据量就很大

那么Beam Search 就是介于上述两种方法的一个这种的方法,假设Beam width=2,表示每次保存的最大的概率的个数,这里每次保存两个,在下一个时间步骤一样,也是保留两个,这样就可以达到约束搜索空间大小的目的,从而提高算法的效率Beam Search也不是全局最优。【维特比算法是全局最优】

  • beam width 是一个超参数。
    • beam width =1 时,就是贪心算法,
    • beam width=候选词的时候,就是计算全部的概率。

比如在下图中:

使用一个树状图来表示每个time step的可能输出,其中的数字表示是条件概率

黄色的箭头表示的是一种greedy search,概率并不是最大的

如果把beam width设置为2,那么后续可以找到绿色路径的结果,这个结果是最大的

在这里插入图片描述

下图是要给beam width=3的例子

  1. 首先输入start token <s>,然后得到四个输出(这里假设一个就四个输出:X,Y,Z,</s>),选择概率最大三个,X,Y,W
  2. 然后分别把X,Y,W放到下一个time step中作为输入,分别得到三组不同的输出(一共12个输出序列:XX, XY, XZ, X</s>,YX, YY, YZ, Y</s>,WX, WY, WZ, W</s>),这12个输出中找到三个输出概率最大的三个(如下图:XX,XY,WY )保存到Beam中。
  3. 然后分别把XX,XY,WY放到下一个time step中作为输入,分别得到三组不同的输出(一共12个输出序列:XXX, XXY, XXZ, XX</s>,XYX, XYY, XYZ, XY</s>,WYX, WYY, WYZ, WY</s>),这12个输出中找到三个输出概率最大的三个(如下图:XXX,XYX,WYX )保存到Beam中。
  4. 然后分别把XXX,XYX,WYX放到下一个time step中作为输入,分别得到三组不同的输出(一共12个输出序列:XXXX, XXXY, XXXZ, XXX</s>,XYXX, XYXY, XYXZ, XYX</s>,WYXX, WYXY, WYXZ, WYX</s>),这12个输出中找到三个输出概率最大的三个(如下图:XYXW,XYXX,WYX</s> )保存到Beam中。
  5. 然后分别把XYXW,XYXX,WYX</s>放到下一个time step中作为输入,分别得到三组不同的输出(一共12个输出序列:XYXWX, XYXWY, XYXWZ, XYXW</s>,XYXXX, XYXXY, XYXXZ, XYXX</s>,WYX</s>X, WYX</s>Y, WYX</s>Z, WYX</s></s>),这12个输出中找到三个输出概率最大的三个(如下图:XYXW</s>,XYXWY,XYXX</s>)保存到Beam中。
  6. 继续重复上述步骤,直到获得结束符时的序列概率最大或者是达到句子的最大长度max_len,结束循环。此时选择概率乘积最大的一个路径。
  7. 拼接整个路径上概率最大的所有结果,比如这里可能是<s>,X,Y,X,W,</s>

在这里插入图片描述

4.2 Beam Search解释

对于MLE算法训练的模型,beam search只在预测的时候需要。训练的时候因为知道正确答案,并不需要再进行这个搜索。

预测的时候,假设词表大小为3,内容为a,b,c。beam size是2,decoder解码的时候:

  1. 生成第1个词的时候,选择概率最大的2个词,假设为a,c,那么当前的2个序列就是a、c。
  2. 生成第2个词的时候,我们将当前序列a和c,分别与词表中的所有词进行组合,得到新的6个序列aa ab ac ca cb cc,计算每个序列的得分并选择得分最高2个序列,作为新的当前序列,假如为aa、cb。
  3. 后面会不断重复这个过程,直到遇到结束符或者达到最大长度为止。最终输出得分最高的2个序列。

4.3 Beam serach的实现

在上述描述的思路中,我们需要注意以下几个内容:

  1. 数据该如何保存,每一次的输出的最大的beam width个结果,和之后之前的结果该如何保存
  2. 保存了之后的概率应该如何比较大小,保留下概率最大的三个
  3. 不能够仅仅只保存当前概率最大的信息,还需要有当前概率最大的三个中,前面的路径的输出结果

4.3.1 数据结构-堆-的认识

对于上面所说的,保留有限个数据,同时需要根据大小来保留,可以使用一种带有优先级的数据结构来实现,这里我们可以使用这种数据结构

是一种优先级的队列,但是他其实并不是队列

  • 队列都是先进先出或者是先进后出
  • 只根据优先级的高低来取出数据
  • 是一种先进后出的数据结构,有入栈和出栈的操作

在python自带的模块中,有一个叫做heapq的模块,提供了堆所有的方法。通过下面的代码我们来了解下heapq的使用方法

my_heap = [] #使用列表保存数据

 #往列表中插入数据,优先级使用插入的内容来表示,就是一个比较大小的操作,越大优先级越高
heapq.heappush(my_heap,[29,True,"xiaohong"]) 
heapq.heappush(my_heap,[28,False,"xiaowang"])
heapq.heappush(my_heap,[29,False,"xiaogang"])

for i in range(3):
    ret= heapq.heappop(my_heap)  #pop操作,优先级最小的数据
    print(ret)
    
#输出如下:
[28, False, 'xiaowang']
[29, False, 'xiaogang']
[29, True, 'xiaohong']

可以发现,输出的顺序并不是数据插入的顺序,而是根据其优先级,从小往大pop(False<True)。

4.3.2 使用堆来实现beam search

为了实现数据的的保存,我们可以把beam search中的数据保存在堆中,同时在往这个堆中添加数据的同时,判断数据的个数,仅仅保存beam width个数据

class Beam:
    def __init__(self):
        self.heap = list() #保存数据的位置
        self.beam_width = config.beam_width #保存数据的总数

    def add(self,probility,complete,seq,decoder_input,decoder_hidden):
        """
        添加数据,同时判断总的数据个数,多则删除
        :param probility: 概率乘积
        :param complete: 最后一个是否为EOS
        :param seq: list,所有token的列表
        :param decoder_input: 下一次进行解码的输入,通过前一次获得
        :param decoder_hidden: 下一次进行解码的hidden,通过前一次获得
        :return:
        """
        heapq.heappush(self.heap,[probility,complete,seq,decoder_input,decoder_hidden])
        #判断数据的个数,如果大,则弹出。保证数据总个数小于等于3
        if len(self.heap)>self.beam_width:
            heapq.heappop(self.heap)

    def __iter__(self):#让该beam能够被迭代
        return iter(self.heap)

实现方法,完成模型eval过程中的beam search搜索

思路:

  1. 构造<SOS>开始符号等第一次输入的信息,保存在堆中
  2. 取出堆中的数据,进行forward_step的操作,获得当前时间步的output,hidden
  3. 从output中选择topk(k=beam width)个输出,作为下一次的input
  4. 把下一个时间步骤需要的输入等数据保存在一个新的堆中
  5. 获取新的堆中的优先级最高(概率最大)的数据,判断数据是否是EOS结尾或者是否达到最大长度,如果是,停止迭代
  6. 如果不是,则重新遍历新的堆中的数据

代码如下

# decoder中的新方法
def evaluatoin_beamsearch_heapq(self,encoder_outputs,encoder_hidden):
    """使用 堆 来完成beam search,对是一种优先级的队列,按照优先级顺序存取数据"""

    batch_size = encoder_hidden.size(1)
    #1. 构造第一次需要的输入数据,保存在堆中
    decoder_input = torch.LongTensor([[word_sequence.SOS] * batch_size]).to(config.device)
    decoder_hidden = encoder_hidden #需要输入的hidden

    prev_beam = Beam()
    prev_beam.add(1,False,[decoder_input],decoder_input,decoder_hidden)
    while True:
        cur_beam = Beam()
        #2. 取出堆中的数据,进行forward_step的操作,获得当前时间步的output,hidden
        #这里使用下划线进行区分
        for _probility,_complete,_seq,_decoder_input,_decoder_hidden in prev_beam:
            #判断前一次的_complete是否为True,如果是,则不需要forward
            #有可能为True,但是概率并不是最大
            if _complete == True:
                cur_beam.add(_probility,_complete,_seq,_decoder_input,_decoder_hidden)
            else:
                decoder_output_t, decoder_hidden,_ = self.forward_step(_decoder_input, _decoder_hidden,encoder_outputs)
                value, index = torch.topk(decoder_output_t, config.beam_width)  # [batch_size=1,beam_widht=3]
             	#3. 从output中选择topk(k=beam width)个输出,作为下一次的input
            	for m, n in zip(value[0], index[0]):
                    decoder_input = torch.LongTensor([[n]]).to(config.device)
                    seq = _seq + [n]
                    probility = _probility * m
                    if n.item() == word_sequence.EOS:
                    	complete = True
                    else:
                        complete = False

                 	#4. 把下一个实践步骤需要的输入等数据保存在一个新的堆中
                	 cur_beam.add(probility,complete,seq,decoder_input,decoder_hidden)
          #5. 获取新的堆中的优先级最高(概率最大)的数据,判断数据是否是EOS结尾或者是否达到最大长度,如果是,停止迭代
          best_prob,best_complete,best_seq,_,_ = max(cur_beam)
         if best_complete == True or len(best_seq)-1 == config.max_len: #减去sos
            return self._prepar_seq(best_seq)
         else:
            #6. 则重新遍历新的堆中的数据
            prev_beam = cur_beam
                                    
      def _prepar_seq(self,seq):#对结果进行基础的处理,共后续转化为文字使用
        if seq[0].item() == word_sequence.SOS:
            seq=  seq[1:]
        if  seq[-1].item() == word_sequence.EOS:
            seq = seq[:-1]
        seq = [i.item() for i in seq]
        return seq

4.4 修改seq2seq模型

在seq2seq中使用evaluatoin_beamsearch_heapq查看效果,会发现使用beam search的效果比单独使用attention的效果更好

使用小黄鸡语料(50万个问答),单个字作为token,5个epoch之后的训练结果,左边为问,右边是回答

你在干什么 >>>>> 你想干啥?
你妹 >>>>> 不是我
你叫什么名字 >>>>> 你猜
你个垃圾 >>>>> 你才是,你
你是傻逼 >>>>> 是你是傻
笨蛋啊 >>>>> 我不是,你

5、其他优化模型方法

  1. 参数的初始化
  2. 优化现有的数据,语料
    • 数据清洗
      • 标点、表情、外文的处理
      • 把时间、人名、地点等名词替换成对应的各自符号
    • 从不懂角度,不同复杂程度去准备语料
      • 角度:天气,吃饭,性别…
      • 复杂度:简单、一般、复杂
  3. 工程的角度优化
    • 使用模板,对常见的问题进行匹配,返回预设的答案
    • 使用分类模型,进行对问题的分类,返回预设的答案
    • 使用搜索模型,从现有的语料库中,返回相似问题对应的答案。
  4. 根据特定的问题,使用分类模型进行训练,然后再训练单独的回个该为题的为模型
    • 比如询问名字,可以使用fasttext先进行意图识别,命中询问名字分类后,直接返回名字
    • 或者是手动构造和名字相关的很多问题,来进行训练,从而能够更加个性化的回答出结果
  5. 直接对现有的语料进行修改和清洗,把语料中更多的答案进行替换,比如咨询名字的,咨询天气的等,这样能够更大程度上的回答出更加规范的答案
  6. 使用搜索模型,不再使用这种生成模型

十一、使用闲聊机器人模型进行预测【闲聊】

预测使用的代码和decoder中的forward()方法差不多,只是在预测时不能使用teaching_force。

import torch
import torch.nn as nn
import config
import random
import torch.nn.functional as F
from word_sequence import word_sequence

class Decoder(nn.Module):
    def __init__(self):
        super(Decoder,self).__init__()
        self.max_seq_len = config.max_len
        self.vocab_size = len(word_sequence)
        self.embedding_dim = config.embedding_dim
        self.dropout = config.dropout

        self.embedding = nn.Embedding(num_embeddings=self.vocab_size,embedding_dim=self.embedding_dim,padding_idx=word_sequence.PAD)
        self.gru = nn.GRU(input_size=self.embedding_dim,
                          hidden_size=config.hidden_size,
                          num_layers=1,
                          batch_first=True,
                          dropout=self.dropout)
        self.log_softmax = nn.LogSoftmax()

        self.fc = nn.Linear(config.hidden_size,self.vocab_size)

    def forward(self, encoder_hidden,target,target_length):
        # encoder_hidden [batch_size,hidden_size*2]
        # target [batch_size,seq-len]

        decoder_input = torch.LongTensor([[word_sequence.SOS]]*config.batch_size).to(config.device)	# 初始化解码器的input
        decoder_hidden = encoder_hidden # 初始化解码器的hidden_state, 形状为:[batch_size,hidden_size*2]【*2是因为编码器使用了bidirectional,所以编码器的输出维度为hidden_size*2】
        decoder_outputs = torch.zeros(config.batch_size,config.max_len,self.vocab_size).to(config.device) # 初始化解码器的输出,形状为: [batch_size,seq_len,14]

        for t in range(config.max_len):
            decoder_output_t , decoder_hidden = self.forward_step(decoder_input,decoder_hidden)
            decoder_outputs[:,t,:] = decoder_output_t
            value, index = torch.topk(decoder_output_t, 1) # 获取当前时间步的预测值  index [batch_size,1]
            decoder_input = index	# 使用当前时间步的预测值作为下一个时间步的输入
        return decoder_outputs,decoder_hidden

    def forward_step(self,decoder_input,decoder_hidden):
        """
        :param decoder_input:[batch_size,1]
        :param decoder_hidden: [1,batch_size,hidden_size*2]
        :return: out:[batch_size,vocab_size],decoder_hidden:[1,batch_size,hidden_size*2]
        """
        embeded = self.embedding(decoder_input)  #embeded: [batch_size,1 , embedding_dim]
        out,decoder_hidden = self.gru(embeded,decoder_hidden) #out [1, batch_size, hidden_size*2], decoder_hidden:[1,batch_size,hidden_size*2]
        out = out.squeeze(0)
        out = F.log_softmax(self.fc(out),dim=-1)#[batch_Size, vocab_size]
        out = out.squeeze(1)
        # print("out size:",out.size(),decoder_hidden.size())
        return out,decoder_hidden

    def prediction(self, encoder_hidden):
        # encoder_hidden [batch_size,hidden_size*2]
        # target [batch_size,seq-len]
        decoder_input = torch.LongTensor([[word_sequence.SOS]]*config.batch_size).to(config.device)	# 初始化解码器的input
        decoder_hidden = encoder_hidden # 初始化解码器的hidden_state, 形状为:[batch_size,hidden_size*2]【*2是因为编码器使用了bidirectional,所以编码器的输出维度为hidden_size*2】
        decoder_outputs = torch.zeros(config.batch_size,config.max_len,self.vocab_size).to(config.device) # 初始化解码器的输出,形状为: [batch_size,seq_len,14]
		predict_result = []
        for t in range(config.max_len):
            decoder_output_t , decoder_hidden = self.forward_step(decoder_input,decoder_hidden)
            decoder_outputs[:,t,:] = decoder_output_t
            value, index = torch.topk(decoder_output_t, 1) # 获取当前时间步的预测值  index [batch_size,1]
            decoder_input = index	# 使用当前时间步的预测值作为下一个时间步的输入
            predict_result.append(index.cpu().detach().numpu()))
            predict_result = np.array(predict_result)
        return decoder_outputs,predict_result 

开始预测:从控制台输入上一句,让闲聊机器人应答。

from utils import cut

def predict():
	# 加载模型
	model = Seq2Seq()
	model.eval()
	model.load_state_dict(torch.load("models/model.pkl"))
	# 准备带预测的数据
    #准备待预测的数据
    while True:
        origin_input =input("Q>>:")
        _input = cut(origin_input, by_word=True)
        input_len = torch.LongTensor([len(_input)]).to(config.device)
        _input = torch.LongTensor([config.input_ws.transform(_input,max_len=config.chatbot_input_max_len)]).to(config.device)

        outputs,predict = model.evaluate(_input,input_len)
        result = config.target_ws.inverse_transform(predict[0])
        print("A>>:",result)

猜你喜欢

转载自blog.csdn.net/u013250861/article/details/115035834
今日推荐