用keras+seq2seq打造一个简单的聊天机器人(附代码和数据集)

前言

最近刚刚入门深度学习,在B站看的吴恩达老师的Deep Learning教程,五门课看完以后真的手痒痒,于是决定做一个对话机器人练练手,网上相关教程很多,但是能完成一整个工程,又比较容易入门的文章其实不多,之前查了很多篇博客,不想让后人再重复走我的弯路,所以写了这篇文章,希望能帮到各位和我一样刚入门的萌新

参考的文章:

  1. keras教程:手把手教你做聊天机器人(上)
  2. keras教程:手把手教你做聊天机器人(下)
  3. 中文NLP笔记:13 用 Keras 实现一个简易聊天机器人
  4. 吴恩达深度学习第五课序列模型的编程练习

数据集下载

萌新不知道干啥用的可以参考第一篇文章keras教程:手把手教你做聊天机器人(上)
1.原始数据集 密码: mqu9

数据示例

data是训练语料,只有100条,数量很少,做出来也就图一乐,真要做成能用的机器人还得几十万条数据
word_vector是训练好的词向量,大家也可以换成自己的

2.我处理过的,已经做好的训练集,可以直接跳过数据预处理阶段直接开始训练
处理好的数据集 分享码:9lyq

开始干活

1.先安装相应拓展

我用的是python 3.6.5+tensorflow1.9.0+keras

  • jieba
  • gensim
  • numpy
  • pickle

pickle是自带的还是要下的?我有点忘了

2.对原始文本进行数据处理

新建一个python文件utils.py,该文件专门用于数据处理

import jieba
import re
from gensim.models import word2vec
import os
import numpy as np

定义一个函数generate_segments,该函数使用jieba分词将原始文本进行分词

def generate_segments(input_file,output_file):
    data = open(input_file,'rb')
    output = open(output_file,'a+',encoding="utf-8")
    lines = data.readlines()
    for line in lines:
        line = line.strip()
        seglist = jieba.cut(line)
        segments = ''
        for word in seglist:
            segments = segments + ' ' + word
        segments = segments + '\n'
        segments = segments.lstrip()
        output.write(segments)
    data.close()
    output.close()

函数写好以后,运行一次,生成分好词的文本文件


分词前

分好词的文本文件

分好词以后,我们需要将原数据里的问句和答句分开,生成输入X和标签Y

def generate_XY(segments_file):
    f = open(segments_file,'r',encoding='utf-8')
    data = f.read()
    X = []
    Y = []
    conversations = data.split('E')
    for q_a in conversations:
        if re.findall('.*M.*M.*',q_a,flags=re.DOTALL):
            q_a = q_a.strip()
            q_a_pair = q_a.split('M')
            X.append(q_a_pair[1].strip())
            Y.append(q_a_pair[2].strip())
    f.close()
    return X,Y

获取到X和Y后,我们需要将它们向量化,这就需要之前下载的词向量模型了,转为词向量后,我们还需要将样本的长度进行统一,统一长度为15,在最后插入终止符全1向量,短的句子需要用全1向量补齐,长的句子需要截断

def XY_vector(X,Y):
    print("加载词向量模型")
    model = word2vec.Word2Vec.load("word_vector/Word60.model")
    X_vector = []
    Y_vector = []
    for sentence in X:
        x = sentence.split(" ")
        x_vec =  [model[w] for w in x if w in model.wv.vocab]
        X_vector.append(x_vec)
    for sentence in Y:
        y = sentence.split(" ")
        y_vec =  [model[w] for w in y if w in model.wv.vocab]
        Y_vector.append(y_vec)

    word_dim = len(X_vector[0][0])
    end_word = np.ones(shape=(word_dim,))
    #将长度统一
    for vector in X_vector:
        if len(vector) > 14:#大于14的情况
            vector[14:] = []
            vector.append(end_word)
        else:
            for i in range(15 - len(vector)):
                vector.append(end_word)
    
    for vector in Y_vector:
        if len(vector) > 14:#大于14的情况
            vector[14:] = []
            vector.append(end_word)
        else:
            for i in range(15 - len(vector)):
                vector.append(end_word)  
    return X_vector,Y_vector

