本文涉及的代码可以从这里下载。
一些废话
我瞧了瞧我的简书简介的其中半句是“一个迷妹”,是时候贯彻这半条简介了,今次给大家带来的是一个恋爱机器人的教程。其实我github的README
写得还算比较详细了,还有一些比较琐碎的东西姑且在本文中提一下。
关于恋爱机器人,最早是因为玩了恋与,和一群同好一起整了个聊天机器人,但是那时候并没有自己编码而是选择了图灵机器人+酷Q的服务,可以说是基本没什么技术含量。不过用图灵的话他虽然有基本的语料库,用户也可以自定义增加语料,当时的同好小组召集了一众爱好者编写了非常多质量不错的语料,我也很幸运地获得了一份。后来因为毕业精力有限,也没有可以24h挂机的机器,机器人组没有再运维下去。我自己本职工作姑且算是NLP相关吧,我内心其实一直想自己亲自写一个聊天机器人来着不过因为水平和精力有限一直没能成行,只是把当初获得的语料收藏好了。
从国内某知名996公司(虽然我在其国外分公司)跳槽以后,终于来了一个钱多活少的机构,又逢疫情爆发,公司让在家工mo作yu,一下子就无所事事,又觉得这样不行自己不能这么荒废,于是搞起了机器人……
架构
咱也不是写论文,懒得画图了,用户输入进来,先经过检索模型查询语料库中的query,如果返回的query不满足阈值,则会进一步进入生成模型来生成回答。
检索模型
中文处理
中文分词主要是用的jieba,之前我也用过snownlp来着。这两个库也都可以做词性标注,也可以过滤停用词。但是闲聊的时候很多确实就是停用词(废话)都过滤了那都没内容了,所以本模型中没有过滤停用词。另外基于同样的原因,没有做词性标注给不同单词赋予相应的权重。
检索算法
检索模型其实有很多不同做法,正巧C君最近选了“Information Retrieval & Text Analysis”这门课,就跟他稍微讨论了一下。其实这课我几年前也选过不过早就忘得精光了。信息检索上比较传统的做法是建立词表倒排索引那一套,这种方法也不是不行,说不定结果还不错,但我想搞点高端(?)的,顺便梳理复习一下基础知识。
检索模型的重点主要是计算句子相似度,常用的方法主要是计算编辑距离、Jaccard系数、tf-idf、词向量这些。具体可以参考这篇文章的介绍。
我这边用的方法主要是词向量,具体来说就是把一个query分词以后找到每个词对应的词向量之后平均作为该query的句向量。词向量又可以有很多选择,比如fastText,glove,bert。其中bert还可以直接训练句向量,本来是想用bert的,但是没找到好用的pytorch库(本来搞机器人就是为了学一学pytorch的来着),最后就随便选了fastText训练好的现成词向量。之前因为在血汗公司搞实体提取,用过脸书家的MUSE是基于fastText的,提供了一套多语言映射到同一空间中的词向量,所以理论上我的这个聊天机器人是可以支持多语言的。
Brute force
我的知识库不算很大一共是19k问答pairs。对于这些知识库中的query是可以提前计算句向量存下来的(参考配置文件config.py
中的sentEmbFile
变量)。但是问题主要是在用户输入query的时候,要计算和这19k个知识库中的query的相似度,这一步就比较费时。但是就19k这个数据量来说其实还可以接受,我实现的brute force模式(retrieve_mode = "brute_force"
)来找最近邻对于每一条query用时大概在两三秒内能完成,讲道理作为一个聊天机器人,对于用户的每一条query,花费几秒才回复反而很真233333。但数据量一旦上升这种方式显然就变得不太合理。另外需要提到一下brute force方式,我选择余弦相似度作为metrics,也就是说两个句子之间的余弦值越接近1越相似,对应的阈值threshold_ret
是我观察了一些例子硬找出来的。
Faiss
关于向量(近似)最近邻的快速召回,各家其实都有研究来着,推荐这块就比较用得着,之前所在的血汗工厂也有自己的类似工具,不过我还没用到就滚蛋了。当时做项目的时候就听说了脸书家的Faiss(上文提到的MUSE实际上也依赖于这个库),我在MAC本地有装过,MUSE也跑得挺好所以我就打算用Faiss把我之前写的brute force方法改写。然而时过境迁,我现在的工作机变成了Windows,Faiss这个辣鸡居然不支持Windows?在他们的repo也看到有人提这个issue,结果开发者非常惨兮兮地表示他们没法获得Windows机器所以无法开发Faiss的Windows版本,甚至他们能开发出Linux和MAC版本都已经非常困难了……我简直EXM???
Ball tree
后来我挺难过的,网上搜了一下KNN确实也有一些加快速度的算法,比如KDTree和Ball Tree,具体算法我不介绍了,sklearn直接有现成的实现,调用这个模式可以参考我的代码(设置retrieve_mode = "ball_tree"
)。但是值得注意的是,sklearn这几个方法支持的metrics里没有余弦值,我还挺奇怪,后来搜了一下发觉有一种观点是余弦值并不是一个合适的KNN指标,因为它违反了triangle inequity,但又有另一群人说在某些特定的问题下用余弦值反而能取得更好的结果,一些讨论可以参考这篇文章。关于搜索最近邻,欧氏距离是比较推荐的指标,但是我试了一下,肉眼可观察到的结果比使用余弦值差了太多。虽然sklearn支持自定义指标,但是对于自定义指标必须满足一定要求,而余弦值并不满足所以就有点尴尬。后来看了这篇文章,终于有了一个解决方法。因为正规化(Regularization)后的向量之间的余弦值和欧氏距离是呈线性关系,即euc=sqrt(2-2*cos)
,所以只要把向量正规化后求欧式距离再得到的KNN结果排序和余弦值得到的结果排序是一样的。所以在compute_sent_emb.py
文件中计算出了句子向量以后立刻进行了正规化操作之后才套了ball tree,肉眼观测结果确实和brute force差不多,结果比不正规划直接用欧氏距离好很多。但是用这种方法返回的similarity值有点奇怪,我用brute force方法设定的阈值用上面的公式倒推欧氏距离的阈值发觉对应关系比较暧昧,无论设置大一点还是小一点都比较有问题(注意欧氏距离是越小则两个句子越相似),所以这种方法不太推荐,一定要用的话推荐把阈值设置的严格一点,那样如果搜不到就会调用生成模型,影响不会很大。
annoy
还好咱们还有第三种方法,那就是spotify家开发的annoy
,文档看这里。这个库倒是号称支持余弦值,但我实际使用的时候发觉也要自己手动正规化向量再设置metric为'angular'
,而且输出的结果也完全是欧式距离,需要自己再转化为余弦值……我又用brute force方法设定的阈值用上面的公式倒推欧氏距离的阈值发觉对应关系比较准,查询速度也很快,数据规模上去了也没问题,不过这种方法搜到的并非是精确的KNN,但我肉眼比较和brute force结果相差不大,也可以通过调参牺牲时间换准确度,具体大家可以自己看文档。
生成模型
生成模型就说简单一点了,主要是参考了pytorch官方的chatbot教程,主要的结构就是seq2seq+attention。当然也做了一些改动,主要是对于词表中没有的单词会进行相应处理不会直接报错,还有支持了中文。唯一值得一提的是官方教程中在计算标准答案和生成句子的cross entropy时,只考虑标准答案长度个词,对于后面padding的部分是不计算的,这就会导致一个问题,那就是生成的答案前半段都很正常,后半段就会莫名其妙增加一段话。比如用户输入“新年快乐”,回答则会类似于“新年快乐。我不知道干嘛你的”,一开始是多加了后处理的方法,就是在句号和问号后把回答截断,不过这有点头痛医头脚痛医脚,训练了10万次结果才差不多能看。
后来我就尝试把loss function改成了针对整个句子算(包括后面padding的部分),效果非常明显1万次后的回答就比较正常了,不会出现前文所属的情况。在配置文件中设置masked=False
即可开启这种模式。
彩蛋
第一次检索出的那个“嗯,乖。”简直亮瞎了我的狗眼哈哈哈~顺便一提两次检索结果不同是因为对于得分相同的检索结果每次会随机抽一条返回,增加回答的多样性。