1.4 计算机“眼”中的语言
当键入“Good Morn’n Rosa”时,计算机只会看到“01000111 0110 1111 01101111…”的二进制串。如何编写聊天机器人程序来智能地响应这个二进制流呢?一棵嵌套的条件树(if…else…
语句)是否可以检查这个流中的每一位并分别进行处理呢?这样做相当于编写了一类特定的称为有限状态机(finite state machine,FSM)的程序。运行时输出新的符号序列的FSM(如Python中的str.translate
函数)被称为有限状态转换机(finite state transducer,FST)。大家甚至可能在自己毫不知情的情况下已经建立了一个FSM。大家写过正则表达式吗?它就是我们在下一节中将要使用的一类FSM,同时它也给出了一种可能的NLP方法,即基于模式的方法。
如果你决定在内存库(数据库)中精确搜索完全相同的位、字符或词串,并使用其他人过去针对该语句的某条回复,你会怎么做呢?此时设想一下如果语句中存在拼写错误或变体该怎么办?这种情况下我们的机器人就会出问题。并且,位流不是连续的,也不具备容错性,它们要么匹配,要么不匹配,没有一种显而易见的方法能基于两个位流的含义来计算它们之间的相似性。从位来看,“good”与“bad!”的相似度和“good”与“okay”的相似度差不多。
但是,在给出更好的方法之前,我们先看看这种方法的工作原理。下面我们将构造一个小的正则表达式来识别像“Good morning Rosa”这样的问候语,并做出合适的回复,我们将构建第一个微型聊天机器人!
1.4.1 锁的语言(正则表达式)
令人惊讶的是,不起眼的密码锁[13]实际上是一台简单的语言处理机。因此,如果你对机械感兴趣的话,那么本节可能会对你很有启发性。当然,如果你不需要机械的类比来帮助理解算法和正则表达式的工作原理的话,那么可以跳过这一节。
读完这部分后,你会对自己的自行车密码锁有新的看法。密码锁当然不能阅读和理解存放在学校储物柜里的课本,但它可以理解锁的语言。当试图“告诉”它一个“密码”组合时,它可以理解。挂锁密码是与锁语言的“语法”(模式)匹配的任何符号序列。更重要的是,挂锁可以判断锁“语句”是否匹配一条特别有意义的语句,该语句只有一条正确的“回复”:松开U形搭扣的扣环,这样就可以进入锁柜了。
这种锁语言(正则表达式)特别简单,但它又不那么简单,我们在聊天机器人中还不能使用它。我们倒是可以用它来识别关键短语或指令来解锁特定的动作或行为。
例如,我们希望聊天机器人能够识别诸如“Hello Rosa”之类的问候语,并做出合适的回复。这种语言就像锁语言一样,是一种形式语言,这是因为它对如何编写和解释一条可接受的语句有着严格的规定。如果大家写过数学公式或者编写过某种编程语言的表达式,那么就算已经写过某个形式语言的语句了。
形式语言是自然语言的子集。很多自然语言中的语句都可以用形式语言的语法(如正则表达式)来匹配或者生成。这就是这里转而介绍机械的“咔嗒——呼啦”(“click, whirr”)[14]锁语言的原因。
1.4.2 正则表达式
正则表达式使用了一类特殊的称为正则语法(regular grammar)[15]的形式语言语法。正则语法的行为可预测也可证明,而且足够灵活,可以支持市面上一些最复杂的对话引擎和聊天机器人。Amazon Alexa和Google Now都是依赖正则语法的主要基于模式的对话引擎。深奥、复杂的正则语法规则通常可以用一行称为正则表达式的代码来表示。Python中有一些成功的聊天机器人框架,如Will
,它们完全依赖这种语言来产生一些有用的和有趣的行为。Amazon Echo、Google Home和类似的复杂而又有用的助手也都使用了这种语言,为大部分用户交互提供编码逻辑。
注意
在Python和Posix(Unix)应用程序(如grep
)中实现的正则表达式并不是真正的正则语法。它们具有一些语言和逻辑特性,如前向环视(look-ahead)和后向环视(look-back),这些特性可以实现逻辑和递归的跳跃,但是这在正则语法中是不允许的。因此,上述正则表达式无法保证一定可以停机(即在有限的时间内结束):它们有时会“崩溃”,有时却会永远运行下去[16]。
大家可能会嘀咕:“我听说过正则表达式,我使用grep
,但那只是用来搜索而已!”你确实是对的,正则表达式确实主要用于搜索和序列匹配。但是任何可以在文本中查找匹配的方法都非常适合用于对话。一些聊天机器人,如Will
,对于知道如何回复的语句,会使用搜索方式在用户语句中查找字符序列。然后,这些识别出的序列会触发一段事先准备好的回复,该回复满足这个特定正则表达式的匹配。同样的正则表达式也可以用来从语句中提取有用的信息。聊天机器人可以把这些信息添加到知识库中,而该知识库收集了有关用户或者用户所描述世界的知识。
处理这种语言的机器可以被看作是一个形式化的数学对象,称为有限状态机(FSM)或确定性有限自动机(deterministic finite antomation,DFA)。FSM会在本书中反复出现,因此,不用深入研究FSM背后的理论和数学,我们最终都会对它的用途很有感觉。对于那些忍不住想进一步了解这些计算机科学工具的读者来说,图1-1显示了FSM在“嵌套”的自动机(bots)世界中所处的位置。下面的“形式语言的形式数学解释”部分给出了一些关于形式语言的更正式的细节。
图1-1 自动机的类型
形式语言的形式数学解释
凯尔·戈尔曼(Kyle Gorman)对编程语言是像下面这样描述的。
- 大多数(如果不是所有的)编程语言都来自上下文无关语言这一类。
- 上下文无关语言使用上下文无关语法进行高效的解析。
- 正则语言也可以有效地进行解析,并广泛用于字符串匹配的计算中。
- 字符串匹配应用程序基本不需要上下文无关的表达能力。
- 有许多类型的形式语言,下面给出了其中的一些(按复杂性从高到低排列)a:
- 递归可枚举的;
- 上下文有关的;
- 上下文无关的;
- 正则。
而对自然语言是像下面这样描述的。
- 不是正则的。b
- 不是上下文无关的。c
- 用任何形式语法都无法定义。d
a 参考标题为“Chomsky hierarchy - Wikipedia”的网页。
b 参考Shuly Wintner的文章“English is not a regular language”。
c 参考Shuly Wintner的文章“Is English context-free?”。
d 参考标题为“1.11. Formal and Natural Languages — How to Think like a Computer Scientist: Interactive Edition”的网页。
1.4.3 一个简单的聊天机器人
下面我们快速粗略地构建一个聊天机器人。这个机器人能力不是很强,但是仍然需要大量对英语这门语言的思考。我们还必须手工编写正则表达式,以匹配人们可能的说话方式。但是,大家如果觉得自己无法编写出这段Python代码的话,也不要担心。大家不需要像本示例一样考虑人们说话的所有不同方式,甚至不需要编写正则表达式来构建一个出色的聊天机器人。我们将在后面的章节中介绍如何在不硬编码任何内容的情况下构建自己的聊天机器人。现代聊天机器人可以通过阅读(处理)一堆英语文本来学习,后面的章节中会给出具体的做法。
这种基于模式匹配的聊天机器人是严格受控的聊天机器人的一个例子。在基于现代机器学习的聊天机器人技术发展之前,基于模式匹配的聊天机器人十分普遍。我们在这里介绍的模式匹配方法的一个变体被用于像亚马逊的Alexa一样的聊天机器人和其他虚拟助手中。
现在我们来构建一个FSM,也就是一个可以“说”锁语言(正则语言)的正则表达式。我们可以通过编程来理解诸如“01-02-03”这样的锁语言语句。更好的一点是,我们希望它能理解诸如“open sesame”(芝麻开门)或“hello Rosa”(Rosa你好)之类的问候语。亲社会聊天机器人的一个重要特点是能够回复别人的问候。在高中,老师经常因为学生在冲进教室上课时忽略这样的问候语而责备其不太礼貌。我们当然不希望我们这个亲切的聊天机器人也这样。
在机器通信协议中,我们定义了一个简单的握手协议,每条消息在两台机器之间来回传递之后,都有一个ACK
(确认)信号。但是,我们这里的机器将会和那些说“Good morning, Rosa”(Rosa早上好)之类的用户进行互动。我们不希望它像对话或Web浏览会话开始时同步调制解调器或HTTP连接而发出一串唧唧声、哔哔声或ACK
消息,相反,我们在对话握手开始时使用正则表达式来识别几种不同的问候语:
>>> import re ⇽--- Python中有两个“官方”的正则表达式包,这里使用的是re包,因为它安装在所有版本的Python中。而regex包只在较新版本的Python中安装,我们将会在第2章看到,与re相比,regex的功能要强大很多
>>> r = "(hi|hello|hey)[ ]*([a-z]*)" ⇽--- '|'表示“OR”,'\*'表示前面的字符在出现0次或多次的情况下都可以匹配。因此,这里的正则表达式将匹配以“hi”“hello”或“hey”开头、后面跟着任意数量的空格字符再加上任意数量字母的问候语
>>> re.match(r, 'Hello Rosa', flags=re.IGNORECASE) ⇽--- 为使正则表达式更简单,通常忽略文本字符的大小写
<_sre.SRE_Match object; span=(0, 10), match='Hello Rosa'>
>>> re.match(r, "hi ho, hi ho, it's off to work ...", flags=re.IGNORECASE)
<_sre.SRE_Match object; span=(0, 5), match='hi ho'>
>>> re.match(r, "hey, what's up", flags=re.IGNORECASE)
<_sre.SRE_Match object; span=(0, 3), match='hey>
在正则表达式中,我们可以使用方括号指定某个字符类,还可以使用短横线(-)来表示字符的范围而不需要逐个输入。因此,正则表达式"[a-z]"
将匹配任何单个小写字母,即“a”到“z”。字符类后面的星号('*'
)表示可以匹配任意数量的属于该字符类的连续字符。
下面我们把正则表达式写得更细致一些,以匹配更多的问候语:
>>> r = r"[^a-z]*([y]o|[h']?ello|ok|hey|(good[ ])?(morn[gin']{0,3}|"\
... r"afternoon|even[gin']{0,3}))[\s,;:]{1,3}([a-z]{1,20})"
>>> re_greeting = re.compile(r, flags=re.IGNORECASE) ⇽--- 可以编译正则表达式,这样就不必在每次使用它们时指定选项(或标志)
>>> re_greeting.match('Hello Rosa')
<_sre.SRE_Match object; span=(0, 10), match='Hello Rosa'>
>>> re_greeting.match('Hello Rosa').groups()
('Hello', None, None, 'Rosa')
>>> re_greeting.match("Good morning Rosa")
<_sre.SRE_Match object; span=(0, 17), match="Good morning Rosa">
>>> re_greeting.match("Good Manning Rosa") ⇽--- 注意,这个正则表达式无法识别(匹配)录入错误
>>> re_greeting.match('Good evening Rosa Parks').groups() ⇽--- 这里的聊天机器人可以将问候语的不同部分分成不同的组,但是它不会知道Rosa是一个著名的姓,因为这里没有一个模式来匹配名后面的任何字符
('Good evening', 'Good ', 'evening', 'Rosa')
>>> re_greeting.match("Good Morn'n Rosa")
<_sre.SRE_Match object; span=(0, 16), match="Good Morn'n Rosa">
>>> re_greeting.match("yo Rosa")
<_sre.SRE_Match object; span=(0, 7), match='yo Rosa'>
提示
引号前的“r”指定的是一个原始字符串,而不是正则表达式。使用Python原始字符串,可以将反斜杠直接传递给正则表达式编译器,无须在所有特殊的正则表达式字符前面加双反斜杠("\\"
),这些特殊字符包括空格("\\ "
)和花括号或称车把符("\\{ \\}"
)等。
上面的第一行代码(即正则表达式)中包含了很多逻辑,它完成了令人惊讶的一系列问候语,但它忽略了那个“Manning”的录入错误,这是NLP很难的原因之一。在机器学习和医学诊断中,这被称为假阴性(false negative)分类错误。不幸的是,它也会与人类不太可能说的话相匹配,即出现了假阳性(false positive)错误,这同样也是一件糟糕的事情。假阳性和假阴性错误的同时存在意味着我们的正则表达式既过于宽松又过于严格。这些错误可能会使机器人听起来有点儿迟钝和机械化,我们必须做更多的努力来改进匹配的短语,使机器人表现得更像人类。
并且,这项枯燥的工作也不太可能成功捕捉到人们使用的所有俚语和可能的拼写错误。幸运的是,手工编写正则表达式并不是训练聊天机器人的唯一方法。请继续关注后面的内容(本书的其余部分)。因此,我们只需在对聊天机器人的行为进行精确控制(如在向手机语音助手发出命令)时才使用正则表达式。
下面我们继续,通过添加一个输出生成器最终得到一个只用一种技巧(正则表达式)的聊天机器人。添加输出生成器的原因是它总需要说些什么。下面我们使用Python的字符串格式化工具构建聊天机器人回复的模板:
>>> my_names = set(['rosa', 'rose', 'chatty', 'chatbot', 'bot',
... 'chatterbot'])
>>> curt_names = set(['hal', 'you', 'u'])
>>> greeter_name = '' ⇽--- 我们还不知道机器人的聊天对象是谁,这里我们也不担心这一点
>>> match = re_greeting.match(input())
...
>>> if match:
... at_name = match.groups()[-1]
... if at_name in curt_names:
... print("Good one.")
... elif at_name.lower() in my_names:
... print("Hi {}, How are you?".format(greeter_name))
所以,如果你运行这一小段脚本,用“Hello Rosa”这样的短语和机器人聊天,她会回答“How are you”。如果用一个略显粗鲁的名字来称呼聊天机器人的话,她回复就会不太积极,但也不会过于激动,而是试图鼓励用户用更礼貌的语言来交谈。如果你指名道姓地说出可能正在监听某条共线电话或某个论坛上的对话的人名,该机器人就会保持安静,并允许你和任何要找的人聊天。显然这里并没有其他人在监视我们的input()
行,但是如果这是一个更大聊天机器人中的函数的话,那么就需要处理这类事情。
受计算资源所限,早期的NLP研究人员不得不使用人类大脑的计算能力来设计和手动调整复杂的逻辑规则来从自然语言字符串中提取信息。这称为基于模式(pattern)的NLP方法。这些模式就像正则表达式那样,可以不仅仅是字符序列模式。NLP还经常涉及词序列、词性或其他高级的模式。核心的NLP构建模块(如词干还原工具和分词器)以及复杂的端到端NLP对话引擎(聊天机器人)(如ELIZA)都是通过这种方式,即基于正则表达式和模式匹配来构建的。基于模式匹配NLP方法的艺术技巧在于,使用优雅的模式来获得想要的内容,而不需要太多的正则表达式代码行。
经典心智计算理论
这种经典的NLP模式匹配方法是建立在心智计算理论(computional theory of mind,CTM)的基础上的。CTM假设类人NLP可以通过一系列处理的有限逻辑规则集来完成。在世纪之交,神经科学和NLP的进步导致了心智“连接主义”理论的发展,该理论允许并行流水线同时处理自然语言,就像在人工神经网络中所做的那样。
在第2章中,我们将学习更多基于模式的方法,例如,用于词干还原的Porter工具和用于分词的Treebank分词器。但是在后面的章节中,我们会利用现代计算资源以及更大的数据集,来简化这种费力的手工编码和调优。
如果大家新接触正则表达式,想了解更多,那么可以查看附录B或Python正则表达式的在线文档。但现在还不需要去理解它们,我们将利用正则表达式构建NLP流水线,并提供相关示例。因此,大家不要担心它们看起来像是胡言乱语。人类的大脑非常善于从一组例子中进行归纳总结,我相信在本书的最后这一切都会变得清晰起来。事实证明,机器也可以通过这种方式学习。
1.4.4 另一种方法
有没有一种统计或机器学习方法可以替代上面基于模式的方法?如果有足够的数据,我们能否做一些不一样的事情?如果我们有一个巨大的数据库,该数据库由数千甚至上百万人类的对话数据构成,这些数据包括用户所说的语句和回复,那又会怎么样呢?构建聊天机器人的一种方法是,在数据库中搜索与用户对聊天机器人刚刚“说过”的话完全相同的字符串。难道我们就不能用其他人过去说过的话作为回复吗?
但是想象一下,如果语句中出现一个书写错误或变异,会给机器人带来多大的麻烦。位和字符序列都是离散的。它们要么匹配,要么不匹配。然而,我们希望机器人能够度量字符序列之间的意义差异。
当使用字符序列匹配来度量自然语言短语之间的距离时,我们经常会出错。具有相似含义的短语,如good和okay,通常会有不同的字符序列,当我们通过清点逐个字符的匹配总数来计算距离时,它们会得到较大的距离。而对于具有完全不同含义的序列,如bad和bar,当我们使用数值序列间的距离计算方法来度量它们的距离时,可能会得到过于接近的结果。像杰卡德距离(Jaccard distance)、莱文斯坦距离(Levenshtein distance)和欧几里得距离(Euclidean distance)这样的计算方法有时可以为结果添加足够的“模糊性”,以防止聊天机器人犯微小的拼写错误。但是,当两个字符串不相似时,这些度量方法无法捕捉它们之间关系的本质。它们有时也会把拼写上存在小差异的词紧密联系在一起,而这些小差异可能并不是真正的拼写错误,如bad和bar。
为数值序列和向量设计的距离度量方法对一些NLP应用程序来说非常有用,如拼写校正器和专有名词识别程序。所以,当这些距离度量方法有意义时,我们使用这些方法。但是,针对那些我们对自然语言的含义比对拼写更感兴趣的NLP应用程序来说,有更好的方法。对于这些NLP应用程序,我们使用自然语言词和文本的向量表示以及这些向量的一些距离度量方法。下面,我们一方面讨论这些不同的向量表示以及它们的应用,另一方面我们将逐一向读者介绍每种方法。
我们不会在这个令人困惑的二进制逻辑世界里待太久,但是可以想象一下,我们是第二次世界大战时期著名的密码破译员玛维斯·贝特(Mavis Batey),我们在布莱切利公园(Bletchley Park)刚刚收到了从两名德国军官之间的通信中截获的二进制莫尔斯码(Morse code)消息。它可能是赢得战争的关键。那么我们从哪里开始呢?我们分析的第一步是对这些位流做一些统计,看看是否能找到规律。我们可以首先使用莫尔斯码表(或者在我们的例子中使用ASCII表)为每组位分配字母。然后,如果字符看上去胡言乱语也不奇怪,因为它们是提交给二战中的计算机或译码机的字符。我们可以开始计数、在字典中查找短序列,这部字典收集了所有我们以前见过的词,每次查到序列就在字典中的该条目旁边做一个标记。我们还可以在其他记录本中做一个标记来标明词出现在哪条消息中,并为以前读过的所有文档创建百科全书式的索引。这个文档集合称为语料库(corpus),索引中列出的词或序列的集合称为词库(lexicon)。
如果我们足够幸运,没有生活在战争年代,所看到的消息没有经过严格加密,那么我们将在上述德语词计数中看到与用于交流类似消息的英语词计数相同的模式。不像密码学家试图破译截获的德语莫尔斯码,我们知道这些符号的含义是一致的,不会随着每次键点击而改变以迷惑我们。这种枯燥的字符和词计数正是计算机无须思考就能做的事情。令人惊讶的是,这几乎足以让机器看起来能理解我们的语言。它甚至可以对这些统计向量进行数学运算,这些向量与我们人类对这些短语和词的理解相吻合。当我们在后面的章节中介绍如何使用Word2vec来教机器学习我们的语言时,它可能看起来很神奇,但事实并非如此,这只是数学和计算而已。
但是我们想一下,刚才在努力统计收到消息中的词信息时,我们到底丢失了哪些信息?我们将词装箱并将它们存储为位向量,就像硬币或词条分拣机一样,后者将不同种类的词条定向到一边或另一边,形成一个级联决策,将它们堆积在底部的箱子中。我们的分拣机必须考虑数十万种(即使不是数百万种的话)可能的词条“面额”,每种面额对应说话人或作家可能使用的一个词。我们将每个短语、句子或文档输入词条分拣机,其底部都会出来一个向量,向量的每个槽中都有词条的计数值。其中的大多数计数值都为零,即使对于冗长的大型文档也是如此。但是目前为止我们还没有丢失任何词,那么我们到底丢失了什么?大家能否理解以这种方式呈现出的文档?也就是说,把语言中每个词的计数值呈现出来,而不把它们按照任何序列或顺序排列。我对此表示怀疑。当然,如果只是一个简短的句子或推文,那么可能在大多数情况下我们都能把它们重新排列成其原始或期望的顺序和意义。
下面给出的是在NLP流水线中如何在分词器(见第2章)之后加入词条分拣机的过程。这里的词条分拣机草图中包含了一个停用词过滤器和一个罕见词过滤器。字符串从顶部流入,词袋向量从底部词条栈中词条的高低堆叠中创建。
事实证明,机器可以很好地处理这种词袋,通过这种方式能够收集即便是中等长度的文档的大部分信息内容。在词条排序和计数之后,每篇文档都可以表示为一个向量,即该文档中每个词或词条的整数序列。图1-2中给出了一个粗略的示例,后面在第2章会给出词袋向量的一些更有用的数据结构。
图1-2 词条分拣托盘
这是我们给出的语言的第一个向量空间模型。这些栈和它们包含的每个词的数目被表示成一个长向量,该向量包含了许多0、一些1或2,这些数字散落在词所属栈出现的位置。这些词的所有组合方式构成的向量称为向量空间。该空间中向量之间的关系构成了我们的模型,这个模型试图预测这些词出现在各种不同的词序列(通常是句子或文档)集合中的组合。在Python中,我们可以将这些稀疏的(大部分元素都为空)向量(数值列表)表示为字典。Python中的Counter
是一种特殊的字典,它存储对象(包括字符串),并按我们想要的方式为对象计数:
>>> from collections import Counter
>>> Counter("Guten Morgen Rosa".split())
Counter({'Guten': 1, 'Rosa': 1, 'morgen': 1})
>>> Counter("Good morning, Rosa!".split())
Counter({'Good': 1, 'Rosa!': 1, 'morning,': 1})
大家可能会想到一些分拣这些词条的方法,我们会在第2章中实现这一点。大家也可能会想,这些稀疏高维向量(许多栈,每个可能的词对应一个栈)对语言处理不是很有用。但是对于一些引起行业变革的工具,如我们将在第3章中讨论的垃圾短信过滤器,它们已经足够好用了。
可以想象,我们把能找到的所有文档、语句、句子甚至单个词,一个一个地输到这台机器。我们会在每个语句处理完之后,对底部每个槽中的词条计数,我们称之为该语句的向量表示。机器以这种方式产生的所有可能的向量称为向量空间。这种表示文档、语句和词的模型称为向量空间模型。它允许我们使用线性代数来对这些向量进行运算,计算距离和自然语言语句的统计信息,这些信息有助于我们用更少的人工编码来解决更广泛的问题,同时也使得NLP流水线更加强大。
一个关于词袋向量序列的统计学问题是,在特定的词袋下最可能出现的词组合是什么?或者,更进一步,如果用户输入一个词序列,那么数据库中最接近用户提供的词袋向量的词袋是什么?这其实是一个搜索查询。输入词是用户可能在搜索框中键入的词,最接近的词袋向量对应于要查找的目标文档或网页。高效回答上述两个问题的能力足以构建一个机器学习聊天机器人,随着我们给它提供的数据越来越多,它也会变得越来越好。
但是等一下,也许这些向量不像大家以前用过的任何向量。它们的维度非常高。从一个大型语料库中得到的3-gram词汇表可能有数百万个维度。在第3章中,我们将讨论“维数灾难”和高维向量难以处理的其他一些性质。