最后还有一步是比较关键的,我们这次使用的是seq2seq结构,在训练decoder时,我们本需要将前一个单元的输出作为后一个单元的输入,但是这么做在训练时容易引起梯度爆炸,所以这次我们直接将答句作为decoder的输入,并且开头输入全零,其他词语延后一个时间步长,理解不了的可以看我的灵魂画图


灵魂画图

这么做其实也不是最优解,更好的方法是一开始先直接输入答句,到后来再逐渐将输入换为前一个RNN单元的输出,这种方法叫作scheduled sampling,不过由于我还不太熟悉keras,这次就先算了
定义一个函数,根据标签Y,生成用于训练时的decoder_input

def generate_decoder_input(decoder_output):#生成解码器的输入序列,输出序列的第一个词是输入序列的第二个词,输入序列的第一个单词为全零向量
    word_dim = len(decoder_output[0][0])
    word_start = np.zeros(shape=(word_dim,))#全0
    decoder_input = []
    if not(decoder_input is decoder_output):
        for example in decoder_output:
            input_example = example[:14]
            input_example.insert(0,word_start)
            decoder_input.append(input_example)
    return decoder_input

函数全部定义好以后,先生成分词好的文本文件,再生成训练集,保存到data.npz文件中

if __name__ == "__main__":
    output_file = "data/chatterbot_segments.txt"
    X,Y = generate_XY(output_file)
    X_vector,Y_vector = XY_vector(X,Y)
    Y_input = generate_decoder_input(Y_vector)
    print(Y_vector[0][:2])#测试用
    print(Y_input[0][:2])#测试用
    np.savez("data",data_X=X_vector,data_input_Y=Y_input,data_output_Y=Y_vector)

3.编写训练模型

新建一个python文件,叫做train.py,先引入要用的模块

import warnings
warnings.filterwarnings('ignore')#用于消除警告的,可以不加
from keras.models import Model
from keras.layers import Input, LSTM, Dense,TimeDistributed
from keras.optimizers import Adam
import numpy as np
import pickle

import utils

然后将处理好的训练集读入,这里你可以用我提供的,也可以自己做

data = np.load("data.npz",allow_pickle=True)
data_X = data["data_X"]
data_input_Y = data["data_input_Y"]
data_output_Y = data["data_output_Y"]#准备数据

m_X = len(data_X)
m_Y = len(data_input_Y)
word_dim = len(data_X[0][0])
word_Tx = len(data_X[0])
print("X的样本数",m_X)
print("X每句话长度",word_Tx)
print("X维数",data_X.shape)
print("Y的样本数",m_Y)
print("Y维数",data_input_Y.shape)
print("Y输出维数",data_output_Y.shape)
print("词向量维数",word_dim)
print("Y输入第一个词",data_input_Y[0][0])
print("Y输出第一个词",data_output_Y[0][0])


模型使用LSTM,先定义编码器(encoder),将问句作为输入,保存编码器的最终状态,再定义解码器(decoder),用编码器的最终状态作为解码器的起始状态,在解码器的输出上再套一层全连接层,将输出映射为词向量长度,注意这里的全连接层激活函数为"linear",不能用tanh,relu,softmax等其他激活函数

hidden_dim = 256#LSTM隐藏层维数
encoder_input = Input(shape=(None,word_dim),name='encoder_input')#输入是维度为行,样本长度为列
encoder = LSTM(hidden_dim,return_state=True,name='encoder')
encoder_output,state_h,state_c = encoder(encoder_input)
encoder_state = [state_h,state_c]

decoder_input = Input(shape=(None,word_dim),name='decoder_input')
decoder = LSTM(hidden_dim,return_sequences=True,return_state=True,name='decoder')
decoder_output,_,_ = decoder(decoder_input,initial_state=encoder_state)
decoder_dense = TimeDistributed(Dense(output_dim=word_dim,activation='linear'),name='densor')
outputs = decoder_dense(decoder_output)

