自然语言处理实战——使用词向量进行推理

348 阅读1小时+

本章内容包括:

  • 理解词嵌入或词向量
  • 用向量表示意义
  • 定制词嵌入以创建特定领域的向量
  • 使用词嵌入进行推理
  • 可视化词语的意义

词嵌入可能是你自然语言处理(NLP)工具箱中最易于理解且普遍有用的工具。它们能为你的NLP管道提供对词语的基本理解。在本章中,你将学习如何将词嵌入应用于实际应用场景。更重要的是,你还将了解在哪里不该使用词嵌入。希望这些示例能帮助你在商业和个人生活中想出新的有趣应用。

你可以将词向量想象成角色扮演游戏角色或《Dota 2》英雄的属性列表。现在,假设这些角色卡片或档案上没有任何文字。你需要将所有表示角色属性的数字保持一致的顺序,这样你才能知道每个数字代表什么。这就是词向量的工作原理。数字并未标明其含义;它们仅以一致的顺序或位置放置在向量中。这样,当你对两个词向量进行加法、减法或乘法运算时,一个向量中的“力量”属性会与另一个向量中的力量属性对齐——同样适用于敏捷、智力、对齐、哲学等属性,类似于《龙与地下城》中的设定。

用心设计的角色扮演游戏往往通过角色个性细腻的组合(如混乱善良或守序邪恶)鼓励对哲学和词语进行更深入的思考。幸运的是,霍布森的地下城主睁开眼睛,看到了像“善良”和“邪恶”这样的词语所暗示的伪二分法,这帮助他理解了词语和词向量的模糊性。你将在这里学习到的词向量为你找到几乎所有文本和语言中可能量化的词汇属性提供了空间。并且这些词向量的属性或特征在复杂的方式中相互交织,能够轻松处理像“守序邪恶”、“仁慈的独裁者”和“利他恶意”等概念。

学习词嵌入通常被归类为表示学习算法。任何词嵌入的目标都是构建词“特征”的紧凑数值表示。这些数值表示使得机器能够以有意义的方式处理你的词语(或你的《Dota 2》角色卡)。

6.1 这是你大脑在词语作用下的表现

词嵌入是我们用来表示意义的向量,而你的大脑是存储意义的地方。你的大脑“在”词语中——它受到词语的影响。就像化学物质影响大脑一样,词语也会影响大脑。“这就是你大脑在毒品作用下的样子”是80年代反毒品电视广告中的一个流行口号,广告中展示了一对在煎锅里滋滋作响的鸡蛋。

幸运的是,词语比化学物质对大脑的影响要温和得多,而且更加有益。图6.1中展示的你大脑在词语作用下的表现看起来与煎锅里滋滋作响的鸡蛋有所不同。这个插图给出了一个想象的方式,展示了当你阅读这些句子时,大脑中的神经元如何产生火花并激发思维。你的大脑通过向适当的邻近神经元发射信号,连接这些词语的意义,寻找相关词汇。词嵌入是这些词语之间连接的向量表示,因此它们也是你大脑中神经元连接网络节点嵌入的粗略表示。

image.png

你可以将词嵌入视为大脑在思考一个单独词语时神经元激发模式的向量表示。每当你想到一个词时,思维会在你的大脑中产生一波电荷和化学反应,这些反应始于与该词或思想相关的神经元。你大脑中的神经元以波浪形式激发,就像石子落入水中产生的圆形涟漪一样,但这些电信号是有选择地通过某些神经元而非其他神经元传播的。

当你阅读这句话中的词语时,你的神经元就像图6.1中的插图一样,激发了闪烁的活动。实际上,研究人员发现了人工神经网络中词嵌入权重的模式与你在思考词语时大脑活动模式之间的惊人相似性。

创建词嵌入的算法是一种自监督学习算法。这意味着你不需要字典或同义词典来为算法提供数据;你只需要大量的文本,算法会从文本中词语之间的连接中学习。在本章稍后,你将收集一些维基百科文章作为你的训练集。随着人类创作的文本数据量越来越大,基于这些数据的嵌入质量也随之提升。

这里有另一个可以思考的“大脑在词语作用下”的例子。词语不仅会影响你的思维方式,还会影响你的沟通方式。而你在某种程度上就像是集体意识中的一个神经元,社会的大脑。这个词语是我们特别强大的神经连接模式,因为我们从丹尼尔·丹内特的《直觉泵》一书中学到了它的意义。在他的著作中,丹内特讨论了一个强有力的概念——“能力与理解无关”。达尔文使用这个概念来解释语言理解的大脑是如何通过简单的机制从单细胞生物进化而来的。在他的时代,认为像生物有机体这样复杂的机器可以在没有智能设计者的情况下存在是革命性的。艾伦·图灵基于相同的概念,展示了如何将复杂的计算分解为简单的机械操作,并由一个不理解算术概念的机器执行。这是另一种思考词嵌入的方式——计算机不需要“理解”一个词,就能对这个词进行复杂的操作,如语义搜索或语言生成。这就引出了一个问题:词嵌入有什么用?

6.2 应用

那么,这些令人惊叹的词嵌入有什么用呢?词嵌入可以用于任何需要机器理解词语或短n-gram的地方:

  • 求职、网页等的语义搜索
  • 记不清词语时的词语查找
  • 重写标题或句子
  • 情感塑造
  • 解答词语类比问题
  • 用词语和名称进行推理

在学术界,研究人员使用词嵌入来解决200多个NLP问题:

  • 词性标注
  • 命名实体识别
  • 类比查询
  • 相似性查询
  • 音译
  • 依存句法分析

如今,词嵌入背后的相同概念被用来嵌入整个句子和段落,使得词嵌入方法变得更加有用和强大。

6.2.1 搜索意义

在过去(20年前),搜索引擎通过其术语频率-逆文档频率(TF-IDF)评分来查找你输入的所有词语,好的搜索引擎会用同义词来增强你的搜索词汇。有时它们甚至会修改你的词语,猜测你在输入某些特定词语组合时真正的意图。所以,如果你搜索“sailing cat”,它们可能会将“cat”改为“catamaran”,以便为你消除歧义。在后台,搜索引擎在排序结果时,甚至可能会将类似“positive sum game”的查询改为“nonzero sum game”,以便引导你访问正确的维基百科页面。

然后,信息检索研究人员发现了如何使潜在语义分析(LSA)更有效——那就是词嵌入。这些新的词嵌入(向量)使得搜索引擎能够直接将你的查询的“意义”与网页匹配,而不必猜测你的意图。你搜索词语的嵌入提供了一个直接的数值表示,基于这些词语在互联网上的平均意义,来表达你的搜索意图。

重要提示:词嵌入(有时称为词向量)是词语意义的高维数值向量表示,涵盖了词语的字面意义和隐含意义。在词嵌入所定义的空间中,距离较近的词语在语义上将比距离较远的词语更相似。

搜索引擎不再需要基于硬编码规则进行同义词替换、词干提取、词形还原、大小写折叠和歧义消解。它们基于其搜索索引中所有页面的文本创建词嵌入。不幸的是,主流搜索引擎决定将这种新发现的能力用于与产品和广告匹配词嵌入,而非真正的词语。像AdWords和iAd这样的服务的词嵌入会根据市场营销人员付费的金额来加权,从而让你偏离你原本的搜索意图。基本上,大科技公司让企业能够迫使搜索引擎操控并训练你成为它们的“消费僵尸”。

如果你使用更诚实的搜索引擎,如Startpage、DISROOT或Wolfram Alpha,你会发现它们更有可能给你你真正想要的结果。如果你有一些深网或私人页面和文档,想要将它们作为你组织或个人生活的知识库,你可以自托管一个带有尖端NLP的搜索引擎,如Elastic Search、Meilisearch、Searx、Apache Solr、Apache Lucene、Qwant或Sphinx。即使是PostgreSQL,在全文搜索精确度方面也超过了主流搜索引擎。这些语义搜索引擎在后台使用向量搜索来查询一个词语和文档嵌入(向量)数据库。你会惊讶地发现,当你使用一个真正诚实的搜索引擎时,你会对世界有更清晰的认识。

开源的Python工具,如NBoost和PyNNDescent,让你可以将词嵌入与自己喜欢的TF-IDF搜索算法结合使用。或者,如果你想以可扩展的方式搜索你精调的嵌入和向量,你可以使用近似最近邻算法来索引任何你喜欢的向量。

这就是词嵌入的好处。你习惯的向量代数数学,如计算距离,也适用于词嵌入——只是现在,这个距离表示的是词语之间的语义差异,而非物理距离。而且,这些新的嵌入比你在TF-IDF向量中习惯的数千维度更加紧凑且充满意义。

你可以利用意义距离在词语数据库中搜索所有与你心目中的职位标题相近的职位标题。这可能会揭示你以前没有想到的其他职位标题。或者,你的搜索引擎可以设计成在你的搜索查询中添加额外的词语,确保返回相关的职位标题。这就像一个理解词语意义的自动完成搜索框,称为语义搜索:

>>> from nessvec.indexers import Index      #1
>>> index = Index(num_vecs=100_000)        #2
>>> index.get_nearest("Engineer").round(2)
Engineer       0.00
engineer       0.23
Engineers      0.27
Engineering    0.30
Architect      0.35
engineers      0.36
Technician     0.36
Programmer     0.39
Consultant     0.39
Scientist      0.39
#1 pip install nessvec
#2 100,000 of the 1,000,000 embeddings in this FastText vocabulary

你可以看到,找到词嵌入的最近邻就像在同义词典中查找词语,但这比你在本地书店或在线字典中找到的同义词典更加模糊且完整。你很快就会看到如何自定义这个字典以适应你喜欢的任何领域。例如,你可以训练它只处理来自英国、印度或澳大利亚的职位招聘,具体取决于你的兴趣区域。或者,你可以训练它更好地处理硅谷的技术职位,而不是纽约的金融和银行职位。如果你希望它处理更长的职位标题(如软件开发者或NLP工程师),你甚至可以训练它处理2-gram和3-gram。

另一个关于词嵌入的好处是它们是模糊的。你可能注意到,工程师的几个邻近词,你可能在同义词词典中看不到,你可以根据需要继续扩展这个列表。所以,如果你在想的是软件工程师而不是建筑师,你可能想扫描get_nearest()列表,寻找另一个词进行搜索,比如Programmer:

>>> index.get_nearest("Developer").round(2)
Developer     -0.00
developer      0.25
Developers     0.25
Programmer     0.33
Software       0.35
developers     0.37
Designer       0.38
Architect      0.39
Publisher      0.39
Development    0.40

嗯,这很惊讶。看来“Developer”这个职位标题经常也与“Publisher”一词相关联。在没有和开发编辑、开发经理甚至Manning Publications的技术开发编辑合作之前,我们可能永远也猜不到为什么会是这样。就在今天,这些“开发者”催促我们赶快动手写这个章节!

6.2.2 组合词嵌入

另一个关于词嵌入的好处是,你可以根据自己的需求将它们组合在一起,创造新的词语!你可以将两个词语的意义加在一起,尝试找出一个能够表达这两个词语含义的单一词语:

>>> chief = (index.data[index.vocab["Chief"]]
...     + index.data[index.vocab["Engineer"]])
>>> index.get_nearest(chief)
Engineer     0.110178
Chief        0.128640
Officer      0.310105
Commander    0.315710
engineer     0.329355
Architect    0.350434
Scientist    0.356390
Assistant    0.356841
Deputy       0.363417
Engineers    0.363686

如果你想成为一名首席工程师,看起来像科学家、建筑师和副手这样的职位可能也是你在途中会遇到的。

那么,关于本章开头提到的“记不清的词语查找”应用呢?你有没有试过在只对某个著名人物有大概印象的情况下搜索他们的名字,可能像这样:

“她在20世纪初的欧洲发明了与物理学相关的某些东西。”

如果你将这个句子输入Google或Bing,你可能得不到你想要的直接答案:玛丽·居里。Google搜索最有可能只是给你列出著名物理学家的链接,不论男女。你必须浏览几页,才能找到你想要的答案,但一旦找到玛丽·居里,Google或Bing会记住这个信息。下次你再搜索科学家时,它们可能会提供更好的搜索结果。(至少,这就是我们在研究这本书时的情况。我们不得不使用私人浏览窗口,以确保你的搜索结果和我们的类似。)

通过词嵌入,你可以搜索结合了“女性”、“欧洲”、“物理学”、“科学家”和“著名”这些词汇意义的词语,这样你就能接近你正在寻找的“玛丽·居里”这个词语。而你所要做的,就是将这些你想要结合的词语的向量加在一起:

>>> answer_vector = wv['woman'] + wv['Europe'] + wv['physics'] +
...     wv['scientist']

在本章中,我们将向你展示如何精确地进行这样的查询。你甚至可以看到,如何利用词嵌入的数学去减去一些词语中的性别偏见:

>>> answer_vector = wv['woman'] + wv['Europe'] + wv['physics'] +\
...     wv['scientist'] - wv['male'] - 2 * wv['man']

通过词嵌入,你可以去除“男性”对“女性”的影响!

6.2.3 类比问题

如果你能将你的问题表述为类比问题会怎样呢?假设你的查询是这样:

“谁在核物理学中相当于路易·巴斯德在细菌学中的地位?”

同样,Google搜索、Bing,甚至DuckDuckGo在这类问题上帮助不大。但是,使用词嵌入,解决方案就简单了——只需要从路易·巴斯德的词向量中减去“细菌”,然后加上“物理学”:

>>> answer_vector = wv['Louis_Pasteur'] - wv['germs'] + wv['physics']

你可能在标准化测试中,像SAT、ACT或GRE考试的英语类比部分看到过类似的问题。有时,它们会像这样以正式的数学符号书写:

MARIE CURIE : SCIENCE :: ? : MUSIC

这让你更容易猜出这些词的向量数学吗?这是其中一种可能:

>>> wv['Marie_Curie'] - wv['science'] + wv['music']

你也可以用类似的方式回答其他问题,不仅仅是关于人和职业的问题——比如,可能是关于体育团队和城市的问题:

“Timbers队和波特兰的关系,就像什么队和西雅图的关系?”

在标准化测试中,这样写:

TIMBERS : PORTLAND :: ? : SEATTLE

与之前的例子类似,词嵌入将让你通过类似的数学表达式来解决这个问题:

wv['Timbers'] - wv['Portland'] + wv['Seattle'] = ?

理想情况下,你希望这个数学运算(词向量推理)给你这个结果:

wv['Seattle_Sounders']

但更常见的是,标准化测试会使用英语词汇并提出不那么有趣的问题,比如:

WALK : LEGS :: ? : MOUTH

所有那些记不清的词语问题对词嵌入来说都是小菜一碟。

词嵌入可以用来回答这些模糊的问题和类比问题,它们能帮助你记住任何你“舌尖上的词语”,只要该词语的向量在你的词汇中存在。(对于Google的预训练Word2Vec模型,你的词语几乎肯定会出现在Google用于训练的1000亿词的新闻数据中,除非你的词语是在2013年之后创建的。)词嵌入也非常适用于那些你无法以搜索查询或类比形式提出的问题。你可以在6.3节中了解与词嵌入相关的一些数学内容。

6.2.4 Word2Vec创新

词语在大脑中相互靠近使用,它们会积累在一起,最终在大脑神经元的连接中定义这些词语的意义。当你还是个幼儿时,你听到别人谈论像足球、消防车、计算机和书籍之类的东西,逐渐地,你会弄明白它们每一个是什么。令人惊讶的是,你的机器并不需要身体或大脑,就能像一个幼儿一样理解词语。

一个孩子可以通过几次展示现实世界的物体或一本图画书来学习一个词。孩子永远不需要读字典或同义词词典,就像孩子一样,机器在没有字典、同义词词典或任何其他监督学习数据集的情况下也能学会。机器甚至不需要看到物体或图片。机器完全是通过你解析文本并设置数据集来自我监督。你所需要的只是大量的文本。

在前几章中,你可以忽略一个词的周围上下文。你所需要做的只是计算一个词在同一文档中的使用次数。事实证明,如果你使你的文档非常非常简短,这些词语共现的计数就变得有用,能够表示词语本身的意义。这就是Tomas Mikolov和他的Word2Vec NLP算法的关键创新。John Rupert Firth曾普及了“一个词的意义由它的搭档决定”的概念。但要使词嵌入变得有用,需要Tomas Mikolov聚焦于一个非常小的“词语公司”,以及21世纪计算机的计算能力和大量可机器读取的文本语料库。你不需要字典或同义词词典来训练词嵌入;你只需要大量的文本。

这就是你将在本章中做的:像幼儿一样教机器成为一块海绵。你将帮助机器弄明白词语的意义,而无需明确地标注词语的字典定义。你所需要的只是从任何随机的书籍或网页中提取的随机句子。一旦你对这些句子进行分词和切分(你在前几章学会了如何做),你的NLP管道每次读取一批新句子时,它会变得越来越聪明。

在第2章和第3章中,你将词语与其邻居隔离,只关心它们是否在每个文档中出现。你忽略了一个词的邻居对其意义的影响,以及这些关系如何影响整体语句的意义,我们的词袋(BOW)概念将每个文档中的所有词汇混在一起,形成一个统计词袋。在本章中,你将创建更小的词袋,只包含少量词语的“邻域”,通常少于10个标记。你还将确保这些邻域有边界,防止词语的意义溢出到相邻的句子中。这一过程将帮助你的词嵌入语言模型专注于彼此最相关的词语。

词嵌入可以帮助你识别同义词、反义词或属于同一类别的词语,如人、动物、地方、植物、名称或概念。我们之前在第4章中通过语义分析就能做到这一点,但通过更加严格限制词语的邻域,你将看到词嵌入的准确度得到了提升。LSA(潜在语义分析)虽然能处理词语、n-gram和文档,但并没有捕捉到词语的所有字面意义,更不用说隐含或潜在的意义了。词语的某些内涵对于LSA的庞大词袋来说更加模糊。

词嵌入的密度和高(但不是太高)维度是其强大功能和局限性的来源。这也是为什么密集的高维嵌入在与你的管道中的稀疏超维TF-IDF向量或离散词袋向量一起使用时最为有价值。

6.2.5 人工智能依赖于词嵌入

词嵌入在自然语言理解的准确度上是一次巨大的进步,但它们也是人工通用智能(AGI)希望的一次突破。你认为你能分辨机器发出的智能和非智能信息吗?这可能不像你想的那么明显。即使是大科技公司的深度大脑,也被2023年最新和最强大的聊天机器人Bing和Bard的出人意料的无智回答给愚弄了。像You.com和Phind.com这样的更简单、更真实的对话搜索工具,在大多数互联网研究任务中超越了大科技公司的搜索。

哲学家Douglas Hofstadter指出,衡量智能时有几个方面需要注意:

  • 灵活性
  • 处理模糊性
  • 忽略无关细节
  • 寻找相似性和类比
  • 生成新想法

你很快就会看到,词嵌入如何在你的软件中实现这些智能方面。例如,词嵌入使得通过赋予词语模糊性和细微差别来灵活回应成为可能,这是之前的表示方法(如TF-IDF向量)无法做到的。在你之前的聊天机器人版本中,如果你想让你的机器人在回应常见问候时具有灵活性,你必须列举所有可能的问候方式。

但有了词嵌入,你可以用一个单一的嵌入向量来识别“hi”、“hello”和“yo”的含义。而且,你可以为你的机器人可能遇到的所有概念创建词嵌入,只需输入尽可能多的文本。你不再需要手工制作词汇表了。

警告:像词嵌入一样,智能本身是一个高维概念。这使得AGI成为一个难以捉摸的目标。小心不要让你的用户或老板认为你的聊天机器人是通用智能的,即使它看起来能达到Hofstadter所说的“基本元素”。

6.3 Word2Vec

2012年,微软的实习生Tomas Mikolov找到了一种将词语的意义嵌入到向量空间的方法。词嵌入或词向量通常有100到500维,具体取决于用于训练它们的语料库中的信息广度。Mikolov训练了一个神经网络来预测目标词周围的词汇出现情况。Mikolov使用了一个包含单个隐藏层的网络,因此几乎任何线性机器学习模型也能工作。逻辑回归、截断SVD、线性判别分析或朴素贝叶斯都能很好地工作,并且被其他人成功使用来复现Mikolov的结果。2013年,在Google,Mikolov和他的团队发布了用于创建这些词向量的软件,称之为Word2Vec。

Word2Vec语言模型仅通过处理大量无标签文本来学习词语的意义。无需人为标注Word2Vec词汇中的词语。无需告诉Word2Vec算法“玛丽·居里”是科学家,“Timbers”是足球队,“西雅图”是城市,或者“波特兰”是俄勒冈州和缅因州的城市。也无需告诉Word2Vec“足球”是一项运动,“团队”是由一群人组成的,“城市”既是地方也是社区。Word2Vec可以自己学会这一切,甚至更多!你所需要的只是一份足够大的语料库,其中提到了“玛丽·居里”、“Timbers”和“波特兰”,并且这些词与科学、足球或城市等相关的其他词汇一起出现。

Word2Vec的无监督性质使它如此强大。这个世界充满了无标签、无分类和无结构的自然语言文本。

无监督学习和监督学习是两种截然不同的机器学习方法。在监督学习中,一个人或一组人必须为目标变量标注数据的正确值。相比之下,无监督学习使机器能够直接从数据中学习,而无需任何人工的帮助。训练数据不需要由人进行组织、结构化或标注。你可以在附录D中了解更多关于监督学习和无监督学习的内容。

像Word2Vec这样的无监督学习算法非常适合自然语言文本。你无需直接训练神经网络去学习目标词的意义(基于这些意义的标签),你可以教会网络预测目标词在句子中的邻近词。所以,从这个意义上讲,你确实有标签:你想要预测的邻近词。但因为这些标签来自数据集本身,并不需要手工标注,因此Word2Vec的训练算法确实是一个无监督学习算法。

预测本身并不是Word2Vec能工作的原因——它仅仅是一个手段。你真正关心的是Word2Vec逐渐建立起来的内部表示,即向量,这些向量帮助它生成这些预测。这个表示将捕捉到目标词(其语义)的更多含义,远远超过第4章中LSA和潜在狄利克雷分配(LDiA)生成的词主题向量。

注意

通过尝试使用低维内部表示重新预测输入来学习的模型被称为自编码器。这可能对你来说有点奇怪。这个过程就像要求机器回显你刚刚问的内容,只是它不能在你说问题时把问题写下来。机器必须将你的问题压缩成简写,并且必须使用相同的简写算法(函数)来处理你问的所有问题。机器学习你声明的内容的新简写(向量)表示。

如果你想了解更多关于创建高维对象(如词语)压缩表示的无监督深度学习模型,可以搜索自编码器(autoencoder)。它们也是开始学习神经网络的常见方法,因为它们几乎可以应用于任何数据集。

