了解聊天机器人(chatbots
)的工作原理很重要。 聊天机器人的一个基本机制是利用文本分类器进行意图识别 。 我们来看一下人工神经网络(ANN)的内部工作原理。
在这个教程中,我们将使用2层神经元(1个隐层)和词袋(bag of words
)方法来组织我们的训练数据。 文本分类的方法有三种 : 模式匹配 , 传统算法和神经网络 。 虽然使用多项朴素贝叶斯(Multinomial Naive Bayes
)的算法出乎意料地有效,但它有三个基本缺陷:
-
MNB
算法的输出是一个分值(score
)而不是概率(probability
)。 我们更希望得到一个概率,以便忽略低于某个阈值的预测。 这类似于VHF
无线电中的“噪音抑制”机制。 -
MNB
算法只能从分类的正例样本中学习模式,然而从分类的负例样本中进行学习也是非常重要的。 - 不平衡的训练数据会导致
NMB
分类器的分值扭曲,迫使算法按照不同分类的数据集的大小来调整分值 。 这不是理想的方案。
与朴素(naive
)相对应,文本分类器并不是试图理解一个句子的意思 ,而只是进行分类。理解这一点很重要, 实际上所谓的智能聊天机器人并不能真的理解人类的语言,不过这是另一回事 了。
如果你是人工神经网络的新手, 那么点这里了解它们是如何工作的 。
要理解用于分类的传统算法,请参见此处 。
现在,让我们按以下步骤实现一个用于意图识别的文本分类神经网络:
- 选择技术栈
- 准备训练数据
- 预处理数据
- 迭代 :代码实现 + 测试 + 模型调整
- 抽象思考
代码在这里,我们使用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
):greeting
、goodbye
和sandwich
:
# 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']
请注意,每个单词都转换为小写并进行了词干提取。 词干提取可以帮助机器理解have
和having
是一样的。 另外,我们也不关心单词的大小写。
我们将训练数据中的每个句子转化为词袋(bag of words
)表示:
下面是转换代码:
# 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层神经网络的核心功能:
如果你是人工神经网络方面的新手, 可以点击这里查看它的工作原理 。
我们使用numpy
,因为它可以快速进行矩阵乘法计算。
我们使用一个sigmoid
函数作为神经元的激活函数。 然后,不断迭代并进行参数调整,直到错误率低到可接受。
下面的代码,实现了词袋处理并将输入句子转换为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
我们在隐层中只使用了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 。