train_model = Model(inputs=[encoder_input,decoder_input],outputs=outputs)

设置一些训练的超参数,注意损失函数用的是mse,不是categorical_crossentropy,因为这不是一个分类问题,训练3000轮,这个值可能有点大,会引起过拟合,建议多试试其他数,训练好的模型保存到硬盘里

opt = Adam(lr=0.005,decay=0.01)
train_model.compile(optimizer=opt, loss='mse', metrics=['accuracy'])
train_model.fit([data_X,data_input_Y],data_output_Y,epochs=3000,batch_size=32)
train_model.save('train_model.h5')

train.py 完整代码

import warnings
warnings.filterwarnings('ignore')#用于消除警告的,可以不加
from keras.models import Model
from keras.layers import Input, LSTM, Dense,TimeDistributed
from keras.optimizers import Adam
import numpy as np
import pickle

import utils

data = np.load("data.npz",allow_pickle=True)
data_X = data["data_X"]
data_input_Y = data["data_input_Y"]
data_output_Y = data["data_output_Y"]#准备数据

m_X = len(data_X)
m_Y = len(data_input_Y)
word_dim = len(data_X[0][0])
word_Tx = len(data_X[0])
print("X的样本数",m_X)
print("X每句话长度",word_Tx)
print("X维数",data_X.shape)
print("Y的样本数",m_Y)
print("Y维数",data_input_Y.shape)
print("Y输出维数",data_output_Y.shape)
print("词向量维数",word_dim)
print("Y输入第一个词",data_input_Y[0][0])
print("Y输出第一个词",data_output_Y[0][0])

hidden_dim = 256#LSTM隐藏层维数
encoder_input = Input(shape=(None,word_dim),name='encoder_input')#输入是维度为行,样本为列
encoder = LSTM(hidden_dim,return_state=True,name='encoder')
encoder_output,state_h,state_c = encoder(encoder_input)
encoder_state = [state_h,state_c]

decoder_input = Input(shape=(None,word_dim),name='decoder_input')
decoder = LSTM(hidden_dim,return_sequences=True,return_state=True,name='decoder')
decoder_output,_,_ = decoder(decoder_input,initial_state=encoder_state)
decoder_dense = TimeDistributed(Dense(output_dim=word_dim,activation='linear'),name='densor')
outputs = decoder_dense(decoder_output)

train_model = Model(inputs=[encoder_input,decoder_input],outputs=outputs)

opt = Adam(lr=0.005,decay=0.01)
train_model.compile(optimizer=opt, loss='mse', metrics=['accuracy'])
train_model.fit([data_X,data_input_Y],data_output_Y,epochs=3000,batch_size=32)
train_model.save('train_model.h5')

运行测试

最后loss大概0.01,acc大概0.41

4.编写预测模型,并进行预测

新建一个predict.py文件,首先引入模块,并设置好一些参数,载入训练好的模型

import warnings
warnings.filterwarnings('ignore')
from keras.models import Model,load_model
from keras.layers import Input, LSTM, Dense,TimeDistributed
from keras.optimizers import Adam
from gensim.models import word2vec
import numpy as np
import jieba
import pickle

print("加载模型中")
model = load_model("train_model.h5")
word2vec_model = word2vec.Word2Vec.load("word_vector/Word60.model")
Ty = 30#回答的最长长度
hidden_dim = 256
word_dim = 60#超参数设置

虽然预测模型使用的还是之前训练模型里的结构,但是在预测时,我们会将decoder前一个单元的输出作为下一个decoder单元的输入,所以还需要重新定义一下网络。在这个模型里,我们使用for循环将前一单元的输出作为下一个单元的输入,并把每个单元的输出放进一个list里