Word2Vec将学习一些你可能不会想到与所有词语相关的东西。你知道每个词语都有一些与地理、情感(积极性)和性别相关的属性吗?如果你语料库中的任何词语具有某些特征,如地方性、人物性、概念性或女性性,那么所有其他词语也将被赋予这些特征的分数,这些分数将反映在它们的词向量中。当Word2Vec学习词向量时,词语的意义会影响到邻近词语。

语料库中的所有词语将通过数值向量来表示,类似于第4章中讨论的词主题向量。只不过这一次,主题的意义更加具体、更精确。在LSA中,词语只要出现在同一文档中,它们的意义就会相互影响并被合并到它们的词主题向量中。而对于Word2Vec词向量,词语必须彼此接近,通常相距不超过五个词,并且出现在同一句话中。此外,可以通过加减词向量的主题权重来创建新的词向量,这些新词向量会有新的意义!

理解词向量的一个有用的心理模型是将词向量看作是一组权重或分数。每个权重或分数与该词的特定含义维度相关,如下所示:

示例6.1 计算nessvector

>>> from nessvec.examples.ch06.nessvectors import *    #1
>>> nessvector('Marie_Curie').round(2)
placeness     -0.46
peopleness     0.35     #2
animalness     0.17
conceptness   -0.32
femaleness     0.26
#1 不要导入这个模块,除非你有大量的RAM和时间。预训练的Word2Vec模型非常庞大。
#2 可以创意地使用你找到的nessvec维度,如“trumpness”或“ghandiness”。那怎样创建一个nessvec PR呢?

你可以使用来自nlpia的工具计算任何词或n-gram的nessvector(gitlab.com/tangibleai/…)。这种方法可以用于评分你能够想到的任何“ness”组件,包括人物性、动物性、地方性、物品性,甚至是概念性。而一个词嵌入将所有这些分数组合成一个密集的向量(没有零值),由浮动的值组成。

Mikolov在尝试以向量的形式数值表示词语时开发了Word2Vec算法。他对第4章中你做的词语情感数学表示不满意;他希望做类比推理,就像你在上一节的类比问题中所做的那样。这个概念可能听起来很复杂,但其实它只是意味着你可以用词向量做数学运算,并且当你将这些向量转换回词语时,答案是合理的。

6.3.1 类比推理

Word2Vec首次在2013年于ACL会议上公开展示。这个听起来枯燥的标题为“连续空间词表示中的语言规律性”的演讲,描述了一个出乎意料地准确的语言模型。Word2Vec嵌入在回答类比问题(如本章前面讨论的那些)时,准确率比等效的LSA模型高出四倍(45%比11%)。事实上,准确率的提升如此惊人,以至于Mikolov的初稿被国际学习表示会议拒绝了。评审认为该模型的表现太好,简直不可能是真的。Mikolov的团队花了将近一年时间才发布源代码,并被计算语言学协会接受。突然间,借助词向量,像下面这样的类比问题可以通过向量代数来解决(见图6.2):

Portland Timbers + Seattle - Portland = ?

image.png

Word2Vec语言模型“知道”Portland和Portland Timbers这两个词之间的距离大致与Seattle和Seattle Sounders之间的距离相同,并且每对词语之间的向量位移大致在同一方向上。这意味着word2vec模块可以用来回答你的体育队伍类比问题。你可以将Portland和Seattle之间的差异加到表示Portland Timbers的向量上,这样就可以接近Seattle Sounders的向量。这个玩具问题的数学公式见方程6.1。

image.png

在加减词向量后,结果向量几乎从不完全等于词向量词汇表中的任何一个向量。Word2Vec词向量通常有数百个维度,每个维度都有连续的实数值。尽管如此,与你的结果最接近的词向量往往就是你问题的答案。与该附近向量相关的英文词就是你关于体育队伍和城市的问题的自然语言答案。

Word2Vec允许你将自然语言中的词频和出现次数向量转换为维度更低的Word2Vec向量空间。在这个低维空间中,你可以进行数学运算,然后再将它们转换回自然语言空间。你可以想象,这种能力对于聊天机器人、搜索引擎、问答系统或信息提取算法有多么有用。

注意
Mikolov和他的同事在2013年发布的初始论文中,仅能实现40%的答案准确率,而当时,这一准确率大大超过了任何其他语义推理方法。自从最初发布以来,Word2Vec的性能得到了提升,这是因为它在极为庞大的语料库上进行了训练——参考实现使用了来自Google新闻语料库的1000亿词数据。这就是你将在本书中看到的预训练模型。

研究团队还发现,单数词和复数词之间的差异通常具有相似的幅度,并且方向相同。你可以在方程6.2中看到如何计算这个复数化向量。

image.png

当你从“coffee”的向量中减去“coffees”的向量时,你应该得到一个表示“coffee”复数形式的向量。在那些复数化对名词理解具有一致性影响的语言中,这个复数化向量应该与“cup”、“cookie”或任何其他名词的复数化向量相似。当Tomas Mikolov(以及NLP界)首次计算出这个复数化向量并发现它确实在他尝试的数千个名词中保持一致时,那是一个真正的“恍然大悟”的时刻。没过多久,Mikolov和其他人发现,大多数词语类比也可以通过相同的方式进行计算。这是类比推理任务第一次能够通过向量数学来计算,而这种计算对于NLP工程师来说是直观的。将词汇和词语嵌入到向量空间的能力,已经成为过去十年中催生NLP和AI爆炸性发展的基础性计算技巧。今天使用的即使是最大型的语言模型,也依赖于与2013年Tomas Mikolov首次创建的词嵌入向量类似的技术。

更多使用词向量的理由

词语的向量表示不仅对推理和类比问题有用,还可以用于你使用自然语言向量空间模型的所有其他任务。从模式匹配到建模和可视化,如果你知道如何使用本章介绍的词向量,你的NLP管道的准确性和实用性将得到提升。

例如,在本章稍后,我们将向你展示如何在2D语义图上可视化词向量,如图6.3所示。你可以将其视为一个流行旅游目的地的卡通地图,或者你在公交车站海报上看到的那种印象派地图。在这些卡通地图中,语义和地理上接近的事物会被紧密地放在一起。对于卡通地图,艺术家调整不同地点图标的比例和位置,以匹配该地方的感觉。而对于词向量,机器也能够感知词语和地点,以及它们应该有多远。因此,你的机器将能够使用本章中学习到的词向量,生成像图6.3中的印象派地图。

image.png

如果你熟悉这些美国城市,你可能会意识到这并不是一个准确的地理地图,但它是一个相当不错的语义地图。我们有时会混淆德州的两大城市,休斯顿和达拉斯,它们的词向量几乎是相同的。而加利福尼亚州的大城市的词向量形成了一个不错的文化三角形。

词向量也非常适用于聊天机器人和搜索引擎。对于这些应用,词向量可以帮助克服模式匹配和关键词匹配中的一些僵化和脆弱性。虽然基于字符的模式无法理解“tell me about a Denver omelet”和“tell me about the Denver Nuggets”之间的区别,但基于词向量的模式可以。基于词向量的模式很可能能够区分食物项目(omelet)和篮球队(Nuggets),并根据用户提问的内容做出适当的回应。

6.3.2 学习词嵌入

词嵌入是表示词语意义(语义)的向量;然而,词语的意义是一个难以捉摸、模糊的东西。一个孤立的单个词语具有非常模糊的意义。以下是一些可能影响词语意义的因素:

  • 通过这个词语传达的是谁的思想
  • 词语面向的观众是谁
  • 词语使用的上下文(何时何地)
  • 假设的领域知识或背景知识
  • 词语的预期意义

你的大脑可能会以与我们不同的方式理解一个词语,并且大脑中词语的意义会随着时间的推移而变化。随着你建立与其他概念的新连接,你会学习到词语的新含义;随着你学习新概念和新词语,你会根据这些新词语对大脑的印象,学习到与这些新词语的联系。嵌入用于表示大脑中由新词语产生的神经连接的这种不断演变的模式,这些新的向量有数百个维度。

考虑一下一个小女孩说“我的妈妈是医生”的情景。想象一下“医生”这个词对她意味着什么,然后思考当她长大后,她对这个词的理解、她的自然语言理解(NLU)算法是如何变化的。随着时间的推移,她会学会区分医学博士(MD)和哲学博士(PhD)。想象一下,几年后,当她自己开始考虑申请医学院或博士项目时,这个词对她意味着什么。再想象一下这个词对她母亲——医生——意味着什么。最后,想象一下这个词对那些没有接受医疗服务机会的人的意义。

创建有用的词语数值表示是很棘手的。你想要编码或嵌入到向量中的意义,不仅取决于你想要表示谁的意义,还取决于你希望机器在何时何地处理并理解该意义。在GloVe、Word2Vec和其他早期的词嵌入中,目标是表示“平均”或最常见的意义。创建这些表示的研究人员专注于类比问题和其他衡量人类和机器理解词语的基准测试。例如,我们在本章早些时候使用了预训练的fastText词嵌入进行代码片段的处理。

提示

有些预训练的词向量表示可用于像维基百科、DBpedia、X(前身为Twitter)和Freebase等语料库。这些预训练模型是你进行词向量应用的好起点:

  • Google提供了一个基于英语Google新闻文章的预训练Word2Vec模型。
  • Facebook发布了他们的词模型,称为fastText,支持294种语言。

幸运的是,一旦你确定了词嵌入的受众或用户,你只需要收集这些词的使用示例即可。Word2Vec、GloVe和fastText都是无监督学习算法。你所需要的只是来自你和用户感兴趣领域的一些原始文本。例如,如果你主要关注医学医生,你可以在医学期刊的文本集合上训练你的嵌入。或者,如果你想要最通用的词语理解,机器学习工程师通常使用维基百科和在线新闻文章来捕捉词语的意义。毕竟,维基百科代表了我们对世界上所有事物的集体理解。

现在你已经有了语料库,如何为你的词嵌入语言模型创建训练集呢?在早期,主要有两种方法:

  • 连续词袋(CBOW)
  • 连续跳字模型(Skip-gram)

连续词袋(CBOW)方法根据附近上下文词(输入词)来预测目标词(输出词)。CBOW与第3章中学到的词袋(BOW)向量的唯一区别在于,CBOW是为每个文档中连续滑动的词窗口创建的。这意味着,你将会有几乎与所有文档中词语序列中词数相同的CBOW向量,而对于BOW向量,每个文档只有一个向量。这为你的词嵌入训练集提供了更多的信息,因此将生成更准确的嵌入向量。使用CBOW方法,你可以从原始文档中提取的每一个短语中创建大量的小型合成文档。

image.png

跳字模型(Skip-gram)方法

使用跳字模型(skip-gram)方法,你同样会创建大量的合成文档,唯一的不同是你反转了预测目标,因此你是用CBOW的目标来预测CBOW的特征。跳字模型根据句子中某个词前后的词来预测被跳过的词。尽管看起来你的词对是反过来的,但很快你会发现,结果在数学上几乎是等价的。图6.4和图6.5分别展示了如何设置神经网络来学习CBOW和跳字模型的词向量。

image.png

你可以看到,跳字模型(skip-gram)和连续词袋(CBOW)方法都产生了相同数量的训练示例。在跳字模型的训练方法中,你预测的是“上下文词”周围的词。假设你的语料库包含了Bayard Rustin和Larry Dane Brimner对个人主义的深刻拒绝:

“我们都是一体的。如果我们不明白这一点,我们将以艰难的方式发现这一点。” — Bayard Rustin

重要提示
跳字模型是一个2-gram或词对,每个词都位于另一个词的“邻域”内。像往常一样,gram可以是你的分词器所设计的任何文本片段——通常是单词。

