本文的代码都可以到我的github中下载:https://github.com/lc222/seq2seq_chatbot
预训练好的模型可以到我的百度云网盘中下载:
链接:https://pan.baidu.com/s/1hrNxaSk 密码:d2sn
前面几篇文章我们已经介绍了seq2seq模型的理论知识,并且从tensorflow源码层面解析了其实现原理,本篇文章我们会聚焦于如何调用tf提供的seq2seq的API,实现一个简单的chatbot对话系统。这里先给出几个参考的博客和代码:
经过一番调查发现网上现在大部分chatbot的代码都是基于1.0版本之前的tf实现的,而且都是从tf官方指导文档nmt上进行迁移和改进,所以基本上大同小异,但是在实际使用过程中会发现一个问题,由于tf版本之间的兼容问题导致这些代码在新版本的tf中无法正常运行,常见的几个问题主要是:
TypeError: can't pickle _thread.lock objects
关于上面第三个错误这里多说几句,因为确实困扰了我很久,基本上我在网上找到的每一份代码都会有这个错(DeepQA除外)。首先来讲一种最简单的方法是将tf版本换成1.0.0,这样问题就解决了。
然后说下不想改tf版本的办法,我在网上找了很久,自己也尝试着去找bug所在,错误定位在embedding_attention_seq2seq函数中调用deepcopy函数,于是就有人尝试着把deepcopy改成copy,或者干脆不进行copy直接让encoder和decoder使用相同参数的RNNcell,但这明显是不正确的做法。我先想出了一种解决方案就是将embedding_attention_seq2seq的传入参数中的cell改成两个,分别是encoder_cell和decoder_cell,然后这两个cell分别使用下面代码进行初始化:
encoCell = tf.contrib.rnn.MultiRNNCell([create_rnn_cell() for _ in range(num_layers)],)
decoCell = tf.contrib.rnn.MultiRNNCell([create_rnn_cell() for _ in range(num_layers)],)
这样做不需要调用deepcopy函数对cell进行复制了,问题也就解决了,但是在模型构建的时候速度会比较慢,我猜测是因为需要构造两份RNN模型,但是最后训练的时候发现速度也很慢,就先放弃了这种做法。
然后我又分析了一下代码,发现问题并不是单纯的出现在embedding_attention_seq2seq这个函数,而是在调用module_with_buckets的时候会构建很多个不同bucket的seq2seq模型,这就导致了embedding_attention_seq2seq会被重复调用很多次,后来经过测试发现确实是这里出现的问题,因为即便不使用model_with_buckets函数,我们自己为每个bucket构建模型时同样也会报错,但是如果只有一个bucket也就是只调用一次embedding_attention_seq2seq函数时就不会报错,其具体的内部原理我现在还没有搞清楚,就看两个最简单的例子:
import tensorflow as tf
import copy
cell = tf.contrib.rnn.BasicLSTMCell(10)
cell1 = copy.deepcopy(cell)#这句代码不会报错,可以正常执行
a = tf.constant([1,2,3,4,5])
b = copy.deepcopy(a)#这句代码会报错,就是can't pickle _thread.lock objects。可以理解为a已经有值了,而且是tf内部类型,导致运行时出错???还是不太理解tf内部运行机制,为什么cell没有线程锁,但是a有呢==
所以先忽视原因,只看解决方案的话就是,不适用buckets构建模型,而是简单的将所有序列都padding到统一长度,然后直接调用一次embedding_attention_seq2seq函数构建模型即可,这样是不会抱错的。(希望看到这的同学如果对这里比较理解可以指点一二,或者互相探讨一下)
最后我也是采用的这种方案,综合了别人的代码实现了一个embedding+attention+beam_search等多种功能的seq2seq模型,训练一个基础版本的chatbot对话机器人,tf的版本是1.4。写这份代码的目的一方面是为了让自己对tf的API接口的使用方法更熟悉,另一方面是因为网上的一些代码都很繁杂,想DeepQA这种,里面会有很多个文件还实现了前端,然后各种封装,显得很复杂,不适合新手入门,所以就想写一个跟textcnn相似风格的代码,只包含四个文件,代码读起来也比较友好。接下来就让我们看一下具体的代码实现吧。最终的代码我会放在github上
这里我们借用DeepQA里面数据处理部分的代码,省去从原始本文文件构造对话的过程直接使用其生成的dataset-cornell-length10-filter1-vocabSize40000.pkl文件。有了该文件之后数据处理的代码就精简了很多,主要包括:
具体的代码含义在注释中都有详细的介绍,这里就不赘述了,见下面的代码:
padToken, goToken, eosToken, unknownToken = 0, 1, 2, 3
class Batch:
#batch类,里面包含了encoder输入,decoder输入,decoder标签,decoder样本长度mask
def __init__(self):
self.encoderSeqs = []
self.decoderSeqs = []
self.targetSeqs = []
self.weights = []
def loadDataset(filename):
'''
读取样本数据
:param filename: 文件路径,是一个字典,包含word2id、id2word分别是单词与索引对应的字典和反序字典,
trainingSamples样本数据,每一条都是QA对
:return: word2id, id2word, trainingSamples
'''
dataset_path = os.path.join(filename)
print('Loading dataset from {}'.format(dataset_path))
with open(dataset_path, 'rb') as handle:
data = pickle.load(handle) # Warning: If adding something here, also modifying saveDataset
word2id = data['word2id']
id2word = data['id2word']
trainingSamples = data['trainingSamples']
return word2id, id2word, trainingSamples
def createBatch(samples, en_de_seq_len):
'''
根据给出的samples(就是一个batch的数据),进行padding并构造成placeholder所需要的数据形式
:param samples: 一个batch的样本数据,列表,每个元素都是[question, answer]的形式,id
:param en_de_seq_len: 列表,第一个元素表示source端序列的最大长度,第二个元素表示target端序列的最大长度
:return: 处理完之后可以直接传入feed_dict的数据格式
'''
batch = Batch()
#根据样本长度获得batch size大小
batchSize = len(samples)
#将每条数据的问题和答案分开传入到相应的变量中
for i in range(batchSize):
sample = samples[i]
batch.encoderSeqs.append(list(reversed(sample[0]))) # 将输入反序,可提高模型效果
batch.decoderSeqs.append([goToken] + sample[1] + [eosToken]) # Add the and tokens
batch.targetSeqs.append(batch.decoderSeqs[-1][1:]) # Same as decoder, but shifted to the left (ignore the )
# 将每个元素PAD到指定长度,并构造weights序列长度mask标志
batch.encoderSeqs[i] = [padToken] * (en_de_seq_len[0] - len(batch.encoderSeqs[i])) + batch.encoderSeqs[i]
batch.weights.append([1.0] * len(batch.targetSeqs[i]) + [0.0] * (en_de_seq_len[1] - len(batch.targetSeqs[i])))
batch.decoderSeqs[i] = batch.decoderSeqs[i] + [padToken] * (en_de_seq_len[1] - len(batch.decoderSeqs[i]))
batch.targetSeqs[i] = batch.targetSeqs[i] + [padToken] * (en_de_seq_len[1] - len(batch.targetSeqs[i]))
#--------------------接下来就是将数据进行reshape操作,变成序列长度*batch_size格式的数据------------------------
encoderSeqsT = [] # Corrected orientation
for i in range(en_de_seq_len[0]):
encoderSeqT = []
for j in range(batchSize):
encoderSeqT.append(batch.encoderSeqs[j][i])
encoderSeqsT.append(encoderSeqT)
batch.encoderSeqs = encoderSeqsT
decoderSeqsT = []
targetSeqsT = []
weightsT = []
for i in range(en_de_seq_len[1]):
decoderSeqT = []
targetSeqT = []
weightT = []
for j in range(batchSize):
decoderSeqT.append(batch.decoderSeqs[j][i])
targetSeqT.append(batch.targetSeqs[j][i])
weightT.append(batch.weights[j][i])
decoderSeqsT.append(decoderSeqT)
targetSeqsT.append(targetSeqT)
weightsT.append(weightT)
batch.decoderSeqs = decoderSeqsT
batch.targetSeqs = targetSeqsT
batch.weights = weightsT
return batch
def getBatches(data, batch_size, en_de_seq_len):
'''
根据读取出来的所有数据和batch_size将原始数据分成不同的小batch。对每个batch索引的样本调用createBatch函数进行处理
:param data: loadDataset函数读取之后的trainingSamples,就是QA对的列表
:param batch_size: batch大小
:param en_de_seq_len: 列表,第一个元素表示source端序列的最大长度,第二个元素表示target端序列的最大长度
:return: 列表,每个元素都是一个batch的样本数据,可直接传入feed_dict进行训练
'''
#每个epoch之前都要进行样本的shuffle
random.shuffle(data)
batches = []
data_len = len(data)
def genNextSamples():
for i in range(0, data_len, batch_size):
yield data[i:min(i + batch_size, data_len)]
for samples in genNextSamples():
batch = createBatch(samples, en_de_seq_len)
batches.append(batch)
return batches
def sentence2enco(sentence, word2id, en_de_seq_len):
'''
测试的时候将用户输入的句子转化为可以直接feed进模型的数据,现将句子转化成id,然后调用createBatch处理
:param sentence: 用户输入的句子
:param word2id: 单词与id之间的对应关系字典
:param en_de_seq_len: 列表,第一个元素表示source端序列的最大长度,第二个元素表示target端序列的最大长度
:return: 处理之后的数据,可直接feed进模型进行预测
'''
if sentence == '':
return None
#分词
tokens = nltk.word_tokenize(sentence)
if len(tokens) > en_de_seq_len[0]:
return None
#将每个单词转化为id
wordIds = []
for token in tokens:
wordIds.append(word2id.get(token, unknownToken))
#调用createBatch构造batch
batch = createBatch([[wordIds, []]], en_de_seq_len)
return batch
有了数据之后看一下模型构建的代码,其实主体代码还是跟前面说到的tf官方指导文档差不多,主要分为以下几个功能模块:
代码如下所示:
import tensorflow as tf
from my_seq2seq_chatbot.seq2seq import embedding_attention_seq2seq
class Seq2SeqModel():
def __init__(self, source_vocab_size, target_vocab_size, en_de_seq_len, hidden_size, num_layers,
batch_size, learning_rate, num_samples=1024,
forward_only=False, beam_search=True, beam_size=10):
'''
初始化并创建模型
:param source_vocab_size:encoder输入的vocab size
:param target_vocab_size: decoder输入的vocab size,这里跟上面一样
:param en_de_seq_len: 源和目的序列最大长度
:param hidden_size: RNN模型的隐藏层单元个数
:param num_layers: RNN堆叠的层数
:param batch_size: batch大小
:param learning_rate: 学习率
:param num_samples: 计算loss时做sampled softmax时的采样数
:param forward_only: 预测时指定为真
:param beam_search: 预测时是采用greedy search还是beam search
:param beam_size: beam search的大小
'''
self.source_vocab_size = source_vocab_size
self.target_vocab_size = target_vocab_size
self.en_de_seq_len = en_de_seq_len
self.hidden_size = hidden_size
self.num_layers = num_layers
self.batch_size = batch_size
self.learning_rate = tf.Variable(float(learning_rate), trainable=False)
self.num_samples = num_samples
self.forward_only = forward_only
self.beam_search = beam_search
self.beam_size = beam_size
self.global_step = tf.Variable(0, trainable=False)
output_projection = None
softmax_loss_function = None
# 定义采样loss函数,传入后面的sequence_loss_by_example函数
if num_samples > 0 and num_samples < self.target_vocab_size:
w = tf.get_variable('proj_w', [hidden_size, self.target_vocab_size])
w_t = tf.transpose(w)
b = tf.get_variable('proj_b', [self.target_vocab_size])
output_projection = (w, b)
#调用sampled_softmax_loss函数计算sample loss,这样可以节省计算时间
def sample_loss(logits, labels):
labels = tf.reshape(labels, [-1, 1])
return tf.nn.sampled_softmax_loss(w_t, b, labels=labels, inputs=logits, num_sampled=num_samples, num_classes=self.target_vocab_size)
softmax_loss_function = sample_loss
self.keep_drop = tf.placeholder(tf.float32)
# 定义encoder和decoder阶段的多层dropout RNNCell
def create_rnn_cell():
encoDecoCell = tf.contrib.rnn.BasicLSTMCell(hidden_size)
encoDecoCell = tf.contrib.rnn.DropoutWrapper(encoDecoCell, input_keep_prob=1.0, output_keep_prob=self.keep_drop)
return encoDecoCell
encoCell = tf.contrib.rnn.MultiRNNCell([create_rnn_cell() for _ in range(num_layers)])
# 定义输入的placeholder,采用了列表的形式
self.encoder_inputs = []
self.decoder_inputs = []
self.decoder_targets = []
self.target_weights = []
for i in range(en_de_seq_len[0]):
self.encoder_inputs.append(tf.placeholder(tf.int32, shape=[None, ], name="encoder{0}".format(i)))
for i in range(en_de_seq_len[1]):
self.decoder_inputs.append(tf.placeholder(tf.int32, shape=[None, ], name="decoder{0}".format(i)))
self.decoder_targets.append(tf.placeholder(tf.int32, shape=[None, ], name="target{0}".format(i)))
self.target_weights.append(tf.placeholder(tf.float32, shape=[None, ], name="weight{0}".format(i)))
# test模式,将上一时刻输出当做下一时刻输入传入
if forward_only:
if beam_search:#如果是beam_search的话,则调用自己写的embedding_attention_seq2seq函数,而不是legacy_seq2seq下面的
self.beam_outputs, _, self.beam_path, self.beam_symbol = embedding_attention_seq2seq(
self.encoder_inputs, self.decoder_inputs, encoCell, num_encoder_symbols=source_vocab_size,
num_decoder_symbols=target_vocab_size, embedding_size=hidden_size,
output_projection=output_projection, feed_previous=True)
else:
decoder_outputs, _ = tf.contrib.legacy_seq2seq.embedding_attention_seq2seq(
self.encoder_inputs, self.decoder_inputs, encoCell, num_encoder_symbols=source_vocab_size,
num_decoder_symbols=target_vocab_size, embedding_size=hidden_size,
output_projection=output_projection, feed_previous=True)
# 因为seq2seq模型中未指定output_projection,所以需要在输出之后自己进行output_projection
if output_projection is not None:
self.outputs = tf.matmul(decoder_outputs, output_projection[0]) + output_projection[1]
else:
# 因为不需要将output作为下一时刻的输入,所以不用output_projection
decoder_outputs, _ = tf.contrib.legacy_seq2seq.embedding_attention_seq2seq(
self.encoder_inputs, self.decoder_inputs, encoCell, num_encoder_symbols=source_vocab_size,
num_decoder_symbols=target_vocab_size, embedding_size=hidden_size, output_projection=output_projection,
feed_previous=False)
self.loss = tf.contrib.legacy_seq2seq.sequence_loss(
decoder_outputs, self.decoder_targets, self.target_weights, softmax_loss_function=softmax_loss_function)
# Initialize the optimizer
opt = tf.train.AdamOptimizer(learning_rate=self.learning_rate, beta1=0.9, beta2=0.999, epsilon=1e-08)
self.optOp = opt.minimize(self.loss)
self.saver = tf.train.Saver(tf.all_variables())
def step(self, session, encoder_inputs, decoder_inputs, decoder_targets, target_weights, go_token_id):
#传入一个batch的数据,并训练性对应的模型
# 构建sess.run时的feed_inpits
feed_dict = {}
if not self.forward_only:
feed_dict[self.keep_drop] = 0.5
for i in range(self.en_de_seq_len[0]):
feed_dict[self.encoder_inputs[i].name] = encoder_inputs[i]
for i in range(self.en_de_seq_len[1]):
feed_dict[self.decoder_inputs[i].name] = decoder_inputs[i]
feed_dict[self.decoder_targets[i].name] = decoder_targets[i]
feed_dict[self.target_weights[i].name] = target_weights[i]
run_ops = [self.optOp, self.loss]
else:
feed_dict[self.keep_drop] = 1.0
for i in range(self.en_de_seq_len[0]):
feed_dict[self.encoder_inputs[i].name] = encoder_inputs[i]
feed_dict[self.decoder_inputs[0].name] = [go_token_id]
if self.beam_search:
run_ops = [self.beam_path, self.beam_symbol]
else:
run_ops = [self.outputs]
outputs = session.run(run_ops, feed_dict)
if not self.forward_only:
return None, outputs[1]
else:
if self.beam_search:
return outputs[0], outputs[1]
接下来我们主要说一下我做的主要工作,就是beam_search这部分,其原理想必大家看过前面的文章应该已经很清楚了,那么如何编程实现呢,首先我们要考虑的是在哪里进行beam search,因为beam search是在预测时需要用到,代替greedy的一种搜索策略,所以第一种方案是在tf之外,用python实现,这样做的缺点是decode速度会很慢。第二种方案是在tf内模型构建时进行,这样做的好处是速度快但是比较麻烦。
在网上找了很久在tensorflow的一个issue里面发现了一个方案,他的思路是修改loop_function函数,也就是之前根据上一时刻输出得到下一时刻输入的函数,在loop function里面实现top_k取出概率最大的几个序列,并把相应的路径和单词对应关系保存下来。但是存在一个问题就是一开始decode的时候传入的是一句话,也就是batch_size为1,但是经过loop_function之后返回的是beam_size句话,但是再将其传入RNNCell的时候就会报错,如何解决这个问题呢,想了很久决定直接从decode开始的时候就把输入扩展为beam_size个,把encoder阶段的输出和attention向量都变成beam_size维的tensor,就说把decoder阶段的RNN输入的batch_size当做为beam_size。
但是这样做仍然会出现一个问题,就是你会发现最后的输出全部都相同,原因就在于decoder开始的时候样本是beam_szie个完全相同的输入,所以经过loop_function得到的beam_size个最大序列也是完全相同的,为了解决这个问题我们需要在第一次编码的时候不取整体最大的前beam_size个序列,而是取第一个元素编码结果的前beam_size个值作为结果。这部分代码比较多就只贴出来loop_function的函数,有兴趣的同学可以去看我github上面的代码,就在seq2seq文件中。
def loop_function(prev, i, log_beam_probs, beam_path, beam_symbols):
if output_projection is not None:
prev = nn_ops.xw_plus_b(prev, output_projection[0], output_projection[1])
# 对输出概率进行归一化和取log,这样序列概率相乘就可以变成概率相加
probs = tf.log(tf.nn.softmax(prev))
if i == 1:
probs = tf.reshape(probs[0, :], [-1, num_symbols])
if i > 1:
# 将当前序列的概率与之前序列概率相加得到结果之前有beam_szie个序列,本次产生num_symbols个结果,
# 所以reshape成这样的tensor
probs = tf.reshape(probs + log_beam_probs[-1], [-1, beam_size * num_symbols])
# 选出概率最大的前beam_size个序列,从beam_size * num_symbols个元素中选出beam_size个
best_probs, indices = tf.nn.top_k(probs, beam_size)
indices = tf.stop_gradient(tf.squeeze(tf.reshape(indices, [-1, 1])))
best_probs = tf.stop_gradient(tf.reshape(best_probs, [-1, 1]))
# beam_size * num_symbols,看对应的是哪个序列和单词
symbols = indices % num_symbols # Which word in vocabulary.
beam_parent = indices // num_symbols # Which hypothesis it came from.
beam_symbols.append(symbols)
beam_path.append(beam_parent)
log_beam_probs.append(best_probs)
# 对beam-search选出的beam size个单词进行embedding,得到相应的词向量
emb_prev = embedding_ops.embedding_lookup(embedding, symbols)
emb_prev = tf.reshape(emb_prev, [-1, embedding_size])
return emb_prev
其实模型训练部分的代码很简单,就是每个epoch都对样本进行shuffle然后分batches,接下来将每个batch的数据分别传入model.step()进行模型的训练,这里比较好的一点是,DeepQA用的是embedding_rnn_seq2seq函数,训练过程中loss经过30个人epoch大概可以降到3点多,但是我这里改成了embedding_attention_seq2seq函数,最后loss可以降到2.0以下,可以说效果还是很显著的,而且模型的训练速度并没有降低,仍然是20个小时左右就可以完成训练。
for e in range(FLAGS.numEpochs):
print("----- Epoch {}/{} -----".format(e + 1, FLAGS.numEpochs))
batches = getBatches(trainingSamples, FLAGS.batch_size, model.en_de_seq_len)
for nextBatch in tqdm(batches, desc="Training"):
_, step_loss = model.step(sess, nextBatch.encoderSeqs, nextBatch.decoderSeqs, nextBatch.targetSeqs,
nextBatch.weights, goToken)
current_step += 1
if current_step % FLAGS.steps_per_checkpoint == 0:
perplexity = math.exp(float(step_loss)) if step_loss < 300 else float('inf')
tqdm.write("----- Step %d -- Loss %.2f -- Perplexity %.2f" % (current_step, step_loss, perplexity))
checkpoint_path = os.path.join(FLAGS.train_dir, "chat_bot.ckpt")
model.saver.save(sess, checkpoint_path, global_step=model.global_step)
贴上两张图看一下训练的效果,这里用的是deepQA的截图,因为我的代码训练的时候忘了加tensorboard的东西:
预测好模型之后,接下来需要做的就是对模型效果进行测试,这里也比较简单,主要是如何根据beam_search都所处的结果找到对应的句子进行输出。代码如下所示:
if beam_search:
sys.stdout.write("> ")
sys.stdout.flush()
sentence = sys.stdin.readline()
while sentence:
#将用户输入的句子转化为id并处理成feed_dict的格式
batch = sentence2enco(sentence, word2id, model.en_de_seq_len)
beam_path, beam_symbol = model.step(sess, batch.encoderSeqs, batch.decoderSeqs, batch.targetSeqs,
batch.weights, goToken)
paths = [[] for _ in range(beam_size)]
curr = [i for i in range(beam_size)]
num_steps = len(beam_path)
#根据beam_path和beam_symbol得到真正的输出语句,存在paths中
for i in range(num_steps-1, -1, -1):
for kk in range(beam_size):
paths[kk].append(beam_symbol[i][curr[kk]])
curr[kk] = beam_path[i][curr[kk]]
recos = set()
print("Replies --------------------------------------->")
#转换成句子并输出
for kk in range(beam_size):
foutputs = [int(logit) for logit in paths[kk][::-1]]
if eosToken in foutputs:
foutputs = foutputs[:foutputs.index(eosToken)]
rec = " ".join([tf.compat.as_str(id2word[output]) for output in foutputs if output in id2word])
if rec not in recos:
recos.add(rec)
print(rec)
print("> ", "")
sys.stdout.flush()
sentence = sys.stdin.readline()
接下来我们看一下几个例子,这里beam_size=5,并去掉了一些重复的回答: