利用 TensorFlow 实现上下文的 Chat-bots

在我们的日常聊天中,情景才是最重要的。我们将使用 TensorFlow 构建一个聊天机器人框架,并且添加一些上下文处理机制来使得机器人更加智能。


“Whole World in your Hand” — Betty Newman-Maguire (http://www.bettynewmanmaguire.ie/)

你是否想过一个问题,为什么那么多的聊天机器人会缺乏会话情景功能?

鉴于上下文在所有的对话场景中的重要性,那么又该如何加入这个特性?

接下来,我们将创建一个聊天机器人的框架,并且以一个岛屿轻便摩托车租赁店为例子,建立一个对话模型。这个小企业的聊天机器人需要处理一些关于租赁时间,租赁选项等的简单问题。我们也希望这个机器人可以处理一些上下文的信息,比如查询同一天的租赁信息。如果可以解决这个问题,那么我们将节约很多的时间。

关于构建聊天机器人,我们通过以下三部进行:

  1. 我们会利用 TensorFlow 来编写对话意图模型。
  2. 接下啦,我们将构建一个处理对话的聊天机器人框架。
  3. 最后,我们将介绍如何将上下文信息合并到我们的响应式处理器中。

在模型中,我们将使用 tflearn 框架,这是一个 TensorFlow 的高层 API,并且我们将使用 IPython 作为开发工具。

1. 我们会利用 TensorFlow 来编写对话意图模型。

完整的 notebook 文档,可以点击这里

对于一个聊天机器人框架,我们需要定义一个会话意图的结构。最简单方便的方式是使用一个 JSON 格式的文件,如下所示:


chat-bot intents

每个会话意图包含:

  • 标签(唯一的名称)
  • 模式(我们的神经网络文本分类器需要分类的句子)
  • 回应(一个将被用作回应的句子)

稍后,我们也会添加一些基本的上下文元素。

首先,我们来导入一些我们需要的包:

# things we need for NLP
import nltk
from nltk.stem.lancaster import LancasterStemmer
stemmer = LancasterStemmer()

# things we need for Tensorflow
import numpy as np
import tflearn
import tensorflow as tf
import random

如果你还不了解 TensorFlow,那么可以学习一下这个教程或者这个教程

# import our chat-bot intents file
import json
with open('intents.json') as json_data:
    intents = json.load(json_data)

代码中的 JSON 文件可以这里下载,接下来我们可以开始组织代码的文件,数据和分类器。

words = []
classes = []
documents = []
ignore_words = ['?']
# loop through each sentence in our intents patterns
for intent in intents['intents']:
    for pattern in intent['patterns']:
        # tokenize each word in the sentence
        w = nltk.word_tokenize(pattern)
        # add to our words list
        words.extend(w)
        # add to documents in our corpus
        documents.append((w, intent['tag']))
        # add to our classes list
        if intent['tag'] not in classes:
            classes.append(intent['tag'])

# stem and lower each word and remove duplicates
words = [stemmer.stem(w.lower()) for w in words if w not in ignore_words]
words = sorted(list(set(words)))

# remove duplicates
classes = sorted(list(set(classes)))

print (len(documents), "documents")
print (len(classes), "classes", classes)
print (len(words), "unique stemmed words", words)

我们创建了一个文件列表(每个句子),每个句子都是由一些词干组成,并且每个文档都属于一个特定的类别。

27 documents
9 classes ['goodbye', 'greeting', 'hours', 'mopeds', 'opentoday', 'payments', 'rental', 'thanks', 'today']
44 unique stemmed words ["'d", 'a', 'ar', 'bye', 'can', 'card', 'cash', 'credit', 'day', 'do', 'doe', 'good', 'goodby', 'hav', 'hello', 'help', 'hi', 'hour', 'how', 'i', 'is', 'kind', 'lat', 'lik', 'mastercard', 'mop', 'of', 'on', 'op', 'rent', 'see', 'tak', 'thank', 'that', 'ther', 'thi', 'to', 'today', 'we', 'what', 'when', 'which', 'work', 'you']

比如,词干 tak 将和 taketakingtakers 等匹配。在实际过程中,我们可以删除一些无用的条目,但在这里已经足够了。

不幸的是,这种数据结构不能在 TensorFlow 中使用,我们需要进一步将这个数据进行转换:从单词转换到数字的张量。

# create our training data
training = []
output = []
# create an empty array for our output
output_empty = [0] * len(classes)

# training set, bag of words for each sentence
for doc in documents:
    # initialize our bag of words
    bag = []
    # list of tokenized words for the pattern
    pattern_words = doc[0]
    # stem each word
    pattern_words = [stemmer.stem(word.lower()) for word in pattern_words]
    # create our bag of words array
    for w in words:
        bag.append(1) if w in pattern_words else bag.append(0)

    # output is a '0' for each tag and '1' for current tag
    output_row = list(output_empty)
    output_row[classes.index(doc[1])] = 1

    training.append([bag, output_row])

# shuffle our features and turn into np.array
random.shuffle(training)
training = np.array(training)

# create train and test lists
train_x = list(training[:,0])
train_y = list(training[:,1])

请注意,我们的数据顺序已经被打乱了。 TensorFlow 会选取其中的一些数据作为测试数据,用来测试训练的模型的准确度。

如果我们观察单个的 x 向量和 y 向量,那么这就是一个词袋模型,一个表示需要匹配的模式,一个表示匹配的目标。

train_x example: [0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1] 
train_y example: [0, 0, 1, 0, 0, 0, 0, 0, 0]

接下来,我们来构建我们的模型。

# reset underlying graph data
tf.reset_default_graph()
# Build neural network
net = tflearn.input_data(shape=[None, len(train_x[0])])
net = tflearn.fully_connected(net, 8)
net = tflearn.fully_connected(net, 8)
net = tflearn.fully_connected(net, len(train_y[0]), activation='softmax')
net = tflearn.regression(net)

# Define model and setup tensorboard
model = tflearn.DNN(net, tensorboard_dir='tflearn_logs')
# Start training (apply gradient descent algorithm)
model.fit(train_x, train_y, n_epoch=1000, batch_size=8, show_metric=True)
model.save('model.tflearn')

这个模型使用的是 2 层神经网络模型,跟这篇文章中的是一样的。


interactive build of a model in tflearn

我们完成了这部分的工作,现在需要保存我们的模型和文档, 以便在后续的代码中可以使用它们。

# save all of our data structures
import pickle
pickle.dump( {'words':words, 'classes':classes, 'train_x':train_x, 'train_y':train_y}, open( "training_data", "wb" ) )

构建我们的聊天机器人框架

这部分,完整的代码在这里

我们将构建一个简单的状态机来处理响应,并且使用我们的在上一部分中提到的意图模型来作为我们的分类器。如果你想了解聊天机器人的工作原理,那么可以点击这里


我们需要导入和上一部分相同的包,然后 un-pickle 我们的模型和句子,正如我们在上一部分中操作的。请记住,我们的聊天机器人框架与我们的模型是分开构建的 —— 除非意图模式改变了,那么我们需要重新运行我们的模型,否则不需要重构模型。如果拥有数百种意图和数千种模式,模型可能需要几分钟的时间才能构建完成。

# restore all of our data structures
import pickle
data = pickle.load( open( "training_data", "rb" ) )
words = data['words']
classes = data['classes']
train_x = data['train_x']
train_y = data['train_y']

# import our chat-bot intents file
import json
with open('intents.json') as json_data:
    intents = json.load(json_data)

接下来,我们需要导入刚刚利用 TensorFlow(tflearn 框架)训练好的模型。请注意,你第一步还是需要去定义 TensorFlow 模型结构,正如我们在第一部分中做的那样。

# load our saved model
model.load('./model.tflearn')

在我们开始处理对话意图之前,我们需要一种从用户输入数据生词词袋的方法。而这个方法,跟我们前面所使用的方法是相同的。

def clean_up_sentence(sentence):
    # tokenize the pattern
    sentence_words = nltk.word_tokenize(sentence)
    # stem each word
    sentence_words = [stemmer.stem(word.lower()) for word in sentence_words]
    return sentence_words

# return bag of words array: 0 or 1 for each word in the bag that exists in the sentence
def bow(sentence, words, show_details=False):
    # tokenize the pattern
    sentence_words = clean_up_sentence(sentence)
    # bag of words
    bag = [0]*len(words)  
    for s in sentence_words:
        for i,w in enumerate(words):
            if w == s: 
                bag[i] = 1
                if show_details:
                    print ("found in bag: %s" % w)

    return(np.array(bag))
p = bow("is your shop open today?", words)
print (p)
[0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 1 0 0 0 0 0 1 0]

现在,我们可以开始构建我们的响应处理器了。

ERROR_THRESHOLD = 0.25
def classify(sentence):
    # generate probabilities from the model
    results = model.predict([bow(sentence, words)])[0]
    # filter out predictions below a threshold
    results = [[i,r] for i,r in enumerate(results) if r>ERROR_THRESHOLD]
    # sort by strength of probability
    results.sort(key=lambda x: x[1], reverse=True)
    return_list = []
    for r in results:
        return_list.append((classes[r[0]], r[1]))
    # return tuple of intent and probability
    return return_list

def response(sentence, userID='123', show_details=False):
    results = classify(sentence)
    # if we have a classification then find the matching intent tag
    if results:
        # loop as long as there are matches to process
        while results:
            for i in intents['intents']:
                # find a tag matching the first result
                if i['tag'] == results[0][0]:
                    # a random response from the intent
                    return print(random.choice(i['responses']))

            results.pop(0)

传递给 response() 的每个句子都会被分类。我们分类器使用 model.predict() 函数来进行类别预测,这个方法非常快。模型返回的概率值和我们定义的意图是一直的,用来生成潜在的响应列表。

如果一个或多个分类结果高于阈值,那么我们会选取出一个与意图匹配的标签,然后处理。我们将我们的分类列表作为一个堆栈,并从这个堆栈中寻找一个适合的匹配,直到找到一个最好的或者直到堆栈变空。

我们来举一个例子,模型会返回最有可能的标签和其概率。

classify('is your shop open today?')
[('opentoday', 0.9264171123504639)]

请注意,“is your shop open today?” 不是这个意图中的任何模式:“pattern : ["Are you open today?", "When do you open today?", "What are your hours today?"]”。但是,“open” 和 “today” 术语对我们的模式是非常有用的(他们在选择意图时,有决定性的作用)。

我们现在从用户的输入数据中产生一个结果:

response('is your shop open today?')
Our hours are 9am-9pm every day

再来一些例子:

response('do you take cash?')
We accept VISA, Mastercard and AMEX

response('what kind of mopeds do you rent?')
We rent Yamaha, Piaggio and Vespa mopeds

response('Goodbye, see you later')
Bye! Come back again soon.

接下来让我们结合一些基础的语境来设计一个聊天机器人,比如拖车租赁聊天机器人。

语境

我们想处理的是一个关于租赁摩托车的问题,并询问一些有关租金的事。对于用户问题的理解应该是非常容易的,语境非常清晰。如果用户询问 “today”,那么上下文的租赁信息就是进入时间框架,那么最好你还能指定是哪一个自行车,这样交流起来就不会浪费时间。

为了实现这一点,我们需要在框架中再加入一个概念 “state” 。这需要一个数据结构来维护这个新的概念和原来的意图。

因为我们需要我们的状态机是一个非常容易的维护,恢复和复制等等操作,所以我们需要把数据都保存在一个诸如字典的数据结构中,这是非常重要的。

接下来,我们给出基本语境的回复过程:

# create a data structure to hold user context
context = {}

ERROR_THRESHOLD = 0.25
def classify(sentence):
    # generate probabilities from the model
    results = model.predict([bow(sentence, words)])[0]
    # filter out predictions below a threshold
    results = [[i,r] for i,r in enumerate(results) if r>ERROR_THRESHOLD]
    # sort by strength of probability
    results.sort(key=lambda x: x[1], reverse=True)
    return_list = []
    for r in results:
        return_list.append((classes[r[0]], r[1]))
    # return tuple of intent and probability
    return return_list

def response(sentence, userID='123', show_details=False):
    results = classify(sentence)
    # if we have a classification then find the matching intent tag
    if results:
        # loop as long as there are matches to process
        while results:
            for i in intents['intents']:
                # find a tag matching the first result
                if i['tag'] == results[0][0]:
                    # set context for this intent if necessary
                    if 'context_set' in i:
                        if show_details: print ('context:', i['context_set'])
                        context[userID] = i['context_set']

                    # check if this intent is contextual and applies to this user's conversation
                    if not 'context_filter' in i or \
                        (userID in context and 'context_filter' in i and i['context_filter'] == context[userID]):
                        if show_details: print ('tag:', i['tag'])
                        # a random response from the intent
                        return print(random.choice(i['responses']))

            results.pop(0)

我们的上下文状态是一个字典,它将包含每个用户的状态。我将为每个用户使用一个唯一的标识(例如,cell#)。这允许我们的框架和状态机同事维护多个用户的状态。

# create a data structure to hold user context
context = {}

我们在意图处理流程中,添加了上下文信息,具体如下:

                if i['tag'] == results[0][0]:
                    # set context for this intent if necessary
                    if 'context_set' in i:
                        if show_details: print ('context:', i['context_set'])
                        context[userID] = i['context_set']

                    # check if this intent is contextual and applies to this user's conversation
                    if not 'context_filter' in i or \
                        (userID in context and 'context_filter' in i and i['context_filter'] == context[userID]):
                        if show_details: print ('tag:', i['tag'])
                        # a random response from the intent
                        return print(random.choice(i['responses']))

如果一个意图想要设置上下文信息,那么我们可以这样做:

{“tag”: “rental”,
 “patterns”: [“Can we rent a moped?”, “I’d like to rent a moped”, … ],
 “responses”: [“Are you looking to rent today or later this week?”],
 “context_set”: “rentalday”
 }

如果另一个意图想要与上下文进行关联,那么可以这样做:

{“tag”:today”,
 “patterns”: [“today”],
 “responses”: [“For rentals today please call 1800-MYMOPED”, …],
“context_filter”: “rentalday”
 }

以这种方法构建的信息库,如果用户只是输入 "today" 而没有上下文信息,那么这个 “today” 的用户意图是不会被处理的。如果用户输入的 "today" 是对我们的一个时间回应,即触动了意图标签 "rental" ,那么这个意图将会被处理。

response('we want to rent a moped')
Are you looking to rent today or later this week?

response('today')
Same-day rentals please call 1-800-MYMOPED

我们上下文信息也改变了:

context
{'123': 'rentalday'}

我们定义我们的 "greeting" 意图用来清除上下文语境信息,这就像我们打招呼一样,标志着我们要开启一个新的对话。我们还添加了 "show_details" 参数,用来帮助我们看到程序里面的信息。

response("Hi there!", show_details=True)
context: ''
tag: greeting
Good to see you again

让我们再次尝试输入 "今天" 这个词,一些有趣的事情就发生了。

response('today')
We're open every day from 9am-9pm

classify('today')
[('today', 0.5322513580322266), ('opentoday', 0.2611265480518341)]

首先,我们对没有上下文信息的 "today" 的回应是不同的。我们的分类产生了 2 个合适的意图,但 "opentoday" 被选中了。所以这个随机性就比较大,上下文信息很重要!

response("thanks, your great")
Happy to help!

现在需要考虑的事情就是如何将对话放置到具体语境中了。

状态处理

没错,你的机器人将会成为你的私人机器人了,不再是那么大众化。除非你想要重建状态,重新加载你的模型和文档 —— 每次调用你的机器人框架,你都会需要加载一个模型状态。

这不是那么困难,你可以在自己的进程中运行一个有状态的聊天机器人框架,并使用 RPC(远程过程调用)或 RMI(远程方法调用)调用它,我推荐使用 Pyro

用户界面(客户端)通常是无状态的,例如:HTTP 或 SMS。

你的聊天机器人客户端将通过 Pyro 函数进行调用,你的状态服务将由它处理,是不是很赞。

这里有一个手把手教你如何构建一个 Twilio SMS 机器人客户端的方法,这里是一个构建 Facebook 机器人的方法。


不要将状态存储在局部变量中

所有状态信息都必须放在诸如字典之类的数据结构中,易于持久化,重新加载或者以原子状态进行复制。

每个用户的对话和上下文语境都会保存在用户 ID 下面,这个ID必须是唯一的。

我们会复制有些用户的对话信息来进行场景分析,如果这些信息被保存在临时变量中,那么就非常难来处理,这是一个最大的考虑。


所以,现在你已经学会了如何去构建一个聊天机器人框架,一个使它能记住上下文信息的机器人,已经如何分析文本。未来的聊天机器人也都是能分析上下文语境的,这是一个大趋势。

我们联想到意图的构建会影响上下文的对话反应,所以我们可以创建各种各样的会话环境。

快去动手试试吧!



来源:Medium

posted @ 2017-07-02 23:35  FontTian  阅读(298)  评论(0编辑  收藏  举报