def predict_model(encoder_layer,decoder_layer,time_densor,word_dim,Ty):
    X0 = Input(shape=(None,word_dim),name="sentence_input")
    _,state_h,state_c = encoder_layer(X0)#获得编码器最终状态
    decoder_states_inputs = [state_h, state_c]
    decoder_input = Input(shape=(1,word_dim),name='decoder_initial_input')
    X = decoder_input
    outputs = []
    for i in range(Ty):
        decoder_output,h,c = decoder_layer(X,initial_state=decoder_states_inputs)
        output = time_densor(decoder_output)
        decoder_states_inputs = [h,c]
        X = output
        outputs.append(output)
    
    model = Model(input=[X0,decoder_input],outputs=outputs)
    return model

这个函数需要接收模型的encoder_layer,decoder_layer,time_densor后面会说到怎么获取这几个实例

定义两个函数,一个用于将人输入的问句向量化,另一个将机器输出的序列转换为文字

1.输入向量化

def input_sentence_vector(sentence,word2vec_model):#将加载好的模型当做参数传入,加快运行速度
    sentence = sentence.strip()
    word_list = jieba.cut(sentence)
    word_vector = [word2vec_model[w] for w in word_list if w in word2vec_model.wv.vocab]#转为词向量
    word_dim = len(word_vector[0])
    
    word_end = np.ones(shape=(word_dim,))#设置停止词
    if len(word_vector) > 14:#裁剪句子
        word_vector[14:] = []
        word_vector.append(word_end)
    else:
        for i in range(15 - len(word_vector)):
            word_vector.append(word_end)
    return np.array([word_vector])

该函数将人的输入问句转为词向量序列,将加载好的词向量模型作为参数接收,而不在函数里加载模型,避免每次调用函数时都要加载一次模型

2.输出序列转文字

def vec2Sentence(answer_sequence,word2vec_model):
    answer_list = [word2vec_model.most_similar([answer_sequence[i][0][0]])[0] for i in range(Ty)]
    answer = ''
    for index,word_tuple in enumerate(answer_list):
        if word_tuple[1]>0.75:#当置信度小于75%时,就不把这个词加入回答中
            answer += str(word_tuple[0])

    return answer

这里有个需要注意的地方,由于全1向量不一定是词向量模型中的停止词,而且网络也不会输出纯粹的全1向量,为了避免输出大量的无意义结果,我做了一个处理,只将置信度大于75%的单词加入到回答中

主函数部分,先获取predict_model需要的三个参数,并初始化模型。get_layer()函数里的字符串是网络层的名字,名字是在原来train.py创建网络层时用参数name指定的,如果你起的名字和我不一样,或者你没写name的话应该会出问题

#sentence = np.zeros(shape=(1,15,60),dtype=float)#测试用的
print("加载模型层")
encoder = model.get_layer('encoder')#编码器生成编码状态
decoder = model.get_layer('decoder')
densor = model.get_layer('densor')
decoder_model = predict_model(encoder,decoder,densor,word_dim,Ty)

最后就是问答的代码了,predict_model里还是需要一个decoder_input的输入,我试过不用,但是会莫名其妙报错,最后只能手动指定一个全零的默认值传进去了

while(True):
    sentence = input("问:")
    sentence_vec = input_sentence_vector(sentence,word2vec_model)
    
    X = np.zeros(shape=[1,1,word_dim])#decoder默认输入,为零就好
    answer_sequence = decoder_model.predict([sentence_vec,X])#输出回答
    print("答:",vec2Sentence(answer_sequence,word2vec_model))

predict.py完整代码

import warnings
warnings.filterwarnings('ignore')
from keras.models import Model,load_model
from keras.layers import Input, LSTM, Dense,TimeDistributed
from keras.optimizers import Adam
from gensim.models import word2vec
import numpy as np
import jieba
import pickle


print("加载模型中")
model = load_model("train_model.h5")
word2vec_model = word2vec.Word2Vec.load("word_vector/Word60.model")
Ty = 30#回答的最长长度
hidden_dim = 256
word_dim = 60#超参数设置


def predict_model(encoder_layer,decoder_layer,time_densor,word_dim,Ty):
    X0 = Input(shape=(None,word_dim),name="sentence_input")
    _,state_h,state_c = encoder_layer(X0)#获得编码序列
    decoder_states_inputs = [state_h, state_c]
    decoder_input = Input(shape=(1,word_dim),name='decoder_initial_input')
    X = decoder_input
    outputs = []
    for i in range(Ty):
        decoder_output,h,c = decoder_layer(X,initial_state=decoder_states_inputs)
        output = time_densor(decoder_output)
        decoder_states_inputs = [h,c]
        X = output
        outputs.append(output)
    
    model = Model(input=[X0,decoder_input],outputs=outputs)
    return model

def input_sentence_vector(sentence,word2vec_model):#将加载好的模型当做参数传入,加快运行速度
    sentence = sentence.strip()
    word_list = jieba.cut(sentence)
    word_vector = [word2vec_model[w] for w in word_list if w in word2vec_model.wv.vocab]#转为词向量
    word_dim = len(word_vector[0])
    
    word_end = np.ones(shape=(word_dim,))#设置停止词
    if len(word_vector) > 14:#裁剪句子
        word_vector[14:] = []
        word_vector.append(word_end)
    else:
        for i in range(15 - len(word_vector)):
            word_vector.append(word_end)
    return np.array([word_vector])

def vec2Sentence(answer_sequence,word2vec_model):
    answer_list = [word2vec_model.most_similar([answer_sequence[i][0][0]])[0] for i in range(Ty)]
    answer = ''
    for index,word_tuple in enumerate(answer_list):
        if word_tuple[1]>0.75:#当置信概率小于75%时,就不把这个词加入回答中
            answer += str(word_tuple[0])

    return answer



#sentence = np.zeros(shape=(1,15,60),dtype=float)#测试用的
print("加载模型层")
encoder = model.get_layer('encoder')#编码器生成编码状态
decoder = model.get_layer('decoder')
densor = model.get_layer('densor')
decoder_model = predict_model(encoder,decoder,densor,word_dim,Ty)
while(True):
    sentence = input("问:")
    sentence_vec = input_sentence_vector(sentence,word2vec_model)
    
    X = np.zeros(shape=[1,1,word_dim])#decoder默认输入,为零就好
    answer_sequence = decoder_model.predict([sentence_vec,X])#输出回答
    print("答:",vec2Sentence(answer_sequence,word2vec_model))
    


运行测试一下


测试

完全按训练集问大部分能答上来,但是语序稍微不一样,或者问别的问题就拉胯了,这也没有办法,毕竟网络比较简单,训练集里数据也不够,如果能用几十万条数据训练应该会好很多

总结

由于网络比较简单,训练集数据量也很少,作为入门实践还是相当不错的,以后想尝试更高级点的东西,把attention机制、beam search、scheduled sampling啥的都给安排上,再换个百度贴吧对话数据集来训练,不过不知啥时才能做成了,咕咕咕

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,425评论 4 361
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,058评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,186评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,848评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,249评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,554评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,830评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,536评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,239评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,505评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,004评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,346评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,999评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,060评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,821评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,574评论 2 271
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,480评论 2 267

推荐阅读更多精彩内容

  • 机器学习术语表 本术语表中列出了一般的机器学习术语和 TensorFlow 专用术语的定义。 A A/B 测试 (...
    yalesaleng阅读 1,930评论 0 11
  • 前面的文章主要从理论的角度介绍了自然语言人机对话系统所可能涉及到的多个领域的经典模型和基础知识。这篇文章,甚至之后...
    我偏笑_NSNirvana阅读 13,648评论 2 64
  • 零、总结 整个项目分为 数据清洗 和 建立模型两个部分。 (1)主要定义了seq2seq这样一个模型。首先是一个构...
    潇萧之炎阅读 2,504评论 1 3
  • 近日,谷歌官方在 Github开放了一份神经机器翻译教程,该教程从基本概念实现开始,首先搭建了一个简单的NMT模型...
    MiracleJQ阅读 6,244评论 1 11
  • 我们回来之后又因为一次偶然的机会又聚在了一起,我们依旧是三加二模式,我和老师跟着啸在一起。 我还没有从上一次的旅行...
    爱梦的我阅读 91评论 0 0