估值上亿的AI代码?叩开NLP世界的大门

(副标题:短文本理解的工程实践)

估值上亿的AI核心代码

某AI公司的核心代码被员工泄露

该公司发言人称

泄露的部分代码估值10个亿

据说该代码可快速搭建一个AI系统

可以实现如今炙手可热得人机交互

最近在煎蛋上看到这则段子,觉得很喜感,正好跟最近研究的东西撞了车。兴奋地输入电脑,效果简直炸裂,特地来给大家分享。咳咳……还是严肃点。

同神经网络、云计算这些热门的概念一样,自然语言处理(Natural Language Processing)这门技术随着Cortana、Siri的流行,让大家耳熟能详。其实 NLP 也是经过多年发展,它所覆盖的范畴,不止有聊天机器人,包括但不限于:

老生常谈 正在进步 人类的明天
垃圾邮件识别 信息提取 机器翻译
情感识别 歧义消除 多轮人机对话
中文分词 句法分析 知识推理
语音识别 文本生成 通用阅读理解

这次分享的内容,是NLP世界中最好理解的一个例子:聊天机器人(chatbot)。

1
2
while True:
print(input("").replace("吗","").replace("?","!").replace("?","!"))

开篇提到的“估值一个亿”的AI核心代码,虽然只是则笑话,但细看一下,它其实包含了 chatbot 的完整要素:

  • 自然语言理解:虽然简陋,但它的确能处理疑问句
  • 自然语言生成:虽然简陋,但它至少道出了人类的本质

人类的本质:复读机

限(楼)于(主)篇(太)幅(懒) 这次分享只包含前一半内容,也就是自然语言理解。

0x01 描述问题

按照惯例,讲 NLP 的文章,一般都会先讲分词、讲文本向量化、讲jieba分词word2vec。我们也不能免俗,但我们先来搞清楚我们要研究的是什么问题。

自然语言理解这个词挺让人一头雾水的,不如我们先把问题描述清楚。文本种类有很多,有诗歌、有散文,有长文、有短句,其实 chatbot 只面向其中一种,也就是短文本的识别。你要对付的,既不是英语新闻、也不是文言古文,而是你常常用来为难 Siri 的短句,于是脑子里瞬间浮现出这些指令:

你看,Siri 有这么多功能:看新闻、拍黄片、查地图、找餐馆、放音乐,这些可以看成是第三方“模组”。Siri 的核心,其实是如何识别你的意图intent),然后把任务交给各式各样的“模组”去处理。

graph LR
    语音 --> |语音识别|文本
    文本 --> |意图识别|指令
    指令 --> |寻找模组|查天气
    指令 --> |寻找模组|放音乐
    指令 --> |寻找模组|其他动作

明白了意图识别已经完成了任务的一半。以“深圳今天天气怎么样”为例,识别出了文本的“意图”,比方说识别结果是“查天气”,那现在就可以去调用相关API查询天气了吗?嗯,天气查询API要求至少给定两个参数:地点 和 时间。所以还要进行信息提取,或者说,是叫命名实体识别 (entities),目标是提取出问句中与意图相关的有效信息。

graph LR
    深圳今天天气怎么样 --> |意图识别|查天气
    查天气 --> |实体识别|坐标_深圳
    查天气 --> |实体识别|时间_今天

所以,为了实现一个 chatbot ,我们要对付的问题就是 短文本识别,解决这个问题的方法分别是 意图识别实体识别

0x02 一点历史

记得之前KM上有篇文章《NLP的巨人肩膀》,读完觉得很震撼,推荐大家先读一下人家的文章再回来。

即便在人类语言诞生之前,人类祖先也可以通过可能已经先于语言而诞生的学习与认知能力,做到以“代”为单位来进行传承与进化,只不过不同于基因进化,这是一种地球生命全新的进化方式,在效率上已经比其他生物的进化效率高的多得多。地球上自生命诞生以来一直被奉为圭臬的基因进化法则,往往都是以一个物种为单位,上一代花了生命代价学习到的生存技能需要不断的通过非常低效的“优胜劣汰,适者生存”的丛林法则,写进到该物种生物的基因中才算完事,而这往往需要几万年乃至几百万年才能完成。

人类之所以成为地球霸主,计算机技术为什么进步飞快,都是建立在语言的基础上的。经过5岁到12岁的学习,一名初中生就能学到5000年来人类知识的精华,并在此基础上进行更深刻的研究。没有语言,就没有复用,就没有《三体》里的技术爆炸。

但是我们知道,计算机天生是用来处理数字的,如果用来处理人类的语言,呐首先要做的,就是把文本表示成数字的形式。这一点,在1981年就完成了。那一年,我国制定出了GB2312标准,对上千个常用字进行的编码,使其能够用计算机存储和显示。有了GB2312,就能按byte、用数字表示汉字,计算机就能处理四库全书了。

啪

emmmm,这跟自然语言处理有半毛钱关系吗?嗯,因为GB2312是按拼音(前3755个常用汉字)和部首/笔画(后3008个非常用字)顺序对汉字进行编码的,虽然把汉字表示成了数字的形式,但它们只能用于计算机对字库进行索引,并没有任何物理意义,里面没有一丁点语义信息。

正经说吧,我们大致可以把语言看作一种抽象符号,这种抽象符号对应现实世界里的物理实体。比如上面的gif,“打”就代表图中的动作,“女孩”就代表小女孩。在这个世界上,无论中国、日本、还是美国,这些物理实体都是相同的。“树”和“tree”指代同一个东西,“吃”和“eat”指代同一个动作,因为大家生活里的物理实体都是相同的,在此基础上,各国语言才能互通翻译。也就是说,管它英语还是汉语,其实都是一回事儿,都跑不了这个世界上存在的物理实体。那么让我们进一步想,抛去这个世界上的物理实体,不要把自己当成人,把吃的喝的玩的东西通通忘掉,如果只让你看到百科全书里的文本,你能看到什么?

