从头实现深度学习的对话系统--简单chatbot代码实现

从头实现深度学习的对话系统–简单chatbot代码实现

本文的代码都可以到我的github中下载:https://github.com/lc222/seq2seq_chatbot

预训练好的模型可以到我的百度云网盘中下载:

链接:https://pan.baidu.com/s/1hrNxaSk 密码:d2sn

前面几篇文章我们已经介绍了seq2seq模型的理论知识,并且从tensorflow源码层面解析了其实现原理,本篇文章我们会聚焦于如何调用tf提供的seq2seq的API,实现一个简单的chatbot对话系统。这里先给出几个参考的博客和代码:

  1. tensorflow官网API指导
  2. Chatbots with Seq2Seq Learn to build a chatbot using TensorFlow
  3. DeepQA
  4. 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分别使用下面代码进行初始化:

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文件。有了该文件之后数据处理的代码就精简了很多,主要包括:

  1. 读取数据的函数loadDataset()
  2. 根据数据创建batches的函数getBatches()和createBatch()
  3. 预测时将用户输入的句子转化成batch的函数sentence2enco()

具体的代码含义在注释中都有详细的介绍,这里就不赘述了,见下面的代码:

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官方指导文档差不多,主要分为以下几个功能模块:

  1. 一些变量的传入和定义
  2. OutputProjection层和sampled_softmax_loss函数的定义
  3. RNNCell的定义和创建
  4. 根据训练或者测试调用相应的embedding_attention_seq2seq函数构建模型
  5. step函数定义,主要用于给定一个batch的数据,构造相应的feed_dict和run_opt

代码如下所示:

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,并去掉了一些重复的回答:

你可能感兴趣的:(nlp,深度学习,TensorFlow,QA,QA_对话机器人_机器阅读专题)