了解聊天机器人(chatbots)的工作原理很重要。 聊天机器人的一个基本机制是利用文本分类器进行意图识别 。 我们来看一下人工神经网络(ANN)的内部工作原理。

優化nlp 意圖模型創新方法_神经网络

在这个教程中,我们将使用2层神经元(1个隐层)和词袋(bag of words)方法来组织我们的训练数据。 文本分类的方法有三种 : 模式匹配 , 传统算法和神经网络 。 虽然使用多项朴素贝叶斯(Multinomial Naive Bayes)的算法出乎意料地有效,但它有三个基本缺陷:

  • MNB算法的输出是一个分值(score)而不是概率(probability)。 我们更希望得到一个概率,以便忽略低于某个阈值的预测。 这类似于VHF无线电中的“噪音抑制”机制。
  • MNB算法只能从分类的正例样本中学习模式,然而从分类的负例样本中进行学习也是非常重要的。
  • 不平衡的训练数据会导致NMB分类器的分值扭曲,迫使算法按照不同分类的数据集的大小来调整分值 。 这不是理想的方案。

与朴素(naive)相对应,文本分类器并不是试图理解一个句子的意思 ,而只是进行分类。理解这一点很重要, 实际上所谓的智能聊天机器人并不能真的理解人类的语言,不过这是另一回事 了。

如果你是人工神经网络的新手, 那么点这里了解它们是如何工作的

要理解用于分类的传统算法,请参见此处

现在,让我们按以下步骤实现一个用于意图识别的文本分类神经网络:

  1. 选择技术栈
  2. 准备训练数据
  3. 预处理数据
  4. 迭代 :代码实现 + 测试 + 模型调整
  5. 抽象思考

代码在这里,我们使用iPython Notebook,对于数据科学项目而言,它是一种超高效的工作方式。 代码开发语言是Python

我们使用NTLK进行自然语言处理。 首先需要一种方法将句子可靠地切分为单词(tokenize)并进行词干提取(stem):

# use natural language toolkit
import nltk
from nltk.stem.lancaster import LancasterStemmer
import os
import json
import datetime
stemmer = LancasterStemmer()

我们的训练数据中,12个句子分别属于3类意图(intent):greetinggoodbyesandwich

# 3 classes of training data
training_data = []
training_data.append({"class":"greeting", "sentence":"how are you?"})
training_data.append({"class":"greeting", "sentence":"how is your day?"})
training_data.append({"class":"greeting", "sentence":"good day"})
training_data.append({"class":"greeting", "sentence":"how is it going today?"})

training_data.append({"class":"goodbye", "sentence":"have a nice day"})
training_data.append({"class":"goodbye", "sentence":"see you later"})
training_data.append({"class":"goodbye", "sentence":"have a nice day"})
training_data.append({"class":"goodbye", "sentence":"talk to you soon"})

training_data.append({"class":"sandwich", "sentence":"make me a sandwich"})
training_data.append({"class":"sandwich", "sentence":"can you make a sandwich?"})
training_data.append({"class":"sandwich", "sentence":"having a sandwich today?"})
training_data.append({"class":"sandwich", "sentence":"what's for lunch?"})
print ("%s sentences in training data" % len(training_data))

现在我们进行数据预处理:

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

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

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

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

运行上面的代码,输出结果如下:

12 documents
3 classes ['greeting', 'goodbye', 'sandwich']
26 unique stemmed words ['sandwich', 'hav', 'a', 'how', 'for', 'ar', 'good', 'mak', 'me', 'it', 'day', 'soon', 'nic', 'lat', 'going', 'you', 'today', 'can', 'lunch', 'is', "'s", 'see', 'to', 'talk', 'yo', 'what']

请注意,每个单词都转换为小写并进行了词干提取。 词干提取可以帮助机器理解havehaving是一样的。 另外,我们也不关心单词的大小写。

我们将训练数据中的每个句子转化为词袋(bag of words)表示:

優化nlp 意圖模型創新方法_算法_02

下面是转换代码:

# 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)

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

# sample training/output
i = 0
w = documents[i][0]
print ([stemmer.stem(word.lower()) for word in w])
print (training[i])
print (output[i])

代码运行的输出结果如下:

['how', 'ar', 'you', '?']
[0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
[1, 0, 0]

上述步骤是文本处理中的一个经典环节:每个训练语句都被变换为一个只有0和1的数组,成员的序号对应于单词在语料库中的为止。

例如,对于句子:

['how', 'are', 'you', '?']

经过词干提取后,变换为:

['how', 'ar', 'you', '?']

然后转换为模型的输入: 因为how在我们的词典中排在第4个,所以在模型输入中,我们将输入的第4个成员置为1,此外,我们决定抛弃?

[0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]

这个输入对应于三个意图中的第1类,因此其输出表示为:

[1,0,0]

请注意,一个句子可以属于多个类(意图), 或不属于任何类 。

你可以尝试练习、运行上面的代码,直到找到点感觉为止。

机器学习的第一步是要有干净的数据。

接下来,我们实现这个2层神经网络的核心功能:

優化nlp 意圖模型創新方法_聊天_03

如果你是人工神经网络方面的新手, 可以点击这里查看它的工作原理

我们使用numpy,因为它可以快速进行矩阵乘法计算。

我们使用一个sigmoid函数作为神经元的激活函数。 然后,不断迭代并进行参数调整,直到错误率低到可接受。

優化nlp 意圖模型創新方法_聊天_04

下面的代码,实现了词袋处理并将输入句子转换为0、1数组。 这和我们对训练数据的转换完全匹配,这一点对于获得正确的结果是至关重要的。

import numpy as np
import time

# compute sigmoid nonlinearity
def sigmoid(x):
    output = 1/(1+np.exp(-x))
    return output

# convert output of sigmoid function to its derivative
def sigmoid_output_to_derivative(output):
    return output*(1-output)

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))

def think(sentence, show_details=False):
    x = bow(sentence.lower(), words, show_details)
    if show_details:
        print ("sentence:", sentence, "\n bow:", x)
    # input layer is our bag of words
    l0 = x
    # matrix multiplication of input and hidden layer
    l1 = sigmoid(np.dot(l0, synapse_0))
    # output layer
    l2 = sigmoid(np.dot(l1, synapse_1))
return l2

现在我们来实现神经网络的训练函数来调整突触的权重。 不要太紧张,主要用到的知识就是中学数学中的矩阵乘法:

def train(X, y, hidden_neurons=10, alpha=1, epochs=50000, dropout=False, dropout_percent=0.5):

    print ("Training with %s neurons, alpha:%s, dropout:%s %s" % (hidden_neurons, str(alpha), dropout, dropout_percent if dropout else '') )
    print ("Input matrix: %sx%s    Output matrix: %sx%s" % (len(X),len(X[0]),1, len(classes)) )
    np.random.seed(1)

    last_mean_error = 1
    # randomly initialize our weights with mean 0
    synapse_0 = 2*np.random.random((len(X[0]), hidden_neurons)) - 1
    synapse_1 = 2*np.random.random((hidden_neurons, len(classes))) - 1

    prev_synapse_0_weight_update = np.zeros_like(synapse_0)
    prev_synapse_1_weight_update = np.zeros_like(synapse_1)

    synapse_0_direction_count = np.zeros_like(synapse_0)
    synapse_1_direction_count = np.zeros_like(synapse_1)

    for j in iter(range(epochs+1)):

        # Feed forward through layers 0, 1, and 2
        layer_0 = X
        layer_1 = sigmoid(np.dot(layer_0, synapse_0))

        if(dropout):
            layer_1 *= np.random.binomial([np.ones((len(X),hidden_neurons))],1-dropout_percent)[0] * (1.0/(1-dropout_percent))

        layer_2 = sigmoid(np.dot(layer_1, synapse_1))

        # how much did we miss the target value?
        layer_2_error = y - layer_2

        if (j% 10000) == 0 and j > 5000:
            # if this 10k iteration's error is greater than the last iteration, break out
            if np.mean(np.abs(layer_2_error)) < last_mean_error:
                print ("delta after "+str(j)+" iterations:" + str(np.mean(np.abs(layer_2_error))) )
                last_mean_error = np.mean(np.abs(layer_2_error))
            else:
                print ("break:", np.mean(np.abs(layer_2_error)), ">", last_mean_error )
                break

        # in what direction is the target value?
        # were we really sure? if so, don't change too much.
        layer_2_delta = layer_2_error * sigmoid_output_to_derivative(layer_2)

        # how much did each l1 value contribute to the l2 error (according to the weights)?
        layer_1_error = layer_2_delta.dot(synapse_1.T)

        # in what direction is the target l1?
        # were we really sure? if so, don't change too much.
        layer_1_delta = layer_1_error * sigmoid_output_to_derivative(layer_1)

        synapse_1_weight_update = (layer_1.T.dot(layer_2_delta))
        synapse_0_weight_update = (layer_0.T.dot(layer_1_delta))

        if(j > 0):
            synapse_0_direction_count += np.abs(((synapse_0_weight_update > 0)+0) - ((prev_synapse_0_weight_update > 0) + 0))
            synapse_1_direction_count += np.abs(((synapse_1_weight_update > 0)+0) - ((prev_synapse_1_weight_update > 0) + 0))        

        synapse_1 += alpha * synapse_1_weight_update
        synapse_0 += alpha * synapse_0_weight_update

        prev_synapse_0_weight_update = synapse_0_weight_update
        prev_synapse_1_weight_update = synapse_1_weight_update

    now = datetime.datetime.now()

    # persist synapses
    synapse = {'synapse0': synapse_0.tolist(), 'synapse1': synapse_1.tolist(),
               'datetime': now.strftime("%Y-%m-%d %H:%M"),
               'words': words,
               'classes': classes
              }
    synapse_file = "synapses.json"

    with open(synapse_file, 'w') as outfile:
        json.dump(synapse, outfile, indent=4, sort_keys=True)
print ("saved synapses to:", synapse_file)

现在准备建立神经网络模型 ,我们将把网络中的突触权重保存到一个json文件,也就是我们的模型文件。

你可以尝试不同的梯度下降参数(alpha),来看看它是如何影响错误率的变化。 这个参数帮助我们的模型达到最低的错误率:

synapse_0 += alpha * synapse_0_weight_update

優化nlp 意圖模型創新方法_優化nlp 意圖模型創新方法_05

我们在隐层中只使用了20个神经元,因此比较容易进行调节。 这些神经元的连接突触权重将根据训练数据的大小和数值而变化,一个合理的错误率目标是小于10 ^ -3。

X = np.array(training)
y = np.array(output)

start_time = time.time()

train(X, y, hidden_neurons=20, alpha=0.1, epochs=100000, dropout=False, dropout_percent=0.2)

elapsed_time = time.time() - start_time
print ("processing time:", elapsed_time, "seconds")

上面代码的运行结果是:

Training with 20 neurons, alpha:0.1, dropout:False 
Input matrix: 12x26    Output matrix: 1x3
delta after 10000 iterations:0.0062613597435
delta after 20000 iterations:0.00428296074919
delta after 30000 iterations:0.00343930779307
delta after 40000 iterations:0.00294648034566
delta after 50000 iterations:0.00261467859609
delta after 60000 iterations:0.00237219554105
delta after 70000 iterations:0.00218521899378
delta after 80000 iterations:0.00203547284581
delta after 90000 iterations:0.00191211022401
delta after 100000 iterations:0.00180823798397
saved synapses to: synapses.json
processing time: 6.501226902008057 seconds

现在,synapse.json文件包含了网络中所有的突触权重, 这就是我们的模型。

一旦计算出突触权重,下面的classify()函数就是进行分类的核心:〜15行代码。

注意:如果训练数据发生了变化,我们需要重新计算整个模型。 对于一个非常大的数据集,这可能需要花费不少的时间。

现在我们可以预测一个句子属于某个分类的概率。 预测速度很快,因为它就是think()函数中的点积计算:

# probability threshold
ERROR_THRESHOLD = 0.2
# load our calculated synapse values
synapse_file = 'synapses.json' 
with open(synapse_file) as data_file: 
    synapse = json.load(data_file) 
    synapse_0 = np.asarray(synapse['synapse0']) 
    synapse_1 = np.asarray(synapse['synapse1'])

def classify(sentence, show_details=False):
    results = think(sentence, show_details)

    results = [[i,r] for i,r in enumerate(results) if r>ERROR_THRESHOLD ] 
    results.sort(key=lambda x: x[1], reverse=True) 
    return_results =[[classes[r[0]],r[1]] for r in results]
    print ("%s \n classification: %s" % (sentence, return_results))
    return return_results

classify("sudo make me a sandwich")
classify("how are you today?")
classify("talk to you tomorrow")
classify("who are you?")
classify("make me some lunch")
classify("how was your lunch today?")
print()
classify("good day", show_details=True)

运行结果如下:

sudo make me a sandwich 
 [['sandwich', 0.99917711814437993]]
how are you today? 
 [['greeting', 0.99864563257858363]]
talk to you tomorrow 
 [['goodbye', 0.95647479275905511]]
who are you? 
 [['greeting', 0.8964283843977312]]
make me some lunch 
 [['sandwich', 0.95371924052636048]]
how was your lunch today? 
 [['greeting', 0.99120883810944971], ['sandwich', 0.31626066870883057]]

你可以尝试一些其他的句子,也可以添加训练数据来改进和扩展模型。

有些句子会产生多个预测(都超过指定的阈值)。 你需要根据具体的应用来设置正确的阈值。 并非所有的文本分类场景都是相同的: 有些场景需要比其他方案更高的置信度。

上面代码中最后的那个分类调用使用show_details参数展示了一些内部的实现细节:

found in bag: good
found in bag: day
sentence: good day 
 bow: [0 0 0 0 0 0 1 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0]
good day 
 [['greeting', 0.99664077655648697]]

注意这个句子的词袋表示,其中有两个单词可以我们的词库相匹配。 神经网络也从0中得到了学习,即那些与词库不匹配的单词。

很容易通过一个示例句子来展示低概率的分类预测,其中’a’(常用词)是唯一的匹配,例如:

found in bag: a
sentence: a burrito! 
 bow: [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]
a burrito! 
 [['sandwich', 0.61776860634647834]]

现在你有了一个构建聊天机器人的基本工具,它能够处理大量的类 (意图),并适用于对有限或大量的训练数据进行分类。 也很容易在模型中添加一个或多个新的意图。

感谢 Alexander Pinto 。