# 深度学习对话系统实战篇--简单chatbot代码实现
前面几篇文章我们已经介绍了seq2seq模型的理论知识,并且从tensorflow源码层面解析了其实现原理,本篇文章我们会聚焦于如何调用tf提供的seq2seq的API,实现一个简单的chatbot对话系统。这里先给出几个参考的博客和代码:
- [tensorflow官网API指导](https://www.tensorflow.org/api_docs/python/tf/contrib/legacy_seq2seq)
- [Chatbots with Seq2Seq Learn to build a chatbot using TensorFlow](http://suriyadeepan.github.io/2016-06-28-easy-seq2seq/)
- [DeepQA](https://github.com/Conchylicultor/DeepQA#chatbot)
- [Neural_Conversation_Models](https://github.com/pbhatia243/Neural_Conversation_Models)
经过一番调查发现网上现在大部分chatbot的代码都是基于1.0版本之前的tf实现的,而且都是从tf官方指导文档nmt上进行迁移和改进,所以基本上大同小异,但是在实际使用过程中会发现一个问题,由于tf版本之间的兼容问题导致这些代码在新版本的tf中无法正常运行,常见的几个问题主要是:
- seq2seq API从tf.nn迁移到了tf.contrib.legacy_seq2seq;
- rnn目前也大都使用tf.contrib.rnn下面的RNNCell;
- embedding_attention_seq2seq函数中调用deepcopy(cell)这个函数经常会爆出(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分别使用下面代码进行初始化:
```python
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函数时就不会报错,其具体的内部原理我现在还没有搞清楚,就看两个最简单的例子:
```python
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相似风格的代码,只包含四个文件,代码读起来也比较友好。接下来就让我们看一下具体的代码实现吧。
![](https://www.writebug.com/myres/static/uploads/2022/1/19/c42496752f8447de55d9827ab3dd1386.writebug)
## **数据处理**
这里我们借用[DeepQA](https://github.com/Conchylicultor/DeepQA#chatbot)里面数据处理部分的代码,省去从原始本文文件构造对话的过程直接使用其生成的dataset-cornell-length10-filter1-vocabSize40000.pkl文件。有了该文件之后数据处理的代码就精简了很多,主要包括:
- 读取数据的函数loadDataset()
- 根据数据创建batches的函数getBatches()和createBatch()
- 预测时将用户输入的句子转化成batch的函数sentence2enco()
具体的代码含义在注释中都有详细的介绍,这里就不赘述了,见下面的代码:
```python
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 <go> and <eos> tokens
batch.targetSeqs.append(batch.decoderSeqs[-1][1:]) # Same as decoder, but shifted to the left (ignore the <go>)
# 将每个元素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(b