现在,你眼里的“电脑”、“苹果手机”、“任天堂”、“哈利波特”,统统都变成了真正的抽象符号,而你面前的百科全书,描述的尽是这些抽象符号之间的种种关系。对你来说,似乎通过一个函数变换(比如wolf eat rabbit、people use phone),就能知道这些抽象符号之间的关系了。你看到的不是狼吃兔子、人使用手机,而是A eat B、C use D,通过海量的文本,你能知道在A eat B这个关系中,wolf、lion和tiger的语义是相近的,但你并不知道它们在现实世界里是什么东西,你只知道在这海量的A eat B中,wolf一般出现在A(捕食者)而不是B(被捕食者)的位置上。

这个,就是自然语言处理的本质。计算机认不得真实生活中的种种物体,计算机只是在处理这些抽象符号之间的关系:

  • 以词为单位,用一组多维向量代表词汇
  • 结合上下文,分析这些词汇的关系

每个词的含义,都是其上下文赋予的,脱离上下文生造出一个词来,是毫无意义的。

分词

跟英文NLP不同的是,因为在中文里字词之间是没有空格分隔的,首先要进行分词(tokenizing)处理,将整句话拆分成独立的词组。

比如:

喷射战士是这个世界上最好玩的游戏

喷射战士 是 这个 世界上 最好玩的 游戏

其中,“世界上”和“最好玩的”都用来修饰“游戏”,后面这一串都是对“喷射战士”的描述。

有两个办法来做分词这件事,一个是基于规则的词典匹配法、一个是建立概率模型,假设每个分词出现的概率仅与前一个词有关。

image-20181224205402568

对搞工程的人来说,数学公式是最让人头疼的,大家可以读一下吴军博士的《数学之美》这本书,基本懂一些高等数学,就能明白为什么TF-IDF能用来提取关键词、余弦定理能用来计算两篇文章的相似性、为什么PageRank算法能够成为现代搜索引擎的基石、贝叶斯网络能为整个世界建模等等。明白这些之后,就能寻找工程上相应的轮子,用来自己搭建AI系统了,其中后者才是我们最擅长的工程上的事情,但许多人都是倒在了前者上面:没能搞懂我们要研究什么问题、以及理解这个问题的本质是什么。

在《数学之美》这本书的第二章,讲述了自然语言处理领域,从基于规则走向基于统计的过程。

如果 S 表示一连串特定顺序排列的词 w1, w2,…, wn ,换句话说,S 可以表示某一个由一连串特定顺序排练的词而组成的一个有意义的句子。现在,机器对语言的识别从某种角度来说,就是想知道S在文本中出现的可能性,也就是数学上所说的S 的概率用 P(S) 来表示。利用条件概率的公式,S 这个序列出现的概率等于每一个词出现的概率相乘,于是P(S) 可展开为:

P(S) = P(w1)P(w2|w1)P(w3| w1 w2)…P(wn|w1 w2…wn-1)

其中 P (w1) 表示第一个词w1 出现的概率;P (w2|w1) 是在已知第一个词的前提下,第二个词出现的概率;以次类推。不难看出,到了词wn,它的出现概率取决于它前面所有词。从计算上来看,各种可能性太多,无法实现。因此我们假定任意一个词wi的出现概率只同它前面的词 wi-1 有关(即马尔可夫假设),于是问题就变得很简单了。现在,S 出现的概率就变为:

P(S) = P(w1)P(w2|w1)P(w3|w2)…P(wi|wi-1)…
(当然,也可以假设一个词又前面N-1个词决定,模型稍微复杂些。)

接下来的问题就是如何估计 P (wi|wi-1)。现在有了大量机读文本后,这个问题变得很简单,只要数一数这对词(wi-1,wi) 在统计的文本中出现了多少次,以及 wi-1 本身在同样的文本中前后相邻出现了多少次,然后用两个数一除就可以了,P(wi|wi-1) = P(wi-1,wi)/ P (wi-1)。

基于这个简单的数学原理,可以做许多复杂的事情。稍微简单一点儿的,就像我之前KM文章里用来根据世界杯淘汰赛的结果计算球队实力排行榜;稍微复杂一点儿的例子的话,它能帮我们完成中文分词的任务。

大学生活好还是初中生活好?

脱离上下文的语境是无法理解这句话的,当上下文是讲校园生活的时候,分词结果应该是:

大学生活 好 还是 初中生活 好

当上下文满满的都是黄段字的时候,这句话显然应该切分成……算了,你们脑补吧。

啪

这就是基于概率模型、结合上下文分析语义的效果。而雇佣语文老师,基于规则和语法的做法,就好比编写正则表达式进行NLP,是永远无法枚举完这个世界上所有的语法的。

分词是个大坑,就不要自己造轮子了。在中文分词方向,做的最好的是“结巴分词”,衍生出Java、C++、Python、C#等各个语言的版本,十分好用。英文分词常用的则是ntlk。

向量化

完成分词之后,要进行向量化处理。向量化的目的,是将字符串表示成数学向量的形式,便于后续进行聚类或建模分析。

最原始的向量化方法,是词袋模型(bag of words)。词袋模型将单词的坐标(在语料中出现的位置)和单词出现的频率作为向量,一段语料用词袋模型表示之后,是一个较为稀疏的矩阵形式。由于稀疏矩阵维度太高,且大量数据为0,所以一般会再进行一次降维处理。常用的降维算法有PCA(主成分分析)、Hash Trick等。

1
2
3
4
5
6
7
8
9
10
11
12
13
["I come to China to travel", 
"This is a car polupar in China",
"I love tea and Apple ",
"The work is to write some papers in science"]

# 用词袋模型进行向量化
# 数据来自 https://www.cnblogs.com/pinard/p/6688348.html