对于连续跳字模型的训练方法,跳字是词对,词对之间跳过零到四个词来创建跳字对。在使用Word2Vec的跳字方法训练词嵌入时,跳字中的第一个词被称为上下文词——即Word2Vec神经网络的输入。跳字对中的第二个词通常被称为目标词——即语言模型和嵌入向量被训练预测的词,或者说是输出。在图6.6中,你可以看到跳字方法创建词嵌入时神经网络架构的样子。

image.png

什么是Softmax

Softmax函数通常作为神经网络输出层的激活函数,当网络的目标是学习分类问题时使用。Softmax会将输出结果压缩到0和1之间,并且所有输出节点的和始终为1。这样,带有Softmax函数的输出层的结果可以被视为概率。

对于每个k个输出节点,神经元的Softmax输出值可以使用归一化的指数函数来计算:

image.png

三神经元输出层的输出向量将类似于这样的三维列向量:

image.png

然后,经过Softmax激活后的“压缩”向量将如下所示(见方程6.5)。注意,向量中值的排名顺序没有变化,但它们的总和现在等于1。

image.png

请注意,这些值的总和(四舍五入到三位有效数字)大约为1.0,像概率分布一样。

图6.7展示了前两个周围词的数值网络输入和输出。在这个例子中,输入词是Monet,网络的预期输出是Claude或painted,具体取决于训练对。在这个图中,你可以看到一些理想化的示例值,这些值用于训练一个使用跳字方法的Word2Vec神经网络,训练对是Monet和Claude,其中Claude是被跳过的目标词。在左侧是表示Monet词语的一热编码输入向量,所有其他位置(如1806或Claude的词语)都是零。右侧是连续密集的Softmax输出向量,目标词Claude的值接近1。

image.png

6.3.3 学习意义而无需字典

对于这个Word2Vec训练示例,你无需使用字典(如wiktionary.org)来显式定义词语的意义。相反,你可以让Word2Vec读取包含有意义句子的文本。你将使用PyTorch中的WikiText2语料库,位于torchtext包中:

>>> import torchtext
>>> dsets = torchtext.datasets.WikiText2()
>>> num_texts = 10000
>>> filepath = DATA_DIR / f'WikiText2-{num_texts}.txt'
>>> with open(filepath, 'wt') as fout:
...     fout.writelines(list(dsets[0])[:num_texts])

为了让这个过程更加清晰,你可以查看刚刚创建的文本文件,其中包含约10,000个段落,来自WikiText2数据集:

>>> !tail -n 3 ~/nessvec-data/WikiText2-10000.txt

When Marge leaves Dr. Zweig 's office , she says ,
" Whenever the wind whistles through the leaves ,
I 'll think , Lowenstein , Lowenstein … " .
This is a reference to The Prince of Tides ; the <unk> is Dr. Lowenstein .

= = Reception = =

第99,998个段落恰好包含了“Dr.”的缩写,表示“doctor”一词。你可以使用这个来练习你的“妈妈是医生”的直觉训练器。你很快就会发现Word2Vec是否能够学习“医生”真正的意义,或者它是否会被使用“Dr.”表示街道地址中的“drive”而感到困惑。

方便的是,WikiText2数据集已经为你将文本分词成了词。词语是通过单个空格字符(“ ”)分隔的,因此你的管道无需决定“Dr.”是否是句子的结尾。如果文本没有被分词,你的NLP管道需要从每个句子的结尾去除标点符号。即使是标题分隔符“==”也已被拆分为两个独立的标记“=”和“=”,段落之间则用换行符("\n")分隔。许多“段落”也将为维基百科的标题创建,例如“== Reception ==”,并且所有段落之间的空行将被保留。

你可以使用句边界检测器或句子分割器,例如spaCy,将段落分割为句子。这样可以防止你的词对训练集跨句子溢出。遵循句子边界使用Word2Vec可以提高词嵌入的准确性,但是否需要这个额外的准确度提升,你可以自行决定。

一个关键的基础设施是你的管道如何处理大规模语料库的内存管理。如果你正在训练数百万段落的词嵌入,你将需要使用一个数据集对象来管理磁盘上的文本,只加载到RAM或GPU中的必要部分。Hugging Face Hub的datasets包可以为你处理这个问题:

>>> import datasets
>>> dset = datasets.load_dataset('text', data_files=str(filepath))
>>> dset
DatasetDict({
    train: Dataset({
        features: ['text'],
        num_rows: 10000
    })
})

但是,你仍然需要告诉Word2Vec什么是词语。这是你需要关注的唯一“监督”Word2Vec数据集,你可以使用第2章中最简单的分词器来取得良好的结果。对于这个通过空格分隔的分词文本,你可以使用str.split()方法,利用str.lower()进行大小写折叠,减少词汇表的大小。令人惊讶的是,这足以让Word2Vec学习词语的意义和内涵,足以应对像SAT这样的类比问题,甚至推理现实世界中的物体和人物:

def tokenize_row(row):
    row['all_tokens'] = row['text'].lower().split()
    return row

现在,你可以在包含WikiText2数据的torchtext数据集上使用分词器,该数据集包含一个可迭代的行数据序列,每一行都有一个文本键:

>>> dset = dset.map(tokenize_row)
>>> dset

DatasetDict({
    train: Dataset({
        features: ['text', 'tokens'],
        num_rows: 10000
    })
})

你需要计算数据集的词汇表来处理神经网络的独热编码和解码:

>>> vocab = list(set(
...     [tok for row in dset['train']['tokens'] for tok in row]))
>>> vocab[:4]
['cast', 'kaifeng', 'recovered', 'doctorate']

>>> id2tok = dict(enumerate(vocab))
>>> list(id2tok.items())[:4]
[(0, 'cast'), (1, 'kaifeng'), (2, 'recovered'), (3, 'doctorate')]

>>> tok2id = {tok: i for (i, tok) in id2tok.items()}
>>> list(tok2id.items())[:4]
[('cast', 0), ('kaifeng', 1), ('recovered', 2), ('doctorate', 3)]

剩下的特征工程步骤是通过窗口化词序列来创建跳字对,然后在这些窗口中配对跳字:

WINDOW_WIDTH = 10

>>> def windowizer(row, wsize=WINDOW_WIDTH):
    """ Compute sentence (str) to sliding-window of skip-gram pairs. """
...    doc = row['tokens']
...    out = []
...    for i, wd in enumerate(doc):
...        target = tok2id[wd]
...        window = [
...            i + j for j in range(-wsize, wsize + 1, 1)
...            if (i + j >= 0) & (i + j < len(doc)) & (j != 0)
...        ]

...        out += [(target, tok2id[doc[w]]) for w in window]
...    row['moving_window'] = out
...    return row

一旦你将windowizer应用到数据集,它就会有一个窗口键,其中存储了词的窗口:

>>> dset = dset.map(windowizer)
>>> dset
DatasetDict({
    train: Dataset({
        features: ['text', 'tokens', 'window'],
        num_rows: 10000
    })
})

这是你的跳字生成函数:

>>> def skip_grams(tokens, window_width=WINDOW_WIDTH):
...    pairs = []
...    for i, wd in enumerate(tokens):
...        target = tok2id[wd]
...        window = [
...            i + j for j in
...            range(-window_width, window_width + 1, 1)
...            if (i + j >= 0)
...            & (i + j < len(tokens))
...            & (j != 0)
...        ]

...        pairs.extend([(target, tok2id[tokens[w]]) for w in window])
    # huggingface datasets are dictionaries for every text element
...    return pairs

你的神经网络只需要跳字对的配对数据:

>>> from torch.utils.data import Dataset

>>> class Word2VecDataset(Dataset):
...    def __init__(self, dataset, vocab_size, wsize=WINDOW_WIDTH):
...        self.dataset = dataset
...        self.vocab_size = vocab_size
...        self.data = [i for s in dataset['moving_window'] for i in s]
...
...    def __len__(self):
...        return len(self.data)
...
...    def __getitem__(self, idx):
...        return self.data[idx]

你的DataLoader将为你处理内存管理。这样可以确保你的管道对几乎任何大小的语料库都是可重用的,甚至是整个维基百科:

from torch.utils.data import DataLoader

dataloader = {}
for k in dset.keys():
    dataloader = {
        k: DataLoader(
            Word2VecDataset(
                dset[k],
                vocab_size=len(vocab)),
            batch_size=BATCH_SIZE,
            shuffle=True,
            num_workers=CPU_CORES - 1)
    }

你需要一个独热编码器来将你的词对转换为独热向量对:

def one_hot_encode(input_id, size):
    vec = torch.zeros(size).float()
    vec[input_id] = 1.0
    return vec

为了揭开你之前看到的示例中的一些神秘面纱,你将从头开始训练神经网络,就像你在第5章所做的那样。你可以看到,Word2Vec神经网络几乎与前一章的单层神经网络相同:

from torch import nn
EMBED_DIM = 100          #1

class Word2Vec(nn.Module):
    def __init__(self, vocab_size=len(vocab), embedding_size=EMBED_DIM):
        super().__init__()
        self.embed = nn.Embedding(vocab_size, embedding_size)           #2
        self.expand = nn.Linear(embedding_size, vocab_size, bias=False)

    def forward(self, input):
        hidden = self.embed(input)          #3
        logits = self.expand(hidden)    #4
        return logits
#1 100 is small but usable for many problems, while 300 is more typical.
#2 Initializes the layers of your network only once, when you instantiate the Word2Vec object
#3 The hidden layer embeds (encodes) the statistics of word usage in a lower-dimensional vector.
#4 The output layer expands (decodes) the 100Δ hidden layer to predict one-hot vectors.

一旦你实例化了Word2Vec模型,你就可以为词汇表中超过20,000个词语创建100维的嵌入:

>>> model = Word2Vec()
>>> model

Word2Vec(
  (embed): Embedding(20641, 100)
  (expand): Linear(in_features=100, out_features=20641, bias=False)
)

如果你有GPU,你可以将模型发送到GPU,以加快训练速度:

>>> import torch
>>> if torch.cuda.is_available():
...     device = torch.device('cuda')
>>> else:
...     device = torch.device('cpu')
>>> device

device(type='cpu')

即使没有GPU,也不用担心。在大多数现代CPU上,这个Word2Vec模型将在不到15分钟内完成训练:

>>> model.to(device)

Word2Vec(
  (embed): Embedding(20641, 100)
  (expand): Linear(in_features=100, out_features=20641, bias=False)
)

现在是最有趣的部分!你将看到Word2Vec如何通过快速学习“Dr.”和成千上万个其他词语的意义,方式就是阅读大量的文本。你可以去喝杯茶,吃点巧克力,或者进行10分钟的冥想,思考生活的意义,同时你的笔记本电脑正在思考词语的意义。首先,让我们定义一些训练参数:

>>> from tqdm import tqdm  # noqa
>>> EPOCHS = 10
>>> LEARNING_RATE = 5e-4
EPOCHS = 10
loss_fn = nn.CrossEntropyLoss()
optimizer = torch.optim.AdamW(model.parameters(), lr=LEARNING_RATE)
running_loss = []
pbar = tqdm(range(EPOCHS * len(dataloader['train'])))

接下来是训练过程:

for epoch in range(EPOCHS):
    epoch_loss = 0
    for sample_num, (center, context) in enumerate(dataloader['train']):
        if sample_num % len(dataloader['train']) == 2:
            print(center, context)
            # center: tensor([ 229,    0, 2379,  ...,  402,  553,  521])
            # context: tensor([ 112, 1734,  802,  ...,   28,  852,  363])
        center, context = center.to(device), context.to(device)
        optimizer.zero_grad()
        logits = model(input=context)
        loss = loss_fn(logits, center)
        if not sample_num % 10000:
            # print(center, context)
            pbar.set_description(f'loss[{sample_num}] = {loss.item()}')
        epoch_loss += loss.item()
        loss.backward()
        optimizer.step()
        pbar.update(1)
    epoch_loss /= len(dataloader['train'])
    running_loss.append(epoch_loss)