[[0 0 0 1 1 0 0 0 0 0 0 0 0 0 0 2 1 0 0]
[0 0 1 1 0 1 1 0 0 1 0 0 0 0 1 0 0 0 0]
[1 1 0 0 0 0 0 1 0 0 0 0 1 0 0 0 0 0 0]
[0 0 0 0 0 1 1 0 1 0 1 1 0 1 0 1 0 1 1]]
[u'and', u'apple', u'car', u'china', u'come', u'in', u'is', u'love', u'papers', u'polupar', u'science', u'some', u'tea', u'the', u'this', u'to', u'travel', u'work', u'write']

可以看到,词袋模型中,只用到词的位置坐标、和词的频率信息。加上一句话只能用到少量词汇,导致内存中大量的“0”出现。这个矩阵竖着是句子的数量,横着是所有单词的数量,可以料想到,在大规模的文本处理中,这种办法需要浪费海量的内存,并且其中大部分数值还都是0。

对词袋模型的改进,最常见的是TF-IDF模型,将词频乘以逆文档频率,避免常用词由于出现频率较高,被误认为是关键词。贴一张来自阮一峰博客的图:

img

img

其中IDF项,如果某个词在所有文档中都出现了,就会无限趋近于log 1 = 0,TF乘以IDF就越小。在《怪物猎人世界的评测》这篇文章中,“游戏”出现的频率远比“怪物猎人”、“狩猎”要高,但有了TF-IDF模型,就不会误认为“游戏”是本文的主题词汇。

有了TF-IDF,就能用来进行简单的聚类、分类等文本分析工作了。之前在管家那边,用垃圾清理的配置规则,导出了一份垃圾文件的常见路径、和普通文件的路径,用贝叶斯分类器训练了一个垃圾文件识别的demo。用到的就是scikit-learn中的GaussianNB分类器

1
2
3
logistic模型的准确度:0.99
bayes模型的准确度:0.99
预测的结果列表:[{'path': 'c:/temp/cache/test.123', 'result': '垃圾目录'}]

对垃圾文件路径这个文本来说,分词是十分简单的,按反斜杠分隔就可以了。用TF-IDF来对分好的词组进行建模,也是非常合适的。当时选这个题目来做NLP的demo,大概也是偷懒吧 23333。

但是,TF-IDF模型仍然只使用了一个词出现在语料中的频率信息,并没有结合上下文的语义,所以仍然不是我们最终想要的。

在向量化中,做的最好的是“word2vec”,顾名思义,完成的任务就是将单词转化为向量形式。word2vec使用的数学模型,是CBoW(连续词袋模型)和Skip-gram模型。word2vec将词袋模型进行了改造,与只看词频、忽略了上下文关系不同的是,word2vec假定上下文相似的单词、其语义也相似。word2vec得到的向量虽然是数学形式,但却是可以理解的。

例如,向量 “中国” 减去 “北京”,与 “日本” 减去 “东京” 是平行的。这一点,开启了NLP界的 “迁移学习”,也就是将别人训练好的通用的模型,用于特定任务。事实上,最近新兴的ONNX格式,能将tensorflow、ml.net、scikit-learn各种框架训练出来的模型,导出成一样的格式,大家可以共享,这就是迁移学习让AI变得更简单的例子。

image-20181218160744744

结巴分词和word2vec,两个都是NLP中最为基础的两个轮子,已经发展出较为完备的实践技术。

信息提取

按照维基百科的官方解释,信息提取包括这些任务:

  • 命名实体识别
  • 共指消解
  • 术语抽取

其实是一系列技术的集合,包括提取抽象符号 -> 物理实体的映射关系、通过语义关系建立两个词之间的距离向量等。

NLU是一个巨大的轮子,需要巨大的财力和海量的资源,也是经过数十年才发展至今。《巨人肩膀》那篇文章里,讲述了从分词、到向量化、再到机器阅读理解的一个个突破。

在AI方面,鹅厂已经有技术储备(https://ai.qq.com/),有文本翻译、语言翻译、词性分析、同义词、智能闲聊等功能。而在短文本理解方向,很多公司都以RESTful API的方式对外提供服务:

  • Dialogflow (来自谷歌,前身是API.AI)
  • LUIS (来自微软)
  • OLAMI (来自威盛电子)

在腾讯内部,也有TEG AI Lab制作的bot_luna(可以直接RTX它,与它进行对话)。

image-20181218160803131

开源方面,主要有两款产品,提供完整的短文本NLU能力:

  • Rasa NLU:民间已经解决了中文版的问题
  • Snips NLU:刚刚开源,目前仍在发展中

这里先介绍一下MITIE:MITIE 是 MIT 发布的信息抽取工具,免费且开源,使用 C++ 编写,性能很高,而且能被各个语言调用。MITIE 可以完成向量化、二元关系检测、结构化SVM等任务。

NLU工具

Rasa NLU 是一个开源的、可本地部署并配套有语料标注工具的自然语言理解框架,官方支持英语和德语。但语言并不是问题,我们开篇提到了,不同国家的语言,都是对相同物理实体的不同的抽象表示,其实都是指代一样的东西。对中文来说,我们提供一套分词工具、训练一套word embedding模型,输入给Rasa进行训练,就能使用了。

MITIE 接收以词为单位的输入语料,所以我们的做法是,使用爬虫爬取特定领域的大量语料,然后用 结巴分词 进行分词处理。然后用MITIE进行非监督训练,得到训练好的语料模型。

0x03 做一个 GameBot!

GameBot

作为公司主机向App篝火营地的用户、VGTime游戏时光的长期读者、任天堂Splatoon2的深度粉丝,就让我们来动手做一个游戏向的chatbot。我们已经知道,在chatbot中,NLU负责意图分类实体识别,然后调用第三方接口完成指定功能(闲聊、科学运算、天气查询、路线查询等)。让我们的gamebot支持如下功能:

  • 搜索和推荐游戏,例如:最近任天堂出了哪些好玩的游戏搜索腾讯出的射击游戏
  • 查询游戏评分,例如:塞尔达传说的评分是多少怪物猎人好玩吗
  • 查询游戏是哪个公司出的,例如:怪物猎人是哪个公司出的
  • 查询游戏所支持的平台,例如:怪物猎人支持哪些平台荒野大镖客有PC版吗
  • 查询游戏的发售时间,例如:喷射战士是哪一年发布的

问题来了,游戏从哪里来?怎么让chatbot认识“塞尔达传说”、“怪物猎人”这些专有名词?没有专门的语料,训练出来肯定是个人工智障啊。

荒野大镖客

喷射战士

咧嘴一笑,看来要对心爱的网站下手了。

0x04 搞定爬虫

找了一些例程,发现现在编写爬虫不像以前裸写HTTP请求,有了scrapyPhantomJS这些工具已经方便多了。

游戏库

爬虫的代码已经上传到了公司的Git上面,可以直接移步:VGTimeSpider

scrapy已经将编写爬虫的工作大大简化,你只需要关心两部分:

  • 匹配目标信息和下层链接(编写XPath表达式)
  • 定义爬出来的数据格式(定义game_item)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# 数据格式
class GameItem(scrapy.Item):
name = scrapy.Field()
nickname = scrapy.Field()
score = scrapy.Field()
count = scrapy.Field()
platform = scrapy.Field()
date = scrapy.Field()
dna = scrapy.Field()
company = scrapy.Field()
tag = scrapy.Field()
url = scrapy.Field()

class TopicItem(scrapy.Item):
article = scrapy.Field()

# XPath 表达式
game = selector.xpath('//section[@class="game_main"]')
if game:
game_name = game.xpath(
'div[@class="game_box main"]/h2/a/text()').extract_first(default='')
game_nickname = game.xpath(
'div[@class="game_box main"]/p/text()').extract_first(default='')
game_score = game.xpath(
'//span[@class="game_score showlist"]//text()').extract_first(default='-1')
game_count = game.xpath(
'//span[@class="game_count showlist"]//text()').extract_first(default="-1")
game_descri = game.xpath(
'//div[@class="game_descri"]/div[@class="descri_box"]')
...

其他细节,诸如模仿浏览器的User Agent使用广度优先遍历链接去重等,都可以在settings.py中进行设置。还可以在pipelines.py中自定义,拿到game_item之后要怎么处理,是直接输出文本还是输入数据库都随你。

作为被调用方,你只需要按需补全自己需要定制的部分即可。一图流,scrapy爬虫会主动接管完整的爬取流程:

scrapy调用流程

图片库

爬虫写好之后还想把游戏图片顺便抓一下,虽然demo只是个控制台程序,万一以后有前端展示的冲动,就可以直接拿图来用了。图片跟爬虫无关,而且如果解析出图片的链接就同步开始下载的话,也会降低爬虫的效率。这里采用了celery异步任务框架,图方便直接用redis做它的后端。之所以需要redis,是由celery的结构决定的:

  • 任务模块 Task:包含异步任务和定时任务。其中,异步任务通常在业务逻辑中被触发并发往任务队列,而定时任务由 Celery Beat 进程周期性地将任务发往任务队列
  • 消息中间件 Broker:任务调度队列,接收任务生产者发来的消息(即任务),将任务存入队列。Celery 本身不提供队列服务,官方推荐使用 RabbitMQ 和 Redis 等。
  • 任务执行单元 Worker:执行任务的处理单元,它实时监控消息队列,获取队列中调度的任务,并执行它
  • 任务结果存储 Backend:用于存储任务的执行结果,以供查询。同消息中间件一样,存储也可使用 RabbitMQ 和 MongoDB 等。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# -*- coding=utf8 -*-
"""
异步任务类
"""
import logging
import requests
from celery import Celery

from VGTimeSpider.settings import BROKER_URL

# 这里把BROKEN_URL偷懒设置成了redis默认的'redis://127.0.0.1:6379'

app = Celery('image_downloader', broker=BROKER_URL)
LOGGER = logging.getLogger(__name__)


@app.task
def download_pic(image_url, image_path):
"""异步下载图片
Args:
image_url (string): 图片链接
image_path (string): 图片路径
"""
if not (image_url and image_path):
LOGGER.INFO('illegal parameter')
try:
image = requests.get(image_url, stream=True)
with open(image_path, 'wb') as img:
img.write(image.content)
except Exception as exc:
LOGGER.ERROR(exc)

这样,抓到图片之后往队列里丢个请求就可以了。至于celery更多并行、定时之类的技巧也没用到。

塞尔达天下第一!

塞尔达天下第一

看看玩家给塞尔达贴的游戏标签吧,当之无愧:

‘动作’, ‘解谜’, ‘沙箱’, ‘冒险’, ‘神作’, ‘掌机游戏巅峰之作’, ‘开放沙盒’, ‘游戏性趣味性’, ‘巅峰’, ‘沙盒天尊’, ‘细节惊人’, ‘宇宙神作’, ‘Game Play’, ‘隐藏要素巨多’, ‘秒天秒地秒空气’, ‘超神开创性’, ‘满分并不意味着这个游戏是完美无缺的,给予如此的评价无非是向制作人员对游戏本质的不懈追求表示我们最高敬意!’, ‘无可争议的神作’, ‘不自觉就沉迷其中’, ‘好玩之余还能练习英语’, ‘垃圾,塞尔达秒了’, ‘垃圾,塞尔达秒了它’, ‘无可挑剔的艺术品’, ‘多年以后再议2017还得是她’, ‘捡破烂’, ‘好玩’, ‘真的好好玩啊啊啊’, ‘这是一款为了玩到它值得买一款主机的游戏’, ‘为什么我买了ns,因为我对塞尔达爱得深沉’, ‘crazy’, ‘几乎完美’, ‘塞尔达天下第一’, ‘最爱’, ‘烟雨未绸缪’, ‘我不叫塞尔达!’, ‘沉浸感’, ‘勇者林克’, ‘任天堂开发的超强作品’, ‘塞尔达系列出色续作’, ‘太牛逼啦!’, ‘时代的艺术品’, ‘肥宅快乐说’, ‘秒了’, ‘世界的主宰’, ‘rug’, ‘没问题塞尔达!’, ‘因为一个米法 值了’, ‘玩过后让ns吃灰’, ‘割草机林克’

旷野之息

还有我最喜欢的 Splatoon 2:

‘第三人称’, ‘射击’, ‘我TM社保’, ‘喷漆工教程’, ‘伟天魔术棒’, ‘寻找过气偶像’, ‘我去打工了’, ‘ns沦为喷射启动机’, ‘主宰’, ‘时尚时尚最时尚’, ‘湿涂鸦忑’, ‘死喷乱涂2’

乌贼

然后再看看下边这组标签,猜猜是哪款游戏(滑稽)?

‘动作’, ‘角色扮演’, ‘恐怖’, ‘回合制’, ‘恋爱模拟’, ‘步行模拟’, ‘受苦’, ‘好玩’, ‘跳崖之魂’, ‘休闲娱乐’, ‘二人转’, ‘射爆之魂’, ‘翻滚之魂’, ‘跑酷之魂’, ‘女装王子之魂’, ‘alll’, ‘传火吗’, ‘太阳万岁’, ‘换装游戏’, ‘小猪佩奇’, ‘愉悦’, ‘爱情’, ‘cosplay’, ‘阖家’, ‘步行模拟器’, ‘饭后消食’, ‘心跳回忆’, ‘莽就完事了’, ‘我舒服了你呢’, ‘黑魂暖暖’

DARK SOUL

最初只是把抓到的游戏库保存成csv表格,偷懒嘛,后来发现到底还是需要搞个数据库才方便之后查询。sqlite3完美满足了我偷懒的需求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
import os
import csv
import json
import sqlite3

CLEAR_TABLE = '''
drop table if exists games;
'''

CREATE_TABLE = '''
create table games
(
name varchar(255) default 'noname',
nickname varchar(255) default 'nonickname',
score int default -1,
pop int default -1,
date varchar(255) default '1970-01-01',
dna varchar(255) default 'nodna',
platform varchar(255) default 'noplatform',
company varchar(255) default 'nocompany',
tag varchar(255) default 'notag',
url varchar(255) default 'https://www.vgtime.com',
img varchar(255) default 'https://static.vgtime.com/image/noimage_vg.png'
);
'''

INSERT_DATA = '''
insert into games (name, nickname, score, pop, date, dna, company, platform, tag, url, img)
values ("{name}", "{nickname}", "{score}", "{pop}", "{date}", "{dna}", "{company}", "{platform}", "{tag}", "{url}", "{img}")
'''

QUERY_BY_NAME = '''
select *
from games where name LIKE "%{name}%" or nickname LIKE "%{name}%" order by pop desc
'''

conn = sqlite3.connect('gamesqlite.db')
cursor = conn.cursor()
cursor.execute(CLEAR_TABLE)
cursor.execute(CREATE_TABLE)
gameset = set()

with open('gamedata.csv', 'r', encoding='utf-8') as f:
reader = csv.DictReader(f)
for line in reader:
if line['name'] in gameset:
continue
columns = {}
columns['name'] = line['name']
columns['nickname'] = line['nickname']
columns['score'] = line['score']
columns['pop'] = line['count']
columns['platform'] = line['platform']
columns['date'] = line['date']
columns['dna'] = line['dna']
columns['company'] = line['company']
columns['tag'] = 'null'
columns['url'] = line['url']
columns['img'] = line['img']
cursor.execute(INSERT_DATA.format(**columns))
gameset.add(line['name'])

print('insert {0} lines'.format(cursor.rowcount))
cursor.close()
conn.commit()
cursor = conn.cursor()
keyword = '塞尔达'
query = {'name': keyword}
cursor.execute(QUERY_BY_NAME.format(**query))
result = cursor.fetchall()
print('games matching {0}:\r\n'.format(keyword))
for game in result:
print(str(game) + '\r\n')
conn.close()

测试输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
insert 1 lines
games matching 塞尔达:

('塞尔达传说:旷野之息', 'The Legend of Zelda:Breath of the Wild', 9.7, 9715, '2017-03-03', "['动作', '沙箱', '冒险']", "['WiiU', 'Switch']", 'Nintendo', 'null', 'https://www.vgtime.com/game/805.jhtml', 'https://img01.vgtime.com/photo/web/160616170441201.jpg')
('塞尔达传说:时之笛 3D', 'The Legend of Zelda: Ocarina of Time 3D', 9.2, 782, '2011-06-16', "['动作', '冒险']", "['3DS']", 'Grezzo', 'null', 'https://www.vgtime.com/game/1814.jhtml', 'https://img01.vgtime.com/photo/web/150425152005859.png')
('塞尔达传说:众神的三角力量 2', 'The Legend of Zelda: A Link Between Worlds', 9.1, 669, '2013-12-26', "['动作', '解谜', '冒险']", "['3DS']", 'Monolith Soft','null', 'https://www.vgtime.com/game/1816.jhtml', 'https://img01.vgtime.com/photo/web/150519170715640.jpg')
('塞尔达传说:梅祖拉的假面3D', "The Legend of Zelda: Majora's Mask 3D", 8.9, 332, '2015-02-14', "['动作', '解谜', '冒险']", "['3DS']", 'Grezzo', 'null', 'https://www.vgtime.com/game/1815.jhtml', 'https://img01.vgtime.com/photo/web/150423150304177.png')
('塞尔达传说 幻影沙漏', 'セルダの伝説 夢幻の砂時計', 8.8, 293, '2007-06-23', "['动作', '角色扮演']", "['NDS']", 'Nintendo', 'null', 'https://www.vgtime.com/game/3434.jhtml', 'https://img01.vgtime.com/photo/web/151127150546533.jpg')
('塞尔达传说:三角力量英雄', 'The Legend of Zelda: Tri Force Heroes', 8.4, 275, '2015-10-22', "['动作', '角色扮演', '冒险']", "['3DS']", 'Nintendo', 'null', 'https://www.vgtime.com/game/2234.jhtml', 'https://img01.vgtime.com/photo/web/150617115040305.jpg')
('塞尔达传说:风之杖HD', 'The Legend of Zelda: The Wind Waker HD', 9.1, 274, '2013-09-26', "['动作', '冒险']", "['WiiU']", 'Nintendo', 'null', 'https://www.vgtime.com/game/806.jhtml', 'https://img01.vgtime.com/photo/web/150429220937175.jpg')
('塞尔达无双:海拉尔全明星', 'ゼルダ無双 ハイラルオールスターズ', 7.8, 244, '2016-01-21', "['动作']", "['3DS', 'Switch']", 'Koei Tecmo', 'null', 'https://www.vgtime.com/game/2189.jhtml', 'https://img01.vgtime.com/photo/web/160129161639315.jpg')
('塞尔达传说 黄昏公主 HD', 'The Legend of Zelda: Twilight Princess HD', 9.1, 242, '2016-03-04', "['动作', '冒险']", "['WiiU']", 'Nintendo', 'null', 'https://www.vgtime.com/game/3312.jhtml', 'https://img01.vgtime.com/photo/web/160223152510763.jpg')
('塞尔达传说 天空之剑', 'ゼルダの伝説 スカイウォードソード', 9.2, 236, '2011-11-23', "['动作', '角色扮演']", "['Wii']", 'Nintendo', 'null', 'https://www.vgtime.com/game/2870.jhtml', 'https://img01.vgtime.com/photo/web/150911160541732.jpg')
('塞尔达传说 灵魂轨道', 'The Legend of Zelda:Spirit Tracks', 8.6, 231, '2009-12-07', "['动作', '角色扮演']", "['NDS']", 'Nintendo', 'null', 'https://www.vgtime.com/game/2617.jhtml', 'https://img01.vgtime.com/photo/web/150803172020674.jpg')
('塞尔达无双', 'Hyrule Warriors', 7.7, 107, '2014-08-14', "['动作']", "['WiiU']", 'Omega Force', 'null', 'https://www.vgtime.com/game/735.jhtml', 'https://img01.vgtime.com/photo/web/150428122751186.jpg')

嗯,够用了。

语料库

最重要的是语料库,为了让机器能理解游戏行业的一些“黑话”和专有名词,需要把VGTime上的文章都爬下来。

image-20181225001925199

本来想再写一个新闻爬虫,不过刚好看到首页上有个“更多主题”按钮……

image-20181225002128243

偷懒成功!

于是直接用load.jhtml?page={0}&pageSize=50这个接口直接获得了VGTime近两年所有新闻页面的URL:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
for i in range(2000):
req_template = "https://www.vgtime.com/topic/index/load.jhtml?page={0}&pageSize=50"
r = requests.get(req_template.format(i+1), headers=headers)
if r.status_code != 200:
error_count += 1
print('error [{0}] crawling page [{1}]'.format(r.status_code, i))
print('total error times: [{0}]'.format(error_count))
else:
links = re.findall(r'(\/topic.*?jhtml)', r.text)
links = list(set(links))
topic_url.extend(links)
print('extended [{0}] links'.format(len(links)))
if (len(links) == 0):
error_count += 1
if error_count > 3:
# 想必是到头了
break
# sleep(0.01)

然后python有个叫html2text的库,可以直接把html节点包裹的文本提取出来,过滤掉属性信息。也就是说,找到<article>节点之后,交给它就能提出新闻文本来了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
for url in topic_url:
url = urllib.parse.urljoin('https://www.vgtime.com/', url)
r = requests.get(url, headers=headers)
if r.status_code != 200:
error_count += 1
print('error [{0}] crawling page [{1}]'.format(r.status_code, url))
print('total error times: [{0}]'.format(error_count))
else:
# 正则太难写了,直接str.find完事儿
idx_start = r.text.find("<article>")
idx_end = r.text.find("</article>")
article = r.text[idx_start:idx_end]
content = converter.handle(article)
topic_file.write(content + '\r\n======\r\n')
# topic_file.flush()
# sleep(0.01)

于是我们得到了30MB大小的VGTime新闻语料库。30MB其实还是太小,远达不到生产标准,但对这次实验来说够用了。

B社:呵呵哒

(额,B社……你确定?

爬虫跑完之后的目录结构:

1
2
3
4
5
6
7
8
9
10
.
├── VGTimeSpider
│   ├── spiders
│   └── tools
├── gameimg # 游戏图片
├── gamedata.csv # 游戏库表格
├── gamedata.json # 游戏库json
├── gamesqlite.db # 游戏库db
├── gametopic.txt # VGTime的文章,作为语料库
└── gametitle.txt # 游戏标题,训练专有名词分词

0x05 搞定NLU

分词

语料库保存在gametopic.txt里,首先要做语料清洗。用之前存好的清洗维基百科语料库的脚本,替换掉特殊字符、还有人工加入的分隔符,得到std_gametopic.txt

1
2
3
4
5
6
7
8
9
10
11
# 分词错误

荒野 大 镖客 -> 荒野大镖客

极限 竞速 -> 极限竞速

塞尔达 传说 -> 塞尔达传说

怪物 猎人 -> 怪物猎人

色彩 喷射 团 -> 色彩喷射团

在进行分词之前,要设定好游戏字典。结巴分词支持设置字典,避免将专有名词进行切分。为了偷懒,我把游戏库里的游戏标题单独提取出来,导出到gametitle.txt,大概是这样的内容:

1
2
3
4
塞尔达传说:旷野之息
马里奥赛车8 Deluxe
英雄不再:Travis Strikes Again
...

还需要对游戏标题进行清洗,把冒号、英文都替换掉,把塞尔达传说旷野之息分成两个词。这部分代码…强加了好多自己人为指定的规则,只能说丑但能用了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import os
import re

with open('gametitle.txt', 'r', encoding='utf-8') as f:
content = f.readlines()

# 消去标点符号
with open('gamedict.txt', 'w', encoding='utf-8') as f:
content = [x.strip() for x in content]
for line in content:
for word in (re.split(': |:| |,|,', line)):
f.write(word + '\r\n')

# 消去英文和数字
with open('gamedict.txt', 'r+', encoding='utf-8') as f:
content = str(f.read())
f.seek(0)
# 丑但能用=。=
english_words = re.findall('[a-zA-Z0-9]+', content)
for word in english_words:
content = content.replace(word, '')
# 匹配英文单词
english_words = re.findall('[a-zA-Z0-9]+', content)
for word in english_words:
wordp = re.compile(word)
content = wordp.sub('', content)
lines = content.split('\n')
# 去重
lines = list(set(lines))
for line in lines:
# 3个字才算整词
if line.strip() != '' and len(line.strip()) >= 3:
f.write(line.strip() + '\r\n')
f.truncate()

于是我们得到了游戏字典gamedict.txt,用来输入结巴分词,对VGTime游戏新闻语料进行分词处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import sys
import jieba
import re


def fenci(input_file):
filename = 'jieba_' + input_file
fileneedCut = str(input_file)
fn = open(fileneedCut, "r", encoding="utf-8")
f = open(filename, "w+", encoding="utf-8")
jieba.load_userdict('gamedict.txt')
for line in fn.readlines():
words = jieba.cut(line)
for w in words:
f.write(str(w) + ' ')
f.close()
fn.close()


if __name__ == '__main__':
input_file = sys.argv[1]
fenci(input_file)

顺便可以看一下语料库里的常见词汇:

游戏

玩家

PS4

发售

Xbox

Switch

任天堂

PC

主机

VR

民间高手果然上榜了,嗯。

向量化

接下来我们使用MITIE中的wordrep进行信息提取。首先down下源码来自己编译。编译好之后,把分好词的语料库单独建个目录丢进去,然后:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 目录结构
.
├── cleanchar.py
├── std_chs_wiki_00
├── std_chs_wiki_01
├── std_wiki_00
├── std_wiki_01
├── wiki_00
├── wiki_01
└── zhwiki-latest-pages-articles.xml.bz2

# MITIE 向量化
./wordrep -e ./zh

# 计算耗时(powershell)
$sw = [Diagnostics.Stopwatch]::StartNew()
./wordrep -e ./zh
$sw.Stop()
$sw.Elapsed

耗时30多分钟,得到如下文件:

1
2
3
4
5
6
7
8
9
10
11
.
├── substring_set.dat
├── substrings.txt
├── top_word_counts.dat
├── top_words.txt
├── total_word_feature_extractor.dat
├── word_morph_feature_extractor.dat
├── word_vects.dat
├── wordrep
└── zh
└── jieba_std_gametopic.txt

其中total_word_feature_extractor.dat就是我们完整的词向量数据集了。

image-20181225005430948

嗯,挺大的。

维基百科会定期每月发布文本版,可以从这里(https://dumps.wikimedia.org/zhwiki/latest/zhwiki-latest-pages-articles.xml.bz2)获取,下载下来是一个1.6GB的纯文本。用这个数据训练较为通用的chatbot效果很好,据说会占用几十GB的内存、耗时两三天……计划再找些游戏新闻,跟维基百科合起来,日后再做训练……

短文本训练

下一个接力棒交给Rasa NLU,它支持使用spaCy、MITIE、MITIE+scikit learn等多种后端。我们使用的是最后这种,fork 第三方魔改的Rasa代码。pipeline配置如下:

1
2
3
4
5
6
7
8
9
10
11
language: "zh"

pipeline:
- name: "nlp_mitie"
model: "data/total_word_feature_extractor_game.dat"
- name: "tokenizer_jieba"
- name: "ner_mitie"
- name: "ner_synonyms"
- name: "intent_entity_featurizer_regex"
- name: "intent_featurizer_mitie"
- name: "intent_classifier_sklearn"

我们预设几种intent,并手冻标注一些entites,也就是编写语料规则。可以使用markdown格式编写,然后手冻运行脚本来转换成json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
## intent:greet
- 你好
- Hi
- Hello
- 老兄
- 早上好

## intent:weather
- [今天](date)的天气怎么样
- [北京](location)的天气怎么样
- [今天](date)[上海]的天气如何啊
- [昨天](date)[上海]下雨了吗

## intent:others
- 来点音乐吧
- 我想去旅游
- 安排9点钟开会
- 打开飞行模式
- 我爱你用英语怎么说

## intent:game_recommend
- 我想玩游戏
- 来个游戏玩玩
- 给我找个[RPG](type)游戏
- 最近[任天堂](company)出了哪些游戏
- 我想玩[塞尔达传说](name)
- 帮我推荐几个[射击](type)游戏
- 我想玩[腾讯](company)游戏
- 我想玩[Xbox](platform)平台的[射击](type)类的游戏
- 查一下[微软](company)出的[Xbox](platform)平台的游戏
- 搜索[任天堂](company)最近的作品
- 寻找评分高于[9.7](score)分的[FPS](type)游戏

## intent:game_query_rating
- [怪物猎人](name)好玩吗
- [塞尔达传说](name)怎么样
- [色彩喷射团](name)评分如何
- [卡普空](company)公司出的[怪物猎人](name)好玩吗
- [R星](company)发行的[荒野大镖客](name)怎么样
- 你喜欢[腾讯](company)制作的[英雄联盟](name)吗
- [微软](company)的[极限竞速](name)媒体评分怎么样
- 搜索[网易](company)[荒野行动](name)的评分

## intent:game_query_year
- [塞尔达传说](name)是哪一年出的
- [侠盗猎车手](name)的发行时间是
- [底特律](name)的发售时间是哪一年
- 在有生之年能玩到[死亡搁浅](game)吗
- [腾讯](company)是哪一年出的[英雄联盟](game)

## intent:game_query_company
- [荒野大镖客](name)是谁出的
- 哪个游戏公司制作了[使命召唤](name)
- [绝地求生](name)是哪个公司出的游戏
- [极限竞速](name)是哪个公司的游戏

## intent:game_query_review
- [塞尔达传说](name)评价怎么样
- [育碧](company)的[尼尔](name)评价怎么样
- 搜索[实况足球](name)的网友评价
- 看一下[战场女武神](name)的玩家评价
- [守望先锋](name)这个游戏玩家怎么说

## intent:game_query_platform
- [塞尔达传说](name)支持哪些平台
- 我的[PS4](platform)可以玩[超级马里奥](name)吗
- [怪物猎人](name)支持[PC](platform)吗
- [极限竞速](name)[PC](platform)能玩吗
- [塞尔达传说](game)有[PS4](platform)版本吗

## synonym:腾讯
- 鹅厂
- Tencent

## synonym:网易
- 猪厂
- 黄易

## synonym:任天堂
- 民间高手
- 老任
- Nintendo

## synonym:育碧
- 育婊
- Uplay

## synonym:卡普空
- 卡婊
- Capcom

## synonym:色彩喷射团
- 乌贼
- 乌贼暖暖
- 喷射战士
- Splatoon

## synonym:怪物猎人
- 怪猎
- 怪猎世界
- 怪物猎人世界
- MHW

之前痴迷 Win 10 UWP 开发的时候,曾经做过 Cortana 第三方应用的接入。那种是基于规则匹配的做法,需要手动编写VCD(Voice Command Definition)文件。这个是爱看段子.UWP使用的VCD文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?xml version="1.0" encoding="utf-8" ?>
<VoiceCommands xmlns="http://schemas.microsoft.com/voicecommands/1.2">
<CommandSet xml:lang="zh-cn" Name="HaHaHaCommandSet_zh-cn">
<AppName>大葱</AppName>
<Example>大葱给我讲个段子吧</Example>

<Command Name="Random">
<Example>大葱给我讲个段子吧</Example>
<ListenFor>[给我]讲个段子</ListenFor>
<ListenFor>[给我]讲讲段子</ListenFor>
<ListenFor>[给我]讲点段子</ListenFor>
<ListenFor>[给我]讲的段子</ListenFor>
<ListenFor>[给我]讲个段子吧</ListenFor>
<Feedback>段子装填中</Feedback>
<VoiceCommandService Target="HaHaHaVoiceCommandService" />
</Command>

</CommandSet>
</VoiceCommands>

可以看到,为了尽可能多的匹配到用户的提问,我把这三种问法枚举了一遍:

  • [给我]讲个段子
  • [给我]讲讲段子
  • [给我]讲点段子
  • [给我]讲的段子(这句是怕语音识别出错加的)

如果对 Cortana 说的话,没有在这个列表里,那就匹配不到了。

但是对于NLU引擎来说,它会寻找语义最接近的规则,并给出每种intent的置信度(confidence)。在进行实体识别之后,能给出相关的实体。

格式转换、训练和测试请求:

1
2
3
4
5
6
7
8
# 转换训练规则 md2json
python -m rasa_nlu.convert --data_file ./data/examples/rasa/demo-rasa_zh_game.md --out_file ./data/examples/rasa/demo-rasa_zh_game.json --format json
# 训练
python -m rasa_nlu.train -c sample_configs/config_jieba_mitie_sklearn.yml --data data/examples/rasa/demo-rasa_zh_game.json --path models
# 启动
python -m rasa_nlu.server -c sample_configs/config_jieba_mitie_sklearn.yml --path models
# 请求
http://localhost:5000/parse?q=搜索任天堂出的射击游戏

尝试几个请求:

第一句,任天堂出的塞尔达传说有PC版吗,把game_query_platform识别成了game_recommend,不服,再来

image-20181225011125279

帮我推荐几个PS4上好玩的射击游戏,这次效果可以,正确识别出了游戏平台和游戏类型:

image-20181225011221305

喷射战士评分是多少,查评分的意图也识别出来了:

image-20181225011308936

底特律是什么时候出的,识别“查询游戏发售年份”的意图也成功了:

image-20181225011356649

0x06 查询和展示

前面导出了sqlite3游戏库,加上意图识别和实体提取,把company、type、rating等出现了的实体信息,生成where语句,通过一串SQL语句就能进行查询了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
QUERY_FREE = '''
select name,score,pop,date,company,platform from games
where {0}
order by pop desc, company limit 10
'''

...

gameinfo = result[random.randint(0, len(result) - 1)]
template = ['可以试试 {company} 出的 {name},看起来不错哦',
'为你推荐 {name}', '{company} 出的 {name} 挺好玩的,快来试试吧']
gamedict = {
'name': gameinfo[0],
'score': gameinfo[1],
'pop': gameinfo[2],
'date': gameinfo[3],
'company': gameinfo[4],
'platform': gameinfo[5]
}
response = template[random.randint(
0, len(template) - 1)].format(**gamedict)
print(response)

...

至于展示……本来满腔热忱想撸一套科技感满满的页面出来的,但是被我偷懒改成了控制台……

乌贼万岁

0x07 逐渐忘记标题

完整的NLP包括两部分:自然语言理解、和自然语言生成。两者合一,才能做出一个完整的chatbot。梳理一下NLP的核心概念,亲手制作一个bot来重温这些知识,还是蛮让人振奋的。本次分享的,只是其中的前一环节,自然语言理解中的短文本意图识别。加上自然语言生成,加上对话管理,才能迈出从“人工智障”到“人工智能”的第一步。

Don’t get cooked, stay off the hook !

大家圣诞快乐~