save_model(model, loss)

6.3.4 使用 gensim.word2vec 模块

如果前一节听起来太复杂,不用担心。各种公司提供了预训练的词向量模型,流行的NLP库也允许你高效地使用这些预训练模型。在以下部分中,我们将看看如何利用词向量的魔力。对于词向量,你将使用流行的gensim库。

要下载模型,你可以在Google上搜索预训练的word2vec模型,这些模型是基于Google新闻文档训练的。找到并下载Google原始二进制格式的模型后,将其放置在本地路径中,然后可以使用gensim包加载它:

>>> from gensim.models.keyedvectors import KeyedVectors
>>> word_vectors = KeyedVectors.load_word2vec_format(\
...     '/path/to/GoogleNews-vectors-negative300.bin.gz', binary=True)

处理词向量可能会占用大量内存。如果你的可用内存有限,或者你不想等待几分钟才能加载词向量模型,你可以通过传入limit关键字参数来减少加载到内存中的词语数量。在以下示例中,你将从Google新闻语料库中加载最常见的200,000个词:

>>> from gensim.models.keyedvectors import KeyedVectors
>>> from nlpia2.loaders import get_data
>>> word_vectors = get_data('w2v', limit=200000)     #1
#1 这样可以通过只加载200,000个词(在200万个Word2Vec向量中的一部分)来限制内存占用。

但是请记住,具有有限词汇量的词向量模型会导致你的NLP管道性能下降,如果你的文档包含你没有加载词向量的词汇。因此,你可能只希望在开发阶段限制词向量模型的大小。对于本章的其他示例,如果你希望获得我们在这里展示的相同结果,你应该使用完整的Word2Vec模型。

gensim的KeyedVectors.most_similar()方法提供了一种高效的方式来查找任何给定词向量的最近邻。positive关键字参数接受一个词向量列表,这些词向量将被加在一起,类似于本章开头的足球队例子。类似地,你可以使用negative参数进行减法运算,排除不相关的词汇。topn参数决定了返回多少个相关词。

与传统的同义词词典不同,Word2Vec的同义词(相似性)是一个连续的分数,一个距离。这是因为Word2Vec本身是一个连续的向量空间模型。Word2Vec的高维度和每个维度的连续值使其能够捕捉任何给定词语的完整意义范围。这就是为什么类比,甚至是zeugmas(同一个词的多个含义的奇特并列),都不是问题。处理类比和zeugmas是非常重要的;这需要对世界有类似人类的理解,包括常识知识和推理能力。词嵌入足以让机器至少具备通过SAT等类比题的基本理解:

>>> word_vectors.most_similar(positive=['cooking', 'potatoes'], topn=5)
[('cook', 0.6973530650138855), ('oven_roasting', 0.6754530668258667), ('Slow_cooker', 0.6742032170295715), ('sweet_potatoes', 0.6600279808044434), ('stir_fry_vegetables', 0.6548759341239929)]

>>> word_vectors.most_similar(positive=['germany', 'france'], topn=1)
[('europe', 0.7222039699554443)]

词向量模型还允许你确定不相关的词汇。gensim库提供了一个名为doesnt_match的方法:

>>> word_vectors.doesnt_match("potatoes milk cake computer".split())
'computer'

要确定列表中最不相关的词,该方法返回与所有其他词的距离最大的词。

如果你想进行计算(例如著名的例子king + woman × man = queen,这是当初让Mikolov和他的导师激动的例子),你可以在most_similar方法调用中添加负向参数:

>>> word_vectors.most_similar(positive=['king', 'woman'],
...     negative=['man'], topn=2)
[('queen', 0.7118192315101624), ('monarch', 0.6189674139022827)]

gensim库还允许你计算两个词之间的相似度。如果你想比较两个词并确定它们的余弦相似度,可以使用similarity()方法:

>>> word_vectors.similarity('princess', 'queen')
0.70705315983704509

如果你想开发自己的函数并处理原始词向量,你可以通过Python的方括号语法([])或KeyedVector实例的get()方法来访问它们。你可以将加载的模型对象视为一个字典,其中你感兴趣的词是字典的键。返回的数组中的每个浮动值代表其中一个向量维度。以Google的词模型为例,你的NumPy数组将具有1×300的形状:

>>> word_vectors['phone']
array([-0.01446533, -0.12792969, -0.11572266, -0.22167969, -0.07373047,
       -0.05981445, -0.10009766, -0.06884766,  0.14941406,  0.10107422,
       -0.03076172, -0.03271484, -0.03125   , -0.10791016,  0.12158203,
        0.16015625,  0.19335938,  0.0065918 , -0.15429688,  0.03710938,
        ...

如果你想知道这些数字的含义,可以进行探索,但这需要很多工作。你需要检查一些同义词,看看它们在数组中的300个数字中共享哪些数字。或者,你可以找到这些数字的线性组合,这些组合构成了像地方性和女性性这样的维度,就像你在本章开头做的那样。

6.3.5 生成你自己的词向量表示

在某些情况下,你可能希望创建自己的特定领域词向量模型。如果你的NLP管道正在处理的文档中使用的词汇与你在Google新闻中看到的内容不同,尤其是在2006年Mikolov训练了参考Word2Vec模型之前,这样做可以提高模型的准确性。请记住,你需要大量的文档来做到这一点,正如Google和Mikolov所做的那样。但是,如果你的词语在Google新闻中比较罕见,或者你的文本在一个特定领域内以独特的方式使用它们(例如医学文本或记录),一个特定领域的词模型可能会提高你的模型准确性。在接下来的部分中,我们将展示如何训练自己的Word2Vec模型。为了训练一个特定领域的Word2Vec模型,你将再次使用gensim库,但在开始训练模型之前,你需要使用第2章中学到的工具对你的语料库进行预处理。

预处理步骤

首先,你需要将文档分割成句子,然后将句子分割成标记。gensim.word2vec模块期望接收一个句子列表,每个句子被分解为标记。这可以防止词向量学习中无关词语的出现,这些无关词语可能出现在相邻的句子中。你的训练输入应该看起来像以下结构:

>>> token_list
[  ['to', 'provide', 'early', 'intervention/early', 'childhood', 'special',   'education', 'services', 'to', 'eligible', 'children', 'and', 'their',   'families'],
  ['essential', 'job', 'functions'],
  ['participate', 'as', 'a', 'transdisciplinary', 'team', 'member', 'to',   'complete', 'educational', 'assessments', 'for']
  ...
]

为了将句子分割成标记,你可以应用你在第2章中学到的各种策略。让我们添加另一个:DetectorMorse。DetectorMorse是一个句子分割器,它已经在来自《华尔街日报》多年的文本上进行了预训练,并且在某些应用中,比NLTK和gensim中的分割器更准确。如果你的语料库包含类似《华尔街日报》的语言,DetectorMorse很可能会为你提供目前可能的最高准确度。如果你有来自自己领域的大量句子数据,你还可以重新训练DetectorMorse。一旦你将文档转换为标记列表的列表(每个句子一个列表),你就可以开始Word2Vec训练了。

训练你的特定领域Word2Vec模型

首先,加载word2vec模块:

>>> from gensim.models.word2vec import Word2Vec

以下列表展示了如何设置Word2Vec训练中最重要的参数。

列表6.2 设置Word2Vec模型训练的参数

>>> num_features = 300    #1
>>> min_word_count = 3      #2
>>> num_workers = 2  #3
>>> window_size = 6          #4
>>> subsampling = 1e-3     #5
#1 向量元素的数量(维度),用于表示词向量
#2 Word2Vec模型考虑的最小词频。如果你的语料库很小,可以减少min_word_count,对于较大的语料库则增加该值。
#3 这是用于训练的CPU核心数量。可以使用multiprocessing.cpu_count()函数自动扩展它们。
#4 上下文窗口大小
#5 高频词汇的下采样率

现在,你准备好开始训练了。

列表6.3 实例化Word2Vec模型

>>> model = Word2Vec(
...     token_list,
...     workers=num_workers,
...     size=num_features,
...     min_count=min_word_count,
...     window=window_size,
...     sample=subsampling)

根据你的语料库大小和CPU性能,训练可能需要相当长的时间。对于较小的语料库,训练可以在几分钟内完成,但对于一个完整的词模型,语料库将包含数百万个句子。你需要确保每个词语的不同用法有足够多的示例。如果你开始处理更大的语料库,比如维基百科语料库,预计训练时间会更长,内存消耗也会更大。

此外,Word2Vec模型可能会占用相当多的内存。但请记住,只有隐藏层的权重矩阵才是重要的。一旦你训练完你的词模型,你可以通过冻结模型并丢弃不必要的信息来减少内存占用约一半。以下命令将丢弃神经网络的输出权重:

>>> model.init_sims(replace=True)

init_sims方法将冻结模型,存储隐藏层的权重,并丢弃预测词共现的输出权重。输出权重在大多数Word2Vec应用中并不使用。但一旦丢弃了输出层的权重,模型将不能进一步训练。

你可以使用以下命令保存训练好的模型,并为后续使用保留它:

>>> model_name = "my_domain_specific_word2vec_model"
>>> model.save(model_name)

如果你想测试你新训练的模型,可以使用与前一节中相同的方法:

列表6.4 加载已保存的Word2Vec模型

>>> from gensim.models.word2vec import Word2Vec
>>> model_name = "my_domain_specific_word2vec_model"
>>> model = Word2Vec.load(model_name)
>>> model.most_similar('radiology')

6.4 Word2Vec的替代方案

Word2Vec是一次突破,但它依赖于一个必须通过反向传播训练的神经网络模型。自Mikolov首次推广词嵌入以来,研究人员已经提出了越来越多准确且高效的方法,将词语的意义嵌入到向量空间中:

  • Word2Vec
  • GloVe
  • fastText

斯坦福NLP研究人员(由Jeffrey Pennington领导)着手理解Word2Vec为何如此有效,并试图找到正在优化的成本函数。他们从统计词汇共现开始,将其记录在一个方阵中。通过计算这个共现矩阵的奇异值分解(SVD),他们发现可以将其分解成与Word2Vec生成的相同的两个权重矩阵。关键是要用相同的方式标准化共现矩阵。但在某些情况下,Word2Vec模型未能收敛到斯坦福研究人员通过SVD方法所能达到的全球最优解。正是通过直接优化词语共现的全局向量(跨整个语料库的共现)使得GloVe得名。

6.4.1 GloVe

GloVe可以生成与Word2Vec输入权重矩阵和输出权重矩阵等效的矩阵,生成一个语言模型,其准确性与Word2Vec相同,但所需时间要短得多。这意味着它通过更有效地使用文本数据来加速过程,并且可以在较小的语料库上训练,并且仍能收敛。由于SVD算法已经经过数十年的优化,GloVe在调试和算法优化上具有先发优势。Word2Vec依赖反向传播来更新形成词嵌入的权重,而神经网络的反向传播效率不如SVD在GloVe中的优化算法。

尽管Word2Vec首次推广了使用词向量进行语义推理的概念,但在训练新的词向量模型时,你的主力应该是GloVe。使用GloVe,你更有可能找到这些词向量表示的全局最优解,从而获得更准确的结果。spaCy将其作为默认的嵌入算法,因此当你运行以下代码时,结果会在底层使用GloVe计算:

>>> import spacy
>>> nlp = spacy.load("en_core_web_sm")
>>> text = "This is an example sentence."
>>> doc = nlp(text)
>>> for token in doc:
...    print(token.text, token.vector)

GloVe的优点包括:

  • 更快的训练
  • 更好的RAM/CPU效率(可以处理更大的文档)
  • 更有效地使用数据(有助于小型语料库)
  • 在相同的训练量下更准确

6.4.2 fastText

Facebook的研究人员将Word2Vec的概念更进一步,通过为模型训练添加了一个新 twist。他们命名的新算法叫做fastText,它预测的是周围的n个字符gram,而不仅仅是像Word2Vec那样预测周围的词。例如,词语“whisper”将生成以下2-gram和3-gram:

['wh', 'whi', 'hi', 'his', 'is', 'isp', 'sp', 'spe', 'pe', 'per', 'er']

fastText为每个n字符gram(称为子词)训练一个向量表示,包括单词、拼写错误的单词、部分单词,甚至单个字符。这种方法的优势在于它比原始的Word2Vec方法更好地处理稀有或新出现的单词。

fastText的分词器会为一个较长单词的两个部分创建向量,如果该长单词的使用频率远低于构成它的子词。例如,如果你的语料库中只提到过一次或两次“Superwoman”,但频繁使用“super”和“woman”,fastText可能会为“super”和“woman”分别创建向量。当你的fastText语言模型在训练结束后遇到“Superwoman”时,它会将“super”和“woman”的向量加在一起,创建“Superwoman”的向量。这减少了fastText需要分配通用的“词汇外”(OOV)向量的单词数。对于你的自然语言理解管道,OOV词向量看起来像是未知词,并且与完全陌生语言中的外来词具有相同的效果。虽然Word2Vec只“知道”如何嵌入它之前见过的单词,但由于其子词方法,fastText更加灵活,也相对轻量,运行速度更快。

作为fastText发布的一部分,Facebook发布了支持294种语言的预训练fastText模型。在Facebook研究的GitHub页面,你可以找到从阿布哈兹到祖鲁语的模型集合。该模型集合甚至包括一些稀有语言,如萨特兰弗里西亚语,这种语言仅由少数德国人使用。Facebook提供的预训练fastText模型仅在现有的维基百科语料库上进行了训练;因此,这些模型的词汇和准确性会因语言的不同而有所变化。

我们在nessvec包中加入了fastText的逻辑,用于为OOV单词创建新向量。我们还对fastText管道进行了增强,使用Peter Norvig著名的拼写纠正算法来处理拼写错误和打字错误。这将为你提供双重好处:一种可理解的训练算法和一个强大的推理或预测模型,当你需要在现实世界中使用训练过的向量时。

使用预训练模型为你的NLP加速

通过利用全球最强大公司的开源预训练嵌入,你可以为你的NLP管道注入强大动力。预训练的fastText向量几乎涵盖了所有可能的语言。如果你想查看所有可用的词嵌入选项,可以访问fastText模型库。为了多语言支持,你可以找到支持157种语言的多语言模型,这些语言都包含在fastText嵌入的Common Crawl版本中。如果你愿意,你可以通过fastText页面上的bin+text链接下载每个语言的嵌入版本。但如果你想节省时间,并只下载最受欢迎的100万个词向量,下面是你需要知道的内容。

警告
bin+text wiki.en.zip文件大小为9.6 GB,text-only wiki.en.vec文件大小为6.1 GB。如果你使用nessvec包,而不是gensim,它将只下载大小为600 MB的wiki-news-300d-1M.vec.zip文件。该文件包含来自Wikipedia和新闻网页的100万个最受欢迎的词汇的300D向量(大小写不敏感)。

nessvec包将创建一个内存映射的数据框,保存所有的预训练向量。内存映射文件(.hdf5)可以避免你的计算机内存溢出,因为它会懒加载你所需要的向量,直到你需要它们为止:

>>> from nessvec.files import load_fasttext
>>> df = load_fasttext()                       #1
>>> df.head().round(2)
      0     1     2    ...   297   298   299
,    0.11  0.01  0.00  ...  0.00  0.12 -0.04
the  0.09  0.02 -0.06  ...  0.16 -0.03 -0.03
.    0.00  0.00 -0.02  ...  0.21  0.07 -0.05
and -0.03  0.01 -0.02  ...  0.10  0.09  0.01
of  -0.01 -0.03 -0.03  ...  0.12  0.01  0.02
>>> df.loc['prosocial']                       #2
0      0.0004
1     -0.0328
2     -0.1185
        ...
297    0.1010
298   -0.1323
299    0.2874
Name: prosocial, Length: 300, dtype: float64
#1 This will download data to the $HOME/.nlpia2-data/ directory.
#2 Uses the standard DataFrame API to retrieve any embedding you like

注意
为了加速你的词嵌入管道,你可以使用Bloom词嵌入。Bloom词嵌入并不是一种新的生成嵌入的算法,而是存储和检索高维向量的一种更快、更准确的索引方法。在Bloom嵌入表中,每个向量表示两个或多个词组合在一起的意义。诀窍是减去你不需要的词,以重建你要找的原始嵌入。幸运的是,spaCy已经在其v2.0语言模型中实现了所有这些效率功能。正是因为这个原因,spaCy能够为数百万个词创建词嵌入,同时只存储20,000个唯一的向量。

6.4.3 Word2Vec与LSA

你可能在想,词嵌入和第4章中介绍的LSA词-主题向量有何不同。LSA是通过主成分分析(PCA)对TF–IDF向量进行处理后创建的词嵌入。LSA还为你提供了文档-主题向量,你可以将其用作整个文档的嵌入。LSA的文档-主题向量是所有词-主题向量的和,这些向量代表了你为其创建嵌入的文档中的所有词。如果你想要获取一个文档的词向量,类似于文档-主题向量,你只需将文档中所有词的向量相加。这与Doc2Vec文档向量的工作方式非常相似。

如果你的LSA矩阵是Nwords × Ntopics的大小,那么LSA的词向量就是该LSA矩阵的行。这些行向量像Word2Vec一样,捕捉了词语在大约200到300个实数值中的含义,而LSA的词-主题向量与Word2Vec向量一样,能够有效地找到相关和不相关的词语。正如在GloVe讨论中所学,Word2Vec向量也可以使用与LSA相同的SVD算法创建,但Word2Vec通过创建一个滑动窗口来重复使用文档中的相同词汇,这样它可以将相同的词汇重用五次才滑动到下一个窗口。

那么,增量训练或在线训练呢?LSA和Word2Vec算法都允许将新文档添加到语料库中,并调整现有的词向量以考虑新文档中的共现关系,但只有现有“词槽”中的词汇可以被更新。添加全新的词汇将改变你的词汇表的总大小;因此,你的独热编码向量也将发生变化。这就要求重新开始训练,如果你想将新词包含在模型中。

LSA比Word2Vec训练得更快,对于长文档,它能更好地区分和聚类这些文档。实际上,斯坦福的研究人员使用这种基于PCA的更快速方法训练了GloVe向量。你可以使用nessvec包比较三种最流行的词嵌入。

Word2Vec的“杀手级应用”是它实现的语义推理。LSA的词-主题向量也可以做这件事,但通常不够准确。如果你想接近Word2Vec推理的准确性和动态性,你必须将文档分割为句子,并且只能使用短语来训练LSA模型。使用Word2Vec,你可以解决像“Harry Potter + University = Hogwarts”这样的类比问题。关于特定领域的Word2Vec模型,看看Niel Chah为《哈利·波特》、《指环王》和其他史诗小说中的词汇创建的模型。

LSA有以下优点:

  • 更快的训练
  • 更好地区分长文档

另一方面,Word2Vec和GloVe提供以下优点:

  • 更高效地使用大规模语料库
  • 更准确的词语推理,如解决类比问题

6.4.4 静态嵌入与上下文化嵌入

在现实世界中,你可能会遇到两种类型的词嵌入:静态嵌入和上下文化嵌入。静态词嵌入可以用于单个词或独立的n-gram,训练完成后,向量保持固定。这些是你在解决类比问题和其他词向量推理问题时使用的词嵌入。你将训练一个语言模型来创建静态词嵌入,这时,词语的上下文只会用于训练模型。一旦你的词嵌入训练完成,你将不会使用词语的上下文来调整你的词嵌入,因为你在使用已训练好的词嵌入,而不是学习它们。这意味着,词语的不同含义或感知都被打包到一个静态向量中。例如,Word2Vec会为“bank”这个词在“World Bank”这个名字和“riverbank”这个表达中返回相同的嵌入。到目前为止,我们看到的所有嵌入——Word2Vec、GloVe和fastText——都是静态嵌入。

与此相对,上下文化词嵌入可以根据前后词汇的嵌入和词汇本身进行更新或细化,且一个词在句子中的顺序相对于其他词的顺序对于上下文化嵌入非常重要。这意味着,大二元组“not happy”在上下文化词嵌入中的嵌入将比在静态词嵌入中的嵌入更接近“unhappy”的嵌入。正如你可以想象的那样,上下文化嵌入在各种应用中可以更加有用,例如语义搜索。创建它们的一个巨大突破是引入了双向Transformer神经网络,例如BERT(双向编码器表示Transformer),我们将在第9章深入讨论。BERT嵌入的表现优于旧的算法,如Word2Vec和GloVe,因为BERT不仅考虑了嵌入词语的左右上下文,还考虑了词语在句子中的顺序。因此,它成为许多NLP应用的流行选择。

6.4.5 可视化词语关系

语义词语关系可能非常强大,其可视化可以带来有趣的发现。在本节中,我们将演示如何在二维空间中可视化词向量。

首先,我们从Google新闻语料库的Google Word2Vec模型加载所有的词向量。正如你可以想象的,这个语料库包含了大量关于俄勒冈州波特兰市和其他城市及州名的提及。为了简化操作,我们使用nlpia包,这样你就可以快速开始使用Word2Vec向量。

列表6.5 使用nlpia加载预训练的fastText语言模型

>>> from nessvec.indexers import Index
>>> index = Index()       #1
>>> vecs = index.vecs
>>> vecs.shape
(3000000, 300)
#1 将预训练的fastText嵌入向量下载到~/.nessvec-data/

警告
Google新闻Word2Vec模型非常庞大,包含300万个词,每个词有300维的向量。完整的词向量模型需要3GB的RAM。

gensim中的KeyedVectors对象现在保存着一个包含300万个Word2Vec向量的表格。我们从Google创建的文件中加载了这些向量,该文件存储了Google在基于Google新闻文章的大型语料库上训练的Word2Vec模型。显然,这些新闻文章中应该有许多关于州和城市的词汇。以下列表展示了从词汇表中提取的部分词汇,从第100万个词开始。

列表6.6 检查Word2Vec词汇频率

>>> import pandas as pd
>>> vocab = pd.Series(wv.vocab)
>>> vocab.iloc[1000000:100006]
Illington_Fund             Vocab(count:447860, index:2552140)
Illingworth                 Vocab(count:2905166, index:94834)
Illingworth_Halifax       Vocab(count:1984281, index:1015719)
Illini                      Vocab(count:2984391, index:15609)
IlliniBoard.com           Vocab(count:1481047, index:1518953)
Illini_Bluffs              Vocab(count:2636947, index:363053)

请注意,复合词和常见的n-gram通过下划线字符(“_”)连接在一起。还要注意,键值映射中的值是一个gensim.Vocab对象,它不仅包含了一个词的索引位置,以便你可以检索Word2Vec向量,还包含了该词在Google新闻语料库中出现的次数。

如前所述,如果你想检索某个特定词的300D向量,你可以使用KeyedVectors对象的方括号来获取任何词或n-gram:

>>> wv['Illini']
array([ 0.15625   ,  0.18652344,  0.33203125,  0.55859375,  0.03637695,
       -0.09375   , -0.05029297,  0.16796875, -0.0625    ,  0.09912109,
       -0.0291748 ,  0.39257812,  0.05395508,  0.35351562, -0.02270508,
       ...
       ])

我们选择了第100万个词(按字母顺序排序)是因为最开始的几千个“词”实际上是标点符号序列,如#####等符号,这些符号在Google新闻语料库中出现频率很高。我们很幸运地发现“Illini”出现在你的列表中。接下来让我们看看“Illini”向量与“Illinois”向量之间的距离。

列表6.7 Illinois与Illini的距离

>>> import numpy as np
>>> np.linalg.norm(wv['Illinois'] - wv['Illini'])      #1
3.3653798
>>> cos_similarity = np.dot(wv['Illinois'], wv['Illini']) / (
...     np.linalg.norm(wv['Illinois']) *\
...     np.linalg.norm(wv['Illini']))     #2
>>> cos_similarity
0.5501352
>>> 1 - cos_similarity    #3
0.4498648
#1 欧几里得距离
#2 余弦相似度是标准化的点积。
#3 余弦距离

这些距离表明,“Illini”和“Illinois”在语义上是相对接近的,但它们并不是完全相同。

现在,让我们检索所有美国城市的Word2Vec向量,这样你就可以利用它们的距离将它们绘制到一个二维的意义地图上。如何在KeyedVectors对象中找到所有城市和州呢?你可以像在前面的列表中那样使用余弦距离来找到所有接近“state”或“city”词汇的向量。

但我们不想遍历全部300万个词和词向量,接下来加载一个包含全球城市和州(地区)列表的数据集。

列表6.8 美国城市数据

>>> from nlpia.data.loaders import get_data
>>> cities = get_data('cities')
>>> cities.head(1).T
geonameid                       3039154
name                          El Tarter
asciiname                     El Tarter
alternatenames     Ehl Tarter,Эл Тартер
latitude                        42.5795
longitude                       1.65362
feature_class                         P
feature_code                        PPL
country_code                         AD
cc2                                 NaN
admin1_code                          02
admin2_code                         NaN
admin3_code                         NaN
admin4_code                         NaN
population                         1052
elevation                           NaN
dem                                1721
timezone                 Europe/Andorra
modification_date            2012-11-03

这个来自GeoCities的数据集包含了很多信息,包括纬度、经度和人口。你可以利用这些数据进行一些有趣的可视化,或者比较地理距离与Word2Vec距离的关系。不过现在,我们只是想将这些Word2Vec距离映射到二维平面上,看看效果如何。让我们先专注于美国。

列表6.9 一些美国州数据

>>> us = cities[(cities.country_code == 'US') &\
...     (cities.admin1_code.notnull())].czopy()
>>> states = pd.read_csv(\
...     'http://www.fonz.net/blog/wp-content/uploads/2008/04/states.csv')
>>> states = dict(zip(states.Abbreviation, states.State))
>>> us['city'] = us.name.copy()
>>> us['st'] = us.admin1_code.copy()
>>> us['state'] = us.st.map(states)
>>> us[us.columns[-3:]].head()
                     city  st    state
geonameid
4046255       Bay Minette  AL  Alabama
4046274              Edna  TX    Texas
4046319    Bayou La Batre  AL  Alabama
4046332         Henderson  TX    Texas
4046430           Natalia  TX    Texas

现在,你为每个城市提供了完整的州名,除了缩写。让我们检查这些州名和城市名是否存在于你的Word2Vec词汇表中:

>>> vocab = pd.np.concatenate([us.city, us.st, us.state])
>>> vocab = np.array([word for word in vocab if word in wv.wv])
>>> vocab[:10]

即使你只查看美国城市,你也会发现很多大城市名字相同,比如俄勒冈州的波特兰和缅因州的波特兰。所以让我们将城市词向量与所在州的词向量结合起来。要在Word2Vec中结合词语的含义,你只需将它们的向量相加——这就是“类比推理”的魔力。

以下列表展示了如何将州的Word2Vec向量与城市向量相加,并将所有这些新的向量放入一个大的DataFrame中。我们使用州的全名或缩写(取决于它是否在Word2Vec词汇表中)。

列表6.10 将美国州词向量与城市词向量结合

>>> city_plus_state = []
>>> for c, state, st in zip(us.city, us.state, us.st):
...     if c not in vocab:
...         continue
...     row = []
...     if state in vocab:
...         row.extend(wv[c] + wv[state])
...     else:
...         row.extend(wv[c] + wv[st])
...     city_plus_state.append(row)
>>> us_300D = pd.DataFrame(city_plus_state)

根据你的语料库,词语关系可以代表不同的属性,例如地理接近性、文化或经济相似性。但这些关系在很大程度上取决于训练语料库,并且会反映语料库的特点。

词向量有偏见

词向量是基于训练语料库学习词语关系的,它们代表了在编写用于训练词嵌入语言模型的文档和页面的人群中的平均含义。这意味着,词嵌入包含了所有编写这些网页的人的偏见和刻板印象。如果你的语料库是关于金融的,那么“bank”这个词向量主要与存款的商业机构相关。而如果你的语料库是关于地质学的,那么“bank”这个词向量将更多地与河流和溪流的关联相关。如果你的语料库主要讲的是一个母系社会,女性是银行家,男性在河里洗衣服,那么你的词向量将会带有这种性别偏见。

以下示例展示了一个在Google新闻文章上训练的词模型的性别偏见。如果你计算“man”和“nurse”之间的距离,并将其与“woman”和“nurse”之间的距离进行比较,你将能够看到这种偏见:

>>> word_model.distance('man', 'nurse')
0.7453
>>> word_model.distance('woman', 'nurse')
0.5586

识别和弥补这样的偏见是任何在偏见世界中训练模型的NLP从业者面临的挑战。

用于训练语料库的新闻文章具有一个共同的组成部分,即城市的语义相似性。文章中语义相似的位置似乎可以互换;因此,词模型学习到它们是相似的。如果你使用了不同的语料库进行训练,你的词语关系可能会有所不同。

在文化和规模相似的城市之间,它们被聚集在一起,即使在地理上相距遥远,例如圣地亚哥和圣何塞。类似地,度假胜地,如檀香山和塔霍湖,也可能会聚集在一起。

幸运的是,你可以使用常规的代数方法将城市的向量与州和州缩写的向量相加。如你在第4章中发现的,你可以使用PCA等工具将向量维度从300降至易于理解的二维表示。PCA使你能够在二维图中查看这些300维向量的投影或“阴影”。最棒的是,PCA算法确保这种投影是数据的最佳可视化,尽可能将向量分开。PCA就像一个好的摄影师,在构图之前从每个角度审视对象,以拍摄出最佳的照片。即使在对城市+州+缩写向量求和后,你也不需要对向量的长度进行归一化,因为PCA会帮你处理这些问题。

我们将这些“增强”后的城市词向量保存在nlpia包中,所以你可以加载它们并在应用程序中使用。以下代码展示了如何使用PCA将它们投影到二维图上。

列表6.11 美国城市的气泡图

>>> from sklearn.decomposition import PCA
>>> pca = PCA(n_components=2)                   #1
>>> us_300D = get_data('cities_us_wordvectors')
>>> us_2D = pca.fit_transform(us_300D.iloc[:, :300])    #2
#1 PCA生成的2D向量用于可视化。我们保留了原始的300维Word2Vec向量,供你进行任何向量推理。
#2 这个DataFrame的最后一列包含城市名称,城市名称也存储在DataFrame的索引中。

图6.8 展示了所有美国城市的300维词向量的二维投影。图中的气泡图看起来有点像大城市通勤火车路线的图表。气泡的位置并不准确表示真实世界中的位置,而是提供了它们的相对位置感觉,以及如何从一个位置到另一个位置。你可以使用Word2Vec向量自动绘制这类地图,因为地理位置在词向量中得到嵌入,当模型训练时,会接触到如“新奥尔良位于密西西比河西岸”或“新奥尔良位于南方”这样的短语。这就是“南方性”和“西方性”如何嵌入到新奥尔良(路易斯安那州)的词向量中的方式。

image.png

注意
较低的语义距离(接近零的距离值)表示词语之间的高度相似性。语义或“意义”距离是由用于训练的文档中词语的邻近出现情况决定的。如果两个词在语料库中经常出现在类似的上下文中(与相似的词语一起出现),那么它们在词向量空间中的距离就会很近。例如,“San Francisco”与“California”之间的距离较小,因为它们经常在句子中相邻出现,并且它们附近使用的词语分布相似。两个词之间的较大距离表示它们共享上下文和意义的可能性较低(它们在语义上不相似),比如“cars”和“peanuts”之间的距离。

如果你想探索图6.8中显示的城市地图,或者尝试绘制一些你自己的向量,列表6.12 显示了如何使用一个Plotly包装器生成你自己的地理语义图,该包装器可以处理DataFrame。Plotly包装器期望一个DataFrame,其中每一行代表一个样本,每一列代表你希望绘制的特征。这些特征可以是分类特征(例如时区)或连续的实值特征(例如城市人口)。生成的图表是互动的,非常适合用于探索多种类型的机器学习数据,特别是复杂事物(如词语和文档)的向量表示。

列表6.12 美国城市词向量的气泡图

>>> import seaborn
>>> from matplotlib import pyplot as plt
>>> from nlpia2.plots import offline_plotly_scatter_bubble
>>> df = get_data('cities_us_wordvectors_pca2_meta')
>>> html = offline_plotly_scatter_bubble(
...     df.sort_values('population', ascending=False)[:350].copy()\
...         .sort_values('population'),
...     filename='plotly_scatter_bubble.xhtml',
...     x='x', y='y',
...     size_col='population', text_col='name', category_col='timezone',
...     xscale=None, yscale=None,  # 'log' or None
...     layout={}, marker={'sizeref': 3000})
{'sizemode': 'area', 'sizeref': 3000}

为了生成你的300D词向量的二维表示,你需要使用一种降维技术;我们使用了PCA。通过减少输入向量中包含的信息范围,减少了在从300D压缩到2D时丢失的信息量,因此你已经将词向量限制为与城市相关的词。这就像在计算TF–IDF或BOW向量时限制语料库的领域或主题一样。

对于包含更多信息内容的多样化向量混合,你可能需要使用非线性嵌入算法,例如t分布随机邻域嵌入(t-SNE)。我们将在后续章节中讨论t-SNE和其他神经网络技术。一旦你掌握了这里的词向量嵌入算法,t-SNE就会更加易于理解。

6.4.6 建立连接

在本节中,我们将构建一个称为图(Graph)的数据结构。图数据结构非常适合表示数据中的关系。图的核心可以描述为具有实体(节点或顶点),这些实体通过关系或边连接在一起。社交网络是图数据结构非常理想的应用场景,用于存储数据。我们将在本节中使用一种特定类型的图,即无向图。这种图中,关系没有方向。例如,Facebook上的好友关系就是一个无向关系,因为两个人之间的好友关系是相互的。另一种图是有向图,这种图中,关系是单向的。例如,Twitter中的“关注”关系就是有向关系,你可以关注某人而不需要他们回关。

为了在本章中可视化思想和概念之间的关系,你可以创建一个无向图,其中句子之间有相似意义的关系(边)连接。你将使用一个基于力导向的布局引擎,将所有相似的概念或节点推到一起形成簇。但是,首先,你需要为每个句子创建某种嵌入。句子通常包含一个独立的思想,那么你如何使用词向量来为一个句子创建嵌入呢?

你可以将之前学到的词向量的知识应用到句子嵌入的创建上。你只需对句子中每个词的嵌入进行平均,生成一个单一的300D句子嵌入。

从NLPIA2手稿中提取自然语言

你可以从nlpia2项目的src/nlpia2/data/manuscript目录下载本书的任何章节的ADOC格式,如列表6.13所示。本节中的示例将使用第六章的ADOC手稿。如果你以后自己编写书籍或软件文档,不要这样做——在文本中编写和测试代码的递归循环可能会让你的大脑崩溃。但你现在可以通过处理你正在阅读的这些词语,享受所有这些头痛带来的成果。

列表6.13 从nlpia2仓库下载ADOC文本

>>> import requests
>>> repo = 'https://gitlab.com/tangibleai/nlpia2/-/raw/main'
>>> name = 'Chapter-06_Reasoning-with-word-embeddings-word-vectors.adoc'
>>> url = f'{repo}/src/nlpia2/data/{name}'
>>> adoc_text = requests.get(url)

现在,你需要将该文本保存为ADOC文件,以便你可以使用命令行工具将其渲染为HTML。

列表6.14 将ADOC字符串写入磁盘

>>> from pathlib import Path
>>> path = Path.cwd() / name
>>> with path.open('w') as fout:
...     fout.write(adoc_text)

接下来,你将需要将ADOC文本渲染为HTML,以便更容易地将自然语言文本从格式化字符和其他“非自然”文本中分离出来,如以下列表所示。你可以使用名为Asciidoc3的Python包,将任何AsciiDoc(ADOC)文本文件转换为HTML。

列表6.15 将AsciiDoc文件转换为HTML

>>> import subprocess
>>> subprocess.run(args=[        #1
...     'asciidoc3', '-a', '-n', '-a', 'icons', path.name])
#1 Asciidoc3应用程序可以将ADOC文件渲染为HTML。

现在你已经有了HTML文本文件,可以使用Beautiful Soup包来提取文本:

>>> if os.path.exists(chapt6_html) and os.path.getsize(chapt6_html) > 0:
...     chapter6_html = open(chapt6_html, 'r').read()
...     bsoup = BeautifulSoup(chapter6_html, 'html.parser')
...     text = bsoup.get_text()     #1
#1 BeautifulSoup.get_text() 提取HTML中的自然语言文本。

现在你有了第六章的文本,你可以运行spaCy的小型英语语言模型来获取句子嵌入向量。spaCy会对Doc对象中的标记向量进行平均。除了获取句子向量外,你还需要从每个句子中提取名词短语,作为我们句子向量的标签。

列表6.16 使用spaCy获取句子嵌入和名词短语

>>> import spacy
>>> nlp = spacy.load('en_core_web_md')
>>> config = {'punct_chars': None}
>>> nlp.add_pipe('sentencizer', config=config)
>>> doc = nlp(text)
>>> sentences = []
>>> noun_phrases = []
>>> for sent in doc.sents:
...     sent_noun_chunks = list(sent.noun_chunks)
...     if sent_noun_chunks:
...         sentences.append(sent)
...         noun_phrases.append(max(sent_noun_chunks))
>>> sent_vecs = []
>>> for sent in sentences:
...    sent_vecs.append(sent.vector)

现在你有了句子向量和名词短语,你应该对句子向量进行归一化,使得所有的向量的长度(或2范数)为1。2范数的计算方式和计算直角三角形对角线的长度一样:你将各维度长度的平方相加,然后对这些平方和取平方根。

列表6.17 使用NumPy归一化句子向量嵌入

>>> import numpy as np
>>> for i, sent_vec in enumerate(sent_vecs):
...     sent_vecs[i] = sent_vec / np.linalg.norm(sent_vec)

通过归一化句子向量,你可以获取所有这些向量之间的相似度。计算列表中所有可能对象对之间的逐对相似度,会生成一个方阵,称为相似度矩阵或亲和矩阵,如以下列表所示。如果你使用每个向量与其他所有向量的点积,你就是在计算你在前几章中熟悉的余弦相似度。

列表6.18 获取相似度或亲和矩阵

>>> np_array_sent_vecs_norm = np.array(sent_vecs)
>>> similarity_matrix = np_array_sent_vecs_norm.dot(
...     np_array_sent_vecs_norm.T)          #1
#1 通过计算矩阵的点积,你在矢量化操作,这比使用for循环更快。

相似度矩阵是通过将归一化后的句子嵌入矩阵(N×300维)与其自身的转置进行点积计算得到的。这会生成一个N×N的矩阵,每一行和每一列代表本章中的一个句子。由于乘法的交换性,矩阵的上对角线部分与下对角线部分有完全相同的值。一个向量与另一个向量的相似度是相同的,无论你是先进行乘法计算还是相似度计算。

通过相似度矩阵,你现在可以创建一个无向图,使用句子向量之间的相似度来创建相似句子之间的图边。列表6.19 中的代码使用了一个名为NetworkX的库来创建无向图数据结构。在内部,数据存储在嵌套字典中——字典的字典的字典……依此类推。就像链表一样,嵌套字典允许对稀疏数据进行快速查找。你通过点积计算了一个密集矩阵作为相似度矩阵,但你需要将其转换为稀疏矩阵,因为你不希望每个句子都与图中的每个其他句子连接。你将断开任何相距较远的句子对之间的链接(即它们的相似度低)。

列表6.19 创建无向图

>>> import re
>>> import networkx as nx
>>> similarity_matrix = np.triu(similarity_matrix, k=1)     #1
>>> iterator = np.nditer(similarity_matrix,
...     flags=['multi_index'], order='C')
>>> node_labels = dict()
>>> G = nx.Graph()
>>> pattern = re.compile(
...    r'[\w\s]*['"]?[\w\s]+-?[\w\s]*['"]?[\w\s]*'
...    )                  #2
>>> for edge in iterator:
...     key = 0
...     value = ''
...     if edge > 0.95:                    #3
...         key = iterator.multi_index[0]
...         value = str(noun_phrases[iterator.multi_index[0]])
...         if (pattern.fullmatch(value)
...             and (value.lower().rstrip() != 'figure')):
...                 node_labels[key] = value
...         G.add_node(iterator.multi_index[0])
...         G.add_edge(iterator.multi_index[0],
...             iterator.multi_index[1], weight=edge)
#1 np.triu 将矩阵的下三角(k = 1表示包括对角线)变为零。这使我们能够为阈值创建一个单一的检查。
#2 这个正则表达式模式帮助我们清理节点标签字典,去掉我们不想作为节点标签的值。
#3 这个阈值是任意的。它似乎是对于这个数据来说的一个合理的切分点。

如以下列表所示,你现在可以使用matplotlib.pyplot来可视化你组建的全新图(网络)。

列表6.20 绘制无向图

>>> import matplotlib.pyplot as plt
>>> plt.subplot(1, 1, 1)              #1
>>> pos = nx.spring_layout(G, k=0.15, seed=42)     #2
>>> nx.draw_networkx(G,
...    pos=pos,           #3
...    with_labels=True,
...    labels=node_labels,
...    font_weight='bold')
>>> plt.show()
#1 初始化一个单一的图形(绘图),并用一个子图填充窗口。
#2 k是弹簧常数——值越大,吸引力越强,节点将更靠近。
#3 pos包含节点的二维位置(x,y)元组,弹簧停止震荡后,节点会停留在这些位置。

最后,在图6.9图6.10中,你可以看到你的无向图是如何显示本书中自然语言的概念簇的!力导向图中的弹簧根据节点之间的连接将相似的概念聚集在一起。每个节点代表本章中一个句子的平均词嵌入,而边(或线)表示具有相似意义的句子之间的连接。通过观察图,你可以看到中央的大簇节点(句子)具有最多的连接。更远的地方,你还可以看到一些较小的簇,表示像体育和城市这样的主题。

image.png

image.png

图中心的紧密概念簇应该包含关于本章核心思想及其相互关系的一些信息。放大后,你可以看到这些段落主要是关于如何用词语和数字来表示词语,因为这正是本章的主题。本章最后有一些练习,你可以完成这些练习来巩固我们在本节中所讲的内容。

6.4.7 非自然词语

词嵌入(如Word2Vec)不仅对英语单词有用,还适用于任何符号序列,其中符号的顺序和邻近性代表了它们的含义。如果你的符号具有语义,嵌入可能是有用的。

正如你可能猜到的,词嵌入也适用于英语以外的语言。例如,嵌入对于图形语言(如传统中文和日语(汉字))非常有用,甚至适用于刻在埃及墓葬中的古代象形文字。嵌入和基于向量的推理同样适用于试图模糊词义的语言。例如,你可以在大规模的秘密信息集合上做基于向量的推理,这些信息是从猪拉丁语或任何其他由儿童(或罗马皇帝)发明的语言转录来的。凯撒密码(如ROT13)和替换密码也容易受到Word2Vec向量推理的影响。你甚至不需要解码戒指(如图6.11所示),只需要一个大规模的消息或n-gram集合,你的Word2Vec嵌入器可以处理这些集合,找到单词或符号的共现关系。

image.png

Word2Vec 甚至已被用于从非自然单词或 ID 号码中获取信息和关系,例如大学课程编号(如 CS-101)、型号(如 Koala E7270 或 Galaga Pro),甚至序列号、电话号码和邮政编码。要获得关于这些 ID 号码之间关系的最有用信息,你需要包含这些 ID 号码的各种句子。如果这些 ID 号码通常包含一个结构,其中符号的位置具有意义,那么将这些 ID 号码分词成它们最小的语义单元(例如自然语言中的单词或音节)可能会有所帮助。

6.5 自我测试

  • 使用预训练的词嵌入,仅根据自然语言总结来计算 Dota 2 英雄的力量、敏捷和智力。
  • 可视化本书另一个章节中的概念连接图(或任何你想更好理解的文本)。
  • 尝试将本书所有章节的词嵌入的图形可视化结合起来。
  • 提供示例,说明词向量如何使 Hofstadter 提出的八个智能元素中的至少两个得以实现。
  • 克隆 nessvec 仓库,并为你喜欢的单词或名人创建自己的可视化或 nessvector“角色表”——可能是你英雄的正念、道德、善良或影响力。人类是复杂的,用来描述他们的词语是多维的。
  • 使用 PCA 和词嵌入创建一个你附近城市或物体词语的 2D 地图。尝试将双字词作为一个单独的点进行结合,然后为每个词分别显示两个独立的点。地理词汇的位置是否在某种程度上与其地理位置相对应?非地理词汇呢?

总结

  • 词向量和面向向量的推理可以解决一些令人惊讶的细微问题,比如类比问题和词语之间的非同义关系。
  • 为了保持你的词向量最新并提高其与当前事件和概念的相关性,你可以使用 gensim 或 PyTorch 重新训练和微调你的词嵌入。
  • nessvec 包是一个有趣的新工具,可以帮助你找到那个悬挂在嘴边的词,或者可视化词的“角色表”。
  • 词嵌入可以揭示一些令人惊讶的隐藏含义,诸如人名、地名、公司名甚至职业名称。
  • 对城市和国家的词向量进行 PCA 投影,可以揭示那些地理上远距离的地方之间的文化相似度。
  • 将潜在语义分析(LSA)向量转化为更强大的词向量的关键是,在创建你的 n-gram 时尊重句子边界。
  • 机器仅凭预训练的词嵌入就能轻松通过标准化测试的词类比部分。